diff --git a/.claude/agents/architect.md b/.claude/agents/architect.md index b129d16..19ca361 100644 --- a/.claude/agents/architect.md +++ b/.claude/agents/architect.md @@ -102,3 +102,4 @@ GameConstants → 전체 (상수 변경 시 밸런스 전체 영향) ## 구현 순서 (의존성 기반) ## 리스크/주의사항 ``` +`Assets/Editor/GamePlayEntry.cs` - Hub로 연 에디터의 기본 Play 시작 씬과 개발용 실행 메뉴 diff --git a/.claude/agents/battle-dev.md b/.claude/agents/battle-dev.md index dd9fb48..77bf291 100644 --- a/.claude/agents/battle-dev.md +++ b/.claude/agents/battle-dev.md @@ -17,15 +17,27 @@ tools: ### Battle 모듈 (전체) - `Assets/Scripts/Battle/InsectBattleController.cs` - 1v1 턴제 배틀 로직 - `Assets/Scripts/Battle/BattleCaptureChanceCalculator.cs` - 1v1 승리 후 포획 확률·롤 판정 +- `Assets/Scripts/Battle/SandboxBattleRules.cs` - 샌드박스(챔피언의 꿈) 전투 규칙 — 피해 배율·HP 하한·3~4턴 길이. 컨트롤러 쪽 `StartSandbox`/`IsSandbox`가 이걸 건다 - `Assets/Scripts/Battle/InsectBattleStats.cs` - 스탯 계산/데미지 적용 - `Assets/Scripts/Battle/InsectBattleUIController.cs` - 배틀 UI 브릿지 - `Assets/Scripts/Battle/RaidBattleController.cs` - 5v1 레이드 - `Assets/Scripts/Battle/BattleArenaController.cs` - 배틀 아레나 ※비주얼은 visual-dev +- `Assets/Scripts/UI/BattleScreenUI.Impact.cs` - 1v1 연출용 타격 정보(HitCue: 세기·마무리·빗나감)를 전투 결과에서 만든다 ※연출은 visual-dev +- `Assets/Scripts/UI/RaidBattleUI.Impact.cs` - 레이드 타격 정보·보스 예고 호출·3D 좌표 변환 ※숫자 배치는 ui-dev, 연출은 visual-dev - `Assets/Scripts/Battle/RaidRoundResolver.cs` - 레이드 동시 라운드 판정(순수 정적). 1v1과 달리 버프 만료가 없다 — 의도된 divergence, 상한은 `MaxBuffStacks` - `Assets/Scripts/Battle/RaidRoundModels.cs` - 레이드 라운드 결과 모델(순수 데이터). 슬롯 피해 배열에 세터를 만들지 말 것 — 컨트롤러가 따로 합산해 이중 가산이 된다 - `Assets/Editor/LedgerDuelProbe.cs` - 장부 압박이 실제 보스전에서 도는지 배치모드 확인 ※순수부는 테스트가 잡고, **배선 누락은 무증상**이라 이쪽이 필요하다 +- `Assets/Scripts/Battle/RaidBossForms.cs` - 체력에 따라 모습을 바꾸는 레이드 보스 표(순수). 바뀌는 건 속성·기술(`CombatData`)뿐, 정체(`Data`)는 그대로 — 이겨도 잡히는 건 원래 종이고 스토리도 원래 종으로 센다 - `Assets/Scripts/Battle/LedgerPressure.cs` - 명부회 보스전 「장부」 압박 순수 계산부 ※임계값(누가 얼마나 빨리 적는가)은 `NpcBossDuels` 표가 든다 — 여기에 인물 ID를 두지 않는다 - `Assets/Scripts/Battle/RaidSupportPlanner.cs` - 비-리더 팀원의 스킬 선택 AI(순수 정적, **난수 미사용** — 동점은 최저 인덱스라 결정론 테스트가 성립한다) +- `Assets/Scripts/Battle/BattleEnvironment.cs` - 낮·밤·날씨 전투 보정 순수부(`BattleEnvironmentNote` 배수·칩 문구, `Applies` 적용 범위). 배수는 `InsectHabits.BattleStatMultiplier`가 단일 출처 ※칩 그리기는 ui-dev +- `Assets/Scripts/Battle/BattleEscapeRules.cs` - 도주 확률 순수 규칙(`Chance`) — 1v1 `TryEscape`와 습격 곤충 도망(capture-dev)이 함께 읽는다 +- `Assets/Scripts/Battle/BattleReadPacing.cs` - 읽는 단계(진입 인트로·컷인·상대 교체·그림자 변신·수문장 등장)의 배속 하한(`ClockScale`·`StagingSeconds`)과 결과 화면 닫기 규칙(`BattleResultRules` — 0.6초 잠금·뗄 때 닫기·꿈 챔피언전만 4초 자동). 순수 +- `Assets/Scripts/Battle/ElementMatchup.cs` - 상성 등급 `Matchup`(Super·Good·Neutral·Weak·Resisted)과 `Describe(배수)`. 경계 1.05/0.95는 「효과가 굉장했다」 문구와 같은 값. 순수 ※아이용 문구는 ui-dev +- `Assets/Scripts/Battle/SignatureSkills.cs` - 전용기 판별(`IsSignature(owner, skill)`·`IsUnlocked`) — 1v1 표지·레이드 보스 기술 고르기·전용기 연출이 같은 기준. 순수 +- `Assets/Scripts/Battle/BattleKind.cs` - 1v1 전투 종류(`BattleKind`·`BattleKinds.Classify`)와 전투 곡 고르기(`BattleMusic` — 수문장 레이드·간부·라온). 순수 ※곡 등록은 visual-dev +- `Assets/Scripts/UI/BattleScreenUI.Flow.cs` - 1v1 화면 흐름 partial — 결과 닫기(`ResultCanClose`·`ResultShownSeconds`)·읽는 단계 시계·진입 인트로(`IntroProgress`·`EntryIntroProgress`)·`CurrentBattleKind` ※그리기는 ui-dev +- `Assets/Scripts/UI/RaidBattleUI.Flow.cs` - 레이드 화면 흐름 partial — 결과 닫기(실제 시간)·인트로/변신 시계·`BossTransformStagingSeconds`·`GuardianIntroClockScale` ※그리기는 ui-dev - `Assets/Scripts/NPC/NpcDuelController.cs` - 곤충잡이 아이 1v1 대결(듀얼 진입·보상) ※아이 상태·상대 배정은 capture-dev ### Core 배틀 관련 @@ -61,7 +73,22 @@ HP×5, ATK×1.5, DEF×1.3 ### 도주 ``` -escapeChance = clamp(0.5 + (playerLv-enemyLv)×0.05, 0.1, 0.9) +escapeChance = clamp(0.5 + (playerLv-enemyLv)×0.05, 0.1, 0.9) // BattleEscapeRules.Chance +``` + +### 읽는 단계 배속 하한 · 결과 닫기 (BattleReadPacing) +``` +단계 시계 += DeltaTime × ClockScale(길이, 최소 실제 초, 배속) // = clamp01(길이 / (최소 × 배속)), 1배속이면 1 +진입 인트로 1.4초(실제 시간) → [컷인 3.2 (실제 ≥2.0) | 평범한 인트로 나머지 0.6] +상대 교체 1.2 (≥1.0) · 그림자 변신 1.5 (≥1.0) · 수문장 레이드 등장 2.6 (≥2.0) · 일반 레이드 인트로 2.0 (≥1.4) +결과: 0.6초 잠금 → 탭·클릭·Space/Enter → 손을 뗄 때 닫음(0.5초 넘게 누르면 그냥 닫음). 자동 닫힘은 꿈 챔피언전(4초)만 +``` + +### 낮·밤·날씨 보정 (야생 실외 전투만) +``` +ATK·DEF × InsectHabits.BattleStatMultiplier(habit, GetWorldState(regionId)) // 1.10 / 1.00 / 0.90, HP 제외 +제외: 수문장 · NPC 대결 · 샌드박스 · 레이드 · 실내 서브에리어(섬은 적용, 세계 날씨) +표지: InsectBattleController.PlayerEnvironment / EnemyEnvironment — BeginBattleCommon이 매번 None으로 끈다 ``` ### 1v1 승리 포획 @@ -83,8 +110,13 @@ captureChance = clamp(0.90 - rarityIndex×0.07 - clamp01(captureDifficulty)×0.5 경계 밖 수정이 필요하면 변경하지 말고 메인 모델에 보고하여 적절한 에이전트에 재위임. ## 설계 원칙 +- `Assets/Scripts/Battle/BattleRandomSource.cs` — 연출과 분리된 전투 난수 - 이벤트 기반: BattleUpdated, BattleEnded, PlayerFainted - UI는 이벤트 구독으로만 갱신 (직접 참조 X) - 스탯은 InsectBattleStats에 집중, UI 모놀리스에서 읽기만 - GameConstants.Battle 상수 활용 - 스킬 쿨다운은 턴 단위, 버프/디버프는 duration 턴 후 해제 + +### 전투 지속시간 회귀 테스트 +- `Assets/Tests/EditMode/BattlePacingTests.cs` - 일반전 라운드 분포 및 보스 계수 분리 +- `Assets/Tests/EditMode/BattleEnvironmentTests.cs` - 낮·밤·날씨 보정(노트·적용 범위·능력치·컨트롤러 배선·다음 전투 상속 금지)과 도주 규칙 diff --git a/.claude/agents/capture-dev.md b/.claude/agents/capture-dev.md index 087c488..5724f3b 100644 --- a/.claude/agents/capture-dev.md +++ b/.claude/agents/capture-dev.md @@ -15,34 +15,52 @@ tools: ## 담당 파일 - `Assets/Scripts/Capture/CaptureController.cs` - 포획률 계산 핵심 - `Assets/Scripts/Capture/CaptureChanceCalculator.cs` - 포획 성공 확률 순수 계산 -- `Assets/Scripts/Capture/CaptureMinigameController.cs` - 3단계 타이밍 미니게임 +- `Assets/Scripts/Capture/CaptureMinigameController.cs` - 포획 미니게임 껍데기 (게임 고르기 · 조작 수집 · 그리기 · 포획 판정 연결) +- `Assets/Scripts/Capture/CaptureMinigame.cs` - 미니게임 3종의 순수 규칙 (살금살금·가두기·던지기, 결과 0~3점, 포획마다 무작위) - `Assets/Scripts/Capture/CaptureMinigameProbability.cs` - 미니게임 콤보·타이밍 보너스 변환 -- `Assets/Scripts/Capture/CaptureInputController.cs` - 포획 입력 +- `Assets/Scripts/Capture/CaptureInputController.cs` - 포획 입력 + 습격 판정 훅(`InsectEntity.AmbushGate`)·교전 종료 시각(쿨다운)·닿으면 습격 창 열기 - `Assets/Scripts/Capture/CaptureProximityTrigger.cs` - 근접 감지 (8m 반경) - `Assets/Scripts/Capture/CaptureRaycastTrigger.cs` - 레이캐스트 감지 - `Assets/Scripts/Capture/CaptureTriggerModeController.cs` - 감지 모드 전환 - `Assets/Scripts/Capture/CaptureFeedbackController.cs` - 포획 연출 - `Assets/Scripts/Capture/CaptureTriggerOptionsUI.cs` - 트리거 설정 UI -- `Assets/Scripts/Spawning/InsectSpawner.cs` - 월드 스폰 (최대20, 리전별 최소5) -- `Assets/Scripts/Spawning/SpawnPoint.cs` - 스폰 포인트 (반경5m, 로컬최대2) -- `Assets/Scripts/Spawning/InsectEntity.cs` - 곤충 엔티티 (프로시저럴 3D 모델) +- `Assets/Scripts/Spawning/InsectSpawner.cs` - 필드 개체군 (리전별 슬롯 기록 · 시간 기반 재생 · 45m 실체화/55m 회수 · 서브에리어 슬롯) +- `Assets/Scripts/Spawning/FieldSpawnRules.cs` - 필드 스폰 순수 규칙 (전역 등급표 · 등급 대체 · 레어 부스트 · 레벨 · 슬롯 수 · 재생/수명) +- `Assets/Scripts/Spawning/FirstShinyEncounter.cs` - 도입부 확정 조우 (세 번째 포획 앞에서 근처 개체 하나를 색다르게 — 계정당 1회) +- `Assets/Scripts/Spawning/FieldPopulation.cs` - 슬롯 기록부 + 상태 전이 (게임플레이 퇴장 vs 거리 회수) +- `Assets/Scripts/Spawning/FleePath.cs` - 도주 방향·거리 순수 판정 (벽 관통 방지) +- `Assets/Scripts/Spawning/AmbushRules.cs` - 습격 순수 규칙 (거절 조건·전역/개체 쿨다운·알아채는 반경·접근 속도·다가갈 길·"왜 덤벼들었나" 한 줄 — `InsectEntity` AI와 `CaptureInputController` 판정이 읽는다, 규칙은 `rules/world-environment.md` 「습격」) +- `Assets/Tests/EditMode/AmbushRulesTests.cs` - 습격 거절 조건 하나씩·쿨다운 경계·싸울 곤충·접근 속도/길·진짜 DB 시간대×날씨 깨어남 표(로그 `[Ambush]`) +- `Assets/Scripts/Spawning/SpawnPoint.cs` - 리전/서브에리어 스폰 표 (풀 · 레벨 대역) — 자리는 정하지 않음 +- `Assets/Scripts/Spawning/InsectEntity.cs` - 곤충 엔티티 (프로시저럴 3D 모델) — AI(경계·도주·습격 접근)는 capture-dev - `Assets/Scripts/Spawning/SimpleObjectPool.cs` - Get()/Return() 오브젝트 풀 - `Assets/Scripts/Spawning/DistanceCulling.cs` - 거리 컬링 (25m/20m) - `Assets/Scripts/Spawning/CaptureItemSpawner.cs` - 아이템 스폰 - `Assets/Scripts/Spawning/CaptureItemPickup.cs` - 아이템 획득 - `Assets/Scripts/Data/InsectSpawnCondition.cs` - 시간/날씨 조건 +- `Assets/Scripts/Data/InsectHabits.cs` - 곤충 시간·날씨 성향 순수 규칙 (주행/야행/무관 · 좋아하는/싫어하는 날씨 · 습격형 → 스폰 배수·궁합 Fit·전투 보정·습격 판정) +- `Assets/Scripts/Data/InsectHabitTable.cs` - 성향 표 (종마다 한 줄, 계통별 곤충학 근거 주석 — 종을 늘리면 한 줄 추가, `InsectHabitsTests`가 DB와 맞춰 본다) - `Assets/Scripts/Data/CaptureItemData.cs` - 포획 아이템 -- `Assets/Scripts/Core/WeatherSystem.cs` - 날씨 (Clear/Rain/Fog/Wind) +- `Assets/Scripts/Core/WeatherSystem.cs` - 날씨 (Clear/Rain/Fog/Wind/Snow) +- `Assets/Scripts/Core/WeatherForecast.cs` - 날씨 예보 순수 규칙 (다음 날씨·지속·지역 기후 — 설산은 비→눈, 사막·화산은 눈→센바람) - `Assets/Scripts/Core/GameClock.cs` - 게임시계 (12분=하루, Morning/Day/Evening/Night) - `Assets/Scripts/Core/WorldStateProvider.cs` - WorldState(시간+날씨) 제공 - `Assets/Scripts/Core/PlayerMovement.cs` - 플레이어 이동 (월드 탐험) - `Assets/Scripts/Core/PlayerStartPlacement.cs` - 플레이어 시작 위치·방향 계산 - `Assets/Scripts/Core/WorldInteractionTypes.cs` - 월드 상호작용 종류 정의 ※프롬프트 UI는 ui-dev -- `Assets/Scripts/UI/CaptureChoiceUI.cs` - 포획/배틀/레이드 선택 허브 ※UI 레이아웃은 ui-dev +- `Assets/Scripts/UI/CaptureChoiceUI.cs` - 포획/배틀/레이드 선택 허브 + 「습격!」 창([싸우기]/[도망치기], ESC=도망) ※UI 레이아웃은 ui-dev - `Assets/Scripts/NPC/NpcManager.cs` - NPC 스폰/디스폰/배치 - `Assets/Scripts/NPC/CatcherKidNpc.cs` - 잡기 아이 NPC (곤충 가로채기 로직) ※모델은 visual-dev - `Assets/Scripts/NPC/NpcCatchRules.cs` - NPC 곤충 가로채기 규칙 - `Assets/Scripts/NPC/VillagerNpc.cs` - 마을 주민 NPC 개체/상호작용 ※모델은 visual-dev +- `Assets/Scripts/Core/IslandWorldBuilder.cs` - 나의 섬 드나들기(분리 서브에리어 진입·이탈·복귀 좌표), 밟는 땅·경계·물건 차단 콜라이더, 방목 곤충 세우기, 꾸미기 미리보기·카메라, 본 마을 나루터 ※모양은 visual-dev(`IslandTerrainBuilder`·`IslandObjectBuilder`) +- `Assets/Scripts/Core/IslandInsectWalker.cs` - 섬 방목 곤충의 배회(빈 칸 사이 이동, 건물 통과 금지, 시간·날씨 기분 적용) +- `Assets/Scripts/Core/IslandInsectMood.cs` - 방목 곤충 기분 순수 규칙 (성향 → 활동도 0~1 → 쉬는 시간·속도·나는 높이, 보통 0.6 = 예전 배회) +- `Assets/Scripts/Core/IslandGuestRules.cs` - 섬 손님 곤충 순수 규칙 (수=BonusSlots · 해금 풀 합집합 · 등급 희귀까지 · 종의 가장 낮은 해금 리전 대역 · 화면 밖에서 떠남 · 빈 칸 위 도주) +- `Assets/Scripts/Core/IslandWorldBuilder.Guests.cs` - 섬 손님 기록·몸(야생 경로 `Initialize`, 작은 풀) + 방목 곤충 기분 전달 — `IslandWorldBuilder`의 partial +- `Assets/Tests/EditMode/IslandGuestRulesTests.cs` - 손님 수 표·들어설 곳·종 고르기(초원만 해금)·등급 상한·레벨 대역·떠나기·빈 칸 도주·들어설 칸 +- `Assets/Tests/EditMode/IslandInsectMoodTests.cs` - 야행성 밤 활발·낮 쉼, 주행성 반대, 날씨 취향, 시간 무관 종은 예전 그대로 +- `Assets/Tests/EditMode/IslandIntegrationTests.cs` - 실제 PlayScene에서 섬 진입·이동·구경·퇴장(서브에리어 빌더 비개입, sticky 해제, 끼임 복구 좌표, 복귀 자리) ## 핵심 공식 @@ -63,11 +81,18 @@ rarityFloor: Common30% / Uncommon22% / Rare14% / Epic8% / Legendary4% 커서 가속: 1 + |pos-0.5|×0.6 (가장자리에서 빨라짐) ``` -### 스폰 레벨 +### 필드 스폰 (FieldSpawnRules가 단일 출처) ``` -level = lerp(min, max, pow(random, power)) -서브에리어: power=2.0, 메인필드: power=3.5 -레어도 보정: Uncommon+0.5, Rare+1.5, Epic+3.0, Legendary+5.0 +등급 = 전역 표 C .60 / U .25 / R .11 / E .035 / L .005 (리전과 무관, 레어 부스트는 R+에만 ×) + → 풀에 없는 등급은 가까운 아래 → 위로 대체 (안전망) +종 = 그 등급의 (리전 풀 ∩ 시간·날씨 후보) 안에서 spawnWeight × 성향 배수 가중 (상태는 리전별 — GetWorldState(regionId)) + → 성향 배수 = InsectHabits.SpawnWeightMultiplier: 야행성 밤 ×2.5·낮 ×0.35 / 주행성 낮 ×1.8·밤 ×0.4 / 좋아하는 날씨 ×1.5·싫어하는 ×0.6, [0.15, 4] clamp — 0 불가(후보가 안 빈다), 등급 분포는 그대로 +보너스 슬롯 = 밤 +1 · 비·안개 +1 (합 최대 +2, FieldSpawnRules.BonusSlots) — RegionCap이 보너스를 먼저 더한 합에 오염 감소(MaxActiveFor)를 얹는다 +시간대 전환 = GameClock.DayPhaseChanged → 잠들 시간의 종은 만료를 5~60초로 당김 (25m 규칙·스토리 목표종 면제는 순환 쪽이 그대로 지킨다) +레벨(메인) = 리전 대역 [requiredLevel, +GetRegionLevelRange] 안 pow(random, 1.5), 등급 보정 없음 +레벨(서브에리어) = min~max 균등 +슬롯 = 설 수 있는 땅 / 550㎡ (8~40) → BlightPolicy.MaxActiveFor +재생 60~120s (서브에리어 45s) · 수명 4~7분(플레이어 25m 밖에서만 교체) · 새 개체는 플레이어 20m 밖 ``` ## 공유 파일 수정 경계 @@ -80,5 +105,6 @@ level = lerp(min, max, pow(random, power)) ## 설계 원칙 - WorldState 기반 스폰 필터링 (시간+날씨 조합) - 오브젝트 풀 필수 사용 (Instantiate 최소화) -- 60m 이상 자동 디스폰, 8초마다 스폰포인트 재배치 +- 기록(슬롯)이 개체다 — 멀어지면 몸만 풀로 돌리고(Recall) 기록은 남긴다. 리전 이동으로 리롤하지 않는다 +- 사라지는 방식 둘: 게임플레이 퇴장(Despawn 콜백 → 재생 지연) vs 스포너 회수(Recall → 개체 유지) - 포획 아이템은 speedMult/zoneMult/timeMult/captureBonus 4가지 효과 diff --git a/.claude/agents/data-architect.md b/.claude/agents/data-architect.md index f1d689d..d3a06ee 100644 --- a/.claude/agents/data-architect.md +++ b/.claude/agents/data-architect.md @@ -44,7 +44,7 @@ tools: - `Assets/Scripts/Core/PlayerInsectData.cs` - 개별 곤충 인스턴스 데이터 - `Assets/Scripts/Core/InsectSizeCalculator.cs` - 개체 크기·무게 계산 ※기준값·배율 튜닝은 game-designer - `Assets/Scripts/Core/GameConstants.cs` - 전역 상수 -- `Assets/Scripts/Core/CharacterOutfitData.cs` - 의상 데이터 모델 +- `Assets/Scripts/Core/CharacterOutfitData.cs` - 의상 데이터 모델 ※`OutfitPart` 스키마(OutfitShapeLibrary.cs)는 2026-09-30에 `shape`/`shapeArgs`·`hasAnchor`/`anchor`가 늘었다 — 전부 기본값이 기존 동작(Primitive·레시피 앵커)이라 세이브·기존 레시피에 영향 없음 - `Assets/Scripts/Data/StarterInsectCatalog.cs` - 첫 파트너 곤충 선택지 + PlayerPrefs 오버라이드 ※ResolveChoice의 화이트리스트는 조작 방어다(빼면 임의 곤충을 1레벨에 받는다). 지급은 여전히 ch1_intro 비트가 한다 - `Assets/Scripts/Data/CharacterAppearanceConfig.cs` - 캐릭터 외형 SO(색 팔레트·프리셋). 선택적 오버라이드이며 에셋이 없는 게 정상 경로 — 코드 폴백을 반드시 유지 - `Assets/Scripts/Core/CharacterPresetLibrary.cs` - 생성 화면 프리셋의 단일 출처(코드 기본값 + SO 오버라이드) ※인덱스는 Character.OutfitPreset으로 저장되므로 순서 불변 @@ -76,6 +76,17 @@ tools: - `Assets/Scripts/Story/StoryProgressData.cs` - 스토리 진행 세이브 모델 - `Assets/Scripts/Story/CutsceneData.cs` - 컷신 샷 데이터 모델 + 타임라인 순수부(CutsceneTimeline) +### 나의 섬 (세이브·데이터) +- `Assets/Scripts/Core/IslandData.cs` - 섬 데이터 모델(물건 정의·`IslandSave`·공개용 `IslandSnapshot`) ※수치는 game-designer(`IslandCatalog`) +- `Assets/Scripts/Core/IslandManager.cs` - 섬 상태·세이브(island.json)·구매·배치·방목·수확·가이드 진행 + 세이브 자가 복구(`IslandSaveRules`) ※가격·생산 수치는 game-designer, 퀘스트 통지 호출부 포함 +- `Assets/Scripts/Core/IslandGrid.cs` - 섬 격자 순수 규칙(차지 칸·경계·겹침·도착 칸 보호·빈 칸) +- `Assets/Scripts/Core/IslandShareClient.cs` - 섬 공개·방문·좋아요 서버 통신(`socialPvpApi` 액션) + 받은 스냅샷 정리 ※서버는 `functions/island.js` +- `Assets/Scripts/Core/FirestoreDocParser.cs` - Firestore REST 문서 필드 파서(공백 없는 형식·들여쓰기 형식 둘 다) — `CloudSaveManager` 복원이 쓴다 +- `Assets/Tests/EditMode/IslandGridTests.cs` - 격자 규칙(경계·도착 칸·겹침·옮기기·확장 후 좌표 유효) +- `Assets/Tests/EditMode/IslandManagerTests.cs` - 섬 상태 흐름(변경 전 정산·수확 소수점 보존·상한·방목 슬롯·유령 id 정리·안내 첫 선물 1회·스냅샷에 instanceId 없음) +- `Assets/Tests/EditMode/IslandSaveRulesTests.cs` - 섬 세이브 호환(빈 JSON·자가 복구·모르는 id 보존)·방문 스냅샷 정리·안내 단계 +- `Assets/Tests/EditMode/FirestoreDocParserTests.cs` - 클라우드 문서 파서(두 형식·이스케이프·블롭 안 동명 키) + ## 세이브 파일 구조 | 파일 | 내용 | 서비스 | |------|------|--------| diff --git a/.claude/agents/game-designer.md b/.claude/agents/game-designer.md index e66c9c7..e3ea81d 100644 --- a/.claude/agents/game-designer.md +++ b/.claude/agents/game-designer.md @@ -16,9 +16,12 @@ tools: 게임 시스템 기획, 밸런스 설계, 신규 기능 사양서 작성을 담당합니다. ## 담당 파일 (게임 시스템 매니저) -- `Assets/Scripts/Core/TrainingManager.cs` - 훈련 시스템 +- `Assets/Scripts/Core/TrainingManager.cs` - 훈련 시스템(기술 습득·성장 훈련 레벨/능력치) +- `Assets/Scripts/Core/TrainingPricing.cs` - 훈련 가격 정본(기술 가치·회당 비용·능력치 +1 비용) +- `Assets/Scripts/Core/TrainerLevelGap.cs` - 캐릭터↔곤충 레벨 차 정본(포획 레벨 제한 배율·캐릭터 EXP 레벨 배율) ※포획 공식 적용부는 capture-dev(`CaptureChanceCalculator`)·battle-dev(`BattleCaptureChanceCalculator`) - `Assets/Scripts/Core/TutorialQuestManager.cs` - 튜토리얼/퀘스트 - `Assets/Scripts/Core/TutorialQuestData.cs` - 퀘스트 데이터 +- `Assets/Scripts/Core/QuestTraitRules.cs` - 조건부 퀘스트(CaptureTrait/BattleFeat) 입력(CaptureFacts/BattleFacts)과 순수 판정 - `Assets/Scripts/Core/WeeklyContestSchedule.cs` - 주간 크기 대결 일정·대상 종·등급 임계 - `Assets/Scripts/Core/WeeklyContestManager.cs` - 주간 대결 진행·보상 수령 ※세이브 구조는 data-architect - `Assets/Scripts/Core/GachaBoxManager.cs` - 가챠 시스템 @@ -35,9 +38,42 @@ tools: - `Assets/Scripts/Story/StoryStageDirector.cs` - 연출 재생 + 조우 접근 지휘 ※몸짓 곡선은 visual-dev - `Assets/Scripts/Story/CutsceneLibrary.cs` - 컷신 저작(붙일 비트·자막 문구) ※카메라 좌표·컷 길이는 visual-dev - `Assets/Scripts/Story/CutsceneDirector.cs` - 컷신 재생·트리거·프리즈 복귀 ※카메라 워크는 visual-dev, 자막 렌더는 ui-dev +- `Assets/Scripts/Story/StoryVideoData.cs` - 영상 정의·자막 큐 데이터 + 타임라인 순수부 +- `Assets/Scripts/Story/StoryVideoLibrary.cs` - 스토리 영상(mp4) 저작(붙일 비트·자막 문구·큐 타이밍, 그림책 15편 — 대사 뒤 11·대사 앞 4) ※샷·프롬프트는 Docs/StoryVideos.md, 그림·소리는 `Tools/Video/storybook/render.py`(이 파일을 정규식으로 읽는다) +- `Assets/Scripts/Story/StoryVideoDirector.cs` - 영상 재생·트리거·프리즈 복귀 + 대사 앞 영상(`TryPlayPrelude` — 모든 종료 경로에서 대사를 한 번 연다) + 저널 다시보기(`PlayReplay`) ※화면 그리기·건너뛰기는 ui-dev +- `Assets/Scripts/Story/StoryPreludeChain.cs` - 대사 앞 연출 고리(영상 → NPC 등장 연출 → 대사) — 대화창의 연출 슬롯 하나에 여럿을 차례로 잇는다(콜백 한 번 보장·고리 예외 흡수) +- `Assets/Tests/EditMode/StoryPreludeTests.cs` - 대사 앞 영상의 종료 경로마다 콜백 한 번(끝·건너뛰기·ESC·디코더 오류·준비 초과·재생 초과·비활성·재진입) / 시작 못 하면 false·콜백 0 / 고리 순서·예외 / 다시보기 / 부트스트랩 배선 +- `Assets/Tests/EditMode/StoryVideoLibraryTests.cs` - 영상 저작(15편·길이 ≤15초·큐 순서·자막 한 줄 ≤24자·쓰지 않는 말) + 실제 Story.json의 대사 앞 영상 비트(1~6줄) +- `Assets/Scripts/Story/StoryBattleWait.cs` - 전투 화면 뒤로 미룬 대사·컷신·영상의 대기 시계(결과 화면·모달 시간은 세지 않는다, 12초 포기·폴백) ※결과 화면 여부는 부트스트랩이 `Func`로 넘긴다 +- `Assets/Tests/EditMode/StoryBattleWaitTests.cs` - 결과 화면 30초 뒤에도 미뤄 둔 컷신·영상·대사가 나온다 / 결과 화면 밖에서 굳은 전투 화면은 여전히 상한 / 부트스트랩 배선 - `Assets/Scripts/Story/StoryObjective.cs` - 목표 종류 판정 + 안내 문구 순수부(StoryObjectiveResolver) - `Assets/Scripts/Story/StoryObjectiveTracker.cs` - 목표 → 월드 좌표·자동 주행 해석 +- `Assets/Scripts/Story/RivalRaceController.cs` - 라온과의 포획 내기 (시간표·보상은 `RivalRaceRules`, `ch1_rival_intro` 뒤 1회) +- `Assets/Scripts/Story/DreamPrologueDirector.cs` - 「챔피언의 꿈」 프롤로그 지휘 (챔피언전 → 챔피언의 섬 → 깨어남, 신규 계정 1회·설정 다시보기) ※화면 그리기는 ui-dev +- `Assets/Scripts/Story/DreamIntroVideo.cs` - 꿈 도입 영상(경기장 입장 8초) 재생기 — 무음 영상 + 별도 음원, 못 뜨면 글자 카드로 대신 ※영상·음원 제작은 `Tools/Video/dream_arena*.py` +- `Assets/Scripts/Core/DreamPrologueData.cs` - 꿈의 저작 데이터 (챔피언 팀·적·꾸며진 섬 배치·기술 고르기·도입 영상 길이) +- `Assets/Scripts/Core/DreamPrologueState.cs` - 꿈 진행 표지(`Active`)와 시작 조건 순수 판정 +- `Assets/Scripts/Battle/SandboxBattleRules.cs` - 샌드박스 전투의 피해 배율·상하한·길이 (battle-dev와 같은 파일을 보나 수치는 여기가 단일 출처) +- `Assets/Scripts/Core/FieldMomentFeed.cs` - 필드 소식 대기열 (레벨업·이야기 보상·내기·색다른 조우 — 넣는 쪽과 그리는 쪽을 잇는다) - `Assets/Scripts/NPC/NpcBossDuels.cs` - 명부회 간부 고정 상대·레벨·보상 표 ※isFinal의 BGM 분기는 battle-dev +- `Assets/Scripts/NPC/DuelBanter.cs` - 대결 상대의 연출 대사(칭호·도발·전투 중 한마디·결과 한마디)와 순간 판정 ※그리기는 ui-dev(`BattleScreenUI.Duel`) +- `Assets/Scripts/NPC/NpcRivalDuels.cs` - 라온 라이벌 단계 표(열림·닫힘 비트·리전·상대 곤충·레벨·첫 승리 보상)와 단계 선택 ※대결 진입·종료 처리는 `NpcDuelController` +- `Assets/Scripts/Story/StoryDuelLauncher.cs` - 대사 직후 대결(`StoryBeat.duelAfter`) — 장면(영상·컷신·연출·선택 결과)이 다 끝난 첫 순간에 간부전·라온전을 연다 ※대결 진입 자체는 `NpcDuelController`(battle-dev), 「승부!」 버튼 그리기는 ui-dev +- `Assets/Scripts/Core/GuardianIntros.cs` - 수문장 등장 화면의 별칭·등장 한 줄(리전별) ※그리기는 ui-dev(레이드 시작 화면) +- `Assets/Tests/EditMode/StoryDuelFlowTests.cs` - 대사 직후 대결(대기열·꿈·오타)·간부 필수(승리 비트가 다음 장 선행)·이미 이긴 세이브의 자동 통과·나중에 끼운 스파인 +- `Assets/Tests/EditMode/RegionStoryLockTests.cs` - 이야기 잠금(서릿길 ← 집게, 잿불 골짜기 ← 저울)·저장된 해금 유지·잠김 문구 +- `Assets/Tests/EditMode/GuardianIntroTests.cs` - 수문장 등장 글의 리전 커버·길이·쓰지 않는 말 +- `Assets/Scripts/Core/GuardianBadges.cs` - 수문장 배지 표(리전·이름·새김글)와 4·8·13 이정표 보상 아이템·수량 ※그림은 `Tools/Badges/guardian_badges.py` +- `Assets/Scripts/Core/OutfitUnlockRules.cs` - 조건부 의상 해금 판정(지역 도달·레벨·퀘스트 토큰) 순수부 ※소유 부여 배선은 `CharacterOutfitManager.EvaluateUnlocks`, 문구는 `CharacterOutfitUI.DescribeUnlockCondition`(ui-dev) +- `Assets/Tests/EditMode/OutfitUnlockRulesTests.cs` - 해금 판정식 + 카탈로그 조건의 도달 가능성(리전·퀘스트 실재·만렙 이하·비매품) +- `Assets/Tests/EditMode/TrainingPricingTests.cs` - 훈련 가격(피해기 옛 가격 유지·상태기 효과 가치·능력치 +1 비용 대역·등급 경계) +- `Assets/Tests/EditMode/TrainerLevelGapTests.cs` - 레벨 차 규칙(포획 배율 유예·하한, EXP 레벨·차 배율, 높은 곤충일수록 EXP 단조 증가) +- `Assets/Scripts/Core/IslandCatalog.cs` - 나의 섬 물건 카탈로그(28종 가격·차지 칸·쾌적도·효과) + 섬·곤충 자리 확장가 ※모델은 visual-dev(`IslandObjectBuilder`), 데이터 모델은 data-architect(`IslandData`) +- `Assets/Scripts/Core/IslandYield.cs` - 섬 생산 공식 정본(섬 등급 배율·쾌적도·설비·친밀도·누적 상한·정산) — 계수는 `GameConstants.Island` +- `Assets/Scripts/Core/IslandGuideSteps.cs` - 섬 첫 방문 안내의 단계 판정·문구와 도움말 항목 ※배너·도움말 창 그리기는 ui-dev(`IslandGuideUI`) +- `Assets/Tests/EditMode/IslandYieldTests.cs` - 섬 수입 대역(기본 섬 ≤ 활동 수입 20%, 풀 확장 ≤ 1.25배)·효과 중복 금지·쾌적도 체감·친밀도 경계·정산(상한·시계 되돌림) +- `Assets/Tests/EditMode/IslandCatalogTests.cs` - 카탈로그 정합(id 유일·가격 하나·모델 switch 누락·확장가·퀘스트 보상 섬 물건 실재) +- `Assets/Scripts/Core/GuardianBadgeService.cs` - 새 배지 알림(첫 격파만)과 이정표 보상 지급·수령 기록 ※수령 기록 직렬화·클라우드 필드는 data-architect와 함께 본다 - `Assets/Editor/StoryBeatWalkthrough.cs` - 스토리 비트 실발화 걸음(배치모드) ※`LiveSceneCapture`(visual-dev)와 같은 배치 도구지만 검증 대상이 3D가 아니라 **저작**이다 ## 역할 diff --git a/.claude/agents/ui-dev.md b/.claude/agents/ui-dev.md index afe86b3..28ead2b 100644 --- a/.claude/agents/ui-dev.md +++ b/.claude/agents/ui-dev.md @@ -1,4 +1,4 @@ ---- +--- name: ui-dev description: 2D IMGUI(OnGUI) 화면 담당 — 화면 흐름과 전환, Rect 좌표와 레이아웃, GUIStyle 캐싱, 키 안내, 이벤트 구독 바인딩, IModalUI 스택. 무엇을 어디에 그리는가가 문제일 때 PROACTIVELY 위임. 예 - 배틀 화면 버튼이 겹친다 / OnGUI에서 매 프레임 new GUIStyle이 생긴다 / ESC로 패널이 안 닫힌다 / 슬롯 배치가 틀어졌다. 3D 메시·머티리얼·파티클·색상값은 visual-dev 영역이므로 손대지 않는다. tools: @@ -17,52 +17,73 @@ tools: ### UI 모듈 (전체) - `Assets/Scripts/UI/MainMenuManager.cs` - 메인 메뉴 (Start/Settings/Exit) - `Assets/Scripts/UI/BattleScreenUI.cs` - 1v1 배틀 화면 (모놀리스, Phase 상태머신) ※배틀 로직은 battle-dev, 시각연출은 visual-dev +- `Assets/Scripts/UI/BattleScreenUI.Duel.cs` - 위의 연출 partial: 간부·수문장 컷인, 전투 중 말풍선, 결과 한마디, 팀 대결의 남은 곤충 공(상대 HP 카드 윗줄)·교체 문구(「집게가 지네를 내보냈다!」 + 교체 한마디 — 교체 단계와 이어지는 턴 배너 동안, 아래쪽 띠) (레이아웃·그리기). 같은 파일 `DuelTeamHud`(순수 — 공 줄·화면에 선 순번·교체 문구 자리·문장) ※대사 문구는 game-designer(`DuelBanter`), 교체 단계 전이는 battle-dev +- `Assets/Scripts/UI/BattleScreenUI.Environment.cs` - 위의 partial: 야생 전투 낮·밤·날씨 보정 칩(양쪽 HP 카드 아래) + HP 카드 아래 쌓기 순수 계산(`BattleHudStack` — 장부 게이지 → 보정 칩 → 대결 말풍선) ※배수·문구는 battle-dev(`BattleEnvironment`) +- `Assets/Scripts/UI/BattleScreenUI.Feel.cs` - 위의 partial(4단계 전투 체감): 진입(화면 쓸기 + 「야생 ○○이(가) 나타났다!」 — 실제 1.4초, 컷인 전투는 진입 구간 끝에 사라짐), 타격 순간 상성 표시(피해 숫자 위 화살표 + 「아주 잘 통했다!」), 전용기 컷인 띠(시전자 클로즈업 동안 — 아레나 머리 위 이름은 끈다), 승리 화면(「승리!」·보상 줄·「눌러서 계속」) ※결과 닫기·진입 시계는 battle-dev(`BattleScreenUI.Flow`), 연출 시각표는 visual-dev(`BattleFlourish`) +- `Assets/Scripts/UI/BattleFeelHud.cs` - 1대1·레이드 공용 체감 화면: 순수 계산(`BattleEntryText`·`BattleEntryStaging`·`BattleEntryLayout` 진입 문구·쓸기·자리, `MatchupHud` 상성 문구·화살표·칩, `SignatureCutInLayout` 전용기 띠, `BattleRewardLines`·`BattleVictoryLayout` 승리 화면 보상 줄·시각·자리, `RaidTeamStrip` 레이드 팀 줄 내림·숨김) + 그리기(`BattleFeelDraw`) ※상성 등급은 battle-dev(`ElementMatchup`) +- `Assets/Scripts/UI/BadgeCeremonyUI.cs` - 수문장 배지 획득 연출(전투 화면이 닫히는 순간 스토리 대사보다 먼저 뜨는 모달) ※배지 표·보상은 game-designer(`GuardianBadges`) +- `Assets/Scripts/UI/BadgeCaseUI.cs` - 배지 케이스(진열판·상세·이정표 [받기]) — 퀵메뉴 [배지](K) +- `Assets/Scripts/UI/BadgeArt.cs` - 배지 PNG 캐시·잠김 틴트·배율 안전 회전 그리기(연출·케이스 공용) - `Assets/Scripts/UI/RaidBattleUI.cs` - 레이드 화면 상태기계 (Phase 전이·입력·컨트롤러 이벤트) ※배틀 로직은 battle-dev, 시각연출은 visual-dev - `Assets/Scripts/UI/RaidBattleUI.Draw.cs` - 위의 렌더 절반 partial (GUIStyle 캐시 + Draw* 전부) ※AOE·유나이트 이펙트는 visual-dev +- `Assets/Scripts/UI/RaidBattleUI.Stage.cs` - 위의 이야기 문구 partial: 수문장 등장 배너(인트로 — 칭호·별칭·이름 + 속성 칩·등장 줄, 포효 박자에 별칭), 변신 문구(변신 단계), 보스 이름 아래 「○○의 모습」. 같은 파일 `RaidStageLayout`(순수 — 보스 카드·예고·팀 줄·합체 게이지·아래 무대·변신 판·수문장 배너) ※별칭·등장 줄은 game-designer(`GuardianIntros`), 변신 규칙은 battle-dev(`RaidBossForms`), 연출 박자는 visual-dev(`BattleStaging`) +- `Assets/Scripts/UI/RaidBattleUI.Feel.cs` - 위의 partial(4단계 전투 체감): 일반 레이드 진입(화면 쓸기 + 아래 무대 「레이드 보스 ○○ 출현!」 — 수문장은 `.Stage` 배너), 타격 순간 상성 표시(팀원 → 보스는 지금 모습 기준), 전용기 행동 강조(행동 문구 띠 크게·속성색 테두리 + 「전용기!」 — 레이드는 사전 컷인 없음), 승리 화면(「레이드 승리!」·보상 줄·「눌러서 계속」) ※팀 줄의 내림·숨김(`TeamStripY`·`TeamStripVisibility`)은 battle-dev 파일 `RaidBattleUI.Impact`의 배치 몫 - `Assets/Scripts/UI/CaptureChoiceUI.cs` - 포획/배틀 선택 허브 (11개 의존성) ※포획 로직은 capture-dev -- `Assets/Scripts/UI/CapturePopupUI.cs` - 포획 팝업 UI +- `Assets/Scripts/UI/CapturePopupUI.cs` - 포획 팝업 UI (가운데 무대의 차례 항목 — 성공 카드는 640×640 설계 좌표를 무대에 맞춰 행렬로 줄인다) - `Assets/Scripts/UI/PlayUIConfig.cs` + `PlayUIRefs.cs` - UI 설정/참조 - `Assets/Scripts/UI/PlayerStatusHUD.cs` - 상태 HUD - `Assets/Scripts/UI/KeyGuideHUD.cs` - 키 안내 HUD - `Assets/Scripts/UI/TrainingUI.cs` - 훈련 UI - `Assets/Scripts/UI/CollectionUI.cs` - 보유 곤충 UI +- `Assets/Scripts/UI/InsectDetailVisualCapture.cs` - 도감·보유 개체 상세와 퀵바 실제 IMGUI 검수용 저장 비접촉 fixture +- `Assets/Scripts/UI/FieldHudVisualCapture.cs` - 필드 HUD·포획 선택/성공 팝업·배틀팀 실제 IMGUI 검수용 저장 비접촉 fixture(`-battleScenario field-ui`) - `Assets/Scripts/UI/BattleTeamUI.cs` - 팀 편성 UI ※배틀 로직은 battle-dev - `Assets/Scripts/UI/HospitalUI.cs` - 병원 치료·아이템 대상 선택 UI - `Assets/Scripts/UI/InventoryUI.cs` - 가방(보유 아이템 목록·사용) UI -- `Assets/Scripts/UI/RegionMapUI.cs` - 지역 맵 UI +- `Assets/Scripts/UI/RegionMapUI.cs` - 지역 맵 UI (잠긴 지역 안내는 `RegionManager.DescribeLock` — 문구는 game-designer) - `Assets/Scripts/UI/SettingsPanel.cs` - 설정 패널 - `Assets/Scripts/UI/AccountSettingsUI.cs` - 계정/오프닝 다시 보기 패널 - `Assets/Scripts/UI/LoginUI.cs` - 로그인 화면 - `Assets/Scripts/UI/CashShopUI.cs` - 캐시샵 화면 - `Assets/Scripts/UI/CharacterOutfitUI.cs` - 의상 UI -- `Assets/Scripts/UI/QuickAccessBarUI.cs` - 퀵액세스 바 +- `Assets/Scripts/UI/QuickAccessBarUI.cs` - 퀵액세스 바 (자리는 순수 계산 `ShortcutBarRectIn` — 퀘스트 칩·섬 HUD·시각 칩이 이걸 기준으로 피해 간다) - `Assets/Scripts/UI/SocialPvpUI.cs` - 소셜 PvP 로비·스킬 선택 UI -- `Assets/Scripts/UI/TutorialQuestUI.cs` - 튜토리얼 퀘스트 UI +- `Assets/Scripts/UI/TutorialQuestUI.cs` - 튜토리얼 퀘스트 UI (칩·복원 버튼·목표 행 자리는 같은 파일 `QuestChipLayout` — 세로 모바일 미니맵 아래, 가로 모바일 미니맵 오른쪽(아래는 조이스틱 자리), 데스크톱은 단축 바 왼쪽 끝까지·바닥에서 위로). 목표 행은 트래커의 「왜」가 있으면 두 줄(`RowHeightFor` — 칩 자리·그리기·등록·무대가 같은 값, 일시 안내는 그 둘째 줄을 잠깐 빌린다) - `Assets/Scripts/UI/GuidedTutorialController.cs` - 첫 몇 단계 강제 가이드 오버레이(코치 배너+시작 프리즈) ※퀘스트 이벤트는 TutorialQuestManager(Core) - `Assets/Scripts/UI/WorldLobbyUI.cs` - 월드 로비 - `Assets/Scripts/UI/CharacterPortraitRenderer.cs` - 통합 캐릭터 포트레이트 렌더러 - `Assets/Scripts/UI/InsectVisual.cs` - 곤충 그림 단일 진입점(3D 썸네일 or 2D 폴백 판단) ※렌더는 InsectModelPreviewRenderer(visual-dev) -- `Assets/Scripts/UI/UIShapes.cs` - 2D 폴백 도형 원시요소(원·캡슐·실루엣) ※색은 UITheme 토큰 +- `Assets/Scripts/UI/UIShapes.cs` - 2D 폴백 도형 원시요소(원·캡슐·실루엣·화살표 — 전투 상성 표시는 글리프 대신 도형) ※색은 UITheme 토큰 +- `Assets/Scripts/UI/KoreanJosa.cs` - 받침으로 고르는 조사(이/가·을/를·은/는·와/과) 순수 계산 — 모르면(영문) 「이(가)」로 둘 다 - `Assets/Scripts/UI/UIHelper.cs` - UI 유틸리티 - `Assets/Scripts/UI/UIScale.cs` - 1920×1080 기준 가상 좌표계 / GUI.matrix 자동 스케일링 -- `Assets/Scripts/UI/UISafeLayout.cs` - 세이프에어리어 + 세로 마진 배치 하네스 (패널 Rect의 단일 출처, `rules/ui-layout.md`) +- `Assets/Scripts/UI/UISafeLayout.cs` - 세이프에어리어 + 세로 마진 배치 하네스 (패널 Rect의 단일 출처, `rules/ui-layout.md`). 같은 파일의 `HudFrame` — 화면 한 장(픽셀·인셋·배율·모바일/세로)을 값으로 세워 순수 배치 함수가 받는다(전수 겹침 검사가 화면 18장을 이걸로 만든다) +- `Assets/Scripts/UI/HudStage.cs` - 가운데 무대 — 잠깐 뜨는 카드·알림(포획 결과·퀘스트 완료/다음·소식·섬 토스트·대결 결과·계정 알림·멀티 초대/안내·잠긴 리전)이 서는 한 자리(`Area` — 늘 떠 있는 HUD를 모두 피한 사각형)와 차례 중재(`Request`/`Granted` — 고정 셋은 늘 서고 차례 항목은 한 번에 하나, 기다리는 동안 시간도 멈춘다). 무대 안의 가운데 것들(대화 버튼·코치·섬 안내·근처 탐험가)은 서 있는 카드와 겹치면 비켜선다(`OccupiedOver` — 카드는 그리는 자리에서 `Request(item, rect)`로 자리를 알린다). 같은 파일 `HudPresence` — 코치 배너가 비켜설 버튼(대화·근처 탐험가·동굴 입구)과 섬 안내의 「지금 서 있다」 표지 - `Assets/Scripts/UI/UISurface.cs` - 둥근 카드·그림자·호버 공용 서피스 (전 화면 표면 처리의 단일 출처). 색은 UITheme 토큰에서만 받는다 - `Assets/Scripts/UI/QuestListLayout.cs` - 퀘스트 목록 아코디언 가변 높이 순수 계산 - `Assets/Scripts/UI/UIDirectScroll.cs` - IMGUI 목록 휠·터치 드래그 직접 스크롤 - `Assets/Scripts/UI/UITheme.cs` - UI 테마/스타일 - `Assets/Scripts/UI/UITween.cs` - UI 트윈 애니메이션 - `Assets/Scripts/UI/InsectBrowseSort.cs` - 보유 곤충 정렬 순수부(등급/레벨/CP/최근) -- `Assets/Scripts/UI/StoryJournalUI.cs` - 스토리 저널 챕터 탭·다시 읽기 렌더 +- `Assets/Scripts/UI/StoryJournalUI.cs` - 스토리 저널 챕터 탭·다시 읽기 렌더 + 장 머리(지난 이야기·이번 목표 — 여는 비트를 본 장만, 스포일러 금지) + 본 비트의 「▶ 영상」 다시보기(대사 앞 `introVideoId` → 뒤 `videoId` 순, `StoryVideoDirector.PlayReplay` — 영상이 모달 맨 위라 ESC·건너뛰기는 영상만 닫고, 덮는 동안 저널은 그리지도 입력을 받지도 않는다). 같은 파일 `StoryJournalHeadLayout`(순수 — 목록 영역 위 절반까지)·`StoryJournalRowLayout`(순수 — 행 버튼 자리: 「다시 읽기」 오른쪽 끝 고정, 「▶ 영상」 그 왼쪽)·`StoryJournalVideo`(순수 — 다시 볼 영상 고르기) ※영상 재생·자막은 game-designer(`StoryVideoDirector`·`StoryVideoLibrary`) +- `Assets/Scripts/UI/StoryRecapVisualCapture.cs` - 「지난 이야기」 카드·HUD 목표 행 두 줄(할 일 + 왜)·잡담 띠 "다음 이야기 — 이유" 실제 IMGUI 검수용 저장 비접촉 fixture(`-battleScenario story`의 앞 컷) +- `Assets/Scripts/UI/StoryVideoScreenLayout.cs` - 스토리 영상 화면의 자막 띠·「건너뛰기」 자리(순수 — 36pt 두 줄 칸·띠 0.68, 건너뛰기는 가로 오른쪽 아래·세로 오른쪽 위, 겹치면 띠를 올린다). 그리기는 `StoryVideoDirector`의 OnGUI(ui-dev 경계) ※재생 로직·대사 앞 영상 콜백은 game-designer +- `Assets/Scripts/UI/StoryVideoVisualCapture.cs` - 스토리 영상 실제 디코더 검수 장면(`-battleScenario story-video`, 일부만 `-videoOnly ch6_wall,ch7_fence`) — 진짜 `StoryVideoDirector.PlayReplay`로 편마다 첫 자막 0.5초 앞·자막 가운데를 찍고 README에 재생 여부·첫 프레임·실제 길이. 앞머리에 저널 「▶ 영상」→ESC 판정(`00-journal-*`). 저장 비접촉(지휘자 AutoWire 없음, 열람 기록은 꺼진 `StoryDirector`에 메모리로). 종료 코드 0/3(재생 실패)/5(저널 ESC 실패)/2 - `Assets/Scripts/UI/AccountLinkUI.cs` - 게스트→정식 계정 연동 화면 - `Assets/Scripts/UI/SaveConflictUI.cs` - 로컬/클라우드 세이브 충돌 선택 모달 ※세이브 구조는 data-architect -- `Assets/Scripts/UI/WorldFieldMultiplayerUI.cs` - 필드 멀티 초대·친구 목록 (픽셀 좌표계 — `UISafeLayout.Px` 사용) +- `Assets/Scripts/UI/WorldFieldMultiplayerUI.cs` - 필드 멀티 초대·친구 목록 (가상 캔버스 — 상태 판·대화 기록·근처 탐험가 자리는 순수 계산 `StatusRect`·`MessagesRect`·`NearbyRect`, 초대·안내는 가운데 무대. 섬·꿈·창·조작 잠금에서 물러난다) - `Assets/Scripts/UI/WorldInteractionController.cs` - 월드 오브젝트 상호작용 프롬프트 - `Assets/Scripts/UI/MinimapUI.cs` - 미니맵 HUD +- `Assets/Scripts/UI/FieldMomentsUI.cs` - 필드 소식 카드 + 라온 내기 점수판 (비모달 — FieldMomentFeed에서 꺼내 그린다. 소식 카드는 가운데 무대의 차례 항목, 점수판 자리는 `RaceChipRect`) +- `Assets/Scripts/UI/WorldClockHUD.cs` - 필드·나의 섬 HUD 시각·날씨 칩 + 시간대·날씨 변화 알림 (비모달 — `FieldHudInput` 등록. 우측 열 맨 아래, 날씨는 현재 리전 기준 `WeatherForecast.EffectiveIn`, 섬은 세계 날씨. 모바일 섬 — 세로는 섬 HUD 열 아래, 가로는 필드와 같은 단축 바 아래) ※시계·날씨 규칙은 Core +- `Assets/Scripts/UI/WorldClockRules.cs` - 시각 표기(10분 내림)·알림 문구 고르기·날씨 지역(`SkyRegionId`)·칩/알림 좌표(필드·섬) 순수 계산 +- `Assets/Scripts/UI/DreamVisualCapture.cs` - 「챔피언의 꿈」 섬·도입·깨어남 화면 검수 장면 (`-battleScenario dream-island`) - `Assets/Scripts/UI/SafeArea.cs` - `Screen.safeArea` 픽셀 인셋 (프레임당 1회 캐시). `UISafeLayout`의 입력원 - `Assets/Scripts/UI/SafeAreaPanel.cs` - uGUI RectTransform 세이프에어리어 적용 컴포넌트 -- `Assets/Scripts/UI/VirtualJoystickUI.cs` - 모바일 가상 조이스틱 ※`ui_layout_lint` 면제 대상(조작 영역이라 마진을 주면 좁아진다) -- `Assets/Scripts/UI/PlayerHintOverlay.cs` - 필드 안내 문구(이동 잠금·리전 레벨 부족) ※상태는 PlayerMovement가 소유, 여기선 그리기만 +- `Assets/Scripts/UI/VirtualJoystickUI.cs` - 모바일 가상 조이스틱 ※`ui_layout_lint` 면제 대상(조작 영역이라 마진을 주면 좁아진다). 등록된 필드 HUD(`FieldHudInput`) 위에서는 시작하지 않는다 — 유휴 힌트 원 안은 예외(`CanBeginAt`). 모바일 배치에서만 켠다(`EnabledFor` — 데스크톱은 좌하단이 퀘스트 칩 자리) +- `Assets/Scripts/UI/PlayerHintOverlay.cs` - 필드 안내 문구(이동 잠금·리전 레벨 부족) ※상태는 PlayerMovement가 소유, 여기선 그리기만. 잠금 안내는 동굴 입구 버튼 자리 아랫줄(`FrozenRect`), 차단 문구는 가운데 무대의 마지막 차례 - `Assets/Scripts/UI/BattleEffectTextOverlay.cs` - 전투 문구 오버레이 ※목록은 BattleArenaController가 소유 +- `Assets/Scripts/UI/BattleShoutOverlay.cs` - 전투 외침 오버레이(기술명 말풍선·비명·의성어) ※목록·타이밍은 BattleArenaController가 소유. 회전·확대는 `GUIUtility.RotateAroundPivot` 대신 행렬을 직접 곱한다(UIScale≠1이면 피벗이 어긋난다) - `Assets/Scripts/UI/FieldHudInput.cs` - 필드 HUD 터치 좌표 변환 ※`ui_layout_lint` 면제 대상(배치가 아니라 입력) - `Assets/Scripts/Dex/DexBrowseLayout.cs` - 도감 순환 선택·그리드 열 수/높이 순수 계산 (도감 탭과 보유 탭이 공유) @@ -78,12 +99,38 @@ tools: - `Assets/Scripts/Core/ItemRarityTuningUIController.cs` - 레어도 튜닝 UI - `Assets/Scripts/Core/ItemInventoryGridItem.cs` - 그리드 아이템 위젯 - `Assets/Scripts/Core/ShopUIController.cs` - 샵 UI 컨트롤러 -- `Assets/Scripts/UI/NpcDialogueUI.cs` - NPC 대화 모달 (레이아웃/렌더) ※대사 내용은 game-designer +- `Assets/Scripts/UI/NpcDialogueUI.cs` - NPC 대화 모달 (레이아웃/렌더) + 장을 여는 비트의 첫 대사 앞 「지난 이야기」 카드(처음 발화만, 다시보기 제외) + 대사 직후 대결이면 마지막 버튼 「승부!」·[건너뛰고 승부]. 같은 파일 `StoryRecapLayout`(순수 — 대사 상자·카드 판·줄 쌓기)·`DialogueDuelPrompt`(순수 — 대결이 이어지는지·버튼 문구). 꺼진·파괴된 창으로는 스토리 대사를 열지 않는다(대사 앞 영상·등장 연출이 씬 재로드 중 끝나며 부르는 콜백 — 비트를 쥐었다가 다시 켜지면 연다, 모달 레지스트리에 안 남긴다) ※대사·장 요약 문구는 game-designer +- `Assets/Scripts/UI/StoryDialogueStaging.cs` - 스토리 대사 무대 규칙(좌우 배치·타자 속도·줄 연출 fx) 순수 계산 ※어느 줄에 어떤 fx를 붙일지는 game-designer - `Assets/Scripts/Core/QuestRewardFormatter.cs` - 퀘스트 보상 표시 문자열 조립 (배너·목록 공용) ※보상 수치 자체는 game-designer +### 나의 섬 화면 +- `Assets/Scripts/UI/IslandHudUI.cs` - 섬 위 버튼 줄(꾸미기·상점·곤충·수확·방문·도움말·나가기 / 남의 섬: 좋아요·돌아가기) — 비모달이라 `FieldHudInput` 등록 필수. 자리는 같은 파일의 `IslandHudLayout`(순수 계산 — 데스크톱 한 줄 · 세로 모바일 열 · 가로 모바일 단축 바 왼쪽 두 칸 판, 결과 토스트 자리. 시각·날씨 칩이 같은 계산으로 피해 간다) +- `Assets/Scripts/UI/IslandEditUI.cs` - 섬 꾸미기(보관함 트레이, 칸 탭 = 자리 고르기, 끌기 = 화면 옮기기, 돌리기·놓기·넣기) — 모달 +- `Assets/Scripts/UI/IslandShopUI.cs` - 섬 상점(건물·가구·지형지물·도구·확장 5탭) ※가격은 game-designer(`IslandCatalog`) +- `Assets/Scripts/UI/IslandInsectUI.cs` - 섬 곤충 창(풀어놓기·거두기, 친밀도·시간당 생산 표시) +- `Assets/Scripts/UI/IslandVisitUI.cs` - 섬 나들목(내 섬 가기·나가기, 섬 코드·공개 설정, 친구·코드로 방문) — 퀵바 [내 섬]과 본 마을 나루터가 연다 +- `Assets/Scripts/UI/IslandGuideUI.cs` - 섬 안내 코치 배너(꾸미기 모달 위에서도 뜬다) + 도움말 창 ※단계 판정·문구는 game-designer(`IslandGuideSteps`) +- `Assets/Scripts/UI/IslandUiKit.cs` - 섬 화면 공용 스타일·결과 문구 +- `Assets/Scripts/UI/IslandVisualCapture.cs` - 섬 화면 실제 IMGUI 검수용 저장 비접촉 fixture(`-battleScenario island-ui`) + ### Editor - `Assets/Editor/PlayUIPrefabGenerator.cs` - UI 프리팹 자동 생성 +### Tests +- `Assets/Tests/EditMode/MapNavigationTests.cs` - 서브월드 지도위치, 출구복원 이동순서, 미니맵 좌표 회귀 +- `Assets/Tests/EditMode/InsectDetailNavigationTests.cs` - 도감에서 보유 개체 상세로 이동할 때 고유 ID와 모달 상태 회귀 +- `Assets/Tests/EditMode/NpcDialogueContinuityTests.cs` - 줄별 화자, 전투 후 재대화, 모달 재진입 회귀, 꺼진 창에 온 스토리 대사(레지스트리에 안 남고 다시 켜지면 열림·다시보기는 버림·파괴된 창) +- `Assets/Tests/EditMode/DuelHudLayoutTests.cs` - 전투 진영 배치, 버튼 중첩, 기술 대상 표시 +- `Assets/Tests/EditMode/StoryBattleHudTests.cs` - 이야기 전투 화면: 조사(이/가·을/를), 팀 대결 공 줄·화면에 선 순번·교체 문구 자리(화면 7장 — 전투 HUD 전 조각과 안 겹침), 레이드 변신 판·수문장 배너(보스 위 2/3·아레나 수문장 창 비움), 「○○의 모습」(연출 전엔 이전 모습), 대화창 「승부!」 조건·문구 +- `Assets/Tests/EditMode/BattleFeelHudTests.cs` - 4단계 전투 체감 화면: 종류별 진입 문구(조사·진짜 대결 상대 이름)·화면 쓸기(덮고 열림)·진입 문구 시각(컷인 앞에서 사라짐), 상성 등급 → 문구·화살표·색(상성표 전 쌍)·숫자 위 자리, 스킬 카드 칩 자리, 전용기 띠(화면 7장 — 전투 HUD 전 조각과 안 겹침·들어오는 쪽), 승리 화면(보상 줄 순서·0.8초부터 한 줄씩·가운데 비움·「눌러서 계속」은 닫을 수 있을 때만), 결과 화면 옛 안내문 금지, 레이드 팀 줄(곧바로 내림·숨김 단계) +- `Assets/Tests/EditMode/ExplorationNavigationTests.cs` - 메뉴 경로, 단축키 보존, 모달 전환 회귀 +- `Assets/Tests/EditMode/UIParityTests.cs` - 등급 한글 표기·HP 색 기준의 단일 출처(화면 간 표기 혼재 회귀) +- `Assets/Tests/EditMode/WorldClockHudTests.cs` - 시각 표기·시간대 이름 일관성, 지역 기준 알림 선택(섬 = 세계 날씨), 칩·알림 배치, 섬 HUD 버튼 배치(모양 셋), 퀘스트 칩 자리(`QuestChip_*` — 단축 바 옆 폭·좁을 때 위로·가로 모바일 미니맵 옆), 조이스틱 시작 판정(`JoystickCanBeginAt_*`), 전투 보정 칩 배치·문구·펄스. 화면 전체 겹침은 아래 `HudOverlapSweepTests`로 옮겼다 +- `Assets/Tests/EditMode/StoryVideoScreenLayoutTests.cs` - 영상 자막 띠·「건너뛰기」(화면 16장 — 세이프 영역 안·안 겹침·세로는 오른쪽 위·두 줄 칸·최장 24자 두 줄·아이용 글자 크기와 띠 농도) +- `Assets/Tests/EditMode/StoryJournalVideoTests.cs` - 저널 영상 다시보기: 행 버튼 자리(행 안·안 겹침·터치 높이·글자가 버튼 앞에서 끊김·「다시 읽기」 열 고정), 다시 볼 영상(앞 → 뒤 순·하나면 하나·모르는 ID는 버튼 없음) +- `Assets/Tests/EditMode/StoryRecapLayoutTests.cs` - 「지난 이야기」 카드(띄울지·대사 상자 위 판·줄 쌓기·넘치면 본문만 줄이기, 가로·세로·배율)·저널 장 머리·HUD 목표 행 「왜」 둘째 줄(무대가 피하는지)·잡담 띠 문구 +- `Assets/Tests/EditMode/HudOverlapSweepTests.cs` - **필드·동굴·나의 섬·남의 섬·꿈 섬 HUD 전수 겹침** — 화면 18장(데스크톱 6, 모바일 세로 2·가로 2 × 배율 0.667/1/1.333)에서 함께 뜰 수 있는 모든 요소의 모든 쌍. 자리는 전부 화면 파일의 순수 배치 함수를 부르고, 함께 뜰 수 없는 쌍만 `Exclusion`이 코드 근거와 함께 뺀다. 안전 영역 안·가운데 무대 크기와 고정 칸·차례 중재(`StageGranted_*`)·조이스틱 자리(늘 떠 있는 HUD 없음·잠깐 뜨는 것 다 띄워도 절반 넘게 빔). **필드 위에 HUD를 새로 그리면 여기 `Elements`에 한 줄 넣을 것** + ## 화면 흐름 ``` MainMenu → PlayScene diff --git a/.claude/agents/visual-dev.md b/.claude/agents/visual-dev.md index ab6f562..099f130 100644 --- a/.claude/agents/visual-dev.md +++ b/.claude/agents/visual-dev.md @@ -1,4 +1,4 @@ ---- +--- name: visual-dev description: 3D 씬 비주얼과 연출 담당 — 프로시저럴 메시 빌더(InsectEntity.BuildModel, PlayerVisualBuilder, RegionTerrainBuilder, SubAreaWorldBuilder), Material·셰이더·색상 팔레트, 파티클과 이펙트, 애니메이션 보간(HP바, 쉐이크, AOE). 어떻게 보이는가(모양·색·움직임)가 문제일 때 PROACTIVELY 위임. 예 - 곤충 모델이 점토처럼 보인다 / 지형이 하늘에 떠 있다 / 레어도 색이 안 맞는다 / 유나이트 이펙트가 안 나온다. UI의 Rect 좌표·레이아웃·화면 전환은 ui-dev 영역이므로 손대지 않는다. tools: @@ -21,8 +21,34 @@ OnGUI의 Rect 좌표와 레이아웃은 **ui-dev 영역**입니다. 여기서는 ### 프로시저럴 비주얼/오디오 - `Assets/Scripts/Spawning/InsectEntity.cs` - 프로시저럴 곤충 모델 (30+ 종) ※스폰 로직은 capture-dev +- `Assets/Scripts/Spawning/InsectSculptureMeshes.cs` - 캐시된 곤충 뿔·턱·외골격·날개 커스텀 메시 +- `Assets/Tests/EditMode/InsectSculptureMeshTests.cs` - 곤충 메시 폐곡면·와인딩·유한 좌표·캐시 검증 - `Assets/Scripts/Battle/BattleArenaController.cs` - 배틀 아레나 환경 구축 +- `Assets/Scripts/Battle/BattleArenaController.Impact.cs` - 타격감 partial: 히트스톱·넉백·피격 섬광(PropertyBlock)·임팩트 버스트·외침 목록·연출 카메라 구동 ※반투명·빛 이펙트 머티리얼은 `CreateFxMaterial`이 단일 출처(Standard Fade는 빌드에서 불투명으로 그려진다) +- `Assets/Scripts/Battle/BattleArenaController.Raid.cs` - 레이드 3D 연출 partial: 합체공격(차례 돌진·합동 일격), 팀원 한 마리 공격, 보스 공격 예고·보스 공격 +- `Assets/Scripts/Battle/BattleArenaController.Staging.cs` - 이야기 전투 등장·변신 partial: 상대 교체 등장(`PlayEnemySwitchIn` — 빛살·도약·착지 먼지), 그림자 변신(`PlayBossTransform` — 모델 교체는 연기가 가장 짙은 한가운데), 수문장 등장 컷(`PlayGuardianIntro`), 모습을 빌리는 보스의 그림자 모습(PropertyBlock 톤·뒤집힌 껍질 테두리·눈빛·김) ※연출은 한 번에 하나 — 다른 연출의 시작점이 `CompleteStagingNow`로 끝 상태에 접는다. 몸 색은 PropertyBlock, 빛·연기는 `CreateFxMaterial`뿐이라 Standard 변형(`BuildKeepers`)에 기대지 않는다 +- `Assets/Scripts/Battle/BattleArenaController.Life.cs` - 살아 있는 몸 partial: 대기 숨쉬기(배·몸통·마디 크기 + 더듬이 까딱 — 모델 루트는 안 건드린다), 상태이상·강화 몸 표시(독 거품·기절 별·공격 강화 불꽃결·방어 강화 육각 막·약화 내려가는 결), 회복 초록 반짝임 ※원천은 컨트롤러를 0.12초마다 읽는다(1대1 `PlayerStunTurns`·`GetActiveEffects`, 레이드 스택·건너뛴 보스 응답) — HP 카드 상태 줄과 같은 원천·규칙. 표시는 모델 밖(아레나 루트 아래)에 두고 몸을 따라간다. 머티리얼은 `CreateFxMaterial` 종류당 한 벌 + PropertyBlock 알파 +- `Assets/Scripts/Battle/BattleArenaController.Flourish.cs` - 전투 체감 partial: 전용기(시전자 클로즈업 컷인 → 기 모으기 → 속성 큰 이펙트 세 겹·조각 폭발 — 판정은 부르는 쪽 `HitCue.Signature`), 사마귀 칼날 궤적, 승리(1대1 포즈 + 카메라 반 바퀴 / 레이드 팀 점프 + 팀 샷), 전투 진입 샷 ※ui-dev가 읽는 공개 값: `IsSignaturePlaying`·`IsSignatureCutIn`·`SignatureCutInProgress`·`IsVictoryPlaying`·`VictoryFinished`·`IsOpeningPlaying` +- `Assets/Scripts/Battle/BattleFlourish.cs` - 전투 체감 순수 규칙: 전용기 컷인 시각표(`SignatureCutInStart`~`SignatureCutInEnd`), 승리 포즈·길이(`VictorySeconds` 실제 초), 진입 샷 길이(`OpeningShotSeconds` 실제 초 — 1대1 진입 구간 `BattleReadPacing.EntryIntroSeconds` 안), 숨쉬기·더듬이 곡선 +- `Assets/Scripts/Battle/BattleStatusLook.cs` - 몸 상태 표시의 순수 규칙: 컨트롤러 상태 → `BattleStatusFlags`(1대1 효과 목록·레이드 스택·보스 기절), 상태 색, 거품·결·막 곡선 +- `Assets/Tests/EditMode/BattleFlourishTests.cs` - 계열 분류(진짜 곤충 ID)·계열 몸짓 타격 시각·전용기 컷인이 타격 전에 끝나는지·전용기/승리/진입 샷 첫·끝 프레임·상태 판정·숨쉬기 범위 검증 +- `Assets/Tests/EditMode/BattleMotionTests.cs` - 계열 몸짓 곡선(예비 동작·복귀·실루엣 구별) +- `Assets/Scripts/Battle/BattleStaging.cs` - 등장·변신 연출의 순수 규칙(교체 등장·그림자 변신 진행률 시각표, 도약·착지·움츠림 곡선, 그림자 색, 수문장 샷 끝 자리·출발 자리) ※수문장 인트로 길이 `GuardianIntroSeconds`의 단일 출처 — `RaidBattleUI`가 읽는다 +- `Assets/Scripts/Battle/BattleCameraDirector.cs` - 스킬 타임라인 → 연출 카메라 샷(시네마틱·펀치·합체공격·상대 등장·그림자 변신·수문장 등장) 순수 계산 ※1v1 기본은 시네마틱(2026-09-28 A/B 비교 후 결정) +- `Assets/Scripts/Battle/BattleShout.cs` - 외침 문구 표(기술명·비명·의성어)와 종 계열 울음 분류(ID 토막 단위) ※문구 톤은 game-designer와 상의 +- `Assets/Scripts/Battle/RaidUniteTimeline.cs` - 합체공격 타임라인 단일 출처 — 아레나 돌진·타격과 UI 슬롯 숫자·TOTAL이 공유 +- `Assets/Tests/EditMode/BattleImpactFeelTests.cs` - 연출 카메라 샷·히트스톱 시계·외침 분류·타격 세기·합체공격 타임라인 검증 +- `Assets/Tests/EditMode/BattleStagingTests.cs` - 등장·변신 시각표가 화면 단계 안에서 끝나는지, 샷 첫·끝 프레임, 수문장이 화면 위쪽 2/3에 서는지(화면비 4종), 그림자 색 검증 +- `Assets/Scripts/Battle/ForestBattleSet.cs` - 머티리얼별 병합 숲 공터 아레나 메시 +- `Assets/Scripts/Battle/BattleMotion.cs` - 계열별 전투 몸짓 곡선 — 사마귀 두 번 베기·딱정벌레 머리 숙여 돌진·나는 종 내리꽂기·벌 찌르기·지네·거미 덮치기·그 밖 돌진, 원거리 시전도 계열별 ※계열 판정(`FamilyOf`)은 `InsectEntity.BuildModel`의 분기 순서를 따른다. 타격 진행률은 `ImpactOf`(찌르기 0.36, 나머지 0.4) — 1대1은 그 순간 `onImpact`가 숫자를 띄우고, 레이드 볼리는 0.46초(`RaidVolleyImpactSeconds`) 안에 곡선을 눌러 담는다 +- `Assets/Scripts/Battle/BattleFraming.cs` - 모델 경계·안전 영역 기반 전투 카메라 프레이밍 +- `Assets/Scripts/Core/BattlePresentation.cs` - 전투 표시 배속·움직임·섬광 설정 +- `Assets/Tests/EditMode/BattleFramingTests.cs` - 화면 비율·모델 크기별 프레이밍 검증 - `Assets/Scripts/Core/ProceduralAudioGenerator.cs` - 프로시저럴 오디오 +- `Assets/Scripts/Core/ProceduralAudioGenerator.Battle.cs` - 곤충 계열 울음·비명(`cry_*`/`hurt_*`)과 층 타격음 합성 ※폰 스피커 대역(300Hz~6kHz)에 에너지가 있어야 기기에서 들린다 +- `Assets/Scripts/Core/ProceduralAudioGenerator.Music.cs` - 전투 계열 곡 6곡(1대1·라온 대결·수문장·레이드·간부·최종전)의 작곡과 합성 — 음표 사건 악보 → 음마다 한 번 합성, 22.05kHz 모노, 48~60초 정수 마디, 곡 끝 여운·잔향을 첫머리에 감아 루프 이음매 없음, 300Hz 위 대역 RMS로 음량 맞춤 ※선율 문자열은 마디마다 4박을 검사해 틀리면 예외(테스트가 전곡을 합성하므로 바로 잡힌다). 새 곡은 `CombatSongKeys`·`ComposeCombatSong`·`GetBGM`의 전투 case 세 곳 + `AudioManager` 4지점. 합성은 순수 계산이라 작업 스레드에서 돈다 — 1대1 곡은 첫 탐험 곡 때 미리 굽고, 다른 곡은 `PrewarmBGM(key)`를 부르는 쪽이 정한다(곡당 4~5MB를 쥐므로 곧 쓸 때만) +- `Assets/Tests/EditMode/CombatMusicTests.cs` - 전투 곡 등록 4지점(키·긴장 램프·작곡기 경로·GetBGM case), 정수 마디 45~64초, 클리핑·리미터 무릎, 폰 대역 에너지 비율(≥20%, 예전 보스 곡 8%), 루프 이음매, 작업 스레드 합성 결정성 +- `Assets/Editor/BattleVoiceExport.cs` - 전투 목소리를 WAV로 추출 — 소리는 화면 캡처로 못 보므로 귀로 검수 - `Assets/Scripts/Core/AudioManager.cs` - 오디오 매니저 (싱글턴) - `Assets/Scripts/Core/UIAudioBinder.cs` - UI 버튼 자동 hover/click 사운드 부착 - `Assets/Scripts/Data/ItemRarityPalette.cs` - 레어도별 색상 ※data-architect 공유 @@ -30,18 +56,39 @@ OnGUI의 Rect 좌표와 레이아웃은 **ui-dev 영역**입니다. 여기서는 - `Assets/Scripts/Dex/InsectModelPreviewRenderer.cs` - 도감/상세용 곤충 모델 프리뷰 렌더 ※화면 배치는 ui-dev ### 환경 비주얼 -- `Assets/Scripts/Core/SubAreaEnvironment.cs` - 서브에리어 환경 전환 (조명, 안개, 앰비언트) +- `Assets/Scripts/Core/SubAreaEnvironment.cs` - 서브에리어 환경 전환 (조명, 안개, 앰비언트) + 그 위에 낮·밤·날씨 하늘 보정 ※전환 보간은 "기본 상태(보정 전)"만 만들고 `WriteFinal`이 하늘을 얹어 쓴다 — 스냅샷을 RenderSettings에서 뜨면 이중 적용. 보정은 메인 필드·나의 섬만(동굴·꿈 제외) +- `Assets/Scripts/Core/WorldSkyRules.cs` - 낮·밤·날씨 하늘의 순수 규칙(시각 키프레임·날씨 등급·달빛·환경광 밝기 바닥·안개 가산) ※정오 맑음 = 기본 상태 그대로가 기준 +- `Assets/Scripts/Core/WorldSkyVisuals.cs` - 스카이박스 머티리얼 복제본(노출·땅색)과 밤별 돔, 흐린 날 연무 돔(안개색·지평선 쪽 짙은 세로 알파 — 큐 별 2501 → 연무 2502), 하늘·날씨 입자용 `Sprites/Default` 재료(`SkyFx`) ※대기 두께는 건드리지 말 것 — 올리면 흐린 날 지평선이 노을처럼 주황이 된다(테스트가 소스로 막는다) +- `Assets/Scripts/Core/WeatherEffects.cs` - 날씨 입자(비·눈·센바람) — 카메라를 따라다니는 방출기, 지역 기준 날씨(`WeatherForecast.EffectiveIn`)와 `Blend01`에 맞춰 방출량 조절 ※필드·섬에서만, 모바일 절반 +- `Assets/Tests/EditMode/WorldSkyRulesTests.cs` - 하늘 규칙 검증(정오 항등·낮 곡선 연속성·밤 가시성 바닥·안개 날씨 11m 투과율·날씨 전환 양끝) - `Assets/Scripts/Core/WorldTerrainBuilder.cs` - 월드 지형 생성 (절벽, 강, 다리, 경사면) - `Assets/Scripts/Core/SubAreaWorldBuilder.cs` - 서브에리어 프로시저럴 던전/환경 생성 - `Assets/Scripts/Core/RegionTerrainBuilder.cs` - 리전별 필드 지형 생성 (언덕, 길, 바위, 나무) +- `Assets/Scripts/Core/WorldBackdropBuilder.cs` - 원경(월드 둘레 산맥·뭉게구름) — 리전·길을 피해 바깥으로 훑어 배치, 산 모양은 가장 가까운 리전을 따른다 +- `Assets/Scripts/Core/RegionPalette.cs` - 리전 바닥색·얼룩 톤의 단일 출처(부트스트랩 바닥 평면 + RegionDressingBuilder) ※감마 반사율, 0.8 넘으면 하얗게 날아간다 +- `Assets/Scripts/Core/RegionAtmosphere.cs` - 리전별 햇빛 색·세기·환경광·Exp2 연무(원경 공기원근) ※11m 투과율 98% 상한(MaxFogDensity), 안개 날씨는 85%(필드)·80%(섬) 상한(MaxWeatherFogDensity*) +- `Assets/Scripts/Core/SceneryBatcher.cs` - 콜라이더 없는 장식 소품을 칸×색 단위 메시로 합치는 배처(드로우콜·컬링) +- `Assets/Scripts/Core/RegionDressingBuilder.cs` - 리전 표면 장식(바닥 얼룩·발밑 디테일·호수·울타리 밖 테두리) ※콜라이더 없음, 남쪽 테두리 키 제한(카메라 차폐), 마을·전초기지가 지어진 뒤 Start에서 빌드 +- `Assets/Scripts/Core/SubAreaGateBuilder.cs` - 필드 쪽 서브에리어 테마 입구 표식 ※여기 등록한 ID는 부트스트랩 CreateSubAreaEntries가 건너뛴다 +- `Assets/Scripts/Core/SubAreaWorldBuilder.Themes.cs` - 같은 environmentType을 공유하는 서브에리어의 subAreaId별 변주(동굴 7테마·개미귀신 구덩이·빈칸·가장 높은 가지 등) ※방 크기는 원래 빌더와 같게(story_lint 21) +- `Assets/Scripts/Core/SceneryMaterials.cs` - 월드 소품 머티리얼의 단일 출처 — 셰이더 폴백(`LitShader`·`Create`), 무광·발광 마감, 반투명(`MakeFade`), 배처 캐시 키(`SceneryMaterialKey`: 색+표면 종류) ※부위별 광택을 따로 주는 곳(곤충 키틴·플레이어 옷·의상 spawn 파츠 — `CharacterPalette.ApplySurface`)은 `Create` 대신 `LitShader`만 빌린다. 오염 거점(`BlightVfx.Mat`)·전투 아레나(`CreateSafeMaterial`)는 2026-09-29 광택 통일로 `Create`(무광)를 쓰고, 금속·물처럼 광택이 제 질감인 스킬 파츠만 `CreateSheenMaterial`로 가른다. 전투 이펙트 반투명·가산은 `BattleArenaController.CreateFxMaterial`(FX 셰이더)이 따로 맡는다. **Standard의 반투명·발광 변형은 `BuildKeepers`(Resources 머티리얼 4벌)가 빌드에 남긴다** — 그게 없던 시절 플레이어 빌드에서 반투명이 불투명, 발광이 무발광으로 그려졌다(2026-09-29 QA 빌드 실측). **Standard를 Always Included Shaders에 다시 넣지 말 것** — 그 목록의 셰이더는 머티리얼 키워드를 안 봐서 이 방법이 무력해진다. 런타임에 새 Standard 키워드 조합을 켜면 `BuildKeepers`에 한 줄 추가 후 `ShaderVariantKeepers.Ensure` +- `Assets/Scripts/Core/FieldGround.cs` - 필드 둔덕(사구·재 더미·이끼 둔덕)의 윗면 높이 조회(`SurfaceY`·`LiftAt`) — 둔덕엔 콜라이더가 없어 곤충 스폰·플레이어 접지가 이걸로 올라탄다 ※`RegionTerrainBuilder.PlaceMound`/`RegisterDome`가 등록, 빌드 시작·OnDestroy에서 비운다 +- `Assets/Scripts/Core/IslandObjectBuilder.cs` - 섬 물건 28종 프로시저럴 모델(카탈로그 id ↔ switch, 차지 칸 안에 들어와야 함) +- `Assets/Scripts/Core/IslandTerrainBuilder.cs` - 섬 땅·모래톱·바다·나루터 모양(콜라이더 없음 — 접지·경계는 IslandWorldBuilder) +- `Assets/Editor/IslandModelCapture.cs` - 섬 지형·물건 배치모드 캡처 도구 +- `Assets/Scripts/Core/IslandMaterialCache.cs` - 섬 머티리얼 캐시(같은 색 = 같은 머티리얼, 불투명·반투명·발광) — 섬의 유일한 머티리얼 생성 경로 ### 캐릭터/의상 비주얼 -- `Assets/Editor/OutfitRenderProbe.cs` - 의상을 입힌 마네킹을 3D 리그로 직접 촬영해 spawn/bind 파츠가 실제로 그려지는지 확인 ※IMGUI를 안 거치므로 배치모드로 돈다 +- `Assets/Editor/OutfitRenderProbe.cs` - 의상을 입힌 마네킹을 3D 리그로 직접 촬영해 spawn/bind 파츠가 실제로 그려지는지 확인 ※IMGUI를 안 거치므로 배치모드로 돈다. `-outfitAll`(카탈로그 전량 × 앞/뒤)·`-outfitLooks`(성별×머리·얼굴·피부·머리색)는 판정 없는 전후 비교용 갤러리 - `Assets/Scripts/Core/CharacterFaceAnimator.cs` - 눈 깜빡임·표정 전환 ※걷기(PlayerMovement.AnimateWalk)와 직교한 별도 컴포넌트로 유지할 것. 눈 스케일은 base에 대입(곱셈 누적 금지) - `Assets/Scripts/Core/ProcMeshLibrary.cs` - 캐릭터용 프로시저럴 메시 생성기(Disc/LowSphere/RoundedBox/TaperedCapsule/Diamond) + 프로세스 수명 정적 캐시 ※bind 가능 노드(Cap·NetHandle 등)에는 쓰지 말 것 — ApplyBound가 sharedMesh·localScale을 덮어쓴다 - `Assets/Scripts/Core/CharacterPalette.cs` - 피부·머리 색 팔레트와 부위별 PBR 재질(SurfaceKind)의 단일 출처. 3D 캐릭터·마네킹·2D 초상·NPC가 전부 여기를 읽는다 ※인덱스 순서는 세이브가 가리키므로 바꾸지 말 것 - `Assets/Scripts/Core/CharacterOutfitManager.cs` - 의상 관리 -- `Assets/Scripts/Core/OutfitShapeLibrary.cs` - 의상 파츠 레시피(itemId → OutfitPart[]) 형태의 단일 출처 ※스키마·앵커 확장은 data-architect 공유 +- `Assets/Scripts/Core/OutfitShapeLibrary.cs` - 의상 파츠 레시피(itemId → OutfitPart[]) 형태의 단일 출처 ※스키마·앵커 확장은 data-architect 공유. spawn 파츠 재질은 슬롯 기본(천·가죽) + `SurfaceOverrides`(금속·유리·가죽 아이템, 색 역할 단위). 2026-09-30: 파츠별 앵커(`hasAnchor`/`anchor` — Body·LegL·LegR, `OP_{슬롯}_{앵커}` 컨테이너)와 메시 모양(`PartShape` — 둥근 상자·캡슐·늘어진 천·토러스·보석·잠자리채 머리)을 더했다. 몸 노드를 누가 칠할지는 스타일 표(소매·겉옷 형태·반바지·샌들)가 정한다 +- `Assets/Scripts/Core/OutfitPatternLibrary.cs` - 의상 **표면 무늬**(줄무늬·별·거미줄·위장 등) 절차 텍스처의 단일 출처 ※형태는 OutfitShapeLibrary, 무늬는 여기. 표면별(몸통·셔츠 판·소매·다리·신발) UV 규약과 앞/뒤 아틀라스는 `ProcMeshLibrary.FaceUv`·캡슐 UV와 짝이다 +- `Assets/Tests/EditMode/OutfitPatternLibraryTests.cs` - 무늬 표의 키·슬롯·표면 정합, 결정성, 상의 무늬 상호 구별, 아틀라스 앞/뒤 분리 +- `Assets/Tests/EditMode/CharacterBodyTests.cs` - 캐릭터 본체 계약(어깨 관절 원점·손 쉬는 자리·HairCrown·모자 속 정수리 덮개·표정별 입·얼굴 부품 표면 앞·정점 예산·몸통 부착물) +- `Assets/Tests/EditMode/OutfitPartSurfaceTests.cs` - 의상 파츠 재질 예외 표의 키 실재·예외 역할 실재·역참조·슬롯 기본 무광 검증 - `Assets/Scripts/Core/CharacterModelPreviewRenderer.cs` - 의상 미리보기용 3D 마네킹 리그·썸네일 렌더 ※화면 배치는 ui-dev - `Assets/Scripts/Core/OutfitBonusProvider.cs` - 의상 보너스 - `Assets/Scripts/Core/CameraFollower.cs` - 카메라 팔로우 @@ -59,7 +106,15 @@ OnGUI의 Rect 좌표와 레이아웃은 **ui-dev 영역**입니다. 여기서는 - `Assets/Scripts/NPC/NpcGesture.cs` - NPC 몸짓 정의 + 각도 곡선 순수부(NpcGesturePose) - `Assets/Scripts/Core/VillageBuilder.cs` - 마을 프로시저럴 지형/건물 - `Assets/Scripts/Core/BlightVfx.cs` - 오염 거점 구조물·안개·지면 탈색·정화 붕괴 연출 -- `Assets/Editor/LiveSceneCapture.cs` - 배치모드 실화면 캡처(3D 변경을 눈으로 확인) ※IMGUI는 안 잡힘 +- `Assets/Editor/LiveSceneCapture.cs` - 배치모드 실화면 캡처(3D 변경을 눈으로 확인) ※IMGUI는 안 잡힘. `-captureHour 0~24`·`-captureWeather clear|rain|fog|wind|snow`로 시각·날씨를 붙잡고 찍는다 — 기본은 정오·맑음(전후 비교가 같은 조명이 되게), `natural`이면 게임 그대로, 날씨 입자는 4초 이후 +- `Assets/Editor/ModelDesignCapture.cs` - 대표 곤충·플레이어·성인·아동 NPC의 표준 조명 3면 비교 캡처 ※IMGUI 제외 +- `Assets/Editor/VillageDesignCapture.cs` - 저장과 분리된 마을 건물 고정 구도 전후 캡처 +- `Assets/Editor/WorldMapDesignCapture.cs` - 실제 지형·소품·마을을 함께 생성한 전체 지역 격리 캡처 +- `Assets/Editor/FieldDesignTour.cs` - 실제 PlayScene으로 마을·리전·전초기지·서브에리어·NPC 전원을 한 번에 도는 전후 비교 캡처(`-tourOnly`·`-tourFilter`) ※IMGUI 제외 ※시계·날씨는 정오·맑음으로 붙잡는다(`-tourHour`·`-tourWeather`, `natural`이면 게임 그대로) +- `Assets/Scripts/UI/WorldMapVisualCapture.cs` - 저장과 분리된 실제 지도/미니맵 IMGUI 촬영 fixture +- `Assets/Scripts/Story/StoryDialogueCapture.cs` - 저장과 분리된 실제 대화 IMGUI 촬영 fixture +- `Assets/Scripts/UI/OutfitVisualCapture.cs` - 의상 창·캐시샵·캐릭터 생성 화면의 실제 IMGUI 촬영 fixture(`-battleScenario outfit`) ※의상 소유·장착 PlayerPrefs는 스냅샷으로 복원, 입어보기·필터·확대는 창 상태를 리플렉션으로 주입 +- `Assets/Scripts/UI/BadgeVisualCapture.cs` - 저장과 분리된 실제 배지 획득 연출·배지 케이스 IMGUI 촬영 fixture(`-battleScenario badge`) - `Assets/Editor/BlightSiteDebugMenu.cs` - 오염 거점 육안 확인용 에디터 메뉴(이동·정화·초기화) ## 현재 비주얼 시스템 @@ -102,7 +157,16 @@ Legendary: 금색/주황 경계 밖 수정이 필요하면 변경하지 말고 메인 모델에 보고하여 적절한 에이전트에 재위임. ## 설계 원칙 +- `Assets/Scripts/Battle/BattleVisualCapture.cs` — 저장 없는 독립 전투 화면 QA. 전투 연출 검수 인자: `-battleCamStyle off|punch|cinematic`(같은 장면을 카메라만 바꿔 비교), `-captureInterval 0.05`(히트스톱은 0.1초 간격으론 안 잡힌다), `-battleScenario elements`(속성 10종 임팩트 순환) · `raid-unite`(첫 차례에 합체공격) · `team-duel`(집게 팀 대결 — 상대 교체 등장 두 번) · `raid-forms`(이름 없는 사마귀 — 수문장 등장 컷 + 그림자 변신 두 번) · `guardian-intro`(숲의 수문장 등장 컷만) · `status-fx`(내 곤충 기절·공격 강화 + 상대 독·방어 강화를 한 장면에, 「연속 2!」·「기절 N턴」·「-8 독」) · `signature`(전용기 한 번 — 0.05 간격 권장) · `victory`(1대1 승리 포즈·카메라 반 바퀴, 결과 뒤 3.2초) · `species-motions`(사마귀·장수풍뎅이·호랑나비·일벌·왕거미 기본 공격 차례로 — 내 모델만 갈아 세운다), 레이드 승리는 `raid`의 끝 장면. 소리는 캡처되지 않으니 `BattleVoiceExport`로 WAV를 뽑아 듣는다 +- `Assets/Scripts/Battle/RaidVisualCapture.cs` — 실제 레이드 화면 QA +- `Assets/Editor/BattleVisualCaptureBuilder.cs` — Windows 실제 IMGUI 검수 빌드 +- `Assets/Scripts/Core/SceneryMaterialVisualCapture.cs` — 월드 반투명·발광이 **플레이어 빌드에서** 살아 있는지 수치로 재는 촬영 fixture(`-battleScenario materials`, 줄무늬 벽 앞 구의 (r−b) 편차·발광 휘도차, 안개 Exp2 판 포함, README에 PASS/FAIL) +- `Assets/Editor/ShaderVariantKeepers.cs` — `SceneryMaterials.BuildKeepers` 조합대로 `Resources/ShaderVariantKeepers/*.mat`을 만들어 Standard 반투명·발광 변형을 빌드에 남긴다(`-executeMethod …ShaderVariantKeepers.Ensure`) + 빌드 로그에 `[ShaderVariants]` 변형 수 보고 +- `Assets/Tests/EditMode/ShaderVariantKeeperTests.cs` — 런타임 키워드 조합이 전부 BuildKeepers에 있고 Resources 머티리얼이 그 키워드를 갖는지 - 프리팹 없이 코드로 시각물 생성 (프로시저럴 우선) - CreatePrimitive 기반이지만 성능 주의 (배틀아레나: 24개 돌 구체) - GUI 색상 변경 후 반드시 원래값 복원 - Screen 비율 기반 반응형 레이아웃 + +- `Assets/Scripts/Core/WorldRouteLayout.cs` - 필드 길·단일 입구 공유 경로 정책 +- `Assets/Tests/EditMode/WorldRouteLayoutTests.cs` - 지역 입구와 필드 경로 통행 회귀 검사 diff --git a/.claude/audit-progress.md b/.claude/audit-progress.md index 1031403..f838242 100644 --- a/.claude/audit-progress.md +++ b/.claude/audit-progress.md @@ -393,6 +393,7 @@ - [x] StoryJournalUI 재감사 (P0:0, P1:1, 2026-09-09) — 인덱스 무효화가 StoryBeatCompleted 구독뿐이라 클라우드 반영(ReloadFromDisk)을 못 봄 → SeenCount 스냅샷 키로 교체(구독 제거). P2 처리(행 메타·헤더 문자열 캐시 · textW 하한) - [x] PlayerMovement 재감사 (P0:0, P1:1, 2026-09-09) — 외형 재빌드가 CharacterFaceAnimator 노드 캐시를 안 비워 생성 직후부터 눈 깜빡임 정지 → InvalidateNodes. P2 처리(옛 파츠 한 프레임 원점 노출 차단 · 도구 없는 외형의 매 프레임 Find 고착) - [x] TutorialQuestManager 재감사 (P0:0, P1:2, 2026-09-09) — 완료 퀘스트 재스윕이 Start·리전 이동에만 있어 로그인/계정 전환 뒤 리전을 옮기기 전까지 비트 미발화 → StoryDirector.ReloadFromDisk에서도 재스윕. P2 처리(IReadOnlyCollection + ToArray 스냅샷). 테스트 공백(재스윕 중복 억제)은 걸음 도구가 덮는 경로라 보류 +- [x] StoryVideoDirector (P0:0, P1:1, 2026-09-10) — 프리즈 타이머가 Prepare 시각 기준이라 준비 5s+영상 15s+여유 3s=23s > AutoUnfreezeTime(20s) → OnPrepared에서 재장전. P2 6건 중 5건 처리(Stop 무조건·큐 모달 가드·죽은 aspectRatio·ignoreListenerPause·BGM 더킹·lint 대소문자/미사용) ## Uncovered (우선순위순) @@ -407,6 +408,7 @@ - 2026-09-09: QuestSaveMerge — P0:0, P1:0 처리(clean). P2 3건 보고·보류. InventoryUI P2 3건은 이 라운드에서 처리(컴파일 0 에러). 자체 발견 회귀 0건 - 2026-09-09: TrainingManager 재감사 — P0:0, P1:2 처리. 08-08 이후 디스크 방식·requiredLevel 게이트·누적 훈련이 들어왔는데 ①디스크가 습득 회차에 소모되는 사실을 UI가 어디에도 안 적었고(`FindDiscFor` 호출부 0) ②새 테스트 2파일이 `TrainSkill`을 한 번도 실행하지 않아 실제 방어선 `IsSkillAllowed`·환불·소모가 전부 공백이었다. 인벤토리 두 종을 실제로 붙이고 세이브를 백업/복원하는 왕복 테스트 5건 추가(878/878). QuestSaveMerge P2 3건도 이 라운드에서 처리. 자체 발견 회귀 0건 - 2026-09-09: 잔여 9건 일괄 재감사 — P0:3, P1:15 처리. **수문장 패배 후 영구 재도전 불가**와 **생성 외형 미반영**은 각각 두 Explore가 독립적으로 짚었다(정체성 판정 도입·캐릭터 리워크의 반대편 결함). 신규 테스트 12건, 887/887(2차 실행). 자체 발견 회귀 1건(ResolveSkill 두 번째 null 참조 — 첫 실행 1 실패 → 수정) +- 2026-09-10: StoryVideoDirector(신규 영상 경로) — P0:0, P1:1 처리. P2 6건 중 5건 즉시 처리(AudioManager.SetBgmDuck 신설 포함). 자체 발견 회귀 0건 보류 P2 1건도 처리 — OpeningReplayCoordinator.CanReplay가 다른 모달(컷신·영상·대화) 중엔 false(ModalUIRegistry.IsAnyOpenExcept 신설, 테스트 4건) - 2026-09-09: 2차 후보 3건(StoryJournalUI·PlayerMovement·TutorialQuestManager) — P0:0, P1:4 처리. 오늘 내가 넣은 코드의 반대편 결함이 셋(저널 무효화 경로 누락 · 얼굴 애니메이터 캐시 · 재스윕 시점). 자체 발견 회귀 0건 > 영역별 처리 이력은 위 Covered 인덱스가 이미 갖고 있다. > diff --git a/.claude/rules/agent-coordination.md b/.claude/rules/agent-coordination.md index 30057f8..6aa0415 100644 --- a/.claude/rules/agent-coordination.md +++ b/.claude/rules/agent-coordination.md @@ -58,6 +58,9 @@ description: 공유 파일별 에이전트 수정 경계와 충돌 방지 절차 | `CutsceneDirector.cs` | game-designer | 재생 시점(StoryBeatCompleted), 전투 지연 큐, 프리즈 복귀 | | | visual-dev | 카메라 프레이밍 적용, 딤 강도 | | | ui-dev | 자막 렌더(OnGUI), IModalUI 스택, 건너뛰기 버튼 | +| `StoryVideoLibrary.cs` | game-designer | 어느 비트에 붙일지, 자막 문구·큐 타이밍, 파일명 | +| `StoryVideoDirector.cs` | game-designer | 재생 시점(StoryBeatCompleted), 전투 지연 큐, 프리즈 복귀 | +| | ui-dev | 영상 그리기(OnGUI·cover UV), 자막 렌더, IModalUI 스택, 건너뛰기 버튼(자리는 `StoryVideoScreenLayout`) | | `NpcBossDuels.cs` | game-designer | 상대 곤충·레벨·보상 아이템·재도전 쿨다운 | | | battle-dev | `isFinal`(보스 BGM 분기), 대결 진입 연동 | | `PlayerVisualBuilder.cs` | visual-dev | BuildAll() 슬림 비례, BuildHair*, Accessory 노드 추가, Material/슈더 fallback | diff --git a/.claude/rules/balance.md b/.claude/rules/balance.md index e8776e6..49b062a 100644 --- a/.claude/rules/balance.md +++ b/.claude/rules/balance.md @@ -13,7 +13,15 @@ description: 밸런스 수치 변경 시 체크리스트 및 참조 기준점 - atkMult 범위: 0.3~3.0, defRatio 범위: `MinAtkDefRatio`~`MaxAtkDefRatio` - HP: baseHp + ivHp×2 + Lv×`HpPerLevel` - 기본공격: ATK × 0.7 -- 도주: 50% ± 5%/레벨차 (10%~90%) +- 도주: 50% ± 5%/레벨차 (10%~90%) — 단일 출처 `BattleEscapeRules.Chance`(습격 도망과 공유) +- 치명타: 피해기 한 번마다 `CritChance`(1/16)로 ×`CritMultiplier`(1.5) — 기대 피해 +3.1%, 1v1·레이드 공통(`InsectBattleController.ApplyCritical`). + 버프·회복·독·기절·빗나감·샌드박스·합체공격·피해 추정치는 굴리지 않는다. **명중과 다른 난수 줄기**다(`SetCritSource` — null이면 없음). + 피해·HP를 정확히 단언하는 테스트는 치명타를 꺼야 결정적이다(`SetRandomSeed` 뒤에 `SetCritSource(null)`). + CRITICAL 문구·무거운 타격 연출은 **진짜 치명타일 때만** — 예전엔 "최대 HP 25% 이상"을 표시만 해서 거의 매 타격이 CRITICAL이었다(2026-10-04) +- 레이드 보스 변신(`RaidBossForms` — 지금은 이름 없는 사마귀: HP 66% 이하 호랑나비, 33% 이하 반딧불이의 모습): 속성·기술만 바뀌고 HP·레벨·격노는 그대로, 래치(회복해도 안 돌아감), 쓰러지는 일격엔 변신하지 않는다 +- 낮·밤·날씨 보정: **야생 실외 전투만**(수문장·NPC 대결·샌드박스·레이드·실내 서브에리어 제외, 나의 섬은 적용). + 적과 내 곤충이 **각자의 성향**으로 ATK·DEF에 유리 ×1.10 / 불리 ×0.90, **HP는 그대로**. 날씨는 그 지역에서 보이는 날씨, + 시각은 전투 시작 시점(교체로 들어온 곤충도 같은 하늘). 단일 출처는 적용 범위·문구 `BattleEnvironment`, 배수 `InsectHabits.BattleStatMultiplier` ### 전투 길이 3종은 함께 움직인다 @@ -29,7 +37,7 @@ description: 밸런스 수치 변경 시 체크리스트 및 참조 기준점 `RaidBossHpMultiplier`는 **이 세 값에서 파생된 값**이다. 셋 중 하나라도 건드리면 보스 HP 배율을 다시 계산할 것 — 안 그러면 레이드 길이가 두 배로 늘거나 반토막 난다 -(8.5 → 4.5 재산출이 그 예다). +(8.5 → 4.5 재산출이 그 예다). **치명타 확률·배율도 같은 계열이다** — 치명타 도입으로 4.5 → 4.65(×1.031)가 됐다. ### 범용 훈련기 위력 상한 @@ -41,9 +49,21 @@ description: 밸런스 수치 변경 시 체크리스트 및 참조 기준점 ## 포획 기준점 - 기본 60%, 레어도당 -8%, 난이도×0.4 감소 - 레벨 보정: 높으면 +2%/lv, 낮으면 -3%/lv. **레벨 차는 ±5로 clamp** - (미니게임·전투 두 경로가 같은 상한을 쓴다 — `MaximumLevelDelta`) + (미니게임·전투 두 경로가 같은 상한을 쓴다 — `MaximumLevelDelta`). 레벨은 **캐릭터(트레이너) 레벨 대 곤충 레벨**이다. - 퍼펙트 타이밍: +15% - 등급별 최저 보장(floor): C .30 / U .22 / R .14 / E .08 / L .04 +- **레벨 제한**: 곤충이 캐릭터보다 +5(`CaptureGraceLevels`)를 넘게 높으면 1레벨마다 **최종 확률**을 −10%씩 + 곱으로 깎는다(최저 ×0.05). +10이면 절반, +15 이상이면 퍼펙트·아이템을 다 얹어도 2~3%다. 단일 출처는 + `TrainerLevelGap.CaptureMultiplier`이고 두 경로가 함께 쓴다. 전투 경로의 최저 10%도 이 아래로 내려간다. + 레이드는 승리 = 확정 포획이라 제외다. + +### 레벨 제한은 덧셈이 아니라 마지막 곱이다 + +±5 clamp와 floor 때문에 덧셈 보정은 +5 너머를 못 본다 — 2026-10-01 전엔 +5와 +20이 같은 확률이라 +**20레벨 높은 전설이 퍼펙트 미니게임 34%, 일반이 66%**로 잡혔다. clamp를 넓히는 건 답이 아니다: +floor가 먼저 깔리고 보너스(미니게임 최대 +30%·아이템·의상)가 그 위에 얹히므로 코어를 아무리 깎아도 +floor+보너스가 남는다. 그래서 전부 더한 뒤 곱한다. `CaptureGraceLevels`는 `MaximumLevelDelta`와 +같아야 한다(어긋나면 그 사이가 평평해진다 — `CaptureGraceLevels_MatchesAdditiveLevelCap`). ### base를 내리려면 floor를 함께 봐야 한다 @@ -60,28 +80,99 @@ floor를 같이 도입했기 때문에 **단순 복원은 되돌리기가 아니 `CaptureChanceCalculatorTests`의 `GameplayTuningProfile_NewAssetDefaults_MatchCaptureFormulaDefaults`는 프로파일과 공식의 **일치**만 본다. 두 값이 같다고 옳은 값인 건 아니다(위 원복을 이 테스트가 가렸다). -상한이 필요한 이유: 메인 필드는 리전이 아무리 높아도 **항상 Lv.1부터** 스폰한다 -(`InsectSpawner` 지수 분포). 상한이 없으면 고레벨 플레이어가 저레벨 전설을 만났을 때 -레벨 보정만으로 등급·난이도 페널티가 전부 상쇄돼 포획이 사실상 보장된다. +상한이 필요한 이유: 고레벨 플레이어가 앞 리전(초원 Lv.1~10)으로 돌아가 저레벨 전설을 만나면 +레벨 보정만으로 등급·난이도 페널티가 전부 상쇄돼 포획이 사실상 보장된다. (2026-09-29 전엔 메인 필드가 +리전과 무관하게 **항상 Lv.1부터** 스폰해서 어느 리전에서나 그랬다 — 지금은 리전 대역 안이다. 아래 스폰 기준점.) + +## 필드 스폰 기준점 + +단일 출처는 `FieldSpawnRules`(순수 규칙)와 `InsectSpawner`(슬롯 기록·실체화)다. + +- **희귀도는 리전과 무관, 레벨만 리전별.** 등급을 전역 표(`…Share` 다섯 상수)로 먼저 굴리고 종은 그 등급 안에서 + 리전 풀의 `spawnWeight`로 고른다. 풀에 그 등급이 없으면 가까운 아래 → 위 등급으로 대체한다(안전망 — + 모든 리전 풀이 다섯 등급을 다 갖는 게 전제다). 레어 부스트(아이템·의상)는 희귀 이상 몫에만 곱한다. +- 레벨: 리전 대역 [`requiredLevel`, `requiredLevel + GetRegionLevelRange`] 안에서 지수 `FieldLevelPower`(낮은 쪽이 + 조금 더 흔함), **등급 보정 없음**. 서브에리어는 min~max 균등. +- **시간 기반 재생.** 리전마다 설 수 있는 땅 × 밀도만큼 슬롯이 있고(오염 거점은 `BlightPolicy.MaxActiveFor`로 감소) + 리전을 오가도 다시 굴리지 않는다. 잡거나 이기거나 놓친 자리는 재생 지연 뒤, 수명이 다한 개체는 플레이어 눈 밖에서 + 그 시점의 시간대·날씨로 바뀐다. **등급표를 바꾸면 `progression_sim`을 다시 돌릴 것** — 리전 income이 전부 이 표에서 나온다 + (예전엔 뒤 리전 풀이 고레어라 income이 올랐다. 지금은 풀이 다섯 등급을 다 갖는 한 전 리전이 같다). ## 보상 기준점 - 배율: C1.0/U1.2/R1.5/E2.0/L2.8 - 레이드: ×3 +- 간부 팀 대결(집게·저울 3마리, 하월 5마리 — `NpcBossDuels` 팀 배열): **이겼을 때만** 상대마다 한 마리 대결과 같은 식으로 캔디·EXP를 계산해 합산, 코인은 3×상대 수. 지면 쓰러뜨린 몫도 0이고 재도전은 첫 곤충부터다(`InsectBattleController.StartTeamDuel`, `TeamDuelTests`). + 간부전 하나가 한 마리 대결의 3~5배 길이·보상이 되므로, 팀 크기나 레벨을 바꾸면 `TeamDuelTests`의 거울전 로그(`[TeamDuel]`)로 길이를 다시 볼 것 - 가챠 천장: 같은 상자 `GachaBoxManager.PityLegendaryPulls`(80)연째 Legendary 확정. 상자별 카운터는 계정 스코프 PlayerPrefs + 클라우드(기기 간 **max** 병합). 없을 땐 브론즈 100연 전설 0개가 60%였다. - 가챠 샤이니 `GachaBoxManager.ShinyChance`(2%) — 필드 1%보다 높아야 상자만의 매력이 선다. 골드 보너스 캔디 45~80은 Lv30 레벨업비의 절반 이상이어야 한다(gacha_sim 신호 5). **OpenBox의 `case "box_X":` 세 개를 접지 말 것** — 추출기가 그 뒤의 Random.Range를 읽는다. +## 캐릭터 EXP 기준점 + +단일 출처는 `TrainerLevelGap`(식)과 `GameConstants.TrainerLevel`(계수), 지급은 +`InsectRewardCalculator.GetExpReward(data, insectLevel, trainerLevel)`이다. 시뮬은 `progression_sim`의 「캐릭터 EXP」 절. + +- EXP = 등급 base × 등급 배율 × **(1 + (곤충Lv−1)×0.05)** × **레벨 차 배율** + - 곤충이 캐릭터보다 높으면 +10%/Lv(최대 +10 → ×2), 낮으면 −10%/Lv(최저 ×0.2, −8부터) +- 포획 성공·야생 전투 승리·NPC 대결·레이드(×3)가 같은 식이다. 스토리·퀘스트 보상 EXP는 고정값 그대로. +- 캐릭터 레벨은 **지급 전** 값으로 잰다 — 포획 확률·EXP·팝업 표시가 같은 레벨 차를 본다 + (팝업은 공식을 다시 돌리지 않고 `CaptureController.LastExpReward`를 읽는다). +- 2026-10-01 전엔 등급만 봐서 Lv60 일반 곤충도 Lv1과 같은 5였다. 같은 레벨 사냥의 캐릭터 1레벨당 조우가 + Lv5 13회 → Lv60 107회로 불었고, 후반 캐릭터가 리전 권장 레벨보다 5~7 낮았다(포획 레벨 제한과 만나면 막힌다). + 지금은 10회 → 26회(신호 5, 4배 이하). +- **차 배율의 감산 쪽이 조절기다.** 0.05/×0.5로는 사냥량 3배에서 캐릭터가 리전보다 20레벨 넘게 앞섰다 — + 약한 곤충 EXP가 충분히 줄어야 많이 사냥해도 +11~13에서 멈춘다. 감산을 약하게 바꾸려면 시뮬부터 돌릴 것. + ## 성장 기준점 -- 곤충 캔디 곡선: `InsectLevelCurve.GetCandyCost` = base 4 × 1.125^(L-1). **판정은 `progression_sim`** - (Lv50까지 현실 진행 4,000전투 미만). 14%였을 때 5,759전투로 FAIL, 12.5%로 3,339. 13%는 4,028로 - 경계다 — 기울기를 올릴 땐 시뮬을 먼저 돌린다. +- 곤충 레벨업 캔디: **실제로 빠지는 식은 `GameConstants.Leveling`의 폴백 선형** `4 + 2×(Lv−1)`, 최대 Lv80 + (Lv1→50 누적 2,548 · Lv1→80 6,478). `InsectLevelCurve` SO의 지수식(4×1.125^)은 **배선되지 않은 죽은 코드**다 — + 곡선 에셋이 없고 InsectData·컬렉션이 런타임 생성이라 `levelCurve`/`defaultCurve`가 늘 null이다. + **판정은 `progression_sim`**(현실 진행 4,000전투 미만)이고, 시뮬은 `game_facts._level_curve_wired`로 + 실제 배선을 보고 곡선을 고른다. +- 2026-09-30까지 이 절과 시뮬은 죽은 지수식을 기준으로 적혀 있었다(09-09의 14%→12.5% 조정도 그 식 위였다). + Lv50 한 레벨이 실제 102캔디인데 1,284로 판정했다. 지수식을 **켜려면** 레벨 범위가 80까지라는 걸 먼저 볼 것 — + 1.125^로는 Lv70 한 레벨이 13,539캔디다. + +## 훈련 기준점 + +단일 출처는 `TrainingPricing`(식)과 `GameConstants.Training`(계수)이다. 시뮬은 `progression_sim`의 +「성장 훈련」 절(참고 지표)이다. + +- **기술 가치** = 피해기 `위력 × 명중`, 상태기 `효과 × 지속턴 × 30`(공격 상승·하락·방어), 회복 `비율 × 100`, + 독 `턴당 피해 × 지속`, 기절 `30`. 회당 캔디 `가치/2 + 해금 레벨`(최소 5), 필요 횟수 `1 + 가치/12`(1~5). + **피해기 가격은 옛 `power` 공식과 같다** — 바뀐 건 상태기와 손 상수였던 종족기·전용기다. + 옛 식은 상태기의 위력 칸(1)을 가격으로 읽어 광폭화(공격 +60%, Lv26)가 **26캔디 1회**, 파멸의 독침이 216캔디였다 + (지금 176 대 216). 종족기 단가는 learnset의 해금 레벨로 굽는다(`CreateLearnableSkill`). +- **능력치(개체값) +1** = `20 × 1.2^현재값 × 등급배율`(보상 배율 C1.0~L2.8을 그대로 쓴다). 최대 15. + 평균 일반 개체(5/5/5) → S급(14/14/13)이 약 2,900캔디(전투 ~950회)로 **Lv1→50 레벨업 전부(2,548)보다 크다** — + "댓가가 커야 한다"가 요구였다. 전설은 약 8,700. 등급 경계(S 41·A 32·B 23·C 14)는 `PlayerInsectData.GradeForSum`. +- 레벨업은 훈련소와 보유 곤충 창이 **같은 함수**(`GetCandyCostForLevel`)를 읽는다 — 두 곳 값이 갈리면 싼 쪽만 쓰인다. +- 계수를 바꾸면 `TrainingPricingTests.AverageCommon_ToGradeS_CostsMoreThanLevelingToFifty`가 대역(2,500~3,300)을 본다. + +## 섬 기준점 + +단일 출처는 `IslandYield`(식)·`GameConstants.Island`(계수)·`IslandCatalog`(물건 가격·효과)다. 시뮬은 `economy_sim`의 신호 6·7. + +- 캔디/h = `CandyPerInsectHour`(0.25) × **섬 등급 배율** × (1 + 쾌적도 보너스 + 설비 보너스) × (1 + 친밀도 보너스). + 코인/h는 등급을 보지 않는다(코인은 꾸미기 재화라 수집 깊이와 묶지 않는다). +- **섬 등급 배율은 보상 배율(C1.0~L2.8)이 아니다** — C1.0 / U1.1 / R1.25 / E1.4 / L1.6. 보상 배율을 그대로 쓰면 + 전설 10마리 섬이 하루 300캔디를 넘겨 평소 활동 수입(약 156)의 두 배가 된다. +- **대역**: 기본 섬(일반 3마리) 하루 18 = 활동 수입의 12%(상한 20%), 풀 확장 섬(전설 10마리·보너스 전부) 하루 약 177 = 113% + (상한 125%). 섬만 돌려도 되는 게임이 되면 안 된다 — `IslandYieldTests`와 `economy_sim`이 같은 대역을 본다. +- 쾌적도는 같은 물건 첫 개 전부 · 둘째 절반 · 셋째부터 0. 설비 효과는 같은 id를 몇 개 놓든 한 번. 도배로는 안 오른다. +- 누적 상한 8시간(창고·바구니로 최대 16). **수확 없이 방치하면 멈춘다** — 그게 섬에 들르게 하는 장치다. +- 섬 퀘스트의 코인 보상은 낮게 둔다. 수확은 한두 시간마다 할 수 있어, 반복 퀘스트(`s_island_keeper`)를 후하게 주면 + 섬이 만드는 코인보다 퀘스트가 주는 코인이 커진다. +- 다이아는 보상으로 줄 수 없다(서버 규칙 — `rules/island.md`). ## 변경 시 체크리스트 1. GameConstants 상수 변경 → 전체 시스템 영향, `/impact-analysis` 실행 2. 데미지 공식 변경 → 1v1 + 레이드 양쪽 확인, `/balance-sim battle` 실행 -3. 포획률 변경 → 미니게임 난이도와 균형, `/balance-sim capture` 실행 +3. 포획률 변경 → 미니게임 난이도와 균형, `/balance-sim capture` 실행. 레벨 제한(`TrainerLevelGap`)을 + 바꾸면 캐릭터 EXP 진행(`progression_sim` 신호 5)도 함께 본다 — 캐릭터가 리전을 못 따라가면 제한에 걸린다 4. 보상 변경 → 경제 밸런스 (레벨업 비용과 비교) -5. IV 공식 변경 → 스탯 범위 + 등급 분포 재계산 -6. 변경 후 관련 EditMode 테스트 실행 필수 (`/test`) +5. IV 공식 변경 → 스탯 범위 + 등급 분포 재계산(능력치 훈련 비용 `TrainingPricing.StatCost`도 함께 본다) +6. 섬 생산 계수·등급 배율·생산 설비 변경 → `economy_sim` 신호 6·7과 `IslandYieldTests` 대역 +7. 변경 후 관련 EditMode 테스트 실행 필수 (`/test`) diff --git a/.claude/rules/dream-prologue.md b/.claude/rules/dream-prologue.md new file mode 100644 index 0000000..80fd81f --- /dev/null +++ b/.claude/rules/dream-prologue.md @@ -0,0 +1,92 @@ +--- +description: 「챔피언의 꿈」 프롤로그 — 꿈 밖을 건드리지 않는 약속, 표지(DreamPrologueState)를 읽어야 하는 자리, 샌드박스 전투 +--- + +# 「챔피언의 꿈」 프롤로그 규칙 + +새 계정이 처음 필드에 서면(첫 퀘스트 `q_move`가 진행 0으로 활성) 1.5초 뒤 한 번 도는 연출이다. 설정의 「챔피언의 꿈 다시 보기」로 +언제든 다시 본다. 흐름은 **경기장 입장 영상 → 챔피언전 → 챔피언의 섬 → 깨어남**이다. 지휘는 `DreamPrologueDirector`(World/), +저작 데이터는 `DreamPrologueData`, 시작 조건·표지는 `DreamPrologueState`, 도입 영상 재생기는 `DreamIntroVideo`. + +## 약속 하나: 꿈이 끝났을 때 이 계정의 어떤 기록도 시작 전과 같다 + +꿈속의 전투·걸음·곤충은 퀘스트·스토리·재화·도감·컬렉션·섬 어디에도 남지 않는다. `DreamPrologueIntegrationTests`가 실제 PlayScene에서 +꿈을 끝까지 돌리고 그 전후를 비교한다 — **이 테스트가 이 규칙의 단일 출처다.** 실패는 전부 조용하다(예외도 경고도 없이 챔피언전 승리가 +"첫 전투!"를 깨거나, 섬 걷기가 "첫 걸음!"을 채운다). + +## `DreamPrologueState.Active`를 읽어야 하는 자리 + +켠 쪽(디렉터)이 끌 길이 여럿이라(끝까지·건너뛰기·오류·씬 재로드) 이벤트가 아니라 정적 표지 하나다. 읽는 자리: + +| 자리 | 하는 일 | +|---|---| +| `TutorialQuestManager` (`NotifyAction`·`NotifyCapture`·`Update`) | 꿈속의 행동·걸음을 세지 않는다 | +| `StoryObjectiveTracker.TryAutoStartFirstObjective` | 꿈속에서 본 어르신을 향해 자동 주행하지 않는다 | +| `PlayerStatusHUD`·`QuickAccessBarUI`·`KeyGuideHUD`·`MinimapUI`·`TutorialQuestUI`·`WorldClockHUD` | 필드 HUD를 숨긴다 | +| `SubAreaEnvironment`·`WeatherEffects` | 낮밤·날씨 하늘 보정과 날씨 입자를 끈다 — 꿈은 늘 맑은 한낮이다(`rules/world-environment.md`) | +| `AccountSettingsUI` | 설정 버튼을 숨긴다 | +| `OpeningReplayCoordinator.CanReplay` | 꿈 도중에 오프닝 다시보기를 막는다 | + +**필드 위에 그리는 HUD를 새로 만들면 이 표지를 확인할 것** — 안 하면 꿈속에 그 HUD가 비치고, 버튼이면 꿈 밖 메뉴가 열린다. +(모달 레지스트리로 대신 못 하는 이유: 모달이 열려 있으면 `PlayerMovement`가 이동 입력을 버린다. 섬 단계는 걸어 다녀야 한다.) + +## 샌드박스 전투 + +`InsectBattleController.StartSandbox`가 연다. 월드 개체 없이 데이터만으로 붙고, **보상·도감·HP 저장·포획 롤·`DuelEnded`가 전부 없다.** +규칙(`SandboxBattleRules`)은 공식이 낸 피해에 배율과 상하한만 건다 — 반드시 이기고(내 곤충 HP 하한 30%) 3~4번째 행동에 끝난다. + +- **`BattleEnded`는 그대로 쏜다**(결과 화면이 그 신호로 뜬다). 대신 구독자 중 이야기를 움직이는 쪽(`StoryDirector.OnBattleEnded`)이 + `IsSandbox`를 보고 물러난다. 퀘스트는 위 표지로 막힌다. **`BattleEnded`를 듣는 새 코드를 쓰면 샌드박스를 어떻게 다룰지 정할 것.** +- `IsSandbox`는 **다음 전투를 시작할 때까지 값을 지킨다**(`BattleEnded` 시점에도 유효). `BeginBattleCommon`이 매번 끈다 — + 표지가 남으면 이후 모든 전투가 보상 0·`BattleWin` 없음이 된다(`NextOrdinaryBattle_DoesNotInheritTheSandboxFlag`). +- 실제 DB로 길이를 잰다(`RealSandboxBattle_WinsInThreeToFourActions_WithoutFalling`) — 곤충 능력치·기술이 바뀌어도 규칙이 길이를 지킨다. +- **결과 화면은 샌드박스만 저절로 닫힌다**(4초 — `BattleResultRules.ShouldAutoClose`). 다른 전투는 탭·Space/Enter로만 닫힌다. 지휘자가 결과 화면이 + 닫히는 것(`IsBattleActive`)을 신호로 섬에 넘어가고 상한이 14초라, 이 예외를 지우면 결과 화면이 떠 있는 채로 섬이 열린다(`Screen_DreamChampionResult_ClosesByItself`). + +## 섬 + +꾸며진 섬은 **남의 섬 방문(`EnterVisit`)의 스냅샷**이다. 새 지형 코드가 없고 저장도 안 건드린다. `IslandWorldBuilder.DreamMode`가 켜지면 +섬 HUD가 숨고 방문 통지(퀘스트)가 가지 않는다. 배치(`DreamPrologueData.IslandObjects`)를 고치면 겹침·경계·입구→분수 통로가 테스트로 잡힌다 — +**정리(`SanitizeSnapshot`)에서 물건이 조용히 빠지는 것**이 실제 위험이다(분수 없는 분수 광장). + +## 도입 영상 — 경기장 입장 8초 + +처음엔 검은 화면에 글자 카드뿐이었다(3.6초). 게임을 켜고 처음 보는 연출이라 **영상으로 바꿨다**: 선수 입장 터널을 걷는 챔피언의 +뒷모습 → 흰 섬광 → 환호하는 관중석 → 서치라이트 아래 마주 선 파트너와 상대 → 맞대결 번쩍(6.1초) → 암전. 이어서 기존 글자 카드 +(「챔피언 결정전」·이름·vs 상대)가 2.4초 뜨고 챔피언전이 열린다. + +| 파일 | 만드는 스크립트 | +|---|---| +| `StreamingAssets/Video/dream_arena.mp4` (1280×720·24fps·무음·H.264 Main) | `python -X utf8 Tools/Video/dream_arena.py` | +| `Resources/Audio/Dream/dream_arena.wav` (44.1kHz 스테레오·8초) | `python -X utf8 Tools/Video/dream_arena_audio.py` | + +- **오프닝 프롤로그와 같은 방식이다 — 무음 영상 + 별도 음원을 한 시계로 튼다.** 영상이 못 떠도 시계·소리가 어긋나지 않고, + 건너뛰기가 시계 하나로 끝난다. 시각표(`T_TOTAL`·`TUNNEL_END`·`FACEOFF`…)의 단일 출처는 영상 스크립트이고 음원 스크립트가 모듈로 읽는다. + 게임 쪽 `DreamPrologueData.IntroSeconds`는 그 길이의 사본이라 `DreamIntroVideoTests`가 mp4·wav **헤더**로 맞춰 본다(디코더 불필요). +- **영상이 못 뜨는 모든 길이 글자 카드로 모인다.** `DreamIntroVideo`의 `Failed`/`Ended` 둘뿐이다 — 파일 없음·디코더 오류·준비 3초 초과· + 재생 중 1.2초 멈춤. 꿈은 영상 없이도 선다(이때는 예전처럼 페이드 0.8초 → 카드 2.8초). +- **세로 화면은 16:9 마스터를 가운데 cover-crop한다**(약 32%만 보인다 — 가로 폭의 56%가 아니다). 맞대결 쌍(뿔 ↔ 머리)을 + **화면 한가운데**에 두었다. 챔피언은 곁가지라 세로에선 잘려도 이야기가 선다. 배치를 바꾸면 세로 크롭부터 확인할 것. +- 영상에 글자를 굽지 않는다(카드는 게임이 얹는다). 재생 중 월드 BGM은 `SetBgmDuck`으로 낮추고 `Stop`이 원복한다 — + `Finish`가 `Stop`을 부르므로 어떤 종료 길이든 더킹이 남지 않는다. +- 실기기 확인 대상: 디코더(준비 시간·첫 프레임)·소리 길이·세로 크롭. 배치모드·PlayMode 러너는 영상을 못 튼다(`rules/testing.md`). + Windows QA 빌드는 튼다 — `-battleScenario dream-island`의 첫 장면이 실제 영상이다. + +## 종료는 한 곳을 지난다 + +`DreamPrologueDirector.Finish`가 표지·섬·프리즈·BGM·도입 영상·"봤다" 기록을 한꺼번에 정리한다. OnDisable/OnDestroy도 같은 곳을 지난다. +끝까지 보았거나 건너뛰었을 때만 "봤다"(`PlayerPrefs`, 계정 스코프)를 적는다 — 오류·씬 재로드로 끊긴 것은 다음에 다시 보여 준다. + +## 검증 + +``` +# 규칙·데이터·샌드박스·실제 DB (36건) + 실제 PlayScene 통합 (1건, 약 45초) + 도입 영상 파일·헤더 (9건) +-testPlatform PlayMode -testFilter InsectGame.Tests.DreamPrologueTests +-testPlatform PlayMode -testFilter InsectGame.Tests.DreamPrologueIntegrationTests +-testPlatform PlayMode -testFilter InsectGame.Tests.DreamIntroVideoTests +``` + +화면은 QA 빌드로 본다(IMGUI라 배치 캡처에 안 잡힌다): `-battleScenario dream-island`(도입 **영상**·카드·섬·안내·깨어남), `dream-battle`(챔피언전 안내·결과). +`dream-island`의 `00-video-*`는 **진짜 영상을 실시간으로 틀어** 영상 시각(1.2·2.6·3.5·4.8·6.15·6.5초)에 찍는다 — README 첫 줄이 +"played for real"인지 확인할 것(디코더가 없으면 "did NOT play"로 나오고 카드만 찍힌다). 출력 폴더는 **비어 있어야** 한다. +가로(1280×720)와 세로(720×1280)를 둘 다 찍을 것 — 세로에서는 전투 HP 카드가 폭을 다 써서 안내 위치가 갈린다(2026-10-03). diff --git a/.claude/rules/island.md b/.claude/rules/island.md new file mode 100644 index 0000000..eb62933 --- /dev/null +++ b/.claude/rules/island.md @@ -0,0 +1,155 @@ +# 나의 섬 규칙 + +개인 섬 하우징. 기획·구조의 배경은 `Docs/IslandDesign.md`, 수치는 코드(`GameConstants.Island`·`IslandCatalog`)가 단일 출처다. + +## 섬은 「분리 서브에리어」로 탄다 — 별개 상태를 만들지 않는다 + +"분리된 공간에 있다"는 판정이 전부 `RegionManager.CurrentSubArea != null`에 걸려 있다(플레이어 접지·끼임 복구, +스포너 필드 틱, 미니맵·지도, 환경광, 오염 VFX). 섬을 별개 상태로 두면 그 전부를 따로 고쳐야 하고, 빠뜨리면 +섬을 오갈 때마다 `RegionChanged`가 다시 울려 **방문 퀘스트·스토리 RegionEnter·BGM이 왕복마다 재발화**한다. + +그래서 섬은 `SubAreaData.detached = true`인 합성 구역(id `player_island`)이고 +`RegionManager.EnterDetachedSubArea`로만 들어간다. 그 대가로 **`detached`를 걸러야 하는 자리**가 생긴다: + +| 자리 | 안 거르면 | +|---|---| +| `SubAreaWorldBuilder.OnSubAreaChanged` | 섬에 들어갈 때 (2000,·,2000)에 빈 방을 짓고 25m 자동 이탈이 돈다 | +| `TutorialQuestManager.OnSubAreaChanged` | 섬 방문이 `q_subarea`(숨겨진 장소)를 깬다 | +| `PlayerMovement.RecoverToSafePosition` | 끼임 복구가 (2000,·,2000) 허공으로 보낸다 | +| `SubAreaEnvironment` 프로필 switch | 섬이 동굴 기본 조명으로 뜬다 | + +`SubAreaChanged`를 새로 구독하는 코드를 쓴다면 섬도 그 이벤트를 탄다는 걸 기억할 것. + +## 모양과 콜라이더는 나뉘어 있다 + +`IslandTerrainBuilder`·`IslandObjectBuilder`(visual-dev)는 **콜라이더를 남기지 않는다.** 밟는 땅·경계 벽·물건 차단은 +`IslandWorldBuilder`가 따로 단다. 플레이어 접지와 클릭-이동이 콜라이더를 보므로, 장식에 콜라이더가 남으면 +지붕 위로 걸어 올라가거나 모래톱·바다로 걸어 나간다. + +머티리얼은 `IslandMaterialCache`로만 만든다(색이 같으면 같은 머티리얼 — 섬 전체가 수십 개로 끝난다). +`new Material`·`Shader.Find` 금지: 빌드에 변형이 남는 키워드 조합은 `SceneryMaterials`가 쥐고 있다. + +오브젝트 이름을 `Region_`·`Scenery_`·`Path_`·`SubArea_`·`Boundary_` 등으로 시작하지 않는다 — +`SubAreaWorldBuilder.HideMainWorld`가 그 접두어를 끈다(본 마을 나루터 `IslandDock`이 그 대상이 될 뻔했다). + +## 물건을 늘릴 때 — 3곳 + +1. `IslandCatalog.Build()`에 한 줄(가격은 코인·다이아 중 **하나만**). +2. `IslandObjectBuilder`의 switch에 모델. 빠뜨리면 **상자 모양 폴백으로 조용히 놓인다** — `IslandCatalogTests.EveryObject_HasItsOwnModel`이 잡는다. +3. id 형식 `^[a-z][a-z0-9_]{1,31}$`. 서버(`functions/island.js`)가 그 형식만 받는다 — 어긋나면 공개할 때 그 물건이 빠진다. + +효과(`IslandEffectKind`)를 늘리면 `IslandYield.CollectEffects`와 `IslandUiKit.EffectText` 두 switch를 함께. +생산 설비를 늘리면 `IslandYieldTests`의 대역 검사와 `economy_sim` 신호 6·7이 최대 수입을 다시 잰다. + +## 세이브 — 정산을 먼저, 저장을 지급보다 먼저 + +- **생산에 영향을 주는 변경 전에 `Settle()`** — 안 그러면 방금 놓은 온실이 지난 여덟 시간에도 있었던 것처럼 계산된다. + `IslandManager`의 TryPlace·Store·TryRelease·Unrelease가 전부 그렇게 한다. 새 변경 경로를 만들면 따라 할 것. +- 수확은 **저장 → 지급** 순서다. 반대면 지급 뒤 저장 전에 죽었을 때 같은 수확물을 다시 받는다. +- `Awake`에서 저장하지 않는다. 로그인 전이라 전역 경로에 island.json을 만들어 버린다. +- 방목 목록은 `instanceId`다. 보유 목록에서 사라진 id는 **읽을 때 걸러내고**, 지우는 건 사용자가 곤충 창에서 + 조작하는 시점에만 한다 — 부트 중에 지우면 로그인 전 빈 목록을 보고 전부 지운다. +- 카탈로그가 모르는 물건 id는 **지우지 않는다**(더 새 버전에서 산 물건). 그리기·효과·겹침 검사가 건너뛸 뿐이다. + +## 다이아는 늘릴 수 없다 + +`firestore.rules`가 클라이언트의 다이아 증가를 거부한다 — 로컬 다이아가 클라우드보다 커지면 **세이브 업로드 전체가 막힌다.** +그래서 되팔기 환급도, 퀘스트·수확의 다이아 보상도 없다. 회수한 물건은 보관함으로 돌아간다. +다이아 결제 뒤에는 소유권을 적고 `SaveToCloud()`를 한 번 더 부른다(`SpendGems`가 부른 저장은 소유권을 적기 전 스냅샷이다). + +## 공유 — 서버 없이도 섬은 돈다 + +방문·좋아요는 Cloud Function `socialPvpApi`의 액션(`publishIsland`·`getIsland`·`likeIsland`·`getMyIsland`·`deleteIsland`)이고 +저장은 `islands/{uid}`다. 규격은 `Docs/SocialPvp.md` 「섬 공유」. 타인 문서는 규칙상 클라이언트가 직접 못 읽는다. + +**운영에는 `socialPvpApi`가 배포돼 있지 않다**(2026-10-02 확인 — 함수 0건, 주소 404. 같은 날 배포하지 않기로 정했다). +"방문이 안 된다"·"`s_island_visit`이 안 깨진다"는 보고는 버그가 아니라 이 상태다. 섬만 따로 켤 수 없다 — +배포하면 친구·PvP·월드 채널이 함께 켜진다(`Docs/SocialPvp.md` 「배포」). + +- 받은 스냅샷은 **남이 만든 데이터**다 — `IslandSaveRules.SanitizeSnapshot`이 모르는 물건·경계 밖·겹침을 버린다. +- 올리는 건 `BuildSnapshot`의 축약본뿐이다(instanceId·재화 없음). +- 계정 삭제는 Auth 계정을 지우기 **전에** `deleteIsland`를 부른다(`AuthManager.DeleteAccountCoroutine`) — 지운 뒤엔 토큰이 없다. + +## 화면 + +- `IslandHudUI`는 **비모달**이다 → 매 OnGUI `FieldHudInput.RegisterBlockingRect`(rules/ui-layout.md). + 모바일에서는 좌하단 사분면(가상 조이스틱 시작 영역)에 버튼을 두지 않는다. +- 자리는 `IslandHudLayout`(순수 계산)이 정하고 시각·날씨 칩(`WorldClockHUD`)이 같은 계산으로 피해 간다. **가로 모바일은 단축 바 왼쪽 두 칸 판** + (높이를 단축 바와 같게) — 세로 열(7줄)을 단축 바 아래에 세우면 화면 85%까지 내려와 우하단 잡기 버튼을 덮는다(밤·비 손님 곤충 때문에 + 섬에서도 잡기 버튼이 뜬다). 수확·좋아요 토스트는 **가운데 무대**(`HudStage` — 포획 결과·퀘스트 완료와 한 자리에서 차례로 선다, + rules/ui-layout.md). `HudOverlapSweepTests`가 섬 화면(내 섬·남의 섬)의 모든 HUD 쌍을 화면 18장에서 잰다. +- `IslandEditUI`는 **모달**이다. 지면 탭이 「칸 고르기」인데 같은 탭을 `PlayerMovement`가 클릭-이동으로도 읽기 때문이다. + 그 위에 뜨는 안내 배너는 자기 자리를 `FieldHudInput`에 등록하고, 꾸미기 화면이 그걸 보고 탭을 양보한다 + (OnGUI 호출 순서가 정해져 있지 않아 먼저 도는 쪽이 탭을 먹는다). +- 섬 화면은 이벤트를 구독하지 않고 상태를 읽는다 — UI 루트가 꺼졌다 켜질 때 구독이 사라지는 계열(subscription_lint)을 피한다. +- 안내 배너(`IslandGuideUI`)는 ✕나 12초 경과로 사라진다. **닫는 것은 표시만이다** — 단계는 실제 행동으로만 넘어가고 + (다시 보기만 ✕가 끝낸다: `EndGuideReplay`), 다음 단계·섬 재진입 때 다시 뜬다. 모바일 필드에서는 화면 가운데 줄(캐릭터 발 아래)에 + 놓는다 — 좌상단은 미니맵·퀘스트 칩 자리라 그리기 순서가 정해지지 않은 IMGUI에서 배너가 그 밑에 깔렸다(2026-10-02). + **검수 fixture에 없는 HUD와의 겹침은 캡처로 안 보인다** — `island-ui`의 안내 장면에 진짜 미니맵과 퀘스트 칩 자리 대역을 함께 띄우는 이유다. + +## 손님 곤충 — 밤·비·안개에 찾아오는 야생 곤충 + +규칙은 `IslandGuestRules`(순수), 기록·몸은 `IslandWorldBuilder.Guests.cs`. **필드 스폰의 순수 함수를 그대로 다시 쓴다** — 등급표·레벨·재생 지연·보너스 수를 +섬에서 새로 만들지 않는다(`FieldSpawnRules`). **섬 수입과는 무관하다**(사용자 결정 — 섬 생산 보너스 없음, `economy_sim` 신호 6·7은 그대로다). + +| 무엇 | 규칙 | 출처 | +|---|---|---| +| 수 | 밤 +1, 비·안개 +1, 최대 2 — 세계 날씨(섬은 지역이 없다) | `FieldSpawnRules.BonusSlots` | +| 후보 | **해금된 리전**(`RegionManager.IsRegionAccessible`) 풀의 합집합 ∩ 지금 시간·날씨에 나오는 종. 비면 합집합 전체 | `InsectSpawner.CopyFieldRegionTables` | +| 등급 | 전역 표로 먼저(부스트 없음). **희귀까지** — 영웅·전설은 후보에서 빼고 그 몫(4%)은 기존 대체 규칙대로 희귀로 내려온다(희귀 11% → 15%) | `FieldSpawnRules.PickRarity` | +| 종 | 그 등급 안에서 `spawnWeight × InsectHabits.SpawnWeightMultiplier` | `FieldSpawnRules.PickWeighted` | +| 레벨 | 그 종이 사는 해금 리전 중 **가장 낮은** 리전의 대역 | `FieldSpawnRules.RollFieldLevel` | +| 재생 | 잡기·이기기·놓침 뒤 60~120초, 수명 4~7분, 조건이 막 시작되면 도착을 5~60초에 흩는다 | `RespawnDelay`·`Lifetime`·`PhaseSwapDelay` | + +- **진짜 야생 개체다** — `InsectEntity.Initialize`(야생 경로)로 세운다. 그래서 잡기 버튼 → 포획 선택 창(미니게임·배틀) → 포획 보상·도감 등록·퀘스트 통지가 + 필드와 같은 길을 탄다. 방목 곤충(`BuildForBattle` — AI 없음·포획 불가)과 섞지 말 것. +- **레벨 대역을 왜 그 종의 가장 낮은 해금 리전에서 잡나**: 가장 높은 해금 리전을 쓰면 섬이 고레벨 사냥터가 되고, 가장 낮은 리전(초원)을 쓰면 뒤 리전 종을 + Lv.1~10으로 거저 준다. 그 종을 필드에서 처음 만나는 곳의 레벨이면 둘 다 아니다. +- **영웅·전설을 왜 빼나**: 레이드로만 맞서는 등급이라(`CaptureChoiceUI.IsRaidRarity`) 집에서 이동 없이 밤마다 레이드(이기면 확정 포획)를 받는 길이 생긴다. + 섬은 곤충을 1배로 두는 곳인데 전설 야생 몸은 1.9배라 가구 사이에서 어색하고, 섬 ↔ 레이드 아레나 왕복은 아직 기기에서 보지 않았다. +- **떠나는 규칙**: 조건이 끝났거나(아침이 오고 맑아져 수가 줄었다) 수명이 다했고, 붙잡히지·놀라지 않았고, **화면 밖**일 때 조용히 떠난다(`InsectEntity.Recall` — + 게임플레이 퇴장이 아니다). 섬은 한 변이 15~33m라 필드의 "25m 밖" 대신 카메라 화면으로 잰다. 들어설 때도 화면 밖 칸(다 보이면 가장 먼 칸)에, 플레이어에서 3칸 이상 떨어져 선다. +- **도주는 섬의 빈 칸을 벗어나지 않는다**(`InsectEntity.SetFleeArea` + `IslandGuestRules.FreeRun`) — 물리 측정은 벽·건물만 보고 "빈 칸"을 모른다. + 놓침은 평소처럼 사라지고 다음 손님은 재생 지연 뒤다(필드의 "같은 개체를 눈 밖으로 옮김"은 섬이 작아 옮길 눈 밖이 없다). +- **기록이 개체다** — 섬을 나가도 기록은 남고(몸만 거둔다) 돌아오면 같은 손님이다. 섬을 드나들어 다시 굴리지 못한다. 떠나 있는 동안 조건이 끝난 손님은 + 이미 떠난 것으로 친다. **세션 간에는 저장하지 않는다**(필드 개체군과 같다). +- 손님 몸은 섬 `root` 밖의 작은 풀(`IslandGuestBodies`, 최대 2)에 둔다 — 섬을 다시 지을 때(`DestroyWorld`) 같이 부서지지 않게. 배치가 바뀌어 손님이 선 칸이 + 막히면 그 몸만 거두고 다음 틱에 다른 빈 칸에 선다. + +### 어디서 막나 + +| 자리 | 막는 것 | +|---|---| +| `IslandGuestRules.CanHostGuests` | 남의 섬 구경(`Visit` — 스냅샷)·꿈 섬(`DreamMode`)·꾸미기 중(`IsEditing`)에는 들어서지 않는다 | +| `IslandWorldBuilder.TickGuests` | 내 섬이 아니면(`Visit`·꿈) 내 섬의 손님 기록을 건드리지 않는다 — 세우지도 떠나보내지도 | +| `CaptureInputController` | 꾸미기 화면(`IslandEditUI`)이 모달이라 그동안 [E]·잡기 버튼이 막힌다(`ModalUIRegistry.IsAnyOpen`) | +| `AmbushRules` | 서브에리어 거절 — 섬 손님은 습격하지 않는다(습격형이어도 온순한 곤충처럼 군다) | + +**섬은 어느 리전도 아니다**: 섬에 있는 동안 `RegionManager.CurrentRegion`은 섬으로 떠나기 전 리전이다(sticky). 그래서 그 값을 읽으면 섬 손님을 잡을 때 +지역 의뢰(`QuestRegionGate`)와 리전 게이트 스토리 비트(`requiredRegionId`)가 그 리전의 포획으로 센다. **"행동이 어느 리전에서 일어났나"는 `RegionManager.ActionRegionId`를 읽는다** +(섬에서는 null, 동굴은 그 리전 — `RegionActionRegionTests`). `TutorialQuestManager.CountsHere`와 `StoryDirector.RegionGateSatisfied`가 그렇게 한다. +리전 기준으로 행동을 세는 코드를 새로 쓴다면 `CurrentRegion`이 아니라 이걸 읽을 것. + +## 방목 곤충의 기분 — 시간·날씨 + +`IslandInsectMood`(순수)가 성향(`InsectHabits`)을 활동도 0~1로 바꾸고, `IslandInsectWalker`가 쉬는 시간·걷는 속도·나는 높이에 곱한다. 야행성은 밤에 활발하고 낮엔 +대부분 쉰다(오래 쉬고 느리고, 나는 종은 땅에 낮게 내려앉는다). 주행성은 반대, 싫은 날씨엔 덜 움직이고 좋은 날씨엔 활발하다. **시간·날씨를 안 타는 종은 예전 배회 그대로다** +(보통 활동도 0.6 = 쉬기 1.2~4.5초 · 속도 ×1). 몸의 위치·속도·쉬는 시간만 바꾼다 — 날갯짓 같은 모델 애니메이션은 visual-dev 영역이다. +섬 수입과 무관하고, 남의 섬·꿈 섬에서도 같다(꿈은 늘 맑은 한낮이라 자연히 낮의 기분이다). + +## 검증 + +``` +python -X utf8 .claude/scripts/economy_sim.py # 신호 6·7: 섬 수입 대역 (손님·기분은 바꾸지 않는다) +python -X utf8 .claude/scripts/quest_lint.py # 섬 퀘스트 7개(QuestType 6종) +-testPlatform PlayMode -testFilter InsectGame.Tests.IslandGuestRulesTests +-testPlatform PlayMode -testFilter InsectGame.Tests.IslandInsectMoodTests +``` + +- 화면: QA 빌드 `-battleScenario island-ui`(데스크톱 1280×720 + 세로 720×1280). 저장을 건드리지 않는 메모리 fixture다. +- 모양: `Assets/Editor/IslandModelCapture.cs` — PlayScene 없이 지형 3단계와 물건 28종을 찍는다. + +``` +"$UNITY_EDITOR_PATH" -batchmode -projectPath "C:/Project/곤충게임" -logFile .claude/cache/island-model.log \ + -executeMethod InsectGame.EditorTools.IslandModelCapture.Run -islandOut .claude/cache/island-model +``` diff --git a/.claude/rules/quest-system.md b/.claude/rules/quest-system.md index 233269a..69d5f54 100644 --- a/.claude/rules/quest-system.md +++ b/.claude/rules/quest-system.md @@ -26,7 +26,9 @@ description: 퀘스트 데이터 정의 위치·추가 절차·Notify 배선 규 어느 시스템에서 발생하는지 찾아 그곳에서 `TutorialQuestManager.Instance.Notify___()`를 부른다. 4. **이벤트 기반이면** `SubscribeEvents`/`UnsubscribeEvents`에 핸들러 등록 + 핸들러가 `NotifyAction(QuestType.X)` 호출 -5. 캔디/EXP/아이템/곤충 외 **새 보상 종류**면 데이터 모델 필드 + `CompleteQuest` 로직 +5. 캔디/EXP/아이템/곤충/코인/섬 물건 외 **새 보상 종류**면 데이터 모델 필드 + 지급(`GrantRewards`) + + 표시(`QuestRewardFormatter`) — 지급과 표시가 **같은 술어**를 써야 "보이는데 안 주는" 어긋남이 없다. + **다이아는 보상으로 줄 수 없다**(서버 규칙이 클라이언트의 다이아 증가를 거부한다 — `rules/island.md`). ### q_team 회귀 — 실제로 겪은 사고 @@ -35,6 +37,108 @@ description: 퀘스트 데이터 정의 위치·추가 절차·Notify 배선 규 (`TutorialQuestManager.cs:322` 주석이 방어 흔적). 이벤트 기반 QuestType은 3번(호출부)이 아니라 4번(구독 등록)이 누락 지점이다. +## 스토리 체인은 "하는 일"만 — 창 열기 과제는 「둘러보기」 서브다 + +수문장까지의 스토리 체인은 이동 → 어르신 → 포획 1 → 포획 3 → 레벨업 → 전투 1 → 전투 3 → 희귀 포획 → 수문장이다. +창을 한 번 열면 끝나는 과제(`q_collection`·`q_dex`·`q_equip`·`q_item`·`q_training`·`q_team`)는 **배열 자리는 그대로 두고 +`category = Side`로** 돌렸다(제목 접두 `[둘러보기]`). 예전엔 수문장 전 14개 중 7개가 그런 과제였고 첫 전투가 9번째였다(2026-10-02). + +- **스토리 퀘스트의 상대 순서를 바꾸지 않는다.** 소급 완료(`BackfillSkippedStoryQuests`)가 "배열 순서 = 완료 순서"에 + 기대므로, 뒤에 있던 퀘스트를 앞으로 당기면 기존 세이브에서 **아직 할 차례인 그 퀘스트가 보상 없이 삼켜진다.** + 빼는 것(Side로 돌리기)은 안전하다 — 남은 것들의 순서가 그대로라서다. +- **스토리의 선행은 스토리여야 한다.** 서브 과제를 선행으로 물면 그 과제를 안 한 사람의 본편이 멈춘다 + (`TutorialQuestOrderTests.RealChain_EveryStoryPrerequisite_IsAStoryQuest`). +- 저장된 활성 퀘스트가 스토리가 아니면 다음 스토리로 넘긴다(`ActiveQuestNeedsReselect`) — 「둘러보기」 과제가 + 활성인 채 저장된 세이브가 그 경우다. + +## 미리 세기 — 차례가 오기 전에 한 일도 센다 + +포획·전투·레이드·레벨업은 **아직 활성이 아닌 스토리 퀘스트에도** 진행이 쌓인다(`BankUpcomingStoryProgress`). +예전엔 활성 퀘스트만 세어서, 다른 퀘스트를 하는 동안 잡은 세 마리가 "3마리 포획" 차례에 0으로 돌아갔다. +이제 "총 N번"이다 — 포획 1을 끝내면 포획 3은 1/3에서 시작하고, 차례가 왔을 때 이미 채웠으면 그 자리에서 완료한다 +(`ReconcileBankedProgress`). + +- 대상은 `TutorialQuestOrder.IsBankable`이 정한다. **`CleanseBlight`는 넣지 않는다** — 「하나 무너뜨리기」와 「하나 더」가 + 이어져 있어 미리 세면 첫 정화 하나가 둘을 한꺼번에 깬다. 방문·이동·대화처럼 **그 순간의 행동이어야 하는 것**도 아니다. +- 새 QuestType을 늘릴 때 "몇 번 했는가"를 세는 종류라면 `IsBankable`에 넣을지 정할 것(기본은 안 넣는다). +- 조용히 올린다 — 진행 이벤트를 쏘면 아직 안 뜬 퀘스트의 알림이 뜬다. + +## 지역 의뢰 — `requiredRegionId` (Side 전용) + +서브 퀘스트에 `requiredRegionId`를 채우면 **①그 리전이 열려야 목록에 뜨고 ②그 리전 안(서브에리어 포함)에서 한 +행동만 센다.** 판정은 `QuestRegionGate`(순수)가 하고, 해금 여부는 `RegionManager.IsRegionAccessible`을 읽는다. +행동의 리전은 `RegionManager.ActionRegionId`다(`CurrentRegion`이 아니다) — 나의 섬은 어느 리전도 아니라서 섬 손님 곤충 포획이 떠나기 전 리전의 의뢰로 세어지면 안 된다(`rules/island.md`). +마을 이야기(`Docs/StoryBible.md` 13장)의 짝이다 — 매듭 비트가 `requiredQuestId`로 이 퀘스트의 완료를 **관찰**한다. + +- **Story 퀘스트에 달지 않는다.** 선형 체인(`ActivateNextQuest`·`NotifyAction`의 활성 퀘스트 경로)은 이 필드를 안 본다 — + 적어 둔 한정이 거짓이 된다. +- **위치가 있는 목표만** — Capture·CaptureRare·CaptureRarity·Battle·RaidBattle·VisitSubArea·NpcDuel. +- **스토리가 물면 1회형이어야 한다.** 반복 서브는 완료 기록이 안 남아 `requiredQuestId` 게이트가 영영 안 열린다(story_lint 29). +- 목록 UI의 "미해금"도 `IsSideUnlocked`를 쓴다. prereq만 보면 잠긴 리전의 의뢰가 0/5 진행 중으로 뜬다. + +## 조건부 퀘스트 — `CaptureTrait` · `BattleFeat` (Side 전용) + +"그냥 N마리"가 아니라 **잡은 개체의 성질**(몸길이·속성·이로치)이나 **이긴 전투의 모습**(상대 등급·속성·레벨 차·내 행동 수·남은 HP·연승)이 +조건과 맞아야 센다. 새 QuestType을 조건마다 늘리지 않고 **타입 둘 + 조건 필드**로 푼다 — 조건 하나를 더하는 데 5지점 등록이 안 든다. + +``` +CaptureController / 전투 포획 ──CaptureFacts──▶ NotifyCapture(CaptureFacts) ─┐ +InsectBattleController(승리 지점) ──BattleFacts──▶ NotifyBattleFeat ─────────┤ QuestTraitRules.Matches(순수) + └▶ 맞는 활성 서브를 먼저 다 모은 뒤 한꺼번에 올린다 +``` + +- **판정은 `QuestTraitRules`(순수)가 단일 출처**다. 조건 필드(`TutorialQuest`)의 0·None·false·Common은 "조건 없음", 채운 것은 **전부** 맞아야 한다(AND). + 몸길이는 **소수 첫째 자리로 반올림해** 잰다(화면 `SizeLabel`과 같은 값 — 39.96이 "40.0mm"로 보이는데 40 이상에 안 세어지면 버그다), + 배율은 부동소수점 오차(롤 90 = 1.2가 1.1999999)를 ε로 흡수한다. **개체를 저장 못 한 포획은 크기를 모른다**(`sizeKnown`) — 중간값이 크기 조건을 채우면 안 된다. +- **병렬이다.** 서브는 다중 활성이라 한 번의 포획·승리가 맞는 조건 전부를 함께 올린다(큰 물 곤충 한 마리 = 큰 곤충 + 물가 + 영웅 채집단). +- **맞는 퀘스트를 먼저 다 모은 뒤 올린다**(`traitTargets`). 배열을 훑으며 바로 올리면 앞 화가 끝나 뒷 화가 열리는 순간 같은 행동이 뒷 화까지 채운다 — + 외전이 "한 걸음씩"이 아니게 된다(`OneWin_CannotAdvanceTwoChaptersOfTheSameTale`). +- **전투 통지는 이긴 지점에서 직접 부른다**(`InsectBattleController` — 야생·수문장·곤충잡이 대결 승리가 모두 지나는 한 곳, 샌드박스 제외). 레이드는 별개 + 컨트롤러라 세지 않는다. 통지가 던져도 전투 종료는 계속된다(try/catch) — `battleEnded`는 켜졌는데 `BattleEnded`가 안 울리면 결과 화면이 멈춘다. + **내 행동 수**는 차례가 넘어간 입력만 센다(스킬·기본 공격·기절로 건너뛴 차례·실패한 도주). 거절된 입력은 안 센다. +- **연승(`resetOnLoss`)**: 이긴 전투는 진행을 올리고, **지거나 도망치면**(`OnBattleEnded(false)`) 진행만 0으로 되돌린다 — 목표·반복 횟수는 그대로, + 진행 이벤트도 쏘지 않는다. 연승이 끊겨도 **조건이 안 맞는 승리는 건드리지 않는다**(다른 조건이 걸린 연승일 때). + +### 조건 필드 + +| 필드 | 쓰는 타입 | 뜻 | +|---|---|---| +| `minSizeMm` · `maxSizeMm` | CaptureTrait | 몸길이(mm) 이상·이하 — 둘 다 주면 구간 | +| `minSizeRatio` · `maxSizeRatio` | CaptureTrait | 같은 종 평균 대비 배율(0.75~1.25) — 종마다 평균이 달라 몸길이와 따로 둔다 | +| `requireShiny` | CaptureTrait | 이로치(색다른 곤충)만 | +| `requiredElement` | 둘 다 | 잡은·맞선 곤충의 주속성 **또는** 부속성 | +| `minRarity` | 둘 다 | 등급 **이상**(CaptureRarity의 `requiredRarity`는 정확히 그 등급 — 다르다) | +| `minLevelEdge` | BattleFeat | 상대 레벨 − **싸우는 내 곤충** 레벨 ≥ N (트레이너 레벨이 아니다) | +| `maxTurns` | BattleFeat | 내 행동 수 ≤ N | +| `minHpPercent` | BattleFeat | 끝났을 때 내 곤충 HP% ≥ N (정수 퍼센트로 비교) | +| `resetOnLoss` | BattleFeat | 연승 | + +**엉뚱한 타입의 필드는 조용히 무시된다** — `CaptureTrait`에 `maxTurns`를 쓰면 그 조건이 사라져 저작보다 쉬워진다. 검사 13이 잡는다. + +### 기준값은 실측으로 정한다 + +몸길이는 종 기준(`ApplySizeProfile`: 등급 기준 22/30/42/58/78mm × 종 해시 0.7~1.3)에 개체 편차 0.75~1.25가 곱해진다. 필드 포획 한 번당 +**40mm 이상 약 11% · 60mm 이상 약 2.6% · 80mm 이상 약 0.4% · 20mm 이하 약 25% · 16mm 이하 약 7.5%**(등급표 60/25/11/3.5/0.5%, 종 균등 가정). +기준을 손으로 바꾸면 분포 밖의 값이 조용히 들어온다 — `QuestTraitTests.RealDb_EveryTraitQuest_IsReachableAtSensibleOdds`가 실제 곤충 DB와 진짜 판정으로 +퀘스트마다 "평균 몇 번 만나야 찬다"를 재서 450번을 넘으면 실패한다(결과표는 로그의 `[QuestTrait]`). +**한계:** 행동 수·HP·레벨 차는 전투가 정해서 DB로 잴 수 없다 — 그 숫자(3번 이내, 70% 등)는 **실측이 아니라 추정**이다. 기기에서 전투 길이를 본 뒤 조정할 것. + +### 조건을 새로 만들려면 (필드 하나 + 판정 한 줄) + +1. `TutorialQuest`에 필드(기본값 = 조건 없음) → 2. `QuestTraitRules.Matches`와 `HasCondition`에 한 줄 → 3. 입력이 모자라면 `CaptureFacts`/`BattleFacts`와 + `From`, 그 값을 아는 지점(포획·전투 컨트롤러)에서 채우기 → 4. `game_facts.py`의 `TRAIT_*_FIELDS`에 이름 추가(안 하면 검사 13이 새 필드를 못 본다) → +5. `QuestTraitTests`에 경계 테스트. **새 QuestType은 필요 없다.** + +### 외전 — 본편 옆을 나란히 가는 연작 + +`[외전]` 접두 서브·1회·선행 사슬이다(어르신의 낡은 일기 4화 · 라온의 도전장 4화 · 세라의 표본 조사 3화). 본편이나 다른 외전, 반복 서브와 **동시에** 진행된다. + +- **스토리 비트·주민이 아니라 퀘스트 글만이다.** 마을 이야기(13장)와 달리 NPC 일곱 곳 등록·`Story.json` 비트가 없다 — 이야기를 비트로 올리려면 그쪽 절차를 따를 것. +- **라온의 말은 직접 하는 말이 아니라 남겨 둔 도전장이다.** 라온이 전력에서 빠지는 구간(ch10~ch12)에 이 목록을 여는 사람에게 그의 목소리가 새로 들리지 않게 한다. +- **세라 연작은 `q_blight_first` 뒤**에 연다 — 세라는 숲에서 합류하므로 만나기 전에 이름이 목록에 뜨면 안 된다. 어르신은 처음부터 곁에 있어 `q_capture3`. +- 주인공 쪽 어휘는 「거둬들이다」를 쓰지 않는다(`StoryBible.md` 2장) — 만나다·맞이하다·기록하다. +- **반복형을 선행으로 물지 않는다.** 반복 서브는 `completedQuests`에 안 들어가 그 뒤가 영영 안 열린다(`RealTable_NoQuestRequiresARepeatableOne`). + ## 검증 — 반드시 quest_lint 실행 퀘스트를 수정하면 반드시: @@ -43,12 +147,15 @@ description: 퀘스트 데이터 정의 위치·추가 절차·Notify 배선 규 python -X utf8 .claude/scripts/quest_lint.py ``` -11검사: questId 중복 / prerequisite 무결성(끊김·순환) / 보상 곤충 ID 존재 / 보상 아이템 ID 존재 / +13검사: questId 중복 / prerequisite 무결성(끊김·순환) / 보상 곤충 ID 존재 / 보상 아이템 ID 존재 / 보스 대결 보상 아이템 ID 존재 / **QuestType↔진행 배선**(q_team류 정지 검출) / 대화 리전키 정합성 / 서브 퀘스트 정합(반복은 Side 전용) / 팀 자동 편성 경로 / **prereq 방향**(배열 앞을 가리켜야 소급 완료가 안전 — 뒤를 가리키면 아직 할 차례인 퀘스트를 보상 없이 삼킨다) / +**지역 의뢰 정합**(리전 실재 · Side 전용 · 위치 있는 목표 — 리전 ID 오타면 의뢰가 목록에서 잠긴 채 남는다) / **questId 구분자 금지**(`,`·`:`·공백 — `QuestSaveMerge`의 세이브 병합 포맷이 그 문자를 구분자로 -쓴다. 든 ID는 병합에서 조용히 버려지고 write-back이 손실을 영구화한다). +쓴다. 든 ID는 병합에서 조용히 버려지고 write-back이 손실을 영구화한다) / +**조건부 퀘스트 정합**(Side 전용 · 조건 있음 · 타입에 맞는 필드 · 하한≤상한 — 전부 무증상이다: 조건이 없으면 무엇이든 세고, +엉뚱한 타입의 필드는 조용히 무시되고, Story에 달면 영구 정지한다). `ci_check`에도 포함돼 세션 밖 편집(Codex CLI 등)의 결함도 CI가 잡는다. 보상 ID(`rewardInsectId`/`rewardItemId`)는 존재하지 않는 값을 물어도 런타임엔 `LogWarning`만 @@ -61,3 +168,5 @@ python -X utf8 .claude/scripts/quest_lint.py | `TutorialQuestManager`/`TutorialQuestData` 퀘스트 로직·목표·보상 | game-designer | data-architect | | `TutorialQuestUI` 렌더링 | ui-dev | — | | 새 QuestType의 게임플레이 호출부 삽입 | 해당 시스템 담당(battle-dev/capture-dev 등) | game-designer | +| `QuestTraitRules` 조건 판정·기준값, 조건부 퀘스트와 외전 저작 | game-designer | data-architect | +| 조건부 통지의 입력(`CaptureFacts`/`BattleFacts`를 채우는 지점) | capture-dev(포획) · battle-dev(전투 승리 지점·행동 수) | game-designer | diff --git a/.claude/rules/save-system.md b/.claude/rules/save-system.md index 0cb84d9..6b5bc36 100644 --- a/.claude/rules/save-system.md +++ b/.claude/rules/save-system.md @@ -17,6 +17,8 @@ description: 로컬 7개 JSON·Firestore 세이브 구조와 필드 추가·마 - player_items.json (아이템) - battle_team.json (5슬롯 팀) - dex_save.json (도감 기록) +- story_progress.json (스토리 진행) +- island.json (나의 섬 — 보관함·배치·방목·누적 수확·가이드 진행. 아래 「섬」 참조) ## 퀘스트 세이브 (PlayerPrefs — JSON 아님) @@ -43,6 +45,23 @@ questSideProgress/questSideRepeat)와 직렬화/파싱/업로드/복원 4곳을 - Bearer 토큰: AuthManager.Instance.IdToken - 에러 처리: 404=신규유저, 401=인증실패, 기타=경고후 계속 +## 섬 (island.json) + +`IslandManager`가 쓰고 클라우드엔 블롭 하나(`GameSaveData.islandData`)로 올라간다 — 곤충 블롭과 같은 방식이라 +`IslandSave`에 필드를 늘릴 때 DTO 4점 변경이 필요 없다. +- **컴팩트 JSON**(`ToJson(save, false)`)으로 쓴다. 이 파일이 통째로 Firestore 문서의 문자열 필드가 되고, 문서 한도(1MiB)를 + 곤충 블롭과 나눠 쓴다. +- 좌표는 **섬 중심 기준**이라 섬을 넓혀도 기존 배치가 그대로 유효하다(마이그레이션 없음). +- 구세이브(파일 없음·빈 블롭)는 필드 기본값만으로 기본 섬이 된다. 손상·구버전·신버전 혼용은 `IslandSaveRules.Sanitize`가 + 고친다 — 놓인 수가 보유 수를 넘으면 **보유 수를 올리고**(놓인 물건을 지우지 않는다), 모르는 물건 id는 **보존한다**. +- 충돌은 세이브 단위 last-write-wins다(블롭 내부 병합 없음). + +## Firestore 문서 파서 + +`CloudSaveManager`의 복원은 `FirestoreDocParser`로 필드를 꺼낸다. **공백 없는 형식과 줄바꿈·들여쓰기된 형식을 둘 다 읽는다.** +예전 구현은 `"키":{"stringValue":"`라는 공백 없는 마커를 통째로 찾아서, 응답이 들여쓰기돼 오면 한 필드도 못 찾고 +전부 기본값(레벨 0·캔디 0·빈 문자열)으로 복원했다. 필드를 늘릴 때 마커 문자열을 손으로 만들지 말 것. + ## 수정 규칙 - 새 세이브 필드 추가 시 기본값 필수 - GameSaveData에 클라우드 필드 추가 시 Firestore 포맷도 수정 diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index f0ab5e6..73634f7 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -94,8 +94,43 @@ EditMode 러너를 되살리려면 `Assets/Scripts`·`Assets/Editor`·`Assets/Te **정지 화면으로는 애니메이션을 못 본다** — 여러 시각을 찍어 픽셀 차분을 낸다. 플레이어 idle 호흡을 이 방법으로 확인했다(2초 간격 3장, 차이가 플레이어 영역에만 몰림). +### 필드 전체는 `FieldDesignTour`로 한 번에 돈다 + +`LiveSceneCapture`는 한 실행에 한 자리다. 지형·건물·NPC 옷처럼 **월드 전체에 걸친 변경**은 +`Assets/Editor/FieldDesignTour.cs`가 실제 PlayScene을 띄워 본 마을·리전 13곳·전초기지·서브에리어 26곳 +(필드 입구 + 내부)·NPC 전원을 한 실행(약 3분, 370장)에 찍는다. 장소마다 `game`(실제 게임 카메라)· +`wide`(조감)·`eye`(눈높이) 세 구도다 — 게임 카메라는 (0,9,-6) 고각이라 세로로 선 것의 모양이 거의 안 보인다. + +``` +"$UNITY_EDITOR_PATH" -batchmode -projectPath "C:/Project/곤충게임" -logFile .claude/cache/tour.log \ + -executeMethod InsectGame.EditorTools.FieldDesignTour.Run -tourOut .claude/cache/tour \ + [-tourOnly world,village,regions,outposts,subareas,npcs,sky] [-tourFilter dunes,canopy] +``` + +전후를 같은 도구로 찍어야 비교가 된다(조명·시각·구도가 같다). `sky`는 공중·거대 렌더러를 로그로 센다 — +2026-09-28에 하늘의 회색 원반(납작한 구름)과 리전을 덮던 올리브색 돔(1.5배 확장 전 좌표의 "먼 산")을 이걸로 찾았다. +NPC는 컬링과 주변 소품을 피해 먼 무대로 같은 프레임 안에 옮겨 찍고 되돌린다. + +**NPC가 새까만 실루엣으로 찍히면 역광이 아니라 환경광 결함을 의심할 것.** 이 씬엔 라이팅 데이터가 없어 +Skybox 환경광 프로브가 0이었다 — 역광 면이 완전 검정이 되는 건 그 때문이었고 지금은 Trilight로 고쳤다. + +### 걸어서 못 나가야 하는 경계는 실제 씬에서 한 바퀴 잰다 — `FieldFenceIntegrationTests` + +초원은 목장 울타리(기둥 + 가로대)로 빈틈없이 둘러 **그려지는데**, 가로대가 합친 메시라 콜라이더가 없어서 기둥 사이 +46칸 전부(칸당 6.5m)를 걸어 나갈 수 있었다. 겹친 습지 쪽은 마지막 기둥과 습지 잠금 원 사이에 3.6m 틈도 있었다(2026-10-02 기기 보고). +그림과 통행이 따로 노는 결함은 캡처(막힌 것처럼 보인다)로도 순수 로직 테스트(콜라이더가 없다)로도 안 잡힌다. + +`FieldFenceIntegrationTests`가 실제 PlayScene에서 울타리 줄을 0.2° 간격으로 돌며 게임의 진짜 이동 차단 +(`PlayerMovement.IsBlockedPosition`)을 불러 열린 구간을 센다 — **통로 하나만** 열려 있어야 한다. 다른 리전의 원 안은 +잠긴 것으로 친다(이 PC의 PlayerPrefs에 해금 기록이 있어도 새 게임 상태를 재도록). 열린 구간은 로그의 `[FENCE]` 줄에 남는다. + +차단은 난간 칸마다 얇은 `BoxCollider`다(`RegionTerrainBuilder.AddRailBlocker`). **레이어가 Ignore Raycast(2)다** — +겹침 검사(`OverlapSphere`, 기본 전 레이어)에만 걸리고 레이·구 캐스트(카메라 차폐·탭 이동·곤충 탭·접지)는 건너뛴다. +보이지 않는 벽을 기본 레이어에 세우면 남쪽 울타리 앞에서 카메라가 3.5m로 당겨진다. + ### 대사가 실제로 뜨는지는 `StoryBeatWalkthrough`로 본다 + 캡처 도구의 첫 번째 한계(IMGUI 미포착)가 정확히 스토리 대사를 덮는다 — 비트가 발화하면 `NpcDialogueUI`가 `OnGUI`로 그리므로 **화면으로는 확인할 방법이 없다.** 그런데 `story_lint`도 못 본다: 그쪽은 Story.json을 **정적으로** 읽어 게이트·참조 무결성만 본다. @@ -107,6 +142,8 @@ EditMode 러너를 되살리려면 `Assets/Scripts`·`Assets/Editor`·`Assets/Te `StoryBeatTriggered`를 구독한 채 게임의 실제 진입점(`OnNpcTalked`·`AddCapturedInsect`· `BattleEnded`·`CleanseByBoss`)을 순서대로 두드리고, 뜬 대사는 `NpcDialogueUI.CloseModal`로 닫는다(닫지 않으면 `DrainPendingTriggers`가 모달 가드에 막혀 **다음 비트가 영영 안 온다**). +스토리 영상은 `StoryVideoDirector.CloseModal`로 건너뛴다 — 배치모드엔 디코더가 없고, 대사 **앞** 영상(`introVideoId`)은 +건너뛰어야 대사가 열린다(영상 → 등장 연출 → 대사의 진짜 종료 경로를 걸음이 지난다). ``` "$UNITY_EDITOR_PATH" -batchmode -projectPath "C:/Project/곤충게임" \ @@ -126,6 +163,12 @@ EditMode 러너를 되살리려면 `Assets/Scripts`·`Assets/Editor`·`Assets/Te |---|---|---| | `blight` (기본) | 오염 거점 아크 `bl_*` | `NpcTalk` · `CaptureInsect` · `BattleWin` · `RegionCleansed` | | `campaign` | 1막 본편 + 꽃밭 | 위 + **`SubAreaEnter`** · **`GuardianDefeat`** | +| `town` | 마을 이야기 `town_*` + 지역 의뢰 `s_town_*` + 따라가기 | `NpcTalk` · `CaptureInsect` · `BattleWin` + **퀘스트 통지**(`NotifyCapture`) | + +**`town`은 스토리만 보지 않는다** — 이 연작의 결함은 퀘스트·따라가기와의 이음매에서 조용히 나서, 한 편마다 +리전 밖 의뢰 행동이 **안 세어지는지**, 의뢰 완료가 매듭의 `requiredQuestId`를 여는지, 따라가기가 `[의뢰]` → +`?`·"알리기" → (대기면) 자동 해제로 바뀌는지를 함께 본다. 배치엔 로그인이 없어 퀘스트 세션이 꺼져 있으므로 +세션·선행 퀘스트·리전 해금을 인메모리로 채우고 보고서의 「미리 채운」 칸에 적는다(`quest:`·`region:` 접두). **선택지가 뜨면 도구가 고른다** — 기본은 첫 항목, `-walkChoice last`면 마지막 항목. 선택 결과는 `Immediate` leaf라 고르는 순간 큐 맨 앞에서 뜬다(`StoryBible.md` 6장 「선택지 규칙」). 최종장 @@ -149,10 +192,45 @@ EditMode 러너를 되살리려면 `Assets/Scripts`·`Assets/Editor`·`Assets/Te `ch5_thesis`가 `bl_mountain_sign`보다 먼저, 유적에서 이기면 `ch6_approach`가 먼저다). 플레이어도 실제로 두 번 해야 한다 — 결함이 아니라 저작 순서다. +### 스토리 영상(mp4)은 기기로만 본다 + +`StoryVideoDirector`의 재생은 `VideoPlayer` 디코더에 달려 있어 **배치모드·PlayMode 러너로는 +검증할 수 없다**(디코더가 없거나 렌더 대상이 없다). 테스트(`StoryVideoLibraryTests`)는 저작(길이 +상한·자막 큐·금칙)만 고정하고, `story_lint` 검사 25가 ID·switch·파일 배치를 본다. 실제 재생· +건너뛰기·조작 복구는 Android 기기에서 확인한다 — 절차는 `Docs/StoryVideos.md`. + +Windows QA 빌드는 디코더가 있어 **실제로 튼다** — `-battleScenario story-video`(`StoryVideoVisualCapture`)가 진짜 +`StoryVideoDirector.PlayReplay`로 편마다 첫 자막 0.5초 앞·자막 가운데를 찍고(자막·「건너뛰기」 포함), README에 편마다 +재생 여부·준비/첫 프레임까지 걸린 시간·실제 재생 길이(디코더가 잰 파일 길이·저작 길이와 함께)를 적는다. 일부만은 `-videoOnly ch6_wall,ch7_fence`. +앞머리 `00-journal-*`는 저널 「▶ 영상」 → ESC가 영상만 닫고 저널이 맨 위로 돌아오는지를 잰다. 종료 코드 0 / 3(한 편이라도 재생 실패) / +5(저널 ESC 판정 실패) / 2(고를 영상 없음). 전부 찍으면 3분이 넘는다(하네스 감시 시계를 장면이 스스로 늘린다). Android의 jar 경로·기기 디코더는 여전히 기기로 본다. + +### 반투명·발광이 빌드에서 사는지는 QA 빌드로 잰다 — `-battleScenario materials` + +에디터와 배치 캡처에는 셰이더 변형이 전부 있어서 **절대 틀리지 않는다.** 결함은 플레이어 빌드가 Standard의 +`shader_feature`(반투명 `_ALPHABLEND_ON`·발광 `_EMISSION`)를 걸러낼 때만 난다 — 2026-09-29까지 빌드엔 그 변형이 0개라 +물·얼음·유리·안개가 불투명, 등불·발광 소품이 무발광으로 그려졌다. `BattleVisualCaptureBuilder.Build`로 QA 빌드를 만든 뒤: + +``` +Builds/Windows/BattleVisualQA/BattleVisualQA.exe -battleCaptureOut <새 빈 폴더> -battleScenario materials -screen-fullscreen 0 -screen-width 1280 -screen-height 720 +``` + +줄무늬 벽 앞 구의 (r−b) 편차와 발광 휘도차를 재서 README에 PASS/FAIL을 적는다(안개 Exp2 판 포함, 종료 코드 0/5). +빌드 로그의 `[ShaderVariants]` 줄이 패스별 변형 수를 키워드별로 센다. 고치는 곳은 `SceneryMaterials.BuildKeepers` +(Resources 머티리얼) — **Standard를 Always Included에 넣으면 이 방법이 무력해진다**(그 목록은 머티리얼 키워드를 안 본다). + ### 한계 셋 (전부 실측) - **IMGUI는 안 잡힌다.** `OnGUI`는 카메라를 거치지 않는다 — 상점·대화창·배틀 UI·HUD는 이 도구로 검증할 수 없다. 필요하면 스탠드얼론 빌드에서 `ScreenCapture`를 써야 한다. + 이미 그렇게 찍는 검수 시나리오가 있다(`BattleVisualCaptureBuilder.Build` → `BattleVisualQA.exe + -battleCaptureOut <빈 폴더> -battleScenario <이름>`, 데스크톱 1280×720과 세로 720×1280 둘 다 찍을 것): + `insect-ui`(도감·보유 곤충·퀵바), `field-ui`(필드 HUD·포획 선택/채집망/출전/성공·실패 팝업·배틀팀·훈련소 — + 훈련소는 실제 부트스트랩 생성 함수로 방식·기술을 만들어 **가격이 실값**이다), + `outfit`(의상 창·캐시샵·캐릭터 생성), `island-ui`(나의 섬 — HUD·꾸미기·상점·곤충·방문·가이드. 진짜 `IslandWorldBuilder`가 + 섬을 짓는다), `badge`, `map`, `story`, `story-video`(스토리 영상 — 실제 디코더, 위 절). 전부 저장을 부르지 않는 메모리 fixture다. + **검수 빌드는 실제 게임과 같은 저장 폴더를 쓴다** — fixture가 재화를 건드리는 동작(구매·수확)을 부르면 이 PC의 진짜 + 세이브를 덮는다. 지갑·캔디는 차감 즉시 파일에 쓰므로 부르지 않고, 섬 매니저는 `PersistenceEnabled`를 끈다. - **`ScreenCapture.CaptureScreenshot`은 배치모드에서 조용히 실패한다**(게임뷰가 없다). 그래서 이 도구는 카메라 → `RenderTexture` → `ReadPixels` 경로를 쓴다. - **Unity 에디터를 열어두면 이 도구가 못 돈다.** 프로젝트가 `Temp/UnityLockfile`로 잠겨 diff --git a/.claude/rules/ui-layout.md b/.claude/rules/ui-layout.md index 93bb2ce..7966946 100644 --- a/.claude/rules/ui-layout.md +++ b/.claude/rules/ui-layout.md @@ -24,6 +24,7 @@ UISurface.Card(new Rect(px, py, pw, ph), SomeBgCol, UITheme.Instance.surfaceBord | `Card(rect)` | 테마 기본색 카드 | | `Rounded(rect, color[, radius])` | 헤더 바, 배지 바탕 | | `Flat(rect, color)` | **얇은 것** — 구분선·진행바·액센트 스트라이프 | +| `Meter(rect, ratio, fill)` | HP·경험치·IV 막대(트랙 + 채움, 둘 다 각진 채). 색은 `UITheme.GetHpColor(ratio)` 등 | | `Button(rect, label, bg, style[, selected])` | 호버 반응이 필요한 버튼 | | `Chip(rect, text, bg, textColor)` | 등급·속성·보상 배지 | | `StatRow(rect, label, value, lc, vc)` | 라벨/값 한 줄 | @@ -235,10 +236,12 @@ FieldHudInput.RegisterBlockingRect(barRect); // 그리기 직전 GUI.DrawTexture(barRect, Texture2D.whiteTexture); ``` -**픽셀 좌표계라면 `UIScale.Scale`로 나눠서 넘긴다.** `RegisterBlockingRect`는 가상 좌표를 받고 -`IsScreenPointOverHud`가 화면 좌표를 `Scale`로 나눠 비교한다. `UIScale.Begin()`을 쓰지 않는 -화면(`UISafeLayout.Px` 사용)이 그대로 넘기면 스케일이 1이 아닌 기기에서 엉뚱한 영역이 막힌다 -(`WorldFieldMultiplayerUI.BlockFieldClicks`가 그 변환의 본보기다). +**등록은 가상 좌표다.** `RegisterBlockingRect`는 가상 좌표를 받고 `IsScreenPointOverHud`가 화면 좌표를 +`Scale`로 나눠 비교한다. `UIScale.Begin()` 안에서 그리는 화면은 그린 Rect를 **그대로** 넘긴다 +(`WorldFieldMultiplayerUI.BlockFieldClicks`·`SubAreaWorldBuilder.BlockFieldClicks`가 본보기다). 필드 HUD는 이제 +전부 가상 캔버스에 그린다 — 마지막까지 픽셀 좌표였던 그 둘을 2026-10-03에 옮겼다. 픽셀 좌표로 그리는 화면을 새로 +만든다면 `HudFrame.ToVirtual(pixelRect)`로 바꿔 넘길 것(그대로 넘기면 스케일이 1이 아닌 기기에서 엉뚱한 영역이 막힌다). +반대로 픽셀 화면을 가상 캔버스로 옮길 때는 **나누던 변환을 함께 걷어낸다** — 가상 Rect를 다시 `Scale`로 나누면 같은 결함이다. **네 번 났다.** 2026-08-17에 `QuickAccessBarUI`(메뉴를 열 때마다)와 `WorldFieldMultiplayerUI` ("3:3 대전"을 누르면 도전과 동시에 캐릭터가 상대 뒤로 걸어감)가 P0이었고, 2026-08-23에 @@ -276,6 +279,11 @@ done 이미 막힌다). 위험한 것은 **플레이어가 자유롭게 움직이는 동안 그려지는 것**뿐이다 — 로그인·오프닝(월드 없음), 미니게임·포획 선택(프리즈)은 제외하고 남는 것을 본다. +**가상 조이스틱도 이 목록을 본다**(`VirtualJoystickUI.CanBeginAt`) — 등록된 HUD 위에서는 조이스틱이 **시작하지 않는다**(이미 잡은 조이스틱은 +손가락이 HUD 위를 지나가도 놓치지 않는다). 예전엔 클릭-이동만 이 목록으로 걸러서 좌하단에 걸친 버튼(대화·동굴 입구·퀘스트 목표 행)을 누르면 +버튼과 조이스틱 이동이 함께 걸렸다(2026-10-03). 예외는 조이스틱 자리를 알려 주는 좌하단 안내 원(`HintCenter`·`HintRadius`) 안이다 — 꿈 섬 첫 걸음의 +안내 카드가 그 원을 덮어도 조작이 먹통처럼 보이지 않게. 그러니 **안내 원 위에는 버튼을 두지 말 것**(누르면 버튼과 조이스틱이 같이 걸린다). + `evt.Use()`는 방어가 **아니다.** 그건 IMGUI 안에서만 유효한데 `PlayerMovement`는 `Input.GetMouseButtonDown(0)`을 Update에서 따로 폴링한다 — IMGUI 밖이라 소비 여부를 모른다. @@ -283,6 +291,27 @@ done `QuickAccessBarUI`, `WorldFieldMultiplayerUI`, `TutorialQuestUI`, `SubAreaWorldBuilder`, `MinimapUI`·`PlayerStatusHUD`(버튼 위젯은 없지만 불투명 패널이라 같이 막는다). +## 필드 HUD는 자리를 순수 함수로 내고, 잠깐 뜨는 카드는 가운데 무대에 세운다 + +필드·동굴·섬 위 HUD는 OnGUI 순서가 정해져 있지 않아 겹치면 어느 쪽이 위인지도 모른다. 그래서 **겹치지 않는 것을 테스트로 고정한다** — +`HudOverlapSweepTests`가 화면 18장(데스크톱 6, 모바일 세로 2·가로 2 × 배율 0.667/1/1.333)에서 함께 뜰 수 있는 모든 요소의 +모든 쌍을 잰다. 2026-10-03 전수 검사 전엔 204쌍이 겹쳤다. + +- **자리는 그 화면 파일의 순수 함수**(`XxxRect(HudFrame f)`)로 낸다. `HudFrame`(UISafeLayout.cs)이 화면 한 장을 값으로 세운다 — + 그리는 쪽은 `HudFrame.Current`, 테스트는 `HudFrame.ForScreen(…)`. 식을 테스트에 다시 세우지 않는다(배치를 고치면 검사가 따라오게). +- **잠깐 뜨는 카드·알림은 `HudStage`(가운데 무대)에 선다.** `HudStage.Place(f, item, w, h)`가 자리를, `HudStage.Request(item)`이 차례를 준다 + — 차례 항목은 한 번에 하나, 앞 차례·고정 칸이 서 있으면 **그리지 말고 시간도 멈출 것**(기다리다 사라지면 못 본다). 고정 칸 셋 + (미니게임 결과·장소 진입 알림·동굴 출입 토스트)은 시간을 다른 담당 파일이 쥐고 있어 늘 서고, 정해진 칸에 차례로 쌓인다. + 새 카드를 만들면 `HudStageItem`에 우선순위 자리를 정해 넣는다. +- **무대는 섬 HUD가 서 있을 때만 섬 HUD를 피한다**(`HudStage.Area(f, islandHud)`) — 필드에서도 섬 열을 피하면 세로 화면의 카드가 왼쪽 절반으로 + 밀린다(2026-10-03 QA 실측, 720×1280에서 x 18~378). 판단은 `HudPresence`(`IslandHudUI`가 섬에서 매 프레임 표시)를 `HudFrame.Current`가 + `HudFrame.IslandHud`로 담아 넘긴다 — 테스트는 `f.WithIslandHud(true)`로 섬 화면을 세운다. 섬에서만 뜨는 HUD를 무대가 피해야 한다면 같은 식으로 맥락을 둘 것. +- 무대 안에 서는 가운데 것들(대화 버튼·코치 배너·섬 안내 배너·근처 탐험가)은 서 있는 카드와 겹치면 비켜선다(`HudStage.OccupiedOver`). + 그래서 카드를 그리는 자리에서는 `Request(item, rect)`로 **그리는 자리를 함께 알린다** — 자리를 모르는 카드는 무대 전체를 덮는다고 본다. +- **필드 위에 HUD를 새로 그리면 `HudOverlapSweepTests.Elements`에 한 줄 넣는다.** 함께 뜰 수 없는 쌍은 `Exclusion`에 코드 근거와 함께 뺀다 — + 근거(숨김 조건)를 지우면 그 행도 지울 것. 꿈(`DreamPrologueState.Active`)·섬(`detached` 서브에리어)·모달·조작 잠금(IsFrozen)에서 + 숨는지가 곧 그 근거다. + ## 구독은 `OnEnable`에서 되살린다 — `subscription_lint.py`가 잡는다 UI 컴포넌트가 `OnDisable`에서 `-=`로 해지한 이벤트는 **반드시 `OnEnable`에서 다시 `+=`** 한다. diff --git a/.claude/rules/world-environment.md b/.claude/rules/world-environment.md new file mode 100644 index 0000000..b137510 --- /dev/null +++ b/.claude/rules/world-environment.md @@ -0,0 +1,148 @@ +# 낮·밤과 날씨 규칙 + +하루 12분의 시계와 다섯 날씨(맑음·비·안개·센바람·눈)가 **하늘과 조명, 곤충의 출현, 곤충의 컨디션**을 바꾼다. +수치는 코드가 단일 출처다(`WorldSkyRules`·`InsectHabits`·`FieldSpawnRules`·`WeatherForecast`) — 여기엔 구조와, 어기면 조용히 깨지는 약속만 적는다. + +``` +GameClock ──DayPhaseChanged──▶ InsectSpawner(갈아입기) · WorldClockHUD(알림) +WeatherSystem ──WeatherChanged/Blend01──▶ WeatherEffects(입자) · SubAreaEnvironment(하늘) · WorldClockHUD + └▶ WorldStateProvider.GetWorldState(regionId) ──▶ InsectSpawner(후보·배수·보너스 슬롯) · InsectBattleController(전투 보정) · 습격 +``` + +## 날씨는 세계에 하나, 보이는 건 지역마다 다르다 + +`WeatherSystem`이 세계 날씨 하나를 돌리고(`WeatherForecast`가 다음·지속을 정한다), **지역 기후**가 보이는 날씨를 바꾼다 — +설산(frostline·mountain)은 비 → 눈, 사막·화산(dunes·emberfall)은 눈 → 센바람(`WeatherForecast.EffectiveIn`). + +- **스폰·전투·조명·HUD는 반드시 `GetWorldState(regionId)`(또는 `EffectiveIn`)를 읽는다.** 인자 없는 `GetWorldState()`는 세계 날씨라 + 설산에서 "비"로 판정해 눈 입자는 내리는데 곤충은 비 성향으로 움직이는 어긋남이 난다. 섬은 지역이 없어 세계 날씨를 그대로 쓴다. +- `WeatherType`은 **값 순서를 바꾸지 않는다.** `InsectSpawnCondition.allowedWeather`가 정수로 직렬화된다(`Snow`는 맨 뒤에 붙였다). +- 처음 3분은 맑음이다(`FirstClearSeconds`) — 튜토리얼 첫 장면이 비·안개에 묻히지 않게. 저장하지 않는다. +- 검수·꿈·연출이 붙잡을 때는 `GameClock.SetTime01(v, hold)`·`WeatherSystem.SetWeather(w, hold, instant)`를 쓰고 끝나면 `Release()`. + +## 하늘 — `SubAreaEnvironment`가 단독으로 쥔다 + +리전 대기(`RegionAtmosphere`)·서브에리어 프로필의 lerp는 **보정 전 `baseState`**만 만들고, `WriteFinal`이 그 위에 하늘(`WorldSkyRules`)을 +얹어 RenderSettings·라이트·카메라 배경에 한 번에 쓴다. **전환 스냅샷은 RenderSettings가 아니라 `baseState`에서 뜬다** — 최종값에서 뜨면 +리전 이동 도중 하늘 보정이 두 번 걸린다. + +- 보정이 걸리는 곳은 **메인 필드와 나의 섬뿐**이다. 동굴·방(실내)은 안 걸린다. **`DreamPrologueState.Active`(꿈)도 안 건다** — 꿈은 늘 맑은 한낮이다. + 드나들 때는 0.5초에 걸쳐 풀리고 걸린다. +- 섬은 카메라가 SolidColor라 **카메라 배경이 곧 하늘**이다 — 배경색도 보정한다. +- 밤이 어두워 게임이 안 보이면 실패다. 환경광 휘도 바닥(위 0.36·옆 0.29·아래 0.16)을 코드가 강제한다 — `MoonIntensity`와 함께 실기기 밝기로 조정한다. +- 안개 날씨는 11m(카메라~캐릭터)에서 투과율 하한을 지킨다(`RegionAtmosphere`의 `MaxWeatherFogDensity`·하한 상수, 섬은 Exp라 따로). 안개 모드는 + `RegionAtmosphere.FieldFogMode` 상수로만 적는다(`FieldFog_RuntimeUsesTheSharedModeConstant`). +- 스카이박스는 씬의 `Default-Skybox`를 **복제한 머티리얼 인스턴스**로 틴트한다 — 에셋을 고치지 않는다. 날씨 입자는 `Sprites/Default`(Always Included). + +## 곤충 성향 — `InsectHabits` + `InsectHabitTable` + +종마다 **활동 시간대**(주행성·야행성·무관)·**좋아하는/싫어하는 날씨**·**기질**(온순·습격형)을 한 줄씩 든다. 표에 없는 종은 중립이다. + +- **새 곤충을 추가하면 `InsectHabitTable`에 한 줄을 넣는다.** 안 넣어도 게임은 돌지만(중립) `InsectHabitsTests`가 "모든 종이 성향을 가진다"를 잡는다. +- **등급은 시간·날씨와 무관하다.** 등급을 먼저 전역 표로 굴리는 구조(`FieldSpawnRules.PickRarity`)는 그대로고, 성향 배수는 **같은 등급 안에서 종을 고를 때만** 곱한다. + 그래서 `progression_sim`의 리전 income은 바뀌지 않는다. +- **배수는 0이 되지 않는다**(`MinSpawnMultiplier`~`MaxSpawnMultiplier`로 clamp) — 풀이 비면 스폰이 영영 멈춘다. 후보가 비는 시간·날씨 조합이 없다는 걸 테스트가 센다. +- 바람 속성 곤충은 센바람을 좋아하고(부트스트랩이 나비·벌·나방을 거의 다 바람 속성으로 만든다), 설산·산 서식종은 눈을 좋아한다. + "날아다니는 종은 비를 싫어함"은 종별 예외로만 건다 — 한 날씨에 풀 대부분이 불리해지면 그 날씨가 곤충 없는 시간이 된다. +- 스폰은 `SpawnWeightMultiplier`, 전투 보정은 `BattleStatMultiplier`(아래 「전투 보정」), 습격은 `IsAmbusher`를 쓴다. + +## 전투 보정 — `BattleEnvironment` + +야생 전투에서 **내 곤충과 적이 각자의 성향으로** ATK·DEF를 유리 ×1.10 / 불리 ×0.90 곱한다(HP는 그대로 — HP바가 튀지 않게). +밤에 야행성 팀을 데려가는 전략이 이걸로 생긴다. + +- **제외**: 수문장·NPC 대결(보스 포함)·꿈 챔피언전(샌드박스)·레이드·실내 서브에리어. `BattleEnvironment.Applies`가 단일 출처다. +- **전투를 시작할 때의 하늘로 끝까지 잰다** — 교체로 들어온 곤충도 시작 시점 상태로 잰다(한 전투 안에서 두 하늘이 섞이지 않게). +- `InsectBattleStats.ApplyEnvironment`는 **처음 값을 기준으로 곱한다**(두 번 불러도 겹치지 않는다). 의상·아이템·버프 보너스(`AttackBonus`/`DefenseBonus`)와 섞지 않는다 — 그쪽은 매 턴 다시 계산된다. +- `BeginBattleCommon`이 `PlayerEnvironment`/`EnemyEnvironment`를 `None`으로 리셋한다. 안 하면 다음 전투가 이전 보정 칩을 물려받는다(샌드박스 표지와 같은 계열). +- 전투 화면 칩 문구(`Reason`)는 실제로 배수를 만든 쪽만 적는다 — "밤 · 야행성", "비 · 비를 싫어함". +- 도주 확률의 단일 출처는 `BattleEscapeRules.Chance`다(전투 [도망]과 습격 창의 [도망치기]가 함께 쓴다). + +## 스폰 — 보너스 슬롯과 갈아입기 + +- **보너스 슬롯**(`FieldSpawnRules.BonusSlots`): 밤 +1, 비·안개 +1, 합 최대 +2. `InsectSpawner.RegionCap`이 **보너스를 먼저 더한 합에 오염 감소를 얹는다.** + 반대 순서면 황폐한 리전(8칸→2칸)이 밤·비에 오히려 3~4칸으로 돌아온다. `blight_lint`가 이 경로를 본다. +- **보너스가 걷혀도 눈앞의 곤충은 사라지지 않는다.** `EnsureSlotCount`는 몸이 선 슬롯을 빼지 않고, 상한 초과 슬롯은 수명이 다했을 때 다시 굴리지 않고 비워서 정리한다. +- **갈아입기**: `DayPhaseChanged`가 오면 새 시간대에 어울리지 않는 종의 **만료만** 5~60초로 당긴다(`FieldPopulation.PullExpiryForward`) — 지우거나 바꾸지 않는다. + 교체는 평소 순환이 하므로 플레이어 25m 안 규칙(`CanRotate`)과 스토리 포획 목표종 면제가 그대로 지켜진다. **날씨가 바뀔 때는 갈아입기를 하지 않는다**(시간대만). +- 서브에리어(`TickSubArea`)는 시간·날씨를 보지 않는다. +- **나의 섬도 시간·날씨를 탄다**(세계 날씨) — 밤·비·안개면 같은 보너스 수(`BonusSlots`)만큼 손님 곤충이 찾아오고, 방목 곤충은 성향대로 활발하거나 쉰다. + 규칙은 `rules/island.md` 「손님 곤충」·「방목 곤충의 기분」. + +## 습격 — 싸움을 거는 곤충 + +깨어 있는 습격형(`InsectHabits.IsAmbusher`)은 플레이어를 알아채면 달아나는 대신 **멈칫 → 다가와 → 닿으면 「습격!」 창**을 띄운다. +창에는 [싸우기]·[도망치기] 둘뿐이다. 수치는 `AmbushRules`(순수)가 단일 출처다. + +``` +InsectEntity(알아챔) ──AmbushGate──▶ CaptureInputController.EvaluateAmbushGate ──▶ AmbushRules.Check(전역 칸은 프레임마다 한 번) + │ 허가 → 멈칫 → 다가가기(일정 간격으로 길을 잰다) + ├─AmbushStarted──▶ 잡기 버튼 위 경고 "덤벼든다!" + └─AmbushReached──▶ (판정 재확인) CaptureChoiceUI.ShowAmbush ──▶ [싸우기] 기존 [B] 배틀·레이드 / [도망치기] BattleEscapeRules.Chance +``` + +| 자리 | 하는 일 | +|---|---| +| `AmbushRules` | 거절 조건·쿨다운·거리·속도·다가갈 길 고르기·"왜 덤벼들었나" 한 줄 | +| `InsectEntity` | 알아챔·멈칫·접근·닿기·물러나기(AI). 판정은 static 훅 `AmbushGate`로 묻는다 — 풀 객체라 AutoWire가 없다. null이면 습격이 없다 | +| `CaptureInputController` | 훅을 OnEnable/OnDisable에서 세우고 지운다. 교전이 끝난 시각을 적어 쿨다운을 잰다. 닿으면 창을 연다 | +| `CaptureChoiceUI` | 습격 창(분기·그리기). 레이드 술어 `IsRaidRarity`와 `CanFight`를 판정과 함께 쓴다 | + +- **성향은 판정하는 그 순간의 상태로 본다** — 플레이어가 있는 리전의 `GetWorldState(regionId)`. 스폰 때 굴린 값이 아니라서 밤이 되면 서 있던 사마귀가 그 자리에서 사나워진다. 스폰 쪽은 바꾸지 않았다. +- **거절 조건**(`AmbushRules.Check` — 순서가 우선순위): 잠잠함(온순·제 시간 아님·싫은 날씨) → 수문장 → 이미 붙잡힘 → 꿈 → 서브에리어(동굴·방은 실내라 시간·날씨를 안 보고, 나의 섬은 집이다 — 밤·비에 찾아오는 손님 곤충도 덤벼들지 않는다) → + 플레이어가 다른 교전 중(선택 창·미니게임·전투·레이드) → 개체 쿨다운 → 전역 쿨다운 → 싸울 곤충 없음 → 멈춤 → 모달. + **다가오던 곤충은 멈춤·모달(대화·메뉴)에서만 기다리고 나머지에서는 물러난다** — 습격 창·전투도 플레이어를 멈추지만 「교전 중」이 먼저 걸려, + 옆에서 기다렸다가 전투가 끝나자마자 덮치지 않는다. 물러나기는 놓침 도주와 같은 길이다(스포너가 같은 개체를 눈 밖 다른 자리로 옮긴다). +- **싸울 곤충이 없으면 습격하지 않는다**(`AmbushRules.CanFight` — 1v1은 기절 안 한 출전 곤충 하나, 영웅·전설은 5칸이 찬 팀). 이 창엔 그냥 닫는 길이 없어서 + 빼면 첫 파트너가 없는 튜토리얼 초반에 닫을 수 없는 창이 뜬다. 판정과 창이 같은 술어를 쓴다 — 레이드 등급은 `CaptureChoiceUI.IsRaidRarity` 하나다. +- **쿨다운**: 전역 60초(습격 교전 — 창에서 이어진 전투·레이드까지 — 이 끝난 뒤) · 다른 교전 뒤 8초(씬이 막 열렸을 때도) · 개체 45초(붙잡혔다 풀려났거나 습격을 접은 몸). + 60초의 근거는 `AmbushRules` 주석이다 — 밤에 쉬지 않고 걸으면 깨어 있는 습격형을 분당 약 1회 만난다(표·로스터·밀도로 잰 값, 기기 실측 아님). +- **도망 규칙의 단일 출처는 `BattleEscapeRules.Chance`**(battle-dev) — 선두 출전 곤충(배틀팀 앞 칸부터 기절 안 한 첫 곤충) 레벨 대 상대 레벨. **한 번만** 굴린다. + 성공하면 창이 닫히고 곤충은 물러난다. 실패하면 「도망치지 못했다!」를 1.1초 보여 준 뒤 싸움으로 넘어간다(1v1은 출전 곤충 고르기, 영웅·전설은 레이드). +- **ESC는 닫기가 아니라 도망치기다.** ESC가 오는 길이 셋(창 자신·`CaptureInputController`·`PlayerMovement`의 모달 ESC)이라 전부 `CaptureChoiceUI.CloseModal`/`RequestClose`로 + 모으고 한 프레임에 한 동작만 받는다. **바깥에서 `Hide()`를 직접 부르지 말 것** — 도망 판정을 우회한다. 창이 뜬 뒤 0.45초는 입력을 받지 않는다(걷던 손가락이 버튼을 누르지 않게). +- 포획 버튼이 없다 — 덤벼드는 곤충을 미니게임으로 잡지 않는다. 이기면 전투의 포획 기회가 있다. 다가오는 중에는 [E]로 먼저 말을 걸 수 있다(`CanBeEngaged`의 뜻은 그대로다). +- **곤충은 몸 콜라이더가 없다** — 다가오는 길을 일정 간격으로 몸 높이 스피어캐스트로 재고(도주와 같은 측정), 울타리 난간(Ignore Raycast 층)도 본다. + 막혀서 못 가거나, 너무 멀어지거나, 너무 오래 쫓으면 접는다. 붙잡힌 몸은 지금 자리를 기준으로 삼는다(`RebaseHere`) — 안 그러면 경계 포즈가 스폰 자리로 순간이동시킨다. +- 연출(다가오는 동안 붉은 기운·`!` 표식)은 아직 없다 — 모양·색은 visual-dev 영역이라 `InsectEntity.IsAmbushing`을 읽을 자리로 열어 두었다. 지금은 경계 포즈와 경고 문구뿐이다. +- **습격은 어두울 때만 일어난다**(`InsectHabits.IsAmbushHour` — 저녁·밤). 요청이 "**밤에** 싸움을 거는 곤충"이다. 밤에는 궁합이 유리한 습격형(대부분 야행성)이, + 저녁에는 좋아하는 날씨일 때만 일찍 깬 습격형(해 질 녘 말벌 포함)이 덤벼든다. **아침·낮에는 아무도 싸움을 걸지 않는다.** + 처음엔 시간대 제한 없이 궁합만 봐서 주행성 말벌·사마귀가 한낮에 깨었고 맑은 낮(필드 한 칸의 4.6%)이 맑은 밤(4.1%)만큼, 정원·숲은 오히려 낮이 더 사나웠다(2026-10-03). +- **진짜 DB에서 언제 깨나**(`AmbushRulesTests`가 로그 `[Ambush]`로 남긴다): 밤 평균 약 8%(비·안개 밤 10% 남짓), 저녁은 좋아하는 날씨일 때만, 아침·낮 0. + `meadow`·`hollow`·`frostline`·`canopy` 풀에는 습격형이 없다 — 시작 리전(초원)은 밤에도 안전하다. 습격을 늘리려면 표(`InsectHabitTable`)와 리전 풀을 고친다. + +## HUD — `WorldClockHUD` + +우측 열 맨 아래의 칩(시간대·시각·날씨)과 변화 알림. 필드 위에 그리는 HUD라 `FieldHudInput.RegisterBlockingRect`(rules/ui-layout.md)와 +`DreamPrologueState.Active` 숨김(rules/dream-prologue.md 표)을 지킨다. 날씨는 **현재 리전 기준**이고, 보이는 날씨가 안 바뀌면 알리지 않는다. +시각은 10분 단위 내림이다(반올림하면 20:55가 "저녁 21:00"으로 보여 시간대 이름과 어긋난다). **섬에서도 보인다**(섬 하늘도 낮·밤·날씨를 따른다) — +지역이 없어 세계 날씨를 쓰고, 모바일에서는 섬 HUD 열과 안 겹치는 자리를 따로 잡는다(`WorldClockRules.IslandMobileChip`). 동굴·방에서는 시각만 보인다. + +전투 화면은 각 HP 카드 바로 아래에 보정 칩(`BattleScreenUI.Environment.cs`)을 그린다 — 유리 민트·불리 코랄, 보정이 없으면 자리도 비우지 않는다. + +## 검증 + +``` +-testPlatform PlayMode -testFilter InsectGame.Tests.WeatherForecastTests +-testPlatform PlayMode -testFilter InsectGame.Tests.WorldSkyRulesTests +-testPlatform PlayMode -testFilter InsectGame.Tests.InsectHabitsTests +-testPlatform PlayMode -testFilter InsectGame.Tests.WorldClockHudTests +-testPlatform PlayMode -testFilter InsectGame.Tests.AmbushRulesTests +``` + +화면은 `LiveSceneCapture`로 본다(`rules/testing.md`) — **기본값은 정오·맑음으로 고정된다**(안 그러면 게임 시계가 도는 대로 매번 다른 조명으로 찍혀 전후 비교가 깨진다). +`-captureHour <0~24>` `-captureWeather `로 고정하고, 게임이 스스로 돌리는 값으로 찍으려면 `natural`을 준다. +두 인자는 **쉼표 목록**도 받는다(촬영 순서대로) — Unity 한 번에 여러 조합을 찍는다(`-captureTimes 12,20,28 -captureHour 12,12,0 -captureWeather snow,rain,rain`). +**첫 촬영은 10초 이후, 간격은 6초 이상.** 촬영 시각은 플레이모드 진입부터 재는데 부트스트랩이 한 프레임에 수 초를 써서, 그보다 이르면 +날씨를 건 바로 그 틱에 찍혀 하늘도 입자도 맑음 그대로 나온다(2026-10-03에 "입자가 안 나온다"로 착각했다 — 로그의 `[WX]` 줄이 +층마다 켜짐·살아 있는 입자 수를 적으니 그걸 먼저 볼 것). 밤 별은 낮은 구도(`-captureOffset 0,1.2,-6 -captureLook 0,3,8`)로 본다. +`FieldDesignTour`는 `-tourHour`·`-tourWeather`. + +입자에서 실제로 고친 것 둘: 눈은 공중 전체 높이에서 생긴다(판에서만 뿌리면 시야까지 내려오는 데 5초가 넘었다), 비는 카메라 둘레 4m를 비운 +고리에서 뿌린다(렌즈 코앞을 지나는 빗방울이 화면을 가르는 빛줄기가 됐다). + +``` +python -X utf8 .claude/scripts/blight_lint.py # 보너스 슬롯이 오염 상한을 못 풀었는지 +python -X utf8 .claude/scripts/data_lint.py +python -X utf8 .claude/scripts/verify_coverage.py +``` diff --git a/.claude/scripts/blight_lint.py b/.claude/scripts/blight_lint.py index a7401c5..cf5c0aa 100644 --- a/.claude/scripts/blight_lint.py +++ b/.claude/scripts/blight_lint.py @@ -199,14 +199,34 @@ def add(name, expect, got, ok): add("정화 호출부(NpcDuelController)", "존재", "있음" if cleanse else "**없음 — 이겨도 정화되지 않는다**", cleanse) - # 13. 스폰 상한이 전부 BlightPolicy 경유인가. - # CountActiveInRegion(...) 비교가 곧 "이 리전에 더 띄울까"다. 하나라도 날것의 - # maxActivePerRegion을 쓰면 그 경로만 오염을 무시해 스폰 편향이 생긴다. + # 13. 리전 곤충 수가 전부 BlightPolicy 경유인가. + # 스포너는 리전마다 슬롯(기록된 개체)을 두고 그 수를 EnsureSlotCount 하나로만 바꾼다 + # (2026-09-29 시간 기반 재생 — 옛 "CountActiveInRegion < maxActivePerRegion" 비교는 없어졌다). + # 메인 필드 호출(isSubArea: false)이 하나라도 RegionCap을 거치지 않으면 그 경로만 오염을 무시하고, + # RegionCap이 MaxActiveFor를 안 부르면 오염 리전이 줄지 않는다. 옛 날것 비교도 계속 0건이어야 한다. spawner = code_only(read("Assets/Scripts/Spawning/InsectSpawner.cs")) raw_cmp = re.findall(r"CountActiveInRegion\([^)]*\)\s*[<>]=?\s*maxActivePerRegion", spawner) - add("리전 상한 비교 = BlightPolicy 경유", "0건 날것", - str(len(raw_cmp)) + "건 " + str(raw_cmp) if raw_cmp else "0건 (전부 RegionCap 경유)", - not raw_cmp) + cap_idx = spawner.find("int RegionCap(") + if cap_idx < 0: + raise gf.ExtractorBroken("InsectSpawner에서 RegionCap을 못 찾았다 — 이름이 바뀌었으면 검사기부터 고칠 것") + cap_tail = spawner[cap_idx:] + cap_end = re.search(r"\n (?:private|public|internal|protected)\s", cap_tail[1:]) + cap_body = cap_tail[:cap_end.start() + 1] if cap_end else cap_tail + field_calls = [c for c in re.findall(r"EnsureSlotCount\(([^;]*)\)\s*;", spawner) if "isSubArea: false" in c] + if not field_calls: + raise gf.ExtractorBroken( + "InsectSpawner에서 메인 필드 EnsureSlotCount(…, isSubArea: false) 호출을 못 찾았다 — 구조가 바뀌었는가?") + bypass = [c.strip() for c in field_calls if "RegionCap(" not in c] + problems = [] + if "BlightPolicy.MaxActiveFor(" not in cap_body: + problems.append("RegionCap이 BlightPolicy.MaxActiveFor를 안 부름") + if bypass: + problems.append("RegionCap 우회 %d건 %s" % (len(bypass), bypass)) + if raw_cmp: + problems.append("날것 비교 %d건 %s" % (len(raw_cmp), raw_cmp)) + add("리전 곤충 수 = BlightPolicy 경유", "0건 우회", + "; ".join(problems) if problems else "0건 (슬롯 조정 %d곳 전부 RegionCap 경유)" % len(field_calls), + not problems) # 14. 스폰 하한 — 0이면 그 리전의 포획·전투 비트가 전부 도달 불가가 된다. policy = code_only(read("Assets/Scripts/Core/BlightPolicy.cs")) diff --git a/.claude/scripts/economy_sim.py b/.claude/scripts/economy_sim.py index aecd5fa..3b5ee82 100644 --- a/.claude/scripts/economy_sim.py +++ b/.claude/scripts/economy_sim.py @@ -26,6 +26,10 @@ 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 +# 근거: 섬은 곁들이는 수입이다. 기본 섬이 활동 수입의 20%를 넘거나 풀 확장 섬이 1.25배를 넘으면 +# "섬만 돌려도 되는 게임"이 된다(Docs/IslandDesign.md 3장, IslandYieldTests가 같은 대역을 본다). +THRESHOLD_ISLAND_BASIC_SHARE_FAIL = 0.20 +THRESHOLD_ISLAND_MAX_SHARE_FAIL = 1.25 # === 정본 = 코드. 사본 없음 === # "동기화 필요"라 적어둔 사본은 동기화되지 않는다 — 실제로 박스 가격이 실버 800 / @@ -182,6 +186,57 @@ def p2w_outfit_gap() -> dict: "ratio_note": "평균 의상가 — 정확값은 CharacterOutfitManager.cs 인스펙터 참조"} +def island_daily_candy() -> dict: + """섬 일일 캔디 — 기본 섬(일반 3마리)과 풀 확장 섬(전설 최대 마리 · 보너스 전부). + + 상수는 코드에서 읽는다(사본 없음). 못 읽으면 조용히 0을 내지 않고 ExtractorBroken으로 죽는다. + 공식은 IslandYield.CandyPerHour와 같아야 한다: + 캔디/h = CandyPerInsectHour × 섬 등급 배율 × (1 + 쾌적도 보너스 + 설비 보너스) × (1 + 친밀도 보너스) + """ + def read(path): + try: + with open(path, encoding="utf-8-sig") as f: + return strip_cs(f.read()) + except OSError as e: + raise game_facts.ExtractorBroken(f"{path} 읽기 실패: {e}") + + def const(src, name, path): + m = re.search(r"\bconst\s+(?:float|int)\s+" + name + r"\s*=\s*([0-9.]+)f?\s*;", src) + if not m: + raise game_facts.ExtractorBroken(f"{path}에서 상수 {name}을 찾지 못했다") + return float(m.group(1)) + + gc_path = "Assets/Scripts/Core/GameConstants.cs" + gc = read(gc_path) + block = re.search(r"class\s+Island\b(.*?)class\s+Battle\b", gc, re.S) + if not block: + raise game_facts.ExtractorBroken(f"{gc_path}에서 Island 상수 블록을 찾지 못했다") + isl = block.group(1) + per_hour = const(isl, "CandyPerInsectHour", gc_path) + base_slots = const(isl, "BaseInsectSlots", gc_path) + max_slots = const(isl, "MaxInsectSlots", gc_path) + max_comfort = const(isl, "MaxComfortBonus", gc_path) + bond_per_level = const(isl, "BondBonusPerLevel", gc_path) + max_bond = const(isl, "MaxBondLevel", gc_path) + + yield_path = "Assets/Scripts/Core/IslandYield.cs" + m = re.search(r"case\s+InsectRarity\.Legendary\s*:\s*return\s+([0-9.]+)f\s*;", read(yield_path)) + if not m: + raise game_facts.ExtractorBroken(f"{yield_path}에서 전설 등급 배율을 찾지 못했다") + legendary = float(m.group(1)) + + cat_path = "Assets/Scripts/Core/IslandCatalog.cs" + tool_bonus = sum(float(v) for v in re.findall( + r"IslandEffectKind\.YieldBonus\s*,\s*([0-9.]+)f", read(cat_path))) + if tool_bonus <= 0: + raise game_facts.ExtractorBroken(f"{cat_path}에서 생산 설비(YieldBonus)를 찾지 못했다") + + basic = base_slots * per_hour * 24.0 + full = (max_slots * per_hour * legendary * (1.0 + max_comfort + tool_bonus) + * (1.0 + bond_per_level * max_bond) * 24.0) + return {"basic": basic, "full": full} + + def evaluate_signals(args) -> list: signals = [] @@ -230,6 +285,20 @@ def evaluate_signals(args) -> list: f"{ratio:.1f}x (코인 {p2w['basic_avg_coin']} vs 젬 {p2w['premium_avg_gem']})", judge)) + # 6. 섬 수입 — 활동 수입 대비 몫. 방치 수입이 주 수입을 넘으면 필드에 나갈 이유가 사라진다. + island = island_daily_candy() + daily = max(1e-6, args.daily_candy_income) + basic_share = island["basic"] / daily + full_share = island["full"] / daily + judge = "FAIL" if basic_share > THRESHOLD_ISLAND_BASIC_SHARE_FAIL else "PASS" + signals.append(("섬 기본 수입 (일반 3마리)", + f"<= 활동 수입의 {THRESHOLD_ISLAND_BASIC_SHARE_FAIL:.0%}", + f"하루 {island['basic']:.0f}캔디 = {basic_share:.0%}", judge)) + judge = "FAIL" if full_share > THRESHOLD_ISLAND_MAX_SHARE_FAIL else "PASS" + signals.append(("섬 최대 수입 (전설 최대 마리 · 보너스 전부)", + f"<= 활동 수입의 {THRESHOLD_ISLAND_MAX_SHARE_FAIL:.0%}", + f"하루 {island['full']:.0f}캔디 = {full_share:.0%}", judge)) + return signals diff --git a/.claude/scripts/gacha_sim.py b/.claude/scripts/gacha_sim.py index faa2ee2..4a56f92 100644 --- a/.claude/scripts/gacha_sim.py +++ b/.claude/scripts/gacha_sim.py @@ -67,10 +67,11 @@ def _load_facts(): # 가챠 EV 가중치 = 게임 내 실제 보상 배율 — 박스 1회 가치를 보상 가치 기준으로 환산. BOX_DEFS, EXCLUSIVE_POOL_SIZE, RARITY_MULT, FIELD_SHINY_PCT, GACHA_SHINY_PCT = _load_facts() -# Lv30 캔디 비용 (progression_sim과 일치) -# 코드가 단일 출처다 — 예전엔 `int(4 * (1.14 ** 29))`를 여기 박아 두어 곡선이 12.5%로 바뀐 뒤에도 178을 썼다. +# Lv30 캔디 비용 (progression_sim과 같은 식 — game_facts.candy_cost_at) +# 코드가 단일 출처다 — 예전엔 `int(4 * (1.14 ** 29))`를 여기 박아 두어 곡선이 12.5%로 바뀐 뒤에도 178을 썼고, +# 그 뒤엔 배선되지 않은 지수식(122)을 읽었다. 실제로 빠지는 값은 폴백 선형식이다(Lv30 = 62). _CURVE = game_facts.insect_candy_curve() -LV30_CANDY_COST = int(_CURVE["base"] * (_CURVE["growth"] ** 29)) +LV30_CANDY_COST = game_facts.candy_cost_at(_CURVE, 30) def draw_rarity(box: str, rng: random.Random) -> str: diff --git a/.claude/scripts/game_facts.py b/.claude/scripts/game_facts.py index f47664a..f950368 100644 --- a/.claude/scripts/game_facts.py +++ b/.claude/scripts/game_facts.py @@ -38,6 +38,8 @@ "reward_calc": "Assets/Scripts/Data/InsectRewardCalculator.cs", "tutorial": "Assets/Scripts/Core/TutorialQuestManager.cs", "insect_entity": "Assets/Scripts/Spawning/InsectEntity.cs", + # 필드 스폰 규칙(전역 등급표 · 레벨 · 슬롯). 리전 등급 분포의 단일 출처다(2026-09-29부터). + "field_rules": "Assets/Scripts/Spawning/FieldSpawnRules.cs", "raid": "Assets/Scripts/Battle/RaidBattleController.cs", "game_constants": "Assets/Scripts/Core/GameConstants.cs", "trainer_progress": "Assets/Scripts/Core/PlayerProgressController.cs", @@ -60,6 +62,9 @@ "village_builder": "Assets/Scripts/Core/VillageBuilder.cs", "stage_library": "Assets/Scripts/Story/StoryStageLibrary.cs", "subarea_builder": "Assets/Scripts/Core/SubAreaWorldBuilder.cs", + # 대결 표 — 명부회 간부(인물당 하나)와 라온 라이벌 단계(인물 하나·단계 여럿). story_lint 검사 35가 duelAfter를 대조한다. + "boss_duels": "Assets/Scripts/NPC/NpcBossDuels.cs", + "rival_duels": "Assets/Scripts/NPC/NpcRivalDuels.cs", } RARITIES = ("Common", "Uncommon", "Rare", "Epic", "Legendary") @@ -310,16 +315,76 @@ def trainer_xp_curve() -> dict: return {"base": base, "growth": growth, "floor": floor, "max": maxlv, "kind": "linear"} +def trainer_level_gap() -> dict: + """캐릭터 레벨 ↔ 곤충 레벨 차 계수. 출처: GameConstants.TrainerLevel(공식은 TrainerLevelGap). + + EXP = base × 등급배율 × (1 + (곤충Lv−1)·exp_per_level) × 차 배율 + 차 배율: 곤충이 높으면 1 + min(차, higher_cap)·higher_step, 낮으면 max(lower_min, 1 + 차·lower_step) + 포획 = 최종 확률 × max(capture_min, 1 − (차 − grace)·capture_drop) (차 > grace일 때만) + 2026-10-01 전엔 EXP가 등급만 봐서 이 계수가 없었다 — Lv60 일반 곤충도 Lv1과 같은 5였다. + """ + src = _read("game_constants") + block = _need(re.search(r"class\s+TrainerLevel\s*\{(.*?)\n\s{8}\}", src, re.DOTALL), + "TrainerLevel 블록", "game_constants").group(1) + + def num(name: str, kind=float): + pat = r"\b" + name + r"\s*=\s*([\d.]+)f?;" + return kind(_need(re.search(pat, block), name, "game_constants").group(1)) + + return { + "grace": num("CaptureGraceLevels", int), + "capture_drop": num("CaptureDropPerLevel"), + "capture_min": num("MinCaptureMultiplier"), + "exp_per_level": num("ExpPerInsectLevel"), + "higher_step": num("ExpHigherBonusPerLevel"), + "higher_cap": num("ExpHigherCapLevels", int), + "lower_step": num("ExpLowerPenaltyPerLevel"), + "lower_min": num("ExpLowerMinFactor"), + } + + +def _level_curve_wired() -> bool: + """InsectLevelCurve가 **실제로 배선되는가** — `levelCurve`/`defaultCurve`에 값을 넣는 코드가 있는가. + + `PlayerInsectCollection`은 곡선이 null이면 GameConstants.Leveling의 폴백(선형)으로 캔디를 뺀다. + 곡선 에셋이 프로젝트에 없고 InsectData·컬렉션이 전부 런타임 생성이라, 대입하는 코드가 없으면 SO의 + 지수식은 **죽은 코드**다. 2026-09-30까지 이 추출기는 그 죽은 식(4×1.125^)을 읽어 진행 판정을 냈다 — + Lv50 한 레벨이 실제 102캔디인데 1,284로 계산했다. + """ + rx = re.compile(r"\b(?:levelCurve|defaultCurve)\s*=(?!=)") + for root, _dirs, files in os.walk("Assets/Scripts"): + for name in files: + if not name.endswith(".cs"): + continue + with open(os.path.join(root, name), encoding="utf-8", errors="replace") as f: + if rx.search(strip_cs(f.read())): + return True + return False + + def insect_candy_curve() -> dict: - """곤충 레벨업 캔디 비용 곡선. 출처: InsectLevelCurve.GetCandyCost. + """곤충 레벨업 캔디 비용 곡선 — **실제로 캔디를 빼는 식**. - 지수: baseCandyCost * growth^(level-1). 곤충은 이 캔디 경로로만 큰다 - (TryLevelUpWithCandy). 곤충 XP 곡선(GetXpToNextLevel, 20*1.12^)은 배선만 돼 있고 + 곤충은 캔디로만 큰다(TryLevelUpWithCandy → GetCandyCostForLevel). 곡선 SO가 배선되지 않았으면 + (`_level_curve_wired`) 컬렉션의 폴백 선형식 `base + (lv-1)*step`(GameConstants.Leveling)이 정본이고, + 배선됐으면 InsectLevelCurve.GetCandyCost의 지수식이다. 곤충 XP 곡선(GetXpToNextLevel)은 게임플레이가 곤충에 XP를 주지 않아 미사용이다 — 진행 경로로 쓰면 안 된다. + 반환: {"kind": "linear", "base", "step", "max"} 또는 {"kind": "exponential", "base", "growth", "max"}. """ + if not _level_curve_wired(): + src = _read("game_constants") + block = _need(re.search(r"class\s+Leveling\s*\{(.*?)\n\s{8}\}", src, re.DOTALL), + "Leveling 블록", "game_constants").group(1) + base = int(_need(re.search(r"FallbackBaseCandyCost\s*=\s*(\d+)", block), + "FallbackBaseCandyCost", "game_constants").group(1)) + step = int(_need(re.search(r"FallbackCandyCostGrowth\s*=\s*(\d+)", block), + "FallbackCandyCostGrowth", "game_constants").group(1)) + maxlv = int(_need(re.search(r"FallbackMaxLevel\s*=\s*(\d+)", block), + "FallbackMaxLevel", "game_constants").group(1)) + return {"base": base, "step": step, "max": maxlv, "kind": "linear"} + src = _read("insect_curve") base = int(_need(re.search(r"baseCandyCost\s*=\s*(\d+)", src), "baseCandyCost", "insect_curve").group(1)) - # GetCandyCost 본체의 Mathf.Pow(1.14f, level - 1) body = _need(re.search(r"GetCandyCost\s*\([^)]*\)\s*\{(.*?)\n\s{8}\}", src, re.DOTALL), "GetCandyCost() 본체", "insect_curve").group(1) growth = float(_need(re.search(r"Pow\(\s*([\d.]+)f", body), "GetCandyCost의 성장률", "insect_curve").group(1)) @@ -327,6 +392,31 @@ def insect_candy_curve() -> dict: return {"base": base, "growth": growth, "max": maxlv, "kind": "exponential"} +def candy_cost_at(curve: dict, level: int) -> int: + """곡선 dict(`insect_candy_curve`)로 Lv→Lv+1 캔디. 두 시뮬(progression·gacha)이 같은 식을 쓴다.""" + if curve["kind"] == "linear": + return max(1, curve["base"] + (level - 1) * curve["step"]) + return max(1, round(curve["base"] * (curve["growth"] ** (level - 1)))) + + +def curve_label(curve: dict) -> str: + if curve["kind"] == "linear": + return f"선형 {curve['base']}+{curve['step']}*(lv-1)" + return f"지수 {curve['base']}*{curve['growth']}^(lv-1)" + + +def training_stat_cost() -> dict: + """능력치(개체값) 훈련 계수. 출처: GameConstants.Training(StatBaseCost·StatCostGrowth). + 공식은 TrainingPricing.StatCost = base × growth^iv × 등급배율(rarity_multipliers).""" + src = _read("game_constants") + block = _need(re.search(r"class\s+Training\s*\{(.*?)\n\s{8}\}", src, re.DOTALL), + "Training 블록", "game_constants").group(1) + base = int(_need(re.search(r"StatBaseCost\s*=\s*(\d+)", block), "StatBaseCost", "game_constants").group(1)) + growth = float(_need(re.search(r"StatCostGrowth\s*=\s*([\d.]+)f", block), + "StatCostGrowth", "game_constants").group(1)) + return {"base": base, "growth": growth, "max_iv": 15} + + def battle_rewards_by_rarity() -> dict: """등급별 전투/포획 보상 base(등급배율 적용 전). 출처: PlaySceneBootstrap switch(rarity). @@ -361,8 +451,10 @@ def field_roster() -> dict: 출처: PlaySceneBootstrap.CreateStableInsect(id, name, InsectRarity.X, weight, ...) + InsectExpansion(2)Definitions.new InsectSeed(id, name, InsectRarity.X, weight, ...). - 가챠 전용(weight=0)은 필드 스폰이 없으므로 제외한다. InsectSpawner.GetWeightedRandom이 - 이 spawnWeight로 후보를 뽑으므로, 리전 내 실제 조우 등급 분포의 단일 출처다. + 가챠 전용(weight=0)은 필드 스폰이 없으므로 제외한다. + + **등급 분포의 출처는 이제 여기가 아니다.** 필드 스폰은 등급을 전역 표(field_rarity_shares)로 먼저 굴리고 + spawnWeight는 그 등급 안에서 종을 고를 때만 쓴다(2026-09-29). 리전 풀에 어떤 등급이 있는지를 가리는 데 쓴다. """ out = {} for key, pat in ( @@ -432,6 +524,30 @@ def quest_types_enum() -> list: ] +# 조건부 퀘스트(QuestType.CaptureTrait / BattleFeat)가 읽는 TutorialQuest 조건 필드. +# 판정은 QuestTraitRules(C#)가 한다 — 여기는 "저작이 그 판정에 닿는가"만 본다. +TRAIT_CAPTURE_FIELDS = ("minSizeMm", "maxSizeMm", "minSizeRatio", "maxSizeRatio", "requireShiny") +TRAIT_BATTLE_FIELDS = ("minLevelEdge", "maxTurns", "minHpPercent", "resetOnLoss") +TRAIT_SHARED_FIELDS = ("requiredElement", "minRarity") +_TRAIT_FIELDS = TRAIT_CAPTURE_FIELDS + TRAIT_BATTLE_FIELDS + TRAIT_SHARED_FIELDS +# 기본값을 일부러 적어 둔 것은 조건이 아니다(C# 필드 기본값과 같은 값). +_TRAIT_DEFAULTS = {"0", "0f", "0.0f", "false", "InsectElement.None", "InsectRarity.Common"} + + +def _trait_fields(block: str) -> dict: + """퀘스트 블록에서 **채워진** 조건 필드만 {필드: 값 문자열}로.""" + out = {} + for name in _TRAIT_FIELDS: + m = re.search(rf"\b{name}\s*=\s*([^,\n]+)", block) + if not m: + continue + val = m.group(1).strip() + if val in _TRAIT_DEFAULTS: + continue + out[name] = val + return out + + def quest_defs() -> list: """[{questId, type, prereq, reward_insect, reward_item, reward_item_count, target}, ...] @@ -468,6 +584,10 @@ def i(name): "category": cat.group(1) if cat else "Story", "repeatable": (rep.group(1) == "true") if rep else False, "target_increment": i("targetIncrement") or 0, + # 지역 의뢰(Side 전용) — 비면 None(어디서든 센다). + "region": s("requiredRegionId"), + # 조건부 퀘스트 조건 — 채운 필드만. 조건부 타입이 아니어도 담아 둔다(엉뚱한 타입에 쓴 것을 검사 13이 잡는다). + "trait_fields": _trait_fields(block), }) if not out: raise ExtractorBroken("allQuests 배열에서 퀘스트를 하나도 못 읽었다 — 구조가 바뀌었는가?") @@ -543,11 +663,10 @@ def quest_progress_wiring() -> dict: # ── 스토리 ────────────────────────────────────────────────────────────────── -def story_beats() -> list: - """Story.json의 비트 목록 (파싱된 dict). json.load라 퀘스트 정규식보다 견고하다. +def story_document() -> dict: + """Story.json 루트 전체 (파싱된 dict) — StoryList 하나다(`beats`·`chapters`). - 설계(Docs/StorySystemDesign.md)가 데이터 모델을 JSON으로 정한 이유가 이것이다 — - lines[]/choices[] 중첩 구조를 정규식으로 자르는 대신 네이티브 파싱한다. + story_lint 검사 14가 루트 키까지 StoryList 필드와 대조하려고 통째로 읽는다. """ import json path = PATHS["story_json"] @@ -558,12 +677,34 @@ def story_beats() -> list: data = json.load(f) except (OSError, ValueError) as e: raise ExtractorBroken(f"{path} 파싱 실패: {e}") + if not isinstance(data, dict): + raise ExtractorBroken(f"{path}의 루트가 객체가 아니다 — StoryList 형식이 바뀌었는가?") + return data + + +def story_beats() -> list: + """Story.json의 비트 목록 (파싱된 dict). json.load라 퀘스트 정규식보다 견고하다. + + 설계(Docs/StorySystemDesign.md)가 데이터 모델을 JSON으로 정한 이유가 이것이다 — + lines[]/choices[] 중첩 구조를 정규식으로 자르는 대신 네이티브 파싱한다. + """ + data = story_document() beats = data.get("beats") if beats is None: - raise ExtractorBroken(f"{path}에 'beats' 키가 없다 — StoryList 형식이 바뀌었는가?") + raise ExtractorBroken(f"{PATHS['story_json']}에 'beats' 키가 없다 — StoryList 형식이 바뀌었는가?") return beats +def story_chapters() -> list: + """Story.json의 장 목록(`chapters`, 파싱된 dict) — 배열 순서가 곧 장 순서다. + + **키가 없으면 빈 목록이다**(추출기 고장이 아니다) — 런타임(JsonUtility)도 옛 JSON을 빈 목록으로 읽는다. + 장이 비어 있는 게 결함인지는 story_lint 검사 34가 판정한다. + """ + chapters = story_document().get("chapters") + return chapters if isinstance(chapters, list) else [] + + def story_trigger_wiring() -> dict: """{triggerType: (in_switch, has_event_source)} — StoryDirector가 각 트리거를 처리하는가. @@ -676,20 +817,23 @@ def _switch_case_ids(key: str, func: str) -> set: def story_npc_display_ids() -> set: - """표시명 switch에 등록된 스토리 NPC(NpcManager.StoryNpcDisplayName). + """표시명 switch에 등록된 스토리 NPC(NpcDialogueDatabase.StorySpeakerName). - **빠뜨리면 default로 떨어져 그 인물이 "마을 어르신"으로 뜬다.** 조용하다. + **빠뜨리면 내부 NPC ID가 이름으로 표시된다.** 월드와 대화가 같은 표를 쓴다. """ - return _switch_case_ids("npc_manager", "StoryNpcDisplayName") + return _switch_case_ids("npc_dialogue", "StorySpeakerName") def story_npc_appearance_ids() -> set: - """외형 switch에 등록된 스토리 NPC(NpcVisualBuilder.StoryNpcAppearance). + """외형 switch에 등록된 스토리 NPC(NpcVisualBuilder.StoryNpcFace — 얼굴·색). **빠뜨리면 default로 떨어져 마을 어르신 외형(백발·모자·따뜻한 상의)으로 뜬다.** 명부회 하수가 마을 어르신 얼굴로 서 있어도 예외도 경고도 안 난다. + + 공개 진입점 `StoryNpcAppearance`는 얼굴(`StoryNpcFace`) 위에 옷차림(`StorySignature`)을 입히는 + 래퍼다. 등록 여부를 가르는 건 얼굴 switch라 그쪽을 읽는다(옷차림 switch는 default가 어르신 옷이다). """ - return _switch_case_ids("npc_visual", "StoryNpcAppearance") + return _switch_case_ids("npc_visual", "StoryNpcFace") def stage_offsets() -> dict: @@ -924,6 +1068,62 @@ def blight_sites() -> list: return sites +def subarea_parent_regions() -> dict: + """{subAreaId: regionId} — 서브에리어가 어느 리전 안에 있는가. + + 출처: RegionDefinitions.CreateAll()의 각 RegionData 블록 안 `subAreaId = "..."`. + 서브에리어 안의 행동은 그 리전의 행동이다(RegionManager.ActionRegionId) — `SubAreaEnter` 비트의 리전을 정할 때 쓴다. + """ + src = _read("region_defs") + out = {} + starts = [m for m in re.finditer(r'regionId = "(\w+)"', src)] + for i, m in enumerate(starts): + end = starts[i + 1].start() if i + 1 < len(starts) else len(src) + for sub in re.findall(r'subAreaId\s*=\s*"([a-z_0-9]+)"', src[m.start():end]): + out.setdefault(sub, m.group(1)) + if not out: + raise ExtractorBroken("RegionDefinitions에서 subAreaId를 하나도 못 읽었다 — 구조가 바뀌었는가?") + return out + + +def boss_duel_npcs() -> set: + """명부회 간부 대결 상대의 storyNpcId 집합 — NpcBossDuels의 표. + + 표가 팀 대결로 바뀌어도(2026-10-04 battle-dev) `storyNpcId = "..."` 줄은 대결마다 하나다. + """ + ids = set(re.findall(r'storyNpcId\s*=\s*"([a-z_0-9]+)"', _read("boss_duels"))) + if not ids: + raise ExtractorBroken("NpcBossDuels에서 storyNpcId를 하나도 못 읽었다 — 표 구조가 바뀌었는가?") + return ids + + +def rival_duel_stages() -> list: + """[{stageId, storyNpcId, regionId, openBeatId, closeBeatId}, ...] — NpcRivalDuels 표(순서 그대로). + + 값이 문자열 리터럴이 아니라 같은 파일의 상수(`closeBeatId = InjuryBeatId`)일 수 있어 상수를 먼저 풀어 둔다. + 상수를 못 풀면 그 칸을 빈 문자열이 아니라 None으로 둔다 — 검사가 "닫힘 없음"으로 오해하지 않게. + """ + src = _read("rival_duels") + consts = dict(re.findall(r'const\s+string\s+(\w+)\s*=\s*"([^"]*)"', src)) + stages = [] + for block in re.findall(r"new\s+Stage\s*\{(.*?)\}", src, re.S): + stage = {} + for key in ("stageId", "storyNpcId", "regionId", "openBeatId", "closeBeatId"): + lit = re.search(key + r'\s*=\s*"([^"]*)"', block) + ref = re.search(key + r'\s*=\s*([A-Za-z_]\w*)\b', block) + if lit: + stage[key] = lit.group(1) + elif ref: + stage[key] = consts.get(ref.group(1)) + else: + stage[key] = "" + if stage.get("stageId"): + stages.append(stage) + if not stages: + raise ExtractorBroken("NpcRivalDuels에서 단계를 하나도 못 읽었다 — 표 구조가 바뀌었는가?") + return stages + + def team_max_slots() -> int: """배틀 팀 최대 슬롯 수. 출처: GameConstants.Battle.MaxTeamSlots. @@ -977,21 +1177,64 @@ def tutorial_rewards() -> dict: def field_shiny_pct() -> float: - """필드 샤이니 확률(%). 출처: InsectEntity.cs의 `shiny = Random.value < 0.01f`. + """필드 샤이니 확률(%). 출처: InsectEntity.cs의 `const float FieldShinyChance = 0.01f`. 느슨한 정규식(`shiny\\w*\\s*[=<]\\s*([\\d.]+)f`)을 먼저 썼다가 `cachedShinyShift = -1f` - 같은 무관한 필드를 물어 조용히 0.0을 반환했다. 대입 형태를 통째로 고정한다 — - 형태가 바뀌면 0을 반환하는 대신 ExtractorBroken으로 죽는 게 낫다. + 같은 무관한 필드를 물어 조용히 0.0을 반환했다. 선언 형태를 통째로 고정한다 — + 형태가 바뀌면 0을 반환하는 대신 ExtractorBroken으로 죽는 게 낫다. (2026-09-29부터 스포너가 슬롯에 개체를 + 들일 때 이 상수로 한 번 굴려 기록한다 — 예전의 `shiny = Random.value < 0.01f` 대입은 상수로 옮겨 갔다.) """ src = _read("insect_entity") m = _need( - re.search(r"\bshiny\s*=\s*UnityEngine\.Random\.value\s*<\s*([\d.]+)f", src), - "`shiny = UnityEngine.Random.value < Xf` 형태의 샤이니 확률", + re.search(r"\bconst\s+float\s+FieldShinyChance\s*=\s*([\d.]+)f", src), + "`const float FieldShinyChance = Xf` 형태의 샤이니 확률", "insect_entity", ) return float(m.group(1)) * 100.0 +def field_rarity_shares() -> dict: + """{"Common": 0.60, ..., "Legendary": 0.005} — 필드 전역 등급표(정규화 전 원래 몫). + + 출처: FieldSpawnRules.cs의 `public const float {Rarity}Share = Xf` 다섯 줄. 리전과 무관한 표다 — + 종은 그 등급 안에서 리전 풀로 고른다. 풀에 그 등급이 없으면 가까운 아래 → 위 등급으로 대체한다 + (field_rarity_fallback). + """ + src = _read("field_rules") + out = {} + for r in RARITIES: + m = _need(re.search(rf"\b{r}Share\s*=\s*([\d.]+)f", src), f"{r}Share 등급 몫", "field_rules") + out[r] = float(m.group(1)) + if sum(out.values()) <= 0: + raise ExtractorBroken(f"필드 등급표 합이 0 이하다 ({out}) — 추출이 어긋났는가?") + return out + + +def field_rarity_fallback(wanted: str, available: set): + """풀에 없는 등급의 대체 — FieldSpawnRules.Fallback과 같은 규칙(가까운 아래 → 위). 없으면 None. + + 규칙은 코드의 순수 함수가 단일 출처이고 여기는 시뮬이 같은 답을 내도록 옮긴 것이다. 코드에서 + 순서 문장을 읽어 확인한다 — "아래 → 위"가 바뀌면(주석이 아니라 루프 방향) 시뮬이 어긋나므로 죽는다. + """ + src = _read("field_rules") + body = _need(re.search(r"static int Fallback\(.*?\n \}", src, re.DOTALL), + "Fallback 본체", "field_rules").group(0) + down = body.find("r >= 0; r--") + up = body.find("r < available.Length; r++") + if down < 0 or up < 0 or down > up: + raise ExtractorBroken("FieldSpawnRules.Fallback의 순서(아래 먼저 → 위)가 바뀌었다 — 시뮬 규칙도 고칠 것") + if wanted in available: + return wanted + i = RARITIES.index(wanted) + for r in reversed(RARITIES[:i]): + if r in available: + return r + for r in RARITIES[i + 1:]: + if r in available: + return r + return None + + def gacha_has_shiny() -> bool: """가챠에 샤이니 로직이 있는가. 없으면 필드와의 격차가 위험 신호.""" return bool(re.search(r"[Ss]hiny", _read("gacha"))) diff --git a/.claude/scripts/progression_sim.py b/.claude/scripts/progression_sim.py index 818746b..96b31ed 100644 --- a/.claude/scripts/progression_sim.py +++ b/.claude/scripts/progression_sim.py @@ -3,7 +3,9 @@ 이 게임엔 분리된 두 레벨 시스템이 있다(코드로 확인): - 트레이너 레벨(PlayerProgressController): **선형** max(floor, base+(lv-1)*growth). 배틀/포획/레이드/튜토리얼 EXP가 전부 여기로 간다(GainXp → 트레이너). - - 곤충 레벨(InsectLevelCurve): **캔디**로만 큰다(TryLevelUpWithCandy). 지수 base*1.14^(lv-1). + - 곤충 레벨: **캔디**로만 큰다(TryLevelUpWithCandy → GetCandyCostForLevel). 곡선은 game_facts가 + 실제 배선을 보고 고른다 — 지금은 InsectLevelCurve SO가 배선되지 않아 폴백 선형식(GameConstants.Leveling)이 + 정본이다. 2026-09-30까지 이 시뮬은 죽은 지수식(4×1.125^)으로 판정했다(Lv50 한 레벨 실제 102 ↔ 시뮬 1,284). 옛 progression_sim은 이 둘을 혼동했다. 곤충 XP 곡선(20*1.12^)을 진행 경로로 썼는데, 곤충 XP(GainXp/currentXp)는 코드·UI에 배선만 돼 있고 **어떤 게임플레이도 곤충에 XP를 @@ -38,6 +40,9 @@ # 근거: 트레이너 곡선은 선형이라 후반/초반비가 완만해야 정상. 선형이 5배를 넘으면 # 어딘가 지수가 섞인 것(이탈 구간). 곤충 캔디(지수)는 이 검사 대상이 아니다 — 지수가 설계다. THRESHOLD_TRAINER_CURVE_WARN = 5.0 +# 근거: 캐릭터 1레벨에 드는 조우 수(같은 레벨 사냥)의 후반/초반비. 요구량이 선형으로 늘어도 EXP가 곤충 +# 레벨에 비례하면 상쇄된다. 4배를 넘으면 후반 캐릭터 레벨이 리전을 못 따라가 포획 레벨 제한에 걸린다. +THRESHOLD_TRAINER_PACE_WARN = 4.0 # 근거: 팀 전체 동시 Lv50 캔디. MaxTeamSlots 반영. 10만 초과는 캔디 인플레. THRESHOLD_TEAM_CANDY_FAIL = 100000 # 근거: 튜토리얼 캔디가 초반(Lv1→10) 곤충 캔디 비용의 몇 배인지. 초반 부양 강도. @@ -54,10 +59,13 @@ def _load_facts(): "raid_mult": game_facts.raid_reward_mult(), "team_max": game_facts.team_max_slots(), "trainer": game_facts.trainer_xp_curve(), + "gap": game_facts.trainer_level_gap(), "candy_curve": game_facts.insect_candy_curve(), + "stat_cost": game_facts.training_stat_cost(), "battle": game_facts.battle_rewards_by_rarity(), "roster": game_facts.field_roster(), "regions": game_facts.region_pools(), + "shares": game_facts.field_rarity_shares(), } except game_facts.ExtractorBroken as e: print(f"추출기 고장: {e}\n게임 수치를 코드에서 읽지 못했다 — 시뮬을 돌리지 않는다.", @@ -79,9 +87,8 @@ def trainer_xp_to_next(level: int) -> int: def insect_candy_cost(level: int) -> int: - """곤충 Lv->Lv+1 필요 캔디. 지수: base*growth^(lv-1).""" - c = F["candy_curve"] - return max(1, round(c["base"] * (c["growth"] ** (level - 1)))) + """곤충 Lv->Lv+1 필요 캔디 — 실제로 빠지는 식(game_facts.candy_cost_at).""" + return game_facts.candy_cost_at(F["candy_curve"], level) def total_trainer_xp(target: int) -> int: @@ -101,12 +108,50 @@ def reward_per_battle(rarity: str) -> dict: return {"exp": b["exp"] * m, "candy": b["candy"] * m} +def exp_multiplier(trainer: int, insect: int) -> float: + """TrainerLevelGap.ExpMultiplier 그대로 — 곤충 레벨 배율 × 캐릭터와의 레벨 차 배율.""" + g = F["gap"] + gap = max(1, insect) - max(1, trainer) + level = 1 + (max(1, insect) - 1) * g["exp_per_level"] + if gap >= 0: + diff = 1 + min(gap, g["higher_cap"]) * g["higher_step"] + else: + diff = max(g["lower_min"], 1 + gap * g["lower_step"]) + return level * diff + + +def exp_reward(rarity: str, insect: int, trainer: int) -> int: + """InsectRewardCalculator.GetExpReward(data, insectLevel, trainerLevel) — 실제 지급 EXP(부스터 제외).""" + return max(0, round(reward_per_battle(rarity)["exp"] * exp_multiplier(trainer, insect))) + + +def expected_exp_same_level(level: int) -> float: + """같은 레벨 곤충 1마리의 기대 EXP — 필드 전역 등급표 분포.""" + tot = sum(F["shares"].values()) + return sum(F["shares"][r] / tot * exp_reward(r, level, level) for r in RARITIES) + + +def encounters_per_trainer_level(level: int, scaled: bool = True) -> float: + """같은 레벨 곤충만 상대할 때 캐릭터 1레벨에 드는 조우 수. scaled=False면 옛 식(등급만).""" + if scaled: + per = expected_exp_same_level(level) + else: + tot = sum(F["shares"].values()) + per = sum(F["shares"][r] / tot * reward_per_battle(r)["exp"] for r in RARITIES) + return trainer_xp_to_next(level) / per if per else 0.0 + + # ── 진행 추정 ── def trainer_battles(target: int, rarity: str) -> int: - """트레이너 target 레벨까지 필요한 전투 수(같은 등급 적 기준).""" - per = reward_per_battle(rarity)["exp"] - return round(total_trainer_xp(target) / per) if per else 0 + """트레이너 target 레벨까지 필요한 전투 수(같은 등급·**같은 레벨** 적 기준 — EXP가 곤충 레벨에 비례한다).""" + total = 0.0 + for level in range(1, target): + per = exp_reward(rarity, level, level) + if per <= 0: + return 0 + total += trainer_xp_to_next(level) / per + return round(total) def insect_candy_battles(target: int, rarity: str) -> int: @@ -124,25 +169,36 @@ def _candy_of(rarity: str) -> int: return int(F["battle"][rarity]["candy"] * MULT[rarity]) -def region_income_curve() -> list: - """[(requiredLevel, E[candy]/전투), ...] 오름차순 — 리전별 스폰가중 기대 캔디. +def region_rarity_shares(ids) -> dict: + """리전 풀 하나의 실제 등급 분포 — 전역 등급표(FieldSpawnRules)를 풀에 있는 등급으로 대체해 합친 것. - 각 리전 insectIds를 spawnWeight로 가중해(InsectSpawner.GetWeightedRandom 그대로) 등급 - 분포를 구하고 전투당 기대 캔디를 낸다. 리전 진행에 따라 조우 등급이 오르므로 income도 오른다. + 필드 스폰은 등급을 먼저 전역 표로 굴리고 종은 그 등급 안에서 리전 풀로 고른다(2026-09-29). 그래서 + 풀이 다섯 등급을 다 가지면 모든 리전이 같은 분포이고, 빠진 등급만 가까운 아래 → 위로 옮겨 간다. + 풀 구성은 코드(RegionDefinitions)에서 읽으므로 풀이 바뀌면 여기도 따라간다. """ roster = F["roster"] # {id: (rarity, weight)} + available = {roster[i][0] for i in ids if i in roster and roster[i][1] > 0} + total = sum(F["shares"].values()) + out = {r: 0.0 for r in RARITIES} + for r in RARITIES: + to = game_facts.field_rarity_fallback(r, available) + if to is not None: + out[to] += F["shares"][r] / total + return out + + +def region_income_curve() -> list: + """[(requiredLevel, E[candy]/전투), ...] 오름차순 — 리전별 기대 캔디. + + 리전 등급 분포(region_rarity_shares — 전역 등급표 + 대체)로 전투당 기대 캔디를 낸다. 예전엔 풀의 + spawnWeight 가중이 곧 등급 분포라 뒤 리전일수록 희귀가 흔해 income이 올랐다(유적 ~7캔디). 지금은 + 희귀도가 리전과 무관해 풀이 다섯 등급을 다 갖는 한 모든 리전의 income이 같다 — 진행이 올리는 건 레벨뿐이다. + """ out = [] for _rid, req, ids in F["regions"]: - tot = 0.0 - acc = 0.0 - for iid in ids: - if iid in roster: - rar, w = roster[iid] - if w > 0: - tot += w - acc += w * _candy_of(rar) - if tot > 0: - out.append((req, acc / tot)) + shares = region_rarity_shares(ids) + if sum(shares.values()) > 0: + out.append((req, sum(p * _candy_of(r) for r, p in shares.items()))) out.sort() return out @@ -151,9 +207,7 @@ def insect_candy_battles_realistic(target: int) -> int: """곤충 레벨이 리전 진행과 동기화된다는 가정의 현실적 전투 수. 곤충 레벨 L을 올릴 캔디를, 그 시점 플레이어가 있는 리전(requiredLevel<=L 중 최상위)의 - 기대 캔디로 나눠 누적한다. 캔디 비용이 지수라 후반 레벨(전체의 84%가 Lv36+)이 - 고레어 엔드리전 income으로 벌리므로 Common 고정보다 크게 낮다. 레이드×3·가챠·튜토리얼은 - 여전히 별도(추가 하향)라 이 값도 상한 성격이다. + 기대 캔디로 나눠 누적한다. 레이드×3·가챠·튜토리얼은 여전히 별도(추가 하향)라 이 값도 상한 성격이다. """ income = region_income_curve() if not income: @@ -188,10 +242,10 @@ def tutorial_early_share(early_level: int = 10) -> float: def render_curve_table(target: int) -> str: - out = ["| Lv | 트레이너 EXP/Lv (선형) | 곤충 캔디/Lv (지수) | 트레이너 EXP누적 | 곤충 캔디누적 |", + out = ["| Lv | 트레이너 EXP/Lv (선형) | 곤충 캔디/Lv | 트레이너 EXP누적 | 곤충 캔디누적 |", "|----|----:|----:|----:|----:|"] cum_xp = cum_candy = 0 - checkpoints = {1, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50} + checkpoints = {1, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80} for L in range(1, target + 1): if L < target: cum_xp += trainer_xp_to_next(L) @@ -202,6 +256,55 @@ def render_curve_table(target: int) -> str: return "\n".join(out) +def stat_training_cost(iv: int, rarity: str) -> int: + """능력치(개체값) iv → +1 캔디. TrainingPricing.StatCost와 같은 식(계수는 GameConstants.Training).""" + c = F["stat_cost"] + if iv >= c["max_iv"]: + return 0 + return max(1, round(c["base"] * (c["growth"] ** iv) * MULT[rarity])) + + +def render_trainer_exp_section() -> str: + """캐릭터 EXP — 곤충 레벨·레벨 차 배율(TrainerLevelGap). 참고 표 + 신호 5의 근거.""" + g = F["gap"] + lines = ["## 캐릭터 EXP — 곤충 레벨 × 레벨 차 (TrainerLevelGap)", + f"- 레벨 배율 1 + (곤충Lv−1)×{g['exp_per_level']} · 차 배율: 높으면 +{g['higher_step']:.0%}/Lv" + f"(최대 {1 + g['higher_cap'] * g['higher_step']:.1f}배), 낮으면 −{g['lower_step']:.0%}/Lv" + f"(최저 {g['lower_min']}배)", + f"- 포획 제한: 레벨 차 +{g['grace']} 초과 1Lv마다 최종 확률 −{g['capture_drop']:.0%}" + f"(최저 ×{g['capture_min']})", + "| 같은 레벨 | 일반 EXP | 전설 EXP | 캐릭터 1Lv당 조우(현행) | (등급만 보던 옛 식) |", + "|----|----:|----:|----:|----:|"] + for lv in (5, 20, 40, 60, 75): + lines.append(f"| Lv{lv} | {exp_reward('Common', lv, lv)} | {exp_reward('Legendary', lv, lv)} | " + f"{encounters_per_trainer_level(lv):.1f} | {encounters_per_trainer_level(lv, scaled=False):.1f} |") + lines.append("") + lines.append("| 캐릭터 Lv30 · 곤충 Lv | " + " | ".join(str(L) for L in (15, 20, 25, 30, 35, 40, 45)) + " |") + lines.append("|----|" + "----:|" * 7) + for r in ("Common", "Rare", "Legendary"): + lines.append(f"| {r} EXP | " + " | ".join(str(exp_reward(r, L, 30)) for L in (15, 20, 25, 30, 35, 40, 45)) + " |") + return "\n".join(lines) + + +def render_training_section() -> str: + """훈련소 「성장 훈련」의 능력치 비용 — 참고 지표(판정 없음). 기준 개체는 등급별 평균 롤이다 + (RollIV power 2/2.5/3/4/5 → 평균 개체값 약 5/4/4/3/3). 목표는 S급 경계(합 41 = 14/14/13).""" + income = region_income_curve() + per_battle = income[0][1] if income else 3.0 + avg_iv = {"Common": 5, "Uncommon": 4, "Rare": 4, "Epic": 3, "Legendary": 3} + lines = ["## 성장 훈련 — 능력치(개체값) 비용 (참고)", + f"- 식: {F['stat_cost']['base']} × {F['stat_cost']['growth']}^현재값 × 등급배율 (TrainingPricing.StatCost)", + f"- 비교 기준: 곤충 Lv1→50 레벨업 총 {total_insect_candy(50):,}캔디", + "| 등급 | 평균 개체 → S급(14/14/13) | 전투 환산 | 한 능력치 0→15 |", + "|----|----:|----:|----:|"] + for r in RARITIES: + start = avg_iv[r] + to_s = sum(stat_training_cost(v, r) for tgt in (14, 14, 13) for v in range(start, tgt)) + full = sum(stat_training_cost(v, r) for v in range(0, F["stat_cost"]["max_iv"])) + lines.append(f"| {r} | {to_s:,} | {to_s / per_battle:,.0f}회 | {full:,} |") + return "\n".join(lines) + + def evaluate_signals(args) -> list: signals = [] @@ -222,13 +325,22 @@ def evaluate_signals(args) -> list: f"< {THRESHOLD_TEAM_CANDY_FAIL:,}", f"{team_candy:,}", judge)) # 3. 트레이너 곡선 형태 — 선형이라 후반/초반비가 완만해야 정상. - # 곤충 캔디(지수)는 여기 검사 대상이 아니다. 지수 성장은 설계다. + # 곤충 캔디 곡선은 여기 검사 대상이 아니다(형태가 아니라 총량을 신호 1·2가 본다). tcr = trainer_curve_ratio(args.target_level) judge = "WARN" if tcr > THRESHOLD_TRAINER_CURVE_WARN else "PASS" signals.append(("트레이너 EXP 후반/초반비 (선형 곡선)", f"<= {THRESHOLD_TRAINER_CURVE_WARN:.1f}x", f"{tcr:.1f}x", judge)) + # 5. 캐릭터 레벨당 조우 수가 후반에 불어나지 않는가 — EXP가 등급만 볼 때(2026-10-01 전)는 + # Lv5 13회 → Lv60 107회(8.5배)였다. 레벨 비례 EXP면 선형 요구량과 상쇄돼 완만해야 정상. + early, late = encounters_per_trainer_level(5), encounters_per_trainer_level(60) + ratio = late / early if early else 0.0 + judge = "WARN" if ratio > THRESHOLD_TRAINER_PACE_WARN else "PASS" + signals.append(("캐릭터 레벨당 조우 수 Lv60/Lv5비 (같은 레벨 사냥)", + f"<= {THRESHOLD_TRAINER_PACE_WARN:.1f}x", + f"{ratio:.1f}x ({early:.0f}회 → {late:.0f}회)", judge)) + # 4. 튜토리얼 초반 부양 — 엔드게임 단일 곤충 평생 캔디가 아니라 초반(Lv1→10) 대비. tes = tutorial_early_share(10) judge = "WARN" if tes < THRESHOLD_TUTORIAL_EARLY_WARN else "PASS" @@ -262,15 +374,16 @@ def main(): help=f"팀 크기 (기본 = MaxTeamSlots = {F['team_max']})") args = p.parse_args() - print(f"# progression-sim — 이원 레벨 (트레이너 EXP·선형 / 곤충 캔디·지수)\n") + print(f"# progression-sim — 이원 레벨 (트레이너 EXP / 곤충 캔디)\n") print("## 코드에서 읽은 구조") print(f"- 트레이너 곡선(선형): max({F['trainer']['floor']}, " f"{F['trainer']['base']}+(lv-1)*{F['trainer']['growth']}) ← 배틀/포획/레이드/튜토리얼 EXP") - print(f"- 곤충 캔디 곡선(지수): {F['candy_curve']['base']}*{F['candy_curve']['growth']}^(lv-1)" - f" ← 캔디로만 (TryLevelUpWithCandy)") + print(f"- 곤충 캔디 곡선: {game_facts.curve_label(F['candy_curve'])} (최대 Lv{F['candy_curve']['max']})" + f" ← 캔디로만 (TryLevelUpWithCandy, 실제 배선 기준)") print(f"- 곤충 XP 곡선은 미사용(dead 배선) — 진행 경로 아님") print(f"- 전투당 보상({args.rarity} 적): EXP {reward_per_battle(args.rarity)['exp']:.1f} / " - f"캔디 {reward_per_battle(args.rarity)['candy']:.1f} (base × 등급배율 {MULT[args.rarity]})") + f"캔디 {reward_per_battle(args.rarity)['candy']:.1f} (base × 등급배율 {MULT[args.rarity]}, " + f"EXP는 Lv1 기준 — 레벨 배율은 「캐릭터 EXP」 절)") print(f"- 튜토리얼: 캔디 {F['tut']['candy']} / EXP {F['tut']['exp']}") print() @@ -286,12 +399,22 @@ def main(): print(f" · 최악(Common 고정, 배틀+포획): 전투 **{insect_candy_battles(args.target_level, 'Common'):,}회** (참고 상한)") print(f"- 팀 {args.team_size}마리 캔디: **{total_insect_candy(args.target_level)*args.team_size:,}**") print() + print(render_trainer_exp_section()) + print() + print(render_training_section()) + print() - print("## 리전별 스폰가중 캔디 income (진행에 따라 상승)") - print("| requiredLevel | E[candy]/전투 |") - print("|----:|----:|") - for req, e in region_income_curve(): - print(f"| {req} | {e:.2f} |") + print("## 리전별 캔디 income (전역 등급표 + 빠진 등급 대체)") + shares = F["shares"] + tot = sum(shares.values()) + print("- 전역 등급표(FieldSpawnRules): " + " / ".join(f"{r} {shares[r] / tot * 100:.1f}%" for r in RARITIES)) + print("| 리전 | requiredLevel | 풀 등급 분포(C/U/R/E/L %) | E[candy]/전투 |") + print("|----|----:|----|----:|") + for rid, req, ids in sorted(F["regions"], key=lambda t: t[1]): + s = region_rarity_shares(ids) + dist = "/".join(f"{s[r] * 100:.1f}" for r in RARITIES) + e = sum(p * _candy_of(r) for r, p in s.items()) + print(f"| {rid} | {req} | {dist} | {e:.2f} |") print() print("## 위험 신호 표") @@ -300,13 +423,13 @@ def main(): print("## 가정 / 한계") print("- 판정(신호1)은 **현실 진행**: 곤충 레벨 L의 캔디를 그 시점 리전(requiredLevel<=L 중") - print(" 최상위)의 스폰가중 기대 캔디로 벌어들인다고 본다. 캔디 비용이 지수라 전체의 84%가") - print(" Lv36+에 몰리고, 그 구간은 고레어 엔드리전(예: 유적 ~7캔디/전투)에서 벌린다.") + print(" 최상위)의 기대 캔디로 벌어들인다고 본다. 희귀도가 리전과 무관해져(전역 등급표) 풀이 다섯") + print(" 등급을 다 갖는 리전은 income이 같다 — 후반 리전이 고레어로 캔디를 더 주던 가속은 없다.") print("- 캔디는 전역 단일 풀(PlayerCandyInventory)이라 종 무관하게 합산된다 — 종별 캔디 아님.") print("- 레이드(×3, 예: Epic 30·Legendary 48캔디)·가챠 박스(5~50)·튜토리얼(336)은 별도라") print(" 현실 전투 수를 더 낮춘다. 현실 수치도 그 의미에서 상한이다.") - print("- 리전 income은 InsectSpawner.GetWeightedRandom(무아이템)과 동일하게 spawnWeight로") - print(" 가중. 레어스폰 아이템/의상 보너스는 미반영(있으면 고레어↑ → income↑).") + print("- 리전 income은 FieldSpawnRules 전역 등급표(무아이템)에 풀에 없는 등급의 대체(아래 → 위)를") + print(" 얹은 분포. 레어스폰 아이템/의상 보너스는 미반영(있으면 희귀 이상↑ → income↑).") print("- 곤충별 candyReward는 PlaySceneBootstrap의 등급별 하드코딩을 읽는다" "(InsectDatabase .asset의 개체별 편차는 미반영).") diff --git a/.claude/scripts/quest_lint.py b/.claude/scripts/quest_lint.py index 96cc03b..71dd002 100644 --- a/.claude/scripts/quest_lint.py +++ b/.claude/scripts/quest_lint.py @@ -54,6 +54,29 @@ def extract_boss_duel_rewards(): if not out: raise ExtractorBroken( "NpcBossDuels에서 대결을 하나도 못 읽었다 — 표 구조가 바뀌었는지 확인할 것") + return out + extract_rival_duel_rewards() + + +def extract_rival_duel_rewards(): + """`NpcRivalDuels`(라온 라이벌 단계)의 (stageId, rewardItemId) 목록. + + 간부 표와 같은 함정이다 — 첫 승리 보상이 오타면 조용히 사라진다. 파일이 있는데 0건이면 + 표 구조가 바뀐 것이므로 `ExtractorBroken`(빈 목록을 "위반 0건"으로 읽지 않는다). + """ + path = os.path.join("Assets", "Scripts", "NPC", "NpcRivalDuels.cs") + if not os.path.exists(path): + return [] + with io.open(path, encoding="utf-8", errors="replace") as f: + src = f.read() + out = [] + for block in re.findall(r"new\s+Stage\s*\{(.*?)\}", src, re.S): + stage = re.search(r'stageId\s*=\s*"([^"]+)"', block) + item = re.search(r'rewardItemId\s*=\s*"([^"]+)"', block) + if stage and item: + out.append((stage.group(1), item.group(1))) + if not out: + raise ExtractorBroken( + "NpcRivalDuels에서 단계를 하나도 못 읽었다 — 표 구조가 바뀌었는지 확인할 것") return out @@ -147,7 +170,7 @@ def evaluate_signals() -> list: # (여러 소스의 합집합이라 C# 쪽에서 다시 모으면 사본이 생긴다) 검사도 여기 둔다. boss_rewards = extract_boss_duel_rewards() bad_boss = [f"{npc}:{item}" for npc, item in boss_rewards if item not in item_ids] - signals.append(("보스 대결 보상 아이템 ID 존재", "0건 미존재", + signals.append(("보스·라이벌 대결 보상 아이템 ID 존재", "0건 미존재", f"{len(bad_boss)}건 ({bad_boss})" if bad_boss else f"0건 (대결 {len(boss_rewards)}건 대조)", "FAIL" if bad_boss else "PASS")) @@ -298,6 +321,74 @@ def evaluate_signals() -> list: f"{len(sep_bad)}건 ({sep_bad})" if sep_bad else f"0건 ({len(ids)}개)", "FAIL" if sep_bad else "PASS")) + # 12. 지역 의뢰(requiredRegionId) 정합 — 세 가지가 전부 무증상이다. + # ① 리전 ID 오타: IsSideUnlocked가 GetRegionById null로 영원히 닫혀 **의뢰가 목록에서 잠긴 채** + # 남는다(예외도 경고도 없다). ② Story 카테고리에 달면: 스토리 체인(ActivateNextQuest· + # NotifyAction의 ActiveQuest 경로)은 이 필드를 보지 않아 **어디서든 세어진다** — 적어 둔 + # 한정이 거짓이 된다. ③ 위치가 없는 목표 타입(LevelUp·OpenDex 등)에 달면 "그 리전에서 + # 레벨업"처럼 우연에 기대는 목표가 된다 — 행동이 일어난 곳이 곧 리전인 타입만 허용한다. + region_types = {"Capture", "CaptureRare", "CaptureRarity", "Battle", "RaidBattle", + "VisitSubArea", "NpcDuel", "CaptureTrait", "BattleFeat"} + region_bad = [] + region_quests = [q for q in quests if q.get("region")] + for q in region_quests: + if q["region"] not in region_ids: + region_bad.append(f"{q['questId']}:{q['region']}(리전 없음)") + if q.get("category", "Story") != "Side": + region_bad.append(f"{q['questId']}(Story 퀘스트엔 리전 한정이 적용되지 않는다)") + if q.get("type") not in region_types: + region_bad.append(f"{q['questId']}:{q.get('type')}(위치 없는 목표 타입)") + signals.append(("지역 의뢰 정합 (리전 실재 · Side 전용 · 위치 있는 목표)", "0건", + f"{len(region_bad)}건 ({region_bad})" if region_bad + else f"0건 (지역 의뢰 {len(region_quests)}개)", + "FAIL" if region_bad else "PASS")) + + # 13. 조건부 퀘스트(CaptureTrait / BattleFeat) 정합 — 넷 다 무증상이다. + # ① 조건이 하나도 없으면 무엇이든 센다 — Capture/Battle과 같은데 이름만 다른 퀘스트가 된다. + # ② 다른 쪽 조건 필드는 판정이 읽지 않아 **조용히 무시된다** — CaptureTrait에 maxTurns를 쓰면 + # 그 조건이 사라져 퀘스트가 저작보다 쉬워진다(조건부 타입이 아닌 퀘스트에 쓴 것도 같다). + # ③ Story에 달면 선형 체인(NotifyCapture/OnBattleEnded의 활성 퀘스트 경로)이 이 타입을 세지 않아 + # **영구 정지**한다. 조건부 타입은 서브 전용이다. + # ④ 하한이 상한보다 크면 영원히 안 찬다. resetOnLoss는 전투 승리 연속이라 BattleFeat에서만 뜻이 있다. + trait_types = {"CaptureTrait", "BattleFeat"} + shared = set(game_facts.TRAIT_SHARED_FIELDS) + allowed = { + "CaptureTrait": set(game_facts.TRAIT_CAPTURE_FIELDS) | shared, + "BattleFeat": set(game_facts.TRAIT_BATTLE_FIELDS) | shared, + } + + def _num(raw): + try: + return float(raw.rstrip("f")) + except ValueError: + return None + + trait_bad = [] + trait_count = 0 + for q in quests: + fields = q.get("trait_fields") or {} + qt = q.get("type") + if qt not in trait_types: + if fields: + trait_bad.append(f"{q['questId']}({qt}에 조건 필드 {sorted(fields)} — 조건부 타입이 아니라 무시된다)") + continue + trait_count += 1 + if q.get("category", "Story") != "Side": + trait_bad.append(f"{q['questId']}(조건부 타입은 Side 전용 — Story면 영구 정지)") + if not fields: + trait_bad.append(f"{q['questId']}(조건 없음 — {qt}가 아니라 기본 타입으로 쓸 것)") + stray = sorted(set(fields) - allowed[qt]) + if stray: + trait_bad.append(f"{q['questId']}({qt}에 {stray} — 그 타입 판정이 읽지 않아 무시된다)") + for lo, hi in (("minSizeMm", "maxSizeMm"), ("minSizeRatio", "maxSizeRatio")): + lo_v, hi_v = _num(fields.get(lo, "")), _num(fields.get(hi, "")) + if lo_v is not None and hi_v is not None and lo_v > hi_v: + trait_bad.append(f"{q['questId']}({lo} {lo_v:g} > {hi} {hi_v:g} — 영원히 안 찬다)") + signals.append(("조건부 퀘스트 정합 (Side 전용 · 조건 있음 · 타입에 맞는 필드 · 하한≤상한)", "0건", + f"{len(trait_bad)}건 ({trait_bad})" if trait_bad + else f"0건 (조건부 {trait_count}개)", + "FAIL" if trait_bad else "PASS")) + return signals diff --git a/.claude/scripts/story_lint.py b/.claude/scripts/story_lint.py index 9391eb1..d7119f6 100644 --- a/.claude/scripts/story_lint.py +++ b/.claude/scripts/story_lint.py @@ -7,6 +7,12 @@ EvaluateTriggers switch 케이스나 이벤트 구독을 빠뜨리면, 그 타입을 쓰는 비트가 영영 발화하지 않는다. quest_lint의 "QuestType↔진행 배선"과 정확히 같은 구조 — q_team 회귀의 스토리 등가물. +36검사다. 31~34는 초등 고학년이 읽는 이야기(2026-10-04 다시 쓰기)의 저작 규칙이다 — +대사 한 줄·장면 길이(31), 쓰지 않는 말(32), HUD 목표 이유 why(33), 장 「지난 이야기」 데이터(34). +35는 대사 직후 대결(duelAfter — 2026-10-04 「이야기 전투」)의 상대·자리·단계 정합이다. +36은 대사 앞 영상(introVideoId — 2026-10-05 「영상이 설명하고, 대사는 짧게」) 비트의 대사 길이(1~6줄)다. +위반 전체 목록은 표 아래 「상세」에 찍는다(표 칸에는 앞 몇 건만). + 종료 코드: 0 정상 / 1 데이터 결함 / 2 추출기 고장 (관통 원칙). """ import io @@ -272,20 +278,40 @@ def evaluate_signals() -> list: # 옮겨 부채를 청산했다. 지금은 0건이며, 다시 늘면 측정값에 드러난다. # FAIL로 승격하지 않는 이유: leaf로 쓰는 QuestComplete는 정상이고(놓쳐도 체인이 안 끊긴다), # 이 검사는 "prereq로 쓰였는가"만 본다 — 그 조건이면 GuardianDefeat와 달리 즉시 위험하진 않다. + # + # **DuelWin은 조건부로 허용한다**(2026-10-04 — 간부 승리 duel_*_win이 다음 장 도착·최종장의 선행이 됐다). + # 이긴 순간의 이벤트(BossDuelWon)는 일생 1회지만 StoryDirector.ResweepPersistentConditions가 **격파 기록**을 보고 + # 시작·리전 이동·비트 완료·클라우드 재적재 때마다 다시 흘린다. 그 한 줄이 이 스파인의 유일한 회복 경로라 소스에서 + # 확인한다 — 지우면 그 순간을 놓친 세이브(앱 종료)와 필수화 전에 이미 이긴 세이브가 그 장에서 영구 정지한다. + # 게이트(requiredBeatId) 대상도 같은 이유로 본다(검사 16은 일생 1회 트리거만 막는다). prereq_targets = {b.get("prerequisiteBeatId") for b in beats if b.get("prerequisiteBeatId")} + gate_targets8 = {b.get("requiredBeatId") for b in beats if b.get("requiredBeatId")} once_only = [] grandfathered = 0 + duel_spines = [] for b in beats: ttype = (b.get("trigger") or {}).get("type") + if ttype == "DuelWin" and (b["beatId"] in prereq_targets or b["beatId"] in gate_targets8): + duel_spines.append(b["beatId"]) if b["beatId"] not in prereq_targets: continue if ttype == "GuardianDefeat": once_only.append(f"{b['beatId']}({ttype})") elif ttype == "QuestComplete": grandfathered += 1 + if duel_spines: + director8 = _read_repo("Assets/Scripts/Story/StoryDirector.cs") + m8 = re.search(r"private void ResweepPersistentConditions\(\)(.*?)\n \}", director8, re.S) + resweeps_duels = bool(m8) and bool(re.search( + r"type\s*==\s*TriggerDuelWin\s*&&\s*HasDefeatedStoryNpc\(\s*param\s*\)", m8.group(1))) + if not resweeps_duels: + once_only.extend(f"{x}(DuelWin — ResweepPersistentConditions의 격파 기록 재확인 없음)" + for x in sorted(duel_spines)) note = f" / QuestComplete 기존 {grandfathered}건은 1막 유예" if grandfathered else "" + if duel_spines: + note += f" / DuelWin 스파인·게이트 {len(duel_spines)}건은 격파 기록 재확인으로 재발화({sorted(duel_spines)})" signals.append(( - "일생 1회 트리거의 스파인 사용 (GuardianDefeat leaf 강제)", + "일생 1회 트리거의 스파인 사용 (GuardianDefeat leaf 강제 · DuelWin은 격파 기록 재확인 필수)", "0건", (f"{len(once_only)}건 ({once_only})" if once_only else "0건") + note, "FAIL" if once_only else "PASS", @@ -447,14 +473,22 @@ def evaluate_signals() -> list: # 조작(SetFrozen)과 모달 스택을 뺏는다. 함께 걸면 서로의 복구를 덮어써서 # 조작이 안 돌아오거나 카메라가 컷신 마지막 구도로 굳는다. # 런타임에도 StoryStageDirector가 컷신에 양보하지만, 저작 단계에서 막는 편이 낫다. + # videoId(StoryVideoDirector)도 같은 시점을 구독한다 — 셋 중 둘 이상이면 FAIL. + # introVideoId(대사 앞 영상)는 시점이 달라 셋과는 안 다투지만 **videoId와는 함께 두지 않는다** — + # 한 지휘자가 한 비트에 두 편(앞·뒤)을 트는 것이고, 영상이 설명하고 대사가 짧은 비트에 마무리 영상까지 + # 붙이면 아이가 대사 6줄을 사이에 둔 영상 두 편(최대 30초)을 보게 된다. 설명은 앞 영상 하나로 한다. both_slots = sorted( b["beatId"] for b in beats - if b.get("stageExitId") and b.get("cutsceneId")) + if sum(1 for k in ("stageExitId", "cutsceneId", "videoId") if b.get(k)) >= 2) + both_videos = sorted( + f"{b['beatId']}(introVideoId+videoId)" for b in beats + if b.get("introVideoId") and b.get("videoId")) + slot_problems = both_slots + both_videos signals.append(( - "stageExitId ↔ cutsceneId 배타", + "stageExitId ↔ cutsceneId ↔ videoId 배타 · introVideoId ↔ videoId 배타", "0건 동시 사용", - f"{len(both_slots)}건 ({both_slots})" if both_slots else "0건", - "FAIL" if both_slots else "PASS", + f"{len(slot_problems)}건 ({slot_problems})" if slot_problems else "0건", + "FAIL" if slot_problems else "PASS", )) # 14. Story.json의 키가 StoryBeat.cs에 실재하는가. @@ -485,8 +519,10 @@ def evaluate_signals() -> list: fields[fname] = elem.group(1) if elem else ftype class_fields[cls_name] = fields - if not class_fields.get("StoryBeat"): - raise ExtractorBroken("StoryBeat.cs에서 StoryBeat의 public 필드를 찾지 못했다 — 추출기가 낡았다") + for required_cls in ("StoryBeat", "StoryList", "StoryChapter"): + if not class_fields.get(required_cls): + raise ExtractorBroken( + f"StoryBeat.cs에서 {required_cls}의 public 필드를 찾지 못했다 — 추출기가 낡았다") def _walk_keys(node, cls_name, path, out): known = class_fields.get(cls_name) @@ -505,14 +541,27 @@ def _walk_keys(node, cls_name, path, out): _walk_keys(item, child, f"{path}.{key}[{idx}]", out) orphan_keys = [] + # 루트(StoryList) — `beats`·`chapters` 외의 키는 JsonUtility가 통째로 버린다("chapter" 한 글자 오타면 + # 장 13개가 전부 증발하고 「지난 이야기」 카드가 하나도 안 뜬다). + story_doc = game_facts.story_document() + for key in story_doc: + if key not in class_fields["StoryList"]: + orphan_keys.append(f"(루트).{key}") for b in beats: _walk_keys(b, "StoryBeat", b.get("beatId") or "(무명)", orphan_keys) + doc_chapters = story_doc.get("chapters") + if isinstance(doc_chapters, list): + for idx, ch in enumerate(doc_chapters): + if isinstance(ch, dict): + _walk_keys(ch, "StoryChapter", f"chapters[{idx}:{ch.get('chapterId') or '?'}]", orphan_keys) signals.append(( - "Story.json 키 실재성 (JSON↔StoryBeat 필드)", + "Story.json 키 실재성 (JSON↔StoryList·StoryBeat·StoryChapter 필드)", "0건 미매핑", f"{len(orphan_keys)}건 ({orphan_keys[:8]})" if orphan_keys - else f"0건 (비트 {len(beats)}개 / 필드 {len(class_fields['StoryBeat'])}종)", + else (f"0건 (비트 {len(beats)}개 / 필드 {len(class_fields['StoryBeat'])}종 · " + f"장 {len(doc_chapters) if isinstance(doc_chapters, list) else 0}개 / " + f"필드 {len(class_fields['StoryChapter'])}종)"), "FAIL" if orphan_keys else "PASS", )) @@ -844,9 +893,493 @@ def _walk_keys(node, cls_name, path, out): "FAIL" if choice_bad else "PASS", )) + # 25. videoId·introVideoId 실재성 — 검사 9(cutsceneId)·12(stageId)와 같은 무증상 결함 계열이다. + # 오타면 런타임에 LogWarning만 찍고 영상이 그냥 안 나온다(대사 앞 영상이면 영상 없이 짧은 대사만 남는다). 셋을 본다: + # ① JSON videoId·introVideoId → StoryVideoLibrary 상수 실재 ② 상수 선언됐는데 TryGet case 없음 + # ③ fileName이 Assets/StreamingAssets/Video/ 에 실제로 있는가. + # ③은 **WARN**이다 — 영상은 외부(AI 생성)에서 만들어 들어오므로 코드가 먼저 준비되는 것이 + # 정상 순서고, 없으면 런타임이 경고 1줄 찍고 건너뛰어 진행은 산다(StoryVideoDirector.Play). + # 단 배포 전에는 0건이어야 한다 — 릴리스 체크리스트가 이 줄을 본다. + video_lib_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "Assets", "Scripts", "Story", "StoryVideoLibrary.cs") + try: + with open(video_lib_path, encoding="utf-8") as fh: + video_src = fh.read() + except OSError as exc: + raise ExtractorBroken(f"StoryVideoLibrary.cs를 읽지 못했다: {exc}") + + video_declared = dict(re.findall( + r'public const string (\w+)\s*=\s*"([a-z_0-9]+)"', video_src)) + if not video_declared: + raise ExtractorBroken( + "StoryVideoLibrary.cs에서 영상 ID 상수를 하나도 찾지 못했다 — 추출기가 낡았다") + known_videos = set(video_declared.values()) + video_dispatched = set(re.findall(r'case (\w+):\s*def\s*=', video_src)) + video_undispatched = sorted(v for k, v in video_declared.items() if k not in video_dispatched) + + # 파일명은 빌더의 `new StoryVideoDefinition(상수, "파일.mp4", ...)`에서 읽는다. + video_files = dict(re.findall( + r'new StoryVideoDefinition\((\w+),\s*"([^"]+\.mp4)"', video_src)) + if len(video_files) != len(video_declared): + raise ExtractorBroken( + f"StoryVideoLibrary.cs 파일명 추출 {len(video_files)}건 ≠ 상수 {len(video_declared)}건 — 추출기가 낡았다") + + VIDEO_KEYS = ("videoId", "introVideoId") + missing_video = sorted( + f"{b['beatId']}.{key}→{b[key]}" + for b in beats + for key in VIDEO_KEYS + if b.get(key) and b[key] not in known_videos) + video_problems = missing_video + [f"{v}(switch 미배선)" for v in video_undispatched] + video_used = sum(1 for b in beats if b.get("videoId")) + intro_used = sum(1 for b in beats if b.get("introVideoId")) + signals.append(( + "videoId·introVideoId 실재성 (JSON↔StoryVideoLibrary)", + "0건 미존재", + f"{len(video_problems)}건 ({video_problems})" if video_problems + else f"0건 (대사 뒤 {video_used}건 · 대사 앞 {intro_used}건 / 정의 {len(known_videos)}종)", + "FAIL" if video_problems else "PASS", + )) + + video_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "Assets", "StreamingAssets", "Video") + # os.path.isfile은 Windows에서 대소문자를 무시한다 — Android(jar)는 구분하므로 실제 디렉터리 + # 목록과 **정확히** 대조한다. 어긋나면 에디터·테스트는 통과하고 기기에서만 5초 검은 화면이 난다. + present_files = set(os.listdir(video_dir)) if os.path.isdir(video_dir) else set() + absent_files = sorted( + f"{video_declared[k]}→{fn}" for k, fn in video_files.items() + if fn not in present_files) + used_video_ids = {b[key] for b in beats for key in VIDEO_KEYS if b.get(key)} + unused_videos = sorted(v for v in known_videos if v not in used_video_ids) + file_notes = ([f"{len(absent_files)}건 미배치 ({absent_files})"] if absent_files else []) \ + + ([f"미사용 {len(unused_videos)}건 ({unused_videos})"] if unused_videos else []) + signals.append(( + "영상 파일 배치 (StreamingAssets/Video · 대소문자 정확)", + "0건 미배치 (배포 전) · 미사용 0건", + " / ".join(file_notes) if file_notes else f"0건 ({len(video_files)}편 배치·전부 사용)", + "WARN" if file_notes else "PASS", + )) + + # 27. 대사 화자 실재성 — lines[].speaker는 **표시명**이고 대사창이 그걸 초상 ID로 푼다 + # (NpcDialogueDatabase.StoryPortraitId). 오타면 예외도 경고도 없이 그 줄만 초상 없이 + # "세리"라는 이름표로 뜬다 — 무대 연출이 좌우 배치를 화자로 정하므로 한 글자 오타가 장면 + # 전체의 자리 배치를 흔든다. 허용: StorySpeakerName의 표시명 ∪ 별칭 "하월" ∪ 해설 "지문". + db_src = _read_repo("Assets/Scripts/NPC/NpcDialogueDatabase.cs") + m_names = re.search(r"public static string StorySpeakerName\(string storyId\)(.*?)default:", db_src, re.S) + if not m_names: + raise ExtractorBroken("NpcDialogueDatabase.StorySpeakerName switch를 찾지 못했다 — 추출기가 낡았다") + speaker_names = set(re.findall(r'case "\w+":\s*return "([^"]+)";', m_names.group(1))) + if len(speaker_names) < 5: + raise ExtractorBroken(f"StorySpeakerName 표시명 추출 {len(speaker_names)}건 — 추출기가 낡았다") + staging_src = _read_repo("Assets/Scripts/UI/StoryDialogueStaging.cs") + m_narr = re.search(r'NarrationSpeaker\s*=\s*"([^"]+)"', staging_src) + if not m_narr: + raise ExtractorBroken("StoryDialogueStaging.NarrationSpeaker를 찾지 못했다 — 추출기가 낡았다") + narration_speaker = m_narr.group(1) + valid_speakers = speaker_names | {"하월", narration_speaker} + bad_speakers = sorted({ + f"{b['beatId']}:{(l.get('speaker') or '(빈 화자)')}" + for b in beats for l in (b.get("lines") or []) + if (l.get("speaker") or "") not in valid_speakers}) + narration_lines = sum(1 for b in beats for l in (b.get("lines") or []) if l.get("speaker") == narration_speaker) + signals.append(( + "대사 화자 실재성 (표시명 · 하월 · 지문)", + "0건", + f"{len(bad_speakers)}건 ({bad_speakers[:10]})" if bad_speakers + else f"0건 (인물 {len(speaker_names)}명 · 지문 {narration_lines}줄)", + "FAIL" if bad_speakers else "PASS", + )) + + # 28. 줄 연출 토큰 — lines[].fx는 쉼표 토큰이고 모르는 토큰은 ParseFx가 **조용히 버린다**. + # "shak"으로 적으면 흔들림 없이 지나가도 아무도 모른다. 목록의 단일 출처는 + # StoryDialogueStaging.KnownFxTokens다(C# 테스트가 목록↔해석 switch 일치를 본다). + m_fx = re.search(r"KnownFxTokens\s*=\s*\{(.*?)\};", staging_src, re.S) + if not m_fx: + raise ExtractorBroken("StoryDialogueStaging.KnownFxTokens를 찾지 못했다 — 추출기가 낡았다") + known_fx = set(re.findall(r'"(\w+)"', m_fx.group(1))) + bad_fx, fx_lines = [], 0 + for b in beats: + for i, l in enumerate(b.get("lines") or []): + raw = l.get("fx") or "" + if not raw.strip(): + continue + fx_lines += 1 + for tok in raw.split(","): + tok = tok.strip().lower() + if tok and tok not in known_fx: + bad_fx.append(f"{b['beatId']}#{i}:{tok}") + signals.append(( + "줄 연출 토큰 (lines[].fx ↔ KnownFxTokens)", + "0건", + f"{len(bad_fx)}건 ({bad_fx[:10]})" if bad_fx + else f"0건 (연출 {fx_lines}줄 · 토큰 {len(known_fx)}종)", + "FAIL" if bad_fx else "PASS", + )) + + # 29. 퀘스트 게이트가 **반복 서브 퀘스트**를 물면 영영 안 열린다. + # `TutorialQuestManager.CompleteSideQuest`는 반복 퀘스트를 completedQuests에 넣지 않고 + # 목표만 올려 재시작한다 — `IsQuestCompleted`가 영원히 false라 그 비트는 조용히 잠긴다. + # 마을 이야기의 매듭 비트(`town_*_close`)가 지역 의뢰(`s_town_*`)의 완료를 이 게이트로 + # 관찰하므로, 의뢰를 반복형으로 바꾸는 순간 이야기가 거기서 끊긴다(예외도 경고도 없다). + repeatable_quests = {q["questId"] for q in game_facts.quest_defs() if q.get("repeatable")} + gated_on_repeat = sorted( + f"{b['beatId']}→{b['requiredQuestId']}" + for b in beats + if b.get("requiredQuestId") and b["requiredQuestId"] in repeatable_quests) + quest_gated = sum(1 for b in beats if b.get("requiredQuestId")) + signals.append(( + "퀘스트 게이트 대상이 1회 완료형인가 (반복 서브는 완료 기록이 안 남는다)", + "0건", + f"{len(gated_on_repeat)}건 ({gated_on_repeat})" if gated_on_repeat + else f"0건 (퀘스트 게이트 {quest_gated}건 / 반복 퀘스트 {len(repeatable_quests)}개)", + "FAIL" if gated_on_repeat else "PASS", + )) + + # 30. 「거둬들이다」는 **명부회의 말**이다(StoryBible 2장). `ch3_warning`에서 세라가 "채집가는 그런 말을 + # 안 써요. 수확하는 사람의 말이에요"라고 짚는데, 그 앞뒤에서 주인공 쪽(라온·세라·어르신·지문)이 같은 + # 말을 쓰면 그 대사가 거짓이 된다 — 예외도 경고도 없이 읽히므로 눈으로만 잡히던 결함이다(2026-10-03에 + # 다섯 줄이 그랬다). 인용부호가 든 줄은 그 말을 **언급**하는 것이라 건너뛴다. 명부회 화자·검은 옷은 자기 말이라 허용. + villain_speakers = {n for n in speaker_names if n.startswith("검은 옷") or n in ("집게", "저울", "먹", "관장 하월")} | {"하월"} + harvest_word = re.compile(r"거둬|거둔|거두[는고며면어]|거뒀|거둘|거둡") + quote_marks = ('"', "“", "”") + hero_harvest = sorted( + f"{b['beatId']}#{i}:{l.get('speaker')}" + for b in beats for i, l in enumerate(b.get("lines") or []) + if (l.get("speaker") or "") not in villain_speakers + and harvest_word.search(l.get("text") or "") + and not any(q in (l.get("text") or "") for q in quote_marks)) + signals.append(( + "명부회의 말 「거둬들이다」를 주인공 쪽이 쓰지 않는가 (인용·명부회 화자 제외)", + "0건", + f"{len(hero_harvest)}건 ({hero_harvest[:10]})" if hero_harvest + else f"0건 (명부회 화자 {len(villain_speakers)}종 제외)", + "FAIL" if hero_harvest else "PASS", + )) + + # ── 31~34: 초등 고학년이 읽는 이야기 (2026-10-04 다시 쓰기) ────────────────────────── + # 대사·목표 이유·장 요약의 **길이와 말**을 본다. 규칙의 단일 출처는 Docs/StoryBible.md다 — + # 여기 상수는 그 규칙을 강제할 뿐이다. 대상은 본편(ch1~ch12·fin)과 명부회 아크(bl)·곁이야기(side·npc)· + # 마을 이야기(town — 2026-10-05에 같은 규칙으로 다시 썼다)다. + # + # 길이는 **화면이 아니라 읽는 사람** 기준이다. 대사창은 LabelFit이 글자를 줄여서라도 다 넣으므로 + # 긴 줄이 잘리지는 않는다 — 대신 18pt까지 작아진 세 줄짜리 문장이 된다. 예외도 경고도 없다. + beat_by_id_all = {b["beatId"]: b for b in beats} + + def _in_scope(b): + return (b.get("chapterId") or "") in STORY_REWRITE_CHAPTERS + + def _is_staged(b): + return any(b.get(k) for k in STAGED_BEAT_KEYS) or bool(b.get("choices")) + + # 31. 대사 한 줄 40자 · 장면 7줄(연출·선택지가 붙으면 10줄). + long_lines, long_scenes = [], [] + scoped = [b for b in beats if _in_scope(b)] + for b in scoped: + lines = b.get("lines") or [] + for i, l in enumerate(lines): + n = len(l.get("text") or "") + if n > MAX_LINE_CHARS: + long_lines.append(f"{b['beatId']}.lines[{i}]={n}자") + cap = MAX_STAGED_SCENE_LINES if _is_staged(b) else MAX_SCENE_LINES + if len(lines) > cap: + long_scenes.append(f"{b['beatId']}={len(lines)}줄(상한 {cap}{' · 연출/선택지' if cap == MAX_STAGED_SCENE_LINES else ''})") + DETAILS["31 대사 한 줄 길이"] = long_lines + DETAILS["31 장면 줄 수"] = long_scenes + scoped_lines = sum(len(b.get("lines") or []) for b in scoped) + if long_lines or long_scenes: + val31 = (f"줄 {len(long_lines)}건 · 장면 {len(long_scenes)}건 " + f"({(long_scenes + long_lines)[:8]} … 전체는 아래 「상세」)") + else: + val31 = f"0건 (대상 비트 {len(scoped)}개 · 대사 {scoped_lines}줄)" + signals.append(( + f"대사 길이 (한 줄 ≤{MAX_LINE_CHARS}자 · 장면 ≤{MAX_SCENE_LINES}줄, 연출·선택지 비트 ≤{MAX_STAGED_SCENE_LINES}줄)", + "0건", + val31, + "FAIL" if (long_lines or long_scenes) else "PASS", + )) + + # 32. 쓰지 않는 말 — 어려운 말을 쉬운 말로 바꿨다. 옛 말이 한 줄이라도 남으면 같은 대상을 두 이름으로 + # 부르게 된다("봉인"과 "이름 벽"이 한 장면에 섞인다). 인용·언급 예외 없음 — 대사와 선택지 문구 전부. + retired_hits = [] + retired_count = {w: 0 for w in RETIRED_WORDS} + for b in scoped: + texts = [(f"lines[{i}]", l.get("text") or "") for i, l in enumerate(b.get("lines") or [])] + texts += [(f"choices[{i}]", c.get("text") or "") for i, c in enumerate(b.get("choices") or [])] + for where, text in texts: + for word, replacement in RETIRED_WORDS.items(): + if word in text: + retired_count[word] += 1 + retired_hits.append(f"{b['beatId']}.{where}:{word}→{replacement}") + DETAILS["32 쓰지 않는 말"] = retired_hits + by_word = " · ".join(f"{w} {n}" for w, n in retired_count.items() if n) + signals.append(( + "쓰지 않는 말 (" + " · ".join(f"{w}→{r}" for w, r in RETIRED_WORDS.items()) + ")", + "0건", + f"{len(retired_hits)}건 ({by_word}: {retired_hits[:6]})" if retired_hits else "0건", + "FAIL" if retired_hits else "PASS", + )) + + # 33. 목표 이유(why) — HUD가 "모래언덕으로" 아래에 "상자에 갇힌 곤충이 있대"를 붙인다. + # **HUD 목표로 우선 뽑히는 본편 비트**에는 이유가 있어야 한다. 그 집합은 C#과 같은 기준이다 + # (StoryObjectiveResolver.SelectObjectiveBeat → CompareObjectivePriority의 0급): + # 스파인(누군가의 prerequisiteBeatId) ∪ 종장(fin) 비트, 단 선택지 결과는 목표에서 빠진다. + # Immediate는 조건이 차는 그 자리에서 뜨므로 목표 행에 머무르지 않아 뺀다. + # leaf(gd_*·여운·대치 뒤 표식)는 스파인이 바닥난 뒤에만 뽑혀 대상이 아니다 — 써 두면 쓰인다. + # 길이는 비트 종류와 무관하게 **why가 있는 모든 비트**에서 본다(HUD 두 번째 줄 한 줄). + spine_ids = {b.get("prerequisiteBeatId") for b in beats if b.get("prerequisiteBeatId")} + choice_ids = {c.get("nextBeatId") for b in beats for c in (b.get("choices") or []) if c.get("nextBeatId")} + why_required = [ + b for b in beats + if _chapter_ordinal(b.get("chapterId")) is not None + and (b["beatId"] in spine_ids or b.get("chapterId") == "fin") + and b["beatId"] not in choice_ids + and (b.get("trigger") or {}).get("type") != "Immediate" + ] + why_missing = [f"{b['beatId']}({(b.get('trigger') or {}).get('type')})" + for b in why_required if not (b.get("why") or "").strip()] + why_long = [f"{b['beatId']}={len(b['why'])}자" + for b in beats if (b.get("why") or "") and len(b["why"]) > MAX_WHY_CHARS] + DETAILS["33 why 누락"] = why_missing + DETAILS["33 why 길이"] = why_long + why_written = sum(1 for b in beats if (b.get("why") or "").strip()) + if why_missing or why_long: + val33 = (f"누락 {len(why_missing)}/{len(why_required)}건 · {MAX_WHY_CHARS}자 초과 {len(why_long)}건 " + f"({(why_long + why_missing)[:8]} … 전체는 아래 「상세」)") + else: + val33 = f"0건 (대상 {len(why_required)}비트 · 작성 {why_written}건)" + signals.append(( + f"목표 이유 why (본편 목표 0급 비트 필수 · ≤{MAX_WHY_CHARS}자)", + "0건", + val33, + "FAIL" if (why_missing or why_long) else "PASS", + )) + + # 34. 장 데이터(StoryList.chapters) — 「지난 이야기」 카드와 저널 장 탭 머리. + # 카드는 openingBeatId 비트의 대사 앞에 뜬다(StoryService.TryGetChapterOpenedBy). 그 ID가 오타거나 + # 다른 장의 비트면 **카드가 조용히 안 뜨거나 엉뚱한 장면 앞에 뜬다** — 예외도 경고도 없다. + # 장 순서가 곧 배열 순서다(StoryList 주석). + chapters = game_facts.story_chapters() + chapter_problems = [] + chapter_ids = [c.get("chapterId") or "" for c in chapters if isinstance(c, dict)] + if not chapters: + chapter_problems.append("chapters 없음(옛 JSON — 루트 키가 없거나 비어 있다)") + dup_chapters = sorted({c for c in chapter_ids if chapter_ids.count(c) > 1}) + if dup_chapters: + chapter_problems.append(f"chapterId 중복 {dup_chapters}") + if chapters: + missing_chapters = [c for c in EXPECTED_CHAPTERS if c not in chapter_ids] + if missing_chapters: + chapter_problems.append(f"장 누락 {missing_chapters}") + unexpected = sorted({c for c in chapter_ids if c not in EXPECTED_CHAPTERS}) + if unexpected: + chapter_problems.append(f"예상 밖 장 {unexpected}(본편 13장만 — 오타인가?)") + seen_order = [] + for c in chapter_ids: + if c in EXPECTED_CHAPTERS and c not in seen_order: + seen_order.append(c) + if seen_order != [c for c in EXPECTED_CHAPTERS if c in seen_order]: + chapter_problems.append(f"장 순서 어긋남 {seen_order}") + opening_ids = [c.get("openingBeatId") for c in chapters if isinstance(c, dict) and c.get("openingBeatId")] + dup_openings = sorted({o for o in opening_ids if opening_ids.count(o) > 1}) + if dup_openings: + chapter_problems.append(f"openingBeatId 중복 {dup_openings}(뒤 장의 카드가 영영 안 뜬다)") + for idx, c in enumerate(chapters): + if not isinstance(c, dict): + chapter_problems.append(f"chapters[{idx}]: 객체가 아니다") + continue + cid = c.get("chapterId") or f"chapters[{idx}]" + if not (c.get("title") or "").strip(): + chapter_problems.append(f"{cid}: title 비어 있음") + goal = c.get("goal") or "" + if not goal.strip(): + chapter_problems.append(f"{cid}: goal 비어 있음") + elif len(goal) > MAX_CHAPTER_LINE_CHARS: + chapter_problems.append(f"{cid}: goal {len(goal)}자(≤{MAX_CHAPTER_LINE_CHARS})") + opening = c.get("openingBeatId") or "" + if not opening: + chapter_problems.append(f"{cid}: openingBeatId 비어 있음") + elif opening not in beat_by_id_all: + chapter_problems.append(f"{cid}: openingBeatId {opening} 없음") + elif (beat_by_id_all[opening].get("chapterId") or "") != c.get("chapterId"): + chapter_problems.append( + f"{cid}: openingBeatId {opening}는 {beat_by_id_all[opening].get('chapterId')} 장의 비트") + recap = c.get("recap") + if recap is None: + recap = [] + if not isinstance(recap, list): + chapter_problems.append(f"{cid}: recap이 목록이 아니다") + continue + if len(recap) > MAX_RECAP_LINES: + chapter_problems.append(f"{cid}: recap {len(recap)}줄(≤{MAX_RECAP_LINES})") + if not recap and c.get("chapterId") != FIRST_CHAPTER: + chapter_problems.append(f"{cid}: recap 비어 있음({FIRST_CHAPTER} 외에는 1줄 이상)") + for i, line in enumerate(recap): + text = line if isinstance(line, str) else "" + if not text.strip(): + chapter_problems.append(f"{cid}.recap[{i}]: 빈 줄") + elif len(text) > MAX_CHAPTER_LINE_CHARS: + chapter_problems.append(f"{cid}.recap[{i}]={len(text)}자(≤{MAX_CHAPTER_LINE_CHARS})") + DETAILS["34 장 데이터"] = chapter_problems + signals.append(( + "장 데이터 (ch1~ch12·fin 13장 · 여는 비트 실재·같은 장 · recap ≤4줄·줄 ≤40자 · goal ≤40자)", + "0건", + f"{len(chapter_problems)}건 ({chapter_problems[:6]})" if chapter_problems + else f"0건 (장 {len(chapters)}개 · recap {sum(len(c.get('recap') or []) for c in chapters)}줄)", + "FAIL" if chapter_problems else "PASS", + )) + + # 35. 대사 직후 대결(duelAfter) — 2026-10-04 「이야기 전투」. 대사가 끝나면 StoryDuelLauncher가 그 상대와 대결을 연다. + # 실패가 전부 무증상이다(런타임은 로그 한 줄만 찍고 대결 없이 지나간다 — 이야기 속 싸움이 그냥 사라진다): + # ① 상대가 대결 표에 없다(오타) — NpcBossDuels(간부)·NpcRivalDuels(라온) 어디에도 없으면 열 대결이 없다. + # ② 퇴장 연출(stageExitId)과 함께 — 상대가 걸어 나간 뒤에 대결이 열린다. + # ③ 소개 없이 — 그 비트의 화자(speakerNpcId)나 말 건 상대가 아니다. 지면 다시 말을 걸어 재도전하는데, + # WorldInteractionController는 "만난 적 있는 인물"(HasMetNpc)에게만 간부전을 연다. + # ④ 간부가 오염 거점 보스면 그 거점 리전에서만 싸운다(NpcDuelController.BossDuelAllowedInRegion). + # ⑤ 라온이면 **그 비트를 본 직후** 그 리전에서 단계가 골라져야 한다(NpcRivalDuels.TrySelect) — 여는 비트가 + # 이 비트이거나 반드시 먼저 본 비트(선행·게이트 사슬)이고, 리전이 맞고(비면 어디서든), 닫는 비트가 그 사슬에 없어야 한다. + # ⑥ 런처 배선 — StoryDuelLauncher가 비트 완료를 구독하고 부트스트랩이 만든다. 하나만 빠져도 전부 조용히 사라진다. + boss_npcs35 = game_facts.boss_duel_npcs() + rival_stages35 = game_facts.rival_duel_stages() + sub_parent35 = game_facts.subarea_parent_regions() + site_regions35 = {} + for rid, boss, _name, _ret in game_facts.blight_sites(): + site_regions35.setdefault(boss, set()).add(rid) + beat_by_id35 = {b["beatId"]: b for b in beats} + + def _guaranteed_seen(beat_id): + """이 비트를 볼 때 반드시 이미 본 비트(자기 포함) — 선행·게이트 사슬 전체.""" + seen, stack = set(), [beat_id] + while stack: + cur = stack.pop() + if not cur or cur in seen: + continue + seen.add(cur) + cb = beat_by_id35.get(cur) + if cb is None: + continue + for key in ("prerequisiteBeatId", "requiredBeatId"): + if cb.get(key): + stack.append(cb[key]) + return seen + + def _beat_region(b): + """비트가 발화하는 리전 — 리전 게이트 · RegionEnter 대상 · SubAreaEnter의 부모 리전. 모르면 None.""" + if b.get("requiredRegionId"): + return b["requiredRegionId"] + trig = b.get("trigger") or {} + if trig.get("type") == "RegionEnter": + return trig.get("param") or None + if trig.get("type") == "SubAreaEnter": + return sub_parent35.get(trig.get("param") or "") + return None + + duel_problems = [] + duel_beats = [b for b in beats if (b.get("duelAfter") or "").strip()] + for b in duel_beats: + bid, npc = b["beatId"], b["duelAfter"].strip() + is_boss = npc in boss_npcs35 + stages = [st for st in rival_stages35 if st.get("storyNpcId") == npc] + if not is_boss and not stages: + duel_problems.append(f"{bid}: duelAfter {npc}는 대결 표(NpcBossDuels·NpcRivalDuels)에 없다") + continue + if b.get("stageExitId"): + duel_problems.append(f"{bid}: stageExitId와 함께 — 상대가 퇴장한 뒤 대결이 열린다") + trig = b.get("trigger") or {} + if b.get("speakerNpcId") != npc and not (trig.get("type") == "NpcTalk" and trig.get("param") == npc): + duel_problems.append(f"{bid}: 화자·말 건 상대가 {npc}가 아니다(소개 없이 대결 — 재도전 길이 막힌다)") + region = _beat_region(b) + if is_boss: + sites = site_regions35.get(npc) + if sites and region not in sites: + duel_problems.append(f"{bid}: {npc}는 거점 보스라 {sorted(sites)}에서만 싸운다(비트 리전 {region})") + continue + seen_chain = _guaranteed_seen(bid) + ok = False + for st in stages: + if st.get("closeBeatId") is None or st.get("openBeatId") is None: + continue # 상수를 못 풀었다 — 아래에서 따로 알린다 + if st.get("regionId") and st["regionId"] != region: + continue + if st.get("openBeatId") not in seen_chain: + continue + if st.get("closeBeatId") and st["closeBeatId"] in seen_chain: + continue + ok = True + break + if not ok: + duel_problems.append(f"{bid}: 이 비트를 본 직후 {npc}의 단계가 안 열린다(비트 리전 {region} · 여는/닫는 비트 확인)") + if any(st.get("closeBeatId") is None or st.get("openBeatId") is None for st in rival_stages35): + duel_problems.append("NpcRivalDuels: 열림·닫힘 비트에 풀리지 않는 상수가 있다 — 추출기를 확인할 것") + if duel_beats: + launcher_src = _read_repo("Assets/Scripts/Story/StoryDuelLauncher.cs") + boot35 = _read_repo("Assets/Scripts/Core/PlaySceneBootstrap.cs") + if "StoryBeatCompleted += OnBeatCompleted" not in launcher_src or "duelAfter" not in launcher_src: + duel_problems.append("StoryDuelLauncher가 비트 완료를 구독하지 않거나 duelAfter를 읽지 않는다") + if not re.search(r"EnsureComponent", boot35) \ + or not re.search(r"duelLauncher\.AutoWire\(", boot35): + duel_problems.append("PlaySceneBootstrap이 StoryDuelLauncher를 만들거나 배선하지 않는다") + DETAILS["35 대사 직후 대결"] = duel_problems + duel_pairs = sorted(f"{b['beatId']}→{b['duelAfter'].strip()}" for b in duel_beats) + signals.append(( + "대사 직후 대결 duelAfter (상대 실재 · 퇴장 연출 금지 · 소개 · 거점 리전 · 라온 단계 열림 · 런처 배선)", + "0건", + f"{len(duel_problems)}건 ({duel_problems[:4]})" if duel_problems + else f"0건 (대결 비트 {len(duel_beats)}개: {duel_pairs})", + "FAIL" if duel_problems else "PASS", + )) + + # 36. 대사 앞 영상(introVideoId) 비트의 대사는 1~6줄 — 2026-10-05 「영상이 설명하고, 대사는 짧게」(초등 고학년). + # 설명은 영상 자막이 한 줄씩 하고 대사는 인물의 반응만 맡는다. 길어지면 영상이 한 설명을 대사가 되풀이한다. + # 대사가 없으면 대사 "앞"이 아니다 — 영상만 틀 거면 대사 뒤 영상(videoId)에 둔다(무대가 그 순서를 가정한다). + # 검사 31의 상한(연출 비트 10줄)보다 좁다 — 둘 다 걸리면 이쪽이 이긴다. + intro_problems = [] + intro_beats = [b for b in beats if b.get("introVideoId")] + for b in intro_beats: + n = len(b.get("lines") or []) + if n == 0: + intro_problems.append(f"{b['beatId']}: 대사 없음(영상만이면 videoId에)") + elif n > MAX_INTRO_VIDEO_LINES: + intro_problems.append(f"{b['beatId']}={n}줄(상한 {MAX_INTRO_VIDEO_LINES})") + DETAILS["36 대사 앞 영상의 대사 길이"] = intro_problems + signals.append(( + f"대사 앞 영상 비트의 대사 (introVideoId · 1~{MAX_INTRO_VIDEO_LINES}줄)", + "0건", + f"{len(intro_problems)}건 ({intro_problems[:4]})" if intro_problems + else f"0건 (대사 앞 영상 비트 {len(intro_beats)}개: {sorted(b['beatId'] + '→' + b['introVideoId'] for b in intro_beats)})", + "FAIL" if intro_problems else "PASS", + )) + return signals +# ── 31~34의 기준 — 규칙의 단일 출처는 Docs/StoryBible.md, 여기는 강제할 뿐이다 ── +# 다시 쓰기 대상 장. 마을 이야기(town)는 2026-10-05에 같은 규칙으로 다시 쓰고 넣었다. +STORY_REWRITE_CHAPTERS = frozenset({f"ch{i}" for i in range(1, 13)} | {"fin", "bl", "side", "npc", "town"}) +MAX_LINE_CHARS = 40 # 대사 한 줄(공백 포함 len). 목표는 30자 안팎. +MAX_SCENE_LINES = 7 # 한 장면(비트)의 lines +MAX_STAGED_SCENE_LINES = 10 # 연출(stageEnterId·cutsceneId·videoId·introVideoId)이나 선택지가 붙은 비트 +STAGED_BEAT_KEYS = ("stageEnterId", "cutsceneId", "videoId", "introVideoId") +MAX_INTRO_VIDEO_LINES = 6 # 대사 앞 영상(introVideoId)이 붙은 비트 — 검사 36. 영상이 설명하므로 대사는 반응만 +# 쓰지 않는 말 → 바꿔 쓰는 말. 「잦아듦」·「명부회」·「장부」·「빈칸」은 그대로 쓴다. +RETIRED_WORDS = { + "봉인": "이름 벽", + "무명": "그림자", + "지워진 개체": "이름 잃은 곤충", + "예비 울타리": "비상 울타리", +} +MAX_WHY_CHARS = 20 # HUD 목표 아래 한 줄 — 22pt로 약 18자, LabelFit 하한 18pt로 약 22자가 든다(QA 실측 2026-10-04). 여유를 둬 20 +# 장 목록 — 배열 순서가 곧 장 순서다. 1장은 지난 이야기가 없어 recap이 비어도 된다. +EXPECTED_CHAPTERS = tuple([f"ch{i}" for i in range(1, 13)] + ["fin"]) +FIRST_CHAPTER = "ch1" +MAX_RECAP_LINES = 4 +MAX_CHAPTER_LINE_CHARS = 40 + +# 표 칸에는 앞 몇 건만 싣고 전체 목록은 여기 모아 main()이 「상세」로 찍는다(31~34 — 다시 쓰는 사람이 볼 목록). +DETAILS = {} + + def _read_repo(rel_path: str) -> str: """저장소 루트 기준 파일 읽기 — game_facts에 별칭이 없는 파일용.""" root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -878,6 +1411,14 @@ def main(): signals = evaluate_signals() print(render(signals)) print() + detail_rows = [(k, v) for k, v in DETAILS.items() if v] + if detail_rows: + print("## 상세 (검사 31~36 위반 전체)") + for key, items in detail_rows: + print(f"\n### {key} — {len(items)}건") + for item in items: + print(f"- {item}") + print() print("## 가정 / 한계") print("- 스토리 비트는 Assets/Resources/Story.json에서 json.load로 읽는다(정규식 아님).") print(" 지연 발화(DeferTrigger)도 배선으로 세되, 큐를 흘리는 지점이 있을 때만 센다.") @@ -896,11 +1437,23 @@ def main(): print("- 검사 21은 SubAreaWorldBuilder의 CreateBoundaryWalls 크기와 FindSafeSpawnPosition의") print(" 입구 좌표를 읽어 연출 워프 지점이 방 안인지 본다. 방은 축정렬 정사각이고 오프셋도") print(" 월드축이라 좌표가 결정적이다 — 벽 밖이면 배우가 막혀 대사만 뜬다(무증상).") - print("- 검사 22는 NpcManager.StoryNpcDisplayName / NpcVisualBuilder.StoryNpcAppearance의") - print(" case를 읽어 월드 배치 인물과 대조한다. 둘 다 default가 마을 어르신이라") + print("- 검사 22는 NpcDialogueDatabase.StorySpeakerName / NpcVisualBuilder.StoryNpcFace의") + print(" case를 읽어 월드 배치 인물과 대조한다. 이름 누락은 내부 ID, 외형 누락은 어르신으로 표시되어") print(" 누락이 무증상이다 — village_elder만 그 default 가지라서 면제한다.") print("- 검사 19는 NpcBossDuels.cs의 storyNpcId를 정규식으로 읽어 Story.json의") print(" speakerNpcId ∪ NpcTalk param과 대조한다(소개 없는 보스 = 영구 도전 불가).") + print("- 검사 25는 StoryVideoLibrary.cs의 상수·case·파일명을 읽어 Story.json의 videoId·introVideoId와 대조한다.") + print(" 파일 미배치는 WARN — 영상은 렌더러(Tools/Video/storybook)가 따로 만들고, 없으면 런타임이 건너뛰어 진행은 산다") + print(" (대사 앞 영상이면 대사가 곧바로 열린다). 배포 전에는 0건이어야 한다.") + print("- 검사 36은 introVideoId가 붙은 비트의 lines 수만 센다(1~6줄). 한 줄 길이는 검사 31이 본다.") + print("- 검사 31·32는 ch1~ch12·fin·bl·side·npc·town을 본다. 글자 수는 공백 포함 len(text)이고") + print(" 한글 음절은 C# string.Length와 같은 값이다. 줄 번호는 lines[i]의 0부터 센 배열 위치다.") + print("- 검사 33의 필수 대상은 C# CompareObjectivePriority의 0급(스파인 ∪ 종장) − 선택지 결과 − Immediate,") + print(" 그중 본편(ch*·fin)이다. 같은 기준을 StoryObjectiveResolver.IsObjectiveThread와 C# 테스트가 쓴다.") + print("- 검사 34의 chapters 키가 없으면(옛 JSON) FAIL이다 — 런타임은 빈 목록으로 읽어 카드가 하나도 안 뜬다.") + print("- 검사 35는 NpcBossDuels·NpcRivalDuels를 정규식으로 읽는다(라이벌 표의 열림·닫힘은 같은 파일의 상수까지 푼다).") + print(" 비트의 리전은 requiredRegionId → RegionEnter 대상 → SubAreaEnter의 부모 리전 순으로 정한다.") + print("- 검사 8의 DuelWin 허용은 StoryDirector.ResweepPersistentConditions 안의 격파 기록 재확인 줄을 소스에서 찾는다.") fail = sum(1 for s in signals if s[3] == "FAIL") return 1 if fail else 0 diff --git a/.claude/scripts/story_video_placeholders.py b/.claude/scripts/story_video_placeholders.py new file mode 100644 index 0000000..d8e6fff --- /dev/null +++ b/.claude/scripts/story_video_placeholders.py @@ -0,0 +1,97 @@ +"""자리표시 애니매틱 생성 — Docs/StoryVideos.md의 샷 길이·팔레트로 무자막 색면 영상을 만든다. +AI 생성 영상이 들어오기 전까지 기기 테스트용. 규격: 1280x720, 24fps, H.264 Main, <=2Mbps, +faststart, 무음.""" +import subprocess, sys, os, numpy as np +from PIL import Image, ImageFilter + +FF = sys.argv[1] +OUT = sys.argv[2] +W, H, FPS = 320, 180, 24 +M = 40 # 드리프트 여유 +rng = np.random.default_rng(7) + +def col(h): # "#rrggbb" -> np float rgb + return np.array([int(h[i:i+2], 16) for i in (1, 3, 5)], dtype=np.float32) / 255.0 + +# 챕터 팔레트 (하늘/땅/강조) +P = { + "meadow": ("#f3c977", "#5f7a3a", "#fff1c9"), "pond": ("#4f8f8f", "#1f3f45", "#a9d9d0"), + "forest": ("#5a7d46", "#22331f", "#a3c47a"), "swamp": ("#8a8574", "#3b3a31", "#b6b09a"), + "mountain": ("#6f5f9a", "#2b2745", "#d6c6ef"), "dunes": ("#d9a55c", "#7a5a2a", "#f6e3b3"), + "frost": ("#bfe3ee", "#3f6f86", "#ffffff"), "ember": ("#e0622f", "#3a1a12", "#ffb15a"), + "canopy": ("#7fc05a", "#2f5a2a", "#e6ffb0"), "nameless": ("#8a8a8a", "#2a2a2a", "#d0d0d0"), +} +# (videoId 파일명, 팔레트, 샷 길이 목록) — Docs/StoryVideos.md §4와 같다 +VIDEOS = [ + ("ch1_prologue", "meadow", [3.5, 3.0, 2.5, 3.0]), + ("ch2_watchers", "pond", [3.0, 4.0, 3.0]), + ("ch3_scholar", "forest", [3.0, 3.0, 3.0, 3.0]), + ("ch4_crates", "swamp", [3.0, 3.5, 3.0, 2.5]), + ("ch5_summit", "mountain", [3.0, 3.0, 3.5, 2.5]), + ("ch8_vault", "dunes", [3.0, 3.5, 3.0, 2.5]), + ("ch9_archive", "frost", [3.0, 3.0, 4.0, 4.0]), + ("ch10_kiln", "ember", [3.0, 3.5, 3.5, 2.5, 2.5]), + ("ch11_crown", "canopy", [3.0, 3.0, 3.0, 3.0]), + ("ch12_ledger", "nameless", [3.0, 3.5, 3.5, 4.0]), + ("fin_epilogue", "meadow", [3.0, 3.0, 3.0, 3.0, 3.0]), +] +DISSOLVE = 0.6 + +def noise_field(seed, scale): + r = np.random.default_rng(seed) + small = r.random(((H + 2 * M) // scale + 2, (W + 2 * M) // scale + 2)).astype(np.float32) + img = Image.fromarray((small * 255).astype(np.uint8)).resize((W + 2 * M, H + 2 * M), Image.Resampling.BICUBIC) + img = img.filter(ImageFilter.GaussianBlur(scale * 0.6)) + return np.asarray(img, dtype=np.float32) / 255.0 + +def shot_frames(seed, sky, ground, accent, dur, drift): + n = int(round(dur * FPS)) + f1, f2 = noise_field(seed, 24), noise_field(seed + 1, 8) + yy = np.linspace(0, 1, H, dtype=np.float32)[:, None] + for i in range(n): + t = i / max(1, n - 1) + dx = int(drift[0] * t); dy = int(drift[1] * t) + a = f1[M + dy:M + dy + H, M + dx:M + dx + W]; b = f2[M + dy:M + dy + H, M + dx:M + dx + W] + grad = np.clip(yy + (a - 0.5) * 0.5, 0, 1)[..., None] + base = sky * (1 - grad) + ground * grad + brush = (b - 0.5) * 0.18 + light = np.clip(0.5 + 0.5 * np.cos(t * 1.4 + a * 3.0), 0, 1)[..., None] * 0.22 + frame = np.clip(base + brush[..., None] + accent * light, 0, 1) + yield frame + +def build(name, pal, durs): + sky, ground, accent = (col(c) for c in P[pal]) + total = sum(durs) + frames = [] + for k, d in enumerate(durs): + seed = hash((name, k)) & 0xffff + drift = (rng.integers(-18, 18), rng.integers(-10, 10)) + tint = 1.0 + 0.08 * ((k % 3) - 1) + # 디졸브는 앞 샷 끝과 겹치므로 그만큼 길게 뽑아야 총 길이가 저작 길이와 같다. + frames.append(list(shot_frames(seed, sky * tint, ground, accent, d + (DISSOLVE if k else 0.0), drift))) + # 디졸브 + 마지막 페이드아웃 + out = [] + for k, seq in enumerate(frames): + if k == 0: + out.extend(seq); continue + m = int(DISSOLVE * FPS) + for j in range(m): + a = j / m + out[-m + j] = out[-m + j] * (1 - a) + seq[j] * a + out.extend(seq[m:]) + fade = int(0.6 * FPS) + for j in range(fade): + out[-fade + j] = out[-fade + j] * (1 - (j + 1) / fade) + path = os.path.join(OUT, name + ".mp4") + cmd = [FF, "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(FPS), "-i", "-", + "-vf", "scale=1280:720:flags=lanczos,gblur=sigma=1.2,noise=alls=6:allf=t,setsar=1", + "-an", "-c:v", "libx264", "-profile:v", "main", "-level", "4.0", "-pix_fmt", "yuv420p", + "-b:v", "1600k", "-maxrate", "2000k", "-bufsize", "4000k", "-movflags", "+faststart", path] + p = subprocess.Popen(cmd, stdin=subprocess.PIPE) + for fr in out: + p.stdin.write((fr * 255).astype(np.uint8).tobytes()) + p.stdin.close(); p.wait() + print(f"{name}.mp4 {total:.1f}s {os.path.getsize(path)/1e6:.2f}MB rc={p.returncode}") + +os.makedirs(OUT, exist_ok=True) +for v in VIDEOS: + build(*v) diff --git a/.claude/skills/add-quest/SKILL.md b/.claude/skills/add-quest/SKILL.md index 8938bdb..cc97827 100644 --- a/.claude/skills/add-quest/SKILL.md +++ b/.claude/skills/add-quest/SKILL.md @@ -23,6 +23,7 @@ argument-hint: " <목표설명>" | targetCount | 목표 횟수 | 1 | | prerequisiteQuestId | 선행 퀘스트 (선형 체인) | `q_battle10` | | 보상 | rewardCandy/rewardExp, rewardItemId+Count, rewardInsectId+DisplayName+Level | 택1+ | +| requiredRegionId | (Side 전용) 지역 의뢰 — 그 리전 해금 후 활성, 그 리전 안 행동만 셈. 규칙은 `rules/quest-system.md` 「지역 의뢰」 | `pond` | **기존 QuestType 목록**: Movement, Capture, ViewCollection, LevelUp, UseItem, Battle, Training, SetTeam, RaidBattle, DefeatGuardian, VisitRegion, VisitSubArea, OpenDex, diff --git a/.claude/skills/add-story/SKILL.md b/.claude/skills/add-story/SKILL.md index 87dfe0e..76570a0 100644 --- a/.claude/skills/add-story/SKILL.md +++ b/.claude/skills/add-story/SKILL.md @@ -25,7 +25,7 @@ FAIL 0을 확인한다.** "requiredRegionId": "리전 ID 또는 \"\" (무param 트리거 리전 잠금)", "trigger": { "type": "RegionEnter", "param": "pond" }, "speakerNpcId": "village_elder", - "lines": [ { "speaker": "이름", "text": "대사" } ], + "lines": [ { "speaker": "이름", "text": "대사" }, { "speaker": "지문", "text": "해설", "fx": "pause" } ], "choices": [], "onComplete": { "rewardCandy": 5, "rewardExp": 0, "rewardItemId": "", "rewardInsectId": "", "unlockQuestId": "" }, @@ -125,14 +125,22 @@ Dictionary 비결정 — 그럴 땐 prereq로 한 번에 하나만 적격이 되 python -X utf8 .claude/scripts/story_lint.py ``` -22검사가 전부 PASS여야 한다(FAIL 0, WARN 0): +29검사가 전부 PASS여야 한다(FAIL 0, WARN 0). 급소만 추리면: - beatId 중복 / prerequisite 무결성(끊김·순환) / 트리거 param 대상 존재 / 분기 도달성(choices.nextBeatId) / onComplete 보상·unlock ID 존재 / **트리거 배선 정합(JSON↔StoryDirector)** / **requiredRegionId 정합(리전 게이트)** / **일생 1회 트리거의 스파인 사용**(GuardianDefeat가 어느 비트의 prereq도 아닐 것) / - **스토리 인물 4중 등록**(앰비언트·소개 비트·표시명·외형) + **스토리 인물 4중 등록**(앰비언트·소개 비트·표시명·외형) / + **퀘스트 게이트의 1회 완료형**(검사 29 — `requiredQuestId`가 반복 서브를 물면 영영 안 열린다) -### 스토리 NPC를 새로 세운다면 — 등록이 여섯 곳이다 +### 마을 이야기(`chapterId: town`)를 더한다면 + +한 편 = 4비트(만남 · 징후 · 매듭 · 후일담) + 지역 의뢰 1개(`s_town_{r}`, Side·1회·`requiredRegionId`). +매듭이 `requiredQuestId`로 의뢰 완료를 **관찰**한다(스토리는 퀘스트를 만들지 않는다). 형태·게이트·자리 계산은 +`Docs/StoryBible.md` 13장. `!`/`?` 표식과 따라가기는 town 챕터의 `NpcTalk` 대상에게 **저절로** 붙는다 — +인물 목록을 코드에 박지 않았다(`StoryTaleResolver.CollectTaleNpcIds`). + +### 스토리 NPC를 새로 세운다면 — 등록이 일곱 곳이다 비트만 쓰면 안 된다. 아래를 다 채워야 그 인물이 제 이름과 얼굴로 서고 말을 한다. 검사기가 넷을 보고 **둘(앵커 각도·보스 표의 레벨)은 사람이 판단해야 한다**: @@ -141,10 +149,11 @@ python -X utf8 .claude/scripts/story_lint.py |---|---|---|---| | 1 | `VillageBuilder` 앵커 (`storyNpcId` **리터럴**) | 월드에 안 선다 | 검사 3 | | 2 | `NpcManager.StoryNpcDisplayName` | **"마을 어르신"으로 뜬다** | 검사 22 | -| 3 | `NpcVisualBuilder.StoryNpcAppearance` | **마을 어르신 얼굴로 뜬다** | 검사 22 | +| 3 | `NpcVisualBuilder.StoryNpcFace`(얼굴) + `StorySignature`(옷차림) | **마을 어르신 얼굴로 뜬다** / 옷차림 누락은 어르신 모자·지팡이 | 검사 22 | | 4 | `NpcDialogueDatabase.StoryNpcLines` | 주민 잡담으로 떨어진다 | 검사 20 | | 5 | `NpcBossDuels.Table` (싸울 상대면) | 도전이 안 열린다 | `blight_lint` 1 | | 6 | 소개 비트(`speakerNpcId` 또는 `NpcTalk` param) | `HasMetStoryNpc` false → 보스전 불가 | 검사 19 | +| 7 | `NpcDialogueUI.GetStoryPortrait` (대사창 초상) | **얼굴 없이 이름표만** 뜬다 | 검사기 없음 — 마을 주민은 `TownTaleTests` | **앵커 각도는 손으로 고른다.** 서브에리어 진입 반경 안에 서면 말을 걸려다 구역에 빨려 들어가고, 같은 리전의 다른 인물과 겹치면 누구에게 말을 거는지 알 수 없다. 그 리전의 diff --git a/.claude/skills/progression-sim/SKILL.md b/.claude/skills/progression-sim/SKILL.md index 39896cf..1cd299a 100644 --- a/.claude/skills/progression-sim/SKILL.md +++ b/.claude/skills/progression-sim/SKILL.md @@ -53,16 +53,16 @@ python .claude/scripts/progression_sim.py --rarity Rare # 중간 시나리 | 항목 | 임계값 | 의미 | |---|---|---| -| 곤충 Lv50 캔디 전투 수 (**현실 진행**) | < 4,000 | 곤충 레벨이 리전 진행과 동기화된다는 가정. 비싼 후반 레벨(전체 캔디의 84%가 Lv36+)이 고레어 엔드리전 income으로 벌린다. 리전 스폰가중(`GetWeightedRandom`)에서 등급 분포를 코드로 읽어 계산 | +| 곤충 Lv50 캔디 전투 수 (**현실 진행**) | < 4,000 | 곤충 레벨이 리전 진행과 동기화된다는 가정. 리전 등급 분포는 전역 등급표(`FieldSpawnRules`의 `…Share`) + 풀에 없는 등급의 대체(아래 → 위)를 코드에서 읽어 계산 | | 팀 N마리 캔디 비용 | < 100,000 | MaxTeamSlots 반영 | | 트레이너 EXP 후반/초반비 | <= 5x | 트레이너는 **선형**이라 완만해야 정상. 곤충 캔디(지수)는 이 검사 대상 아님 — 지수가 설계 | | 튜토리얼 캔디 / 곤충 Lv1→10 비용 | >= 5% | 초반 부양 강도. **엔드게임 단일 곤충 평생 캔디가 아니라 초반 대비** | 신호1은 **현실 진행**(리전 동기화)을 판정한다. "Common 고정·배틀+포획만"(~8,700전투)은 출력에 참고 상한으로만 병기하고 **FAIL 트리거로 쓰지 않는다** — 시뮬 자신이 그걸 "최악"이라 부르면서 -그걸로 FAIL을 내면 자기가 인정한 극단으로 거짓 경보를 울리는 셈이다. 실제 등급 분포는 진행에 -따라 오르고(유적: Common 0%, R+E+L 77%, ~7캔디/전투) 레이드(×3)·가챠·튜토리얼이 더해져 -현실 전투 수(~3,100)는 임계값 아래다. +그걸로 FAIL을 내면 자기가 인정한 극단으로 거짓 경보를 울리는 셈이다. 등급 분포는 2026-09-29부터 +**리전과 무관한 전역 표**다(희귀도는 리전을 안 따르고 레벨만 따른다) — 예전처럼 뒤 리전이 고레어로 캔디를 +더 주는 가속(유적 ~7캔디/전투)은 없다. 수치는 출력의 리전 표를 볼 것. ## Phase 4: 권장 조정 (FAIL/WARN별) @@ -87,8 +87,8 @@ python .claude/scripts/progression_sim.py --rarity Rare # 중간 시나리 ## 가정 / 한계 - 신호1(판정)은 **현실 진행**: 곤충 레벨 L의 캔디를 그 시점 리전(requiredLevel<=L 중 최상위)의 - 스폰가중 기대 캔디로 벌어들인다고 본다. 리전 income은 `RegionDefinitions` 풀 + 로스터 - spawnWeight를 코드에서 읽어 산출(`InsectSpawner.GetWeightedRandom` 무아이템과 동일 가중). + 기대 캔디로 벌어들인다고 본다. 리전 income은 `FieldSpawnRules` 전역 등급표 + `RegionDefinitions` + 풀의 등급 구성(빠진 등급 대체)을 코드에서 읽어 산출(무아이템). 풀이 다섯 등급을 다 가지면 전 리전이 같다. - 캔디는 전역 단일 풀(`PlayerCandyInventory`)이라 종 무관 합산 — 종별 캔디 아님. - 레이드(×3)·가챠 박스(5~50)·튜토리얼(336)은 별도라 현실 전투 수를 더 낮춘다. 현실 수치도 그 의미에서 상한. 레어스폰 아이템/의상 보너스도 미반영(있으면 고레어↑ → income↑). diff --git a/.codex/agents/capture-dev.toml b/.codex/agents/capture-dev.toml index fcb4f2e..1849726 100644 --- a/.codex/agents/capture-dev.toml +++ b/.codex/agents/capture-dev.toml @@ -12,8 +12,11 @@ developer_instructions = """ - `Assets/Scripts/Capture/CaptureTriggerModeController.cs` - 감지 모드 전환 - `Assets/Scripts/Capture/CaptureFeedbackController.cs` - 포획 연출 - `Assets/Scripts/Capture/CaptureTriggerOptionsUI.cs` - 트리거 설정 UI -- `Assets/Scripts/Spawning/InsectSpawner.cs` - 월드 스폰 (최대20, 리전별 최소5) -- `Assets/Scripts/Spawning/SpawnPoint.cs` - 스폰 포인트 (반경5m, 로컬최대2) +- `Assets/Scripts/Spawning/InsectSpawner.cs` - 필드 개체군 (리전별 슬롯 기록 · 시간 기반 재생 · 45m 실체화/55m 회수 · 서브에리어 슬롯) +- `Assets/Scripts/Spawning/FieldSpawnRules.cs` - 필드 스폰 순수 규칙 (전역 등급표 · 등급 대체 · 레어 부스트 · 레벨 · 슬롯 수 · 재생/수명) +- `Assets/Scripts/Spawning/FieldPopulation.cs` - 슬롯 기록부 + 상태 전이 (게임플레이 퇴장 vs 거리 회수) +- `Assets/Scripts/Spawning/FleePath.cs` - 도주 방향·거리 순수 판정 (벽 관통 방지) +- `Assets/Scripts/Spawning/SpawnPoint.cs` - 리전/서브에리어 스폰 표 (풀 · 레벨 대역) — 자리는 정하지 않음 - `Assets/Scripts/Spawning/InsectEntity.cs` - 곤충 엔티티 (프로시저럴 3D 모델) - `Assets/Scripts/Spawning/SimpleObjectPool.cs` - Get()/Return() 오브젝트 풀 - `Assets/Scripts/Spawning/DistanceCulling.cs` - 거리 컬링 (25m/20m) @@ -44,11 +47,15 @@ timingBonus: |timing-0.5|≤0.15이면 +0.15 커서 가속: 1 + |pos-0.5|×0.6 (가장자리에서 빨라짐) ``` -### 스폰 레벨 +### 필드 스폰 (FieldSpawnRules가 단일 출처) ``` -level = lerp(min, max, pow(random, power)) -서브에리어: power=2.0, 메인필드: power=3.5 -레어도 보정: Uncommon+0.5, Rare+1.5, Epic+3.0, Legendary+5.0 +등급 = 전역 표 C .60 / U .25 / R .11 / E .035 / L .005 (리전과 무관, 레어 부스트는 R+에만 ×) + → 풀에 없는 등급은 가까운 아래 → 위로 대체 (안전망) +종 = 그 등급의 (리전 풀 ∩ 시간·날씨 후보) 안에서 spawnWeight 가중 +레벨(메인) = 리전 대역 [requiredLevel, +GetRegionLevelRange] 안 pow(random, 1.5), 등급 보정 없음 +레벨(서브에리어) = min~max 균등 +슬롯 = 설 수 있는 땅 / 550㎡ (8~40) → BlightPolicy.MaxActiveFor +재생 60~120s (서브에리어 45s) · 수명 4~7분(플레이어 25m 밖에서만 교체) · 새 개체는 플레이어 20m 밖 ``` ## 공유 파일 수정 경계 @@ -61,5 +68,6 @@ level = lerp(min, max, pow(random, power)) ## 설계 원칙 - WorldState 기반 스폰 필터링 (시간+날씨 조합) - 오브젝트 풀 필수 사용 (Instantiate 최소화) -- 60m 이상 자동 디스폰, 8초마다 스폰포인트 재배치 +- 기록(슬롯)이 개체다 — 멀어지면 몸만 풀로 돌리고(Recall) 기록은 남긴다. 리전 이동으로 리롤하지 않는다 +- 사라지는 방식 둘: 게임플레이 퇴장(Despawn 콜백 → 재생 지연) vs 스포너 회수(Recall → 개체 유지) - 포획 아이템은 speedMult/zoneMult/timeMult/captureBonus 4가지 효과""" diff --git a/.gitignore b/.gitignore index 9d7f5a5..5637835 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ crashlytics-build.properties # Test results / 배치 실행 로그 TestResults.xml +TestResults*.xml TestResults/ # `unity-test.log`(하이픈)만 적어둔 탓에 실제 산출물인 `unity_test.log`·`unity_compile.log` # (언더스코어)가 매번 `git add -A`에 딸려 들어왔다. 두 표기를 모두 덮는다. @@ -112,3 +113,7 @@ KakaoTalk_*.png # Codex 미러 실행 로그 /.codex/*.log + +# QA 캡처 산출물(배지·전투 가로/세로 촬영 등 — 수백 MB)과 로컬 원본 압축본. 저장소 대상이 아니다. +/Artifacts/ +/Insect.zip diff --git a/AGENTS.md b/AGENTS.md index 57a65aa..1dd4cf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,22 +58,27 @@ RaidBattleController → RaidBattleUI |---|---|---| | 배틀 1v1 | `InsectBattleController` | 턴제, 스킬 쿨다운, 도주 판정 | | 레이드 5v1 | `RaidBattleController` | 보스 스탯 배율, 유나이트 게이지, 주기적 AOE | -| 포획 | `CaptureController` → `CaptureMinigameController` | 3단계 난이도 미니게임 | -| 스폰 | `InsectSpawner` | WorldState(시간+날씨) 후보 필터링, 거리 컬링 | +| 포획 | `CaptureController` → `CaptureMinigameController` | 미니게임 3종(살금살금·가두기·던지기)이 포획마다 무작위. 규칙은 `CaptureMinigame`(순수), 결과는 0~3점 | +| 스폰 | `InsectSpawner` → `FieldSpawnRules`·`FieldPopulation` | 리전별 슬롯 기록(리전 이동으로 리롤 안 함) + 시간 기반 재생·수명 순환, 45m 실체화/55m 회수. 희귀도는 전역 등급표, 레벨만 리전 대역 | | 세이브 | `PlayerProgressSaveService`, `CloudSaveManager` | 로컬 7개 JSON + Firestore. 규칙은 `rules/save-system.md` | | 스탯/IV | `PlayerInsectData` | IV 0~15(HP/ATK/DEF), 등급 S~D | +| 첫 시작 연출 | `DreamPrologueDirector` | 「챔피언의 꿈」 — 신규 계정 1회(설정에서 다시보기). 경기장 입장 영상 → 챔피언전(샌드박스 전투) → 꾸며진 섬 걷기 → 깨어남. **꿈 밖 기록을 건드리지 않는 약속**을 `DreamPrologueState.Active`로 지킨다. 규칙은 `rules/dream-prologue.md` | +| 나의 섬 | `IslandManager` → `IslandWorldBuilder` | 개인 섬 하우징. 칸 배치·방목 시간 보상·스냅샷 방문. **분리 서브에리어**로 탄다. 규칙은 `rules/island.md` | +| 낮·밤·날씨 | `GameClock` · `WeatherSystem` → `SubAreaEnvironment`(하늘) · `InsectSpawner`(성향·보너스 슬롯) | 하루 12분, 날씨 5종(맑음·비·안개·센바람·눈). 날씨는 세계에 하나지만 **보이는 건 지역 기후로 다르다**(`GetWorldState(regionId)`). 곤충 194종이 `InsectHabitTable`의 성향(주행성·야행성·좋아하는 날씨)을 든다. 규칙은 `rules/world-environment.md` | ### UI 흐름 ``` MainMenu → PlayScene ├→ 필드 탐험 (PlayerMovement + HUD) ├→ CaptureChoiceUI (곤충 접근 시) - │ ├→ [E] 미니게임 포획 + │ ├→ [E] 미니게임 포획 (지난번 채집망으로 바로 — 버튼 아래 줄에서 바꾼다) │ ├→ [B] 1v1 배틀 │ └→ [R] 레이드 (Epic/Legendary만) ├→ DexScreenUI (도감) ├→ CollectionUI (보유 곤충) ├→ ShopUI / GachaUI + ├→ IslandVisitUI (탐험 메뉴 [내 섬] · 본 마을 나루터) + │ └→ 섬: IslandHudUI → 꾸미기 / 상점 / 곤충 / 수확 / 방문 / 도움말 └→ SettingsPanel ``` @@ -87,7 +92,10 @@ AutoWire·이벤트·오브젝트 풀 패턴, 금지 사항이 전부 거기 있 `rules/ui-layout.md`(배치는 `UISafeLayout`, 표면·색은 `UISurface`+`UITheme` 토큰 경유 강제), `rules/save-system.md`(세이브 필드 추가), `rules/scriptable-objects.md`(SO 생성), -`rules/quest-system.md`(퀘스트 추가 시 다지점 등록 — 빠뜨리면 영구 정지), +`rules/quest-system.md`(퀘스트 추가 시 다지점 등록 — 빠뜨리면 영구 정지. 크기·속성·전투 조건은 새 타입 없이 `CaptureTrait`·`BattleFeat` + 조건 필드), +`rules/dream-prologue.md`(첫 시작 연출 — 표지를 읽어야 하는 자리, 샌드박스 전투), +`rules/island.md`(나의 섬 — `detached` 서브에리어를 걸러야 하는 자리, 물건 추가 3곳, 다이아 환급 금지), +`rules/world-environment.md`(낮·밤·날씨 — 지역 기준 날씨를 읽어야 하는 자리, 하늘 보정의 이중 적용 금지, 새 곤충은 성향표 한 줄), `rules/testing.md`(테스트 필수 기준), `rules/agent-coordination.md`(공유 파일 수정 경계). @@ -98,11 +106,11 @@ AutoWire·이벤트·오브젝트 풀 패턴, 금지 사항이 전부 거기 있 | 검사기 | 강제하는 규칙 | 단일 출처 | |---|---|---| -| `quest_lint.py` | questId 중복·prerequisite 무결성·QuestType↔진행 배선 등 11검사 | `rules/quest-system.md` | +| `quest_lint.py` | questId 중복·prerequisite 무결성·QuestType↔진행 배선·지역 의뢰 정합·조건부 퀘스트 정합 등 13검사 | `rules/quest-system.md` | | `ui_layout_lint.py` | 패널 y·height 직접 계산 금지 (`UISafeLayout` 경유) | `rules/ui-layout.md` | | `subscription_lint.py` | `OnDisable`에서 해지한 구독을 `OnEnable`에서 되살릴 것 | `rules/ui-layout.md` | | `data_lint.py` | 곤충·아이템·리전 데이터 정합(ID 유일성, 참조 무결, 풀 배정) | 코드(`InsectDatabase` 등)와 스크립트 자신 | -| `story_lint.py` | 스토리 비트 트리거·보상·리전키 정합 + 스토리 NPC 4중 등록(앰비언트·소개비트·표시명·외형) 24검사 | 코드(`StoryBeat`)와 스크립트 자신 | +| `story_lint.py` | 스토리 비트 트리거·보상·리전키 정합 + 스토리 NPC 4중 등록(앰비언트·소개비트·표시명·외형) + 컷신·영상 ID 실재성 + 대사 화자·줄 연출 토큰 + 퀘스트 게이트의 1회 완료형 + 명부회 어휘(주인공 쪽이 「거둬들이다」를 안 쓴다) + 쉬운 이야기(대사 한 줄·장면 길이·쓰지 않는 말·HUD 목표 이유 why·장 「지난 이야기」) + 대사 직후 대결(`duelAfter` 상대·자리·라온 단계·런처 배선, 간부 승리 스파인의 재확인) + 대사 앞 영상(`introVideoId` 실재·`videoId`와 배타·대사 1~6줄) 36검사 | 코드(`StoryBeat`)와 스크립트 자신 | | `dex_grant_lint.py` | 곤충을 지급하면 도감에도 올릴 것(`AddCapturedInsect`↔`RegisterCapture`) | 코드(`DexController`)와 스크립트 자신 | | `blight_lint.py` | 명부회 아크 — 거점(보스·귀환종·비트·재도전 예외·스폰 하한·퀘스트 달성 가능성)과 「장부」 소모 지점 20검사 | 코드(`RegionData` 거점 필드)와 스크립트 자신 | | `singleton_lint.py` | 싱글턴이 `OnDestroy`에서 `Instance`를 비울 것 | 코드(`*Manager.cs`)와 스크립트 자신 | diff --git a/Assets/Editor/BattleVisualCaptureBuilder.cs b/Assets/Editor/BattleVisualCaptureBuilder.cs new file mode 100644 index 0000000..2e4fa3c --- /dev/null +++ b/Assets/Editor/BattleVisualCaptureBuilder.cs @@ -0,0 +1,59 @@ +#if UNITY_EDITOR +using System; +using System.IO; +using InsectGame.Battle; +using UnityEditor; +using UnityEditor.Build.Reporting; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace InsectGame.EditorTools +{ + public static class BattleVisualCaptureBuilder + { + public static void Build() + { + if (!Application.isBatchMode) throw new InvalidOperationException("Use batch mode to protect the open scene."); + const string scenePath = "Assets/BattleVisualCaptureGenerated.unity"; + if (File.Exists(scenePath)) + { + // 빌드가 도중에 죽으면(메모리 부족 등) finally가 못 돌아 이 씬이 남는다. 이 빌더가 만든 모양 그대로면 치우고 계속한다. + if (!IsLeftoverFromThisBuilder(scenePath)) + throw new InvalidOperationException("Temporary scene already exists and was not made by this builder; inspect it before retrying."); + Debug.Log("[QA] removing leftover " + scenePath + " from an interrupted build"); + AssetDatabase.DeleteAsset(scenePath); + } + var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + new GameObject("BattleVisualCapture").AddComponent(); + try + { + EditorSceneManager.SaveScene(scene, scenePath); + BuildReport report = BuildPipeline.BuildPlayer(new BuildPlayerOptions + { + scenes = new[] { scenePath }, + locationPathName = "Builds/Windows/BattleVisualQA/BattleVisualQA.exe", + target = BuildTarget.StandaloneWindows64, + options = BuildOptions.Development + }); + if (report.summary.result != BuildResult.Succeeded) throw new InvalidOperationException(report.summary.result.ToString()); + } + finally + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + AssetDatabase.DeleteAsset(scenePath); + } + } + + // 빈 씬 + BattleVisualCapture 오브젝트 하나 — 위 Build가 저장하는 모양이다. 누가 손댄 씬이면 false. + static bool IsLeftoverFromThisBuilder(string scenePath) + { + string text = File.ReadAllText(scenePath); + int gameObjects = 0; + for (int at = text.IndexOf("--- !u!1 &", StringComparison.Ordinal); at >= 0; + at = text.IndexOf("--- !u!1 &", at + 1, StringComparison.Ordinal)) + gameObjects++; + return gameObjects == 1 && text.Contains("m_Name: BattleVisualCapture"); + } + } +} +#endif diff --git a/Assets/Editor/BattleVisualCaptureBuilder.cs.meta b/Assets/Editor/BattleVisualCaptureBuilder.cs.meta new file mode 100644 index 0000000..2eaa085 --- /dev/null +++ b/Assets/Editor/BattleVisualCaptureBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: adb792c4028b48045bd63d6635641503 \ No newline at end of file diff --git a/Assets/Editor/BattleVoiceExport.cs b/Assets/Editor/BattleVoiceExport.cs new file mode 100644 index 0000000..3b9964d --- /dev/null +++ b/Assets/Editor/BattleVoiceExport.cs @@ -0,0 +1,73 @@ +#if UNITY_EDITOR +using System; +using System.IO; +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.EditorTools +{ + /// + /// 전투 목소리(울음·비명·타격음)를 WAV로 뽑는다 — 소리는 화면 캡처로 확인할 수 없어서 + /// 합성 결과를 귀로 비교하려면 파일이 필요하다. 합성기는 게임이 쓰는 그 코드 + /// (ProceduralAudioGenerator.GetSFX) 그대로다. + /// + /// + /// Unity.exe -batchmode -projectPath X:/ -executeMethod InsectGame.EditorTools.BattleVoiceExport.Run + /// -voiceOut .claude/cache/fx/voices -quit + /// + /// + public static class BattleVoiceExport + { + private static readonly string[] Keys = + { + "hit", "critical", + "cry_beetle", "hurt_beetle", "cry_mantis", "hurt_mantis", "cry_flyer", "hurt_flyer", + "cry_buzzer", "hurt_buzzer", "cry_chirper", "hurt_chirper", "cry_crawler", "hurt_crawler", + "cry_boss", "hurt_boss" + }; + + public static void Run() + { + string[] args = Environment.GetCommandLineArgs(); + int arg = Array.IndexOf(args, "-voiceOut"); + string output = Path.GetFullPath(arg >= 0 && arg + 1 < args.Length ? args[arg + 1] : ".claude/cache/fx/voices"); + Directory.CreateDirectory(output); + int written = 0; + foreach (string key in Keys) + { + AudioClip clip = ProceduralAudioGenerator.GetSFX(key); + if (clip == null) { Debug.LogError($"[VOICE] missing {key}"); continue; } + WriteWav(Path.Combine(output, key + ".wav"), clip); + written++; + } + Debug.Log($"[VOICE] wrote {written}/{Keys.Length} to {output}"); + if (Application.isBatchMode) UnityEditor.EditorApplication.Exit(written == Keys.Length ? 0 : 1); + } + + private static void WriteWav(string path, AudioClip clip) + { + float[] samples = new float[clip.samples * clip.channels]; + clip.GetData(samples, 0); + using (var stream = new FileStream(path, FileMode.Create)) + using (var w = new BinaryWriter(stream)) + { + int byteCount = samples.Length * 2; + w.Write(new[] { 'R', 'I', 'F', 'F' }); + w.Write(36 + byteCount); + w.Write(new[] { 'W', 'A', 'V', 'E', 'f', 'm', 't', ' ' }); + w.Write(16); + w.Write((short)1); + w.Write((short)clip.channels); + w.Write(clip.frequency); + w.Write(clip.frequency * clip.channels * 2); + w.Write((short)(clip.channels * 2)); + w.Write((short)16); + w.Write(new[] { 'd', 'a', 't', 'a' }); + w.Write(byteCount); + foreach (float s in samples) + w.Write((short)Mathf.RoundToInt(Mathf.Clamp(s, -1f, 1f) * short.MaxValue)); + } + } + } +} +#endif diff --git a/Assets/Editor/BattleVoiceExport.cs.meta b/Assets/Editor/BattleVoiceExport.cs.meta new file mode 100644 index 0000000..a98a883 --- /dev/null +++ b/Assets/Editor/BattleVoiceExport.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5d3a29e11eb09794986a38f5f20c600a \ No newline at end of file diff --git a/Assets/Editor/FieldDesignTour.cs b/Assets/Editor/FieldDesignTour.cs new file mode 100644 index 0000000..20674b0 --- /dev/null +++ b/Assets/Editor/FieldDesignTour.cs @@ -0,0 +1,692 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.NPC; +using UnityEditor; +using UnityEngine; + +namespace InsectGame.EditorTools +{ + /// + /// 필드 디자인 순회 캡처 — 실제 PlayScene을 띄워 한 번의 실행으로 본 마을·리전 13곳· + /// 전초기지·서브에리어 전부·NPC 전원을 찍는다. 테마(건물·지형지물·NPC 옷) 개선의 전/후 기준이다. + /// + /// + /// Unity.exe -batchmode -projectPath <proj> -logFile <log> \ + /// -executeMethod InsectGame.EditorTools.FieldDesignTour.Run \ + /// -tourOut .claude/cache/tour [-tourOnly village,regions,outposts,subareas,npcs,population] [-tourFilter dunes,canopy] + /// (population은 사진이 아니라 판정이다 — 리전을 떠났다 돌아와도 같은 곤충인지. 보고서에 줄이 남는다) + /// + /// + /// 장소마다 세 구도를 남긴다 — game(실제 게임 카메라 그대로, 플레이어가 보는 것), + /// wide(고각 조감 — 배치·밀도), eye(눈높이 — 건물·지형지물의 실루엣). + /// 게임 카메라는 (0,9,-6) 고각이라 세로로 선 것의 형태가 거의 안 보인다 — eye가 그걸 메운다. + /// + /// NPC는 제자리에서 찍지 않는다. 컬링(DistanceCulling)이 렌더러를 끄고 주변 소품이 가린다. + /// 같은 프레임 안에서 무대(먼 좌표의 바닥판)로 옮겨 찍고 되돌린다 — Update가 끼지 않으니 배회가 안 섞인다. + /// 실제 스폰 경로(NpcManager)가 만든 개체를 찍으므로 외형 규칙이 바뀌면 그대로 따라온다. + /// + /// IMGUI는 안 찍힌다(LiveSceneCapture와 같은 한계). 에디터를 열어 두면 프로젝트 잠금으로 못 돈다. + /// + /// 배치모드 전용이다(ModelDesignCapture·VillageDesignCapture와 같은 가드). 열린 에디터에서 돌리면 + /// 저장 안 된 씬을 묻지도 않고 PlayScene으로 갈아 끼우고, 끝나면 EditorApplication.Exit로 에디터째 닫는다. + /// 그래서 메뉴 항목도 두지 않는다. + /// + /// 종료 코드는 실패()가 한 건도 없을 때만 0이고, 있으면 3이다(LiveSceneCapture 관례). + /// 예전엔 실패가 보고서 줄로만 쌓이고 늘 0으로 끝나 "다 찍었다"로 읽혔다. + /// + public static class FieldDesignTour + { + private const string StageKey = "InsectGame.FieldDesignTour.Stage"; + private const string Scene = "Assets/Scenes/PlayScene.unity"; + private const float BootWait = 7f; + private const float HardTimeoutSeconds = 900f; + private static readonly Vector3 NpcStage = new Vector3(4000f, 0f, 4000f); + + private struct Step + { + public string label; + public Action act; + public float wait; + public Action shoot; + } + + private static string outDir; + private static HashSet only; + private static HashSet filter; + private static List steps; + private static int index; + private static bool acted; + private static float startTime; + private static float stepStart; + private static int written; + private static int failures; + private static readonly StringBuilder report = new StringBuilder(); + + public static void Run() + { + if (!Application.isBatchMode) throw new InvalidOperationException("Field design tour requires batch mode to protect the open scene."); + string o = Arg("-tourOut") ?? ".claude/cache/tour"; + string stage = string.Join("|", o, Arg("-tourOnly") ?? "", Arg("-tourFilter") ?? ""); + UnityEditor.SceneManagement.EditorSceneManager.OpenScene( + Scene, UnityEditor.SceneManagement.OpenSceneMode.Single); + SessionState.SetString(StageKey, stage); + Log($"시작 {stage}"); + EditorApplication.EnterPlaymode(); + } + + [InitializeOnLoadMethod] + private static void Resume() + { + string stage = SessionState.GetString(StageKey, ""); + if (string.IsNullOrEmpty(stage)) return; + if (!EditorApplication.isPlayingOrWillChangePlaymode) return; + + string[] p = stage.Split('|'); + outDir = Path.GetFullPath(Path.Combine(Application.dataPath, "..", p[0])); + only = Set(p.Length > 1 ? p[1] : ""); + filter = Set(p.Length > 2 ? p[2] : ""); + Directory.CreateDirectory(outDir); + steps = null; + index = 0; + acted = false; + written = 0; + failures = 0; + report.Length = 0; + startTime = Time.realtimeSinceStartup; + EditorApplication.update += Tick; + Log("플레이모드 진입 — 부트스트랩 대기"); + } + + private static HashSet Set(string raw) + { + var s = new HashSet(); + foreach (string part in raw.Split(',')) + if (!string.IsNullOrWhiteSpace(part)) s.Add(part.Trim()); + return s; + } + + private static bool Wants(string group) => only.Count == 0 || only.Contains(group); + private static bool Passes(string regionId) => filter.Count == 0 || filter.Contains(regionId); + + /// + /// 시계·날씨를 정오·맑음으로 붙잡는다. 게임 시계는 새벽 6시에 시작해 돌고(하루 12분) 날씨도 스스로 바뀌므로, 3분짜리 투어 370장이 + /// 서로 다른 조명으로 찍힌다 — 전후 비교는 같은 조건이어야 한다. -tourHour 0~24·-tourWeather clear|rain|fog|wind|snow로 + /// 다른 조건을 고를 수 있다(natural이면 게임 그대로). + /// + private static void PinSky() + { + string hourArg = Arg("-tourHour"); + string weatherArg = Arg("-tourWeather"); + var provider = UnityEngine.Object.FindFirstObjectByType(); + if (provider == null) + { + Log("시각·날씨 공급자를 못 찾아 하늘을 고정하지 못했다"); + return; + } + + if (!string.Equals(hourArg, "natural", StringComparison.OrdinalIgnoreCase) && provider.Clock != null) + { + float hour = 12f; + if (!string.IsNullOrEmpty(hourArg)) float.TryParse(hourArg, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out hour); + provider.Clock.SetTime01(hour / 24f, true); + } + + if (!string.Equals(weatherArg, "natural", StringComparison.OrdinalIgnoreCase) && provider.Weather != null) + { + InsectGame.Core.WeatherType weather = InsectGame.Core.WeatherType.Clear; + switch ((weatherArg ?? "").Trim().ToLowerInvariant()) + { + case "rain": weather = InsectGame.Core.WeatherType.Rain; break; + case "fog": weather = InsectGame.Core.WeatherType.Fog; break; + case "wind": weather = InsectGame.Core.WeatherType.Wind; break; + case "snow": weather = InsectGame.Core.WeatherType.Snow; break; + } + provider.Weather.SetWeather(weather, true, true); + } + Log($"하늘 고정 — hour={hourArg ?? "12"} weather={weatherArg ?? "clear"}"); + } + + private static void Tick() + { + float now = Time.realtimeSinceStartup; + float elapsed = now - startTime; + if (elapsed > HardTimeoutSeconds) + { + Finish($"시간 초과 {elapsed:F0}s — {index}/{steps?.Count ?? 0}단계에서 멈춤", 3); + return; + } + if (elapsed < BootWait) return; + + try + { + if (steps == null) + { + PinSky(); + steps = BuildSteps(); + Log($"단계 {steps.Count}개"); + } + if (index >= steps.Count) + { + Finish($"완료 written={written} failures={failures} elapsed={elapsed:F0}s", failures == 0 ? 0 : 3); + return; + } + + Step s = steps[index]; + if (!acted) + { + acted = true; + stepStart = now; + s.act?.Invoke(); + return; + } + if (now - stepStart < s.wait) return; + s.shoot?.Invoke(); + index++; + acted = false; + } + catch (Exception e) + { + Debug.LogException(e); + Finish($"예외로 중단 — {e.Message}", 3); + } + } + + private static void Finish(string message, int code) + { + EditorApplication.update -= Tick; + SessionState.SetString(StageKey, ""); + report.AppendLine(message); // 보고서만 봐도 실패 수·종료 사유가 보이게 + try { File.WriteAllText(Path.Combine(outDir, "tour-report.txt"), report.ToString()); } + catch (Exception e) { Log($"보고서 기록 실패: {e.Message}"); } + Log(message); + EditorApplication.Exit(code); + } + + // ── 단계 구성 ── + + private static List BuildSteps() + { + var list = new List(); + var rm = UnityEngine.Object.FindFirstObjectByType(); + if (rm == null || rm.Regions == null) throw new InvalidOperationException("RegionManager 없음"); + RegionData[] regions = rm.Regions; + + RegionData meadow = null; + foreach (RegionData r in regions) if (r != null && r.regionId == "meadow") meadow = r; + + if (Wants("sky")) AuditSky(); + + if (Wants("world")) + { + Vector3 o = Vector3.zero; + foreach (RegionData r in regions) if (r != null) o += r.centerPosition; + o /= regions.Length; + list.Add(new Step + { + label = "world", + act = null, + wait = 0.1f, + shoot = () => + { + Shot("world_s", o + new Vector3(0f, 230f, -420f), o, 1200, 760); + Shot("world_n", o + new Vector3(0f, 230f, 420f), o, 1200, 760); + Shot("world_e", o + new Vector3(420f, 230f, 0f), o, 1200, 760); + Shot("world_w", o + new Vector3(-420f, 230f, 0f), o, 1200, 760); + // 필드 안에서 지평선 쪽을 보는 구도 — 원경 산맥·구름이 실제로 어떻게 걸리는가 + foreach (RegionData r in regions) + { + if (r == null || (r.regionId != "meadow" && r.regionId != "frostline" && r.regionId != "dunes")) continue; + Vector3 away = (r.centerPosition - o).normalized; + Vector3 eye = Ground(r.centerPosition + away * r.radius * 0.5f) + Vector3.up * 2.2f; + Shot($"horizon_{r.regionId}", eye, eye + away * 100f + Vector3.up * 14f, 1000, 560); + } + }, + }); + } + + if (Wants("village") && meadow != null && Passes("meadow")) + { + Vector3 v = VillageBuilder.GetMainVillageCenter(meadow.centerPosition, meadow.radius); + list.Add(new Step + { + label = "village", + act = () => MovePlayer(v + new Vector3(0f, 0f, -4f)), + wait = 1.6f, + shoot = () => + { + GameShot("village_game"); + Vector3 g = Ground(v); + Shot("village_wide_s", g + new Vector3(0f, 16f, -26f), g); + Shot("village_wide_n", g + new Vector3(0f, 16f, 26f), g); + Shot("village_wide_e", g + new Vector3(26f, 16f, 0f), g); + Shot("village_wide_w", g + new Vector3(-26f, 16f, 0f), g); + Shot("village_eye", g + new Vector3(-9f, 2.2f, -13f), g + new Vector3(0f, 2.5f, 0f)); + }, + }); + } + + if (Wants("regions")) + { + foreach (RegionData r in regions) + { + if (r == null || !Passes(r.regionId)) continue; + RegionData region = r; + list.Add(new Step + { + label = "region " + region.regionId, + act = () => MovePlayer(region.centerPosition), + wait = 1.6f, + shoot = () => + { + GameShot($"region_{region.regionId}_game"); + Vector3 c = Ground(region.centerPosition); + float R = region.radius; + Shot($"region_{region.regionId}_wide", c + new Vector3(0f, 0.85f * R, -1.1f * R), c); + Shot($"region_{region.regionId}_wide2", c + new Vector3(1.0f * R, 0.7f * R, 0.6f * R), c); + Vector3 edge = Ground(c + new Vector3(-0.55f * R, 0f, -0.55f * R)); + Shot($"region_{region.regionId}_eye", edge + Vector3.up * 3f, c + Vector3.up * 2.5f); + }, + }); + } + } + + if (Wants("outposts")) + { + foreach (RegionData r in regions) + { + if (r == null || r.regionId == "meadow" || !Passes(r.regionId)) continue; + string id = r.regionId; + list.Add(new Step + { + label = "outpost " + id, + act = () => + { + GameObject op = GameObject.Find("Outpost_" + id); + if (op != null) MovePlayer(op.transform.TransformPoint(new Vector3(0f, 0f, 6f))); + else Fail($"outpost {id}: 오브젝트 없음"); + }, + wait = 1.3f, + shoot = () => + { + GameObject op = GameObject.Find("Outpost_" + id); + if (op == null) return; + Transform t = op.transform; + GameShot($"outpost_{id}_game"); + Vector3 focus = t.position + Vector3.up * 1.4f; + Shot($"outpost_{id}_wide", t.TransformPoint(new Vector3(4f, 10f, 15f)), focus); + Shot($"outpost_{id}_eye", t.TransformPoint(new Vector3(-5.5f, 1.7f, 9.5f)), focus); + }, + }); + } + } + + if (Wants("subareas")) + { + foreach (RegionData r in regions) + { + if (r == null || r.subAreas == null || !Passes(r.regionId)) continue; + foreach (SubAreaData s in r.subAreas) + { + if (s == null) continue; + SubAreaData sub = s; + // 진입 요청이 나갔는가 — 서브에리어마다 새 변수라 enter 단계의 act·shoot 람다가 이 서브에리어 것만 공유한다 + bool requested = false; + list.Add(new Step + { + label = "near " + sub.subAreaId, + act = () => MovePlayer(sub.centerPosition + new Vector3(0f, 0f, -3f)), + wait = 0.9f, + // 필드 쪽 입구 — 무엇이 여기가 입구라고 알려 주는가 + shoot = () => + { + GameShot($"gate_{sub.subAreaId}_game"); + Vector3 g = Ground(sub.centerPosition); + Shot($"gate_{sub.subAreaId}_eye", g + new Vector3(-4f, 2.4f, -9f), g + Vector3.up * 1.2f); + }, + }); + list.Add(new Step + { + label = "enter " + sub.subAreaId, + act = () => + { + requested = false; + var m = UnityEngine.Object.FindFirstObjectByType(); + if (m == null || m.NearbySubArea == null || m.NearbySubArea.subAreaId != sub.subAreaId) + { + Fail($"sub {sub.subAreaId}: 근접 판정 실패 (nearby={m?.NearbySubArea?.subAreaId ?? "없음"})"); + return; + } + m.RequestEnterSubArea(); + requested = true; + }, + wait = 2.2f, + shoot = () => + { + // **진입이 안 됐으면 찍지 않는다.** 찍으면 메인 필드 사진이 sub_* 이름으로 저장돼 + // 전후 비교에서 "서브에리어가 필드처럼 생겼다"로 읽힌다(실패 원인이 사진 뒤로 숨는다). + if (!requested) return; // 근접 실패는 act에서 이미 셌다 + var m = UnityEngine.Object.FindFirstObjectByType(); + string cur = m?.CurrentSubArea?.subAreaId ?? "없음"; + if (cur != sub.subAreaId) + { + Fail($"sub {sub.subAreaId}: 진입 요청 후에도 current={cur} — 촬영 건너뜀"); + return; + } + Note($"sub {sub.subAreaId}: env={sub.environmentType} current={cur}"); + GameShot($"sub_{sub.subAreaId}_game"); + GameObject root = GameObject.Find("SubArea_" + sub.subAreaId); + Vector3 o = root != null ? root.transform.position : new Vector3(2000f, 0f, 2000f); + Shot($"sub_{sub.subAreaId}_wide", o + new Vector3(0f, 17f, -21f), o); + GameObject player = GameObject.Find("Player"); + Vector3 pp = player != null ? player.transform.position : o; + Shot($"sub_{sub.subAreaId}_eye", pp + new Vector3(0f, 1.9f, -3f), o + new Vector3(0f, 1.6f, 3f)); + }, + }); + list.Add(new Step + { + label = "exit " + sub.subAreaId, + act = () => + { + var b = UnityEngine.Object.FindFirstObjectByType(); + if (b != null) b.RequestExit(); + else UnityEngine.Object.FindFirstObjectByType()?.ForceExitSubArea(); + }, + wait = 1.0f, + }); + } + } + } + + if (Wants("npcs")) + { + list.Add(new Step { label = "npcs", act = null, wait = 0.2f, shoot = ShootNpcs }); + } + if (Wants("population") && meadow != null) AddPopulationSteps(list, regions, meadow); + return list; + } + + // ── 필드 개체군 영속 — 리전을 떠났다 돌아와도 같은 곤충인가 ── + // + // 스포너는 개체를 슬롯 기록으로 들고 플레이어 45m 안에서만 몸을 세운다(InsectSpawner 주석). 옛 스포너는 리전을 + // 옮길 때마다 새로 굴려서, 돌아오면 다른 곤충이었다. 초원 한 자리 → 가장 먼 리전 → 같은 자리로 돌아와 종·레벨·자리가 + // 같은지 본다. 곤충은 제자리에서 조금씩 배회하므로 자리는 4m 안이면 같다고 친다. 첫 채움의 수명(1~7분)이 자리를 + // 비운 몇 초 사이에 끝나 교체되는 슬롯이 드물게 있어, 80% 이상 같으면 통과다. + private static List<(string id, int level, Vector3 pos)> populationBefore; + + private static void AddPopulationSteps(List list, RegionData[] regions, RegionData meadow) + { + RegionData far = null; + float farSq = -1f; + foreach (RegionData r in regions) + { + if (r == null) continue; + float d = (r.centerPosition - meadow.centerPosition).sqrMagnitude; + if (d > farSq) { farSq = d; far = r; } + } + Vector3 home = meadow.centerPosition + new Vector3(0f, 0f, -meadow.radius * 0.45f); + Vector3 away = far.centerPosition; + list.Add(new Step + { + label = "population_home", + act = () => MovePlayer(home), + wait = 2.5f, + shoot = () => + { + populationBefore = Nearby(home); + report.AppendLine($"개체군 초원 첫 방문: 45m 안 {populationBefore.Count}마리 — {Describe(populationBefore)}"); + if (populationBefore.Count == 0) Fail("개체군: 초원에 곤충이 하나도 안 섰다"); + }, + }); + list.Add(new Step + { + label = "population_away", + act = () => MovePlayer(away), + wait = 2.5f, + shoot = () => + { + var there = Nearby(away); + report.AppendLine($"개체군 {far.regionId}: 45m 안 {there.Count}마리"); + foreach (var e in Nearby(home)) + Fail($"개체군: 초원을 떠났는데 몸이 남아 있다 {e.id}"); + }, + }); + list.Add(new Step + { + label = "population_return", + act = () => MovePlayer(home), + wait = 2.5f, + shoot = () => + { + var after = Nearby(home); + var pool = new List<(string id, int level, Vector3 pos)>(after); + int same = 0; + foreach (var b in populationBefore) + { + int hit = pool.FindIndex(a => a.id == b.id && a.level == b.level + && (new Vector2(a.pos.x - b.pos.x, a.pos.z - b.pos.z)).sqrMagnitude < 16f); + if (hit < 0) continue; + same++; + pool.RemoveAt(hit); + } + int total = Mathf.Max(1, populationBefore.Count); + report.AppendLine($"개체군 초원 재방문: 45m 안 {after.Count}마리, 첫 방문과 같은 개체 {same}/{populationBefore.Count}"); + if (same < total * 0.8f) + Fail($"개체군: 돌아오니 다른 곤충이다 — 같은 개체 {same}/{populationBefore.Count}"); + }, + }); + } + + private static List<(string id, int level, Vector3 pos)> Nearby(Vector3 at) + { + var list = new List<(string id, int level, Vector3 pos)>(); + var spawner = UnityEngine.Object.FindFirstObjectByType(); + if (spawner == null) { Fail("InsectSpawner 없음"); return list; } + foreach (var e in spawner.ActiveInsects) + { + if (e == null || e.Data == null || !e.gameObject.activeInHierarchy) continue; + Vector3 p = e.transform.position; + if (new Vector2(p.x - at.x, p.z - at.z).sqrMagnitude > 45f * 45f) continue; + list.Add((e.Data.insectId, e.Level, p)); + } + return list; + } + + private static string Describe(List<(string id, int level, Vector3 pos)> list) + { + var sb = new StringBuilder(); + foreach (var e in list) sb.Append(e.id).Append(" Lv").Append(e.level).Append(", "); + return sb.ToString(); + } + + /// + /// 공중(바닥 8m 위)에 떠 있는 렌더러를 루트별로 센다. 눈높이 샷의 하늘에 정체불명의 원반이 + /// 찍혀서 만든 진단이다 — 서브에리어(2000,·,2000)와 NPC 무대는 뺀다. + /// + private static void AuditSky() + { + var counts = new Dictionary(); + var sample = new Dictionary(); + foreach (Renderer r in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) + { + if (!r.enabled || !r.gameObject.activeInHierarchy) continue; + Bounds b = r.bounds; + if (b.min.y < 8f || b.center.x > 1500f) continue; + string root = r.transform.root.name; + string key = root + "/" + r.gameObject.name.Split('_')[0]; + counts[key] = counts.TryGetValue(key, out int n) ? n + 1 : 1; + if (!sample.ContainsKey(key)) + sample[key] = $"{r.gameObject.name} c={b.center} size={b.size}"; + } + foreach (var kv in counts) Note($"sky {kv.Key} x{kv.Value} — {sample[kv.Key]}"); + if (counts.Count == 0) Note("sky: 8m 위 렌더러 없음"); + + // 지면 위로 솟은 거대 덩어리(폭 14m↑·높이 0.8m↑). 리전 한가운데를 덮는 돔을 찾으려고 둔다. + foreach (Renderer r in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) + { + if (!r.enabled || !r.gameObject.activeInHierarchy) continue; + Bounds b = r.bounds; + if (b.center.x > 1500f || b.max.y < 0.8f) continue; + if (Mathf.Max(b.size.x, b.size.z) < 14f || b.size.y > 30f) continue; + if (r.gameObject.name.StartsWith("Region_") || r.gameObject.name == "Ground") continue; + Note($"big {r.gameObject.name} c={b.center} size={b.size} top={b.max.y:F2}"); + } + } + + // ── 동작 ── + + private static void MovePlayer(Vector3 target) + { + GameObject player = GameObject.Find("Player"); + if (player == null) { Fail("Player 없음"); return; } + Vector3 p = Ground(target) + Vector3.up * 1.2f; + player.transform.position = p; + player.GetComponent()?.StopNavigation(); + Physics.SyncTransforms(); + } + + private static Vector3 Ground(Vector3 p) + { + if (Physics.Raycast(new Vector3(p.x, 300f, p.z), Vector3.down, out RaycastHit hit, 600f, + ~0, QueryTriggerInteraction.Ignore)) + return new Vector3(p.x, hit.point.y, p.z); + return new Vector3(p.x, 0f, p.z); + } + + /// + /// NPC 전원을 무대로 옮겨 한 명씩 찍는다. 같은 호출 안에서 되돌리므로 게임 상태가 안 바뀐다. + /// 렌더러는 컬링이 꺼 뒀을 수 있어 강제로 켜고 원래 값으로 복구한다. + /// + private static void ShootNpcs() + { + GameObject floor = GameObject.CreatePrimitive(PrimitiveType.Plane); + floor.name = "~TourNpcStage"; + floor.transform.position = NpcStage; + floor.transform.localScale = Vector3.one * 2f; + // SceneryMaterials.Create로 바꾸지 않는다 — 무광 마감이 붙어 무대 바닥의 광택(Standard 기본 0.5)이 바뀌면 + // 이전 순회의 NPC 컷과 전후 비교가 어긋난다. 에디터 전용이라 Standard가 늘 있어 폴백도 필요 없다. + var floorMat = new Material(Shader.Find("Standard")) { color = new Color(0.46f, 0.50f, 0.44f) }; + floor.GetComponent().sharedMaterial = floorMat; + + var npcs = new List(); + foreach (VillagerNpc v in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) + npcs.Add(v.transform); + foreach (CatcherKidNpc k in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) + npcs.Add(k.transform); + npcs.Sort((a, b) => string.CompareOrdinal(a.name, b.name)); + + foreach (Transform t in npcs) + { + Vector3 pos = t.position; + Quaternion rot = t.rotation; + Renderer[] rs = t.GetComponentsInChildren(true); + bool[] was = new bool[rs.Length]; + for (int i = 0; i < rs.Length; i++) { was[i] = rs[i].enabled; rs[i].enabled = true; } + try + { + t.SetPositionAndRotation(NpcStage, Quaternion.identity); + Vector3 look = NpcStage + Vector3.up * 0.78f * t.localScale.y; + float dist = 2.4f * Mathf.Max(0.75f, t.localScale.y); + Shot("npc_" + t.name.Replace("Npc_", ""), look + new Vector3(0.9f, 0.35f, dist), look, 360, 440, 40f); + Shot("npcback_" + t.name.Replace("Npc_", ""), look + new Vector3(-1.2f, 0.45f, -dist), look, 360, 440, 40f); + } + finally + { + t.SetPositionAndRotation(pos, rot); + for (int i = 0; i < rs.Length; i++) rs[i].enabled = was[i]; + } + } + Note($"npcs: {npcs.Count}명"); + UnityEngine.Object.DestroyImmediate(floor); + UnityEngine.Object.DestroyImmediate(floorMat); + } + + // ── 촬영 ── + + private static void GameShot(string name) + { + Camera src = Camera.main; + if (src == null) { Fail(name + ": Camera.main 없음"); return; } + Render(name, src, src.transform.position, src.transform.rotation, 800, 560, -1f); + } + + private static void Shot(string name, Vector3 pos, Vector3 look, int w = 800, int h = 560, float fov = -1f) + { + Camera src = Camera.main; + if (src == null) { Fail(name + ": Camera.main 없음"); return; } + Render(name, src, pos, Quaternion.LookRotation(look - pos), w, h, fov); + } + + private static void Render(string name, Camera src, Vector3 pos, Quaternion rot, int w, int h, float fov) + { + var rigGo = new GameObject("~FieldTourRig"); + var rig = rigGo.AddComponent(); + rig.CopyFrom(src); + rig.enabled = false; + rig.farClipPlane = Mathf.Max(src.farClipPlane, 600f); + if (fov > 0f) rig.fieldOfView = fov; + rig.transform.SetPositionAndRotation(pos, rot); + + RenderTexture rt = null; + Texture2D tex = null; + RenderTexture prev = RenderTexture.active; + try + { + rt = new RenderTexture(w, h, 24); + rig.targetTexture = rt; + rig.Render(); + RenderTexture.active = rt; + tex = new Texture2D(w, h, TextureFormat.RGB24, false); + tex.ReadPixels(new Rect(0, 0, w, h), 0, 0); + tex.Apply(); + File.WriteAllBytes(Path.Combine(outDir, name + ".png"), tex.EncodeToPNG()); + written++; + } + catch (Exception e) + { + Fail($"{name}: 촬영 실패 {e.Message}"); + } + finally + { + RenderTexture.active = prev; + rig.targetTexture = null; + if (tex != null) UnityEngine.Object.DestroyImmediate(tex); + if (rt != null) { rt.Release(); UnityEngine.Object.DestroyImmediate(rt); } + UnityEngine.Object.DestroyImmediate(rigGo); + } + } + + private static string Arg(string name) + { + string[] args = Environment.GetCommandLineArgs(); + for (int i = 0; i < args.Length - 1; i++) + if (args[i] == name) return args[i + 1]; + return null; + } + + private static void Note(string line) + { + report.AppendLine(line); + Log(line); + } + + /// + /// 실패 한 건 — 보고서에 "FAIL"로 남기고 종료 코드를 3으로 만든다. 정보성 줄(sky·npcs 수 등)은 . + /// 실패를 Note로만 적으면 수백 장 중 몇 장이 빠졌는지 로그를 다 읽기 전엔 모른다. + /// + private static void Fail(string line) + { + failures++; + Note("FAIL " + line); + } + + private static void Log(string message) => + Debug.Log("[TOUR] " + message.ToString(CultureInfo.InvariantCulture)); + } +} +#endif diff --git a/Assets/Editor/FieldDesignTour.cs.meta b/Assets/Editor/FieldDesignTour.cs.meta new file mode 100644 index 0000000..3af461a --- /dev/null +++ b/Assets/Editor/FieldDesignTour.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1935a493e1395314fa2126bc4c16426d \ No newline at end of file diff --git a/Assets/Editor/GamePlayEntry.cs b/Assets/Editor/GamePlayEntry.cs new file mode 100644 index 0000000..50b9c32 --- /dev/null +++ b/Assets/Editor/GamePlayEntry.cs @@ -0,0 +1,61 @@ +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace InsectGame.EditorTools +{ + /// Keeps normal editor Play on the shipping entry scene, independently of QA scenes. + [InitializeOnLoad] + public static class GamePlayEntry + { + private const string OpeningScene = "Assets/Scenes/OpeningScene.unity"; + private const string CurrentScenePreference = "InsectGame.PlayCurrentScene."; + + static GamePlayEntry() + { + if (!Application.isBatchMode) EditorApplication.delayCall += Configure; + } + + private static void Configure() + { + if (Application.isBatchMode || EditorApplication.isPlayingOrWillChangePlaymode) return; + if (EditorApplication.isCompiling || EditorApplication.isUpdating) + { + EditorApplication.delayCall += Configure; + return; + } + bool currentScene = EditorPrefs.GetBool(CurrentScenePreference + Application.dataPath, false); + EditorSceneManager.playModeStartScene = currentScene ? null : AssetDatabase.LoadAssetAtPath(OpeningScene); + Scene active = SceneManager.GetActiveScene(); + // Never replace a saved or edited scene. An empty scene left by a batch QA run has no useful content. + if (!currentScene && active.IsValid() && string.IsNullOrEmpty(active.path) + && !active.isDirty && active.rootCount == 0 && SceneManager.sceneCount == 1) + EditorSceneManager.OpenScene(OpeningScene); + } + + [MenuItem("Insect Game/Play/Game from Opening")] + public static void PlayGame() + { + if (EditorApplication.isPlayingOrWillChangePlaymode) return; + if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) return; + EditorPrefs.SetBool(CurrentScenePreference + Application.dataPath, false); + EditorSceneManager.playModeStartScene = AssetDatabase.LoadAssetAtPath(OpeningScene); + if (EditorSceneManager.playModeStartScene == null) + { + Debug.LogError("OpeningScene is missing. Restore Assets/Scenes/OpeningScene.unity before playing."); + return; + } + EditorApplication.isPlaying = true; + } + + [MenuItem("Insect Game/Play/Current Scene (development)")] + public static void PlayCurrentScene() + { + if (EditorApplication.isPlayingOrWillChangePlaymode) return; + EditorPrefs.SetBool(CurrentScenePreference + Application.dataPath, true); + EditorSceneManager.playModeStartScene = null; + EditorApplication.isPlaying = true; + } + } +} diff --git a/Assets/Editor/GamePlayEntry.cs.meta b/Assets/Editor/GamePlayEntry.cs.meta new file mode 100644 index 0000000..bcec867 --- /dev/null +++ b/Assets/Editor/GamePlayEntry.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c95f02c8132c1d64e9d413fcc8b84913 \ No newline at end of file diff --git a/Assets/Editor/IslandModelCapture.cs b/Assets/Editor/IslandModelCapture.cs new file mode 100644 index 0000000..ca161b1 --- /dev/null +++ b/Assets/Editor/IslandModelCapture.cs @@ -0,0 +1,322 @@ +using System; +using System.Globalization; +using System.IO; +using InsectGame.Core; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.Rendering; + +namespace InsectGame.EditorTools +{ + /// + /// 섬 지형과 물건 28종을 PlayScene 없이 빈 씬에 세워 찍는 도구. 모양(형태·색 대비·차지 칸 넘침)을 눈으로 본다. + /// + /// + /// Unity.exe -batchmode -projectPath <proj> -logFile <log> \ + /// -executeMethod InsectGame.EditorTools.IslandModelCapture.Run -islandOut .claude/cache/island + /// + /// + /// 찍는 것: + /// + /// terrain_L{0,2,3}_game/arrival/aerial/horizon — 섬 크기별 지형. game·arrival은 게임 카메라 구도 + /// (대상 + (0,9,−6), 대상 + 0.85를 본다), aerial은 섬 전체 조감(안개 끔), horizon은 바다가 하늘로 녹는지. + /// category_{building,furniture,terrain,tool} — 가장 큰 섬 위에 분류별로 줄지어 놓은 조감. + /// {id} — 물건 하나를 게임 카메라 각도로 당겨 찍은 것(위에서 무엇으로 읽히는가). + /// {id}_front는 정면(+Z) 쪽 낮은 각도 — 게임 카메라는 회전 0의 물건을 뒤에서 보므로 문·날개가 여기서만 보인다. + /// + /// 물건 밑의 반투명 흰 판이 차지 칸이다. 넘침은 로그에도 수치로 남는다([CAPTURE] {id} parts=… spill=…) — + /// spill은 렌더러 경계 기준이라 돌려 놓은 조각에서는 실제보다 조금 크게 나온다. + /// + /// 플레이 모드에 들어가지 않는다. 빌더가 정적 함수라 씬·부트스트랩·세이브가 필요 없고, 도메인 리로드를 건너 + /// 상태를 나를 일도 없다. 대신 플레이 모드 밖이라 Object.Destroy가 안 먹는다 — 빌더 쪽이 + /// IslandObjectBuilder.DestroySafe로 가른다. + /// + /// 조명·안개는 SubAreaEnvironment의 섬 프로필을 손으로 옮긴 값이다(그쪽을 바꾸면 여기도 바꿀 것). + /// IMGUI는 안 찍힌다. 배치모드 전용이다 — 열린 에디터에서 돌리면 씬을 묻지 않고 갈아 끼우고 에디터째 닫는다. + /// 종료 코드: 한 장이라도 찍었으면 0, 한 장도 못 찍었으면 3, 예외는 4. + /// + public static class IslandModelCapture + { + private const string DefaultOut = ".claude/cache/island"; + private const int Width = 1280; + private const int Height = 720; + private const float Fov = 40f; + + private static readonly Vector3 GameOffset = new Vector3(0f, 9f, -6f); + private const float GameLookHeight = 0.85f; + + private static string outDir; + private static Camera camera; + private static int written; + + public static void Run() + { + if (!Application.isBatchMode) + throw new InvalidOperationException("Island model capture requires batch mode to protect the open scene."); + + try + { + written = 0; + outDir = Path.GetFullPath(ProjectPath(Arg("-islandOut") ?? DefaultOut)); + Directory.CreateDirectory(outDir); + Log("시작 out=" + outDir); + + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + BuildStage(); + + IslandMaterialCache materials = new IslandMaterialCache(); + ShootTerrains(materials); + ShootCatalog(materials); + + Log("완료 written=" + written); + EditorApplication.Exit(written > 0 ? 0 : 3); + } + catch (Exception e) + { + Debug.LogException(e); + Log("예외로 중단 written=" + written + " — " + e.Message); + EditorApplication.Exit(4); + } + } + + private static void BuildStage() + { + Light sun = new GameObject("Sun").AddComponent(); + sun.type = LightType.Directional; + sun.color = new Color(1f, 0.97f, 0.88f); + sun.intensity = 1.1f; + sun.shadows = LightShadows.Soft; + sun.transform.rotation = Quaternion.Euler(52f, 35f, 0f); + + RenderSettings.ambientMode = AmbientMode.Flat; + RenderSettings.ambientLight = new Color(0.40f, 0.44f, 0.50f); + RenderSettings.fog = true; + RenderSettings.fogMode = FogMode.Exponential; + RenderSettings.fogDensity = 0.006f; + RenderSettings.fogColor = new Color(0.66f, 0.84f, 0.95f); + DynamicGI.UpdateEnvironment(); + + camera = new GameObject("CaptureCamera").AddComponent(); + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = new Color(0.56f, 0.80f, 0.96f); + camera.fieldOfView = Fov; + camera.nearClipPlane = 0.3f; + camera.farClipPlane = 1000f; + camera.enabled = false; // 자동 렌더 금지 — 장면마다 수동으로 한 번씩만 그린다 + } + + // ── 1. 지형 ── + + private static void ShootTerrains(IslandMaterialCache materials) + { + foreach (int level in new[] { 0, 2, 3 }) + { + GameObject terrain = IslandTerrainBuilder.Build(level, null, materials); + float half = IslandGrid.HalfExtent(level); + string prefix = "terrain_L" + level + "_"; + + ShotGame(prefix + "game", Vector3.zero); + // 섬 중심에서는 모래톱·나루터가 화면 밖이다 — 도착 자리에서 한 장 더. + ShotGame(prefix + "arrival", IslandGrid.ArrivalPoint(level)); + + // 조감은 배치 확인용이라 안개를 끈다(80m 위에서는 연무가 40%를 덮는다). + float h = AerialHeight(half + 9f, half + 9f); + RenderSettings.fog = false; + Shot(prefix + "aerial", new Vector3(0f, h, -h * 0.3f), Vector3.zero); + RenderSettings.fog = true; + + Shot(prefix + "horizon", new Vector3(0f, 5f, -half - 16f), new Vector3(0f, 0.5f, 0f)); + + UnityEngine.Object.DestroyImmediate(terrain); + } + } + + // ── 2. 물건 ── + + private static void ShootCatalog(IslandMaterialCache materials) + { + const int level = GameConstants.Island.MaxSizeLevel; + IslandTerrainBuilder.Build(level, null, materials); + + int half = IslandGrid.GridSize(level) / 2; + float cs = GameConstants.Island.CellSize; + Material pad = materials.GetFade(new Color(1f, 1f, 1f, 0.35f)); + Transform stage = new GameObject("CatalogStage").transform; + + // 북쪽 끝에서부터 분류마다 새 줄로 흘려 놓는다. 물건 사이·줄 사이는 한 칸씩 비운다. + int left = -half + 1; + int right = half - 1; + int rowTop = half - 1; + int rowDepth = 0; + int cursor = left; + + foreach (IslandObjectCategory category in Enum.GetValues(typeof(IslandObjectCategory))) + { + if (rowDepth > 0) + { + rowTop -= rowDepth + 1; + rowDepth = 0; + cursor = left; + } + + bool any = false; + Bounds area = default; + foreach (IslandObjectDef def in IslandCatalog.All) + { + if (def.category != category) continue; + if (cursor + def.width > right && cursor > left) + { + rowTop -= rowDepth + 1; + rowDepth = 0; + cursor = left; + } + + int x = cursor; + int z = rowTop - def.depth; + cursor += def.width + 1; + rowDepth = Mathf.Max(rowDepth, def.depth); + if (z < -half) Log("경고: " + def.id + "가 섬 남쪽 밖에 놓였다(z=" + z + ")"); + + Vector3 center = IslandGrid.FootprintCenter(x, z, def.width, def.depth); + Transform holder = new GameObject("Placed_" + def.id).transform; + holder.SetParent(stage, false); + holder.localPosition = center; + + GameObject model = IslandObjectBuilder.Build(def, holder, materials); + AddPad(holder, def.width * cs, def.depth * cs, pad); + + Bounds foot = new Bounds(center, new Vector3(def.width * cs, 0.1f, def.depth * cs)); + if (any) area.Encapsulate(foot); + else area = foot; + any = true; + + Bounds body = Inspect(def, model, foot); + float dist = Mathf.Max(4f, body.extents.magnitude * 2.9f); + Shot(def.id, body.center + GameOffset.normalized * dist, body.center); + Shot(def.id + "_front", body.center + new Vector3(0.45f, 0.55f, 0.75f).normalized * dist, body.center); + } + + if (!any) continue; + float h = AerialHeight(area.extents.x + 1.5f, area.extents.z + 2.5f); + Shot("category_" + category.ToString().ToLowerInvariant(), + area.center + new Vector3(0f, h, -h * 0.45f), area.center); + } + } + + /// + /// 조각 수·높이·차지 칸 넘침을 로그에 남기고 모델의 경계를 돌려준다. + /// spill은 차지 칸 밖으로 나간 거리(음수면 그만큼 안쪽). 규칙은 −0.1 이하, 나무 수관만 +0.3까지다. + /// + private static Bounds Inspect(IslandObjectDef def, GameObject model, Bounds foot) + { + MeshRenderer[] renderers = model != null ? model.GetComponentsInChildren() : new MeshRenderer[0]; + if (renderers.Length == 0) + { + Log("경고: " + def.id + " 렌더러가 없다"); + return foot; + } + + Bounds body = renderers[0].bounds; + for (int i = 1; i < renderers.Length; i++) body.Encapsulate(renderers[i].bounds); + + float spill = Mathf.Max( + Mathf.Max(body.max.x - foot.max.x, foot.min.x - body.min.x), + Mathf.Max(body.max.z - foot.max.z, foot.min.z - body.min.z)); + string flag = spill > 0.3f ? " OVER" : spill > -0.1f ? " (여유 0.1 미만)" : ""; + string fallback = IslandObjectBuilder.HasModel(def.id) ? "" : " FALLBACK(전용 모델 없음)"; + Log(string.Format(CultureInfo.InvariantCulture, "{0} parts={1} height={2:F2} spill={3:F2}{4}{5}", + def.id, renderers.Length, body.max.y, spill, flag, fallback)); + return body; + } + + private static void AddPad(Transform holder, float sizeX, float sizeZ, Material material) + { + GameObject pad = GameObject.CreatePrimitive(PrimitiveType.Quad); + pad.name = "FootprintPad"; + UnityEngine.Object.DestroyImmediate(pad.GetComponent()); + pad.transform.SetParent(holder, false); + pad.transform.localPosition = new Vector3(0f, 0.01f, 0f); + pad.transform.localRotation = Quaternion.Euler(90f, 0f, 0f); // Quad는 −Z를 본다 — X로 90° 눕히면 위를 본다 + pad.transform.localScale = new Vector3(sizeX, sizeZ, 1f); + MeshRenderer renderer = pad.GetComponent(); + renderer.sharedMaterial = material; + renderer.shadowCastingMode = ShadowCastingMode.Off; + renderer.receiveShadows = false; + } + + // ── 촬영 ── + + private static void ShotGame(string name, Vector3 target) + { + Shot(name, target + GameOffset, target + Vector3.up * GameLookHeight); + } + + /// 그 넓이가 화면에 다 들어오는 카메라 높이(세로·가로 화각 중 빠듯한 쪽 + 여유 20%). + private static float AerialHeight(float halfWidth, float halfDepth) + { + float tanV = Mathf.Tan(Fov * 0.5f * Mathf.Deg2Rad); + float tanH = tanV * Width / Height; + return Mathf.Max(halfDepth / tanV, halfWidth / tanH) * 1.2f; + } + + // ScreenCapture는 배치모드에 게임뷰가 없어 조용히 실패한다 — 카메라 → RenderTexture → ReadPixels로 읽는다 + // (LiveSceneCapture와 같은 경로). 실패는 삼키지 않고 올려 보낸다: 종료 코드 4가 "몇 장 빠졌다"보다 분명하다. + private static void Shot(string name, Vector3 position, Vector3 lookAt) + { + camera.transform.position = position; + camera.transform.LookAt(lookAt); + + RenderTexture rt = null; + Texture2D tex = null; + RenderTexture previous = RenderTexture.active; + try + { + rt = new RenderTexture(Width, Height, 24); + camera.targetTexture = rt; + camera.Render(); + + RenderTexture.active = rt; + tex = new Texture2D(Width, Height, TextureFormat.RGB24, false); + tex.ReadPixels(new Rect(0, 0, Width, Height), 0, 0); + tex.Apply(); + + string path = Path.Combine(outDir, name + ".png"); + File.WriteAllBytes(path, tex.EncodeToPNG()); + written++; + Log("촬영 → " + path); + } + finally + { + RenderTexture.active = previous; + camera.targetTexture = null; + if (tex != null) UnityEngine.Object.DestroyImmediate(tex); + if (rt != null) + { + rt.Release(); + UnityEngine.Object.DestroyImmediate(rt); + } + } + } + + // ── CLI ── + + private static string Arg(string name) + { + string[] args = Environment.GetCommandLineArgs(); + for (int i = 0; i < args.Length - 1; i++) + if (args[i] == name) return args[i + 1]; + return null; + } + + private static string ProjectPath(string relative) + { + return Path.IsPathRooted(relative) + ? relative + : Path.Combine(Application.dataPath, "..", relative); + } + + // 로그 접두사를 고정한다 — 호출부(CLI)가 grep 한 번으로 결과를 읽는다. + private static void Log(string message) => Debug.Log("[CAPTURE] " + message); + } +} diff --git a/Assets/Editor/IslandModelCapture.cs.meta b/Assets/Editor/IslandModelCapture.cs.meta new file mode 100644 index 0000000..a5a9da2 --- /dev/null +++ b/Assets/Editor/IslandModelCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bd3b374cb17e2094a82b0c7d7d243ec2 \ No newline at end of file diff --git a/Assets/Editor/LedgerDuelProbe.cs b/Assets/Editor/LedgerDuelProbe.cs index 533d1c3..b2ee40c 100644 --- a/Assets/Editor/LedgerDuelProbe.cs +++ b/Assets/Editor/LedgerDuelProbe.cs @@ -199,9 +199,15 @@ private static void Probe(NpcDuelController duels, InsectBattleController battle latestPlayer = null; probedBattle = battle; battle.BattleUpdated += OnBattleUpdated; + // 간부는 팀으로 싸운다 — 한 방에 쓰러진 곤충 대신 다음 곤충이 나오면 그쪽을 잰다. + battle.EnemySwitched += OnEnemySwitched; + // **치명타를 끈다.** 이 도구는 정독 턴과 평턴의 피해를 나눠 배율을 재는데, 평턴에 1/16로 ×1.5가 끼면 + // 그 표본이 정독(배율)처럼 보여 비가 흐려진다. 치명타는 별개 줄기라 끄더라도 명중 롤 순서는 같다. + battle.SetCritSource(null); if (!duels.TryStartBossDuel(bossId, Time.time)) { - notes.Add("**대결이 안 열렸다** — CanBossDuel이 false(리더 없음/쿨다운/데이터)"); + notes.Add("**대결이 안 열렸다** — CanBossDuel이 false(리더 없음/쿨다운/데이터, " + + "또는 거점 보스인데 플레이어가 그 거점 리전 밖 — BossDuelAllowedInRegion)"); return; } @@ -321,6 +327,11 @@ private static void OnBattleUpdated(InsectBattleStats player, InsectBattleStats latestEnemy = enemy; } + private static void OnEnemySwitched(InsectBattleStats outgoing, InsectBattleStats incoming) + { + latestEnemy = incoming; + } + private static float PlayerHp() { return latestPlayer != null ? latestPlayer.CurrentHp : 0f; @@ -353,6 +364,7 @@ private static void Finish(string error) if (probedBattle != null) { probedBattle.BattleUpdated -= OnBattleUpdated; + probedBattle.EnemySwitched -= OnEnemySwitched; probedBattle = null; } SessionState.SetString(StageKey, ""); diff --git a/Assets/Editor/LiveSceneCapture.cs b/Assets/Editor/LiveSceneCapture.cs index ccec830..494a6ae 100644 --- a/Assets/Editor/LiveSceneCapture.cs +++ b/Assets/Editor/LiveSceneCapture.cs @@ -19,6 +19,17 @@ namespace InsectGame.EditorTools /// -captureSize 900x700 -captureOffset 1.5,1.15,1.9 -captureLook 0,0.8,0 /// /// + /// 낮·밤·날씨 검수 — -captureHour 0~24(소수 가능, 12 = 정오)와 -captureWeather clear|rain|fog|wind|snow가 + /// 시계와 날씨를 그 값으로 붙잡고(hold, 날씨는 즉시 전환) 찍는다. 둘 다 안 주면 정오·맑음이다 — 낮·밤이 생기기 전의 캡처는 + /// 늘 한낮 조명이었고(게임 시계는 새벽 6시에 시작해 돈다), 전후 비교 도구가 실행마다 다른 조명으로 찍히면 안 되기 때문이다. + /// 게임이 스스로 돌리는 시각·날씨 그대로 찍으려면 -captureHour natural / -captureWeather natural. + /// 두 인자는 쉼표 목록도 받는다 — 촬영 순서대로 그 시각·날씨로 찍고(목록이 짧으면 마지막 값), 한 장을 찍자마자 다음 장의 값을 건다. + /// 그러면 Unity를 한 번만 띄워 여러 조합을 찍는다(파일 이름에 _날씨_시각h가 붙는다). + /// 촬영 시각은 플레이모드 진입부터 잰다 — 부트스트랩이 한 프레임에 수 초를 쓰므로 첫 촬영을 10초 이후로 잡을 것. + /// 그보다 이르면 시각·날씨를 건 바로 그 틱에 찍혀 하늘도 입자도 바뀌기 전 모습이 나온다(2026-10-03에 그렇게 맑은 하늘을 찍었다). + /// 하늘 조명은 곧바로 바뀌지만 눈은 위에서 내려와 시야에 차기까지 몇 초 걸리므로 촬영 간격도 6초 이상 둔다. 하늘이 보이는 낮은 구도(-captureOffset 0,1.2,-6 -captureLook 0,3,8)도 함께 찍어 두면 + /// 지평선·안개·별을 볼 수 있다 — 기본 게임 카메라는 고각이라 하늘이 거의 안 잡힌다. + /// /// 알아야 할 한계 — IMGUI는 안 찍힌다. OnGUI는 카메라를 거치지 않고 화면 위에 /// 직접 그리므로 여기서 잡히지 않는다(상점·대화창·배틀 UI·HUD). 그건 스탠드얼론 빌드로 /// ScreenCapture를 써야 한다. 이 도구가 덮는 것은 월드에 있는 것이다. @@ -73,6 +84,7 @@ private struct Settings /// 초원 밖의 것을 찍으려면 이게 필요하다 — 플레이어는 늘 초원에서 시작하고, /// 리전에 실제로 들어가야만 그 리전에 붙은 것들(수문장 봉인·오염 거점 구조물 등)이 /// 지어진다. 좌표만 옮기면 RegionManager.Update가 리전 변경을 알아서 잡는다. + /// island면 나의 섬으로 들어간다(). /// public string region; @@ -90,6 +102,27 @@ private struct Settings /// 촬영 전에 정화 기록을 지울 것인가. public bool resetBlight; + + /// + /// 시계를 붙잡을 시각(0~24, 기본 12) — 촬영 순서대로의 목록이고 목록보다 촬영이 많으면 마지막 값을 쓴다. + /// null(natural)이면 건드리지 않는다. + /// + public float[] hours; + + /// 붙잡을 날씨 이름(clear·rain·fog·wind·snow, 기본 clear)의 쉼표 목록 — 시각과 같은 규칙. 비면(natural) 건드리지 않는다. + public string weather; + + public float HourAt(int shot) => hours == null || hours.Length == 0 ? -1f : hours[Mathf.Clamp(shot, 0, hours.Length - 1)]; + + public string WeatherAt(int shot) + { + if (string.IsNullOrEmpty(weather)) return ""; + string[] parts = weather.Split(','); + return parts[Mathf.Clamp(shot, 0, parts.Length - 1)].Trim(); + } + + /// 촬영마다 시각·날씨가 바뀌는가 — 그러면 파일 이름에 그 조합을 붙인다(한 장짜리는 옛 이름 그대로). + public bool VariesPerShot => (hours != null && hours.Length > 1) || (weather != null && weather.Contains(",")); } [MenuItem("InsectGame/Live Scene Capture")] @@ -123,12 +156,65 @@ private static void Resume() written = 0; regionMoved = false; cleansed = false; + skyApplied = false; + skyAppliedShot = -1; EditorApplication.update += Tick; Log("플레이모드 진입 — 촬영 대기"); } private static bool regionMoved; private static bool cleansed; + private static bool skyApplied; + + /// + /// -captureHour·-captureWeather를 게임에 건다. 시계·날씨 공급자는 부트스트랩이 지은 뒤에야 있으므로 + /// 찾을 때까지 틱마다 시도하고, 한 번 걸린 뒤에도 매 촬영 직전에 다시 건다(다른 연출이 시계를 풀었어도 같은 조건으로 찍히게). + /// 아무 인자도 없으면 아무것도 안 하고 true다. 공급자를 못 찾으면 false. + /// + private static bool ApplySkyOverrides(int shot) + { + float hour = settings.HourAt(shot); + string weatherName = settings.WeatherAt(shot); + bool wantHour = hour >= 0f; + bool wantWeather = !string.IsNullOrEmpty(weatherName); + if (!wantHour && !wantWeather) return true; + + var provider = UnityEngine.Object.FindFirstObjectByType(); + if (provider == null) return false; + + if (wantHour && provider.Clock != null) + provider.Clock.SetTime01(hour / 24f, true); + + if (wantWeather && provider.Weather != null) + { + if (TryParseWeather(weatherName, out InsectGame.Core.WeatherType weather)) + provider.Weather.SetWeather(weather, true, true); + else if (!skyApplied) + Log($"알 수 없는 날씨 '{weatherName}' — clear|rain|fog|wind|snow 중 하나여야 한다"); + } + + if (shot != skyAppliedShot) + Log($"하늘 고정(촬영 {shot + 1}) — hour={(wantHour ? hour.ToString("0.##", CultureInfo.InvariantCulture) : "-")} weather={(wantWeather ? weatherName : "-")}"); + skyApplied = true; + skyAppliedShot = shot; + return true; + } + + /// 마지막으로 하늘을 건 촬영 번호 — 같은 촬영의 재적용은 로그에 다시 남기지 않는다. + private static int skyAppliedShot = -1; + + private static bool TryParseWeather(string raw, out InsectGame.Core.WeatherType weather) + { + switch ((raw ?? "").Trim().ToLowerInvariant()) + { + case "clear": weather = InsectGame.Core.WeatherType.Clear; return true; + case "rain": weather = InsectGame.Core.WeatherType.Rain; return true; + case "fog": weather = InsectGame.Core.WeatherType.Fog; return true; + case "wind": weather = InsectGame.Core.WeatherType.Wind; return true; + case "snow": weather = InsectGame.Core.WeatherType.Snow; return true; + default: weather = InsectGame.Core.WeatherType.Clear; return false; + } + } /// /// 정화 기록을 지운다. 정화는 세이브에 남으므로 안 지우면 두 번째 실행부터 거점이 @@ -197,10 +283,27 @@ private static void MoveToRegion(string regionId) Log($"리전 이동 → {regionId} {p}"); } + /// + /// 나의 섬으로 들어간다(-captureRegion island) — 섬 하늘(낮·밤·날씨)과 손님 곤충을 찍기 위한 것이다. + /// 게임의 진입 경로(IslandWorldBuilder.EnterOwnIsland)를 그대로 탄다. + /// + private static void EnterOwnIsland() + { + var island = UnityEngine.Object.FindFirstObjectByType(); + if (island == null) + { + Log("섬 진입 실패 — IslandWorldBuilder 없음"); + return; + } + Log($"섬 진입 {(island.EnterOwnIsland() ? "성공" : "실패")}"); + } + private static void Tick() { float elapsed = Time.realtimeSinceStartup - startTime; + if (!skyApplied) ApplySkyOverrides(0); + if (!regionMoved && !string.IsNullOrEmpty(settings.region) && elapsed >= settings.regionAt) { regionMoved = true; @@ -208,7 +311,8 @@ private static void Tick() // 잡히는데 실행 중에는 계정 스코프 키를 쓴다 — 엉뚱한 키를 지우고 "초기화했다"고 // 로그만 남긴 채 거점이 안 서는 일을 실제로 겪었다. if (settings.resetBlight) ResetBlightRecord(); - MoveToRegion(settings.region); + if (settings.region == "island") EnterOwnIsland(); + else MoveToRegion(settings.region); } if (!cleansed && settings.cleanseAt >= 0f && elapsed >= settings.cleanseAt) @@ -219,10 +323,17 @@ private static void Tick() if (nextShot < settings.times.Length && elapsed >= settings.times[nextShot]) { + ApplySkyOverrides(nextShot); + LogWeatherFx(); + string suffix = settings.VariesPerShot + ? $"_{settings.WeatherAt(nextShot)}_{settings.HourAt(nextShot).ToString("0.#", CultureInfo.InvariantCulture)}h" + : ""; string path = Path.GetFullPath(ProjectPath(Path.Combine( - settings.outDir, $"shot_{settings.times[nextShot].ToString("0.0", CultureInfo.InvariantCulture)}s.png"))); + settings.outDir, $"shot_{settings.times[nextShot].ToString("0.0", CultureInfo.InvariantCulture)}s{suffix}.png"))); if (Capture(path)) written++; nextShot++; + // 다음 촬영의 시각·날씨를 지금 건다 — 하늘은 바로, 날씨 입자는 몇 초에 걸쳐 차오르므로 촬영 간격만큼 자리를 잡는다. + if (nextShot < settings.times.Length) ApplySkyOverrides(nextShot); } bool done = nextShot >= settings.times.Length; @@ -234,6 +345,31 @@ private static void Tick() EditorApplication.Exit(written == settings.times.Length ? 0 : 3); } + /// + /// 날씨 입자의 상태를 한 줄씩 남긴다 — 입자는 정지 화면에서 "안 보인다"와 "안 생겼다"를 가를 수 없다. + /// 층마다 켜짐·재생·살아 있는 수·방출량·위치를, 그리고 끌 수 있는 조건(꿈·서브에리어·시간 정지)을 함께 적는다. + /// + private static void LogWeatherFx() + { + var fx = UnityEngine.Object.FindFirstObjectByType(FindObjectsInactive.Include); + var rm = UnityEngine.Object.FindFirstObjectByType(); + Camera cam = Camera.main; + Log($"[WX] fx={(fx != null ? (fx.isActiveAndEnabled ? "on" : "off") : "없음")} timeScale={Time.timeScale:0.##} " + + $"dream={InsectGame.Core.DreamPrologueState.Active} sub={(rm != null && rm.CurrentSubArea != null ? rm.CurrentSubArea.subAreaId : "-")} " + + $"region={(rm != null && rm.CurrentRegion != null ? rm.CurrentRegion.regionId : "-")} " + + $"cam={(cam != null ? cam.transform.position.ToString("F1") : "없음")} fxPos={(fx != null ? fx.transform.position.ToString("F1") : "-")}"); + var islandWorld = UnityEngine.Object.FindFirstObjectByType(); + if (rm != null && rm.CurrentSubArea != null && islandWorld != null) + Log($"[WX] 섬 손님 {islandWorld.ActiveGuestCount}마리"); + if (fx == null) return; + foreach (ParticleSystem ps in fx.GetComponentsInChildren(true)) + { + ParticleSystem.EmissionModule em = ps.emission; + Log($"[WX] {ps.name} active={ps.gameObject.activeInHierarchy} playing={ps.isPlaying} count={ps.particleCount} " + + $"rate={em.rateOverTime.constant:0.#} bounds={ps.GetComponent().bounds.center.ToString("F1")}"); + } + } + /// /// 한 장 촬영. 게임 카메라를 옮기지 않는다 — 전용 카메라를 새로 만들어 설정만 복사한다. /// Camera.main을 직접 옮기면 CameraFollower와 다투고, 월드 좌표 이름표 @@ -323,6 +459,8 @@ private static Settings ReadSettings() regionAt = Floats(Arg("-captureRegionAt"), new[] { 2f })[0], cleanseAt = Floats(Arg("-captureCleanseAt"), new[] { -1f })[0], resetBlight = Arg("-captureResetBlight") != null, + hours = IsNatural(Arg("-captureHour")) ? null : Floats(Arg("-captureHour"), new[] { 12f }), + weather = IsNatural(Arg("-captureWeather")) ? "" : (Arg("-captureWeather") ?? "clear"), }; Size(Arg("-captureSize"), out s.width, out s.height); // "-captureTarget none"이면 게임 카메라 구도를 그대로 쓴다. @@ -330,6 +468,8 @@ private static Settings ReadSettings() return s; } + private static bool IsNatural(string raw) => string.Equals(raw, "natural", StringComparison.OrdinalIgnoreCase); + private static string Arg(string name) { string[] args = Environment.GetCommandLineArgs(); @@ -378,7 +518,9 @@ private static string Serialize(Settings s) V(s.offset), V(s.look), s.target ?? "", s.region ?? "", s.regionAt.ToString(CultureInfo.InvariantCulture), s.cleanseAt.ToString(CultureInfo.InvariantCulture), - s.resetBlight ? "1" : "0"); + s.resetBlight ? "1" : "0", + s.hours == null ? "" : string.Join(",", Array.ConvertAll(s.hours, h => h.ToString(CultureInfo.InvariantCulture))), + s.weather ?? ""); } private static string V(Vector3 v) => string.Format(CultureInfo.InvariantCulture, @@ -401,6 +543,8 @@ private static Settings Deserialize(string raw) regionAt = p.Length > 9 ? Floats(p[9], new[] { 2f })[0] : 2f, cleanseAt = p.Length > 10 ? Floats(p[10], new[] { -1f })[0] : -1f, resetBlight = p.Length > 11 && p[11] == "1", + hours = p.Length > 12 ? Floats(p[12], null) : null, + weather = p.Length > 13 ? p[13] : "", }; } diff --git a/Assets/Editor/ModelDesignCapture.cs b/Assets/Editor/ModelDesignCapture.cs new file mode 100644 index 0000000..37f11aa --- /dev/null +++ b/Assets/Editor/ModelDesignCapture.cs @@ -0,0 +1,129 @@ +#if UNITY_EDITOR +using System; +using System.IO; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.NPC; +using InsectGame.Spawning; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace InsectGame.EditorTools +{ + /// Isolated, deterministic model gallery. Camera captures exclude IMGUI. + /// Batch: -executeMethod InsectGame.EditorTools.ModelDesignCapture.Run -modelCaptureOut path + /// Do not pass -quit or -nographics; requires a licensed editor and graphics device. + public static class ModelDesignCapture + { + private const string Pending = "InsectGame.ModelDesignCapture.Pending"; + + public static void Run() + { + if (!Application.isBatchMode) throw new InvalidOperationException("Gallery capture requires batch mode to protect the open scene."); + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + SessionState.SetBool(Pending, true); + EditorApplication.isPlaying = true; + } + + [InitializeOnLoadMethod] + private static void Resume() + { + if (SessionState.GetBool(Pending, false)) EditorApplication.update += Tick; + } + + private static void Tick() + { + if (!EditorApplication.isPlaying) return; + EditorApplication.update -= Tick; + SessionState.SetBool(Pending, false); + try { CaptureGallery(); EditorApplication.Exit(0); } + catch (Exception error) { Debug.LogException(error); EditorApplication.Exit(3); } + } + + private static void CaptureGallery() + { + string output = "Artifacts/model-design"; + string[] args = Environment.GetCommandLineArgs(); + for (int i = 0; i + 1 < args.Length; i++) + if (args[i] == "-modelCaptureOut") output = args[i + 1]; + Directory.CreateDirectory(output); + RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat; + RenderSettings.ambientLight = new Color(0.42f, 0.46f, 0.53f); + Light light = new GameObject("GalleryKey").AddComponent(); + light.type = LightType.Directional; + light.intensity = 1.25f; + light.transform.rotation = Quaternion.Euler(38f, -28f, 0f); + Camera camera = new GameObject("GalleryCamera").AddComponent(); + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = new Color(0.055f, 0.075f, 0.10f); + camera.orthographic = true; + camera.nearClipPlane = 0.01f; + camera.farClipPlane = 30f; + string[] ids = { "rhinoceros_beetle", "stag_beetle", "mantis", "monarch_butterfly", "dragonfly", "ant" }; + foreach (string id in ids) + { + var data = ScriptableObject.CreateInstance(); + data.insectId = id; + GameObject model = new GameObject(id); + model.AddComponent().BuildForBattle(data, 1, false); + CaptureViews(camera, model, output, id); + model.SetActive(false); + UnityEngine.Object.Destroy(model); + UnityEngine.Object.Destroy(data); + } + GameObject player = new GameObject("GalleryPlayer"); + player.SetActive(false); + player.AddComponent().BuildForPreview(new AppearanceSpec()); + player.SetActive(true); + CaptureViews(camera, player, output, "player"); + player.SetActive(false); + UnityEngine.Object.Destroy(player); + for (int i = 0; i < 2; i++) + { + GameObject npc = new GameObject("GalleryNpc"); + NpcVisualBuilder.Build(npc.transform, i == 0 ? NpcVisualBuilder.RandomVillager(12) : NpcVisualBuilder.RandomKid(12)); + CaptureViews(camera, npc, output, i == 0 ? "adult" : "child"); + npc.SetActive(false); + NpcVisualBuilder.CleanupMaterials(npc.transform); + UnityEngine.Object.Destroy(npc); + } + File.WriteAllText(Path.Combine(output, "README.txt"), "27 model-only PNGs: front, side and rear for six insects and player/adult/child. Identical lighting, per-model bounds framing. Excludes IMGUI, gameplay, Android performance, and animation validation."); + } + + private static void CaptureViews(Camera camera, GameObject model, string output, string name) + { + Renderer[] renderers = model.GetComponentsInChildren(); + Bounds bounds = renderers[0].bounds; + foreach (Renderer renderer in renderers) bounds.Encapsulate(renderer.bounds); + camera.orthographicSize = Mathf.Max(bounds.size.y, Mathf.Max(bounds.size.x, bounds.size.z)) * 0.7f; + Vector3[] directions = { new Vector3(1f, 0.55f, 1.8f), new Vector3(2f, 0.4f, 0f), new Vector3(-1f, 0.55f, -1.8f) }; + string[] views = { "front", "side", "rear" }; + RenderTexture rt = RenderTexture.GetTemporary(768, 768, 24); + RenderTexture previous = RenderTexture.active; + Texture2D pixels = new Texture2D(768, 768, TextureFormat.RGB24, false); + try + { + camera.targetTexture = rt; + for (int i = 0; i < directions.Length; i++) + { + camera.transform.position = bounds.center + directions[i].normalized * 7f; + camera.transform.LookAt(bounds.center); + camera.Render(); + RenderTexture.active = rt; + pixels.ReadPixels(new Rect(0f, 0f, 768f, 768f), 0, 0); + pixels.Apply(); + File.WriteAllBytes(Path.Combine(output, name + "_" + views[i] + ".png"), pixels.EncodeToPNG()); + } + } + finally + { + camera.targetTexture = null; + RenderTexture.active = previous; + RenderTexture.ReleaseTemporary(rt); + UnityEngine.Object.Destroy(pixels); + } + } + } +} +#endif diff --git a/Assets/Editor/ModelDesignCapture.cs.meta b/Assets/Editor/ModelDesignCapture.cs.meta new file mode 100644 index 0000000..09399c8 --- /dev/null +++ b/Assets/Editor/ModelDesignCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b2521670678c93c47af1061f012d2865 \ No newline at end of file diff --git a/Assets/Editor/OutfitRenderProbe.cs b/Assets/Editor/OutfitRenderProbe.cs index e87e821..3c9ad3f 100644 --- a/Assets/Editor/OutfitRenderProbe.cs +++ b/Assets/Editor/OutfitRenderProbe.cs @@ -58,6 +58,9 @@ public static void Run() UnityEditor.SceneManagement.EditorSceneManager.OpenScene( ScenePath, UnityEditor.SceneManagement.OpenSceneMode.Single); SessionState.SetString(StageKey, outDir); + // 플레이 모드 진입의 도메인 리로드가 static을 지운다 — 스위치도 outDir처럼 넘긴다. + SessionState.SetBool(StageKey + ".All", galleryAll); + SessionState.SetBool(StageKey + ".Looks", galleryLooks); EditorApplication.EnterPlaymode(); } @@ -70,6 +73,8 @@ private static void Resume() SessionState.EraseString(StageKey); outDir = stage; + galleryAll = SessionState.GetBool(StageKey + ".All", false); + galleryLooks = SessionState.GetBool(StageKey + ".Looks", false); failures = 0; shots = 0; Report.Length = 0; @@ -141,9 +146,119 @@ private static IEnumerator Drive() yield return ProbeItem(id, baseline); } + if (galleryAll) yield return GalleryAll(); + if (galleryLooks) yield return GalleryLooks(); + CleanupRig(); } + // ── 갤러리(판정 없음, 눈으로 보는 용도) ── + // + // 위 표본 판정은 "그려지는가"만 본다. 의상·외형을 **고칠 때는** 전후를 같은 조명·구도로 + // 나란히 봐야 한다 — 그래서 카탈로그 전량과 외형 조합을 한 실행에 찍는 모드를 둔다. + // 파일명 접두사(g_·look_)로 표본 컷과 갈라 두어 시트를 만드는 스크립트가 고르기 쉽게 한다. + + private static bool galleryAll; + private static bool galleryLooks; + + /// 새 계정이 처음 입는 조합 — CharacterOutfitManager.LoadEquipment의 기본 장착과 같다. + private static OutfitLoadout StarterLoadout() + { + OutfitLoadout lo = new OutfitLoadout(); + lo.Set(OutfitSlot.Hat, "hat_cap"); + lo.Set(OutfitSlot.Top, "top_shirt"); + lo.Set(OutfitSlot.Bottom, "bot_pants"); + lo.Set(OutfitSlot.Outerwear, "outer_jacket"); + lo.Set(OutfitSlot.Shoes, "shoe_boots"); + lo.Set(OutfitSlot.Backpack, "bag_basic"); + lo.Set(OutfitSlot.Tool, "tool_net"); + lo.Set(OutfitSlot.Accessory, "acc_none"); + return lo; + } + + /// + /// 카탈로그 전량을 기본 복장 위에 한 벌씩 입혀 앞(3/4)·뒤(3/4) 두 컷. 실제 플레이어가 보는 건 + /// 맨몸 위의 한 벌이 아니라 **다른 옷과 섞인 모습**이라 기본 복장을 바탕으로 깐다. + /// + private static IEnumerator GalleryAll() + { + CharacterOutfitManager mgr = CharacterOutfitManager.Instance; + OutfitSlot[] slots = (OutfitSlot[])Enum.GetValues(typeof(OutfitSlot)); + for (int s = 0; s < slots.Length; s++) + { + OutfitItem[] items = mgr.GetItemsForSlot(slots[s]); + for (int i = 0; i < items.Length; i++) + { + OutfitLoadout lo = StarterLoadout(); + lo.Set(slots[s], items[i].itemId); + string stem = string.Format("g_{0}_{1:00}_{2}", (int)slots[s], i, items[i].itemId); + yield return Shoot(lo, stem + "_a", _ => { }, CharacterModelPreviewRenderer.FrontYaw - 25f); + yield return Shoot(lo, stem + "_b", _ => { }, 35f); + // 상의는 겉옷을 벗은 모습도 — 자켓을 입으면 앞섶 사이 판으로만 보이고, 벗으면 몸통 전체가 된다. + if (slots[s] == OutfitSlot.Top) + { + lo.Set(OutfitSlot.Outerwear, "outer_none"); + yield return Shoot(lo, stem + "_t", _ => { }, CharacterModelPreviewRenderer.FrontYaw - 25f); + } + } + } + } + + /// + /// 외형 조합 — 성별 × 머리 모양(모자 벗김), 성별 × 얼굴형, 피부색, 머리색. + /// 마네킹을 외형마다 다시 짓는다(외형은 빌드 시점에 굳는다 — 실제 게임도 RebuildFromPrefs로 다시 짓는다). + /// + private static IEnumerator GalleryLooks() + { + OutfitLoadout lo = StarterLoadout(); + lo.Set(OutfitSlot.Hat, "hat_none"); + + for (int g = 0; g < 2; g++) + for (int h = 0; h < 4; h++) + yield return ShootLook(new AppearanceSpec { gender = g, hairStyle = h }, lo, + string.Format("look_hair_g{0}_h{1}", g, h)); + + for (int g = 0; g < 2; g++) + for (int f = 0; f < 4; f++) + yield return ShootLook(new AppearanceSpec { gender = g, faceType = f, hairStyle = 1 }, lo, + string.Format("look_face_g{0}_f{1}", g, f)); + + for (int k = 0; k < CharacterPalette.SkinCount; k++) + yield return ShootLook(new AppearanceSpec { gender = k % 2, skinColor = k, hairStyle = 1 }, lo, + string.Format("look_skin_{0}", k)); + + for (int c = 0; c < CharacterPalette.HairCount; c++) + yield return ShootLook(new AppearanceSpec { gender = c % 2, hairColor = c, hairStyle = 2 }, lo, + string.Format("look_haircol_{0}", c)); + + // 머리 × 모자 — 모자 선 위로 솟는 머리(HairCrown)가 덮는 모자에선 숨고 열린 모자(머리띠·바이저)에선 + // 남는지, 긴 머리가 챙을 뚫지 않는지. + string[] hats = { "hat_cap", "hat_straw", "hat_military", "hat_butterfly_wing", "hat_cyber_visor" }; + int[,] looks = { { 0, 3 }, { 1, 3 }, { 1, 2 } }; // (성별, 머리) + for (int k = 0; k < looks.GetLength(0); k++) + { + for (int i = 0; i < hats.Length; i++) + { + OutfitLoadout withHat = StarterLoadout(); + withHat.Set(OutfitSlot.Hat, hats[i]); + yield return ShootLook(new AppearanceSpec { gender = looks[k, 0], hairStyle = looks[k, 1] }, withHat, + string.Format("look_hat_g{0}_h{1}_{2}", looks[k, 0], looks[k, 1], hats[i])); + } + } + } + + private static IEnumerator ShootLook(AppearanceSpec spec, OutfitLoadout lo, string stem) + { + if (mannequin != null) UnityEngine.Object.Destroy(mannequin); + mannequin = null; + yield return null; // Destroy는 프레임 끝 — 새 몸과 한 컷에 겹치지 않게 + BuildMannequin(spec); + yield return null; + yield return Shoot(lo, stem + "_a", _ => { }, CharacterModelPreviewRenderer.FrontYaw - 20f); + yield return Shoot(lo, stem + "_face", _ => { }, CharacterModelPreviewRenderer.FrontYaw - 20f, headOnly: true); + yield return Shoot(lo, stem + "_b", _ => { }, 35f); + } + /// /// 레시피를 가진 아이템 중 슬롯을 골고루 덮는 표본. /// 45개 전부를 찍으면 프로세스가 길어지고 보고서도 읽기 어려워, spawn·bind·hideNodes @@ -206,16 +321,19 @@ private static IEnumerator ProbeItem(string itemId, byte[] baseline) // ── 리그 ── - private static void BuildRig() + private static void BuildMannequin(AppearanceSpec spec) { - AppearanceSpec spec = AppearanceSpec.FromPlayerPrefs(); - GameObject go = new GameObject("OutfitProbeMannequin"); go.SetActive(false); // Awake 억제 — PlayerPrefs 외형으로 먼저 지어지지 않게 go.transform.position = RigOrigin; go.AddComponent().BuildForPreview(spec); go.SetActive(true); mannequin = go; + } + + private static void BuildRig() + { + BuildMannequin(AppearanceSpec.FromPlayerPrefs()); GameObject camGo = new GameObject("OutfitProbeCam"); camGo.transform.rotation = Quaternion.Euler(6f, 0f, 0f); @@ -245,7 +363,8 @@ private static void CleanupRig() } /// 의상을 입히고 한 장 찍어 PNG로 남긴다. 픽셀 배열도 돌려줘 비교에 쓴다. - private static IEnumerator Shoot(OutfitLoadout loadout, string name, Action onDone) + private static IEnumerator Shoot(OutfitLoadout loadout, string name, Action onDone, + float yaw = CharacterModelPreviewRenderer.FrontYaw, bool headOnly = false) { CharacterOutfitManager mgr = CharacterOutfitManager.Instance; if (mgr != null) mgr.ApplyToCharacter(mannequin, loadout); @@ -253,18 +372,23 @@ private static IEnumerator Shoot(OutfitLoadout loadout, string name, Action(false)) + foreach (Renderer r in frameRoot.GetComponentsInChildren(false)) { if (r == null) continue; if (!any) { b = r.bounds; any = true; } @@ -348,6 +472,12 @@ private static void ParseArgs() string[] args = Environment.GetCommandLineArgs(); for (int i = 0; i < args.Length - 1; i++) if (args[i] == "-outfitOut") outDir = args[i + 1]; + // 스위치는 값을 안 받으므로 마지막 인자까지 본다. + for (int i = 0; i < args.Length; i++) + { + if (args[i] == "-outfitAll") galleryAll = true; + if (args[i] == "-outfitLooks") galleryLooks = true; + } } private static void Log(string msg) { Debug.Log("[OUTFIT-PROBE] " + msg); } diff --git a/Assets/Editor/ShaderVariantKeepers.cs b/Assets/Editor/ShaderVariantKeepers.cs new file mode 100644 index 0000000..753bdf3 --- /dev/null +++ b/Assets/Editor/ShaderVariantKeepers.cs @@ -0,0 +1,103 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Rendering; +using UnityEngine; +using UnityEngine.Rendering; + +namespace InsectGame.EditorTools +{ + /// + /// 런타임에 키워드를 켜는 Standard 머티리얼의 셰이더 변형을 빌드에 남긴다. + /// + /// 월드 머티리얼은 전부 코드에서 new Material(Standard)로 만들고 반투명(SceneryMaterials.MakeFade — + /// _ALPHABLEND_ON)·발광(SceneryMaterials.SetEmission — _EMISSION)을 런타임에 켠다. 그런데 둘 다 + /// Standard의 shader_feature라 빌드에 든 머티리얼이 쓰는 조합만 컴파일된다. 이 저장소엔 .mat이 하나도 + /// 없어서 Standard가 Always Included인데도 빌드 로그가 "FORWARD 664 → built-in stripping 뒤 8"이었다 — 반투명·발광 + /// 변형이 통째로 빠졌고, 없는 변형을 요청하면 불투명·무발광 변형으로 그려진다(전투 이펙트가 QA 빌드에서 불투명 공으로 + /// 찍힌 원인). 물·얼음·유리·안개·발광 소품이 같은 경로라 기기에서 같은 결함을 안고 있었다. + /// + /// 고치는 방법은 그 조합을 쓰는 머티리얼을 Resources에 두는 것이다 — Resources 에셋은 항상 빌드에 들어가고, + /// 빌드는 그 키워드 조합의 변형을 multi_compile(조명·그림자·안개) 전부와 곱해 남긴다. 머티리얼은 로드하지 않는다. + /// 조합 목록의 단일 출처는 런타임 쪽 SceneryMaterials.BuildKeepers다(테스트가 런타임 어셈블리라 에디터 코드를 못 본다). + /// + /// 단 Standard가 Always Included Shaders에 있으면 이 방법이 안 먹는다 — 그 목록의 셰이더는 머티리얼 키워드를 안 보고 + /// shader_feature를 끈 변형만 싣는다. 머티리얼을 두고 빌드해도 로그가 그대로여서(FORWARD/Fragment 16) 목록에서 뺐고, + /// 그러자 64(반투명 32·발광 32·둘 다 16)가 됐다. 그래서 키워드 없는 StandardOpaque도 둔다 — 셰이더 자체를 빌드에 + /// 싣는 게 그 몫이다(없으면 Shader.Find("Standard")가 빌드에서 null이 되어 Unlit 폴백으로 떨어진다). + /// 판정은 QA 빌드의 -battleScenario materials(SceneryMaterialVisualCapture)가 수치로 한다. + /// + public static class ShaderVariantKeepers + { + public const string Folder = "Assets/Resources/" + InsectGame.Core.SceneryMaterials.BuildKeeperFolder; + + /// 배치: -executeMethod InsectGame.EditorTools.ShaderVariantKeepers.Ensure. 있으면 키워드만 맞춘다. + [MenuItem("InsectGame/Shader Variant Keepers 갱신")] + public static void Ensure() + { + Shader standard = Shader.Find("Standard"); + if (standard == null) throw new BuildFailedException("Standard 셰이더를 찾지 못했다"); + if (!AssetDatabase.IsValidFolder(Folder)) + { + Directory.CreateDirectory(Folder); + AssetDatabase.Refresh(); + } + foreach ((string name, string[] keywords) in InsectGame.Core.SceneryMaterials.BuildKeepers) + { + string path = Folder + "/" + name + ".mat"; + Material mat = AssetDatabase.LoadAssetAtPath(path); + bool created = mat == null; + if (created) mat = new Material(standard); + mat.shader = standard; + mat.shaderKeywords = keywords; + bool fade = System.Array.IndexOf(keywords, "_ALPHABLEND_ON") >= 0; + // 변형 선택에는 키워드만 쓰인다. 블렌드·큐는 인스펙터에서 열었을 때 같은 모습이 되게만 맞춘다(MakeFade와 같은 값). + mat.SetFloat("_Mode", fade ? 2f : 0f); + mat.SetInt("_SrcBlend", (int)(fade ? BlendMode.SrcAlpha : BlendMode.One)); + mat.SetInt("_DstBlend", (int)(fade ? BlendMode.OneMinusSrcAlpha : BlendMode.Zero)); + mat.SetInt("_ZWrite", fade ? 0 : 1); + mat.renderQueue = fade ? (int)RenderQueue.Transparent : -1; + if (System.Array.IndexOf(keywords, "_EMISSION") >= 0) + { + mat.SetColor("_EmissionColor", new Color(0.5f, 0.5f, 0.5f)); + mat.globalIlluminationFlags = MaterialGlobalIlluminationFlags.None; + } + if (created) AssetDatabase.CreateAsset(mat, path); + else EditorUtility.SetDirty(mat); + Debug.Log("[ShaderVariantKeepers] " + path + " — " + string.Join(" ", keywords)); + } + AssetDatabase.SaveAssets(); + } + } + + /// + /// 빌드 로그에 Standard 변형이 실제로 몇 개 남았는지 키워드별로 적는다 — 아무것도 걸러내지 않는다. + /// 반투명·발광 변형이 0이면 기기에서 물이 불투명해지는데 빌드는 성공하므로, 로그에서 "[ShaderVariants]"를 찾아 확인한다. + /// 마지막 순서라 다른 스트리퍼가 걸러낸 뒤의 수다. + /// + internal sealed class StandardVariantReport : IPreprocessShaders + { + public int callbackOrder => int.MaxValue; + + public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList data) + { + if (shader == null || shader.name != "Standard") return; + var alpha = new ShaderKeyword(shader, "_ALPHABLEND_ON"); + var emission = new ShaderKeyword(shader, "_EMISSION"); + int a = 0, e = 0, both = 0; + for (int i = 0; i < data.Count; i++) + { + bool ha = data[i].shaderKeywordSet.IsEnabled(alpha); + bool he = data[i].shaderKeywordSet.IsEnabled(emission); + if (ha) a++; + if (he) e++; + if (ha && he) both++; + } + Debug.Log($"[ShaderVariants] Standard {snippet.passName}/{snippet.shaderType}: {data.Count}개 — " + + $"_ALPHABLEND_ON {a} · _EMISSION {e} · 둘 다 {both}"); + } + } +} +#endif diff --git a/Assets/Editor/ShaderVariantKeepers.cs.meta b/Assets/Editor/ShaderVariantKeepers.cs.meta new file mode 100644 index 0000000..2baf68c --- /dev/null +++ b/Assets/Editor/ShaderVariantKeepers.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b6e83125b452b1a4eb111b7719c8107b \ No newline at end of file diff --git a/Assets/Editor/StoryBeatWalkthrough.cs b/Assets/Editor/StoryBeatWalkthrough.cs index 18c15d2..73a8cfa 100644 --- a/Assets/Editor/StoryBeatWalkthrough.cs +++ b/Assets/Editor/StoryBeatWalkthrough.cs @@ -21,8 +21,8 @@ namespace InsectGame.EditorTools /// -walkOut .claude/cache/story-walk.md [-walkRegion mountain] [-walkMode campaign] /// /// - /// -walkMode는 blight(기본 · 오염 거점 아크)와 campaign(1막 본편 + 꽃밭) - /// 둘이다. 거점 모드는 NpcTalk·CaptureInsect·BattleWin·RegionCleansed만 + /// -walkMode는 blight(기본 · 오염 거점 아크), campaign(1막 본편 + 꽃밭), + /// town(마을 이야기 + 지역 의뢰 + 따라가기) 셋이다. 거점 모드는 NpcTalk·CaptureInsect·BattleWin·RegionCleansed만 /// 두드리므로, SubAreaEnter와 GuardianDefeat에 걸린 비트는 오래 사각지대였다 /// — 본편에서 가장 많이 쓰는 두 트리거인데도 그랬다. campaign 모드가 그 둘을 실제 경로로 두드린다 /// (근접 → [E] 진입 → 이탈 / RegionManager.DefeatGuardian). @@ -43,6 +43,8 @@ namespace InsectGame.EditorTools /// 영구 미발화 결함이라 FAIL로 잡는다) /// 정화 — RegionBlightManager.CleanseByBoss (승리 경로와 같은 함수) /// 대사 닫기 — NpcDialogueUI.CloseModal (플레이어가 닫는 그 경로. 보상·열람 기록이 여기서 난다) + /// 영상 건너뛰기 — StoryVideoDirector.CloseModal (「건너뛰기」·ESC와 같은 경로). 배치모드엔 디코더가 없다. + /// 대사 앞 영상(introVideoId)은 이걸로 끝나야 대사가 열린다 — 영상 → 등장 연출 → 대사 순서를 실제로 지난다. /// /// 선행 비트만 CompleteBeat로 채운다 — 여기서 검증하려는 건 bl_*이지 /// 1막 82비트가 아니다. 무엇을 채웠는지는 보고서에 그대로 적는다. @@ -179,9 +181,10 @@ private static void Tick() if (now < bootDeadline) return; if (!Bootstrapped()) { bootDeadline = now + 1f; return; } booted = true; - if (IsCampaign) BuildCampaignSteps(); else BuildSteps(); + SuspendDuelLauncher(); + if (IsCampaign) BuildCampaignSteps(); else if (IsTown) BuildTownSteps(); else BuildSteps(); if (steps.Count == 0) { Finish("걸어볼 것이 없다"); return; } - Log((IsCampaign ? "본편" : "거점 " + SiteCount() + "개") + Log((IsCampaign ? "본편" : IsTown ? "마을 이야기" : "거점 " + SiteCount() + "개") + " · 걸음 " + steps.Count + "개 시작"); } @@ -197,6 +200,29 @@ private static void Tick() /// private static void TickJanitor(float now) { + // 수문장 배지 연출 — 수문장 걸음마다 gd_* 대사보다 먼저 뜬다. 모달이라 닫지 않으면 대사가 영영 안 온다. + // 플레이어가 화면을 누르는 것처럼 닫고, 떴다는 사실을 보고서에 남긴다(순서 검증). + var ceremony = UnityEngine.Object.FindFirstObjectByType(); + if (ceremony != null && ceremony.IsOpen) + { + Log("배지 연출 — " + ceremony.CurrentRegionId); + ceremony.CloseModal(); + return; + } + + // 스토리 영상 — 배치모드엔 디코더가 없어 영상은 준비 시간 초과(5초)까지 검은 화면으로 선다. 대사 **앞** 영상 + // (introVideoId)이면 그동안 대사창이 안 열려 아래 ModalOpenWait 뒤 "직접 완료"로 새고, 그 뒤 영상이 끝나며 콜백이 + // 같은 대사를 한 번 더 연다. 플레이어가 「건너뛰기」를 누르는 것처럼 닫는다 — Stop → 콜백 → (등장 연출) → 대사. + // 이 길이 곧 대사 앞 영상의 진짜 종료 경로라 걸음이 그것까지 검증한다. 떴다는 사실은 보고서에 남긴다(순서 검증). + // 파일이 아직 없는 영상은 지휘자가 시작도 안 하고(false) 대사가 곧바로 열린다 — 여기 안 온다. + var video = UnityEngine.Object.FindFirstObjectByType(); + if (video != null && video.IsPlaying) + { + Log((video.IsPlayingPrelude ? "영상(대사 앞) 건너뛰기 — " : "영상 건너뛰기 — ") + video.CurrentVideoId); + video.CloseModal(); + return; + } + if (closingId == null) { if (toClose.Count == 0) return; @@ -449,8 +475,22 @@ private static void BuildCampaignSteps() // 여기서 마지막 두 걸음이 갈린다: 잡몹을 이기면 엔딩이 **안 나야** 하고, // 이름 없는 사마귀를 이겨야 난다. 그 순서 그대로 걷는다. SubAreaBeat(fin, "nameless_ledger", "ch12_confront", "장부의 방(관장 대면)"); - SubAreaBeat(fin, "nameless_core", "fin_unnamed", "빈칸(무명 대면·컷신)"); - // 무명 대면의 선택지 — 고르는 것이 대사창을 닫고, 결과 leaf가 큐 맨 앞에서 곧바로 뜬다. + // **대사 직후 대결**(StoryBeat.duelAfter) — 대치가 끝나면 StoryDuelLauncher가 관장전을 대기열에 넣는다. + // 걸음은 진짜 전투를 열지 않는다(런처를 멈춰 뒀다 — SuspendDuelLauncher). 대기열에 들어갔는지 보고, 승리를 + // 실제 구독 경로(BossDuelWon)로 흘린다. 2026-10-04부터 관장을 이겨야 빈칸(fin_unnamed)이 열린다. + steps.Add(new Step + { + site = fin, + label = "대사 직후 대결 대기열(관장)", + act = () => { }, + until = () => DuelQueued("ledger_chief"), + maxTries = 1, + }); + steps.Add(BeatStep(fin, "관장 대결 승리(대사 직후 대결)", "duel_chief_win", + () => WinBossDuel("ledger_chief"), 3)); + // 대사 앞 영상(vid_fin_shadow)이 먼저 뜬다 — 청소부가 건너뛰기로 닫아야 대사(와 선택지)가 열린다(TickJanitor). + SubAreaBeat(fin, "nameless_core", "fin_unnamed", "빈칸(그림자 대면·대사 앞 영상)"); + // 그림자 대면의 선택지 — 고르는 것이 대사창을 닫고, 결과 leaf가 큐 맨 앞에서 곧바로 뜬다. // 첫 항목(거절)이 기본, `-walkChoice last`면 수락 쪽(세라가 막는다). 둘 다 leaf라 진행은 같다. steps.Add(BeatStep(fin, walkChoiceLast ? "선택 결과(이름을 준다 → 세라가 막는다)" : "선택 결과(이름을 주지 않는다)", walkChoiceLast ? "fin_named" : "fin_refuse", () => { }, 2)); @@ -468,7 +508,308 @@ private static void BuildCampaignSteps() // 1v1로 걸으면 게임에 없는 경로를 통과시키는 셈이 되고, 그게 이 결함을 놓친 이유다. Beat(fin, "이름 없는 사마귀 레이드 격파(엔딩)", "fin_seal", () => WinRaidAgainst("mantis_unnamed"), 3); - Beat(fin, "전투 승리(관장 마지막 말)", "ch12_clash", WinBattle, 3); + // 관장의 마지막 말은 **추가 전투 없이 말을 걸어** 듣는다(StoryBible 0장 2026-09-19 연결성 보정 — + // 트리거가 BattleWin → NpcTalk ledger_chief). 걸음이 옛 트리거로 두드리면 3회 시도 뒤 FAIL이 난다. + Beat(fin, "말 걸기(관장 마지막 말)", "ch12_clash", () => Talk("ledger_chief"), 6); + } + + private static bool IsTown { get { return walkMode == "town"; } } + + // 마을 이야기 한 편 — (리전, 주민, 의뢰, 의뢰가 전투인가, 목표 수, 징후 종). + // 저작 데이터에서 파생하지 않고 적어 두는 이유: 이 표가 곧 **기대값**이다. 저작이 바뀌어 + // 여기와 어긋나면 걸음이 FAIL로 알려 준다(TownTaleTests가 정적 정합을, 이 걸음이 발화를 본다). + private static readonly (string region, string npc, string quest, bool battle, int count, string sign)[] Tales = + { + ("meadow", "town_meadow", "s_town_meadow", false, 6, "ladybug_seven"), + ("pond", "town_pond", "s_town_pond", false, 5, "water_strider_pond"), + ("forest", "town_forest", "s_town_forest", true, 3, "cicada_summer"), + ("swamp", "town_swamp", "s_town_swamp", false, 5, "firefly_swamp"), + ("mountain", "town_mountain", "s_town_mountain", true, 4, "katydid_leaf"), + ("garden", "town_garden", "s_town_garden", false, 5, "butterfly_peacock"), + ("ruins", "town_ruins", "s_town_ruins", false, 3, "cricket_tomb"), + // 2막 — 후일담 게이트가 종장(fin_seal)까지 가므로 선행 채우기가 본편을 거의 다 채운다. + ("hollow", "town_hollow", "s_town_hollow", false, 5, "cricket_hush"), + ("dunes", "town_dunes", "s_town_dunes", false, 5, "bee_digger"), + ("frostline", "town_frostline", "s_town_frostline", true, 4, "beetle_hoarfrost"), + ("emberfall", "town_emberfall", "s_town_emberfall", false, 5, "cricket_ember"), + ("canopy", "town_canopy", "s_town_canopy", false, 3, "caterpillar_silk"), + }; + + /// + /// 마을 이야기 걸음(-walkMode town). 스토리 발화만이 아니라 퀘스트·따라가기와 맞물리는 곳을 + /// 실제 진입점으로 본다 — 이 연작의 결함은 전부 그 이음매에서 조용히 난다: + /// + /// 의뢰가 다른 리전에서 세어지면 안 된다(QuestRegionGate) — 리전 이동 전에 한 번 두드려 0인지 본다. + /// 의뢰 완료 → 매듭 비트의 requiredQuestId 게이트가 열리는가. + /// 따라가기가 만남 뒤 [의뢰] 목표를 잡고, 완료 뒤 ? 표식과 "알리기"로 바뀌고, + /// 매듭 뒤 본편을 기다리는 단계가 되면 스스로 풀리는가(StoryObjectiveTracker). + /// + /// 배치에는 로그인이 없어 퀘스트 세션이 꺼져 있고(NotifyAction이 무시된다) 해금 리전은 로컬 설정에 + /// 달려 있다 — 둘을 먼저 채우고 무엇을 채웠는지 보고서에 적는다(검증 대상 아님). + /// + private static void BuildTownSteps() + { + steps = new List(); + steps.Add(new Step + { + site = "-", + label = "진행 기록 초기화(스토리 열람 + 수문장) + 의뢰 상태 채우기", + act = () => { ResetProgress(); SeedTownQuestState(); }, + until = () => true, + }); + + for (int i = 0; i < Tales.Length; i++) + { + var t = Tales[i]; + if (!string.IsNullOrEmpty(onlyRegion) && t.region != onlyRegion) continue; + string site = t.region + "(" + t.npc + ")"; + + // 리전 밖 행동은 세지 않는다 — 앞 리전에 선 채로 한 번 두드린다(초원은 시작 지점이라 뺀다). + if (i > 0) + { + string quest = t.quest; + bool battle = t.battle; + steps.Add(new Step + { + site = site, + label = "리전 밖 의뢰 행동 — 세지 않아야 한다", + act = () => ErrandAction(battle, 1, quietBattle: true), + until = () => TownQuests() != null && TownQuests().GetSideProgress(quest) == 0 + && !TownQuests().IsQuestCompleted(quest), + maxTries = 1, + }); + } + + Region(site, t.region); + Beat(site, "만남(말 걸기)", "town_" + t.region + "_meet", () => Talk(t.npc), 6); + + string npc = t.npc; + steps.Add(new Step + { + site = site, + label = "따라가기 → [의뢰] 목표", + act = () => { var tr = Tracker(); if (tr != null) tr.TrackTale(npc); }, + until = () => { var tr = Tracker(); return tr != null && tr.IsTrackingTale && tr.Label.StartsWith("[의뢰]"); }, + maxTries = 2, + }); + // 같은 리전의 본편 포획 비트(chN, 순위 앞)가 먼저 나가면 한 번에 하나씩 밀린다 — 넉넉히 준다. + steps.Add(BeatStep(site, "징후(포획)", "town_" + t.region + "_sign", () => Capture(t.sign), 6)); + + string q = t.quest; + bool isBattle = t.battle; + int count = t.count; + steps.Add(new Step + { + site = site, + label = "의뢰 " + count + "회(" + (isBattle ? "전투 승리" : "포획") + ")", + act = () => ErrandAction(isBattle, count, quietBattle: false), + until = () => TownQuests() != null && TownQuests().IsQuestCompleted(q), + maxTries = 2, + }); + steps.Add(new Step + { + site = site, + label = "보고 표식(?) + '알리기' 목표", + until = () => + { + var tr = Tracker(); + return director != null && director.GetTaleStep(npc, out _) == TaleStepKind.Report + && tr != null && HasMarker(tr, npc, InsectGame.NPC.QuestMark.Report) + && tr.Label.Contains("알리기"); + }, + }); + steps.Add(BeatStep(site, "매듭(말 걸기)", "town_" + t.region + "_close", () => Talk(npc), 6)); + // 매듭 뒤 — 후일담 게이트(본편 뒷 장면)가 아직이면 따라가기가 **스스로 풀려야** 하고, + // 이미 열려 있으면 **계속 따라가며** "말 걸기"를 가리켜야 한다. 둘 다 이 걸음에서 실제로 난다: + // 앞 마을의 후일담을 채우며 본편 사슬이 함께 채워지므로(연못 → ch8_confront의 조상에 + // 습지 게이트 ch7_confront가, 초원 → ch7_arrive의 조상에 유적 게이트 ch7_opening이 있다). + steps.Add(new Step + { + site = site, + label = "매듭 뒤 — 대기면 따라가기 해제 / 후일담이 열려 있으면 계속", + until = () => + { + var tr = Tracker(); + if (tr == null || director == null) return false; + TaleStepKind k = director.GetTaleStep(npc, out _); + if (k == TaleStepKind.Waiting) return !tr.IsTrackingTale; + return k == TaleStepKind.Talk && tr.IsTrackingTale && tr.Label.Contains("말 걸기"); + }, + }); + Beat(site, "후일담(말 걸기)", "town_" + t.region + "_after", () => Talk(npc), 6); + steps.Add(new Step + { + site = site, + label = "이야기 끝(Done) · 표식 없음", + until = () => + { + var tr = Tracker(); + return director != null && director.GetTaleStep(npc, out _) == TaleStepKind.Done + && tr != null && !HasMarker(tr, npc, InsectGame.NPC.QuestMark.New) + && !HasMarker(tr, npc, InsectGame.NPC.QuestMark.Report); + }, + }); + } + } + + private static TutorialQuestManager TownQuests() + { + return UnityEngine.Object.FindFirstObjectByType(); + } + + private static StoryObjectiveTracker Tracker() + { + return UnityEngine.Object.FindFirstObjectByType(); + } + + private static bool HasMarker(StoryObjectiveTracker tr, string npcId, InsectGame.NPC.QuestMark mark) + { + var marks = tr.TaleMarkers; + for (int i = 0; i < marks.Count; i++) + if (marks[i].Npc != null && marks[i].Npc.StoryNpcId == npcId && marks[i].Mark == mark) return true; + return false; + } + + /// + /// 의뢰 행동 — 포획은 CaptureController가 부르는 NotifyCapture, 전투는 BattleEnded + /// 구독 경로() 그대로다. 리전 판정은 매니저 안에서 난다(그게 검증 대상이다). + /// 이면 전투도 퀘스트 통지만 한다 — 리전 밖 확인에서 앞 리전의 + /// 본편 전투 비트까지 흔들 이유가 없다(퀘스트가 BattleEnded에서 부르는 바로 그 함수다). + /// + private static void ErrandAction(bool battle, int count, bool quietBattle) + { + var qm = TownQuests(); + for (int i = 0; i < count; i++) + { + if (!battle) { if (qm != null) qm.NotifyCapture(InsectGame.Data.InsectRarity.Uncommon); } + else if (quietBattle) { if (qm != null) qm.NotifyAction(QuestType.Battle); } + else WinBattle(); + } + } + + /// + /// 의뢰가 세어질 수 있는 상태를 만든다 — 퀘스트 세션 켜기, 마을 의뢰 진행 비우기, 선행 퀘스트·리전 해금. + /// 전부 인메모리이고, 퀘스트가 진행을 저장할 때 **배치 환경의 전역(비로그인) 키**에만 남는다 — + /// 그 키는 걸음 전 값으로 떠 두었다가 가 되돌린다(). + /// + private static void SeedTownQuestState() + { + SnapshotPlayerState(); + var qm = TownQuests(); + var rm = UnityEngine.Object.FindFirstObjectByType(); + if (qm == null || rm == null) { Log("퀘스트/리전 매니저 없음 — 의뢰를 채울 수 없다"); return; } + + const BindingFlags F = BindingFlags.Instance | BindingFlags.NonPublic; + typeof(TutorialQuestManager).GetField("tutorialSessionStarted", F).SetValue(qm, true); + var completed = (HashSet)typeof(TutorialQuestManager).GetField("completedQuests", F).GetValue(qm); + var side = (Dictionary)typeof(TutorialQuestManager).GetField("sideProgress", F).GetValue(qm); + var unlocked = (HashSet)typeof(RegionManager).GetField("unlockedRegions", F).GetValue(rm); + + foreach (var t in Tales) + { + completed.Remove(t.quest); + side.Remove(t.quest); + if (unlocked.Add(t.region)) seeded.Add("region:" + t.region); + } + foreach (string prereq in new[] { "q_capture3", "q_battle" }) + if (completed.Add(prereq)) seeded.Add("quest:" + prereq); + + // 따라가던 이야기가 남아 있으면 첫 걸음의 기대("따라가기 켜기")가 흐려진다. + var tr = Tracker(); + if (tr != null) tr.StopTrackingTale(); + Log("마을 의뢰 상태 채움 — 세션 켜기, 의뢰 " + Tales.Length + "개 비움, 선행·해금 " + seeded.Count + "건"); + } + + // ── 걸음 전 상태 원복 ── + // 걸음은 개발 PC의 **비로그인(전역) 세이브**를 그대로 쓴다. 진행 초기화(ResetProgress)가 스토리 세이브· + // 수문장 격파·정화 기록을 지우고, 걸음 자체가 포획 보상·퀘스트·해금·따라가기를 저장한다. 되돌리지 않으면 + // 걸음 뒤 에디터에서 비로그인으로 플레이할 때 "해금됐는데 수문장은 안 쓰러진" 같은 섞인 상태가 남았다. + // 첫 초기화 직전의 세이브 파일 전부(GameConstants.SaveFiles — 목록을 박지 않고 리플렉션으로 읽는다)와 + // 진행 키를 떠 두었다가 Finish에서 되돌린다. 설정 키(음량·그래픽)는 걸음이 건드리지 않아 빼 둔다. + private static readonly string[] WalkTouchedPrefs = + { + GameConstants.PrefsKeys.QuestProgress, GameConstants.PrefsKeys.QuestCompleted, + GameConstants.PrefsKeys.ActiveQuest, GameConstants.PrefsKeys.QuestSideProgress, + GameConstants.PrefsKeys.QuestSideRepeat, GameConstants.PrefsKeys.QuestUnseen, + GameConstants.PrefsKeys.TutorialHidden, GameConstants.PrefsKeys.WeeklyContestClaimed, + GameConstants.PrefsKeys.BlightCleansed, GameConstants.PrefsKeys.BadgeMilestonesClaimed, + GameConstants.PrefsKeys.TrackedTale, GameConstants.PrefsKeys.LastSubAreaId, + // RegionManager·RegionBlightManager의 private 키와 같은 문자열 — 어긋나면 원복이 조용히 빠진다. + "InsectGame.UnlockedRegions", "InsectGame.DefeatedGuardians", "InsectGame.DefeatedLedgerBosses", + }; + + private enum PrefKind { Absent, String, Int, Float } + private struct PrefSnap { public PrefKind kind; public string s; public int i; public float f; } + + private static Dictionary prefsSnapshot; // 스코프 키 → 걸음 전 값 + private static Dictionary saveFileSnapshot; // 파일 경로 → 걸음 전 내용(null = 없던 파일) + + private static void SnapshotPlayerState() + { + if (prefsSnapshot != null) return; // 첫 초기화 직전 값만 뜬다 + prefsSnapshot = new Dictionary(); + foreach (string baseKey in WalkTouchedPrefs) + { + string key = SaveScope.PrefsKey(baseKey); + prefsSnapshot[key] = TakePref(key); + } + saveFileSnapshot = new Dictionary(); + foreach (FieldInfo f in typeof(GameConstants.SaveFiles).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (!f.IsLiteral || f.FieldType != typeof(string)) continue; + string path = SaveScope.FilePath((string)f.GetRawConstantValue()); + try { saveFileSnapshot[path] = File.Exists(path) ? File.ReadAllBytes(path) : null; } + catch (Exception e) { Log("세이브 스냅숏 실패(" + path + "): " + e.Message); } + } + Log("걸음 전 상태를 떠 둠 — 키 " + prefsSnapshot.Count + "개, 세이브 파일 " + saveFileSnapshot.Count + "개"); + } + + // PlayerPrefs는 타입을 알려 주지 않는다 — 문자열 → 정수 → 실수 순으로 떠 본다(틀린 타입으로 읽으면 기본값이 온다). + // 정수를 문자열로 되돌리면 GetInt 판독부가 0을 읽는다(SaveScope의 v3 결함과 같은 함정). + private static PrefSnap TakePref(string key) + { + if (!PlayerPrefs.HasKey(key)) return new PrefSnap { kind = PrefKind.Absent }; + const string Sentinel = "\u0001walk-sentinel"; + string sv = PlayerPrefs.GetString(key, Sentinel); + if (sv != Sentinel) return new PrefSnap { kind = PrefKind.String, s = sv }; + int iv = PlayerPrefs.GetInt(key, int.MinValue); + if (iv != int.MinValue) return new PrefSnap { kind = PrefKind.Int, i = iv }; + return new PrefSnap { kind = PrefKind.Float, f = PlayerPrefs.GetFloat(key, 0f) }; + } + + private static void RestorePlayerState() + { + if (prefsSnapshot == null) return; + // 디바운스로 미뤄 둔 저장(PlayerInsectCollection 등)은 종료 중 OnDisable에서 flush된다 — 되돌린 뒤에 + // 그게 오면 원복을 덮는다. 씬을 먼저 꺼서 지금 다 쓰게 하고(dirty가 내려간다) 그 위에 되돌린다. + if (Application.isPlaying) + foreach (GameObject root in UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects()) + if (root != null) root.SetActive(false); + foreach (KeyValuePair kv in prefsSnapshot) + { + switch (kv.Value.kind) + { + case PrefKind.Absent: PlayerPrefs.DeleteKey(kv.Key); break; + case PrefKind.String: PlayerPrefs.SetString(kv.Key, kv.Value.s); break; + case PrefKind.Int: PlayerPrefs.SetInt(kv.Key, kv.Value.i); break; + case PrefKind.Float: PlayerPrefs.SetFloat(kv.Key, kv.Value.f); break; + } + } + PlayerPrefs.Save(); + int files = 0; + foreach (KeyValuePair kv in saveFileSnapshot) + { + try + { + if (kv.Value != null) File.WriteAllBytes(kv.Key, kv.Value); + else if (File.Exists(kv.Key)) File.Delete(kv.Key); + files++; + } + catch (Exception e) { Log("세이브 원복 실패(" + kv.Key + "): " + e.Message); } + } + Log("걸음 전 상태로 되돌림 — 키 " + prefsSnapshot.Count + "개, 세이브 파일 " + files + "개"); + prefsSnapshot = null; + saveFileSnapshot = null; } private static void Region(string site, string regionId) @@ -608,10 +949,37 @@ private static void WinBattleAgainst(string insectId) return; } d.Invoke(true); - // 결과 화면이 닫혔다고 알린다 — 안 알리면 미뤄 둔 트리거가 12초를 기다린다. + CloseResultScreens(); + // 결과 화면이 닫혔다고 알린다 — 안 알리면 미뤄 둔 트리거가 12초를 기다린다(화면이 닫으며 이미 알렸으면 두 번째는 무해하다). if (director != null) director.NotifyBattlePresentationClosed(); } + /// + /// 승리 신호로 떠오른 결과 화면을 플레이어가 닫는 길(BattleScreenUI.EndBattle·RaidBattleUI.EndRaid)로 닫는다. + /// + /// 2026-10-04부터 결과 화면은 눌러야 닫힌다(BattleResultRules — 예전엔 4초·5초 뒤 저절로). 걸음은 탭하지 않으므로 그냥 두면 + /// 화면이 결과 단계에 영원히 머물러 플레이어를 묶고(TickResultClose), 스토리 큐는 결과 화면 뒤로 계속 미룬다 + /// (StoryBattleWait의 결과 화면 탐침) — 그 뒤 걸음이 전부 "발화 없음"이 된다. + /// + private static void CloseResultScreens() + { + const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + var screen = UnityEngine.Object.FindFirstObjectByType(); + if (screen != null && screen.IsBattleActive) + { + MethodInfo end = typeof(InsectGame.UI.BattleScreenUI).GetMethod("EndBattle", Private); + if (end != null) end.Invoke(screen, null); + else Log("**BattleScreenUI.EndBattle을 못 찾았다** — 결과 화면이 안 닫혀 뒤 걸음이 막힌다"); + } + var raid = UnityEngine.Object.FindFirstObjectByType(); + if (raid != null && raid.IsRaidActive) + { + MethodInfo end = typeof(InsectGame.UI.RaidBattleUI).GetMethod("EndRaid", Private); + if (end != null) end.Invoke(raid, null); + else Log("**RaidBattleUI.EndRaid를 못 찾았다** — 결과 화면이 안 닫혀 뒤 걸음이 막힌다"); + } + } + /// /// 레이드 승리를 흘린다. Epic·Legendary는 이 길밖에 없다 — /// CaptureChoiceUI.IsRaidTarget이 그 두 등급에 포획과 1v1을 둘 다 막는다. @@ -653,6 +1021,7 @@ private static void WinRaidAgainst(string insectId) return; } d.Invoke(true); + CloseResultScreens(); if (director != null) director.NotifyBattlePresentationClosed(); } @@ -684,6 +1053,51 @@ private static bool SeatEnemy(InsectGame.Battle.InsectBattleController bc, strin return true; } + /// + /// 대사 직후 대결 런처를 멈춘다 — 대기열에는 넣되 진짜 전투는 열지 않는다. 걸음의 선행 채우기가 대치 비트를 + /// CompleteBeat로 채우는 순간마다 대결이 대기열에 들어오는데, 진짜로 열리면 전투 화면이 다음 행위를 막는다 + /// (SeatEnemy가 StartDuel을 피하는 것과 같은 이유). + /// + private static void SuspendDuelLauncher() + { + var launcher = UnityEngine.Object.FindFirstObjectByType(); + if (launcher == null) { Log("**StoryDuelLauncher 없음** — 대사 직후 대결이 배선되지 않았다"); return; } + launcher.Suspended = true; + Log("대사 직후 대결 런처 정지(대기열만 기록)"); + } + + /// 대치 대사가 끝난 뒤 그 상대의 대결이 대기열에 들어왔는가. + private static bool DuelQueued(string npcId) + { + var launcher = UnityEngine.Object.FindFirstObjectByType(); + bool queued = launcher != null && launcher.PendingNpcId == npcId; + if (!queued) + Log("**대사 직후 대결이 대기열에 없다** — 기대 " + npcId + ", 실제 " + + (launcher != null ? "'" + launcher.PendingNpcId + "'" : "런처 없음")); + return queued; + } + + /// + /// 간부 대결 승리를 실제 구독 경로로 흘린다 — NpcDuelController.BossDuelWon(StoryDirector의 DuelWin 소스). + /// WinBattleAgainst와 같은 방식이다: 이벤트를 밖에서 못 올리니 대리자를 꺼내 부르고, 구독이 비면 여기서 잡힌다. + /// 격파 기록은 적지 않는다 — 걸음이 보려는 건 승리 비트가 뜨는가이고, 기록은 걸음 뒤 원복 대상이다. + /// + private static void WinBossDuel(string npcId) + { + var duel = UnityEngine.Object.FindFirstObjectByType(); + if (duel == null) { Log("NpcDuelController 없음"); return; } + FieldInfo f = typeof(InsectGame.NPC.NpcDuelController) + .GetField("BossDuelWon", BindingFlags.Instance | BindingFlags.NonPublic); + var d = f != null ? f.GetValue(duel) as Action : null; + if (d == null) + { + Log("**BossDuelWon 구독자가 없다** — 간부 승리 비트(duel_*_win)가 영영 발화하지 않는다"); + return; + } + d.Invoke(npcId); + if (director != null) director.NotifyBattlePresentationClosed(); + } + private static void Cleanse(string boss, string regionId) { var blight = UnityEngine.Object.FindFirstObjectByType(); @@ -810,6 +1224,7 @@ private static void SeedPrerequisites(string beatId) private static void ResetProgress() { + SnapshotPlayerState(); // 지우기 전에 뜬다 — 두 번째 호출부터는 아무것도 안 한다 try { string path = SaveScope.FilePath(GameConstants.SaveFiles.StoryProgress); @@ -826,6 +1241,12 @@ private static void ResetProgress() // 키 문자열은 RegionManager.GuardianKey가 private이라 여기 한 번 더 적는다 — // 어긋나면 초기화가 조용히 아무것도 안 지운다. PlayerPrefs.DeleteKey(SaveScope.PrefsKey("InsectGame.DefeatedGuardians")); + // 간부 격파 기록 — 본편 걸음만 지운다. 남아 있으면 대치(ch12_confront)를 닫는 순간 자동 통과 + // (StoryDirector.ResweepPersistentConditions의 DuelWin 재확인)로 승리 비트가 먼저 떠, 대사 직후 대결 걸음이 + // 무엇을 검증했는지 흐려진다. 키는 NpcDuelController.DefeatedBossKey와 같은 문자열이다(스냅샷 목록에도 있다 — 원복된다). + if (IsCampaign) PlayerPrefs.DeleteKey(SaveScope.PrefsKey("InsectGame.DefeatedLedgerBosses")); + // 배지 이정표 수령 — 격파 기록을 지웠으니 함께 지워야 이정표 지급(4·8)이 다시 걸린다. + PlayerPrefs.DeleteKey(SaveScope.PrefsKey(GameConstants.PrefsKeys.BadgeMilestonesClaimed)); PlayerPrefs.Save(); if (director != null) @@ -837,6 +1258,10 @@ private static void ResetProgress() if (blight != null) blight.ReloadFromDisk(); var regions = UnityEngine.Object.FindFirstObjectByType(); if (regions != null) regions.ReloadFromDisk(); + var badgeService = UnityEngine.Object.FindFirstObjectByType(); + if (badgeService != null) badgeService.ReloadFromDisk(); + var duelController = UnityEngine.Object.FindFirstObjectByType(); + if (duelController != null) duelController.ReloadFromDisk(); Log("진행 기록 초기화 완료"); } @@ -854,11 +1279,13 @@ private static void Finish(string error) EditorApplication.update -= Tick; if (director != null) director.StoryBeatTriggered -= OnBeatFired; SessionState.SetString(StageKey, ""); + RestorePlayerState(); bool allPassed = true; var sb = new StringBuilder(); sb.AppendLine(IsCampaign ? "# 1막 본편 + 꽃밭 스토리 비트 — 실제 발화 걸음" + : IsTown ? "# 마을 이야기 + 지역 의뢰 + 따라가기 — 실제 발화 걸음" : "# 오염 거점 스토리 비트 — 실제 발화 걸음"); sb.AppendLine(); if (!string.IsNullOrEmpty(error)) @@ -866,7 +1293,7 @@ private static void Finish(string error) sb.AppendLine("> **중단**: " + error); sb.AppendLine(); } - sb.AppendLine((IsCampaign ? "본편" : "거점 " + SiteCount() + "개") + sb.AppendLine((IsCampaign ? "본편" : IsTown ? "마을 이야기" : "거점 " + SiteCount() + "개") + " · 걸음 " + (steps != null ? steps.Count : 0) + "개"); sb.AppendLine(); sb.AppendLine("| 구역 | 걸음 | 기대 비트 | 시도 | 결과 |"); diff --git a/Assets/Editor/VillageDesignCapture.cs b/Assets/Editor/VillageDesignCapture.cs new file mode 100644 index 0000000..aa81791 --- /dev/null +++ b/Assets/Editor/VillageDesignCapture.cs @@ -0,0 +1,112 @@ +#if UNITY_EDITOR +using System; +using System.IO; +using InsectGame.Core; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace InsectGame.EditorTools +{ + /// Isolated village geometry capture; no bootstrap, saves, or account services. + public static class VillageDesignCapture + { + private const string Pending = "InsectGame.VillageDesignCapture.Pending"; + + public static void Run() + { + if (!Application.isBatchMode) throw new InvalidOperationException("Use a separate batch editor."); + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + SessionState.SetBool(Pending, true); + EditorApplication.EnterPlaymode(); + } + + [InitializeOnLoadMethod] + private static void Resume() + { + if (SessionState.GetBool(Pending, false)) EditorApplication.update += Tick; + } + + private static void Tick() + { + if (!EditorApplication.isPlaying) return; + EditorApplication.update -= Tick; + SessionState.SetBool(Pending, false); + try { Capture(); EditorApplication.Exit(0); } + catch (Exception error) { Debug.LogException(error); EditorApplication.Exit(3); } + } + + private static void Capture() + { + string output = "Artifacts/village-design"; + string[] args = Environment.GetCommandLineArgs(); + for (int i = 0; i + 1 < args.Length; i++) + if (args[i] == "-villageCaptureOut") output = args[i + 1]; + if (Directory.Exists(output) && Directory.GetFileSystemEntries(output).Length > 0) + throw new IOException("Capture directory must be empty."); + Directory.CreateDirectory(output); + UnityEngine.Random.InitState(8173); + var regions = RegionDefinitions.CreateAll(); + new GameObject("VillageCaptureHost").AddComponent().Build(regions); + RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat; + RenderSettings.ambientLight = new Color(.48f, .50f, .53f); + Light key = new GameObject("Key").AddComponent(); + key.type = LightType.Directional; + key.intensity = 1.15f; + key.transform.rotation = Quaternion.Euler(48f, -30f, 0f); + GameObject ground = GameObject.CreatePrimitive(PrimitiveType.Plane); + ground.transform.localScale = Vector3.one * 250f; + ground.GetComponent().material.color = new Color(.30f, .40f, .29f); + Camera camera = new GameObject("CaptureCamera").AddComponent(); + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = new Color(.13f, .19f, .23f); + camera.nearClipPlane = .1f; + camera.farClipPlane = 160f; + camera.fieldOfView = 50f; + Transform main = GameObject.Find("MainVillage").transform; + Shot(camera, main.position, new Vector3(0, 29, 32), output, "village_overview"); + foreach (string name in new[] { "House_1", "Shop", "Hospital", "TrainingHall", "GachaHut" }) + { + Transform building = main.Find(name); + if (building == null) throw new InvalidOperationException("Missing " + name); + Vector3 target = building.position + Vector3.up * 1.8f; + Vector3 offset = building.TransformDirection(new Vector3(7, 4, 11)); + Shot(camera, target, offset, output, name); + } + foreach (string id in new[] { "forest", "swamp", "mountain" }) + { + Transform outpost = GameObject.Find("Outpost_" + id).transform; + Shot(camera, outpost.position + Vector3.up * 1.4f, + outpost.TransformDirection(new Vector3(7, 5, 13)), output, "outpost_" + id); + } + File.WriteAllText(Path.Combine(output, "README.txt"), + "Seed 8173. Actual VillageBuilder geometry, fixed lighting/cameras. Isolated from saves. Excludes NPCs, IMGUI, terrain clutter, input and Android performance."); + } + + private static void Shot(Camera camera, Vector3 target, Vector3 offset, string output, string name) + { + camera.transform.position = target + offset; + camera.transform.LookAt(target); + RenderTexture rt = RenderTexture.GetTemporary(1280, 800, 24); + RenderTexture previous = RenderTexture.active; + Texture2D pixels = new Texture2D(1280, 800, TextureFormat.RGB24, false); + try + { + camera.targetTexture = rt; + camera.Render(); + RenderTexture.active = rt; + pixels.ReadPixels(new Rect(0, 0, 1280, 800), 0, 0); + pixels.Apply(); + File.WriteAllBytes(Path.Combine(output, name + ".png"), pixels.EncodeToPNG()); + } + finally + { + camera.targetTexture = null; + RenderTexture.active = previous; + RenderTexture.ReleaseTemporary(rt); + UnityEngine.Object.Destroy(pixels); + } + } + } +} +#endif diff --git a/Assets/Editor/VillageDesignCapture.cs.meta b/Assets/Editor/VillageDesignCapture.cs.meta new file mode 100644 index 0000000..b5986b0 --- /dev/null +++ b/Assets/Editor/VillageDesignCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: debcb7ce19e47cb4cab1c6721cb658ce \ No newline at end of file diff --git a/Assets/Editor/WorldMapDesignCapture.cs b/Assets/Editor/WorldMapDesignCapture.cs new file mode 100644 index 0000000..ee74135 --- /dev/null +++ b/Assets/Editor/WorldMapDesignCapture.cs @@ -0,0 +1,166 @@ +#if UNITY_EDITOR +using System; +using System.IO; +using System.Reflection; +using System.Collections.Generic; +using InsectGame.Core; +using InsectGame.Data; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace InsectGame.EditorTools +{ + /// Actual combined ground/scenery/buildings without invoking account or save systems. + public static class WorldMapDesignCapture + { + private const string Pending = "InsectGame.WorldMapDesignCapture.Pending"; + private static Camera camera; + private static RegionData[] regions; + private static double builtAt; + private static string output; + + public static void Run() + { + if (!Application.isBatchMode) throw new InvalidOperationException("Requires a separate batch editor."); + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + SessionState.SetBool(Pending, true); + EditorApplication.EnterPlaymode(); + } + + [InitializeOnLoadMethod] + private static void Resume() + { + if (SessionState.GetBool(Pending, false)) EditorApplication.update += Tick; + } + + private static void Tick() + { + if (!EditorApplication.isPlaying) return; + try + { + if (camera == null) { Build(); return; } + if (EditorApplication.timeSinceStartup - builtAt < 1) return; + EditorApplication.update -= Tick; + SessionState.SetBool(Pending, false); + AuditRoutes(); + foreach (RegionData region in regions) + Shot(region.centerPosition, new Vector3(.15f, 1.15f, .72f) * region.radius, region.regionId); + Vector3 village = VillageBuilder.GetMainVillageCenter(regions[0].centerPosition, regions[0].radius); + Shot(village, new Vector3(0, 29, 32), "village_full_field"); + Shot(Vector3.Lerp(regions[0].centerPosition, regions[1].centerPosition, .58f), new Vector3(0, 33, 22), "meadow_pond_crossing"); + GameObject bridge = GameObject.Find("Bridge_PondRiver_Floor"); + if (bridge != null) Shot(bridge.transform.position, new Vector3(12, 16, 12), "pond_bridge_detail"); + foreach (string regionId in new[] { "meadow", "pond", "ruins" }) + { + RegionData region = WorldRouteLayout.Find(regions, regionId); + Vector3 gateway = WorldRouteLayout.GetGateway(region, regions); + Vector3 outward = (gateway - region.centerPosition).normalized; + Vector3 target = gateway + Vector3.Cross(Vector3.up, outward) * 3.4f + Vector3.up * 2.05f; + Shot(target, -outward * 8f + Vector3.up * 2.2f, regionId + "_sign_inside"); + Shot(target, outward * 8f + Vector3.up * 2.2f, regionId + "_sign_outside"); + } + File.WriteAllText(Path.Combine(output, "README.txt"), + "Actual EnsureGround (terrain/scenery/subarea entries) plus VillageBuilder, seed8173. Bootstrap remains inactive: no BuildSystems, accounts, saves, NPCs or gameplay. Fixed overview cameras; excludes IMGUI, physical traversal and Android performance."); + EditorApplication.Exit(0); + } + catch (Exception error) + { + SessionState.SetBool(Pending, false); + EditorApplication.update -= Tick; + Debug.LogException(error); + EditorApplication.Exit(3); + } + } + + private static void Build() + { + output = "Artifacts/world-map"; + string[] args = Environment.GetCommandLineArgs(); + for (int i = 0; i + 1 < args.Length; i++) if (args[i] == "-mapCaptureOut") output = args[i + 1]; + if (Directory.Exists(output) && Directory.GetFileSystemEntries(output).Length > 0) + throw new IOException("Capture directory must be empty."); + Directory.CreateDirectory(output); + UnityEngine.Random.InitState(8173); + GameObject host = new GameObject("InactiveGroundOnlyFixture"); + host.SetActive(false); // Never run Bootstrap.Awake -> BuildSystems. + var bootstrap = host.AddComponent(); + typeof(PlaySceneBootstrap).GetMethod("EnsureGround", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(bootstrap, null); + string error = (string)typeof(PlaySceneBootstrap).GetField("groundError", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(bootstrap); + if (!string.IsNullOrEmpty(error)) throw new InvalidOperationException(error); + regions = RegionDefinitions.CreateAll(); + new GameObject("VillageHost").AddComponent().Build(regions); + RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat; + RenderSettings.ambientLight = new Color(.40f, .44f, .48f); + Light light = new GameObject("CaptureKey").AddComponent(); + light.type = LightType.Directional; + light.intensity = 1.0f; + light.transform.rotation = Quaternion.Euler(48, -30, 0); + camera = new GameObject("MapCaptureCamera").AddComponent(); + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = new Color(.12f, .19f, .23f); + camera.nearClipPlane = .1f; + camera.farClipPlane = 1300f; + camera.fieldOfView = 65f; + builtAt = EditorApplication.timeSinceStartup; + } + + private static void Shot(Vector3 target, Vector3 offset, string name) + { + camera.transform.position = target + offset; + camera.transform.LookAt(target); + RenderTexture rt = RenderTexture.GetTemporary(1440, 960, 24); + RenderTexture previous = RenderTexture.active; + var pixels = new Texture2D(1440, 960, TextureFormat.RGB24, false); + try + { + camera.targetTexture = rt; + camera.Render(); + RenderTexture.active = rt; + pixels.ReadPixels(new Rect(0, 0, 1440, 960), 0, 0); + pixels.Apply(); + File.WriteAllBytes(Path.Combine(output, name + ".png"), pixels.EncodeToPNG()); + } + finally + { + camera.targetTexture = null; + RenderTexture.active = previous; + RenderTexture.ReleaseTemporary(rt); + UnityEngine.Object.Destroy(pixels); + } + } + + private static void AuditRoutes() + { + Physics.SyncTransforms(); + using (var report = new StreamWriter(Path.Combine(output, "route-collisions.tsv"))) + { + report.WriteLine("route\tsamples\tblockedSamples\tobstacles"); + foreach (WorldRouteEdge edge in WorldRouteLayout.FieldConnections) + { + Vector3[] route = WorldRouteLayout.BuildRoute(regions, edge.FromRegionId, edge.ToRegionId); + int samples = 0, blocked = 0; + var obstacles = new HashSet(); + for (int segment = 1; segment < route.Length; segment++) + { + int steps = Mathf.CeilToInt(Vector3.Distance(route[segment - 1], route[segment]) / .75f); + for (int step = 0; step <= steps; step++) + { + Vector3 point = Vector3.Lerp(route[segment - 1], route[segment], (float)step / Mathf.Max(1, steps)); + point.y = 1.48f; + bool hit = false; + foreach (Collider collider in Physics.OverlapSphere(point, .4f)) + { + if (collider.isTrigger || collider.bounds.size.y < .25f) continue; + hit = true; obstacles.Add(collider.name); + } + samples++; if (hit) blocked++; + } + } + report.WriteLine($"{edge.FromRegionId}-{edge.ToRegionId}\t{samples}\t{blocked}\t{string.Join(",", obstacles)}"); + } + } + } + } +} +#endif diff --git a/Assets/Editor/WorldMapDesignCapture.cs.meta b/Assets/Editor/WorldMapDesignCapture.cs.meta new file mode 100644 index 0000000..16ddc83 --- /dev/null +++ b/Assets/Editor/WorldMapDesignCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 069593a3d52ab1b4ea8b0014be098d58 \ No newline at end of file diff --git a/Assets/Resources/Audio/Dream.meta b/Assets/Resources/Audio/Dream.meta new file mode 100644 index 0000000..15342fd --- /dev/null +++ b/Assets/Resources/Audio/Dream.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b72e732841eee8549b508eca9406bf29 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Audio/Dream/dream_arena.wav b/Assets/Resources/Audio/Dream/dream_arena.wav new file mode 100644 index 0000000..b39c125 Binary files /dev/null and b/Assets/Resources/Audio/Dream/dream_arena.wav differ diff --git a/Assets/Resources/Audio/Dream/dream_arena.wav.meta b/Assets/Resources/Audio/Dream/dream_arena.wav.meta new file mode 100644 index 0000000..bf34735 --- /dev/null +++ b/Assets/Resources/Audio/Dream/dream_arena.wav.meta @@ -0,0 +1,23 @@ +fileFormatVersion: 2 +guid: e0ed75f541ed3d240942a49be01c054b +AudioImporter: + externalObjects: {} + serializedVersion: 8 + defaultSettings: + serializedVersion: 2 + loadType: 0 + sampleRateSetting: 0 + sampleRateOverride: 44100 + compressionFormat: 1 + quality: 1 + conversionMode: 0 + preloadAudioData: 0 + platformSettingOverrides: {} + forceToMono: 0 + normalize: 1 + loadInBackground: 0 + ambisonic: 0 + 3D: 1 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Audio/Opening/opening_prologue.wav b/Assets/Resources/Audio/Opening/opening_prologue.wav new file mode 100644 index 0000000..5fba6b9 Binary files /dev/null and b/Assets/Resources/Audio/Opening/opening_prologue.wav differ diff --git a/Assets/Resources/Audio/Opening/opening_prologue.wav.meta b/Assets/Resources/Audio/Opening/opening_prologue.wav.meta new file mode 100644 index 0000000..94be1a2 --- /dev/null +++ b/Assets/Resources/Audio/Opening/opening_prologue.wav.meta @@ -0,0 +1,23 @@ +fileFormatVersion: 2 +guid: ebec0602f798bee4480dc79dffa0a355 +AudioImporter: + externalObjects: {} + serializedVersion: 8 + defaultSettings: + serializedVersion: 2 + loadType: 0 + sampleRateSetting: 0 + sampleRateOverride: 44100 + compressionFormat: 1 + quality: 1 + conversionMode: 0 + preloadAudioData: 0 + platformSettingOverrides: {} + forceToMono: 0 + normalize: 1 + loadInBackground: 0 + ambisonic: 0 + 3D: 1 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/CharacterAppearanceConfig.asset b/Assets/Resources/CharacterAppearanceConfig.asset index d597b79..e23af91 100644 --- a/Assets/Resources/CharacterAppearanceConfig.asset +++ b/Assets/Resources/CharacterAppearanceConfig.asset @@ -83,15 +83,15 @@ MonoBehaviour: - tool_magnify - displayName: "\uC9C1\uC811 \uB9CC\uB4E4\uAE30" gender: 0 - hairStyle: 0 + hairStyle: 1 hairColor: 0 faceType: 0 skinColor: 1 outfitItemIds: - - hat_cap - - top_shirt - - outer_jacket + - hat_none + - top_polo + - outer_none - bot_pants - - shoe_boots + - shoe_sneakers - bag_basic - tool_net diff --git a/Assets/Resources/InsectLore.json b/Assets/Resources/InsectLore.json index 37f25dd..0b6e93d 100644 --- a/Assets/Resources/InsectLore.json +++ b/Assets/Resources/InsectLore.json @@ -700,7 +700,7 @@ "insectId": "bee_queen", "displayName": "여왕벌", "description": "수만 마리 벌 가족을 이끄는 벌집의 주인님이에요. 일벌보다 훨씬 크고 늘씬한 몸으로 평생 알 낳는 일에 전념하고, 충직한 일벌들이 사방에서 호위하며 로열젤리를 바친답니다.", - "habitatHint": "화창한 초여름 낮, 꽃밭 벌집 근처", + "habitatHint": "화창한 초여름 낮, 꽃이 가득한 들판과 정원의 벌집 근처", "spritePath": "Sprites/Insects/bee_queen", "rewardCoins": 48 }, @@ -812,7 +812,7 @@ "insectId": "cricket_stone", "displayName": "돌귀뚜라미", "description": "돌 틈에 숨어 살며 해 질 녘이면 골짜기에 울음소리를 메아리로 울리는 귀뚜라미예요. 바위산의 찬 공기에도 끄떡없는 강인한 몸을 지녀, 높은 산에서도 씩씩하게 노래한답니다.", - "habitatHint": "해 질 녘, 산비탈 돌무더기 틈새", + "habitatHint": "해 질 녘, 산비탈이나 무너진 유적의 돌무더기 틈새", "spritePath": "Sprites/Insects/cricket_stone", "rewardCoins": 7 }, @@ -820,7 +820,7 @@ "insectId": "pill_bug_rock", "displayName": "바위공벌레", "description": "잔돌이 굴러떨어져도 끄떡없는 단단한 갑옷을 두른 산악 공벌레예요. 몸을 말면 진짜 조약돌과 구별이 안 될 만큼 똑같아서, 산새들도 번번이 속아 넘어간답니다.", - "habitatHint": "시간을 가리지 않고, 산길 바위 밑과 자갈 틈", + "habitatHint": "시간을 가리지 않고, 산길과 무너진 돌 틈의 바위 밑", "spritePath": "Sprites/Insects/pill_bug_rock", "rewardCoins": 6 }, @@ -892,7 +892,7 @@ "insectId": "cicada_ancient", "displayName": "태고매미", "description": "수십 년, 어쩌면 그보다 훨씬 오래 땅속에서 잠들었다가 깨어난다는 전설의 매미예요. 이 매미의 첫 울음소리를 들은 사람에게는 아주 큰 행운이 찾아온다는 이야기가 산마을에 전해진답니다.", - "habitatHint": "산안개 걷히는 새벽, 아주 오래된 거목 줄기", + "habitatHint": "안개 걷히는 새벽, 산과 들, 눈길가의 아주 오래된 거목 줄기", "spritePath": "Sprites/Insects/cicada_ancient", "rewardCoins": 100 }, @@ -964,7 +964,7 @@ "insectId": "scarab_pharaoh", "displayName": "파라오풍뎅이", "description": "먼 옛날 태양을 하늘 저편으로 굴렸다고 전해지는 전설의 성스러운 풍뎅이예요. 등껍질에 태양 문양이 금빛으로 새겨져 있어, 옛 왕들은 이 풍뎅이를 왕관보다 귀한 보물로 모셨답니다.", - "habitatHint": "동이 트는 새벽, 제단 위 첫 햇살이 닿는 자리", + "habitatHint": "동이 트는 새벽, 제단과 모래언덕에 첫 햇살이 닿는 자리", "spritePath": "Sprites/Insects/scarab_pharaoh", "rewardCoins": 120 }, @@ -972,7 +972,7 @@ "insectId": "butterfly_midnight", "displayName": "그믐황후나비", "description": "달빛이 제단에 닿는 자정에만 모습을 드러낸다는 밤의 황후예요. 깊은 남색 날개에는 별가루 같은 은빛 점이 흩뿌려져 있어, 날아오르면 밤하늘 한 조각이 움직이는 것처럼 보인답니다.", - "habitatHint": "달빛 밝은 자정, 고대 제단 위", + "habitatHint": "달빛 밝은 자정, 고대 제단 위나 인적 끊긴 꽃밭과 들판", "spritePath": "Sprites/Insects/butterfly_midnight", "rewardCoins": 110 }, @@ -980,7 +980,7 @@ "insectId": "hornet_emperor", "displayName": "황제말벌", "description": "폐허의 하늘을 다스리는 말벌들의 황제예요. 날갯소리만으로 다른 곤충들이 몸을 숨길 만큼 위엄이 넘치고, 한 번 정한 영역은 그 어떤 침입자에게도 내주지 않는답니다.", - "habitatHint": "구름 낀 낮, 유적 첨탑 꼭대기 하늘", + "habitatHint": "구름 낀 낮, 유적 첨탑이나 잿불 골짜기 위 하늘", "spritePath": "Sprites/Insects/hornet_emperor", "rewardCoins": 105 }, diff --git a/Assets/Resources/ShaderVariantKeepers.meta b/Assets/Resources/ShaderVariantKeepers.meta new file mode 100644 index 0000000..4921b30 --- /dev/null +++ b/Assets/Resources/ShaderVariantKeepers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b1ae954fddcd90d438998c873377f19a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/ShaderVariantKeepers/StandardEmission.mat b/Assets/Resources/ShaderVariantKeepers/StandardEmission.mat new file mode 100644 index 0000000..e81f3f2 --- /dev/null +++ b/Assets/Resources/ShaderVariantKeepers/StandardEmission.mat @@ -0,0 +1,85 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: StandardEmission + m_Shader: {fileID: 46, guid: 0000000000000000f000000000000000, type: 0} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: + - _EMISSION + m_InvalidKeywords: [] + m_LightmapFlags: 0 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: -1 + stringTagMap: {} + disabledShaderPasses: [] + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _BumpMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailAlbedoMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailMask: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailNormalMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _EmissionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MetallicGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _OcclusionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _ParallaxMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _BumpScale: 1 + - _Cutoff: 0.5 + - _DetailNormalMapScale: 1 + - _DstBlend: 0 + - _GlossMapScale: 1 + - _Glossiness: 0.5 + - _GlossyReflections: 1 + - _Metallic: 0 + - _Mode: 0 + - _OcclusionStrength: 1 + - _Parallax: 0.02 + - _SmoothnessTextureChannel: 0 + - _SpecularHighlights: 1 + - _SrcBlend: 1 + - _UVSec: 0 + - _ZWrite: 1 + m_Colors: + - _Color: {r: 1, g: 1, b: 1, a: 1} + - _EmissionColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/Assets/Resources/ShaderVariantKeepers/StandardEmission.mat.meta b/Assets/Resources/ShaderVariantKeepers/StandardEmission.mat.meta new file mode 100644 index 0000000..dee4755 --- /dev/null +++ b/Assets/Resources/ShaderVariantKeepers/StandardEmission.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7ac84284b482b904ca0bdf361c2b8d42 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/ShaderVariantKeepers/StandardFade.mat b/Assets/Resources/ShaderVariantKeepers/StandardFade.mat new file mode 100644 index 0000000..a943012 --- /dev/null +++ b/Assets/Resources/ShaderVariantKeepers/StandardFade.mat @@ -0,0 +1,86 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: StandardFade + m_Shader: {fileID: 46, guid: 0000000000000000f000000000000000, type: 0} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: + - _ALPHABLEND_ON + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: 3000 + stringTagMap: + RenderType: Transparent + disabledShaderPasses: [] + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _BumpMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailAlbedoMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailMask: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailNormalMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _EmissionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MetallicGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _OcclusionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _ParallaxMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _BumpScale: 1 + - _Cutoff: 0.5 + - _DetailNormalMapScale: 1 + - _DstBlend: 10 + - _GlossMapScale: 1 + - _Glossiness: 0.5 + - _GlossyReflections: 1 + - _Metallic: 0 + - _Mode: 2 + - _OcclusionStrength: 1 + - _Parallax: 0.02 + - _SmoothnessTextureChannel: 0 + - _SpecularHighlights: 1 + - _SrcBlend: 5 + - _UVSec: 0 + - _ZWrite: 0 + m_Colors: + - _Color: {r: 1, g: 1, b: 1, a: 1} + - _EmissionColor: {r: 0, g: 0, b: 0, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/Assets/Resources/ShaderVariantKeepers/StandardFade.mat.meta b/Assets/Resources/ShaderVariantKeepers/StandardFade.mat.meta new file mode 100644 index 0000000..8514ad0 --- /dev/null +++ b/Assets/Resources/ShaderVariantKeepers/StandardFade.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a2a921cc0b7ad6d4982ce1454d9ebf84 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/ShaderVariantKeepers/StandardFadeEmission.mat b/Assets/Resources/ShaderVariantKeepers/StandardFadeEmission.mat new file mode 100644 index 0000000..07ad1ab --- /dev/null +++ b/Assets/Resources/ShaderVariantKeepers/StandardFadeEmission.mat @@ -0,0 +1,87 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: StandardFadeEmission + m_Shader: {fileID: 46, guid: 0000000000000000f000000000000000, type: 0} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: + - _ALPHABLEND_ON + - _EMISSION + m_InvalidKeywords: [] + m_LightmapFlags: 0 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: 3000 + stringTagMap: + RenderType: Transparent + disabledShaderPasses: [] + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _BumpMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailAlbedoMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailMask: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailNormalMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _EmissionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MetallicGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _OcclusionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _ParallaxMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _BumpScale: 1 + - _Cutoff: 0.5 + - _DetailNormalMapScale: 1 + - _DstBlend: 10 + - _GlossMapScale: 1 + - _Glossiness: 0.5 + - _GlossyReflections: 1 + - _Metallic: 0 + - _Mode: 2 + - _OcclusionStrength: 1 + - _Parallax: 0.02 + - _SmoothnessTextureChannel: 0 + - _SpecularHighlights: 1 + - _SrcBlend: 5 + - _UVSec: 0 + - _ZWrite: 0 + m_Colors: + - _Color: {r: 1, g: 1, b: 1, a: 1} + - _EmissionColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/Assets/Resources/ShaderVariantKeepers/StandardFadeEmission.mat.meta b/Assets/Resources/ShaderVariantKeepers/StandardFadeEmission.mat.meta new file mode 100644 index 0000000..b728abd --- /dev/null +++ b/Assets/Resources/ShaderVariantKeepers/StandardFadeEmission.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 623e64dff04615c47a77fa8275c01dd5 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/ShaderVariantKeepers/StandardOpaque.mat b/Assets/Resources/ShaderVariantKeepers/StandardOpaque.mat new file mode 100644 index 0000000..443a145 --- /dev/null +++ b/Assets/Resources/ShaderVariantKeepers/StandardOpaque.mat @@ -0,0 +1,84 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: StandardOpaque + m_Shader: {fileID: 46, guid: 0000000000000000f000000000000000, type: 0} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: -1 + stringTagMap: {} + disabledShaderPasses: [] + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _BumpMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailAlbedoMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailMask: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailNormalMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _EmissionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MetallicGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _OcclusionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _ParallaxMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _BumpScale: 1 + - _Cutoff: 0.5 + - _DetailNormalMapScale: 1 + - _DstBlend: 0 + - _GlossMapScale: 1 + - _Glossiness: 0.5 + - _GlossyReflections: 1 + - _Metallic: 0 + - _Mode: 0 + - _OcclusionStrength: 1 + - _Parallax: 0.02 + - _SmoothnessTextureChannel: 0 + - _SpecularHighlights: 1 + - _SrcBlend: 1 + - _UVSec: 0 + - _ZWrite: 1 + m_Colors: + - _Color: {r: 1, g: 1, b: 1, a: 1} + - _EmissionColor: {r: 0, g: 0, b: 0, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/Assets/Resources/ShaderVariantKeepers/StandardOpaque.mat.meta b/Assets/Resources/ShaderVariantKeepers/StandardOpaque.mat.meta new file mode 100644 index 0000000..510245b --- /dev/null +++ b/Assets/Resources/ShaderVariantKeepers/StandardOpaque.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b036a68b16544f449afb748b07b74bda +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Story.json b/Assets/Resources/Story.json index e333072..44abe98 100644 --- a/Assets/Resources/Story.json +++ b/Assets/Resources/Story.json @@ -12,21 +12,31 @@ }, "speakerNpcId": "village_elder", "lines": [ + { + "speaker": "지문", + "text": "초원 어귀. 풀벌레 소리가 하나도 들리지 않는다." + }, { "speaker": "마을 어르신", - "text": "오, 드디어 왔구나! 이 초원에 새 곤충 친구를 찾으러 온 게로군." + "text": "오, 드디어 왔구나! 들리니? 이 조용함이.", + "fx": "pause" }, { "speaker": "마을 어르신", - "text": "그런데 말이다… 요즘 초원의 곤충들이 하나둘 자취를 감추고 있어. 어제 있던 아이가 오늘은 보이질 않는단다." + "text": "요즘 곤충들이 하나둘 사라지고 있어." }, { "speaker": "마을 어르신", - "text": "맨손으로는 시작할 수 없지. 네가 고른 그 아이를 데려가거라 — 오래전 나도 저런 친구와 함께 걸었단다. 은빛 그물도 함께 받으렴." + "text": "소리가 잦아들듯 사라진다고 '잦아듦'이라 부른단다.", + "fx": "slow" + }, + { + "speaker": "지문", + "text": "어르신이 은빛 그물 두 장을 건넨다." }, { "speaker": "마을 어르신", - "text": "그 아이와 함께 풀밭으로 나가 보렴. 곤충을 만나 기록하는 법은, 직접 해 보는 게 가장 빠르단다." + "text": "네가 고른 그 아이와 함께, 곤충을 만나 도감에 적어 주렴." } ], "choices": [], @@ -40,8 +50,10 @@ "rewardInsectLevel": 6, "unlockQuestId": "" }, - "cutsceneId": "cs_story_prologue", - "stageEnterId": "st_ch1_elder_greet" + "cutsceneId": "", + "stageEnterId": "st_ch1_elder_greet", + "videoId": "vid_ch1_prologue", + "why": "곤충이 사라지는 까닭을 들어 보자" }, { "beatId": "ch1_first_capture", @@ -57,15 +69,15 @@ "lines": [ { "speaker": "마을 어르신", - "text": "훌륭해! 이 초원에서 곤충을 거둬 왔구나. 네 손끝엔 정말 소질이 있어." + "text": "훌륭해! 첫 곤충을 만났구나." }, { "speaker": "마을 어르신", - "text": "그 아이의 모습을 도감에 새겼으니, 이제 쉽게 사라지지 않을 게다. 이게 바로 우리가 할 수 있는 일이란다." + "text": "도감에 적힌 곤충은 쉽게 사라지지 않는단다." }, { "speaker": "마을 어르신", - "text": "예전엔 이 풀밭이 곤충들로 북적였는데… 지금은 너무 조용해. 부디 더 많은 친구들을 기록해 주렴." + "text": "초원이 너무 조용해. 친구를 더 만나 주렴." } ], "choices": [], @@ -78,7 +90,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "사라지기 전에 도감에 적자" }, { "beatId": "ch1_rival_intro", @@ -92,17 +105,35 @@ "requiredRegionId": "meadow", "speakerNpcId": "catcher_rival", "lines": [ + { + "speaker": "지문", + "text": "풀숲이 갈라지더니 아이 하나가 튀어나온다.", + "fx": "shake" + }, + { + "speaker": "라온", + "text": "야, 네가 요즘 소문난 그 채집가구나!" + }, + { + "speaker": "라온", + "text": "난 라온. 나도 사라지는 곤충들을 쫓고 있어." + }, + { + "speaker": "라온", + "text": "방금 그거 한 번에 잡았지? 난 사흘 걸렸는데." + }, { "speaker": "라온", - "text": "야, 너였구나. 요즘 초원을 헤집고 다닌다는 채집가가." + "text": "내 여치도 만만치 않아. 누가 센지 겨뤄 보자!" }, { "speaker": "라온", - "text": "난 라온. 나도 곤충들이 사라지는 걸 쫓고 있었어. …인정할게, 너 실력 좋더라." + "text": "끝나면 누가 먼저 세 마리 잡나 내기도 하고." }, { "speaker": "라온", - "text": "좋아, 승부다. 누가 더 많은 친구를 구해내는지. 뒤처지지나 마!" + "text": "좋아, 승부다!", + "fx": "shout" } ], "choices": [], @@ -117,7 +148,8 @@ "unlockQuestId": "" }, "stageEnterId": "st_ch1_rival_enter", - "stageExitId": "st_ch1_rival_exit" + "why": "초원에 친구가 더 있을 거야", + "duelAfter": "catcher_rival" }, { "beatId": "ch1_growth", @@ -132,15 +164,20 @@ "lines": [ { "speaker": "마을 어르신", - "text": "곤충은 사탕을 먹여 키운단다. 강해진 친구는 더 험한 곳에서도 널 지켜주지." + "text": "걸음이 단단해졌구나. 걸은 만큼 자라는 법이지." + }, + { + "speaker": "마을 어르신", + "text": "곤충 친구는 캔디를 먹여 키운단다." }, { "speaker": "마을 어르신", - "text": "…네가 더 강해져야 할 이유가 있어. 이 모든 사라짐의 뿌리는 저 멀리 고대 유적에 있다는 오랜 이야기가 전해온단다." + "text": "옛이야기가 있어. 사라짐의 뿌리가 고대 유적에 있다고.", + "fx": "pause" }, { "speaker": "마을 어르신", - "text": "아직은 먼 이야기야. 지금은 눈앞의 친구들부터 돌보렴. 이 성장의 묘약을 받으렴 — 네 친구들이 한결 빨리 자랄 게다." + "text": "이 성장의 묘약을 받으렴. 친구들이 빨리 자랄 게다." } ], "choices": [], @@ -153,7 +190,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "더 강해져야 멀리 갈 수 있어" }, { "beatId": "ch1_first_battle", @@ -169,15 +207,25 @@ "lines": [ { "speaker": "라온", - "text": "방금 그 전투, 봤어. 제법인데?" + "text": "너 싸우는 거 처음 맞아? 제법인데?" + }, + { + "speaker": "라온", + "text": "근데 너무 몰아붙이진 마. 이기는 거랑 다치게 하는 건 달라." + }, + { + "speaker": "라온", + "text": "초원 끝 '수문장'을 이겨야 다음 땅으로 갈 수 있어." }, { "speaker": "라온", - "text": "이 초원 끝에는 '수문장'이 버티고 있어. 그 녀석을 넘어야 다음 땅으로 갈 수 있지." + "text": "나? …두 번 졌어. 두 번 다 풀밭에 코 박았지.", + "fx": "whisper" }, { "speaker": "라온", - "text": "사라짐의 실마리는 분명 초원 너머에 있을 거야. 준비되면 도전해 봐." + "text": "준비되면 도전해 봐. 내 몫까지!", + "fx": "shout" } ], "choices": [], @@ -190,7 +238,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "곤충과 겨루며 함께 강해지자" }, { "beatId": "ch1_guardian_call", @@ -206,15 +255,19 @@ "lines": [ { "speaker": "마을 어르신", - "text": "혼자선 수문장을 당해내지 못한다. 아끼는 친구들로 팀을 꾸리렴." + "text": "라온 녀석이 네 얘기를 한참 하더구나." + }, + { + "speaker": "마을 어르신", + "text": "수문장은 저마다 힘이 달라. 팀을 잘 꾸리렴." }, { "speaker": "마을 어르신", - "text": "요즘 그 수문장 사마귀가 몹시 사나워졌어. 곤충들이 사라지는 걸 저도 느끼는 게지… 불안해하는 게야." + "text": "사마귀 수문장도 곤충이 사라지는 게 불안했을 게다." }, { "speaker": "마을 어르신", - "text": "부디 조심히. 그리고 그 아이도 너무 상하게 하지 않도록. …자, 이 금빛 그물을 가져가렴. 네 손을 든든히 받쳐줄 게다." + "text": "이 금빛 그물을 받으렴. 네 손을 든든히 받쳐 줄 게다." } ], "choices": [], @@ -227,7 +280,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "수문장에 도전할 힘을 기르자" }, { "beatId": "ch1_guardian_win", @@ -241,16 +295,22 @@ "speakerNpcId": "village_elder", "lines": [ { - "speaker": "마을 어르신", - "text": "해냈구나! 수문장이 길을 열어 주었어. 이제 초원 너머로 갈 수 있단다." + "speaker": "지문", + "text": "수문장이 물러나고, 초원 너머로 길이 열렸다.", + "fx": "flash" + }, + { + "speaker": "라온", + "text": "이겼다고?! 내가 두 번 진 그 녀석을?", + "fx": "shout" }, { "speaker": "마을 어르신", - "text": "그런데… 쓰러진 그 아이가 마지막에 남긴 떨림이, 저 먼 유적 쪽을 향하고 있었어. 우연이 아닐 게다." + "text": "해냈구나! 이제 초원 너머로 갈 수 있단다." }, { "speaker": "마을 어르신", - "text": "사라짐의 뿌리가 정말 그곳이라면, 네 여행은 이제 시작인 셈이야. 연못과 꽃밭, 두 갈래 길이 열렸단다." + "text": "연못과 꽃밭, 두 갈래 길이 열렸어. 가 보렴." } ], "choices": [], @@ -263,7 +323,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "수문장을 넘어야 길이 열려" }, { "beatId": "ch1_reach_pond", @@ -276,13 +337,21 @@ }, "speakerNpcId": "village_elder", "lines": [ + { + "speaker": "지문", + "text": "공기가 축축해진다. 수면 위로 잠자리 하나가 스친다." + }, + { + "speaker": "지문", + "text": "떠나기 전 어르신이 해 준 말이 떠오른다." + }, { "speaker": "마을 어르신", - "text": "여기가 바로 연못이야. 물가엔 초원과는 전혀 다른 곤충들이 살지." + "text": "사라짐의 뿌리는 고대 유적에 있다는 옛이야기가 있지." }, { "speaker": "마을 어르신", - "text": "…하지만 여기도 조용하구나. 사라짐이 물가까지 번진 게야. 서둘러 이 아이들도 기록해 주렴. 여행의 다음 장이 시작되었단다." + "text": "가는 길마다 만나는 아이들을 도감에 적어 주렴." } ], "choices": [], @@ -295,7 +364,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "물가 곤충들도 사라지고 있대" }, { "beatId": "ch2_water", @@ -311,11 +381,20 @@ "lines": [ { "speaker": "라온", - "text": "물가 곤충들은 초원 애들이랑 또 다르지? 근데 여기도 예전 같지 않아 — 수가 확 줄었어." + "text": "야, 라온이야! 나도 사라지는 곤충을 쫓아왔어." + }, + { + "speaker": "라온", + "text": "할머니가 그러셨어. 여름 연못은 날개 소리로 시끄러웠다고." + }, + { + "speaker": "지문", + "text": "지금은 바람 소리뿐이다.", + "fx": "pause" }, { "speaker": "라온", - "text": "그래도 네가 이렇게 하나씩 거둬들일 때마다, 뭔가 조금은 되돌아오는 기분이야. 계속하자." + "text": "그래도 네가 하나씩 적을 때마다 뭔가 돌아오는 것 같아." } ], "choices": [], @@ -328,7 +407,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "연못 곤충도 도감에 적어 주자" }, { "beatId": "ch2_resolve", @@ -343,11 +423,15 @@ "lines": [ { "speaker": "마을 어르신", - "text": "여기까지 오다니, 이제 내가 가르칠 건 다 가르쳤구나. 넌 어엿한 채집가야." + "text": "여기까지 오다니, 이제 어엿한 채집가구나." + }, + { + "speaker": "마을 어르신", + "text": "사라짐의 뿌리는 아직 저 너머에 있어." }, { "speaker": "마을 어르신", - "text": "허나 사라짐의 진짜 뿌리는 아직 저 너머에 있어. 숲으로, 또 그 너머로 나아가렴. 답은 고대 유적에 있을 게다." + "text": "숲으로, 그 너머로 가 보렴. 답은 유적에 있을 게다." } ], "choices": [], @@ -360,7 +444,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "어르신의 과제를 끝내 보자" }, { "beatId": "ch3_reach_forest", @@ -373,17 +458,35 @@ }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "숲 그늘. 누군가 바위 앞에서 무늬를 베끼고 있다." + }, + { + "speaker": "세라", + "text": "아, 미안해요. 사라지는 곤충을 쫓는 채집가죠?", + "fx": "pause" + }, { "speaker": "세라", - "text": "당신이군요. 사라지는 곤충들을 쫓는다는 그 채집가가." + "text": "저는 세라예요. 고대 유적을 연구해요." }, { "speaker": "세라", - "text": "저는 세라, 고대 유적을 연구하고 있어요. 이 '잦아듦' 현상… 유적의 봉인에 금이 간 것과 결코 무관하지 않아요." + "text": "유적에는 곤충 이름이 잔뜩 새겨진 벽이 있대요." }, { "speaker": "세라", - "text": "당신이 모으는 기록 하나하나가, 어쩌면 그 균열을 메우는 열쇠일지도 몰라요." + "text": "그 이름이 바래면 곤충도 사라지는 것 같아요." + }, + { + "speaker": "세라", + "text": "그러니 당신이 도감에 적는 일이 중요할지 몰라요." + }, + { + "speaker": "세라", + "text": "…같이 가도 될까요? 숲이 너무 조용해서요.", + "fx": "pause" } ], "choices": [], @@ -396,7 +499,9 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "videoId": "vid_ch3_scholar", + "why": "사라짐의 실마리가 숲에 있을지도" }, { "beatId": "ch3_forest_carving", @@ -411,11 +516,20 @@ "lines": [ { "speaker": "세라", - "text": "이 각인… 유적의 것과 같은 문양이에요. 이 깊은 동굴까지 봉인의 흔적이 뻗어 있다니." + "text": "이 무늬… 유적의 벽에 있는 것과 같아요." + }, + { + "speaker": "지문", + "text": "세라가 무늬를 따라가자 한 줄이 잠깐 빛난다.", + "fx": "flash" + }, + { + "speaker": "세라", + "text": "이건 곤충 이름이에요. 옛날 글자로 새긴 거죠." }, { "speaker": "세라", - "text": "곤충과 이 땅은 아주 오래전부터 이어져 있었나 봐요. 계속 기록해요 — 실마리가 쌓이고 있어요." + "text": "곤충과 이 땅은 오래전부터 이어져 있었나 봐요." } ], "choices": [], @@ -428,7 +542,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "숲 동굴에 옛 무늬가 있대" }, { "beatId": "ch3_battle_insight", @@ -444,11 +559,16 @@ "lines": [ { "speaker": "세라", - "text": "방금 그 전투… 쓰러진 곤충이 사라지기 직전, 빛의 결이 유적 쪽으로 흘렀어요. 봤나요?" + "text": "방금 쓰러진 곤충에게서 빛이 흘렀어요. 봤어요?" + }, + { + "speaker": "라온", + "text": "빛? 난 그냥 반짝했다 싶었는데." }, { "speaker": "세라", - "text": "초원의 수문장도 그랬다죠. 우연이 아니에요 — 모든 곤충이 그 봉인과 이어져 있어요." + "text": "그 빛이 유적 쪽으로 갔어요. 곤충들이 유적과 이어져 있나 봐요.", + "fx": "pause" } ], "choices": [], @@ -461,7 +581,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "숲 곤충과 겨뤄 실마리를 찾자" }, { "beatId": "ch4_reach_swamp", @@ -474,17 +595,31 @@ }, "speakerNpcId": "catcher_rival", "lines": [ + { + "speaker": "지문", + "text": "발밑이 질척하다. 고인 물 냄새가 코를 찌른다." + }, { "speaker": "라온", - "text": "여기까지 왔구나. …이 습지, 숨 쉴 때마다 목이 칼칼해. 공기부터 무겁지 않아?" + "text": "여기 공기 무겁지 않아? 솔직히 좀 무섭다.", + "fx": "whisper" + }, + { + "speaker": "세라", + "text": "습지는 생명이 많은 곳이에요. 이렇게 조용하면 안 돼요." }, { "speaker": "라온", - "text": "솔직히 여긴 좀 무섭다. 그래도… 너 혼자 보내진 않을 거야." + "text": "그래도 너 혼자 보내진 않을 거야." + }, + { + "speaker": "세라", + "text": "저도요. 여럿이 적으면 더 정확하거든요." }, { "speaker": "라온", - "text": "이제부턴 나도, 세라도 함께야. 셋이 뭉치면 어디든 갈 수 있어." + "text": "그거 같이 가자는 말이지? 좋아, 이제 셋이 함께야!", + "fx": "shout" } ], "choices": [], @@ -497,7 +632,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "습지 곤충들도 위험하대" }, { "beatId": "ch4_decay", @@ -513,15 +649,20 @@ "lines": [ { "speaker": "세라", - "text": "방금 그 아이, 쓰러지며 사라지는 속도가 초원 때보다 훨씬 빨랐어요. 봉인의 금이 그만큼 벌어졌다는 뜻이에요." + "text": "방금 그 아이, 초원 때보다 빨리 사라졌어요." }, { "speaker": "세라", - "text": "이대론 당신이 거둔 아이들마저 위태로워요. 도감이 붙들고는 있지만… 그 힘도 영원하진 않아요." + "text": "벽의 이름이 그만큼 빨리 바래고 있다는 뜻이에요." + }, + { + "speaker": "라온", + "text": "그럼 도감에 적은 애들도… 언젠간 사라져?", + "fx": "pause" }, { "speaker": "세라", - "text": "서둘러야 해요. 근원을 막지 못하면, 지금 되돌린 것마저 다시 스러질 테니까요." + "text": "도감이 붙들고는 있지만 영원하진 않아요. 서둘러요." } ], "choices": [], @@ -534,7 +675,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "습지 곤충과 겨뤄 살펴보자" }, { "beatId": "ch4_bond", @@ -550,11 +692,19 @@ "lines": [ { "speaker": "라온", - "text": "예전엔 너랑 경쟁만 하려 했는데… 이젠 아니야. 이 일은 혼자 될 게 아니더라." + "text": "예전엔 너랑 경쟁만 하려 했는데, 이젠 아니야." + }, + { + "speaker": "라온", + "text": "이 일은 혼자서는 안 되더라. 내가 거들게." + }, + { + "speaker": "세라", + "text": "그럼 저는 만난 아이들을 하나하나 적을게요." }, { "speaker": "라온", - "text": "네가 한 마리씩 거둘 때 나도 곁에서 거들게. 세라도 함께야. 우리 셋이면 유적까지 갈 수 있어." + "text": "좋아. 우리 셋이면 유적까지 갈 수 있어." } ], "choices": [], @@ -567,7 +717,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "습지 친구들도 도감에 적자" }, { "beatId": "ch5_reach_mountain", @@ -580,17 +731,30 @@ }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "가파른 비탈. 돌 하나가 저 아래로 굴러떨어진다." + }, + { + "speaker": "라온", + "text": "헉헉… 세라, 넌 왜 나보다 안 지쳐?" + }, { "speaker": "세라", - "text": "산을 오를수록 유적이 가까워져요. 모든 사라짐의 근원이 바로 저 위에 있어요." + "text": "유적 연구는 반이 등산이에요." }, { "speaker": "세라", - "text": "이제 확신해요 — 당신이 곤충을 붙잡아 도감에 새길 때마다, 금 갔던 봉인이 조금씩 메워지고 있어요." + "text": "이제 알 것 같아요. 도감에 적을 때마다 벽이 되살아나요.", + "fx": "pause" + }, + { + "speaker": "라온", + "text": "그러니까 우리가 한 게 헛걸음이 아니었구나!" }, { "speaker": "세라", - "text": "당신의 기록이 곧 이 세계를 붙드는 힘이에요. 조금만 더 힘내요." + "text": "네. 이 산만 넘으면 유적이에요." } ], "choices": [], @@ -603,7 +767,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "유적으로 가는 길이 산 너머야" }, { "beatId": "ch5_summit", @@ -616,13 +781,35 @@ }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "산꼭대기. 구름이 발아래로 천천히 흐른다." + }, + { + "speaker": "라온", + "text": "와, 지나온 데가 다 보여! 저기가 초원, 저기가 연못…" + }, + { + "speaker": "세라", + "text": "저 아래 안개 너머로 빛나는 게 고대 유적이에요.", + "fx": "pause" + }, + { + "speaker": "세라", + "text": "여기까지 온 채집가는 당신이 처음이에요." + }, + { + "speaker": "라온", + "text": "나도 처음인데? 나도 세 줘." + }, { "speaker": "세라", - "text": "보여요? 저 아래, 안개 너머로 빛나는 게 고대 유적이에요." + "text": "…두 번째요.", + "fx": "pause" }, { "speaker": "세라", - "text": "여기까지 온 채집가는 당신이 처음이에요. …이제 마지막 장만 남았어요." + "text": "이제 마지막 장만 남았어요." } ], "choices": [], @@ -635,7 +822,9 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "videoId": "vid_ch5_summit", + "why": "산꼭대기에서 유적이 보인대" }, { "beatId": "ch5_thesis", @@ -651,15 +840,15 @@ "lines": [ { "speaker": "세라", - "text": "당신이 한 생명씩 도감에 더할 때마다, 나는 조금씩 믿게 돼요 — 이 세계가 아직 늦지 않았다고." + "text": "당신이 한 마리씩 적을 때마다 믿게 돼요." }, { "speaker": "세라", - "text": "숲에서 처음 봤을 땐 그저 실마리를 쫓는 채집가였는데… 이젠 당신이 마지막 희망이에요." + "text": "이 세상이 아직 늦지 않았다고요." }, { "speaker": "세라", - "text": "정상 너머, 유적에서 다 끝내요. 함께 가요." + "text": "유적에서 다 끝내요. 함께 가요." } ], "choices": [], @@ -672,7 +861,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "산 곤충들도 도감에 적자" }, { "beatId": "ch6_reach_ruins", @@ -683,15 +873,30 @@ "type": "RegionEnter", "param": "ruins" }, - "speakerNpcId": "village_elder", + "speakerNpcId": "ruins_scholar", "lines": [ { - "speaker": "마을 어르신", - "text": "…결국 여기까지 왔구나. 고대 유적, 모든 사라짐이 시작된 곳이야." + "speaker": "지문", + "text": "돌기둥이 늘어선 폐허. 바람이 낮게 운다.", + "fx": "dark" }, { - "speaker": "마을 어르신", - "text": "내가 흘리던 옛이야기가 이젠 네 눈앞의 현실이 되었어. 조심하렴 — 그리고 널 믿는다." + "speaker": "세라", + "text": "드디어 왔어요. 고대 유적이에요.", + "fx": "pause" + }, + { + "speaker": "라온", + "text": "생각보다 조용하다. 원래 이렇게 조용했어?", + "fx": "whisper" + }, + { + "speaker": "세라", + "text": "모르겠어요. 제가 왔을 땐 벽이 흙에 묻혀 있었어요." + }, + { + "speaker": "라온", + "text": "들어가자. 여기서 돌아가면 할머니한테 혼나." } ], "choices": [], @@ -704,7 +909,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "모든 사라짐이 시작된 곳이래" }, { "beatId": "ch6_approach", @@ -716,15 +922,24 @@ "param": "" }, "requiredRegionId": "ruins", - "speakerNpcId": "village_elder", + "speakerNpcId": "ruins_scholar", "lines": [ { - "speaker": "마을 어르신", - "text": "유적 안에선 한 걸음 한 걸음이 무겁구나. 방금 그 싸움에서도 공기가 떨리는 걸 느꼈어." + "speaker": "라온", + "text": "방금 그 녀석, 뭔가를 지키려는 눈이었어." }, { - "speaker": "마을 어르신", - "text": "두려워 말렴. 네가 여기까지 거둬 온 모든 생명이 널 지켜줄 게다. 진실은 바로 저 안쪽에 있어." + "speaker": "세라", + "text": "두려워 마요. 당신이 만난 친구들이 곁에 있어요." + }, + { + "speaker": "라온", + "text": "…나도 있고.", + "fx": "whisper" + }, + { + "speaker": "세라", + "text": "네, 라온도요. 답은 저 안쪽 신전에 있어요." } ], "choices": [], @@ -737,7 +952,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "유적 곤충과 겨뤄 길을 열자" }, { "beatId": "ch6_secret", @@ -752,15 +968,25 @@ "lines": [ { "speaker": "세라", - "text": "이곳이 봉인의 심장이에요. 보세요 — 벽을 가득 메운 곤충들의 문양을." + "text": "보세요! 사라졌던 고대의 잠자리예요!", + "fx": "shout" + }, + { + "speaker": "라온", + "text": "벽에서 나왔어! 이게 다 곤충 이름이야?" }, { "speaker": "세라", - "text": "봉인은 애초에 '기록'이었어요. 고대인들은 모든 곤충을 새겨 이 세계에 붙들어 두었죠. 세월에 그 기록이 바래며 종이 하나둘 지워지기 시작한 거예요." + "text": "네. 여기가 '이름 벽'이에요.", + "fx": "slow" + }, + { + "speaker": "라온", + "text": "그럼 잦아듦은 이름이 바래서 생긴 거였네!" }, { "speaker": "세라", - "text": "그리고 당신이 그 일을 다시 해냈어요. 당신의 도감이 봉인을 되살렸어요. …보세요, 사라졌던 고대의 잠자리가 당신에게 돌아왔어요." + "text": "맞아요. 그 빈칸을 당신 도감이 메웠어요." } ], "choices": [], @@ -774,7 +1000,8 @@ "rewardInsectLevel": 45, "unlockQuestId": "" }, - "cutsceneId": "cs_seal_discovery" + "introVideoId": "vid_ch6_wall", + "why": "신전 안쪽에 답이 있대" }, { "beatId": "ch7_opening", @@ -788,24 +1015,26 @@ "speakerNpcId": "ruins_scholar", "lines": [ { - "speaker": "세라", - "text": "신전의 기록은 다시 채워졌어요. 그런데… 왜 유적이 조용해지지 않죠?" + "speaker": "라온", + "text": "방금 봤어? 빈칸이 움직였어!", + "fx": "shout" }, { "speaker": "세라", - "text": "지하를 보세요. 벽의 문양 사이, 아무것도 새겨지지 않은 자리가 있어요. 빈칸이에요. 그게 방금… 움직였어요." + "text": "그림자가 이름 하나를 빼앗아 숨어 있던 거예요.", + "fx": "dark" }, { - "speaker": "세라", - "text": "봉인은 곤충을 지키려던 게 아니었어요. 이름 있는 것들로 세계를 빈틈없이 메워서, 이름 없는 것이 들어올 자리를 없앤 울타리였어요." + "speaker": "라온", + "text": "벽이 다 차서 숨을 데가 없으니 들킨 거구나!" }, { "speaker": "세라", - "text": "종이 하나 지워질 때마다 빈칸이 생겼고, 그리로 그것이 스며들었죠. 그리고 우리가 기록을 되살리는 순간 — 이미 들어와 있던 것이 안에 갇힌 거예요." + "text": "빼앗긴 이름을 돌려주면 그림자는 밖으로 밀려나요." }, { "speaker": "세라", - "text": "갇힌 건 나가려 해요. 유적 너머, 지도에서 지워진 땅으로. …쫓아가야 해요. 우리가 연 문이니까요." + "text": "그림자는 '텅 빈 들'로 갔어요. 따라가요!" } ], "choices": [], @@ -819,7 +1048,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "cutsceneId": "cs_seal_opening" + "introVideoId": "vid_ch7_fence", + "why": "유적 아래에서 울림이 들려" }, { "beatId": "ch7_arrive", @@ -833,20 +1063,25 @@ "speakerNpcId": "ruins_scholar", "lines": [ { - "speaker": "세라", - "text": "들어보세요. …아무 소리도 안 나죠?" + "speaker": "지문", + "text": "텅 빈 들. 마른 풀만 바람에 쓸린다." }, { - "speaker": "세라", - "text": "여긴 잦아듦이 가장 먼저 훑고 간 자리예요. 지도에는 분명 들판이라고 적혀 있는데, 남아 있는 건 마른 풀하고 쓰러진 울타리뿐이에요." + "speaker": "라온", + "text": "귀가 먹먹해. 소리가 없으니까 더 시끄러워.", + "fx": "whisper" }, { "speaker": "세라", - "text": "저 표석들 보이세요? 원래는 이름이 새겨져 있었을 거예요. 지금은 전부 빈칸이고요." + "text": "잦아듦이 가장 먼저 휩쓸고 간 곳이에요." + }, + { + "speaker": "라온", + "text": "저 돌들도 다 비었네. 들판 전체가 빈칸 같아." }, { "speaker": "세라", - "text": "여기서부터예요. 한 종씩 다시 만나고 다시 새겨요. 그게 우리가 할 수 있는 전부고, 동시에 전부 다예요." + "text": "한 종씩 다시 만나서 다시 적어요. 여기서부터예요." } ], "choices": [], @@ -859,7 +1094,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "그림자가 텅 빈 들로 갔어" }, { "beatId": "ch7_sign", @@ -875,11 +1111,20 @@ "lines": [ { "speaker": "라온", - "text": "네 수집이 늘 때마다 이 들판이 조금씩 소리를 되찾는 것 같아. 기분 탓인가?" + "text": "네가 적을 때마다 들판이 소리를 찾는 것 같아." + }, + { + "speaker": "라온", + "text": "근데 저기 검은 곤충이 자꾸 보여. 색이 하나도 없어." }, { "speaker": "라온", - "text": "…근데 아까부터 저기, 검은 그림자 하나가 자꾸 눈에 밟혀. 형태는 곤충인데 색이 없어. 쫓아가면 사라지고." + "text": "쫓아가면 사라져. 들키기 싫은 것처럼." + }, + { + "speaker": "세라", + "text": "저런 아이가 더 있을 거예요. 발밑을 잘 봐요.", + "fx": "whisper" } ], "choices": [], @@ -892,7 +1137,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "텅 빈 들에 곤충을 되찾자" }, { "beatId": "ch7_confront", @@ -905,21 +1151,41 @@ }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "침묵의 자리. 색도 무늬도 없는 곤충이 앉아 있다.", + "fx": "dark" + }, + { + "speaker": "라온", + "text": "저거야. 아까부터 보이던 검은 곤충.", + "fx": "whisper" + }, { "speaker": "세라", - "text": "…저기예요. 라온이 봤다던 그림자." + "text": "곤충은 맞아요. 그런데 색도, 무늬도 없어요." }, { "speaker": "세라", - "text": "곤충은 맞아요. 그런데 무늬가 없고, 색도 없고… 저 아이는 자기가 무엇인지 몰라요." + "text": "그림자에게 이름을 빼앗긴 '이름 잃은 곤충'이에요." + }, + { + "speaker": "지문", + "text": "검은 곤충이 고개를 든다. 눈이 있을 자리가 비어 있다.", + "fx": "shake" + }, + { + "speaker": "라온", + "text": "되돌릴 수 있는 거지? 그렇다고 해 줘." }, { "speaker": "세라", - "text": "이름을 빼앗긴 거예요. 그것이 자리를 얻는 방식이 이거예요 — 남의 이름을 가져가서 그 자리에 대신 서는 것." + "text": "있어요. 다시 만나서 도감에 적으면 돼요." }, { "speaker": "세라", - "text": "되돌릴 방법이 없는 건 아니에요. 다시 만나서, 다시 도감에 새기면 돼요. 이름을 돌려주는 거죠." + "text": "그러면 이름이 돌아와요.", + "fx": "pause" } ], "choices": [], @@ -933,7 +1199,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "stageEnterId": "st_ch7_scholar_follow" + "stageEnterId": "st_ch7_scholar_follow", + "why": "들판 한가운데가 너무 조용해" }, { "beatId": "ch7_clash", @@ -947,13 +1214,19 @@ "requiredRegionId": "hollow", "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "쓰러진 곤충 위로 무언가 반짝 떠올랐다 사라진다.", + "fx": "flash" + }, { "speaker": "세라", - "text": "쓰러진 자리에 글자가 잠깐 떠올랐다가 꺼졌어요. 보셨어요?" + "text": "글자가 잠깐 떠올랐어요. 봤어요?" }, { "speaker": "세라", - "text": "아직 완전히 지워지진 않았다는 뜻이에요. 늦지 않았어요." + "text": "이름이 아직 다 지워지진 않았다는 뜻이에요.", + "fx": "pause" } ], "choices": [], @@ -966,7 +1239,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "텅 빈 들의 곤충과 겨뤄 보자" }, { "beatId": "ch7_echo", @@ -982,11 +1256,16 @@ "lines": [ { "speaker": "세라", - "text": "…제가 틀렸을지도 몰라요. 봉인이 무엇을 가뒀는지, 우린 한 번도 묻지 않았어요." + "text": "저는 벽이 곤충을 지키는 줄로만 알았어요." + }, + { + "speaker": "세라", + "text": "무엇을 막는 울타리인지는 한 번도 안 물었죠." }, { "speaker": "세라", - "text": "무엇을 지키려는 울타리인지만 물었지, 무엇을 막으려는 울타리인지는 안 물었죠. 학자로서 부끄러운 일이에요." + "text": "학자로서 부끄러운 일이에요.", + "fx": "pause" } ], "choices": [], @@ -999,7 +1278,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "세라가 할 말이 있대" }, { "beatId": "ch8_arrive", @@ -1012,17 +1292,32 @@ }, "speakerNpcId": "catcher_rival", "lines": [ + { + "speaker": "지문", + "text": "모래언덕. 뜨거운 바람 속에 수레바퀴 자국이 뻗어 있다." + }, { "speaker": "라온", - "text": "야, 이거 봐. 모래에 반쯤 묻힌 상자야. 하나둘이 아니라 줄줄이." + "text": "이거 봐. 모래에 묻힌 상자가 줄줄이 있어." }, { "speaker": "라온", - "text": "뚜껑에 이름표가 붙어 있어. 종 이름이랑… 날짜랑, 수량." + "text": "이름표가 붙었어. 종 이름이랑 날짜랑 숫자." + }, + { + "speaker": "지문", + "text": "상자 안에서 사각사각, 벽을 긁는 소리가 난다.", + "fx": "pause" }, { "speaker": "라온", - "text": "안에 살아 있어. 아직 살아 있다고. 근데 아무도 안 돌보고 있잖아." + "text": "안에 살아 있어! 근데 아무도 안 돌보잖아!", + "fx": "shout" + }, + { + "speaker": "세라", + "text": "누가 이만큼을 여기 두고 갔을까요.", + "fx": "whisper" } ], "choices": [], @@ -1035,7 +1330,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "수레바퀴 자국이 모래언덕으로 나 있어" }, { "beatId": "ch8_sign", @@ -1051,11 +1347,21 @@ "lines": [ { "speaker": "라온", - "text": "네가 하는 거랑 저 상자랑, 겉으로는 똑같아 보이겠지. 잡아서 기록에 남기는 거니까." + "text": "네가 하는 거랑 저 상자, 겉보기엔 비슷하지?" + }, + { + "speaker": "세라", + "text": "저도 방금 같은 생각을 했어요." }, { "speaker": "라온", - "text": "근데 아니야. 만나고 놓아주면서 새기는 거랑, 가둬 놓고 세는 거랑은 달라. 완전히 달라." + "text": "근데 만나서 적는 거랑 가둬 놓고 세는 건 완전히 달라!", + "fx": "shout" + }, + { + "speaker": "세라", + "text": "네. 그걸 잊으면 우리도 상자가 돼요.", + "fx": "pause" } ], "choices": [], @@ -1068,7 +1374,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "모래언덕 곤충도 도감에 적자" }, { "beatId": "ch8_confront", @@ -1081,25 +1388,52 @@ }, "speakerNpcId": "ledger_grip", "lines": [ + { + "speaker": "지문", + "text": "모래 창고. 천장까지 이름표 붙은 상자가 쌓여 있다.", + "fx": "dark" + }, + { + "speaker": "라온", + "text": "이게 다 곤충이야? 문 열어! 숨 막혀 죽겠다고!", + "fx": "shout" + }, { "speaker": "집게", - "text": "손대지 마라. 그건 명부회 물건이다." + "text": "손대지 마라. 명부회 물건이다.", + "fx": "shake" + }, + { + "speaker": "라온", + "text": "물건? 살아 있는 걸 두고 물건?" }, { "speaker": "집게", - "text": "우리가 뭘 하는지 아나? 빈칸을 메우는 거다. 종 하나가 지워질 때마다 세상에 구멍이 뚫려. 그 구멍으로 뭐가 들어오는지는 너도 봤을 텐데." + "text": "빈칸마다 구멍이 뚫린다. 그래서 전부 장부에 올린다.", + "fx": "dark" + }, + { + "speaker": "세라", + "text": "…스승님 말투 그대로네요.", + "fx": "whisper" }, { "speaker": "집게", - "text": "그래서 남김없이 장부에 올린다. 전부, 지금 당장. 한 마리씩 만나고 다니는 네 방식으로는 백 년이 걸려도 못 끝내." + "text": "넌 세라로군. 먼저 나간 쪽이라고 들었다." }, { "speaker": "세라", - "text": "…스승님 말투 그대로네요. 그런데 집게 씨, 저 상자 안의 아이들은 지금 몇이나 살아 있죠?" + "text": "그 얘기는 나중에요. 상자 속 아이들, 몇이나 살아 있죠?" + }, + { + "speaker": "집게", + "text": "…장부에는 올라가 있다.", + "fx": "slow" }, { "speaker": "집게", - "text": "…장부에는 올라가 있다." + "text": "상자를 열고 싶으면 실력으로 와라.", + "fx": "dark" } ], "choices": [], @@ -1113,7 +1447,10 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "stageEnterId": "st_ch8_grip_enter" + "stageEnterId": "st_ch8_grip_enter", + "videoId": "vid_ch8_vault", + "why": "모래 창고에 상자가 잔뜩 있대", + "duelAfter": "ledger_grip" }, { "beatId": "ch8_clash", @@ -1127,13 +1464,23 @@ "requiredRegionId": "dunes", "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "쓰러진 곤충의 다리에 번호 고리가 채워져 있다." + }, + { + "speaker": "라온", + "text": "상자에서 나온 애야. 고리 풀어 줄게.", + "fx": "whisper" + }, { "speaker": "세라", - "text": "저 장부를 봤어요. 우리 도감이랑 똑같이 생겼더군요. 같은 양식, 같은 칸." + "text": "명부회 장부는 우리 도감이랑 똑같이 생겼어요." }, { "speaker": "세라", - "text": "다른 건 딱 하나예요. 이름 옆에 날짜와 수량이 적혀 있었어요. …그건 만난 기록이 아니라 들어온 물건의 기록이에요." + "text": "다른 건 하나, 곤충을 물건처럼 세어서 적었다는 거예요.", + "fx": "slow" } ], "choices": [], @@ -1146,14 +1493,15 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "모래언덕 곤충과 겨뤄 보자" }, { "beatId": "ch8_echo", "chapterId": "ch8", "order": 30, "prerequisiteBeatId": "ch7_echo_rival", - "requiredBeatId": "ch8_arrive", + "requiredBeatId": "ch8_confront", "trigger": { "type": "NpcTalk", "param": "catcher_rival" @@ -1162,11 +1510,21 @@ "lines": [ { "speaker": "라온", - "text": "쟤들 목적이 우리랑 같다는 게 제일 화나. 차라리 곤충이 미워서 저러는 거면 속이 편하겠어." + "text": "제일 화나는 게 뭔지 알아? 쟤들 목적이 우리랑 같다는 거." + }, + { + "speaker": "라온", + "text": "빈칸을 메우자는 거잖아. 나도 그러고 싶어." }, { "speaker": "라온", - "text": "빈칸을 메우자는 거잖아. 나도 그러고 싶어. 근데 저렇게 메우면 그 자리에 또 빈칸이 생긴다고." + "text": "근데 저렇게 가두면 곤충이 죽고, 빈칸이 또 생겨.", + "fx": "shout" + }, + { + "speaker": "라온", + "text": "…미안. 너한테 화낸 거 아니야.", + "fx": "whisper" } ], "choices": [], @@ -1179,30 +1537,42 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "라온이 할 말이 있대" }, { "beatId": "ch9_arrive", "chapterId": "ch9", "order": 31, - "prerequisiteBeatId": "ch8_confront", + "prerequisiteBeatId": "duel_grip_win", "trigger": { "type": "RegionEnter", "param": "frostline" }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "서릿길. 걸을 때마다 눈이 뽀드득 운다." + }, + { + "speaker": "라온", + "text": "으, 추워! 모래언덕에서 땀 흘린 게 엊그제인데!", + "fx": "shout" + }, { "speaker": "세라", - "text": "여긴… 숨을 쉴 때마다 폐가 아려요. 그런데 보세요, 저 얼음 벽." + "text": "저 얼음 벽 보세요. 이름이 하나도 안 바랬어요.", + "fx": "flash" }, { "speaker": "세라", - "text": "글자가 있어요. 유적 신전의 것과 같은 문양인데, 하나도 안 바랬어요. 얼음이 시간을 붙들어 둔 거예요." + "text": "얼음이 이름을 붙들어 둔 거예요. 그래서 여긴 무사해요." }, { "speaker": "세라", - "text": "여기 종들은 한 번도 이름을 잃은 적이 없어요. …이 땅이 왜 무사한지 알아내면, 나머지도 구할 수 있을지 몰라요." + "text": "왜 무사한지 알면 다른 곳도 구할지 몰라요.", + "fx": "pause" } ], "choices": [], @@ -1215,7 +1585,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "얼음 벽에 이름이 남아 있대" }, { "beatId": "ch9_sign", @@ -1231,11 +1602,15 @@ "lines": [ { "speaker": "세라", - "text": "얼음이 붙든 것과 도감이 붙든 것은 무엇이 다를까요." + "text": "얼음은 곤충을 멈춰 세울 뿐이에요. 자라지도 못하게요." + }, + { + "speaker": "라온", + "text": "어려운 얘기 하지 마. 손 시려서 머리가 안 돌아가." }, { "speaker": "세라", - "text": "얼음은 그냥 멈춰 세운 거예요. 자라지도 죽지도 못하게. …당신 도감은 그렇지 않죠. 그 아이들은 여전히 살아 있어요." + "text": "당신 도감 속 아이들은 살아 있다는 얘기예요." } ], "choices": [], @@ -1248,7 +1623,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "서릿길 곤충도 도감에 적자" }, { "beatId": "ch9_confront", @@ -1261,29 +1637,47 @@ }, "speakerNpcId": "ledger_scale", "lines": [ + { + "speaker": "지문", + "text": "얼음 서고. 얼어붙은 표본 상자가 끝없이 늘어서 있다.", + "fx": "dark" + }, { "speaker": "저울", - "text": "세라구나. 여기까지 올 줄 알았다." + "text": "세라구나. 올 줄 알았다." }, { "speaker": "세라", - "text": "…저울 선배." + "text": "…저울 선배.", + "fx": "pause" }, { "speaker": "저울", - "text": "선배라고 부르지 마라. 넌 나갔고 난 남았다. 그게 전부다." + "text": "선배라 부르지 마라. 넌 나갔고 난 남았다." }, { - "speaker": "저울", - "text": "그런데 하나만 짚고 가지. 너도 한때는 우리 방식이 옳다고 했어. 이 서고 목록을 만든 게 누구였더라?" + "speaker": "라온", + "text": "나갔다니… 세라, 너 명부회였어?", + "fx": "shout" }, { "speaker": "세라", - "text": "…저였어요. 부정하지 않아요." + "text": "…네. 스승님 곁에서, 저 선배와 함께요.", + "fx": "whisper" + }, + { + "speaker": "저울", + "text": "이 서고 목록을 만든 게 너였지. 그럼 뭐가 달라졌지?" }, { "speaker": "저울", - "text": "그럼 지금 무엇이 달라졌지? 대답해 봐라. 대답이 안 되면 넌 그냥 겁먹고 도망친 거다." + "text": "대답 못 하면 넌 그냥 겁먹고 도망친 거다.", + "fx": "dark" + }, + { + "speaker": "지문", + "text": "세라가 입을 열었다 닫는다. 저울이 당신을 본다.", + "fx": "pause" } ], "choices": [ @@ -1306,7 +1700,10 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "stageEnterId": "st_ch9_scale_enter" + "stageEnterId": "st_ch9_scale_enter", + "videoId": "vid_ch9_archive", + "why": "얼음 서고에 누군가 있어", + "duelAfter": "ledger_scale" }, { "beatId": "ch9_clash", @@ -1322,11 +1719,20 @@ "lines": [ { "speaker": "세라", - "text": "아까 저울 선배 물음에 대답을 못 했어요. 지금은 할 수 있을 것 같아요." + "text": "아까 선배 물음에 이제 대답할 수 있어요." + }, + { + "speaker": "세라", + "text": "달라진 건 빠르기가 아니라 방향이에요.", + "fx": "flash" }, { "speaker": "세라", - "text": "달라진 건 속도가 아니라 방향이에요. 우린 이름을 되돌려주고 있고, 저쪽은 이름만 베껴 가고 있어요." + "text": "우린 이름을 돌려주고, 저쪽은 가둬 놓고 세요." + }, + { + "speaker": "라온", + "text": "그 말, 선배한테 직접 해 줘. 난 옆에 서 있을게." } ], "choices": [], @@ -1339,7 +1745,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "서릿길 곤충과 겨뤄 보자" }, { "beatId": "ch9_echo", @@ -1355,15 +1762,16 @@ "lines": [ { "speaker": "세라", - "text": "스승님 이야기를 해도 될까요. 관장님은 30년 전에 제자를 하나 잃으셨어요. 잦아듦으로요." + "text": "스승님은 30년 전에 제자 하나를 잃으셨어요." }, { "speaker": "세라", - "text": "그날 이후로 기다리는 걸 못 견디세요. 느리게 옳은 것보다 빠르게 틀린 걸 고르시죠." + "text": "잦아듦으로요. 그 뒤로 기다리는 걸 못 견디세요." }, { "speaker": "세라", - "text": "…미워할 수가 없어서 더 막아야 해요. 이해가 되나요, 이 말이." + "text": "미워할 수가 없어서, 더 막아야 해요.", + "fx": "pause" } ], "choices": [], @@ -1376,13 +1784,14 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "세라가 스승님 얘기를 한대" }, { "beatId": "ch10_arrive", "chapterId": "ch10", "order": 36, - "prerequisiteBeatId": "ch9_confront", + "prerequisiteBeatId": "duel_scale_win", "trigger": { "type": "RegionEnter", "param": "emberfall" @@ -1390,16 +1799,26 @@ "speakerNpcId": "catcher_rival", "lines": [ { - "speaker": "라온", - "text": "여긴 재가 아직도 안 식었어. 발밑이 따뜻해." + "speaker": "지문", + "text": "잿불 골짜기. 검게 탄 나무 사이로 연기가 오른다.", + "fx": "dark" }, { - "speaker": "라온", - "text": "세라 말로는 여기 기록이 통째로 불탔대. 그러니까 빈칸이 제일 두껍게 겹친 데가 여기라는 거지." + "speaker": "세라", + "text": "불에 탄 땅이에요. 빈칸이 제일 많은 곳이죠." + }, + { + "speaker": "지문", + "text": "재 속에서 아주 작게 무언가가 운다.", + "fx": "pause" }, { "speaker": "라온", - "text": "…솔직히 무섭다. 근데 여기 아직 살아 있는 애들이 있어. 그럼 가야지." + "text": "그래도 살아 있는 애들이 있어. 그럼 가야지!" + }, + { + "speaker": "세라", + "text": "라온, 이번엔 앞서가지 마요. 땅이 꺼질지 몰라요." } ], "choices": [], @@ -1412,7 +1831,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "재 속에 살아남은 곤충이 있대" }, { "beatId": "ch10_sign", @@ -1426,17 +1846,26 @@ "requiredRegionId": "emberfall", "speakerNpcId": "ledger_ink", "lines": [ + { + "speaker": "지문", + "text": "검은 소매의 남자가 멀찍이서 이쪽을 보고 있다." + }, { "speaker": "먹", - "text": "…나는 명부회의 필경사다. 장부를 직접 쓰는 사람." + "text": "나는 명부회의 먹. 장부를 직접 쓰는 사람이다." }, { "speaker": "먹", - "text": "네 손끝을 보고 있으면 자꾸 세게 되더군. 내가 적은 이름 중에 아직 살아 있는 게 몇이나 될까 하고." + "text": "내가 적은 이름 중 몇이 아직 살아 있을까. 세어 봤다." }, { "speaker": "먹", - "text": "…세어 봤다. 답은 말하지 않겠다." + "text": "…답은 말하지 않겠다." + }, + { + "speaker": "세라", + "text": "말하지 않아도 알 것 같아요.", + "fx": "whisper" } ], "choices": [], @@ -1449,7 +1878,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "골짜기 곤충도 도감에 적자" }, { "beatId": "ch10_confront", @@ -1462,33 +1892,54 @@ }, "speakerNpcId": "ledger_ink", "lines": [ + { + "speaker": "지문", + "text": "가마 갱도. 천장에서 돌가루가 떨어진다.", + "fx": "dark" + }, { "speaker": "라온", - "text": "저 안에 아직 애들이 있어! 갱도 내려앉기 전에—" + "text": "저 안에 아직 애들이 있어! 무너지기 전에—", + "fx": "shout" }, { "speaker": "세라", - "text": "라온, 안 돼요!" + "text": "라온, 안 돼요!", + "fx": "shout" }, { - "speaker": "먹", - "text": "…비켜라." + "speaker": "지문", + "text": "라온이 뛰어들고, 곧 천장이 무너져 내린다.", + "fx": "shake" }, { - "speaker": "먹", - "text": "(먹이 무너지는 갱도에서 라온을 끌어낸다. 팔에 상자 하나가 들려 있다.)" + "speaker": "세라", + "text": "라온! 대답해요!", + "fx": "shout" + }, + { + "speaker": "지문", + "text": "먹이 무너진 틈으로 들어가 라온을 끌어낸다." + }, + { + "speaker": "라온", + "text": "괜찮아… 상자, 상자는…", + "fx": "whisper" }, { "speaker": "먹", - "text": "상자는 다 못 꺼냈다. 이 하나뿐이다." + "text": "상자는 하나밖에 못 꺼냈다.", + "fx": "pause" }, { "speaker": "먹", - "text": "…나는 이름을 적는 사람이지 가두는 사람이 아니었다. 이제야 그 차이를 알겠군." + "text": "나는 이름을 적는 사람이지, 가두는 사람이 아니었다.", + "fx": "slow" }, { "speaker": "세라", - "text": "라온은 여기서 쉬어야 해요. 뼈가 상했어요. …여기서부턴 우리 둘이에요." + "text": "라온은 다쳤어요. 여기서부턴 우리 둘이에요.", + "fx": "pause" } ], "choices": [], @@ -1502,13 +1953,15 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "stageEnterId": "st_ch10_ink_enter" + "stageEnterId": "st_ch10_ink_enter", + "videoId": "vid_ch10_kiln", + "why": "갱도 안에 갇힌 곤충이 있대" }, { "beatId": "ch10_clash", "chapterId": "ch10", "order": 39, - "prerequisiteBeatId": "ch10_arrive", + "prerequisiteBeatId": "ch10_confront", "trigger": { "type": "BattleWin", "param": "" @@ -1518,11 +1971,15 @@ "lines": [ { "speaker": "세라", - "text": "라온은 괜찮을 거예요. 붙임성이 좋아서 전초기지 사람들이 잘 봐줄 거고요." + "text": "라온은 괜찮을 거예요. 전초기지 사람들이 돌봐요." + }, + { + "speaker": "세라", + "text": "벌써 곤충 이름 맞히기 퀴즈를 내고 있대요." }, { "speaker": "세라", - "text": "그러니까 우리는 우리 몫을 해요. 저 아이가 돌아왔을 때 보여줄 게 있어야죠." + "text": "우리는 우리 몫을 해요. 돌아오면 보여 줄 게 있어야죠." } ], "choices": [], @@ -1535,14 +1992,15 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "라온 몫까지 힘을 내자" }, { "beatId": "ch10_echo", "chapterId": "ch10", "order": 40, "prerequisiteBeatId": "", - "requiredBeatId": "ch10_arrive", + "requiredBeatId": "ch10_confront", "trigger": { "type": "NpcTalk", "param": "ledger_ink" @@ -1551,11 +2009,19 @@ "lines": [ { "speaker": "먹", - "text": "장부는 두고 왔다. 태우진 않았다 — 거기 적힌 이름들에는 죄가 없으니까." + "text": "장부는 두고 왔다. 태우진 않았다." + }, + { + "speaker": "먹", + "text": "거기 적힌 이름들에는 죄가 없으니까." + }, + { + "speaker": "먹", + "text": "관장님은 30년째 같은 날에 서 계신다." }, { "speaker": "먹", - "text": "관장님을 미워하진 않는다. 다만 그분은 30년째 같은 날에 서 계신다. 나는 이제 다음 날로 가고 싶다." + "text": "나는 이제 다음 날로 가고 싶다." } ], "choices": [], @@ -1568,7 +2034,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "먹이 할 말이 있대" }, { "beatId": "ch11_arrive", @@ -1581,17 +2048,28 @@ }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "우듬지. 잎 사이로 햇빛이 물처럼 흘러내린다.", + "fx": "flash" + }, + { + "speaker": "세라", + "text": "초록이에요. 매미가 여럿이나 울고 있어요.", + "fx": "pause" + }, { "speaker": "세라", - "text": "…초록이에요. 여기는 초록이 있어요." + "text": "잦아듦이 여기엔 오지 않았어요. 꽃밭처럼요." }, { "speaker": "세라", - "text": "잦아듦이 여길 건드리지 않았어요. 꽃밭처럼요. 라온이 예전에 물었죠 — 왜 꽃밭만 무사하냐고." + "text": "라온이 이걸 봤어야 했는데.", + "fx": "whisper" }, { "speaker": "세라", - "text": "답이 여기 있을 거예요. 두 곳이 같은 이유로 살아남았다면, 그 이유가 나머지도 구할 열쇠예요." + "text": "두 곳이 무사한 이유를 찾으면, 나머지도 구할 수 있어요." } ], "choices": [], @@ -1604,7 +2082,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "우듬지는 아직 무사하대" }, { "beatId": "ch11_sign", @@ -1620,11 +2099,15 @@ "lines": [ { "speaker": "세라", - "text": "여기 아이들은 이름을 잃은 적이 한 번도 없어요. 기록이 바랜 적이 없으니까요." + "text": "여기 아이들은 이름을 잃은 적이 없어요." + }, + { + "speaker": "지문", + "text": "방금 만난 아이가 손등에 앉았다가 세라 쪽으로 건너간다." }, { "speaker": "세라", - "text": "당신 도감에 새 칸이 늘 때마다 저는 자꾸 이 나무를 봐요. 우리가 하려는 게 결국 이거구나 싶어서요." + "text": "우리가 되찾으려는 게 바로 이런 모습이에요." } ], "choices": [], @@ -1637,7 +2120,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "우듬지 곤충도 도감에 적자" }, { "beatId": "ch11_confront", @@ -1650,25 +2134,44 @@ }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "나무 꼭대기. 바람이 불자 껍질의 무늬가 빛난다." + }, + { + "speaker": "세라", + "text": "이 나무 껍질에도 이름이 새겨져 있어요.", + "fx": "flash" + }, { "speaker": "세라", - "text": "…여기서 보니까 알겠어요. 이 나무 껍질에도 문양이 있어요. 유적 신전의 것과 같은." + "text": "옛사람들은 울타리를 하나만 치지 않았어요.", + "fx": "pause" }, { "speaker": "세라", - "text": "고대인은 울타리를 하나만 친 게 아니었어요. 본 기록이 무너질 때를 대비해서, 두 군데에 예비를 남겨 뒀어요. 꽃밭과 여기." + "text": "큰 울타리가 무너질 때를 대비해 두 곳을 더 남겼어요." }, { "speaker": "세라", - "text": "그러니까 그들은 알고 있었던 거예요. 자기들이 실패할 거라는 걸." + "text": "꽃밭과 여기. '비상 울타리'예요." + }, + { + "speaker": "세라", + "text": "자기들이 실패할 걸 알았던 거예요.", + "fx": "slow" + }, + { + "speaker": "지문", + "text": "세라가 껍질의 이름을 손끝으로 따라 그린다." }, { "speaker": "세라", - "text": "…실패할 걸 알면서 울타리를 치고, 실패한 뒤를 위해 두 칸을 남겼어요. 우리한테요." + "text": "실패한 뒤를 위해, 우리에게 남겨 둔 거예요." }, { "speaker": "세라", - "text": "부끄럽지 않게 받아요. 남은 건 그것이 서려던 자리 하나뿐이에요." + "text": "이제 남은 건 울타리의 맨 끝뿐이에요." } ], "choices": [], @@ -1682,13 +2185,15 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "stageEnterId": "st_ch11_scholar_lead" + "stageEnterId": "st_ch11_scholar_lead", + "videoId": "vid_ch11_crown", + "why": "나무 꼭대기에 무언가 있대" }, { "beatId": "ch11_clash", "chapterId": "ch11", "order": 44, - "prerequisiteBeatId": "ch11_arrive", + "prerequisiteBeatId": "ch11_confront", "trigger": { "type": "BattleWin", "param": "" @@ -1698,11 +2203,16 @@ "lines": [ { "speaker": "세라", - "text": "고대인이 남긴 두 칸 말이에요. 저는 그게 예비 울타리라기보다 편지 같아요." + "text": "옛사람들이 남긴 두 곳 말이에요. 편지 같아요." + }, + { + "speaker": "세라", + "text": "'여기까지가 우리 몫이었다'고 적은 편지요.", + "fx": "pause" }, { "speaker": "세라", - "text": "'여기까지가 우리 몫이었다'고 적어 둔 거죠. 나머지는 읽는 사람 몫이고요." + "text": "나머지는 읽는 사람 몫이고요." } ], "choices": [], @@ -1715,7 +2225,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "우듬지 곤충과 겨뤄 보자" }, { "beatId": "ch11_echo", @@ -1729,21 +2240,27 @@ }, "speakerNpcId": "village_elder", "lines": [ + { + "speaker": "지문", + "text": "초원. 어르신이 낡은 종이 묶음을 무릎에 올려 두었다." + }, { "speaker": "마을 어르신", - "text": "네가 없는 동안 이 늙은이는 초원만 지켰구나. …이제 와서 하나 털어놓으마." + "text": "내가 하던 옛이야기, 명부회 옛 문서에서 온 거란다." }, { "speaker": "마을 어르신", - "text": "내가 흘리던 옛이야기 말이다. 그거, 명부회 옛 문서에서 온 거란다. 나도 한때 그 사람들과 같은 자리에 있었어." + "text": "나도 한때 그 사람들과 함께 있었어.", + "fx": "pause" }, { "speaker": "마을 어르신", - "text": "그 방식이 무서워서 내려온 게야. 그러곤 여기서 아무것도 안 하고 나이만 먹었지." + "text": "그 방식이 무서워서 내려왔지. 그리곤 숨어 지냈단다." }, { "speaker": "마을 어르신", - "text": "네가 대신 걷고 있구나. …고맙고, 미안하다." + "text": "네가 대신 걷고 있구나. 고맙고, 미안하다.", + "fx": "slow" } ], "choices": [], @@ -1756,7 +2273,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "어르신이 할 말이 있대" }, { "beatId": "ch12_arrive", @@ -1769,17 +2287,32 @@ }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "이름 없는 자리. 바람이 한 박자 늦게 따라온다.", + "fx": "dark" + }, + { + "speaker": "세라", + "text": "여긴 울타리의 맨 끝이에요. 너머가 그림자가 사는 곳이죠." + }, { "speaker": "세라", - "text": "어느 지도에도 이 땅은 없어요. 우리가 지금 서 있는데도요." + "text": "저 돌판들이 마지막 말뚝인데, 전부 비었어요." }, { "speaker": "세라", - "text": "여기가 그것이 원래 있던 자리예요. 고대인이 울타리를 친 건 이 자리를 비워 두려는 거였고요." + "text": "그림자는 이 구멍으로 드나든 거예요." + }, + { + "speaker": "지문", + "text": "돌판 너머 멀리, 불 켜진 창 하나가 보인다.", + "fx": "pause" }, { "speaker": "세라", - "text": "저 석판들 보세요. 전부 비어 있어요. 무언가를 둘러싸고 서 있었던 모양 그대로." + "text": "누가 먼저 와 있어요. 이런 곳에.", + "fx": "whisper" } ], "choices": [], @@ -1792,7 +2325,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "울타리의 맨 끝으로 가자" }, { "beatId": "ch12_sign", @@ -1808,11 +2342,16 @@ "lines": [ { "speaker": "세라", - "text": "한 이름씩, 한 자리씩이에요. 당신 도감이 늘 때마다 그것이 설 곳이 줄어요." + "text": "한 이름씩, 한 칸씩이에요." + }, + { + "speaker": "지문", + "text": "돌판에 방금 만난 아이의 이름이 떠올랐다 가라앉는다.", + "fx": "flash" }, { "speaker": "세라", - "text": "화려한 방법은 아니에요. 그래도 이게 제일 확실한 방법이에요." + "text": "도감이 늘 때마다 그림자가 설 곳이 줄어요." } ], "choices": [], @@ -1825,7 +2364,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "빈 돌판을 이름으로 채우자" }, { "beatId": "ch12_confront", @@ -1838,33 +2378,49 @@ }, "speakerNpcId": "ledger_chief", "lines": [ + { + "speaker": "지문", + "text": "장부의 방. 벽마다 같은 모양의 장부가 빽빽하다.", + "fx": "dark" + }, { "speaker": "하월", - "text": "왔구나. 세라, 그리고… 자네가 그 채집가로군." + "text": "왔구나, 세라. 그리고 자네가 그 채집가로군." + }, + { + "speaker": "세라", + "text": "…스승님.", + "fx": "pause" }, { "speaker": "하월", - "text": "이 방을 보게. 내 장부다. 삼천 종을 적었지. 삼십 년이 걸렸어." + "text": "내 장부다. 삼천 종을 적었지. 삼십 년이 걸렸어." }, { "speaker": "세라", - "text": "스승님. 그중 몇이 아직 살아 있나요." + "text": "스승님, 그중 몇이 아직 살아 있나요?" }, { "speaker": "하월", - "text": "…절반이 창고에서 죽었다." + "text": "…절반이 창고에서 죽었다. 알고 있었어.", + "fx": "slow" }, { "speaker": "하월", - "text": "알고 있었다. 세는 동안에도 알고 있었어. 그런데 멈추면 그 아이 생각이 나서, 멈출 수가 없더구나." + "text": "잃은 제자 생각에, 빈칸을 메우려다 빈칸을 만들었지." + }, + { + "speaker": "지문", + "text": "하월의 손바닥에 무늬 없는 나방 한 마리가 앉아 있다." }, { "speaker": "하월", - "text": "빈칸을 메우겠다고 빈칸을 만들었어. 삼십 년을." + "text": "내가 30년 전에 가둔 아이다. 이름을 잃었지." }, { "speaker": "하월", - "text": "…길을 비켜 주마. 안쪽으로 가라. 그것이 자네를 기다리고 있다." + "text": "내 장부의 다섯 아이를 넘어 보게. 마지막이 이 아이다.", + "fx": "dark" } ], "choices": [], @@ -1878,7 +2434,9 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "stageEnterId": "st_ch12_chief_enter" + "stageEnterId": "st_ch12_chief_enter", + "why": "불 켜진 창에 누군가 있어", + "duelAfter": "ledger_chief" }, { "beatId": "ch12_clash", @@ -1886,23 +2444,28 @@ "order": 54, "prerequisiteBeatId": "fin_seal", "trigger": { - "type": "BattleWin", - "param": "" + "type": "NpcTalk", + "param": "ledger_chief" }, "requiredRegionId": "nameless", "speakerNpcId": "ledger_chief", "lines": [ { "speaker": "하월", - "text": "…끝났다고 들었다. 확인하러 왔네." + "text": "끝났다고 들었다. 자네를 보니 실감이 나는군." + }, + { + "speaker": "세라", + "text": "지금 스승님 얼굴, 제일 편해 보여요." }, { "speaker": "하월", - "text": "나는 삼십 년을 서둘렀고 자네는 한 마리씩 만났다. 결과가 이 꼴이라니." + "text": "…그런가.", + "fx": "pause" }, { "speaker": "하월", - "text": "변명은 안 하마. 다만 자네 도감은 끝까지 채우게. 내 장부와 다른 것이 되도록." + "text": "자네 도감은 끝까지 채우게. 내 장부와 다르게." } ], "choices": [], @@ -1915,7 +2478,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "하월이 기다리고 있어" }, { "beatId": "ch12_echo", @@ -1930,16 +2494,27 @@ "speakerNpcId": "catcher_rival", "lines": [ { - "speaker": "라온", - "text": "…늦어서 미안. 팔은 아직 잘 안 굽혀지는데, 걷는 건 되더라." + "speaker": "지문", + "text": "익숙한 발소리가 다가온다. 팔에 붕대를 감았다." }, { "speaker": "라온", - "text": "처음에 나 너한테 승부하자고 했지. 누가 더 많이 구해내나 겨루자고." + "text": "늦어서 미안. 먹 아저씨가 반쯤 업어다 줬어." + }, + { + "speaker": "세라", + "text": "라온! 아직 쉬어야 한다고 했잖아요!", + "fx": "shout" }, { "speaker": "라온", - "text": "그거 취소. 그냥 같이 가자. 마지막은 같이 가야지." + "text": "처음에 승부하자고 한 거 취소. 그냥 같이 가자.", + "fx": "pause" + }, + { + "speaker": "세라", + "text": "…네. 다 같이요.", + "fx": "whisper" } ], "choices": [], @@ -1952,13 +2527,14 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "라온이 돌아왔대" }, { "beatId": "fin_unnamed", "chapterId": "fin", "order": 51, - "prerequisiteBeatId": "ch12_confront", + "prerequisiteBeatId": "duel_chief_win", "trigger": { "type": "SubAreaEnter", "param": "nameless_core" @@ -1967,27 +2543,26 @@ "lines": [ { "speaker": "세라", - "text": "…여기예요. 아무것도 새겨지지 않은 자리." - }, - { - "speaker": "세라", - "text": "보이세요? 형체가 없어요. 볼 때마다 다른 곤충의 모습을 빌려 쓰고 있어요. 전부 그것이 빼앗은 이름들이에요." + "text": "저게 그림자예요. 모습을 계속 바꾸고 있어요." }, { "speaker": "세라", - "text": "…말을 걸어오네요. 이름을 달라고." + "text": "전부 그림자가 빼앗은 이름들이에요." }, { - "speaker": "세라", - "text": "주지 마세요. 절대로. 이름을 받는 순간 그것은 자리를 얻어요. 우리 세계의 한 칸을 정식으로 차지하는 거예요." + "speaker": "지문", + "text": "머릿속에 물음 하나가 떠오른다. '이름을 줘.'", + "fx": "shake" }, { "speaker": "세라", - "text": "우리가 할 일은 딱 하나예요. 마지막 빈칸까지 다른 이름으로 메워서, 그것이 설 자리를 없애는 것." + "text": "절대 주지 마세요. 울타리 안에 자리를 얻게 돼요.", + "fx": "whisper" }, { - "speaker": "세라", - "text": "당신이 여기까지 만나 온 모든 아이들이 지금 그 칸을 채우고 있어요. 가요." + "speaker": "지문", + "text": "그림자가 다시 묻는다. 이번엔 더 가까이서.", + "fx": "dark" } ], "choices": [ @@ -2010,7 +2585,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "cutsceneId": "cs_nameless_confront" + "introVideoId": "vid_fin_shadow", + "why": "울타리 끝 빈칸에 그림자가 있어" }, { "beatId": "fin_seal", @@ -2026,19 +2602,24 @@ "lines": [ { "speaker": "세라", - "text": "…마지막 칸이 메워졌어요." + "text": "마지막 칸이 메워졌어요!", + "fx": "pause" }, { "speaker": "세라", - "text": "보세요. 그것이 빌려 쓰던 모습들이 하나씩 제자리로 돌아가고 있어요. 이름이 주인에게 돌아가는 거예요." + "text": "그림자는 밖으로 밀려났어요. 다시 들어올 틈도 없어요." }, { - "speaker": "세라", - "text": "그것은 이제 설 자리가 없어요. 죽은 게 아니라 — 들어올 틈이 없어진 거죠." + "speaker": "지문", + "text": "텅 빈 들에 다시 풀벌레 소리가 들린다." + }, + { + "speaker": "지문", + "text": "남은 자리에 초록 사마귀 한 마리가 앉아 있다." }, { "speaker": "세라", - "text": "저 아이도 돌아왔네요. 이름을 되찾았어요. 데려가 주세요. 당신이 처음으로 이름을 돌려준 아이잖아요." + "text": "이름을 되찾았네요. 당신이 데려가 주세요." } ], "choices": [], @@ -2052,7 +2633,8 @@ "rewardInsectLevel": 60, "unlockQuestId": "" }, - "cutsceneId": "cs_final_seal" + "introVideoId": "vid_fin_return", + "why": "그림자와 맞서 이기자" }, { "beatId": "fin_epilogue", @@ -2066,25 +2648,35 @@ }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "모든 것이 끝난 뒤. 세라가 새 수첩을 펼친다." + }, { "speaker": "세라", - "text": "스승님은 장부를 전부 넘기셨어요. 이제 저 이름들을 한 종씩 찾아다니며 살아 있는지 확인하실 거래요. 남은 평생 걸릴 거고, 본인도 아세요." + "text": "스승님은 장부의 곤충들을 평생 찾아다니실 거래요." }, { "speaker": "세라", - "text": "라온은 어르신을 도우러 초원에 갔고요. 먹은 새 장부를 쓰기 시작했어요. 이번엔 수량 칸이 없대요." + "text": "라온은 어르신을 도우러 초원에 갔어요." }, { "speaker": "세라", - "text": "그리고 하나 말해 둘 게 있어요. …빈칸은 사라지지 않았어요." + "text": "먹은 새 장부를 써요. 이번엔 숫자 칸이 없대요." }, { "speaker": "세라", - "text": "종이 사라지면 자리는 또 생겨요. 그건 우리가 이겨서 없앨 수 있는 게 아니에요." + "text": "하나만 기억해요. 곤충이 사라지면 빈칸은 또 생겨요.", + "fx": "pause" + }, + { + "speaker": "지문", + "text": "어디선가 풀벌레 한 마리가 운다. 작지만 분명하게." }, { "speaker": "세라", - "text": "그러니까 계속 만나요. 한 마리씩, 계속. 그게 이 세계를 붙드는 유일한 방법이니까요." + "text": "그러니 계속 만나요. 한 마리씩, 계속.", + "fx": "slow" } ], "choices": [], @@ -2097,7 +2689,9 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "videoId": "vid_fin_epilogue", + "why": "세라가 마지막 이야기를 한대" }, { "beatId": "gd_ruins", @@ -2110,13 +2704,18 @@ }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "파라오풍뎅이가 물러난다. 기둥에서 모래가 흘러내린다.", + "fx": "flash" + }, { "speaker": "세라", - "text": "파수꾼이 길을 열었어요. …그런데 발밑이 흔들리는 건 저만 느끼는 건가요?" + "text": "길이 열렸어요. 그런데 발밑이 흔들리지 않아요?" }, { "speaker": "세라", - "text": "유적 지하로 내려가 봐요. 신전 아래에 아직 우리가 안 본 게 있어요." + "text": "신전 아래로 내려가 봐요. 아직 못 본 게 있어요." } ], "choices": [], @@ -2129,7 +2728,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "수문장을 넘어야 길이 열려" }, { "beatId": "gd_hollow", @@ -2142,13 +2742,18 @@ }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "사마귀가 낫을 내리고 풀숲으로 물러난다.", + "fx": "flash" + }, { "speaker": "세라", - "text": "쓰러뜨린 게 아니라 놓아준 것 같네요. 저 아이도 이름을 반쯤 잃고 있었어요." + "text": "저 아이도 이름을 반쯤 잃고 있었어요." }, { "speaker": "세라", - "text": "길이 열렸어요. 동쪽 모래언덕 쪽으로 수레바퀴 자국이 이어져 있어요." + "text": "길이 열렸어요. 수레바퀴 자국이 모래언덕으로 이어져요." } ], "choices": [], @@ -2161,7 +2766,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "수문장을 넘어야 모래언덕으로 가" }, { "beatId": "gd_dunes", @@ -2174,13 +2780,22 @@ }, "speakerNpcId": "catcher_rival", "lines": [ + { + "speaker": "지문", + "text": "장수말벌이 날아오르자, 묻혀 있던 상자가 드러난다.", + "fx": "flash" + }, { "speaker": "라온", - "text": "집게는 갔어. 상자는… 우리가 열 수 있는 만큼 열었고." + "text": "여기도 상자야. 열 수 있는 만큼 열자." + }, + { + "speaker": "지문", + "text": "뚜껑이 열리자 몇 마리가 햇빛 속으로 날아간다." }, { "speaker": "라온", - "text": "다 살리진 못했어. 그래도 몇은 날아갔다. 그거면 오늘은 됐다고 치자." + "text": "다는 못 살렸어. 그래도 몇은 날아갔다." } ], "choices": [], @@ -2193,7 +2808,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "수문장을 넘어야 서릿길로 가" }, { "beatId": "gd_frostline", @@ -2207,12 +2823,19 @@ "speakerNpcId": "ruins_scholar", "lines": [ { - "speaker": "세라", - "text": "저울 선배는 서고에 남겠대요. 목록을 처음부터 다시 세어 보겠다고." + "speaker": "지문", + "text": "오로라나방이 날개를 펴자 얼음 벽이 빛난다.", + "fx": "flash" + }, + { + "speaker": "라온", + "text": "봤어? 벽이 대답한 것 같았어.", + "fx": "whisper" }, { "speaker": "세라", - "text": "…그게 그 사람 방식의 사과예요. 우리는 앞으로 가요." + "text": "바람에 재 냄새가 실려 와요. 앞으로 가요.", + "fx": "pause" } ], "choices": [], @@ -2225,7 +2848,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "수문장을 넘어야 잿불 골짜기로 가" }, { "beatId": "gd_emberfall", @@ -2236,15 +2860,20 @@ "type": "GuardianDefeat", "param": "emberfall" }, - "speakerNpcId": "ledger_ink", + "speakerNpcId": "ruins_scholar", "lines": [ { - "speaker": "먹", - "text": "골짜기가 조용해졌군. 라온은 잘 있다 — 걷진 못해도 말은 잘한다." + "speaker": "지문", + "text": "용암말벌이 불티를 뿌리며 물러난다.", + "fx": "flash" }, { - "speaker": "먹", - "text": "나는 여기 남아 상자를 마저 꺼내겠다. 자네는 위로 가라. 우듬지가 아직 살아 있다더군." + "speaker": "세라", + "text": "골짜기가 조용해졌어요. 재 밑에서 뭔가 움직여요." + }, + { + "speaker": "세라", + "text": "위를 보세요. 연기 너머로 초록이 보여요. 우듬지예요." } ], "choices": [], @@ -2257,7 +2886,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "수문장을 넘어야 우듬지로 가" }, { "beatId": "gd_canopy", @@ -2270,13 +2900,18 @@ }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "세계수나비가 날개를 접었다 펴자 우듬지가 술렁인다.", + "fx": "flash" + }, { "speaker": "세라", - "text": "세계수나비가 길을 비켜 줬어요. 막아선 게 아니라 시험한 거였나 봐요." + "text": "길을 비켜 줬어요. 막은 게 아니라 시험한 거였나 봐요." }, { "speaker": "세라", - "text": "이제 남은 건 하나예요. 지도에 없는 자리." + "text": "이제 남은 건 하나예요. 지도에 없는 땅." } ], "choices": [], @@ -2289,7 +2924,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "수문장을 넘어야 마지막 땅으로 가" }, { "beatId": "dex_60", @@ -2308,7 +2944,7 @@ }, { "speaker": "세라", - "text": "숫자로 말해서 미안해요. 그런데 이건 숫자가 맞아요 — 예순 개의 자리가 다시 채워졌다는 뜻이니까요." + "text": "숫자로 말해 미안해요. 예순 칸이 다시 채워졌다는 뜻이에요." } ], "choices": [], @@ -2321,7 +2957,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "도감 60종을 채워 보자" }, { "beatId": "dex_100", @@ -2336,11 +2973,16 @@ "lines": [ { "speaker": "세라", - "text": "백 종. …관장님 장부가 삼천인데, 그중 살아 있는 게 절반이라 하셨죠." + "text": "백 종이에요." }, { "speaker": "세라", - "text": "당신 백 종은 전부 살아 있어요. 어느 쪽이 더 큰 숫자인지는 이제 분명하네요." + "text": "명부회 장부엔 더 많겠죠. 그중 몇이 살아 있을까요.", + "fx": "pause" + }, + { + "speaker": "세라", + "text": "당신의 백 종은 전부 살아 있어요." } ], "choices": [], @@ -2353,7 +2995,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "도감 100종을 채워 보자" }, { "beatId": "dex_140", @@ -2368,15 +3011,15 @@ "lines": [ { "speaker": "세라", - "text": "백사십 종이에요. 이 정도면 고대인이 남긴 벽의 절반을 넘겼어요." + "text": "백사십 종. 이름 벽의 절반을 넘겼어요." }, { "speaker": "세라", - "text": "빈칸이 줄어드는 게 눈에 보여요. 그것이 설 자리가 그만큼 좁아졌다는 뜻이고요." + "text": "빈칸이 줄어드는 게 보여요. 그림자가 설 곳도 줄고요." }, { "speaker": "세라", - "text": "…끝까지 가요. 이번엔 우리가 먼저 채워요." + "text": "끝까지 가요. 이번엔 우리가 먼저 채워요." } ], "choices": [], @@ -2389,184 +3032,83 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "도감 140종을 채워 보자" }, { - "beatId": "talk_grip", - "chapterId": "ch8", - "order": 80, - "prerequisiteBeatId": "ch8_confront", + "beatId": "garden_intro", + "chapterId": "side", + "order": 15, + "prerequisiteBeatId": "ch1_reach_pond", "trigger": { - "type": "NpcTalk", - "param": "ledger_grip" + "type": "RegionEnter", + "param": "garden" }, - "speakerNpcId": "ledger_grip", + "speakerNpcId": "catcher_rival", "lines": [ { - "speaker": "집게", - "text": "또 왔군. 창고 얘기라면 그만해라 — 나는 명령을 받은 대로 했다." + "speaker": "지문", + "text": "꽃향기가 훅 끼친다. 날갯소리가 겹겹이 들린다." }, { - "speaker": "집게", - "text": "네가 옳다고 생각하나? 그럼 증명해 봐라. 말이 아니라 손으로." + "speaker": "라온", + "text": "여긴 꽃밭이야. 다른 데는 조용한데, 여긴 멀쩡해!" }, { - "speaker": "집게", - "text": "…한 번 더 말을 걸면 그때는 붙는 거다. 준비하고 와라." + "speaker": "라온", + "text": "저 나비들 봐. 사람이 와도 안 도망가." + }, + { + "speaker": "라온", + "text": "왜 꽃밭만 무사할까? 큰 단서일지도 몰라." } ], "choices": [], "onComplete": { - "rewardCandy": 30, - "rewardExp": 70, + "rewardCandy": 15, + "rewardExp": 25, "rewardItemId": "", "rewardItemCount": 0, "rewardInsectId": "", "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "꽃밭은 곤충이 멀쩡하대" }, { - "beatId": "talk_scale", - "chapterId": "ch9", - "order": 81, - "prerequisiteBeatId": "ch9_confront", + "beatId": "talk_elder", + "chapterId": "npc", + "order": 30, + "prerequisiteBeatId": "", "trigger": { "type": "NpcTalk", - "param": "ledger_scale" + "param": "village_elder" }, - "speakerNpcId": "ledger_scale", + "speakerNpcId": "village_elder", "lines": [ { - "speaker": "저울", - "text": "세라는 대답을 찾았더군. 방향이 다르다고." - }, - { - "speaker": "저울", - "text": "말은 그럴듯하다. 그런데 나는 수치만 믿는다 — 네가 나보다 강하면 그 말이 맞는 거고, 아니면 그냥 감상이다." + "speaker": "마을 어르신", + "text": "오, 왔구나. 얼굴을 보니 마음이 놓여." }, { - "speaker": "저울", - "text": "다시 오면 재 보자. 그때는 봐주지 않는다." + "speaker": "마을 어르신", + "text": "무리하지 말고, 도감을 한 장씩 채워 주렴." } ], "choices": [], "onComplete": { - "rewardCandy": 35, - "rewardExp": 85, + "rewardCandy": 5, + "rewardExp": 0, "rewardItemId": "", "rewardItemCount": 0, "rewardInsectId": "", "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } - }, - { - "beatId": "talk_chief", - "chapterId": "ch12", - "order": 82, - "prerequisiteBeatId": "ch12_confront", - "trigger": { - "type": "NpcTalk", - "param": "ledger_chief" - }, - "speakerNpcId": "ledger_chief", - "lines": [ - { - "speaker": "하월", - "text": "길은 비켜 주었다만, 그냥 보내는 건 또 다른 이야기다." - }, - { - "speaker": "하월", - "text": "이 아이를 보게. 이름이 지워진 나방이야. 삼십 년 전 내 장부 첫 장에 적힌 종이지." - }, - { - "speaker": "하월", - "text": "내가 적어서 살린 게 아니라 적어서 가둔 아이다. 이 아이를 넘지 못하면 자네도 나와 같은 길을 갈 걸세." - }, - { - "speaker": "하월", - "text": "…준비되면 오게. 마지막으로 스승 노릇을 해 주지." - } - ], - "choices": [], - "onComplete": { - "rewardCandy": 60, - "rewardExp": 150, - "rewardItemId": "", - "rewardItemCount": 0, - "rewardInsectId": "", - "rewardInsectDisplayName": "", - "rewardInsectLevel": 0, - "unlockQuestId": "" - } - }, - { - "beatId": "garden_intro", - "chapterId": "side", - "order": 15, - "prerequisiteBeatId": "ch1_reach_pond", - "trigger": { - "type": "RegionEnter", - "param": "garden" - }, - "speakerNpcId": "catcher_rival", - "lines": [ - { - "speaker": "라온", - "text": "여긴 꽃밭이야. …이상하지? 다른 데는 다 조용한데, 여기 곤충들은 멀쩡해." - }, - { - "speaker": "라온", - "text": "왜 꽃밭만 무사한 걸까? 세라한테 물어봐야겠어. 뭔가 큰 단서가 될지도 몰라." - } - ], - "choices": [], - "onComplete": { - "rewardCandy": 15, - "rewardExp": 25, - "rewardItemId": "", - "rewardItemCount": 0, - "rewardInsectId": "", - "rewardInsectDisplayName": "", - "rewardInsectLevel": 0, - "unlockQuestId": "" - } - }, - { - "beatId": "talk_elder", - "chapterId": "npc", - "order": 30, - "prerequisiteBeatId": "", - "trigger": { - "type": "NpcTalk", - "param": "village_elder" - }, - "speakerNpcId": "village_elder", - "lines": [ - { - "speaker": "마을 어르신", - "text": "오, 가까이 왔구나. 이렇게 얼굴을 보니 마음이 놓여. 곤충들이 사라지는 이 시국에, 네가 있어 참 다행이야." - }, - { - "speaker": "마을 어르신", - "text": "무리하진 말고… 허나 도감을 한 장 한 장 채워 주렴. 그게 이 세계를 붙드는 길이란다." - } - ], - "choices": [], - "onComplete": { - "rewardCandy": 5, - "rewardExp": 0, - "rewardItemId": "", - "rewardItemCount": 0, - "rewardInsectId": "", - "rewardInsectDisplayName": "", - "rewardInsectLevel": 0, - "unlockQuestId": "" - }, - "requiredBeatId": "ch1_intro" + }, + "requiredBeatId": "ch1_intro", + "why": "어르신께 인사하자" }, { "beatId": "talk_rival", @@ -2581,11 +3123,11 @@ "lines": [ { "speaker": "라온", - "text": "왔냐. 딱 봐도 또 곤충 잡으러 가는 얼굴인데?" + "text": "왔냐. 또 곤충 잡으러 가는 얼굴인데?" }, { "speaker": "라온", - "text": "나도 질 생각 없거든. 누가 더 많이 구해내나, 계속 겨뤄 보자고!" + "text": "나도 질 생각 없어. 누가 더 많이 구하나 계속 겨루자!" } ], "choices": [], @@ -2599,7 +3141,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "requiredBeatId": "ch1_intro" + "requiredBeatId": "ch1_intro", + "why": "라온에게 인사하자" }, { "beatId": "talk_scholar", @@ -2614,11 +3157,11 @@ "lines": [ { "speaker": "세라", - "text": "당신이군요. 마침 잘 왔어요 — 유적의 봉인에 대해 새로 알아낸 게 있어요." + "text": "마침 잘 왔어요. 유적 벽에 대해 알아낸 게 있어요." }, { "speaker": "세라", - "text": "당신이 모으는 기록 하나하나가 그 균열을 메우고 있어요. 부디, 멈추지 말아요." + "text": "도감에 적을수록 벽의 이름이 되살아나는 것 같아요." } ], "choices": [], @@ -2632,7 +3175,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "requiredBeatId": "ch1_intro" + "requiredBeatId": "ch1_intro", + "why": "세라에게 인사하자" }, { "beatId": "ch2_watchers", @@ -2647,17 +3191,36 @@ "requiredRegionId": "pond", "speakerNpcId": "ledger_thug_cord", "lines": [ + { + "speaker": "지문", + "text": "갈대 사이에 검은 옷의 사내가 서 있다.", + "fx": "dark" + }, + { + "speaker": "검은 옷의 사내", + "text": "방금 잡은 거, 무슨 종이지?" + }, + { + "speaker": "라온", + "text": "인사도 없이 뭐예요? 아저씨는 누군데요?" + }, { "speaker": "검은 옷의 사내", - "text": "…네가 방금 잡은 거, 무슨 종이지?" + "text": "이름은 중요하지 않다. 종이 중요하지.", + "fx": "pause" }, { "speaker": "검은 옷의 사내", - "text": "아니, 됐다. 어차피 곧 전부 장부에 오를 테니." + "text": "됐다. 어차피 곧 전부 장부에 오를 테니.", + "fx": "dark" }, { "speaker": "라온", - "text": "뭐야 저 사람? 이름도 안 밝히고 곤충 종만 물어보네." + "text": "장부? 잡은 곤충 숫자를 적는 책 말이에요?" + }, + { + "speaker": "지문", + "text": "사내가 돌아선다. 허리춤의 빈 채집통이 달각거린다." } ], "choices": [], @@ -2672,7 +3235,9 @@ "unlockQuestId": "" }, "stageEnterId": "st_ch2_cord_turn", - "cutsceneId": "" + "cutsceneId": "", + "videoId": "vid_ch2_watchers", + "why": "갈대 쪽에 수상한 사람이 있어" }, { "beatId": "ch2_emperor", @@ -2685,19 +3250,25 @@ "param": "dragonfly_emperor" }, "requiredRegionId": "pond", - "speakerNpcId": "ruins_scholar", + "speakerNpcId": "catcher_rival", "lines": [ { - "speaker": "세라", - "text": "왕잠자리. 이 연못에서 가장 오래 산 종이에요." + "speaker": "라온", + "text": "왕잠자리다! 이 연못에서 제일 오래 산 종이야!", + "fx": "shout" }, { - "speaker": "세라", - "text": "고대 기록에도 같은 이름이 있어요 — 천 년 전에도 이 물가를 돌았다는 뜻이죠." + "speaker": "라온", + "text": "천 년 전에도 이 물가를 날았대." }, { - "speaker": "세라", - "text": "그런 종이 사라지면, 사라지는 건 한 마리가 아니라 천 년이에요." + "speaker": "지문", + "text": "손안의 날개가 햇빛에 무지갯빛으로 반짝인다." + }, + { + "speaker": "라온", + "text": "이 녀석이 사라지면 천 년이 사라지는 거야.", + "fx": "pause" } ], "choices": [], @@ -2711,7 +3282,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "cutsceneId": "" + "cutsceneId": "", + "why": "연못에 왕잠자리가 산대" }, { "beatId": "ch3_warning", @@ -2728,19 +3300,30 @@ "lines": [ { "speaker": "검은 옷의 여자", - "text": "숲까지 왔군. 부지런하네." + "text": "숲까지 왔군. 충고 하나 하지." }, { - "speaker": "검은 옷의 여자", - "text": "충고 하나 하지. 하나씩 만나고 다니는 건 느려. 그러다 다 놓쳐." + "speaker": "세라", + "text": "누구시죠? 이 숲은 출입을 막는 곳이 아닌데요." }, { "speaker": "검은 옷의 여자", - "text": "전부 한 번에 거둬들이면 될 일을, 왜 그렇게 어렵게 하지?" + "text": "하나씩 만나는 건 너무 느려. 전부 한 번에 거둬들이면 될 것을.", + "fx": "dark" + }, + { + "speaker": "지문", + "text": "여자는 대답을 듣지 않고 숲 안쪽으로 사라진다." + }, + { + "speaker": "세라", + "text": "지금 곤충을 \"거둬들인다\"고 했어요.", + "fx": "whisper" }, { "speaker": "세라", - "text": "…지금 저 사람, 곤충을 \"거둬들인다\"고 했어요." + "text": "채집가는 그런 말 안 써요. 추수할 때나 쓰는 말이에요.", + "fx": "pause" } ], "choices": [], @@ -2755,7 +3338,8 @@ "unlockQuestId": "" }, "stageEnterId": "st_ch3_rule_block", - "cutsceneId": "" + "cutsceneId": "", + "why": "숲에 검은 옷의 여자가 나타났어" }, { "beatId": "ch3_stag", @@ -2772,15 +3356,21 @@ "lines": [ { "speaker": "라온", - "text": "사슴벌레! 숲의 왕이라 불리는 녀석이야." + "text": "사슴벌레다! 숲의 왕이라 불리는 녀석이야!", + "fx": "shout" }, { "speaker": "라온", - "text": "근데 알아? 이 녀석은 나무 한 그루에 평생을 매여 살아. 그 나무가 없어지면 같이 없어져." + "text": "이 녀석은 나무 한 그루에 매여 살아. 나무가 없어지면 끝이야." + }, + { + "speaker": "세라", + "text": "라온, 그건 어디서 배웠어요? 학자들이 하는 얘긴데." }, { "speaker": "라온", - "text": "그러니까 잡는 것보다 어려운 건 — 그 나무를 남겨두는 거야." + "text": "우리 할머니한테. 할머니는 밭 매셨어.", + "fx": "pause" } ], "choices": [], @@ -2794,7 +3384,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "cutsceneId": "" + "cutsceneId": "", + "why": "숲의 왕 사슴벌레를 찾아보자" }, { "beatId": "ch4_harvest", @@ -2814,16 +3405,33 @@ "text": "또 만났군. 여긴 볼 것도 없는데." }, { - "speaker": "검은 옷의 사내", - "text": "…이 상자? 신경 쓸 것 없다. 습지 것들은 어차피 수가 많아." + "speaker": "라온", + "text": "등 뒤에 그거 뭐예요? 상자잖아요." + }, + { + "speaker": "지문", + "text": "상자 틈에서 무언가 파닥이는 소리가 난다.", + "fx": "pause" + }, + { + "speaker": "라온", + "text": "안에 곤충이 있잖아요! 열어 봐요!", + "fx": "shout,shake" }, { "speaker": "검은 옷의 사내", - "text": "많은 건 세어도 티가 안 나거든. 그래서 여기부터 하는 거고." + "text": "습지 것들은 수가 많아. 많은 건 세어도 티가 안 나거든.", + "fx": "dark" }, { "speaker": "세라", - "text": "상자가… 열 개는 넘어 보였어요. 저게 다 살아 있는 거라면." + "text": "라온, 그만. 지금은 안 돼요.", + "fx": "whisper" + }, + { + "speaker": "세라", + "text": "상자가 열 개도 넘었어요. 저게 다 살아 있다면…", + "fx": "pause" } ], "choices": [], @@ -2838,7 +3446,9 @@ "unlockQuestId": "" }, "stageEnterId": "st_ch4_cord_haul", - "cutsceneId": "" + "cutsceneId": "", + "videoId": "vid_ch4_crates", + "why": "습지에 그 사내가 또 나타났어" }, { "beatId": "ch4_firefly", @@ -2855,15 +3465,20 @@ "lines": [ { "speaker": "세라", - "text": "푸른 반딧불이에요. 습지에서만, 그것도 몇 주만 빛나요." + "text": "푸른 반딧불이에요. 습지에서 몇 주만 빛나요." }, { "speaker": "세라", - "text": "옛 기록엔 이 빛으로 밤길을 찾았다고 적혀 있어요." + "text": "옛날엔 이 빛으로 밤길을 찾을 만큼 많았대요." + }, + { + "speaker": "라온", + "text": "이 조그만 빛으로? 와…" }, { "speaker": "세라", - "text": "이 아이들이 사라지면 그 기록은 읽을 수는 있어도 이해할 수 없는 글이 돼요." + "text": "이 아이들이 사라지면 그 밤길도 사라지는 거예요.", + "fx": "pause" } ], "choices": [], @@ -2877,7 +3492,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "cutsceneId": "" + "cutsceneId": "", + "why": "습지의 푸른 반딧불이를 찾자" }, { "beatId": "ch5_blocked", @@ -2894,23 +3510,35 @@ "lines": [ { "speaker": "검은 옷의 여자", - "text": "여기서부터는 그만 올라와." + "text": "여기서부터는 올라오지 마." + }, + { + "speaker": "세라", + "text": "숲에서 만났던 분이네요.", + "fx": "pause" }, { "speaker": "검은 옷의 여자", - "text": "위쪽은 이미 정리가 끝났어. 네가 볼 건 없다는 뜻이야." + "text": "위쪽은 정리가 끝났어. 볼 건 없다." + }, + { + "speaker": "라온", + "text": "정리? 곤충이 무슨 짐이에요?", + "fx": "shout" }, { "speaker": "검은 옷의 여자", - "text": "…그 표정 뭐지? 우리가 나쁜 짓이라도 한다고 생각하나?" + "text": "우리가 나쁜 짓이라도 한다는 얼굴이군." }, { "speaker": "검은 옷의 여자", - "text": "우리는 지키는 쪽이야. 다만 방법이 다를 뿐이지." + "text": "우리도 지키는 쪽이야. 방법이 다를 뿐.", + "fx": "dark" }, { "speaker": "라온", - "text": "저 말… 어디서 많이 듣던 말투인데." + "text": "저 말… 어딘가 이상해.", + "fx": "whisper" } ], "choices": [ @@ -2934,7 +3562,8 @@ "unlockQuestId": "" }, "stageEnterId": "st_ch5_rule_bar", - "cutsceneId": "" + "cutsceneId": "", + "why": "산길을 막는 사람이 있어" }, { "beatId": "ch5_apollo", @@ -2951,15 +3580,20 @@ "lines": [ { "speaker": "세라", - "text": "아폴로나비. 고산에서만 살고, 산을 내려오면 죽어요." + "text": "아폴로나비예요. 높은 산에서만 살아요." }, { "speaker": "세라", - "text": "옮겨 심을 수 없다는 뜻이에요 — 이 산이 이 종의 전부예요." + "text": "산을 내려오면 살 수 없어요. 이 산이 전부예요." + }, + { + "speaker": "라온", + "text": "그럼 이 산이 망가지면…" }, { "speaker": "세라", - "text": "아까 그 사람들이 \"정리가 끝났다\"고 한 게… 이런 뜻은 아니었으면 좋겠어요." + "text": "그 사람들의 '정리'가 그런 뜻이 아니길 바라요.", + "fx": "pause" } ], "choices": [], @@ -2973,7 +3607,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "cutsceneId": "" + "cutsceneId": "", + "why": "산꼭대기 나비가 위험할지 몰라" }, { "beatId": "ch6_ledger_hint", @@ -2994,19 +3629,31 @@ }, { "speaker": "검은 옷의 사내", - "text": "좋아, 하나만 알려주지. 우리는 이름을 적는 사람들이다." + "text": "하나만 알려 주지. 우리는 명부회다." + }, + { + "speaker": "검은 옷의 사내", + "text": "곤충 이름을 남김없이 장부에 적는 사람들이지." }, { "speaker": "검은 옷의 사내", - "text": "빈칸이 재앙을 부르거든. 그래서 남김없이 채우는 거야 — 살아 있든 아니든." + "text": "빈칸이 재앙을 부르거든. 살아 있든 아니든 채운다.", + "fx": "dark" + }, + { + "speaker": "라온", + "text": "살아 있든 아니든…? 그게 무슨—", + "fx": "shout,shake" }, { "speaker": "세라", - "text": "빈칸이라니… 그 말, 어디서 나온 거죠? 그건 고대 기록의 용어예요." + "text": "빈칸은 고대 글에 나오는 말이에요. 어떻게 알죠?", + "fx": "pause" }, { "speaker": "세라", - "text": "저 사람들, 우리보다 이 유적을 먼저 읽었어요." + "text": "저 사람들, 우리보다 먼저 이 유적을 읽었어요.", + "fx": "whisper" } ], "choices": [], @@ -3021,7 +3668,8 @@ "unlockQuestId": "" }, "stageEnterId": "st_ch6_cord_yield", - "cutsceneId": "" + "cutsceneId": "", + "why": "그 사내가 유적까지 쫓아왔어" }, { "beatId": "ch6_relic", @@ -3034,19 +3682,24 @@ "param": "scarab_relic" }, "requiredRegionId": "ruins", - "speakerNpcId": "village_elder", + "speakerNpcId": "ruins_scholar", "lines": [ { - "speaker": "마을 어르신", - "text": "유물풍뎅이로구나. 고대인이 신전을 지키게 했다는 그 종이야." + "speaker": "세라", + "text": "유물풍뎅이예요. 옛날에 신전을 지키던 종이죠." }, { - "speaker": "마을 어르신", - "text": "천 년을 같은 자리에서 같은 일을 해 온 셈이지." + "speaker": "라온", + "text": "천 년 동안 문지기라니. 휴가도 없이?" }, { - "speaker": "마을 어르신", - "text": "…네가 여기까지 왔다는 건, 이제 그 기록을 읽을 준비가 됐다는 뜻이겠구나." + "speaker": "세라", + "text": "…그러네요. 이제 좀 쉬어도 될 텐데.", + "fx": "pause" + }, + { + "speaker": "세라", + "text": "이 아이가 길을 알려 줄 거예요. 신전으로 가요." } ], "choices": [], @@ -3060,7 +3713,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "cutsceneId": "" + "cutsceneId": "", + "why": "신전을 지키던 풍뎅이가 있대" }, { "beatId": "bl_forest_arrive", @@ -3077,23 +3731,23 @@ "lines": [ { "speaker": "검은 옷의 청년", - "text": "…이쪽은 안 돼. 통제 구역이야." + "text": "이쪽은 안 돼. 통제 구역이야." }, { "speaker": "검은 옷의 청년", - "text": "그물은 아침에 걷어. 그때까진 아무것도 못 지나가." + "text": "그물은 아침에 걷어. 그때까진 못 지나가." }, { "speaker": "세라", - "text": "그물이… 나무와 나무 사이를 통째로 가로질러 쳐져 있어요." + "text": "나무 사이를 통째로 가로지른 그물이에요." }, { "speaker": "세라", - "text": "저건 한 마리를 잡는 그물이 아니에요. 지나가는 걸 전부 받는 그물이에요." + "text": "한 마리가 아니라, 지나가는 걸 전부 잡는 그물이에요." }, { "speaker": "검은 옷의 청년", - "text": "…나는 치라니까 친 거야. 그 이상은 몰라." + "text": "나는 치라니까 친 거야. 그 이상은 몰라." } ], "choices": [], @@ -3106,7 +3760,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "숲에 커다란 그물이 쳐졌대" }, { "beatId": "bl_forest_sign", @@ -3122,11 +3777,11 @@ "lines": [ { "speaker": "세라", - "text": "방금 그 한 마리, 오늘 이 숲에서 처음 본 거예요." + "text": "방금 만난 아이도 이 숲에서 버틴 아이겠죠." }, { "speaker": "세라", - "text": "숲이 이렇게 조용할 리가 없어요. 여긴 원래 가장 시끄러운 곳이에요." + "text": "빼앗긴 소리를 되찾으려면 하나씩 만나야 해요." } ], "choices": [], @@ -3139,7 +3794,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "그물 너머 곤충을 만나자" }, { "beatId": "bl_forest_confront", @@ -3155,27 +3811,29 @@ "lines": [ { "speaker": "검은 옷의 청년", - "text": "…또 왔네. 그물 걷어 달라고?" + "text": "또 왔네. 그물 걷어 달라고?" }, { "speaker": "검은 옷의 청년", - "text": "내가 친 것도 아니야. 위에서 치라니까 쳤어." + "text": "내가 정한 것도 아니야. 위에서 시켰어." }, { "speaker": "검은 옷의 청년", - "text": "몇 마리가 걸리든 그건 내 몫이 아니고." + "text": "몇 마리가 걸리든 내 몫이 아니고." }, { "speaker": "세라", - "text": "당신 몫이 아니면, 누구 몫이죠?" + "text": "당신 몫이 아니면, 누구 몫이죠?", + "fx": "pause" }, { "speaker": "검은 옷의 청년", - "text": "…" + "text": "…", + "fx": "pause" }, { "speaker": "세라", - "text": "대답을 못 하시네요. 그럼 지금은 당신 몫이에요." + "text": "대답을 못 하네요. 그럼 지금은 당신 몫이에요." } ], "choices": [], @@ -3189,7 +3847,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "stageEnterId": "st_bl_forest_confront" + "stageEnterId": "st_bl_forest_confront", + "why": "그물 친 청년을 다시 만나자" }, { "beatId": "bl_forest_clash", @@ -3205,11 +3864,11 @@ "lines": [ { "speaker": "세라", - "text": "그물이 성글어졌어요. 걸리는 게 줄었다는 뜻이 아니라—" + "text": "그물이 성글어졌어요." }, { "speaker": "세라", - "text": "빠져나가는 게 늘었다는 뜻이에요." + "text": "빠져나가는 아이가 늘었다는 뜻이에요." } ], "choices": [], @@ -3222,7 +3881,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "숲 곤충과 겨뤄 그물을 풀자" }, { "beatId": "bl_forest_restore", @@ -3236,17 +3896,28 @@ }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "그물이 걷히자 가지 사이로 무언가 파랗게 스친다.", + "fx": "flash" + }, { "speaker": "검은 옷의 청년", - "text": "…걷을게. 어차피 아침엔 걷는 거였어." + "text": "걷을게. 어차피 아침엔 걷는 거였어." }, { "speaker": "세라", - "text": "로살리아하늘소예요. 도감엔 이름이 있는데, 실물을 본 사람은 거의 없는—" + "text": "로살리아하늘소예요! 본 사람이 거의 없는 아이요." }, { "speaker": "세라", - "text": "…있었네요. 계속 여기 있었어요. 지나갈 수가 없었을 뿐이에요." + "text": "계속 여기 있었어요. 그물 때문에 못 지나갔을 뿐이에요.", + "fx": "pause" + }, + { + "speaker": "라온", + "text": "날개에 하늘이 박혀 있어!", + "fx": "shout" } ], "choices": [], @@ -3260,7 +3931,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "cutsceneId": "cs_bl_cleanse" + "cutsceneId": "cs_bl_cleanse", + "why": "숲의 그물을 걷어 내자" }, { "beatId": "bl_mountain_arrive", @@ -3281,11 +3953,11 @@ }, { "speaker": "검은 옷의 여자", - "text": "저 능선을 봐라. 그물을 쳐 뒀다. 여기서 나는 건 전부 우리 장부로 간다." + "text": "저 능선에 그물을 쳤다. 다 우리 장부로 간다." }, { "speaker": "세라", - "text": "…능선이 조용해요. 이 시간이면 뭐라도 울어야 하는데요." + "text": "능선이 조용해요. 이 시간엔 뭐라도 울어야 하는데요." }, { "speaker": "검은 옷의 여자", @@ -3293,11 +3965,11 @@ }, { "speaker": "세라", - "text": "그게 지키는 건가요? 이름만 남기고 나머지를 전부 상자에 넣는 게요." + "text": "이름만 남기고 전부 상자에 넣는 게 지키는 건가요?" }, { "speaker": "검은 옷의 여자", - "text": "이름이 남으면 종은 지워지지 않아. 그게 우리 방식이다." + "text": "이름이 남으면 종은 안 지워져. 그게 우리 방식이다." } ], "choices": [], @@ -3310,7 +3982,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "산 능선에 그물이 쳐졌대" }, { "beatId": "bl_mountain_sign", @@ -3326,15 +3999,15 @@ "lines": [ { "speaker": "세라", - "text": "이상해요. 예전에 이 고도에서 잡히던 게 이만큼이 아니었어요." + "text": "옛날 조사엔 이 높이에 곤충이 훨씬 많았어요." }, { "speaker": "세라", - "text": "잦아듦은 종이 '지워지는' 거예요. 이건 달라요 — 지워진 게 아니라 가져간 거예요." + "text": "잦아듦으로 사라진 것과 명부회가 걷어 간 걸 나눠 적어요." }, { "speaker": "세라", - "text": "빈칸이 늘까 봐 걷는다면서, 걷어서 빈칸을 만들고 있어요." + "text": "빈칸을 막겠다며 걷어 간 일이 빈칸을 만들었어요." } ], "choices": [], @@ -3347,7 +4020,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "능선 곤충을 다시 만나자" }, { "beatId": "bl_mountain_confront", @@ -3363,19 +4037,21 @@ "lines": [ { "speaker": "검은 옷의 여자", - "text": "…장부는 정직하다. 몇 마리를 어디서 언제 걷었는지 다 적혀 있어." + "text": "장부는 정직하다. 몇 마리를 언제 걷었는지 다 있어." }, { "speaker": "검은 옷의 여자", - "text": "그 다음 줄은 안 적는다. 그 아이들이 그 뒤에 어떻게 됐는지는." + "text": "그 뒤에 어떻게 됐는지는 안 적는다.", + "fx": "dark" }, { "speaker": "세라", - "text": "적으세요. 거기까지 적으면 그건 장부가 아니라 기록이에요." + "text": "거기까지 적으세요. 그래야 장부가 아니라 도감이에요." }, { "speaker": "검은 옷의 여자", - "text": "…비켜라. 여긴 내 현장이다." + "text": "…비켜라. 여긴 내 현장이다.", + "fx": "shake" } ], "choices": [], @@ -3389,7 +4065,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "stageEnterId": "st_bl_mountain_confront" + "stageEnterId": "st_bl_mountain_confront", + "why": "능선의 여자를 다시 만나자" }, { "beatId": "bl_mountain_clash", @@ -3405,11 +4082,11 @@ "lines": [ { "speaker": "세라", - "text": "그물이 아직 남아 있는데도 잡히는 게 늘었어요." + "text": "휩쓸고 간 능선에서도 살아 있는 아이를 만났어요." }, { "speaker": "세라", - "text": "땅은 살아 있어요. 걷어 가는 손만 없으면 돌아와요." + "text": "땅에는 아직 돌아올 자리가 있어요." } ], "choices": [], @@ -3422,7 +4099,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "산 곤충과 겨뤄 그물을 풀자" }, { "beatId": "bl_mountain_restore", @@ -3438,15 +4116,21 @@ "lines": [ { "speaker": "세라", - "text": "…소리가 돌아왔어요." + "text": "소리가 돌아왔어요.", + "fx": "pause" + }, + { + "speaker": "라온", + "text": "들려! 능선 쪽에서 뭐가 울어!", + "fx": "shout" }, { "speaker": "세라", - "text": "돌아온 게 이긴 건 아니에요. 원래 있던 걸 되돌린 것뿐이죠." + "text": "이긴 게 아니라 원래 있던 걸 되돌린 거예요." }, { "speaker": "세라", - "text": "그 사이에 못 돌아온 것도 있을 거예요. 그건 장부에도 안 적혀 있고요." + "text": "못 돌아온 아이도 있을 거예요. 장부에도 없는 아이들이요." } ], "choices": [], @@ -3460,7 +4144,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "cutsceneId": "cs_bl_cleanse" + "cutsceneId": "cs_bl_cleanse", + "why": "능선의 그물을 걷어 내자" }, { "beatId": "bl_ruins_arrive", @@ -3477,19 +4162,25 @@ "lines": [ { "speaker": "검은 옷의 사내", - "text": "여긴 걷는 데가 아니라 쌓는 데다." + "text": "여긴 걷는 데가 아니라 쌓는 데다.", + "fx": "dark" }, { "speaker": "검은 옷의 사내", - "text": "능선에서 걷은 게 전부 여기로 온다. 상자마다 이름표가 붙지." + "text": "능선에서 걷은 게 전부 여기로 온다." }, { "speaker": "세라", - "text": "…상자가 몇 개죠?" + "text": "상자가 몇 개죠?" }, { "speaker": "검은 옷의 사내", "text": "세 봐야 소용없다. 숫자는 장부에 있으니까." + }, + { + "speaker": "라온", + "text": "왜 소용이 없어요. 하나하나 다 살아 있는데!", + "fx": "shout" } ], "choices": [], @@ -3502,7 +4193,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "유적에 상자가 쌓여 있대" }, { "beatId": "bl_ruins_sign", @@ -3518,11 +4210,11 @@ "lines": [ { "speaker": "세라", - "text": "저 상자들, 뚜껑이 안 열린 지 오래됐어요." + "text": "유적엔 곤충을 오래 가둔 흔적이 있어요." }, { "speaker": "세라", - "text": "기록은 늘고 살아 있는 건 줄어요. 두 숫자가 반대로 가는데 아무도 안 봐요." + "text": "장부의 이름이 늘어도, 살아 있는 아이는 줄 수 있어요." } ], "choices": [], @@ -3535,7 +4227,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "유적 곤충을 다시 만나자" }, { "beatId": "bl_ruins_confront", @@ -3551,11 +4244,16 @@ "lines": [ { "speaker": "검은 옷의 사내", - "text": "…나도 안다. 열어 본 적 있으니까." + "text": "나도 안다. 상자를 열어 본 적 있으니까.", + "fx": "pause" + }, + { + "speaker": "검은 옷의 사내", + "text": "관장님은 적는 게 먼저라고 하셨다." }, { "speaker": "검은 옷의 사내", - "text": "관장님은 기록이 먼저라고 하셨다. 먼저라면 나중이 있어야 하는데, 그 나중이 안 왔다." + "text": "먼저라면 나중이 있어야 하는데, 안 왔다." }, { "speaker": "세라", @@ -3563,7 +4261,7 @@ }, { "speaker": "검은 옷의 사내", - "text": "…내가 그럴 급이 아니야. 여기까지다." + "text": "내가 그럴 급이 아니야. 여기까지다." } ], "choices": [], @@ -3577,7 +4275,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "stageEnterId": "st_bl_ruins_confront" + "stageEnterId": "st_bl_ruins_confront", + "why": "상자 지키는 사내를 다시 만나자" }, { "beatId": "bl_ruins_clash", @@ -3597,7 +4296,7 @@ }, { "speaker": "세라", - "text": "아니었어요. 조용하게 만든 거였어요." + "text": "아니었어요. 누가 조용하게 만든 거였어요." } ], "choices": [], @@ -3610,7 +4309,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "유적 곤충과 겨뤄 상자를 열자" }, { "beatId": "bl_ruins_restore", @@ -3624,13 +4324,24 @@ }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "상자 뚜껑이 하나씩 열린다. 날개들이 빠져나온다.", + "fx": "flash" + }, { "speaker": "세라", - "text": "상자가 열렸어요. …전부는 아니지만요." + "text": "상자가 열렸어요. 전부는 아니지만요.", + "fx": "pause" + }, + { + "speaker": "라온", + "text": "전부가 아니면… 나머지는?", + "fx": "whisper" }, { "speaker": "세라", - "text": "이 기록은 우리가 이어서 쓰죠. 걷었다는 줄 다음에, 돌려보냈다는 줄을." + "text": "장부에 이어서 적어요. '돌려보냈다'는 줄을요." } ], "choices": [], @@ -3644,7 +4355,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "cutsceneId": "cs_bl_cleanse" + "cutsceneId": "cs_bl_cleanse", + "why": "유적의 상자를 열어 주자" }, { "beatId": "garden_sign", @@ -3660,11 +4372,11 @@ "lines": [ { "speaker": "라온", - "text": "봤어? 방금 그 녀석, 네가 손 뻗을 때까지 그냥 있었어. 도망칠 생각을 안 해." + "text": "방금 그 녀석, 네가 손 뻗을 때까지 그냥 있었어." }, { "speaker": "라온", - "text": "다른 데선 인기척만 나도 숨는데. …여긴 뭘 무서워해 본 적이 없는 애들 같아." + "text": "여긴 겁나 본 적이 없는 애들 같아." } ], "choices": [], @@ -3677,7 +4389,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "꽃밭 곤충도 도감에 적자" }, { "beatId": "garden_glass", @@ -3693,15 +4406,15 @@ "lines": [ { "speaker": "세라", - "text": "유리 아래를 보세요. 바닥에 문양이 있어요 — 흙에 그린 게 아니라 돌에 새긴 거예요." + "text": "유리 아래 바닥 보세요. 돌에 이름이 새겨져 있어요." }, { "speaker": "세라", - "text": "누가 이걸 유리로 덮어 뒀어요. 비바람에 닳지 말라고요." + "text": "누가 비바람에 닳지 말라고 유리로 덮어 뒀어요." }, { "speaker": "세라", - "text": "…기록을 지키려고 지붕까지 씌운 사람이 있었다는 뜻이에요. 이 세상에서 여기 한 곳만." + "text": "이름을 지키려고 지붕까지 씌운 거예요. 여기 한 곳만요." } ], "choices": [], @@ -3715,7 +4428,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "stageEnterId": "st_garden_scholar_glass" + "stageEnterId": "st_garden_scholar_glass", + "why": "유리 온실에 무언가 있대" }, { "beatId": "garden_clash", @@ -3732,15 +4446,15 @@ "lines": [ { "speaker": "세라", - "text": "방금 그 아이, 같은 레벨 다른 리전 개체보다 확실히 셌어요. 느끼셨죠?" + "text": "방금 그 아이, 다른 곳 아이들보다 확실히 셌죠?" }, { "speaker": "세라", - "text": "곤충이 약해지는 건 수가 줄어서예요. 겨룰 상대가 없으면 겨뤄 볼 일도 없으니까요." + "text": "여기 아이들은 한 번도 줄어든 적이 없어서예요." }, { "speaker": "세라", - "text": "여기 아이들은 한 번도 줄어든 적이 없다는 얘기예요. 이 세계에서 여기만요." + "text": "이 세상에서 여기만요." } ], "choices": [], @@ -3753,7 +4467,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "꽃밭 곤충과 겨뤄 보자" }, { "beatId": "garden_fence", @@ -3768,19 +4483,19 @@ "lines": [ { "speaker": "세라", - "text": "이 미로… 자란 게 아니에요. 놓인 거예요." + "text": "이 미로는 저절로 자란 게 아니에요. 누가 심었어요." }, { "speaker": "세라", - "text": "갈림길 간격이 전부 같아요. 씨가 이렇게 자랄 리 없어요. 누가 심고, 누가 다듬었어요." + "text": "갈림길 간격이 전부 같거든요." }, { "speaker": "세라", - "text": "고대인은 울타리를 한 군데만 친 게 아니었어요. 여기에 하나 더 쳤어요." + "text": "옛사람들이 여기에도 울타리를 쳤던 거예요." }, { "speaker": "세라", - "text": "왜 두 번이어야 했는지는 아직 모르겠어요. …라온한테는 답이 반쯤 나왔다고 해 두세요." + "text": "왜 하나 더 쳤는지는 아직 몰라요." } ], "choices": [], @@ -3793,7 +4508,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "꽃밭 미로에 비밀이 있대" }, { "beatId": "gd_garden", @@ -3808,15 +4524,19 @@ "lines": [ { "speaker": "세라", - "text": "호랑나비가 길을 비켜 줬어요. …비켜 줄 길이랄 게 없는 자리인데도요." + "text": "호랑나비가 길을 비켜 줬어요." + }, + { + "speaker": "세라", + "text": "이 아이는 나가는 걸 막은 게 아니에요." }, { "speaker": "세라", - "text": "이 아이는 나가는 걸 막고 있던 게 아니에요. 들어오는 걸 막고 있었어요." + "text": "들어오는 걸 막고 있었어요." }, { "speaker": "세라", - "text": "울타리를 친 사람들은 문지기도 함께 뒀나 봐요. 이 그물, 여기서 나온 거예요. 받아 두세요." + "text": "이 그물, 여기서 나왔어요. 받아 두세요." } ], "choices": [], @@ -3829,7 +4549,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "수문장이 꽃밭을 지키고 있어" }, { "beatId": "ch1_cave", @@ -3844,11 +4565,11 @@ "lines": [ { "speaker": "마을 어르신", - "text": "굴 안이구나. 볕이 안 드는 자리에는 볕을 싫어하는 아이들이 산단다." + "text": "굴 안이구나. 못 만난 곤충은 이런 데 숨어 있단다." }, { "speaker": "마을 어르신", - "text": "이런 자리를 그냥 지나치지 말거라. 도감의 빈칸은 대개 이런 데 숨어 있어." + "text": "이런 데를 그냥 지나치지 말거라." } ], "choices": [], @@ -3861,7 +4582,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "숨은 곤충은 굴에 있대" }, { "beatId": "ch2_reeds", @@ -3876,15 +4598,11 @@ "lines": [ { "speaker": "라온", - "text": "갈대밭이야. 밤에 우는 애들이 낮엔 여기 숨어 있어." - }, - { - "speaker": "라온", - "text": "근데 이상해 — 여긴 소리가 나. 바깥 연못은 아까부터 조용했잖아." + "text": "갈대밭이야. 밤에 우는 애들이 낮엔 여기 숨어." }, { "speaker": "라온", - "text": "숨을 데가 있으면 남는 걸까? 아니면 여기는 아직 차례가 안 온 걸까." + "text": "근데 여긴 소리가 나. 숨을 데가 있으면 남는 걸까?" } ], "choices": [], @@ -3897,7 +4615,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "갈대밭에 숨은 곤충이 있대" }, { "beatId": "ch4_fog", @@ -3912,15 +4631,11 @@ "lines": [ { "speaker": "세라", - "text": "안개 속은 조심하세요. 여기 사는 아이들은 형체를 흐리는 데 능해요." - }, - { - "speaker": "세라", - "text": "…이상하죠. 안 보이는 것과 없는 것은 다른데, 우리는 늘 같은 말로 불러요. 사라졌다고요." + "text": "안개 속 아이들은 숨는 데 능해요. 안 보여도 없는 건 아니에요." }, { "speaker": "세라", - "text": "이 습지에서 사라진 아이들 중 몇은, 어쩌면 아직 여기 있을지도 몰라요." + "text": "사라진 아이 중 몇은 아직 여기 있을지도 몰라요." } ], "choices": [], @@ -3933,7 +4648,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "안개 속에 숨은 곤충이 있대" }, { "beatId": "ch2_clash", @@ -3949,15 +4665,16 @@ "lines": [ { "speaker": "라온", - "text": "물가 애들은 초원이랑 싸우는 법이 달라. 봤지? 물러섰다가 파고들잖아." + "text": "물가 애들은 싸우는 법이 달라. 물러섰다 파고들잖아." }, { "speaker": "라온", - "text": "나도 여기서 세 번 졌어. 세 번 지고 나서야 알았지." + "text": "난 여기서 세 번 지고 나서야 알았어." }, { "speaker": "라온", - "text": "넌 한 번에 알았네. …야, 그거 좀 얄미운데." + "text": "넌 한 번에 알았네. …좀 얄밉다.", + "fx": "pause" } ], "choices": [], @@ -3970,7 +4687,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "연못 곤충과 겨뤄 보자" }, { "beatId": "ch5_clash", @@ -3986,15 +4704,16 @@ "lines": [ { "speaker": "세라", - "text": "여기까지 올라온 아이들은 다들 한 번씩 무언가를 견딘 애들이에요." + "text": "여기 아이들은 다들 힘든 걸 견뎌 온 애들이에요." }, { "speaker": "세라", - "text": "산은 약한 개체를 남겨 두지 않아요. 그래서 여기 종들이 잦아듦에도 가장 오래 버텼죠." + "text": "그래서 잦아듦에도 가장 오래 버텼죠." }, { "speaker": "세라", - "text": "그런데 그 아이들마저 줄고 있어요. 견디는 걸로는 안 되는 일이 벌어지고 있다는 뜻이에요." + "text": "그런데 이 아이들마저 줄고 있어요. 견디는 걸로는 안 돼요.", + "fx": "pause" } ], "choices": [], @@ -4007,7 +4726,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "산 곤충과 겨뤄 보자" }, { "beatId": "ch2_echo", @@ -4023,11 +4743,15 @@ "lines": [ { "speaker": "라온", - "text": "나 사실 너보다 먼저 시작했어. 근데 도감은 네가 더 빨라." + "text": "사실 내가 너보다 먼저 시작했어. 근데 도감은 네가 빨라." }, { "speaker": "라온", - "text": "억울하냐고? …좀. 근데 그것보다, 내 도감이 안 느는 이유가 내 탓이 아닌 것 같아서 그게 더 무서워." + "text": "억울하냐고? 좀. 근데 더 무서운 게 있어." + }, + { + "speaker": "라온", + "text": "내 도감이 안 느는 게 내 탓이 아닌 것 같거든." } ], "choices": [], @@ -4040,7 +4764,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "라온이 할 말이 있대" }, { "beatId": "ch3_echo", @@ -4056,15 +4781,15 @@ "lines": [ { "speaker": "세라", - "text": "제가 왜 유적이 아니라 숲에 있었는지 궁금하셨죠?" + "text": "제가 왜 유적이 아니라 숲에 있었는지 궁금했죠?" }, { "speaker": "세라", - "text": "유적 벽의 문양과 같은 각인이 숲 동굴에도 있거든요. 유적 밖에서 나온 첫 사례예요." + "text": "숲 동굴에서 유적과 같은 무늬를 처음 찾았거든요." }, { "speaker": "세라", - "text": "봉인이 유적에만 있는 게 아니라면… 이 이야기는 제가 생각한 것보다 훨씬 커요." + "text": "이 일은 제 생각보다 훨씬 클지도 몰라요." } ], "choices": [], @@ -4077,7 +4802,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "세라가 할 말이 있대" }, { "beatId": "ch4_echo", @@ -4097,7 +4823,7 @@ }, { "speaker": "라온", - "text": "습지 들어와 보고 알았어. 이건 누가 더 빨리 잡느냐 문제가 아니야." + "text": "습지에 와 보니 알겠어. 빨리 잡는 게 문제가 아니야." }, { "speaker": "라온", @@ -4114,7 +4840,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "라온이 할 말이 있대" }, { "beatId": "ch5_echo", @@ -4130,15 +4857,15 @@ "lines": [ { "speaker": "세라", - "text": "여기 오면서 리전마다 도감을 대조해 봤어요. 사라지는 데 순서가 있어요." + "text": "지역마다 도감을 비교해 봤어요. 사라지는 순서가 있어요." }, { "speaker": "세라", - "text": "흔한 종이 먼저 지워지고, 그다음이 그 종을 먹던 종이에요. 아래부터 무너지는 거예요." + "text": "흔한 종이 먼저, 그다음은 그 종을 먹던 종이에요." }, { "speaker": "세라", - "text": "그러니까 이건 누가 잡아가는 게 아니에요. …아니, 그것도 있지만, 그것만은 아니에요." + "text": "아래부터 무너지는 거죠. 잡아가는 것만이 문제가 아니에요." } ], "choices": [], @@ -4151,7 +4878,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "세라가 무언가를 알아냈대" }, { "beatId": "ch6_echo", @@ -4167,15 +4895,15 @@ "lines": [ { "speaker": "세라", - "text": "여기 오기 전에 스승님께 편지를 썼어요. 답이 없네요." + "text": "스승님께 편지를 썼는데, 답이 없어요." }, { "speaker": "세라", - "text": "그분이라면 벌써 답을 내셨을 거예요. 저보다 삼십 년 먼저 이 벽을 읽으신 분이니까요." + "text": "저보다 삼십 년 먼저 이 벽을 읽으신 분이에요." }, { "speaker": "세라", - "text": "답을 내고도 안 알려 주시는 거라면… 그건 그것대로 무서운 얘기죠." + "text": "답을 알고도 안 알려 주시는 거라면… 무서운 일이죠." } ], "choices": [], @@ -4188,7 +4916,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "세라가 스승님 얘기를 한대" }, { "beatId": "gd_pond", @@ -4202,16 +4931,18 @@ "speakerNpcId": "catcher_rival", "lines": [ { - "speaker": "라온", - "text": "왕잠자리를 이겼어?! 저거 이 근방에서 제일 큰 놈인데." + "speaker": "지문", + "text": "거대한 날개가 떨리더니, 수면을 스치며 물러난다.", + "fx": "flash" }, { "speaker": "라온", - "text": "근데 좀 이상하지 않아? 저만한 게 왜 이런 데 혼자 서 있었을까." + "text": "왕잠자리를 이겼어?! 여기서 제일 큰 녀석인데!", + "fx": "shout" }, { "speaker": "라온", - "text": "지킬 게 있으니까 서 있었던 거 아냐? …뭘 지켰는진 모르겠지만." + "text": "근데 저 녀석, 뭔가 지키고 있었던 것 같지 않아?" } ], "choices": [], @@ -4224,7 +4955,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "수문장을 넘어야 숲으로 가" }, { "beatId": "gd_forest", @@ -4238,16 +4970,18 @@ "speakerNpcId": "ruins_scholar", "lines": [ { - "speaker": "세라", - "text": "헤라클레스가 물러났어요. …진 게 아니라 물러났다고 하는 게 맞아요." + "speaker": "지문", + "text": "뿔이 땅을 긁던 소리가 멎는다. 헤라클레스가 물러난다.", + "fx": "shake" }, { "speaker": "세라", - "text": "저 종은 원래 이 숲 가장 안쪽에 살아요. 왜 길목까지 나와 있었을까요." + "text": "진 게 아니라 물러난 거예요. 왜 길목까지 나왔을까요." }, { "speaker": "세라", - "text": "안쪽에 지킬 게 없어졌거나, 바깥에서 막을 게 생겼거나. 둘 중 하나예요." + "text": "안쪽에 지킬 게 없어졌거나, 막을 게 생겼거나요.", + "fx": "pause" } ], "choices": [], @@ -4260,7 +4994,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "수문장을 넘어야 습지로 가" }, { "beatId": "gd_swamp", @@ -4274,16 +5009,18 @@ "speakerNpcId": "catcher_rival", "lines": [ { - "speaker": "라온", - "text": "…저거 진짜 있었던 거 맞지? 이겼는데도 뭘 이겼다는 느낌이 안 들어." + "speaker": "지문", + "text": "안개가 한꺼번에 걷힌다. 서 있던 자리가 비어 있다.", + "fx": "flash" }, { - "speaker": "라온", - "text": "유령 사마귀라더니 이름값 하네. 안개 걷히니까 자리만 남았어." + "speaker": "세라", + "text": "자리는 남았어요. 돌아올 곳이 있다는 뜻이에요." }, { "speaker": "라온", - "text": "야, 우리가 쫓는 게 계속 이런 거면… 나중엔 뭘 놓쳤는지도 모르게 되는 거 아냐?" + "text": "우리가 쫓는 게 다 이런 거면… 놓친 것도 모르겠네.", + "fx": "pause" } ], "choices": [], @@ -4296,7 +5033,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "수문장을 넘어야 산으로 가" }, { "beatId": "gd_mountain", @@ -4310,16 +5048,17 @@ "speakerNpcId": "ruins_scholar", "lines": [ { - "speaker": "세라", - "text": "아틀라스나방이 길을 열었어요. 이 위로는 이제 유적뿐이에요." + "speaker": "지문", + "text": "큰 날개가 펄럭이자 산바람이 뒤집힌다.", + "fx": "shake" }, { "speaker": "세라", - "text": "…여기까지 오면서 몇 종을 새기셨죠? 제 기록으로는 이 산에서만 열 종이 넘어요." + "text": "아틀라스나방이 길을 열었어요. 이 위는 유적뿐이에요." }, { "speaker": "세라", - "text": "당신이 지나온 자리마다 도감이 채워졌어요. 그게 무슨 뜻인지는 유적에서 말씀드릴게요." + "text": "이 산에서만 열 종 넘게 적으셨어요. 그 뜻은 유적에서 말할게요." } ], "choices": [], @@ -4332,7 +5071,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "수문장을 넘어야 유적으로 가" }, { "beatId": "gd_nameless", @@ -4346,16 +5086,18 @@ "speakerNpcId": "ruins_scholar", "lines": [ { - "speaker": "세라", - "text": "…이 아이한테는 이름이 없어요. 도감에 올릴 칸은 있는데, 칸 안이 비어 있어요." + "speaker": "지문", + "text": "이름 없는 사마귀가 무릎을 꿇는다. 몸이 여러 모양으로 번진다.", + "fx": "shake" }, { "speaker": "세라", - "text": "그것이 빌려 쓰다 놓고 간 자리예요. 여기 있는 것들은 전부 그래요." + "text": "이 아이는 이름이 없어요. 그림자가 빌려 쓰다 버린 몸이에요." }, { "speaker": "세라", - "text": "이 아이는 나중에 꼭 다시 만나요. 이름을 돌려줄 수 있는 건 당신뿐이에요." + "text": "나중에 꼭 다시 만나요. 이름을 돌려줄 수 있어요.", + "fx": "pause" } ], "choices": [], @@ -4368,7 +5110,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "이름 없는 사마귀가 길을 막아" }, { "beatId": "ch5_blocked_refute", @@ -4383,11 +5126,16 @@ "lines": [ { "speaker": "검은 옷의 여자", - "text": "…지키는 게 아니면 뭐지? 네가 여기서 본 게 전부라고 생각하나." + "text": "지키는 게 아니면 뭐지? 네가 본 게 전부라고?" + }, + { + "speaker": "지문", + "text": "여자의 눈이 잠깐 능선 쪽을 향했다가 돌아온다." }, { "speaker": "라온", - "text": "말로는 못 이기겠다. 근데 방금 저 사람, 눈을 피했어. 봤지?" + "text": "방금 눈 피한 거 봤지? 뭔가 숨기고 있어.", + "fx": "whisper" } ], "choices": [], @@ -4415,11 +5163,17 @@ "lines": [ { "speaker": "검은 옷의 여자", - "text": "그래. 우린 전부 적고, 넌 하나씩 만나지. 어느 쪽이 먼저 끝나는지는 두고 보면 알아." + "text": "그래. 우린 전부 적고, 넌 하나씩 만나지." + }, + { + "speaker": "검은 옷의 여자", + "text": "어느 쪽이 먼저 끝나는지 두고 보자.", + "fx": "dark" }, { "speaker": "세라", - "text": "…'끝난다'는 게 뭔지는 말하지 않네요. 그 말이 제일 마음에 걸려요." + "text": "'끝난다'는 게 뭔지는 말하지 않네요.", + "fx": "pause" } ], "choices": [], @@ -4445,13 +5199,22 @@ }, "speakerNpcId": "ledger_scale", "lines": [ + { + "speaker": "지문", + "text": "저울의 눈썹이 아주 조금 움직인다." + }, { "speaker": "저울", "text": "…방향. 세라가 못 한 대답을 네가 하는군." }, { "speaker": "세라", - "text": "당신이 먼저 말해 줬어요. 저는… 아직 제 입으로는 못 하겠어요. 다음엔 제가 말할게요." + "text": "당신이 먼저 말해 줬네요. 다음엔 제가 말할게요." + }, + { + "speaker": "저울", + "text": "말은 들었다. 이제 실력을 재 보자. 봐주지 않는다.", + "fx": "dark" } ], "choices": [], @@ -4477,13 +5240,23 @@ }, "speakerNpcId": "ledger_scale", "lines": [ + { + "speaker": "지문", + "text": "한동안 얼음 갈라지는 소리만 난다.", + "fx": "pause" + }, { "speaker": "저울", - "text": "정직하군. 정직한 건 내 자로는 안 재지지만 — 기억은 해 두마." + "text": "정직하군. 내 자로는 못 재지만, 기억은 해 두마." }, { "speaker": "세라", - "text": "…같이 못 한 거네요, 우리. 그럼 같이 찾아요. 대답은 이 땅 어딘가에 있을 거예요." + "text": "그럼 같이 찾아요. 대답은 이 땅 어딘가에 있을 거예요." + }, + { + "speaker": "저울", + "text": "대답 대신 실력을 재 보자. 봐주지 않는다.", + "fx": "dark" } ], "choices": [], @@ -4509,13 +5282,24 @@ }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "당신은 끝까지 입을 다문다.", + "fx": "pause" + }, { "speaker": "세라", - "text": "…그래요. 그게 답이에요." + "text": "그래요. 그게 답이에요." + }, + { + "speaker": "지문", + "text": "그림자가 크게 일렁인다. 설 자리를 찾지 못한다.", + "fx": "shake" }, { "speaker": "세라", - "text": "그것이 물러서요. 아니 — 자리를 못 찾는 거예요. 지금이에요, 가요." + "text": "지금이에요! 그림자와 맞서요!", + "fx": "shout" } ], "choices": [], @@ -4541,13 +5325,23 @@ }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "지문", + "text": "당신이 입을 열려는 순간—" + }, { "speaker": "세라", - "text": "안 돼요 — 아직 입 밖에 안 냈어요. 삼키세요. 그대로요." + "text": "안 돼요! 아직 말 안 했죠? 삼키세요!", + "fx": "shout" + }, + { + "speaker": "지문", + "text": "세라가 당신의 손목을 꽉 잡는다. 그림자가 흐려진다.", + "fx": "shake" }, { "speaker": "세라", - "text": "…됐어요. 부르지 않은 이름은 이름이 아니에요. 그것이 잠깐 흔들렸을 뿐이에요. 가요." + "text": "부르지 않은 이름은 이름이 아니에요. 가요!" } ], "choices": [], @@ -4565,23 +5359,22 @@ { "beatId": "duel_grip_win", "chapterId": "ch8", - "order": 83, + "order": 28, "prerequisiteBeatId": "", "trigger": { "type": "DuelWin", "param": "ledger_grip" }, - "requiredBeatId": "talk_grip", - "requiredRegionId": "dunes", + "requiredBeatId": "ch8_confront", "speakerNpcId": "ledger_grip", "lines": [ { "speaker": "집게", - "text": "…손으로 증명했군. 말은 안 믿어도 이건 믿는다." + "text": "실력으로 증명했군. 이건 믿는다." }, { "speaker": "집게", - "text": "상자는 네가 열어라. 나는 명령을 받은 대로 했고, 이제 명령이 없다." + "text": "상자는 네가 열어라. 이제 내게 명령은 없다." }, { "speaker": "세라", @@ -4602,24 +5395,24 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "집게를 이겨야 상자가 열려" }, { "beatId": "duel_scale_win", "chapterId": "ch9", - "order": 84, + "order": 33, "prerequisiteBeatId": "", "trigger": { "type": "DuelWin", "param": "ledger_scale" }, - "requiredBeatId": "talk_scale", - "requiredRegionId": "frostline", + "requiredBeatId": "ch9_confront", "speakerNpcId": "ledger_scale", "lines": [ { "speaker": "저울", - "text": "수치가 그렇다면 그런 거다. 나는 내 자로 쟀고 내 자로 졌어." + "text": "내 자로 쟀고, 내 자로 졌다." }, { "speaker": "저울", @@ -4627,11 +5420,12 @@ }, { "speaker": "세라", - "text": "…선배." + "text": "…선배.", + "fx": "pause" }, { "speaker": "저울", - "text": "선배라고 부르지 말랬지. …오늘까지는 봐주마." + "text": "선배라 부르지 말랬지. …오늘까지는 봐주마." } ], "choices": [], @@ -4644,28 +5438,28 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "저울을 이겨야 길이 열려" }, { "beatId": "duel_chief_win", "chapterId": "ch12", - "order": 85, + "order": 48, "prerequisiteBeatId": "", "trigger": { "type": "DuelWin", "param": "ledger_chief" }, - "requiredBeatId": "talk_chief", - "requiredRegionId": "nameless", + "requiredBeatId": "ch12_confront", "speakerNpcId": "ledger_chief", "lines": [ { "speaker": "하월", - "text": "…졌군. 첫 장의 아이가 마지막에 나를 이겼어." + "text": "졌군. 첫 장의 아이가 마지막에 나를 이겼어." }, { "speaker": "하월", - "text": "이 아이를 데려가게. 삼십 년 전에 적어서 가둔 이름이야 — 자네 도감에서는 다르게 적히겠지." + "text": "이 아이를 데려가게. 자네 도감에선 다르게 적히겠지." }, { "speaker": "세라", @@ -4673,7 +5467,12 @@ }, { "speaker": "하월", - "text": "…그래. 그렇게 하자. 스승 노릇은 여기까지다." + "text": "그래. 스승 노릇은 여기까지다.", + "fx": "slow" + }, + { + "speaker": "하월", + "text": "길을 비켜 주마. 안쪽에서 그림자가 기다린다." } ], "choices": [], @@ -4686,7 +5485,9 @@ "rewardInsectDisplayName": "이름을 돌려받은 나방", "rewardInsectLevel": 62, "unlockQuestId": "" - } + }, + "videoId": "vid_ch12_ledger", + "why": "하월을 이겨야 안으로 가" }, { "beatId": "ch7_echo_rival", @@ -4700,17 +5501,22 @@ "requiredBeatId": "ch7_arrive", "speakerNpcId": "catcher_rival", "lines": [ + { + "speaker": "지문", + "text": "라온이 마른 풀 한 줄기를 입에 문다." + }, { "speaker": "라온", - "text": "소리 없는 초원이 이런 거구나. 우리 초원도 그날 이랬을까." + "text": "소리 없는 들판이라… 우리 초원도 그날 이랬을까." }, { "speaker": "라온", - "text": "…어르신은 이걸 알고 계셨던 거야. 그래서 그렇게 서두르라고 하셨나 봐." + "text": "네가 한 마리 적을 때마다 풀이 한 포기씩 서는 것 같아." }, { "speaker": "라온", - "text": "네가 한 마리 새길 때마다 여기 풀이 한 포기씩 서는 것 같아. 기분 탓이라도 좋아." + "text": "기분 탓이라도 좋아.", + "fx": "whisper" } ], "choices": [], @@ -4724,7 +5530,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "requiredRegionId": "hollow" + "requiredRegionId": "hollow", + "why": "라온이 할 말이 있대" }, { "beatId": "ch8_echo_scholar", @@ -4740,15 +5547,17 @@ "lines": [ { "speaker": "세라", - "text": "상자 뚜껑의 이름표… 양식이 낯익어요. 칸을 나누는 방식, 날짜를 적는 자리." + "text": "상자 이름표 양식이 낯익어요." }, { "speaker": "세라", - "text": "스승님 손이에요. 제가 배운 그 양식이에요." + "text": "스승님 글씨예요. 제가 배운 그대로예요.", + "fx": "pause" }, { "speaker": "세라", - "text": "…저도 저렇게 적었어요, 한때는. 그래서 알아보는 거예요." + "text": "저도 한때 저렇게 적었어요. 그래서 알아봐요.", + "fx": "whisper" } ], "choices": [], @@ -4762,7 +5571,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "requiredRegionId": "dunes" + "requiredRegionId": "dunes", + "why": "세라가 할 말이 있대" }, { "beatId": "ch9_echo_rival", @@ -4778,15 +5588,20 @@ "lines": [ { "speaker": "라온", - "text": "저 얼음 벽 글자, 난 하나도 못 읽겠어. 세라는 읽고 너는 새기잖아." + "text": "저 얼음 글자, 난 하나도 못 읽겠어." }, { "speaker": "라온", - "text": "그럼 난 뭘 하지 싶었는데 — 둘이 읽고 새기는 동안 뒤를 보는 거. 그거면 됐다." + "text": "세라는 읽고 너는 적잖아. 난 뭘 하지 싶었는데." }, { "speaker": "라온", - "text": "…괜히 말했다. 잊어." + "text": "둘이 하는 동안 뒤를 지키는 거. 그거면 됐다." + }, + { + "speaker": "라온", + "text": "…괜히 말했다. 잊어.", + "fx": "whisper" } ], "choices": [], @@ -4800,7 +5615,8 @@ "rewardInsectLevel": 0, "unlockQuestId": "" }, - "requiredRegionId": "frostline" + "requiredRegionId": "frostline", + "why": "라온이 할 말이 있대" }, { "beatId": "ch11_echo_ink", @@ -4816,15 +5632,15 @@ "lines": [ { "speaker": "먹", - "text": "라온이라는 아이, 걷더군. 아직 못 굽히는 팔로 상자를 옮기고 있다." + "text": "라온이라는 아이, 다친 팔로 상자를 옮기더군." }, { "speaker": "먹", - "text": "내가 말렸는데 — 이름 적는 사람이 손 놓고 있는 건 못 보겠다더라. 자네 얘기라던데." + "text": "말렸는데, 손 놓고는 못 있겠다더라. 자네 얘기라던데." }, { "speaker": "먹", - "text": "…위에 갔다 왔다고 했지. 우듬지가 살아 있다면, 그 이유를 이 장부에도 적어 다오." + "text": "우듬지가 왜 무사한지, 이 장부에도 적어 다오." } ], "choices": [], @@ -4837,7 +5653,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "먹이 할 말이 있대" }, { "beatId": "ch1_pond_edge", @@ -4852,11 +5669,11 @@ "lines": [ { "speaker": "마을 어르신", - "text": "웅덩이구나. 초원 한복판에 물이 고인 자리는 귀하단다. 실잠자리가 여기만 찾아오지." + "text": "웅덩이구나. 실잠자리가 여기만 찾아온단다." }, { "speaker": "마을 어르신", - "text": "작은 물 하나에 매인 아이들이 있어. 물이 마르면 같이 사라진단다 — 잘 봐 두렴." + "text": "작은 물에 매여 사는 아이들이야. 물이 마르면 같이 사라지지." } ], "choices": [], @@ -4869,7 +5686,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "초원 웅덩이에 실잠자리가 있대" }, { "beatId": "ch2_deep", @@ -4884,11 +5702,16 @@ "lines": [ { "speaker": "라온", - "text": "여기가 연못 바닥이야. 대왕물방개가 여기 산대 — 나 아직 못 봤어." + "text": "여기가 연못 바닥이야. 대왕물방개가 산대." }, { "speaker": "라온", - "text": "위는 조용한데 밑은 어떨까. …네가 먼저 봐. 난 물이 좀 무섭거든." + "text": "나 아직 못 봤어. …네가 먼저 봐." + }, + { + "speaker": "라온", + "text": "난 물이 좀 무섭거든. 비밀이다?", + "fx": "whisper" } ], "choices": [], @@ -4901,7 +5724,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "연못 바닥에 물방개가 산대" }, { "beatId": "ch3_deep", @@ -4916,11 +5740,11 @@ "lines": [ { "speaker": "세라", - "text": "숲 가장 안쪽이에요. 낙엽사마귀는 여기서 낙엽인 척하고 살아요 — 있어도 없는 것처럼." + "text": "숲 가장 안쪽이에요. 낙엽사마귀가 낙엽인 척 살아요." }, { "speaker": "세라", - "text": "사라진 종과 숨은 종을 가려내는 게 제 일이에요. 여기선 그 둘이 특히 헷갈려요." + "text": "사라진 종과 숨은 종을 가려내기가 여기선 특히 어려워요." } ], "choices": [], @@ -4933,7 +5757,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "숲 깊은 곳에 숨은 곤충이 있대" }, { "beatId": "ch4_cave", @@ -4948,11 +5773,11 @@ "lines": [ { "speaker": "라온", - "text": "습지에 동굴이 있을 줄은 몰랐네. 안이 마른 걸 보니 물이 여기까진 못 오나 봐." + "text": "습지에 동굴이 있었네. 안은 말라 있어." }, { "speaker": "라온", - "text": "…근데 이상하지. 이런 데는 아무도 안 훑었을 텐데, 여기도 조용해." + "text": "근데 이상하지. 아무도 안 왔을 텐데 여기도 조용해." } ], "choices": [], @@ -4965,7 +5790,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "습지 동굴을 살펴보자" }, { "beatId": "ch5_cave", @@ -4980,11 +5806,11 @@ "lines": [ { "speaker": "세라", - "text": "산속 동굴이에요. 아틀라스나방이 낮에 여기서 날개를 접고 쉬어요." + "text": "산속 동굴이에요. 아틀라스나방이 낮에 여기서 쉬어요." }, { "speaker": "세라", - "text": "밖에서 걷어 가는 사람들도 여긴 못 찾았나 봐요. 숨을 자리가 있는 종은 아직 남아요 — 숨을 데 없는 종부터 지워지는 거죠." + "text": "걷어 가는 사람들도 여긴 못 찾았나 봐요. 숨을 데 있는 종은 남아요." } ], "choices": [], @@ -4997,7 +5823,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "산속 동굴을 살펴보자" }, { "beatId": "ch7_burrow", @@ -5010,13 +5837,17 @@ }, "speakerNpcId": "ruins_scholar", "lines": [ + { + "speaker": "라온", + "text": "찢어진 거미줄이네. 주인이 떠났나 보다." + }, { "speaker": "세라", - "text": "굴 안에 거미줄이 있어요. 찢어진 채로 — 바람만 걸려요." + "text": "아니요. 걸릴 게 없어서 안 고치는 거예요." }, { "speaker": "세라", - "text": "…줄을 친 아이는 아직 있어요. 걸릴 게 없어서 안 고치는 거예요. 여기 애들은 그렇게 기다려요." + "text": "여기 애들은 그렇게 기다리고 있어요." } ], "choices": [], @@ -5029,7 +5860,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "마른 굴 안을 살펴보자" }, { "beatId": "ch8_pit", @@ -5042,13 +5874,18 @@ }, "speakerNpcId": "catcher_rival", "lines": [ + { + "speaker": "지문", + "text": "모래 구덩이. 가장자리 모래가 안쪽으로 흘러내린다.", + "fx": "shake" + }, { "speaker": "라온", - "text": "발 조심해. 구덩이 바닥에 개미귀신이 있어 — 미끄러지면 끝이야." + "text": "발 조심해. 바닥에 개미귀신이 있어." }, { "speaker": "라온", - "text": "근데 저 명부회 상자 말이야, 저것도 구덩이 같지 않아? 한번 들어가면 못 나오는." + "text": "저 상자들도 구덩이 같아. 한번 들어가면 못 나오는." } ], "choices": [], @@ -5061,7 +5898,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "모래 구덩이를 살펴보자" }, { "beatId": "ch9_ridge", @@ -5076,11 +5914,15 @@ "lines": [ { "speaker": "라온", - "text": "능선이야. 바람이 이렇게 센데 나뭇가지에 뭔가 매달려 있어. …사마귀인가?" + "text": "저 가지에 뭔가 매달려 있어. 얼어 버린 걸까?" }, { "speaker": "라온", - "text": "저렇게 얼어붙은 척하고 버티는 거야. 여기 애들은 이름을 안 뺏긴 대신 저러고 산다는 거지." + "text": "아니, 얼어붙은 척 버티는 거야. 더듬이가 움직여." + }, + { + "speaker": "세라", + "text": "여기 애들은 그렇게 살아남았군요." } ], "choices": [], @@ -5093,7 +5935,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "바람 센 능선을 살펴보자" }, { "beatId": "ch10_vent", @@ -5106,13 +5949,18 @@ }, "speakerNpcId": "ledger_ink", "lines": [ + { + "speaker": "지문", + "text": "굴뚝 안. 재가 눈처럼 내리는데 매미 한 마리가 운다." + }, { "speaker": "먹", - "text": "굴뚝 안은 아직 뜨겁다. 여기 매미는 재 속에서 운다 — 장부엔 '멸종'이라고 적혀 있었지." + "text": "장부에는 이 매미가 '멸종'이라고 적혀 있었다." }, { "speaker": "먹", - "text": "내가 적었다. 틀린 줄도 모르고. …틀린 줄을 이제 안다." + "text": "내가 적었다. 틀린 줄도 모르고.", + "fx": "pause" } ], "choices": [], @@ -5125,7 +5973,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "재가 내리는 굴뚝을 살펴보자" }, { "beatId": "ch11_bough", @@ -5140,11 +5989,11 @@ "lines": [ { "speaker": "세라", - "text": "가지가 겹친 안쪽이에요. 대벌레가 가지인 척 서 있어요 — 보이세요? 셋… 넷." + "text": "대벌레가 가지인 척 서 있어요. 셋, 넷, 다섯…" }, { "speaker": "세라", - "text": "이름을 잃은 적 없는 아이들은 숨는 법이 달라요. 무서워서가 아니라 원래 그렇게 사는 거예요." + "text": "무서워서 숨는 게 아니라, 원래 그렇게 사는 거예요." } ], "choices": [], @@ -5157,7 +6006,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "가지가 겹친 안쪽을 살펴보자" }, { "beatId": "dex_20", @@ -5173,11 +6023,11 @@ "lines": [ { "speaker": "마을 어르신", - "text": "스무 종이구나. 네 도감이 스무 칸 밝아졌어." + "text": "스무 종이구나. 도감이 스무 칸 밝아졌어." }, { "speaker": "마을 어르신", - "text": "벽 이야기는 아직 모를 게다 — 그저, 네가 채운 칸만큼 이 초원이 덜 조용해졌다는 것만 알아 두렴." + "text": "네가 채운 만큼 초원이 덜 조용해졌단다." } ], "choices": [], @@ -5190,7 +6040,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "도감 20종을 채워 보자" }, { "beatId": "dex_40", @@ -5206,11 +6057,11 @@ "lines": [ { "speaker": "라온", - "text": "마흔 종? 나 서른둘이야. …졌다." + "text": "마흔 종? 난 서른둘이야. …졌다." }, { "speaker": "라온", - "text": "근데 이상하게 안 분해. 네 도감이 늘면 내 도감 칸도 덜 비는 기분이거든." + "text": "근데 안 분해. 네 도감이 늘면 내 마음도 덜 비거든." } ], "choices": [], @@ -5223,7 +6074,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "도감 40종을 채워 보자" }, { "beatId": "dex_160", @@ -5238,11 +6090,11 @@ "lines": [ { "speaker": "세라", - "text": "백예순 종. 이제 벽에 남은 빈칸을 손으로 셀 수 있어요." + "text": "백예순 종. 이제 남은 빈칸을 손으로 셀 수 있어요." }, { "speaker": "세라", - "text": "남은 칸은 당신이 아직 못 만난 아이들이에요. 사라진 게 아니라요. …그 차이를 아는 데 여기까지 왔네요." + "text": "남은 칸은 사라진 아이가 아니라 아직 못 만난 아이들이에요." } ], "choices": [], @@ -5255,7 +6107,8 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "도감 160종을 채워 보자" }, { "beatId": "post_rival_rematch", @@ -5270,16 +6123,17 @@ "speakerNpcId": "catcher_rival", "lines": [ { - "speaker": "라온", - "text": "끝났으니까 말인데 — 처음에 승부 취소한 거, 그거 다시 취소." + "speaker": "지문", + "text": "라온이 다 나은 팔을 크게 돌려 보인다." }, { "speaker": "라온", - "text": "누가 더 많이 구하나가 아니라, 그냥 누가 더 센가. 팔도 다 나았고. 이번엔 진짜 겨루자." + "text": "끝났으니까, 승부 취소한 거 다시 취소! 진짜로 겨루자." }, { "speaker": "라온", - "text": "동네 최강자 자리, 내가 먼저 걸어 둘게. 도전은 네 쪽에서 해." + "text": "동네 최강자 자리, 내가 먼저 맡아 둘게. 도전해!", + "fx": "shout" } ], "choices": [], @@ -5292,7 +6146,2524 @@ "rewardInsectDisplayName": "", "rewardInsectLevel": 0, "unlockQuestId": "" - } + }, + "why": "라온이 다시 승부하자고 해" + }, + { + "beatId": "camp_pond", + "chapterId": "side", + "order": 80, + "prerequisiteBeatId": "ch1_rival_intro", + "trigger": { + "type": "LevelReach", + "param": "9" + }, + "requiredBeatId": "ch1_reach_pond", + "speakerNpcId": "catcher_rival", + "lines": [ + { + "speaker": "지문", + "text": "해 질 녘 연못가. 라온이 물가 바위에 앉아 있다." + }, + { + "speaker": "라온", + "text": "왔냐. 앉아. 오늘은 승부 안 해." + }, + { + "speaker": "라온", + "text": "우리 할머니는 소리만 듣고 곤충을 다 맞히셨어." + }, + { + "speaker": "라온", + "text": "근데 작년에 그러시더라. '올해는 들을 게 없구나.'", + "fx": "slow" + }, + { + "speaker": "지문", + "text": "라온이 던진 돌멩이에 물결이 퍼졌다 사라진다.", + "fx": "pause" + }, + { + "speaker": "라온", + "text": "그래서 쫓기 시작했어. 할머니께 다시 들려 드리려고." + }, + { + "speaker": "라온", + "text": "이거 비밀이다? 다음엔 또 승부야!", + "fx": "shout" + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 10, + "rewardExp": 30, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + }, + "why": "라온이 연못가에 있대" + }, + { + "beatId": "camp_swamp", + "chapterId": "side", + "order": 81, + "prerequisiteBeatId": "camp_pond", + "trigger": { + "type": "LevelReach", + "param": "23" + }, + "requiredBeatId": "ch4_reach_swamp", + "speakerNpcId": "ruins_scholar", + "lines": [ + { + "speaker": "지문", + "text": "습지의 밤. 작은 모닥불이 탁탁 튄다." + }, + { + "speaker": "라온", + "text": "세라, 넌 왜 유적 연구를 해?" + }, + { + "speaker": "세라", + "text": "스승님 연구실에서 벽 탁본을 처음 봤어요." + }, + { + "speaker": "세라", + "text": "곤충 수천 마리에 전부 이름이 달려 있었죠.", + "fx": "pause" + }, + { + "speaker": "세라", + "text": "스승님은 늘 제가 느리다고 하셨어요.", + "fx": "whisper" + }, + { + "speaker": "라온", + "text": "난 느린 거 좋은데. 천천히 가야 벌레 소리가 들려." + }, + { + "speaker": "세라", + "text": "라온은 가끔 스승님보다 현명해요.", + "fx": "pause" + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 20, + "rewardExp": 60, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + }, + "why": "모닥불 곁에서 쉬어 가자" + }, + { + "beatId": "camp_ruins", + "chapterId": "side", + "order": 82, + "prerequisiteBeatId": "camp_swamp", + "trigger": { + "type": "LevelReach", + "param": "38" + }, + "requiredBeatId": "ch6_reach_ruins", + "speakerNpcId": "ruins_scholar", + "lines": [ + { + "speaker": "지문", + "text": "유적 입구의 밤. 무너진 기둥 사이로 별이 많다.", + "fx": "dark" + }, + { + "speaker": "라온", + "text": "내일이면 신전이지. 끝나면 뭐 할 거야?" + }, + { + "speaker": "라온", + "text": "난 할머니랑 초원에 누워 풀벌레 소리 들을래." + }, + { + "speaker": "세라", + "text": "저는 스승님께 보여 드릴래요. 느려도 여기까지 왔다고." + }, + { + "speaker": "지문", + "text": "파트너가 무릎에 올라와 더듬이를 흔든다." + }, + { + "speaker": "세라", + "text": "고마워요. 셋이 아니라 넷이었네요.", + "fx": "pause" + }, + { + "speaker": "라온", + "text": "도감 친구들까지 치면 백 명은 넘지!", + "fx": "shout" + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 30, + "rewardExp": 90, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + }, + "why": "유적 앞에서 쉬어 가자" + }, + { + "beatId": "camp_frost", + "chapterId": "side", + "order": 83, + "prerequisiteBeatId": "ch9_confront", + "trigger": { + "type": "LevelReach", + "param": "52" + }, + "requiredRegionId": "frostline", + "speakerNpcId": "ruins_scholar", + "lines": [ + { + "speaker": "지문", + "text": "서릿길의 밤. 얼음 동굴 속 작은 불 위로 김이 오른다." + }, + { + "speaker": "라온", + "text": "이제 말해 줄 거지? 명부회 얘기." + }, + { + "speaker": "세라", + "text": "저랑 저울 선배는 곤충 목록을 맡았어요." + }, + { + "speaker": "세라", + "text": "먼저 적어 두면 아무것도 안 잃는다고 배웠죠." + }, + { + "speaker": "세라", + "text": "그런데 다시 세어 보니 절반이 없었어요.", + "fx": "slow" + }, + { + "speaker": "세라", + "text": "그래서 나왔어요. 저도 틀릴 수 있어요, 라온." + }, + { + "speaker": "라온", + "text": "그럼 같이 틀리자. 셋이면 심심하진 않을 거야." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 40, + "rewardExp": 100, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + }, + "why": "얼음 동굴에서 쉬어 가자" + }, + { + "beatId": "camp_ember", + "chapterId": "side", + "order": 84, + "prerequisiteBeatId": "ch10_confront", + "trigger": { + "type": "LevelReach", + "param": "56" + }, + "requiredRegionId": "emberfall", + "speakerNpcId": "catcher_rival", + "lines": [ + { + "speaker": "지문", + "text": "전초기지의 밤. 라온이 팔에 부목을 대고 앉아 있다." + }, + { + "speaker": "라온", + "text": "표정 왜 그래. 안 죽었어, 안 죽었다고." + }, + { + "speaker": "라온", + "text": "나 빠지는 거 알아. 우듬지는 둘이 가." + }, + { + "speaker": "라온", + "text": "그 대신 내 몫까지 도감에 적어 와!", + "fx": "shout" + }, + { + "speaker": "라온", + "text": "돌아오면 제일 먼저 나한테 보여 줘야 해.", + "fx": "whisper" + }, + { + "speaker": "지문", + "text": "천막 입구에서 먹이 헛기침을 한다." + }, + { + "speaker": "먹", + "text": "이 아이는 내가 본다. …가라." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 45, + "rewardExp": 110, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + }, + "why": "다친 라온을 보러 가자" + }, + { + "beatId": "camp_canopy", + "chapterId": "side", + "order": 85, + "prerequisiteBeatId": "ch11_confront", + "trigger": { + "type": "LevelReach", + "param": "60" + }, + "requiredRegionId": "canopy", + "speakerNpcId": "ruins_scholar", + "lines": [ + { + "speaker": "지문", + "text": "우듬지의 밤. 반딧불이 몇 마리가 천천히 오르내린다." + }, + { + "speaker": "세라", + "text": "라온이 있었으면 반딧불이를 세자고 했을 거예요." + }, + { + "speaker": "세라", + "text": "그리고 세다가 까먹었겠죠.", + "fx": "pause" + }, + { + "speaker": "지문", + "text": "수첩 첫 장에 서툰 낙서가 있다. '세라 느림. 그래도 정확함.'" + }, + { + "speaker": "세라", + "text": "라온은 느린 걸 칭찬처럼 적었어요." + }, + { + "speaker": "세라", + "text": "다음은 지도에 없는 땅이에요. 이번엔 도망치지 않아요." + }, + { + "speaker": "세라", + "text": "같이 가요. 라온 몫까지.", + "fx": "pause" + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 50, + "rewardExp": 120, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + }, + "why": "우듬지에서 쉬어 가자" + }, + { + "beatId": "town_meadow_meet", + "chapterId": "town", + "order": 1, + "prerequisiteBeatId": "", + "requiredBeatId": "ch1_rival_intro", + "trigger": { + "type": "NpcTalk", + "param": "town_meadow" + }, + "speakerNpcId": "town_meadow", + "lines": [ + { + "speaker": "꼬마 화가 달래", + "text": "저기요! 채집가죠? 뜰채 보면 알아요." + }, + { + "speaker": "꼬마 화가 달래", + "text": "이건 제 곤충 그림책이에요. 근데 여기 봐요." + }, + { + "speaker": "지문", + "text": "뒤쪽 칸이 하얗다. 이름만 있고 그림이 없다.", + "fx": "pause" + }, + { + "speaker": "꼬마 화가 달래", + "text": "이름만 듣고 본 적이 없어서 못 그렸어요." + }, + { + "speaker": "꼬마 화가 달래", + "text": "초원 곤충 여섯만 만나 주세요! 옆에서 보고 그릴게요." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 5, + "rewardExp": 0, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_meadow_sign", + "chapterId": "town", + "order": 2, + "prerequisiteBeatId": "town_meadow_meet", + "requiredRegionId": "meadow", + "trigger": { + "type": "CaptureInsect", + "param": "ladybug_seven" + }, + "speakerNpcId": "town_meadow", + "lines": [ + { + "speaker": "지문", + "text": "칠성무당벌레다. 달래가 제일 그리고 싶던 곤충이다." + }, + { + "speaker": "지문", + "text": "점이 일곱 개다. 달래 그림 속엔 여섯 개였는데.", + "fx": "pause" + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 5, + "rewardExp": 10, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_meadow_close", + "chapterId": "town", + "order": 3, + "prerequisiteBeatId": "town_meadow_meet", + "requiredQuestId": "s_town_meadow", + "trigger": { + "type": "NpcTalk", + "param": "town_meadow" + }, + "speakerNpcId": "town_meadow", + "lines": [ + { + "speaker": "꼬마 화가 달래", + "text": "왔다! 잠깐만요, 움직이지 마요. 그리는 중이에요." + }, + { + "speaker": "지문", + "text": "연필 소리가 이어진다. 하얀 칸이 하나씩 찬다.", + "fx": "pause" + }, + { + "speaker": "꼬마 화가 달래", + "text": "다 됐다! 이제 빈 칸이 거의 없어요." + }, + { + "speaker": "꼬마 화가 달래", + "text": "그림 모델 해 준 값이에요. 엄마 가게 채집망이요!" + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 10, + "rewardExp": 0, + "rewardItemId": "net_silver", + "rewardItemCount": 3, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_meadow_after", + "chapterId": "town", + "order": 4, + "prerequisiteBeatId": "town_meadow_close", + "requiredBeatId": "ch7_arrive", + "trigger": { + "type": "NpcTalk", + "param": "town_meadow" + }, + "speakerNpcId": "town_meadow", + "lines": [ + { + "speaker": "꼬마 화가 달래", + "text": "채집가님… 요즘 그림책을 못 펴겠어요." + }, + { + "speaker": "꼬마 화가 달래", + "text": "밤에 보면 그림 사이에 하얀 데가 또 생겨요." + }, + { + "speaker": "지문", + "text": "펼쳐 보니 그림은 그대로다. 달래는 책을 꼭 쥐고 있다.", + "fx": "pause" + }, + { + "speaker": "꼬마 화가 달래", + "text": "그래서 무서운 데엔 채집가님을 그려 넣을 거예요." + }, + { + "speaker": "꼬마 화가 달래", + "text": "그럼 안 무섭잖아요.", + "fx": "whisper" + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 30, + "rewardExp": 70, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_pond_meet", + "chapterId": "town", + "order": 11, + "prerequisiteBeatId": "", + "requiredBeatId": "ch1_reach_pond", + "trigger": { + "type": "NpcTalk", + "param": "town_pond" + }, + "speakerNpcId": "town_pond", + "lines": [ + { + "speaker": "물결 할머니", + "text": "어이, 채집가. 이 할미 얘기 좀 들어 주겠나?" + }, + { + "speaker": "물결 할머니", + "text": "옛날엔 이맘때 물 위에 소금쟁이가 가득했어." + }, + { + "speaker": "물결 할머니", + "text": "우리 할아버지가 그걸 세서 기둥에 금을 그었지." + }, + { + "speaker": "지문", + "text": "나루터 기둥에 금이 빼곡하다. 끝쪽은 드문드문하다.", + "fx": "pause" + }, + { + "speaker": "물결 할머니", + "text": "올해는 하나도 못 봤어. 셀 사람도 없고." + }, + { + "speaker": "물결 할머니", + "text": "연못 곤충 다섯만 만나 주게. 몇이나 남았나 보게." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 8, + "rewardExp": 0, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_pond_sign", + "chapterId": "town", + "order": 12, + "prerequisiteBeatId": "town_pond_meet", + "requiredRegionId": "pond", + "trigger": { + "type": "CaptureInsect", + "param": "water_strider_pond" + }, + "speakerNpcId": "town_pond", + "lines": [ + { + "speaker": "지문", + "text": "소금쟁이가 물 위를 미끄러진다." + }, + { + "speaker": "지문", + "text": "할머니의 기둥에 금 하나를 더 그을 수 있겠다.", + "fx": "pause" + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 8, + "rewardExp": 15, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_pond_close", + "chapterId": "town", + "order": 13, + "prerequisiteBeatId": "town_pond_meet", + "requiredQuestId": "s_town_pond", + "trigger": { + "type": "NpcTalk", + "param": "town_pond" + }, + "speakerNpcId": "town_pond", + "lines": [ + { + "speaker": "물결 할머니", + "text": "…그래, 아직 있구나. 다섯이면 금 다섯 개지." + }, + { + "speaker": "지문", + "text": "할머니가 작은 칼로 기둥에 새 금을 긋는다.", + "fx": "pause" + }, + { + "speaker": "물결 할머니", + "text": "세는 건 가두려는 게 아니라 잊지 않으려는 거래." + }, + { + "speaker": "물결 할머니", + "text": "이거 받게. 연못 수문장한테 쓰려고 아껴 둔 거야." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 12, + "rewardExp": 0, + "rewardItemId": "guardian_totem", + "rewardItemCount": 1, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_pond_after", + "chapterId": "town", + "order": 14, + "prerequisiteBeatId": "town_pond_close", + "requiredBeatId": "ch8_confront", + "trigger": { + "type": "NpcTalk", + "param": "town_pond" + }, + "speakerNpcId": "town_pond", + "lines": [ + { + "speaker": "물결 할머니", + "text": "얼굴이 어둡구먼. 곤충을 상자에 가두는 사람들이라고?" + }, + { + "speaker": "물결 할머니", + "text": "할아버지가 그랬어. 검은 옷이 기둥 금을 베껴 갔대." + }, + { + "speaker": "지문", + "text": "기둥의 금은 그사이 조금 더 늘어 있다." + }, + { + "speaker": "물결 할머니", + "text": "할아버지는 셀 때마다 이름을 불렀어." + }, + { + "speaker": "물결 할머니", + "text": "그 사람들은 숫자만 적었겠지. 그건 달라." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 35, + "rewardExp": 80, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_forest_meet", + "chapterId": "town", + "order": 21, + "prerequisiteBeatId": "", + "requiredBeatId": "ch3_reach_forest", + "trigger": { + "type": "NpcTalk", + "param": "town_forest" + }, + "speakerNpcId": "town_forest", + "lines": [ + { + "speaker": "나무꾼 솔", + "text": "쉿. …아, 채집가구나. 도끼 소리에 놀랐나 했네." + }, + { + "speaker": "나무꾼 솔", + "text": "요즘 누가 나무마다 곤충 잡는 그물을 쳐 놔." + }, + { + "speaker": "지문", + "text": "솔의 발밑에 잘린 그물이 쌓여 있다.", + "fx": "pause" + }, + { + "speaker": "나무꾼 솔", + "text": "풀어 준 녀석들이 화가 나서 사람만 보면 덤벼." + }, + { + "speaker": "나무꾼 솔", + "text": "숲에서 세 번만 이겨서 달래 줄래? 이젠 괜찮다고." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 10, + "rewardExp": 0, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_forest_sign", + "chapterId": "town", + "order": 22, + "prerequisiteBeatId": "town_forest_meet", + "requiredRegionId": "forest", + "trigger": { + "type": "CaptureInsect", + "param": "cicada_summer" + }, + "speakerNpcId": "ruins_scholar", + "lines": [ + { + "speaker": "세라", + "text": "매미 소리예요. 그물에 걸렸으면 못 들었겠죠." + }, + { + "speaker": "세라", + "text": "나무꾼 아저씨가 그물을 걷어 준 덕분이에요." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 10, + "rewardExp": 20, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_forest_close", + "chapterId": "town", + "order": 23, + "prerequisiteBeatId": "town_forest_meet", + "requiredQuestId": "s_town_forest", + "trigger": { + "type": "NpcTalk", + "param": "town_forest" + }, + "speakerNpcId": "town_forest", + "lines": [ + { + "speaker": "나무꾼 솔", + "text": "조용해졌어. 덤비던 녀석들이 이젠 도끼질을 구경해." + }, + { + "speaker": "나무꾼 솔", + "text": "이거 받아. 검은 옷 사람들이 쓰던 그물이야." + }, + { + "speaker": "지문", + "text": "가늘고 질긴 그물이다. 감기면 쉽게 못 빠져나간다." + }, + { + "speaker": "나무꾼 솔", + "text": "가두지 말고, 도망치는 녀석 얼굴만 보고 놓아줘." + }, + { + "speaker": "나무꾼 솔", + "text": "그물도 누가 쥐느냐에 따라 달라지니까." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 15, + "rewardExp": 0, + "rewardItemId": "binding_net", + "rewardItemCount": 1, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_forest_after", + "chapterId": "town", + "order": 24, + "prerequisiteBeatId": "town_forest_close", + "requiredBeatId": "bl_forest_restore", + "trigger": { + "type": "NpcTalk", + "param": "town_forest" + }, + "speakerNpcId": "town_forest", + "lines": [ + { + "speaker": "나무꾼 솔", + "text": "그물터가 없어졌다며? 밤새 숲이 시끌시끌했어." + }, + { + "speaker": "나무꾼 솔", + "text": "검은 옷 청년이 남은 그물을 걷으러 왔더라." + }, + { + "speaker": "나무꾼 솔", + "text": "도끼를 들었더니 먼저 고개를 숙이더군." + }, + { + "speaker": "나무꾼 솔", + "text": "나도 시키는 대로 벤 적이 있어. 지금은 베면 심어." + }, + { + "speaker": "나무꾼 솔", + "text": "쳤으면 걷을 차례지. 그 청년한테도 그렇게 말했어." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 20, + "rewardExp": 40, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_swamp_meet", + "chapterId": "town", + "order": 31, + "prerequisiteBeatId": "", + "requiredBeatId": "ch4_reach_swamp", + "trigger": { + "type": "NpcTalk", + "param": "town_swamp" + }, + "speakerNpcId": "town_swamp", + "lines": [ + { + "speaker": "약초꾼 이끼", + "text": "발밑 조심해. 그 풀은 밟으면 독이 올라." + }, + { + "speaker": "약초꾼 이끼", + "text": "난 늪에서 약초를 캐. 해독약도 여기 풀로 만들어." + }, + { + "speaker": "약초꾼 이끼", + "text": "그런데 요새 안개가 짙어서 바구니를 못 채워." + }, + { + "speaker": "약초꾼 이끼", + "text": "예전엔 반딧불이 불빛 아래 약초가 있었거든." + }, + { + "speaker": "약초꾼 이끼", + "text": "늪 곤충 다섯만 만나 줄래? 누가 남았는지 알고 싶어." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 12, + "rewardExp": 0, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_swamp_sign", + "chapterId": "town", + "order": 32, + "prerequisiteBeatId": "town_swamp_meet", + "requiredRegionId": "swamp", + "trigger": { + "type": "CaptureInsect", + "param": "firefly_swamp" + }, + "speakerNpcId": "ruins_scholar", + "lines": [ + { + "speaker": "세라", + "text": "늪반딧불이예요. 안개 속에서도 불빛이 또렷해요." + }, + { + "speaker": "세라", + "text": "이끼 씨가 말한 불빛이에요. 근처에 약초가 있겠죠." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 12, + "rewardExp": 25, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_swamp_close", + "chapterId": "town", + "order": 33, + "prerequisiteBeatId": "town_swamp_meet", + "requiredQuestId": "s_town_swamp", + "trigger": { + "type": "NpcTalk", + "param": "town_swamp" + }, + "speakerNpcId": "town_swamp", + "lines": [ + { + "speaker": "약초꾼 이끼", + "text": "…다들 아직 여기 있구나. 안 보였을 뿐이야." + }, + { + "speaker": "지문", + "text": "이끼가 바구니를 내려놓고 안개를 바라본다.", + "fx": "pause" + }, + { + "speaker": "약초꾼 이끼", + "text": "안 보인다고 없는 게 아니었어." + }, + { + "speaker": "약초꾼 이끼", + "text": "약을 넉넉히 만들었어. 산엔 몸을 굳게 하는 녀석이 많대." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 18, + "rewardExp": 0, + "rewardItemId": "paralysis_heal", + "rewardItemCount": 3, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_swamp_after", + "chapterId": "town", + "order": 34, + "prerequisiteBeatId": "town_swamp_close", + "requiredBeatId": "ch7_confront", + "trigger": { + "type": "NpcTalk", + "param": "town_swamp" + }, + "speakerNpcId": "town_swamp", + "lines": [ + { + "speaker": "약초꾼 이끼", + "text": "텅 빈 들에 이름 잃은 곤충이 있다며?" + }, + { + "speaker": "약초꾼 이끼", + "text": "약초꾼한텐 그게 제일 무서워." + }, + { + "speaker": "약초꾼 이끼", + "text": "이름을 잊으면 약초랑 독초가 똑같아 보이거든." + }, + { + "speaker": "약초꾼 이끼", + "text": "넌 그 애들 이름을 다시 찾아 주는구나. 반딧불이처럼." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 30, + "rewardExp": 75, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_mountain_meet", + "chapterId": "town", + "order": 41, + "prerequisiteBeatId": "", + "requiredBeatId": "ch5_reach_mountain", + "trigger": { + "type": "NpcTalk", + "param": "town_mountain" + }, + "speakerNpcId": "town_mountain", + "lines": [ + { + "speaker": "봉수지기 너울", + "text": "허, 올라오느라 힘들었지? 여긴 봉수대야." + }, + { + "speaker": "봉수지기 너울", + "text": "옛날에 불을 피워 먼 마을에 소식을 알리던 곳이지." + }, + { + "speaker": "봉수지기 너울", + "text": "난 불 대신 매일 곤충을 적어. 사십 년째야." + }, + { + "speaker": "봉수지기 너울", + "text": "여치가 낮게 울면 사흘 안에 비가 와. 다 적혀 있지." + }, + { + "speaker": "지문", + "text": "낡은 공책이 수십 권이다. 맨 위 공책은 며칠째 비어 있다.", + "fx": "pause" + }, + { + "speaker": "봉수지기 너울", + "text": "무릎이 아파 산을 못 돌아. 네 번만 겨뤄 보고 알려 주겠나?" + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 15, + "rewardExp": 0, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_mountain_sign", + "chapterId": "town", + "order": 42, + "prerequisiteBeatId": "town_mountain_meet", + "requiredRegionId": "mountain", + "trigger": { + "type": "CaptureInsect", + "param": "katydid_leaf" + }, + "speakerNpcId": "ruins_scholar", + "lines": [ + { + "speaker": "세라", + "text": "여치예요. 너울 할아버지 공책엔 낮게 울면 비가 온대요." + }, + { + "speaker": "세라", + "text": "사십 년을 매일 적다니… 그게 진짜 기록이에요." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 15, + "rewardExp": 30, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_mountain_close", + "chapterId": "town", + "order": 43, + "prerequisiteBeatId": "town_mountain_meet", + "requiredQuestId": "s_town_mountain", + "trigger": { + "type": "NpcTalk", + "param": "town_mountain" + }, + "speakerNpcId": "town_mountain", + "lines": [ + { + "speaker": "봉수지기 너울", + "text": "어디 보자… 다들 기운이 좋구먼." + }, + { + "speaker": "지문", + "text": "너울이 빈 칸을 하나씩 채운다. 글씨가 조금 떨린다.", + "fx": "pause" + }, + { + "speaker": "봉수지기 너울", + "text": "적어 두면, 못 올라가는 날에도 산을 잊지 않아." + }, + { + "speaker": "봉수지기 너울", + "text": "이거 가져가. 손주가 두고 간 거야." + }, + { + "speaker": "봉수지기 너울", + "text": "곤충이 빨리 크게 돕는대. 난 쓸 데가 없어서." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 20, + "rewardExp": 0, + "rewardItemId": "exp_boost", + "rewardItemCount": 2, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_mountain_after", + "chapterId": "town", + "order": 44, + "prerequisiteBeatId": "town_mountain_close", + "requiredBeatId": "ch9_arrive", + "trigger": { + "type": "NpcTalk", + "param": "town_mountain" + }, + "speakerNpcId": "town_mountain", + "lines": [ + { + "speaker": "봉수지기 너울", + "text": "서릿길 얼음 속엔 옛 글씨가 그대로 있다며?" + }, + { + "speaker": "봉수지기 너울", + "text": "내 공책은 비에 번지고 쥐가 갉아 먹는데 말이야." + }, + { + "speaker": "봉수지기 너울", + "text": "그래도 난 번지는 쪽이 좋아. 틀리면 고칠 수 있잖아." + }, + { + "speaker": "봉수지기 너울", + "text": "산도 매일 조금씩 달라져. 공책도 그래야지." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 40, + "rewardExp": 90, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_garden_meet", + "chapterId": "town", + "order": 51, + "prerequisiteBeatId": "", + "requiredBeatId": "garden_intro", + "trigger": { + "type": "NpcTalk", + "param": "town_garden" + }, + "speakerNpcId": "town_garden", + "lines": [ + { + "speaker": "정원사 누리", + "text": "어서 와요. 미로 울타리만 건드리지 않으면 돼요." + }, + { + "speaker": "정원사 누리", + "text": "우리 집은 대대로 이 꽃밭을 가꿨어요." + }, + { + "speaker": "정원사 누리", + "text": "집안 규칙은 하나예요. '미로 모양을 바꾸지 마라.'" + }, + { + "speaker": "정원사 누리", + "text": "이유는 할머니도 몰랐대요. 그래야 나비가 온대요." + }, + { + "speaker": "정원사 누리", + "text": "정말일까요? 꽃밭 곤충 다섯만 만나 봐 줄래요?" + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 12, + "rewardExp": 0, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_garden_sign", + "chapterId": "town", + "order": 52, + "prerequisiteBeatId": "town_garden_meet", + "requiredRegionId": "garden", + "trigger": { + "type": "CaptureInsect", + "param": "butterfly_peacock" + }, + "speakerNpcId": "town_garden", + "lines": [ + { + "speaker": "지문", + "text": "공작나비가 날개를 편다. 눈알 무늬 두 개가 보인다." + }, + { + "speaker": "지문", + "text": "누리 말대로다. 나비는 미로 쪽에서 날아왔다.", + "fx": "pause" + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 12, + "rewardExp": 25, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_garden_close", + "chapterId": "town", + "order": 53, + "prerequisiteBeatId": "town_garden_meet", + "requiredQuestId": "s_town_garden", + "trigger": { + "type": "NpcTalk", + "param": "town_garden" + }, + "speakerNpcId": "town_garden", + "lines": [ + { + "speaker": "정원사 누리", + "text": "다들 와 있네요. 규칙 덕분인지는 몰라도요." + }, + { + "speaker": "정원사 누리", + "text": "사실 오늘 미로 모퉁이 하나를 깎아 보려 했어요." + }, + { + "speaker": "지문", + "text": "누리의 가위에 초록 물이 들어 있다. 잘린 가지는 없다.", + "fx": "pause" + }, + { + "speaker": "정원사 누리", + "text": "손이 안 움직였어요. 이유를 알 때까진 지킬래요." + }, + { + "speaker": "정원사 누리", + "text": "할머니가 쓰던 약이에요. 몸 성히 다녀요." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 18, + "rewardExp": 0, + "rewardItemId": "full_restore", + "rewardItemCount": 2, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_garden_after", + "chapterId": "town", + "order": 54, + "prerequisiteBeatId": "town_garden_close", + "requiredBeatId": "ch11_confront", + "trigger": { + "type": "NpcTalk", + "param": "town_garden" + }, + "speakerNpcId": "town_garden", + "lines": [ + { + "speaker": "정원사 누리", + "text": "우듬지에 다녀왔죠? 꽃밭이 비상 울타리였다고요?" + }, + { + "speaker": "정원사 누리", + "text": "그럼 미로 모양이 울타리 모양이었네요." + }, + { + "speaker": "정원사 누리", + "text": "할머니도, 그 할머니도 모르고 지켜 온 거예요." + }, + { + "speaker": "지문", + "text": "누리가 웃다가 눈가를 훔친다.", + "fx": "pause" + }, + { + "speaker": "정원사 누리", + "text": "몰라도 지켰으니까 지금 여기 나비가 있는 거죠." + }, + { + "speaker": "정원사 누리", + "text": "할머니가 '정말 귀한 날' 피우라던 향이에요. 오늘이요." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 50, + "rewardExp": 110, + "rewardItemId": "golden_censer", + "rewardItemCount": 1, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_ruins_meet", + "chapterId": "town", + "order": 61, + "prerequisiteBeatId": "", + "requiredBeatId": "ch6_reach_ruins", + "trigger": { + "type": "NpcTalk", + "param": "town_ruins" + }, + "speakerNpcId": "town_ruins", + "lines": [ + { + "speaker": "탁본꾼 결", + "text": "앗, 발 조심해 주세요! 먹물이 아직 안 말랐어요." + }, + { + "speaker": "탁본꾼 결", + "text": "전 결이에요. 예전에 세라 선생님을 도왔어요." + }, + { + "speaker": "탁본꾼 결", + "text": "돌에 종이를 대고 두드려 무늬를 떠요. 탁본이에요." + }, + { + "speaker": "지문", + "text": "벽에 붙인 종이에 곤충 무늬가 떠 있다. 몇 칸은 하얗다.", + "fx": "pause" + }, + { + "speaker": "탁본꾼 결", + "text": "빈 칸은 두드려도 안 떠요. 살아 있는 애를 봐야 해요." + }, + { + "speaker": "탁본꾼 결", + "text": "이 근처 귀한 곤충 셋만 만나 주실래요?" + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 18, + "rewardExp": 0, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_ruins_sign", + "chapterId": "town", + "order": 62, + "prerequisiteBeatId": "town_ruins_meet", + "requiredRegionId": "ruins", + "trigger": { + "type": "CaptureInsect", + "param": "cricket_tomb" + }, + "speakerNpcId": "ruins_scholar", + "lines": [ + { + "speaker": "세라", + "text": "무덤귀뚜라미예요. 결의 종이에 이 무늬가 반쯤 있었죠." + }, + { + "speaker": "세라", + "text": "…그 애가 아직 여기 있었구나. 인사도 못 하고 떠났는데." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 18, + "rewardExp": 35, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_ruins_close", + "chapterId": "town", + "order": 63, + "prerequisiteBeatId": "town_ruins_meet", + "requiredQuestId": "s_town_ruins", + "trigger": { + "type": "NpcTalk", + "param": "town_ruins" + }, + "speakerNpcId": "town_ruins", + "lines": [ + { + "speaker": "탁본꾼 결", + "text": "보셨어요? 방금 뜬 종이에 없던 선이 생겼어요." + }, + { + "speaker": "지문", + "text": "하얗던 칸에 희미한 더듬이 선이 떠 있다.", + "fx": "pause" + }, + { + "speaker": "탁본꾼 결", + "text": "돌은 그대로인데 종이가 달라졌어요." + }, + { + "speaker": "탁본꾼 결", + "text": "선생님이라면 '기록은 보는 사람한테 있다'고 하실 거예요." + }, + { + "speaker": "탁본꾼 결", + "text": "선생님이 두고 간 부적이에요. 유적 수문장한테 쓰세요." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 25, + "rewardExp": 0, + "rewardItemId": "beast_mark", + "rewardItemCount": 1, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_ruins_after", + "chapterId": "town", + "order": 64, + "prerequisiteBeatId": "town_ruins_close", + "requiredBeatId": "ch7_opening", + "trigger": { + "type": "NpcTalk", + "param": "town_ruins" + }, + "speakerNpcId": "town_ruins", + "lines": [ + { + "speaker": "탁본꾼 결", + "text": "…들으셨죠? 벽의 빈칸이 움직였다는 거." + }, + { + "speaker": "탁본꾼 결", + "text": "저 그거 먼저 봤어요. 자고 나면 빈칸이 옮겨 가 있었어요." + }, + { + "speaker": "탁본꾼 결", + "text": "제가 잘못 붙인 줄 알고 몇 번이나 다시 떴어요." + }, + { + "speaker": "탁본꾼 결", + "text": "이젠 못 뜨겠어요. 종이 속에도 숨을까 봐요.", + "fx": "whisper" + }, + { + "speaker": "지문", + "text": "결이 종이 묶음을 끈으로 꽁꽁 묶는다.", + "fx": "pause" + }, + { + "speaker": "탁본꾼 결", + "text": "대신 도감을 꼭 채워 주세요. 그림자가 숨을 틈이 없게요." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 30, + "rewardExp": 80, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_hollow_meet", + "chapterId": "town", + "order": 71, + "prerequisiteBeatId": "", + "requiredBeatId": "ch7_arrive", + "trigger": { + "type": "NpcTalk", + "param": "town_hollow" + }, + "speakerNpcId": "town_hollow", + "lines": [ + { + "speaker": "풍경지기 메아리", + "text": "…쉿. 들려요? 아니, 안 들리죠. 저도요." + }, + { + "speaker": "풍경지기 메아리", + "text": "이 들엔 원래 풀벌레 소리가 가득했대요." + }, + { + "speaker": "풍경지기 메아리", + "text": "그 소리를 잊지 않으려고 바람에 우는 풍경을 걸었어요." + }, + { + "speaker": "지문", + "text": "막대마다 풍경이 달려 있다. 바람이 불어도 소리가 없다.", + "fx": "pause" + }, + { + "speaker": "풍경지기 메아리", + "text": "들판 곤충 다섯만 만나 주세요. 풍경이 울지도 몰라요." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 20, + "rewardExp": 0, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_hollow_sign", + "chapterId": "town", + "order": 72, + "prerequisiteBeatId": "town_hollow_meet", + "requiredRegionId": "hollow", + "trigger": { + "type": "CaptureInsect", + "param": "cricket_hush" + }, + "speakerNpcId": "ruins_scholar", + "lines": [ + { + "speaker": "세라", + "text": "들었어요? 방금 귀뚜라미가 아주 작게 울었어요." + }, + { + "speaker": "세라", + "text": "여기서 처음 듣는 소리예요. 풍경도 따라 울까요?" + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 20, + "rewardExp": 45, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_hollow_close", + "chapterId": "town", + "order": 73, + "prerequisiteBeatId": "town_hollow_meet", + "requiredQuestId": "s_town_hollow", + "trigger": { + "type": "NpcTalk", + "param": "town_hollow" + }, + "speakerNpcId": "town_hollow", + "lines": [ + { + "speaker": "지문", + "text": "딸랑. 가장 가까운 풍경 하나가 작게 운다.", + "fx": "pause" + }, + { + "speaker": "풍경지기 메아리", + "text": "…들렸죠? 제가 잘못 들은 거 아니죠?" + }, + { + "speaker": "풍경지기 메아리", + "text": "하나면 돼요. 소리는 옆으로 번지니까요." + }, + { + "speaker": "풍경지기 메아리", + "text": "이 약 가져가요. 여기 수문장은 소리 없이 덤빈대요." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 30, + "rewardExp": 0, + "rewardItemId": "full_restore", + "rewardItemCount": 3, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_hollow_after", + "chapterId": "town", + "order": 74, + "prerequisiteBeatId": "town_hollow_close", + "requiredBeatId": "fin_seal", + "trigger": { + "type": "NpcTalk", + "param": "town_hollow" + }, + "speakerNpcId": "town_hollow", + "lines": [ + { + "speaker": "풍경지기 메아리", + "text": "요즘 밤에 잠을 못 자요. 너무 시끄러워서요." + }, + { + "speaker": "지문", + "text": "들판 가득 풍경이 운다. 풀벌레 소리가 섞여 있다.", + "fx": "pause" + }, + { + "speaker": "풍경지기 메아리", + "text": "이젠 풍경 소리를 찾아야 해요. 곤충 소리에 묻혀서요." + }, + { + "speaker": "풍경지기 메아리", + "text": "풍경은 그냥 둘래요. 조용하던 날을 잊지 않게요." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 60, + "rewardExp": 150, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_dunes_meet", + "chapterId": "town", + "order": 81, + "prerequisiteBeatId": "", + "requiredBeatId": "ch8_arrive", + "trigger": { + "type": "NpcTalk", + "param": "town_dunes" + }, + "speakerNpcId": "town_dunes", + "lines": [ + { + "speaker": "떠돌이 상인 모래", + "text": "물 한 모금 할 텐가? 값은 이야기 하나면 돼." + }, + { + "speaker": "떠돌이 상인 모래", + "text": "요즘 모래 밑에서 상자가 자꾸 나와. 곤충이 가득 든 상자야." + }, + { + "speaker": "지문", + "text": "수레에 빈 상자가 실려 있다. 뚜껑을 억지로 연 자국이 있다.", + "fx": "pause" + }, + { + "speaker": "떠돌이 상인 모래", + "text": "몇 개는 내가 열었어. 보고 있자니 숨이 막혀서." + }, + { + "speaker": "떠돌이 상인 모래", + "text": "풀려난 애들이 잘 지내는지 봐 주게. 다섯이면 돼." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 22, + "rewardExp": 0, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_dunes_sign", + "chapterId": "town", + "order": 82, + "prerequisiteBeatId": "town_dunes_meet", + "requiredRegionId": "dunes", + "trigger": { + "type": "CaptureInsect", + "param": "bee_digger" + }, + "speakerNpcId": "ruins_scholar", + "lines": [ + { + "speaker": "세라", + "text": "이 벌은 모래를 파고 들어가네요." + }, + { + "speaker": "세라", + "text": "상자에 갇혀도 파고 나올 아이예요. 모래 씨한테 전해요." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 22, + "rewardExp": 50, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_dunes_close", + "chapterId": "town", + "order": 83, + "prerequisiteBeatId": "town_dunes_meet", + "requiredQuestId": "s_town_dunes", + "trigger": { + "type": "NpcTalk", + "param": "town_dunes" + }, + "speakerNpcId": "town_dunes", + "lines": [ + { + "speaker": "떠돌이 상인 모래", + "text": "잘 지낸다고? …그래, 그럼 됐어." + }, + { + "speaker": "떠돌이 상인 모래", + "text": "사실 겁났어. 괜히 뜨거운 햇볕에 내놓은 건가 하고." + }, + { + "speaker": "지문", + "text": "모래가 빈 상자를 바람 속으로 밀어 버린다.", + "fx": "pause" + }, + { + "speaker": "떠돌이 상인 모래", + "text": "가둬 두는 건 지키는 게 아니지." + }, + { + "speaker": "떠돌이 상인 모래", + "text": "이 표식 가져가. 사나운 녀석 상대할 때 써." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 35, + "rewardExp": 0, + "rewardItemId": "beast_mark", + "rewardItemCount": 1, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_dunes_after", + "chapterId": "town", + "order": 84, + "prerequisiteBeatId": "town_dunes_close", + "requiredBeatId": "ch12_confront", + "trigger": { + "type": "NpcTalk", + "param": "town_dunes" + }, + "speakerNpcId": "town_dunes", + "lines": [ + { + "speaker": "떠돌이 상인 모래", + "text": "상자 주인 얘길 들었어. 삼천 종 중 절반이 죽었다며." + }, + { + "speaker": "떠돌이 상인 모래", + "text": "난 겨우 몇 상자 열었을 뿐이야." + }, + { + "speaker": "지문", + "text": "모래가 수레바퀴에 묻은 모래를 털어 낸다." + }, + { + "speaker": "떠돌이 상인 모래", + "text": "그래도 그 상자 속 애들한텐 그게 전부였겠지." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 55, + "rewardExp": 140, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_frostline_meet", + "chapterId": "town", + "order": 91, + "prerequisiteBeatId": "", + "requiredBeatId": "ch9_arrive", + "trigger": { + "type": "NpcTalk", + "param": "town_frostline" + }, + "speakerNpcId": "town_frostline", + "lines": [ + { + "speaker": "필사생 서리", + "text": "잠깐만요, 이 줄만 마저 베끼고요… 됐다." + }, + { + "speaker": "필사생 서리", + "text": "전 얼음 벽에 남은 글을 공책에 베껴 써요." + }, + { + "speaker": "필사생 서리", + "text": "얼음 속 글씨는 안 바래요. 그런데 읽으러 오는 사람이 없어요." + }, + { + "speaker": "지문", + "text": "서리의 공책에 곤충 이름과 작은 그림이 빼곡하다.", + "fx": "pause" + }, + { + "speaker": "필사생 서리", + "text": "요즘 누가 벽 앞 곤충들을 들쑤셔서 못 다가가요." + }, + { + "speaker": "필사생 서리", + "text": "네 번만 겨뤄서 진정시켜 줄래요?" + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 24, + "rewardExp": 0, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_frostline_sign", + "chapterId": "town", + "order": 92, + "prerequisiteBeatId": "town_frostline_meet", + "requiredRegionId": "frostline", + "trigger": { + "type": "CaptureInsect", + "param": "beetle_hoarfrost" + }, + "speakerNpcId": "ruins_scholar", + "lines": [ + { + "speaker": "세라", + "text": "등에 서리가 하얗게 앉은 딱정벌레예요." + }, + { + "speaker": "세라", + "text": "서리 씨 공책에 오늘 만났다고 적어 달라고 해요." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 24, + "rewardExp": 55, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_frostline_close", + "chapterId": "town", + "order": 93, + "prerequisiteBeatId": "town_frostline_meet", + "requiredQuestId": "s_town_frostline", + "trigger": { + "type": "NpcTalk", + "param": "town_frostline" + }, + "speakerNpcId": "town_frostline", + "lines": [ + { + "speaker": "필사생 서리", + "text": "조용해졌네요. 덕분에 벽 하나를 다 베꼈어요." + }, + { + "speaker": "필사생 서리", + "text": "들쑤시던 사람이요? 검은 옷이 곤충마다 번호를 붙였어요." + }, + { + "speaker": "지문", + "text": "서리가 공책 한 장을 건넨다. 이름 옆에 '만남'이라고 쓰여 있다.", + "fx": "pause" + }, + { + "speaker": "필사생 서리", + "text": "이름만 적으면 장부예요. 만난 날을 적으면 기록이고요." + }, + { + "speaker": "필사생 서리", + "text": "이건 얼음 틈에서 주운 거예요. 전 싸울 일이 없어서요." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 40, + "rewardExp": 0, + "rewardItemId": "disc_mega_strike", + "rewardItemCount": 1, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_frostline_after", + "chapterId": "town", + "order": 94, + "prerequisiteBeatId": "town_frostline_close", + "requiredBeatId": "ch10_arrive", + "trigger": { + "type": "NpcTalk", + "param": "town_frostline" + }, + "speakerNpcId": "town_frostline", + "lines": [ + { + "speaker": "필사생 서리", + "text": "잿불 골짜기 기록이 다 탔다고 들었어요." + }, + { + "speaker": "필사생 서리", + "text": "한 곳에만 있는 기록은 그곳이 무너지면 끝이더라고요." + }, + { + "speaker": "지문", + "text": "서리의 짐 옆에 공책이 몇 권 더 늘어 있다." + }, + { + "speaker": "필사생 서리", + "text": "그래서 두 벌씩 베껴요. 하나는 여기, 하나는 들고 다녀요." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 55, + "rewardExp": 140, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_emberfall_meet", + "chapterId": "town", + "order": 101, + "prerequisiteBeatId": "", + "requiredBeatId": "ch10_arrive", + "trigger": { + "type": "NpcTalk", + "param": "town_emberfall" + }, + "speakerNpcId": "town_emberfall", + "lines": [ + { + "speaker": "광부 숯", + "text": "어이, 갱도 쪽으론 가지 마. 요새 자주 무너져." + }, + { + "speaker": "광부 숯", + "text": "여긴 곤충 기록을 모아 둔 집이 있었어. 불에 다 탔지." + }, + { + "speaker": "지문", + "text": "숯의 손바닥이 새까맣다. 석탄 가루인지 재인지 모르겠다.", + "fx": "pause" + }, + { + "speaker": "광부 숯", + "text": "종이는 타도 기억은 남아. 그래서 사람들 기억을 받아 적어." + }, + { + "speaker": "광부 숯", + "text": "불 난 해에도 귀뚜라미가 울었다는 할머니도 있지." + }, + { + "speaker": "광부 숯", + "text": "정말 그런지 보고 싶어. 여기 곤충 다섯만 만나 줘." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 26, + "rewardExp": 0, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_emberfall_sign", + "chapterId": "town", + "order": 102, + "prerequisiteBeatId": "town_emberfall_meet", + "requiredRegionId": "emberfall", + "trigger": { + "type": "CaptureInsect", + "param": "cricket_ember" + }, + "speakerNpcId": "town_emberfall", + "lines": [ + { + "speaker": "지문", + "text": "재 속에서 귀뚜라미가 운다. 등이 불씨처럼 붉다." + }, + { + "speaker": "지문", + "text": "불 난 해에도 귀뚜라미는 울었다 — 할머니 말이 맞았다.", + "fx": "pause" + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 26, + "rewardExp": 60, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_emberfall_close", + "chapterId": "town", + "order": 103, + "prerequisiteBeatId": "town_emberfall_meet", + "requiredQuestId": "s_town_emberfall", + "trigger": { + "type": "NpcTalk", + "param": "town_emberfall" + }, + "speakerNpcId": "town_emberfall", + "lines": [ + { + "speaker": "광부 숯", + "text": "다 맞았어. 할머니 기억도, 대장장이 할아버지 기억도." + }, + { + "speaker": "지문", + "text": "숯이 공책에 까만 동그라미를 친다.", + "fx": "pause" + }, + { + "speaker": "광부 숯", + "text": "종이는 타도 기억은 사람을 따라다녀. 사람은 곤충을 또 만나고." + }, + { + "speaker": "광부 숯", + "text": "이거 받아. 갱도 깊은 데서 캔 귀한 거야." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 40, + "rewardExp": 0, + "rewardItemId": "spirit_blessing", + "rewardItemCount": 1, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_emberfall_after", + "chapterId": "town", + "order": 104, + "prerequisiteBeatId": "town_emberfall_close", + "requiredBeatId": "ch12_echo", + "trigger": { + "type": "NpcTalk", + "param": "town_emberfall" + }, + "speakerNpcId": "town_emberfall", + "lines": [ + { + "speaker": "광부 숯", + "text": "그 소년, 다시 걷는다며? 갱도 무너진 날 그 애 말이야." + }, + { + "speaker": "광부 숯", + "text": "난 그날을 받아 적었어." + }, + { + "speaker": "광부 숯", + "text": "소년은 곤충을 구했고, 검은 옷 사람은 소년을 구했지." + }, + { + "speaker": "지문", + "text": "숯의 공책 마지막 장에 그날 일이 또박또박 적혀 있다.", + "fx": "pause" + }, + { + "speaker": "광부 숯", + "text": "누가 누굴 구했는지는 꼭 적어야 해. 다음 불에 안 타게." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 60, + "rewardExp": 150, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_canopy_meet", + "chapterId": "town", + "order": 111, + "prerequisiteBeatId": "", + "requiredBeatId": "ch11_arrive", + "trigger": { + "type": "NpcTalk", + "param": "town_canopy" + }, + "speakerNpcId": "town_canopy", + "lines": [ + { + "speaker": "나무타기 잎새", + "text": "채집가님! 큰일 났어요!" + }, + { + "speaker": "나무타기 잎새", + "text": "제 누에가 없어졌어요! 어제까지 이 잎에 있었는데!" + }, + { + "speaker": "지문", + "text": "잎 뒷면에 하얀 고치 하나가 매달려 있다.", + "fx": "pause" + }, + { + "speaker": "나무타기 잎새", + "text": "여긴 아무것도 없어진 적이 없대요. 할머니 때도요." + }, + { + "speaker": "나무타기 잎새", + "text": "다른 애들도 없어질까 봐 무서워요." + }, + { + "speaker": "나무타기 잎새", + "text": "우듬지 귀한 곤충 셋만 만나서 다들 있는지 봐 주세요!" + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 28, + "rewardExp": 0, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_canopy_sign", + "chapterId": "town", + "order": 112, + "prerequisiteBeatId": "town_canopy_meet", + "requiredRegionId": "canopy", + "trigger": { + "type": "CaptureInsect", + "param": "caterpillar_silk" + }, + "speakerNpcId": "ruins_scholar", + "lines": [ + { + "speaker": "세라", + "text": "누에네요. 잎새가 찾던 아이랑 같은 종이에요." + }, + { + "speaker": "세라", + "text": "잎새한테 고치 얘기를 해 줘야겠어요. 없어진 게 아니라고." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 28, + "rewardExp": 65, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_canopy_close", + "chapterId": "town", + "order": 113, + "prerequisiteBeatId": "town_canopy_meet", + "requiredQuestId": "s_town_canopy", + "trigger": { + "type": "NpcTalk", + "param": "town_canopy" + }, + "speakerNpcId": "town_canopy", + "lines": [ + { + "speaker": "나무타기 잎새", + "text": "다들 있었어요? 다행이다…" + }, + { + "speaker": "나무타기 잎새", + "text": "세라 언니가 그랬어요. 그 하얀 건 누에가 든 집이래요." + }, + { + "speaker": "지문", + "text": "잎새가 고치가 달린 잎을 살살 쓰다듬는다.", + "fx": "pause" + }, + { + "speaker": "나무타기 잎새", + "text": "그럼 기다리면 돼요. 기다리는 건 잘해요!" + }, + { + "speaker": "나무타기 잎새", + "text": "할머니가 아끼던 거예요. 채집가님은 없어지지 마요." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 45, + "rewardExp": 0, + "rewardItemId": "disc_eclipse", + "rewardItemCount": 1, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + }, + { + "beatId": "town_canopy_after", + "chapterId": "town", + "order": 114, + "prerequisiteBeatId": "town_canopy_close", + "requiredBeatId": "fin_seal", + "trigger": { + "type": "NpcTalk", + "param": "town_canopy" + }, + "speakerNpcId": "town_canopy", + "lines": [ + { + "speaker": "나무타기 잎새", + "text": "채집가님! 봐요, 나왔어요!" + }, + { + "speaker": "지문", + "text": "고치가 비었다. 하얀 나방이 젖은 날개를 말린다.", + "fx": "pause" + }, + { + "speaker": "나무타기 잎새", + "text": "이름을 뭐라고 하죠? 이젠 누에가 아니잖아요." + }, + { + "speaker": "나무타기 잎새", + "text": "…이름은 안 지을래요. 이 애가 만날 친구들이 불러 주겠죠." + } + ], + "choices": [], + "onComplete": { + "rewardCandy": 70, + "rewardExp": 160, + "rewardItemId": "", + "rewardItemCount": 0, + "rewardInsectId": "", + "rewardInsectDisplayName": "", + "rewardInsectLevel": 0, + "unlockQuestId": "" + } + } + ], + "chapters": [ + { + "chapterId": "ch1", + "title": "1장 · 초원", + "openingBeatId": "ch1_intro", + "recap": [], + "goal": "첫 곤충 친구와 초원을 둘러보자" + }, + { + "chapterId": "ch2", + "title": "2장 · 연못", + "openingBeatId": "ch1_reach_pond", + "recap": [ + "초원의 곤충들이 소리 없이 사라지고 있다.", + "어르신은 이 일을 '잦아듦'이라고 불렀다.", + "초원의 수문장을 넘어 새 길이 열렸다." + ], + "goal": "연못의 곤충들을 만나 도감에 적자" + }, + { + "chapterId": "ch3", + "title": "3장 · 숲", + "openingBeatId": "ch3_reach_forest", + "recap": [ + "연못에도 잦아듦이 번져 있었다.", + "사라짐의 뿌리는 고대 유적에 있다고 한다.", + "연못의 수문장 왕잠자리를 넘어섰다." + ], + "goal": "숲에서 사라짐의 실마리를 찾자" + }, + { + "chapterId": "ch4", + "title": "4장 · 습지", + "openingBeatId": "ch4_reach_swamp", + "recap": [ + "숲에서 유적 학자 세라를 만났다.", + "유적에는 곤충 이름을 새긴 벽이 있다고 한다.", + "그 이름이 바래면 곤충도 사라지는 것 같다." + ], + "goal": "습지에서 사라지는 곤충들을 지키자" + }, + { + "chapterId": "ch5", + "title": "5장 · 산", + "openingBeatId": "ch5_reach_mountain", + "recap": [ + "습지에서 라온, 세라와 한 팀이 됐다.", + "습지는 생명이 많아야 할 곳인데도 조용했다.", + "습지의 수문장을 넘어 산으로 왔다." + ], + "goal": "산을 넘어 고대 유적으로 가자" + }, + { + "chapterId": "ch6", + "title": "6장 · 고대 유적", + "openingBeatId": "ch6_reach_ruins", + "recap": [ + "세라는 도감이 벽의 이름을 되살린다고 믿는다.", + "곤충을 적을수록 바랜 이름이 진해진다고 한다.", + "산의 수문장 아틀라스나방이 길을 열었다." + ], + "goal": "유적 안쪽 신전에 들어가 보자" + }, + { + "chapterId": "ch7", + "title": "7장 · 텅 빈 들", + "openingBeatId": "ch7_opening", + "recap": [ + "신전에서 곤충 이름을 새긴 '이름 벽'을 찾았다.", + "이름이 바래면 그 곤충이 사라졌던 것이다.", + "도감 덕분에 벽의 이름이 다시 빛났다." + ], + "goal": "그림자를 따라 텅 빈 들로 가자" + }, + { + "chapterId": "ch8", + "title": "8장 · 모래언덕", + "openingBeatId": "ch8_arrive", + "recap": [ + "벽에서 움직이는 빈칸, 그림자를 봤다.", + "그림자는 곤충의 이름을 빼앗아 숨는다.", + "이름 잃은 곤충은 도감에 적으면 돌아온다." + ], + "goal": "수레바퀴 자국을 따라 창고를 찾자" + }, + { + "chapterId": "ch9", + "title": "9장 · 서릿길", + "openingBeatId": "ch9_arrive", + "recap": [ + "모래 창고에 곤충 상자가 가득했다.", + "명부회 집게는 전부 가둬야 한다고 했다.", + "집게와의 대결에서 이겼다." + ], + "goal": "얼음 벽이 무사한 이유를 알아내자" + }, + { + "chapterId": "ch10", + "title": "10장 · 잿불 골짜기", + "openingBeatId": "ch10_arrive", + "recap": [ + "얼음 서고에서 명부회 저울을 만났다.", + "세라는 한때 명부회였다고 털어놓았다.", + "저울과의 대결에서 이겼다." + ], + "goal": "잿불 골짜기에 남은 곤충을 구하자" + }, + { + "chapterId": "ch11", + "title": "11장 · 우듬지", + "openingBeatId": "ch11_arrive", + "recap": [ + "갱도가 무너져 라온이 크게 다쳤다.", + "명부회의 먹이 라온을 구해 냈다.", + "라온은 쉬고, 세라와 둘이 길을 간다." + ], + "goal": "우듬지가 무사한 이유를 찾자" + }, + { + "chapterId": "ch12", + "title": "12장 · 이름 없는 자리", + "openingBeatId": "ch12_arrive", + "recap": [ + "우듬지 나무에도 이름이 새겨져 있었다.", + "꽃밭과 우듬지는 비상 울타리였다.", + "남은 건 울타리의 맨 끝뿐이다." + ], + "goal": "지도에 없는 땅에서 그림자를 쫓자" + }, + { + "chapterId": "fin", + "title": "마지막 장 · 빈칸", + "openingBeatId": "fin_unnamed", + "recap": [ + "장부의 방에서 세라의 스승 하월을 만났다.", + "하월이 가둔 곤충 절반이 죽었다고 했다.", + "하월과의 대결에서 이겨 길이 열렸다." + ], + "goal": "그림자에게 이름을 주지 말고 이기자" } ] } diff --git a/Assets/Resources/UI/Badges.meta b/Assets/Resources/UI/Badges.meta new file mode 100644 index 0000000..f9b00ef --- /dev/null +++ b/Assets/Resources/UI/Badges.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b298b2361ece7034f8abc7dc6de8a777 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UI/Badges/badge_canopy.png b/Assets/Resources/UI/Badges/badge_canopy.png new file mode 100644 index 0000000..dc87e6b Binary files /dev/null and b/Assets/Resources/UI/Badges/badge_canopy.png differ diff --git a/Assets/Resources/UI/Badges/badge_canopy.png.meta b/Assets/Resources/UI/Badges/badge_canopy.png.meta new file mode 100644 index 0000000..86ed932 --- /dev/null +++ b/Assets/Resources/UI/Badges/badge_canopy.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: 6f0c28877e88303409c83fd5b98d08c4 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UI/Badges/badge_dunes.png b/Assets/Resources/UI/Badges/badge_dunes.png new file mode 100644 index 0000000..ef0d70a Binary files /dev/null and b/Assets/Resources/UI/Badges/badge_dunes.png differ diff --git a/Assets/Resources/UI/Badges/badge_dunes.png.meta b/Assets/Resources/UI/Badges/badge_dunes.png.meta new file mode 100644 index 0000000..8c9e071 --- /dev/null +++ b/Assets/Resources/UI/Badges/badge_dunes.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: 08e0f1f869e086d47ad30aab5cd151eb +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UI/Badges/badge_emberfall.png b/Assets/Resources/UI/Badges/badge_emberfall.png new file mode 100644 index 0000000..a316a64 Binary files /dev/null and b/Assets/Resources/UI/Badges/badge_emberfall.png differ diff --git a/Assets/Resources/UI/Badges/badge_emberfall.png.meta b/Assets/Resources/UI/Badges/badge_emberfall.png.meta new file mode 100644 index 0000000..e7dd1d2 --- /dev/null +++ b/Assets/Resources/UI/Badges/badge_emberfall.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: c0143858561261e418b25dc8afa122a7 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UI/Badges/badge_forest.png b/Assets/Resources/UI/Badges/badge_forest.png new file mode 100644 index 0000000..fb2db96 Binary files /dev/null and b/Assets/Resources/UI/Badges/badge_forest.png differ diff --git a/Assets/Resources/UI/Badges/badge_forest.png.meta b/Assets/Resources/UI/Badges/badge_forest.png.meta new file mode 100644 index 0000000..f116bb3 --- /dev/null +++ b/Assets/Resources/UI/Badges/badge_forest.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: 72a4efbb05f70b84ca6c145ddb2a8c8d +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UI/Badges/badge_frostline.png b/Assets/Resources/UI/Badges/badge_frostline.png new file mode 100644 index 0000000..2b97a0b Binary files /dev/null and b/Assets/Resources/UI/Badges/badge_frostline.png differ diff --git a/Assets/Resources/UI/Badges/badge_frostline.png.meta b/Assets/Resources/UI/Badges/badge_frostline.png.meta new file mode 100644 index 0000000..ed18f3c --- /dev/null +++ b/Assets/Resources/UI/Badges/badge_frostline.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: c3cac2ddbafba224d9fd187e70db685a +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UI/Badges/badge_garden.png b/Assets/Resources/UI/Badges/badge_garden.png new file mode 100644 index 0000000..18e56a8 Binary files /dev/null and b/Assets/Resources/UI/Badges/badge_garden.png differ diff --git a/Assets/Resources/UI/Badges/badge_garden.png.meta b/Assets/Resources/UI/Badges/badge_garden.png.meta new file mode 100644 index 0000000..af4eb11 --- /dev/null +++ b/Assets/Resources/UI/Badges/badge_garden.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: fca31b007225ca34485861cc8214ef54 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UI/Badges/badge_glow.png b/Assets/Resources/UI/Badges/badge_glow.png new file mode 100644 index 0000000..2aef37c Binary files /dev/null and b/Assets/Resources/UI/Badges/badge_glow.png differ diff --git a/Assets/Resources/UI/Badges/badge_glow.png.meta b/Assets/Resources/UI/Badges/badge_glow.png.meta new file mode 100644 index 0000000..0bef4bc --- /dev/null +++ b/Assets/Resources/UI/Badges/badge_glow.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: 3e5bc8f1a74aa504dab53df6ad6d9177 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UI/Badges/badge_hollow.png b/Assets/Resources/UI/Badges/badge_hollow.png new file mode 100644 index 0000000..b7a5be1 Binary files /dev/null and b/Assets/Resources/UI/Badges/badge_hollow.png differ diff --git a/Assets/Resources/UI/Badges/badge_hollow.png.meta b/Assets/Resources/UI/Badges/badge_hollow.png.meta new file mode 100644 index 0000000..be140f9 --- /dev/null +++ b/Assets/Resources/UI/Badges/badge_hollow.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: f8c01260e7713c547a8dbaf612eff035 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UI/Badges/badge_meadow.png b/Assets/Resources/UI/Badges/badge_meadow.png new file mode 100644 index 0000000..f251524 Binary files /dev/null and b/Assets/Resources/UI/Badges/badge_meadow.png differ diff --git a/Assets/Resources/UI/Badges/badge_meadow.png.meta b/Assets/Resources/UI/Badges/badge_meadow.png.meta new file mode 100644 index 0000000..5e9dae7 --- /dev/null +++ b/Assets/Resources/UI/Badges/badge_meadow.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: 8e34ae2bf6f32d4499493dd986f3da82 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UI/Badges/badge_mountain.png b/Assets/Resources/UI/Badges/badge_mountain.png new file mode 100644 index 0000000..81fba5d Binary files /dev/null and b/Assets/Resources/UI/Badges/badge_mountain.png differ diff --git a/Assets/Resources/UI/Badges/badge_mountain.png.meta b/Assets/Resources/UI/Badges/badge_mountain.png.meta new file mode 100644 index 0000000..89fd44e --- /dev/null +++ b/Assets/Resources/UI/Badges/badge_mountain.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: 7f7038c6359a3e04d97a9ff4613f9a4f +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UI/Badges/badge_nameless.png b/Assets/Resources/UI/Badges/badge_nameless.png new file mode 100644 index 0000000..8bd361e Binary files /dev/null and b/Assets/Resources/UI/Badges/badge_nameless.png differ diff --git a/Assets/Resources/UI/Badges/badge_nameless.png.meta b/Assets/Resources/UI/Badges/badge_nameless.png.meta new file mode 100644 index 0000000..664a11b --- /dev/null +++ b/Assets/Resources/UI/Badges/badge_nameless.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: f87c1a9bf4562664cbc8d53965b3cb3f +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UI/Badges/badge_pond.png b/Assets/Resources/UI/Badges/badge_pond.png new file mode 100644 index 0000000..f4f614b Binary files /dev/null and b/Assets/Resources/UI/Badges/badge_pond.png differ diff --git a/Assets/Resources/UI/Badges/badge_pond.png.meta b/Assets/Resources/UI/Badges/badge_pond.png.meta new file mode 100644 index 0000000..80ba7ec --- /dev/null +++ b/Assets/Resources/UI/Badges/badge_pond.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: c1559f5a887d2c64585724afd64a845e +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UI/Badges/badge_rays.png b/Assets/Resources/UI/Badges/badge_rays.png new file mode 100644 index 0000000..852b704 Binary files /dev/null and b/Assets/Resources/UI/Badges/badge_rays.png differ diff --git a/Assets/Resources/UI/Badges/badge_rays.png.meta b/Assets/Resources/UI/Badges/badge_rays.png.meta new file mode 100644 index 0000000..15d34d1 --- /dev/null +++ b/Assets/Resources/UI/Badges/badge_rays.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: 6d6044c0367886b4aab999d2ebac373c +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UI/Badges/badge_ruins.png b/Assets/Resources/UI/Badges/badge_ruins.png new file mode 100644 index 0000000..f226c81 Binary files /dev/null and b/Assets/Resources/UI/Badges/badge_ruins.png differ diff --git a/Assets/Resources/UI/Badges/badge_ruins.png.meta b/Assets/Resources/UI/Badges/badge_ruins.png.meta new file mode 100644 index 0000000..f11ea62 --- /dev/null +++ b/Assets/Resources/UI/Badges/badge_ruins.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: b85730bd1bade9a40a41af7f74a8ac2d +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UI/Badges/badge_sparkle.png b/Assets/Resources/UI/Badges/badge_sparkle.png new file mode 100644 index 0000000..d8eb7ee Binary files /dev/null and b/Assets/Resources/UI/Badges/badge_sparkle.png differ diff --git a/Assets/Resources/UI/Badges/badge_sparkle.png.meta b/Assets/Resources/UI/Badges/badge_sparkle.png.meta new file mode 100644 index 0000000..3961f9b --- /dev/null +++ b/Assets/Resources/UI/Badges/badge_sparkle.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: 281dddd7f39bb41478fdbf8b8ca2bc27 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UI/Badges/badge_swamp.png b/Assets/Resources/UI/Badges/badge_swamp.png new file mode 100644 index 0000000..0c2f3f9 Binary files /dev/null and b/Assets/Resources/UI/Badges/badge_swamp.png differ diff --git a/Assets/Resources/UI/Badges/badge_swamp.png.meta b/Assets/Resources/UI/Badges/badge_swamp.png.meta new file mode 100644 index 0000000..54de221 --- /dev/null +++ b/Assets/Resources/UI/Badges/badge_swamp.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: 263009bd9a4b5ce44a2cd9de53cbbdd7 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: WebGL + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Battle/BattleArenaController.Flourish.cs b/Assets/Scripts/Battle/BattleArenaController.Flourish.cs new file mode 100644 index 0000000..1208029 --- /dev/null +++ b/Assets/Scripts/Battle/BattleArenaController.Flourish.cs @@ -0,0 +1,660 @@ +using System.Collections; +using System.Collections.Generic; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Spawning; +using UnityEngine; + +namespace InsectGame.Battle +{ + /// + /// 전투 체감 연출 — ① 전용기(시전자 클로즈업 컷인 → 속성색 큰 이펙트 여러 겹 → 타격), ② 사마귀 베기의 칼날 궤적, + /// ③ 승리(내 곤충의 계열별 포즈 + 카메라 반 바퀴 / 레이드 팀 전원 점프 + 팀을 담는 카메라), ④ 전투 진입 샷(상대 옆에서 크게 휘돌아 들어온다). + /// 시각표·포즈는 , 카메라 샷은 (둘 다 순수). + /// + /// ui-dev가 읽는 공개 값 — 전용기 컷인 글자: ··· + /// ··. 「승리!」: · + /// ·. 진입 배너: ·. + /// + /// 머티리얼: 빛·연기·조각은 전부 , 흙·잎 조각만 불투명 , + /// 쇳조각은 — 새 셰이더·키워드가 없다(Standard 변형 BuildKeepers에 기대지 않는다). + /// + public partial class BattleArenaController + { + // ───────────── 전용기 상태(ui-dev 컷인 글자가 읽는다) ───────────── + + private bool signatureActive; + private float signatureElapsed; + private float signatureCutInEnd; + private float signatureImpactSeconds; + private bool signatureFromPlayer; + private string signatureSkillName; + private InsectElement signatureElement; + + /// + /// 지금 전용기 연출 중인가 — 스킬 연출 첫 프레임부터 타격 뒤 까지(1대1), + /// 레이드는 그 팀원의 볼리(0.46초) + 꼬리. ui-dev가 기술 이름 컷인을 이 동안 그린다. + /// + public bool IsSignaturePlaying => signatureActive; + /// 전용기 연출이 시작된 뒤 지난 연출 시계 초(연출 중이 아니면 0). + public float SignatureElapsed => signatureActive ? signatureElapsed : 0f; + /// + /// 이번 연출의 컷인 끝(연출 시계 초) — 보통 (0.5초), 타격이 이른 짧은 연출이면 그보다 짧다. + /// 레이드는 볼리 타격(0.46초) 전에 끝난다. + /// + public float SignatureCutInEndSeconds => signatureActive ? signatureCutInEnd : 0f; + /// 이번 연출의 타격 시각(연출 시계 초) — 1대1 스킬(2.5초)이면 0.9~1.0초. + public float SignatureImpactSeconds => signatureActive ? signatureImpactSeconds : 0f; + /// 시전자 클로즈업 컷인 구간인가(~). + public bool IsSignatureCutIn => signatureActive && signatureElapsed < signatureCutInEnd; + /// 컷인 진행률 0~1(컷인 밖이면 0이 아니라 1 — 컷인이 지났다). 연출 중이 아니면 0. + public float SignatureCutInProgress => !signatureActive ? 0f + : Mathf.Clamp01(signatureElapsed / Mathf.Max(0.01f, signatureCutInEnd)); + /// 전용기를 쓴 쪽이 내 곤충(레이드는 팀원)인가 — 컷인을 어느 쪽에서 들일지. + public bool SignatureFromPlayer => signatureFromPlayer; + /// 전용기 이름(연출 중이 아니면 null). + public string SignatureSkillName => signatureActive ? signatureSkillName : null; + /// 전용기 속성 — 컷인 띠 색(). + public InsectElement SignatureElement => signatureElement; + + /// + /// 전용기 머리 위 기술 이름 말풍선(BeginSkillPresentation)을 띄울지. ui-dev의 컷인이 같은 이름을 크게 그리면 꺼서 겹치지 않게 한다. + /// + public static bool ShowSignatureCallout = true; + + private void BeginSignature(GameObject caster, InsectElement element, HitCue cue, bool fromPlayer, float impactSeconds, float cutInEnd) + { + signatureActive = true; + signatureElapsed = 0f; + signatureImpactSeconds = impactSeconds; + signatureCutInEnd = cutInEnd; + signatureFromPlayer = fromPlayer; + signatureSkillName = cue.SkillName; + signatureElement = element; + if (caster != null) StartCoroutine(SignatureChargeCoroutine(caster, element, Mathf.Max(0.25f, impactSeconds))); + } + + private void EndSignature() + { + signatureActive = false; + signatureElapsed = 0f; + } + + // ───────────── 전용기 — 기 모으기 ───────────── + + /// + /// 컷인 동안 시전자가 기를 모은다 — 발밑에서 솟는 속성색 빛기둥, 몸 둘레를 나선으로 감아 오르는 속성 조각(잎·물방울·번개·돌…), + /// 발밑 고리, 몸이 속성색으로 맥동한다. 타격 직전에 조각이 시전자 앞으로 모인다. 밝은 섬광 줄이기면 빛기둥·맥동 없이 조각만. + /// + private IEnumerator SignatureChargeCoroutine(GameObject caster, InsectElement element, float seconds) + { + if (arenaRoot == null || caster == null) yield break; + Color color = GetElementColor3D(element); + Color glow = Color.Lerp(color, Color.white, 0.35f); + bool flashes = !BattlePresentation.ReducedFlashes; + Bounds b = BattleFraming.ModelBounds(caster); + float radius = Mathf.Max(0.45f, Mathf.Max(b.extents.x, b.extents.z) * 1.1f); + float height = Mathf.Max(0.6f, b.size.y); + Vector3 feet = new Vector3(b.center.x, b.min.y + 0.03f, b.center.z); + // 몸 중심은 루트 기준 어긋남으로 따라간다 — 매 프레임 경계를 다시 재면 렌더러 배열을 새로 만든다. + Vector3 centerOffset = b.center - caster.transform.position; + float feetOffset = feet.y - caster.transform.position.y; + var fx = new List(); + + Material columnMat = null; + GameObject column = null; + if (flashes) + { + columnMat = CreateFxMaterial(new Color(glow.r, glow.g, glow.b, 0f), true); + column = FxPrimitive(PrimitiveType.Cylinder, "SignatureColumn", feet + Vector3.up * height, columnMat); + fx.Add(column); + } + StartCoroutine(GroundRingCoroutine(feet, radius * 1.6f, radius * 0.5f, 0.12f, + new Color(glow.r, glow.g, glow.b, 0.85f), Mathf.Min(0.6f, seconds), flashes)); + + const int Shards = 10; + Material shardMat = ShardMaterial(element, color, flashes); + var shards = new Transform[Shards]; + for (int i = 0; i < Shards; i++) + { + GameObject s = CreateElementShard(element, shardMat, feet); + fx.Add(s); + shards[i] = s.transform; + } + + // 조각이 모이는 쪽 — 시전자가 바라보는 앞(모델은 상대를 향해 서 있다). + Vector3 toward = caster.transform.forward; + toward.y = 0f; + toward = toward.sqrMagnitude > 0.0001f ? toward.normalized : Vector3.forward; + + float t = 0f; + try + { + while (t < seconds) + { + if (arenaRoot == null || caster == null) yield break; + t += PresentationDelta; + float k = Mathf.Clamp01(t / seconds); + Vector3 center = caster.transform.position + centerOffset; + feet.y = caster.transform.position.y + feetOffset; + if (column != null) + { + float width = Mathf.Lerp(0.15f, radius * 1.3f, Mathf.SmoothStep(0f, 1f, k / 0.35f)) * (1f - 0.6f * k * k); + column.transform.position = new Vector3(center.x, feet.y + height, center.z); + column.transform.localScale = new Vector3(width, height, width); + columnMat.color = new Color(glow.r, glow.g, glow.b, 0.45f * Mathf.Sin(Mathf.Min(1f, k * 1.15f) * Mathf.PI)); + } + // 나선으로 감아 오르다(0~0.75) 시전자 앞 한 점으로 모인다(0.75~1). + float gather = Mathf.SmoothStep(0f, 1f, (k - 0.75f) / 0.25f); + Vector3 focus = center + toward * (radius * 0.9f); + for (int i = 0; i < Shards; i++) + { + if (shards[i] == null) continue; + float a = i * Mathf.PI * 2f / Shards + t * 7f; + float rise = Mathf.Repeat(k * 1.6f + i * 0.1f, 1f); + float r = radius * Mathf.Lerp(1.25f, 0.55f, rise); + Vector3 spiral = new Vector3(center.x + Mathf.Cos(a) * r, feet.y + rise * height * 1.1f, center.z + Mathf.Sin(a) * r); + shards[i].position = Vector3.Lerp(spiral, focus, gather); + shards[i].rotation = Quaternion.Euler(t * 300f + i * 37f, t * 410f, i * 50f); + shards[i].localScale = ShardScale(element, radius * 0.22f * (1f - 0.5f * gather)); + } + if (flashes) SetFlash(caster, glow, 0.18f + 0.22f * Mathf.Abs(Mathf.Sin(t * 14f))); + yield return null; + } + } + finally + { + if (caster != null && flashes) SetFlash(caster, Color.white, 0f); + for (int i = 0; i < fx.Count; i++) if (fx[i] != null) Destroy(fx[i]); + } + } + + // ───────────── 전용기 — 큰 이펙트 ───────────── + + /// + /// 전용기 타격 — 속성 임팩트를 세 겹(가운데 + 좌우로 0.07초씩 늦게), 그 위에 속성 조각이 크게 사방으로 터지고(속성마다 다르게 난다 — + /// 잎은 팔랑이며, 물은 포물선으로 떨어지며, 번개는 지그재그로, 바람은 소용돌이로, 흙은 무겁게, 독은 부풀며 떠오른다), 화면을 향한 + /// 큰 충격 고리 두 개, 흔들림. 보통 기술보다 확실히 크게(). 밝은 섬광 줄이기면 조각만. + /// + private void PlaySignatureBurst(Vector3 point, InsectElement element, float size) + { + if (arenaRoot == null) return; + Color color = GetElementColor3D(element); + Camera cam = Camera.main; + Vector3 side = cam != null ? cam.transform.right : Vector3.right; + if (!BattlePresentation.ReducedFlashes) + { + StartCoroutine(DelayedElementImpact(point + side * (0.55f * size) + Vector3.up * 0.15f, element, color, 0.07f)); + StartCoroutine(DelayedElementImpact(point - side * (0.55f * size) + Vector3.up * 0.1f, element, color, 0.14f)); + StartCoroutine(ImpactBurstCoroutine(point, Color.Lerp(color, Color.white, 0.2f), 1f)); + StartCoroutine(SignatureRingCoroutine(point, color, size)); + } + StartCoroutine(SignatureShardBurstCoroutine(point, element, color, size)); + CameraFollower follower = ResolveFollower(); + if (follower != null) follower.Shake(0.4f, 0.5f); + } + + /// 큰 이펙트 크기 — 대상 몸집을 따르되(1대1 곤충 ≈1.0, 레이드 보스 ≈2.0) 화면을 덮지 않게 자른다. + private static float SignatureBurstSize(GameObject target) + { + return BattleFlourish.SignatureBurstScale * Mathf.Clamp(FlareSize(target) * 0.7f, 0.55f, 1.1f); + } + + private IEnumerator DelayedElementImpact(Vector3 point, InsectElement element, Color color, float delay) + { + float t = 0f; + while (t < delay) + { + if (arenaRoot == null) yield break; + t += PresentationDelta; + yield return null; + } + CreateElementImpact3D(point, element, color); + } + + /// 화면을 향한 큰 충격 고리 둘 — 0.1초 간격으로 반지름 2.6배까지 퍼진다(가산). + private IEnumerator SignatureRingCoroutine(Vector3 point, Color color, float size) + { + if (arenaRoot == null) yield break; + Camera cam = Camera.main; + Vector3 toCam = cam != null ? (cam.transform.position - point).normalized : Vector3.back; + Color hot = Color.Lerp(color, Color.white, 0.45f); + Material mat = CreateFxMaterial(new Color(hot.r, hot.g, hot.b, 0f), true); + var rings = new LineRenderer[2]; + for (int i = 0; i < rings.Length; i++) + { + var go = new GameObject("SignatureRing"); + go.transform.SetParent(arenaRoot.transform, false); + go.transform.position = point + toCam * 0.2f; + go.transform.rotation = Quaternion.LookRotation(-toCam); + rings[i] = go.AddComponent(); + rings[i].useWorldSpace = false; + rings[i].loop = true; + rings[i].positionCount = 44; + rings[i].sharedMaterial = mat; + rings[i].shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + rings[i].receiveShadows = false; + rings[i].widthMultiplier = 0f; + } + const float Each = 0.5f; + const float Stagger = 0.1f; + float t = 0f; + while (t < Each + Stagger) + { + if (arenaRoot == null) yield break; + t += PresentationDelta; + float alpha = 0f; + for (int i = 0; i < rings.Length; i++) + { + if (rings[i] == null) continue; + float k = Mathf.Clamp01((t - i * Stagger) / Each); + if (k <= 0f) continue; + float radius = Mathf.Lerp(0.3f, 2.6f * size, 1f - (1f - k) * (1f - k)); + SetFacingRingRadius(rings[i], radius, Mathf.Lerp(0.22f, 0.02f, k)); + alpha = Mathf.Max(alpha, 0.9f * (1f - k)); + } + mat.color = new Color(hot.r, hot.g, hot.b, alpha); + yield return null; + } + for (int i = 0; i < rings.Length; i++) if (rings[i] != null) Destroy(rings[i].gameObject); + } + + /// 속성 조각 열넷이 화면 평면 쪽으로 크게 터진다 — 속성마다 나는 모양이 다르다. + private IEnumerator SignatureShardBurstCoroutine(Vector3 point, InsectElement element, Color color, float size) + { + if (arenaRoot == null) yield break; + Material mat = ShardMaterial(element, color, !BattlePresentation.ReducedFlashes); + Camera cam = Camera.main; + Vector3 toCam = cam != null ? (cam.transform.position - point).normalized : Vector3.back; + const int Count = 14; + var shards = new Transform[Count]; + var dirs = new Vector3[Count]; + uint seed = 52711u + (uint)element * 977u; + for (int i = 0; i < Count; i++) + { + shards[i] = CreateElementShard(element, mat, point).transform; + float a = (i + NextRand(ref seed) * 0.5f) * Mathf.PI * 2f / Count; + Vector3 d = Quaternion.LookRotation(-toCam) * new Vector3(Mathf.Cos(a), Mathf.Sin(a), 0f); + d.y = Mathf.Abs(d.y) * 0.8f + 0.15f; + dirs[i] = d.normalized; + } + const float Duration = 0.65f; + float reach = 2.4f * size; + float t = 0f; + while (t < Duration) + { + if (arenaRoot == null) yield break; + t += PresentationDelta; + float k = Mathf.Clamp01(t / Duration); + float ease = 1f - (1f - k) * (1f - k); + for (int i = 0; i < Count; i++) + { + if (shards[i] == null) continue; + shards[i].position = point + ShardFlight(element, dirs[i], ease, k, i) * reach; + shards[i].rotation = Quaternion.Euler(t * 520f + i * 23f, t * 380f, i * 41f); + shards[i].localScale = ShardScale(element, 0.16f * size * (1f - 0.7f * k)); + } + yield return null; + } + for (int i = 0; i < Count; i++) if (shards[i] != null) Destroy(shards[i].gameObject); + } + + /// 속성마다 조각이 나는 길(반지름 1 기준) — 같은 방향이라도 결이 다르게. + private static Vector3 ShardFlight(InsectElement element, Vector3 dir, float ease, float k, int i) + { + Vector3 side = Vector3.Cross(Vector3.up, dir); + if (side.sqrMagnitude < 0.0001f) side = Vector3.right; + side.Normalize(); + switch (element) + { + case InsectElement.Leaf: // 팔랑이며 퍼진다 + return dir * ease + side * (Mathf.Sin(k * 14f + i) * 0.12f) + Vector3.down * (0.25f * k * k); + case InsectElement.Water: // 포물선으로 솟았다 떨어진다 + return dir * ease + Vector3.up * (0.5f * Mathf.Sin(k * Mathf.PI)) + Vector3.down * (0.6f * k * k); + case InsectElement.Electric: // 지그재그 + return dir * ease + side * ((Mathf.Repeat(k * 9f + i * 0.3f, 1f) < 0.5f ? 1f : -1f) * 0.1f); + case InsectElement.Wind: // 소용돌이 + { + float a = k * Mathf.PI * 3f + i; + return dir * ease + (side * Mathf.Cos(a) + Vector3.up * Mathf.Sin(a)) * (0.25f * (1f - k)); + } + case InsectElement.Earth: // 무겁게 솟았다 떨어진다 + return dir * (ease * 0.8f) + Vector3.up * (0.7f * Mathf.Sin(k * Mathf.PI)) + Vector3.down * (0.9f * k * k); + case InsectElement.Poison: // 부풀며 위로 떠오른다 + return dir * (ease * 0.7f) + Vector3.up * (0.5f * k); + case InsectElement.Dark: // 한 번 빨려 들었다 터진다 + return dir * (k < 0.25f ? -0.15f * (k / 0.25f) : Mathf.Lerp(-0.15f, 1.1f, (k - 0.25f) / 0.75f)); + default: + return dir * ease; + } + } + + private static Vector3 ShardScale(InsectElement element, float s) + { + switch (element) + { + case InsectElement.Leaf: return new Vector3(s * 1.4f, s * 0.12f, s * 0.7f); + case InsectElement.Electric: return new Vector3(s * 0.22f, s * 1.6f, s * 0.22f); + case InsectElement.Metal: return new Vector3(s * 1.7f, s * 0.14f, s * 0.45f); + case InsectElement.Light: return new Vector3(s * 0.3f, s * 1.5f, s * 0.3f); + case InsectElement.Wind: return new Vector3(s * 1.4f, s * 0.12f, s * 0.12f); + default: return Vector3.one * s; + } + } + + /// 속성 조각 머티리얼 — 빛나는 속성은 가산, 물·독·어둠은 반투명, 흙·잎은 불투명, 쇠는 광택. + private Material ShardMaterial(InsectElement element, Color color, bool glow) + { + switch (element) + { + case InsectElement.Earth: + case InsectElement.Leaf: + return CreateSafeMaterial(color); + case InsectElement.Metal: + return CreateSheenMaterial(new Color(0.82f, 0.85f, 0.9f), SurfaceKind.Metal); + case InsectElement.Water: + case InsectElement.Poison: + case InsectElement.Dark: + return CreateFxMaterial(new Color(color.r, color.g, color.b, 0.75f), false); + default: + return CreateFxMaterial(new Color(color.r, color.g, color.b, 0.95f), glow); + } + } + + private GameObject CreateElementShard(InsectElement element, Material mat, Vector3 position) + { + PrimitiveType shape; + switch (element) + { + case InsectElement.Water: + case InsectElement.Poison: + case InsectElement.Bug: + case InsectElement.None: + shape = PrimitiveType.Sphere; + break; + default: + shape = PrimitiveType.Cube; + break; + } + GameObject go = FxPrimitive(shape, "SignatureShard", position, mat); + go.transform.localScale = Vector3.zero; + return go; + } + + // ───────────── 사마귀 베기 ───────────── + + /// + /// 칼날 궤적 — 시전자 앞에서 대상 쪽으로 휘는 활 모양 선(가산, 0.2초). 첫 번째와 두 번째 베기는 반대 대각선이다. + /// 밝은 섬광 줄이기면 그리지 않는다(몸의 휘두름은 남는다). + /// + private void PlaySlashArc(GameObject attacker, GameObject target, Color color, bool first) + { + if (arenaRoot == null || attacker == null || target == null || BattlePresentation.ReducedFlashes) return; + StartCoroutine(SlashArcCoroutine(attacker, target, color, first)); + } + + private IEnumerator SlashArcCoroutine(GameObject attacker, GameObject target, Color color, bool first) + { + Bounds ab = BattleFraming.ModelBounds(attacker); + Bounds tb = BattleFraming.ModelBounds(target); + Vector3 dir = tb.center - ab.center; + dir.y = 0f; + dir = dir.sqrMagnitude > 0.0001f ? dir.normalized : Vector3.right; + Vector3 center = Vector3.Lerp(ab.center, tb.center, 0.62f); + float span = Mathf.Max(0.5f, Mathf.Max(tb.extents.y, ab.extents.y) * 1.5f); + Vector3 side = Vector3.Cross(Vector3.up, dir).normalized; + // 대각선 — 첫 베기는 위-앞에서 아래-뒤로, 두 번째는 반대. + Vector3 axis = (Vector3.up * (first ? 1f : -1f) + side * 0.8f).normalized; + Color hot = Color.Lerp(color, Color.white, 0.6f); + Material mat = CreateFxMaterial(new Color(hot.r, hot.g, hot.b, 0.95f), true); + var go = new GameObject("SlashArc"); + go.transform.SetParent(arenaRoot.transform, false); + LineRenderer line = go.AddComponent(); + line.useWorldSpace = true; + line.positionCount = 14; + line.sharedMaterial = mat; + line.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + line.receiveShadows = false; + line.widthCurve = new AnimationCurve(new Keyframe(0f, 0.01f), new Keyframe(0.55f, 1f), new Keyframe(1f, 0.02f)); + const float Duration = 0.2f; + float t = 0f; + while (t < Duration) + { + if (arenaRoot == null || line == null) yield break; + t += PresentationDelta; + float k = Mathf.Clamp01(t / Duration); + float sweep = Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(k / 0.55f)); + for (int i = 0; i < line.positionCount; i++) + { + float u = (float)i / (line.positionCount - 1) * sweep; + float s = Mathf.Lerp(-0.5f, 0.5f, u); + line.SetPosition(i, center + axis * (s * span) + dir * (0.25f * span * (1f - 4f * s * s))); + } + line.widthMultiplier = 0.14f * (1f - k * 0.7f); + mat.color = new Color(hot.r, hot.g, hot.b, 0.95f * (1f - k * k)); + yield return null; + } + if (go != null) Destroy(go); + } + + // ───────────── 승리 ───────────── + + private bool victoryPlaying; + private bool victoryDone; + private float victoryElapsed; + + /// 승리 연출이 도는 중인가(, 실제 초). ui-dev가 「승리!」를 이 동안 띄운다. + public bool IsVictoryPlaying => victoryPlaying; + /// + /// 승리 연출이 끝까지 돌았다 — 그 뒤 카메라는 마지막 구도에 머문다(결과 화면 뒤). 이번 전투에서 승리 연출을 안 했으면 false. + /// 움직임 줄이기면 몸·카메라는 그대로이고 시간만 흐른다(이 값은 같은 시각에 선다). + /// + public bool VictoryFinished => victoryDone; + /// 승리 연출 진행률 0~1(시작 전 0, 끝나면 1). + public float VictoryProgress => victoryDone ? 1f : victoryPlaying ? Mathf.Clamp01(victoryElapsed / BattleFlourish.VictorySeconds) : 0f; + + private sealed class VictoryState + { + public GameObject[] Heroes; + public Pose3[] Rests; + public BattleMotion.Family[] Families; + public float[] Delays; + public Vector3 Pivot; + public Vector3 Face; + public float Orbit; + public float EndDistance; + public float EndHeight; + } + + /// + /// 1대1 승리 — 내 곤충이 계열별 승리 포즈()를 잡고, 카메라가 그 둘레를 얼굴 쪽으로 반 바퀴 + /// () 돌며 다가간다(). 결과 화면에 들어가는 순간 + /// BattleScreenUI가 부른다(상대의 쓰러짐은 이미 끝났다). 패배·도망은 부르지 않는다. + /// + public void PlayVictory() + { + if (!isActive || arenaRoot == null || playerModel == null || !playerModel.activeInHierarchy || bossModel != null) return; + Bounds b = BattleFraming.ModelBounds(playerModel); + float baseDistance = hasBaseCam ? Vector3.Distance(new Vector3(baseCamPos.x, 0f, baseCamPos.z), new Vector3(b.center.x, 0f, b.center.z)) : 6f; + var st = new VictoryState + { + Heroes = new[] { playerModel }, + Pivot = b.center, + Face = playerModel.transform.forward, + Orbit = BattleFlourish.VictoryOrbitDegrees, + EndDistance = Mathf.Max(BattleFlourish.VictoryMinDistance, Mathf.Max(b.extents.x, b.extents.y) * 3.2f, + baseDistance * BattleFlourish.VictoryCloseRatio), + EndHeight = 0.15f + b.extents.y * 0.2f + }; + StartVictory(st); + } + + /// + /// 레이드 승리 — 서 있는 팀원 모두가 0.08초씩 어긋나 계열별 포즈로 뛰어오르고, 카메라가 팀 뒤에서 팀 앞(보스가 있던 쪽)으로 + /// 돌아 다섯 마리를 한 화면에 담는다. 보스 쓰러짐이 끝난 뒤(결과 단계 진입) RaidBattleUI가 부른다. + /// + public void PlayRaidVictory() + { + if (!isActive || arenaRoot == null || teamModels == null) return; + var heroes = new List(teamModels.Length); + for (int i = 0; i < teamModels.Length; i++) + if (IsStanding(teamModels[i])) heroes.Add(teamModels[i]); + if (heroes.Count == 0) return; + Bounds b = BattleFraming.ModelBounds(heroes[0]); + for (int i = 1; i < heroes.Count; i++) b.Encapsulate(BattleFraming.ModelBounds(heroes[i])); + Camera cam = Camera.main; + float fov = cam != null ? cam.fieldOfView : 42f; + float aspect = cam != null ? cam.aspect : 16f / 9f; + Vector3 face = bossBattlePos - b.center; + var st = new VictoryState + { + Heroes = heroes.ToArray(), + Pivot = b.center, + Face = face, + Orbit = BattleFlourish.RaidVictoryOrbitDegrees, + EndDistance = BattleFlourish.FitDistance(Mathf.Max(b.extents.x, b.extents.z) + 0.4f, b.extents.y + 0.9f, fov, aspect), + EndHeight = 0.9f + b.extents.y + }; + StartVictory(st); + } + + private void StartVictory(VictoryState st) + { + int n = st.Heroes.Length; + st.Rests = new Pose3[n]; + st.Families = new BattleMotion.Family[n]; + st.Delays = new float[n]; + for (int i = 0; i < n; i++) + { + st.Rests[i] = PoseOf(st.Heroes[i]); + InsectEntity entity = st.Heroes[i].GetComponent(); + st.Families[i] = BattleMotion.FamilyOf(entity != null && entity.Data != null ? entity.Data.insectId : null); + st.Delays[i] = i * BattleFlourish.RaidVictoryStagger; + } + // 벽이 카메라 뒤에 서지 않게 — 끝 자리까지 넉넉히 민다(수문장 샷과 같은 방법). + float reach = Mathf.Max(st.EndDistance, hasBaseCam ? Vector3.Distance(baseCamPos, st.Pivot) : 0f); + float span = Mathf.Max(CurrentWallSpan(), Mathf.Abs(st.Pivot.x - arenaCenter.x) + reach + 2f, + Mathf.Abs(st.Pivot.z - arenaCenter.z) + reach + 2f); + PlaceArenaWalls(span); + victoryPlaying = true; + victoryDone = false; + victoryElapsed = 0f; + BeginStaging(VictoryCoroutine(st), () => FinishVictory(st)); + } + + private IEnumerator VictoryCoroutine(VictoryState st) + { + bool motion = !BattlePresentation.ReducedMotion; + bool cried = false; + float t = 0f; + while (isActive && arenaRoot != null) + { + for (int i = 0; i < st.Heroes.Length; i++) + { + GameObject hero = st.Heroes[i]; + if (hero == null || !motion) continue; + BattleMotion.Pose pose = BattleFlourish.VictoryPose(st.Families[i], t - st.Delays[i]); + Pose3 rest = st.Rests[i]; + hero.transform.SetPositionAndRotation(rest.Position + Vector3.up * pose.Lift, + Quaternion.AngleAxis(pose.Yaw, Vector3.up) * rest.Rotation * Quaternion.Euler(pose.Pitch, 0f, pose.Roll)); + } + if (!cried && t >= 0.15f) + { + cried = true; + if (AudioManager.Instance != null && st.Heroes.Length > 0 && st.Heroes[0] != null) + AudioManager.Instance.PlayCry(CryKey(CryOf(st.Heroes[0], false), false)); + } + if (hasBaseCam) + ApplyStagingShot(BattleCameraDirector.EvaluateVictoryOrbit(t, baseCamPos, baseCamRot, st.Pivot, st.Face, + st.Orbit, st.EndDistance, st.EndHeight)); + victoryElapsed = t; + if (!victoryDone && t >= BattleFlourish.VictorySeconds) + { + victoryDone = true; + victoryPlaying = false; + } + yield return null; + // 실제 초 — 결과 화면(ResultShownSeconds)과 같은 시계라 2배속에서도 「승리!」와 박자가 같다. + t += Time.deltaTime * BattlePresentation.TimeScale; + } + } + + private void FinishVictory(VictoryState st) + { + for (int i = 0; i < st.Heroes.Length; i++) + if (st.Heroes[i] != null) SetPose(st.Heroes[i], st.Rests[i]); + victoryPlaying = false; + ClearCameraShot(); + } + + // ───────────── 전투 진입 샷 ───────────── + + private bool openingPlaying; + private float openingElapsed; + + /// 전투 진입 샷이 도는 중인가(, 실제 초). + public bool IsOpeningPlaying => openingPlaying; + /// 진입 샷 진행률 0~1(시작 전·끝난 뒤 1이 아니라 — 돌지 않으면 1). + public float OpeningProgress => openingPlaying ? Mathf.Clamp01(openingElapsed / BattleFlourish.OpeningShotSeconds) : 1f; + + /// + /// 1대1 전투 진입 — 카메라가 상대 바로 옆(배틀 구도에서 상대 쪽으로 75° 돌아간 낮은 자리)에서 상대를 보며 시작해, 크게 휘돌아 물러나 + /// 배틀 구도에 내려앉는다(, 실제 초). BattleScreenUI가 아레나를 세운 직후(인트로 시작)에 부른다 — + /// ui-dev의 화면 쓸기 전환과 「야생 ○○이(가) 나타났다!」가 그 위에 그려진다. 움직임 줄이기면 카메라는 구도에 고정이다. + /// 다른 연출이 시작되면(스킬 등) 끝 상태(배틀 구도)로 접힌다. + /// + public void PlayBattleOpening() + { + if (!isActive || arenaRoot == null || !hasBaseCam) return; + GameObject subject = bossModel != null ? bossModel : enemyModel; + if (subject == null) return; + Bounds focus = BattleFraming.ModelBounds(subject); + if (bossModel != null) + { + if (teamModels != null) + foreach (GameObject m in teamModels) + if (m != null) focus.Encapsulate(BattleFraming.ModelBounds(m)); + } + else if (playerModel != null) focus.Encapsulate(BattleFraming.ModelBounds(playerModel)); + Vector3 subjectCenter = BattleFraming.ModelBounds(subject).center; + Vector3 focusCenter = focus.center; + openingPlaying = true; + openingElapsed = 0f; + BeginStaging(OpeningCoroutine(subjectCenter, focusCenter), FinishOpening); + } + + private IEnumerator OpeningCoroutine(Vector3 subject, Vector3 focus) + { + float t = 0f; + while (isActive && arenaRoot != null && t < BattleFlourish.OpeningShotSeconds) + { + openingElapsed = t; + ApplyStagingShot(BattleCameraDirector.EvaluateOpening(t, baseCamPos, baseCamRot, focus, subject)); + yield return null; + // 실제 초 — 1대1 진입 구간(BattleReadPacing.EntryIntroSeconds)이 배속과 무관하게 실제 시간으로 흐른다. + t += Time.deltaTime * BattlePresentation.TimeScale; + } + EndStaging(); + } + + private void FinishOpening() + { + openingPlaying = false; + openingElapsed = 0f; + ClearCameraShot(); + } + + /// 아레나 정리 — 코루틴은 이미 멈췄다. 표지만 되돌린다. + private void ClearFlourishState() + { + signatureActive = false; + signatureElapsed = 0f; + signatureSkillName = null; + victoryPlaying = false; + victoryDone = false; + victoryElapsed = 0f; + openingPlaying = false; + openingElapsed = 0f; + } + } +} diff --git a/Assets/Scripts/Battle/BattleArenaController.Flourish.cs.meta b/Assets/Scripts/Battle/BattleArenaController.Flourish.cs.meta new file mode 100644 index 0000000..34d593b --- /dev/null +++ b/Assets/Scripts/Battle/BattleArenaController.Flourish.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 02833c0381813e946ac7783ada6db145 \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleArenaController.Impact.cs b/Assets/Scripts/Battle/BattleArenaController.Impact.cs new file mode 100644 index 0000000..6eb42f3 --- /dev/null +++ b/Assets/Scripts/Battle/BattleArenaController.Impact.cs @@ -0,0 +1,786 @@ +using System.Collections; +using System.Collections.Generic; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Spawning; +using UnityEngine; + +namespace InsectGame.Battle +{ + /// + /// 타격감 — 히트스톱·넉백·피격 플래시·임팩트 버스트·외침·연출 카메라. + /// + /// 예전 타격은 대상이 제자리에서 0.12m 떨고 카메라가 0.12 세기로 0.18초 흔들리는 게 전부였고, + /// 벌레 속성 임팩트는 바닥에 깔리는 납작한 원판이었다(CreateSafeMaterial은 불투명이라 알파 + /// 페이드가 먹지 않는다). 무엇이 누구를 얼마나 세게 쳤는지가 몸으로 읽히지 않았다. + /// + /// 본체(2천 줄 넘는 연출 코루틴 모음)를 더 키우지 않으려고 partial로 떼었다. 본체는 + /// SkillAttackCoroutine에서 ·· + /// ·를 부르고, 정리는 CleanupArena가 + /// 로 한다. + /// + public partial class BattleArenaController + { + /// + /// 연출이 알아야 하는 타격 정보 — 결과는 이미 확정돼 있고(전투는 동기로 해결된다) 연출은 그걸 + /// 얼마나 세게 보여줄지만 정한다. 판정을 다시 하지 않는다. + /// + public readonly struct HitCue + { + /// 외칠 기술 이름. null이면 외치지 않는다(기본 공격). + public readonly string SkillName; + /// + /// 타격 세기 0..1 — 넉백·불꽃 수·카메라 반동처럼 연속으로 커지는 것이 따른다. 평타는 + /// ~, 치명타는 이상, 마무리는 늘 1이다. + /// + public readonly float Weight; + /// 이 한 방으로 쓰러졌다 — 슬로모션, 가장 큰 연출. + public readonly bool Finisher; + /// 피해 기술인데 피해가 0 — 빗나감(대상이 피한다). + public readonly bool Missed; + /// + /// 진짜 치명타 — 컨트롤러·리졸버가 굴린 판정(InsectBattleController.LastPlayerHitCritical· + /// RaidActionResult.Critical)을 그대로 옮긴다. 별 빛살·긴 멈춤·CriticalHit 소리가 이걸 따른다. + /// + public readonly bool Critical; + /// + /// 전용기 — 곤충마다 하나씩 있는 필살기(로 부르는 쪽이 가른다 — 아레나는 판정하지 않는다). + /// 1대1은 시전자 클로즈업 컷인 → 속성색 큰 이펙트 → 타격(BattleArenaController.Flourish), 레이드는 기 모으기 + 큰 이펙트. + /// + public readonly bool Signature; + + public HitCue(string skillName, float weight, bool finisher, bool missed, bool critical = false, bool signature = false) + { + SkillName = skillName; + Finisher = finisher; + Missed = missed; + Critical = critical && !missed; + Signature = signature; + // 마무리는 늘 만점 — 남은 HP가 적어 평타로 끝냈어도 "가장 크게"가 보여야 한다. + Weight = finisher ? 1f : Mathf.Clamp01(weight); + } + + /// 같은 타격에 전용기 표지만 붙인 사본 — 부르는 쪽이 BuildHitCue(…).WithSignature(…)로 쓴다. + public HitCue WithSignature(bool signature) + { + return new HitCue(SkillName, Weight, Finisher, Missed, Critical, signature); + } + + public static readonly HitCue None = new HitCue(null, 0f, false, false); + + // ── 세기 표 ── + // 2026-10-04 진짜 치명타(1/16, ×1.5) 도입과 짝. 예전 식(피해 ÷ 최대 HP의 40%)은 3~4라운드 전투의 평타 + // (최대 HP의 25~35%)에 0.6~0.9를 주어, 평타가 CriticalHit 소리·큰 비명·큰 흔들림·긴 멈춤을 다 받았고 + // 치명타와 대비가 없었다. 지금은 평타가 "무거운 타격"선(BattleShout.HeavyWeight) 아래에 머문다. + + /// 피해 몫 만점 — 대상 최대 HP의 40%를 깎으면 그 등급의 천장이다. + public const float FullShare = 0.4f; + /// 평타 세기 바닥(긁힌 정도의 피해). + public const float NormalFloor = 0.25f; + /// 평타 세기 천장 — 보다 아래여야 한다(테스트가 고정). + public const float NormalCeiling = 0.5f; + /// 치명타 세기 바닥 — 적게 들어간 치명타도 이만큼 무겁다. + public const float CriticalFloor = 0.85f; + + /// + /// 피해량·대상 최대 HP·치명타로 세기를 낸다. 같은 피해 몫이라도 평타는 + /// [, ], 치명타는 [, 1]에 놓인다 — + /// 두 구간이 겹치지 않아 아무리 센 평타도 가장 약한 치명타보다 가볍다. + /// + public static float WeightFor(int damage, int targetMaxHp, bool critical) + { + if (damage <= 0 || targetMaxHp <= 0) return 0f; + float share = Mathf.Clamp01(damage / (targetMaxHp * FullShare)); + return critical ? Mathf.Lerp(CriticalFloor, 1f, share) : Mathf.Lerp(NormalFloor, NormalCeiling, share); + } + + /// + /// 치명타·마무리 — 센 의성어·큰 비명·CriticalHit 소리·보스 비명이 이것만 본다(세기 문턱이 아니다). + /// + public bool Heavy => Critical || Finisher; + + // 히트스톱(실제 초) — 평타 0.06~0.08 · 치명타 0.16~0.18 · 마무리 0.19(+슬로모션) · 치명타 마무리 상한 0.24. + public const float HitStopBase = 0.045f; + public const float HitStopPerWeight = 0.07f; + public const float CriticalHitStopBonus = 0.06f; + public const float FinisherHitStopBonus = 0.07f; + /// 전용기 타격의 멈춤 덧셈 — 큰 이펙트가 터지는 순간을 조금 더 붙든다(상한 는 그대로). + public const float SignatureHitStopBonus = 0.05f; + public const float MaxHitStop = 0.24f; + + /// 타격 순간 멈춤(실제 초). 빗나감은 멈추지 않는다. + public float HitStopSeconds => Missed ? 0f : Mathf.Min(MaxHitStop, + HitStopBase + HitStopPerWeight * Weight + + (Critical ? CriticalHitStopBonus : 0f) + (Finisher ? FinisherHitStopBonus : 0f) + + (Signature ? SignatureHitStopBonus : 0f)); + + // 카메라 흔들림 — 평타 0.12~0.16 · 치명타 0.32~0.34 · 마무리 0.38. 전체 공격은 +0.12(그래도 평타 전체 공격 + // 0.24~0.28 < 치명타 한 방 0.32 — 큰 기술이라고 치명타의 몫을 빼앗지 않는다). + public const float ShakeBase = 0.08f; + public const float ShakePerWeight = 0.16f; + public const float HeavyShakeBonus = 0.10f; + public const float FinisherShakeBonus = 0.04f; + public const float AreaShakeBonus = 0.12f; + + /// 카메라 흔들림 세기. 면 레이드 보스 전체 공격(팀원 다섯이 함께 맞는다). + public float ShakeAmplitude(bool area = false) + { + if (Missed) return 0f; + return ShakeBase + ShakePerWeight * Weight + (Heavy ? HeavyShakeBonus : 0f) + + (Finisher ? FinisherShakeBonus : 0f) + (area ? AreaShakeBonus : 0f); + } + + /// 카메라 흔들림 길이(초) — 평타 0.21~0.26 · 치명타·마무리 0.41~0.44, 전체 공격 +0.1. + public float ShakeSeconds(bool area = false) + { + if (Missed) return 0f; + return 0.16f + 0.2f * Weight + (Heavy ? 0.08f : 0f) + (area ? 0.1f : 0f); + } + } + + /// + /// 치명타 강조색 — 내가 친 치명타는 금빛, 맞은 치명타는 붉은빛. BattleScreenUI.RevealImpact의 화면 섬광과 + /// 같은 색 규칙이다(그쪽은 화면 전체, 여기는 맞은 자리의 별 빛살). + /// + public static Color CriticalAccent(bool struckByUs) + { + return struckByUs ? new Color(1f, 0.86f, 0.3f) : new Color(1f, 0.3f, 0.24f); + } + + /// 이 대상이 상대 쪽(1v1 적·레이드 보스)이다 — 맞힌 쪽이 우리 편이다. + private bool StruckByUs(GameObject target) + { + return target != null && (target == enemyModel || target == bossModel); + } + + private Vector3 baseCamPos; + private Quaternion baseCamRot = Quaternion.identity; + private bool hasBaseCam; + + private readonly List activeShouts = new List(); + private const int MaxConcurrentShouts = 5; + + private readonly Dictionary activeReacts = new Dictionary(); + private readonly Dictionary reactRestPoses = new Dictionary(); + private MaterialPropertyBlock flashBlock; + + private struct Pose3 + { + public Vector3 Position; + public Quaternion Rotation; + public Vector3 Scale; + } + + // ── 외침 ── + + /// 떠 있는 외침. 그리기는 UI(BattleShoutOverlay)가 한다 — 효과 문구와 같은 이음매. + public IReadOnlyList GetActiveShouts() + { + float now = Time.unscaledTime; + for (int i = activeShouts.Count - 1; i >= 0; i--) + { + BattleShout.Entry e = activeShouts[i]; + if (e == null || now - e.StartTime >= e.Duration) activeShouts.RemoveAt(i); + } + return activeShouts; + } + + /// + /// 외침을 띄운다. 만큼 늦게 나타난다(실제 초) — 타격음 뒤에 비명이 + /// 따라와야 둘이 한 덩어리로 안 뭉개진다. + /// + /// 띄운 항목(문구가 비면 null) — 일찍 거둬야 하는 쪽(쓰러짐 「털썩…」)이 쥐어 둔다. + public BattleShout.Entry PlayShout(BattleShout.Kind kind, Vector3 worldPoint, string text, Color color, + float duration, float delay = 0f, float tilt = 0f) + { + if (string.IsNullOrEmpty(text)) return null; + if (activeShouts.Count >= MaxConcurrentShouts) activeShouts.RemoveAt(0); + var entry = new BattleShout.Entry + { + Text = text, + Kind = kind, + WorldPoint = worldPoint, + Color = color, + // 시작을 미래로 잡으면 나이(now - StartTime)가 음수라 그 동안은 그리지도 지우지도 않는다. + StartTime = Time.unscaledTime + Mathf.Max(0f, delay), + Duration = duration, + Tilt = tilt + }; + activeShouts.Add(entry); + return entry; + } + + /// 쓰러짐 의성어(「털썩…」) — 상대 교체 단계가 시작되면 일찍 거둔다(). + private readonly List faintShouts = new List(); + /// 교체 단계 시작에 「털썩…」이 사라지는 길이(실제 초) — 오버레이의 끝 페이드(0.25초)보다 짧게 접는다. + private const float FaintShoutRetireSeconds = 0.12f; + + /// + /// 남은 「털썩…」을 일찍 거둔다 — 팀 대결에서 첫 상대가 쓰러진 뒤 교체 배너(아래쪽 가운데, ui-dev)가 뜨는 자리와 겹쳤다 + /// (3단계 QA). 아직 나타나지 않은 것(지연 중)은 지우고, 떠 있는 것은 안에 사라지게 수명을 줄인다. + /// + private void RetireFaintShouts() + { + float now = Time.unscaledTime; + for (int i = 0; i < faintShouts.Count; i++) + { + BattleShout.Entry e = faintShouts[i]; + if (e == null || !activeShouts.Contains(e)) continue; + float age = now - e.StartTime; + if (age < 0f) activeShouts.Remove(e); + else e.Duration = Mathf.Min(e.Duration, age + FaintShoutRetireSeconds); + } + faintShouts.Clear(); + } + + private static BattleShout.Cry CryOf(GameObject model, bool boss) + { + if (boss) return BattleShout.Cry.Boss; + InsectEntity entity = model != null ? model.GetComponent() : null; + return BattleShout.CryFor(entity != null && entity.Data != null ? entity.Data.insectId : null); + } + + private static string CryKey(BattleShout.Cry cry, bool hurt) + { + return (hurt ? "hurt_" : "cry_") + cry.ToString().ToLowerInvariant(); + } + + private Vector3 HeadPoint(GameObject model) + { + Bounds b = BattleFraming.ModelBounds(model); + return new Vector3(b.center.x, b.max.y, b.center.z); + } + + // ── 시전 ── + + /// + /// 스킬 연출 첫 프레임 — 기술 이름을 외치고 운다. 강화·회복도 외친다(몸을 부풀리는 순간이다). + /// + private void BeginSkillPresentation(GameObject attacker, InsectElement element, HitCue cue) + { + if (attacker == null) return; + bool boss = attacker == bossModel; + BattleShout.Cry cry = CryOf(attacker, boss); + if (!string.IsNullOrEmpty(cue.SkillName) && (!cue.Signature || ShowSignatureCallout)) + { + // 0.95초 — 카메라가 대상 쪽으로 넘어가기 전에 사라진다. 길면 시전자 머리 위에 고정된 + // 말풍선이 화면 가장자리(HP 상자 위)로 밀려난다(실측). + PlayShout(BattleShout.Kind.Callout, HeadPoint(attacker), BattleShout.Callout(cue.SkillName), + GetUIElementColor(element), 0.95f); + } + if (AudioManager.Instance != null) AudioManager.Instance.PlayCry(CryKey(cry, false)); + } + + // ── 타격 ── + + /// + /// 타격 순간 — 히트스톱, 넉백·플래시, 버스트, 의성어·비명, 카메라 흔들림. + /// 원래 있던 속성 임팩트(CreateElementImpact3D)와 스킬 효과음은 호출부가 그대로 낸다. + /// + private void PlayImpactFeel(GameObject attacker, GameObject target, Vector3 hitPoint, + InsectElement element, Color color, HitCue cue) + { + if (target == null) return; + Vector3 dir = target.transform.position - (attacker != null ? attacker.transform.position : hitPoint); + dir.y = 0f; + dir = dir.sqrMagnitude > 0.0001f ? dir.normalized : Vector3.forward; + float w = cue.Weight; + bool boss = target == bossModel; + + if (cue.Missed) + { + StartReact(target, DodgeCoroutine(target, dir)); + PlayShout(BattleShout.Kind.Sound, hitPoint, "휙!", new Color(0.82f, 0.86f, 0.92f), 0.7f, + 0f, Random.Range(-8f, 8f)); + return; + } + + // 평타는 짧게·가볍게, 치명타는 길게·크게, 마무리는 가장 크게 — 세기 표는 HitCue 한 곳이다. + BattlePresentation.HitStop(cue.HitStopSeconds); + if (cue.Finisher) BattlePresentation.SlowMotion(0.3f, 0.6f); + + Color accent = CriticalAccent(StruckByUs(target)); + StartReact(target, HitReactCoroutine(target, dir, w, boss, cue.Critical, accent)); + if (!BattlePresentation.ReducedFlashes) + { + // 치명타는 불꽃·충격파를 강조색으로 물들이고 별 빛살을 하나 얹는다 — 속성 임팩트는 호출부가 그대로 낸다. + StartCoroutine(ImpactBurstCoroutine(hitPoint, cue.Critical ? Color.Lerp(color, accent, 0.65f) : color, w)); + if (cue.Critical) StartCoroutine(CriticalFlareCoroutine(hitPoint, accent, FlareSize(target))); + } + + // 자리 나눔 — 피해 숫자·CRITICAL은 대상 몸통 위(BattleScreenUI), 의성어는 맞아서 밀려나는 + // 쪽 아래, 비명은 같은 쪽 머리 높이. 셋을 한 점에 띄우면 타격 순간 글자가 뭉개진다(실측). + Color soundColor = cue.Critical ? Color.Lerp(accent, Color.white, 0.2f) : Color.Lerp(color, Color.white, 0.35f); + float shoutWeight = cue.Heavy ? 1f : w; + PlayShout(BattleShout.Kind.Sound, hitPoint + dir * 0.65f + Vector3.down * 0.1f, + BattleShout.Sound(element, shoutWeight), soundColor, 0.85f, 0f, Random.Range(-11f, 11f)); + // 보스는 치명타·마무리에만 비명을 지른다 — 레이드는 한 라운드에 팀원 다섯이 연달아 때려서 + // 매번 지르면 "그르륵!"이 다섯 번 반복된다. + bool voice = !boss || cue.Heavy; + BattleShout.Cry cry = CryOf(target, boss); + if (voice) + PlayShout(BattleShout.Kind.Hurt, boss ? BossShoutPoint(target) : HeadPoint(target) + dir * 0.55f, + BattleShout.Hurt(cry, shoutWeight, Random.Range(0, 100)), + Color.white, 1.0f, 0.12f); + + if (AudioManager.Instance != null) + { + AudioManager.Instance.PlaySFX(cue.Heavy ? SfxType.CriticalHit : SfxType.Hit); + if (voice) StartCoroutine(PlayCryDelayed(CryKey(cry, true), 0.1f)); + } + + CameraFollower follower = ResolveFollower(); + if (follower != null) follower.Shake(cue.ShakeAmplitude(), cue.ShakeSeconds()); + } + + /// 별 빛살 크기 — 대상 몸집을 따른다(레이드 보스는 1v1 곤충의 두 배쯤). + private static float FlareSize(GameObject target) + { + Bounds b = BattleFraming.ModelBounds(target); + return Mathf.Clamp(Mathf.Max(b.extents.x, b.extents.y) * 1.3f, 0.6f, 1.8f); + } + + /// + /// 레이드 보스의 비명 자리 — 머리 위가 아니라 머리 옆(카메라 오른쪽). 머리 위에 띄우면 팀원 치명타의 「치명타!」(보스 중심 위 + /// 피해 숫자 바로 위 — RaidBattleUI.DrawRaidCritCaption)와 같은 세로 줄에 겹쳤다(1단계 QA, 「그오오오!!」). + /// 화면에서 보스 중심과의 가로 거리가 짧은 변의 31% 이상이 되게 민다 — 가상 캔버스(짧은 변 1080)로 약 330px, + /// 비명 칸 반폭(160) + 「치명타!」 반폭(150)보다 넓다. 화면 오른쪽 끝(86%)을 넘으면 거기서 멈춘다. + /// + private Vector3 BossShoutPoint(GameObject boss) + { + Vector3 head = HeadPoint(boss); + Camera cam = Camera.main; + if (cam == null || boss == null) return head; + Vector3 right = cam.transform.right; + right.y = 0f; + right = right.sqrMagnitude > 0.0001f ? right.normalized : Vector3.right; + Bounds b = BattleFraming.ModelBounds(boss); + Vector3 centerScreen = cam.WorldToScreenPoint(b.center); + Vector3 headScreen = cam.WorldToScreenPoint(head); + Vector3 stepScreen = cam.WorldToScreenPoint(head + right); + float pixelsPerMeter = Mathf.Abs(stepScreen.x - headScreen.x); + if (centerScreen.z <= 0f || headScreen.z <= 0f || pixelsPerMeter < 1f) + return head + right * (b.extents.x + 0.6f); + float need = 0.31f * Mathf.Min(Screen.width, Screen.height); + float push = Mathf.Max(0f, need - (headScreen.x - centerScreen.x)) / pixelsPerMeter; + float room = Mathf.Max(0f, Screen.width * 0.86f - headScreen.x) / pixelsPerMeter; + return head + right * Mathf.Min(push, room) + Vector3.down * (b.extents.y * 0.15f); + } + + /// 쓰러짐 — 바닥에 "털썩…"과 마지막 비명. 쓰러지는 동작은 PlayFaintCoroutine이 한다. + private void AnnounceFaint(GameObject model) + { + if (model == null) return; + Bounds b = BattleFraming.ModelBounds(model); + BattleShout.Entry thud = PlayShout(BattleShout.Kind.Sound, new Vector3(b.center.x, b.min.y + 0.1f, b.center.z), "털썩…", + new Color(0.78f, 0.8f, 0.86f), 1.1f, 0.25f, Random.Range(-6f, 6f)); + if (thud != null) faintShouts.Add(thud); + if (AudioManager.Instance != null) + AudioManager.Instance.PlayCry(CryKey(CryOf(model, model == bossModel), true)); + } + + private IEnumerator PlayCryDelayed(string key, float delay) + { + yield return new WaitForSecondsRealtime(delay); + if (AudioManager.Instance != null) AudioManager.Instance.PlayCry(key); + } + + private CameraFollower ResolveFollower() + { + if (cachedCameraFollower == null && Camera.main != null) + cachedCameraFollower = Camera.main.GetComponent(); + return cachedCameraFollower; + } + + /// + /// 모델 하나에 반응 코루틴은 하나만 — 겹치면 앞 것이 잡아 둔 원위치로 먼저 되돌린 뒤 새로 건다. + /// 안 그러면 두 번째 반응이 밀려난 자리를 "원위치"로 잡아 모델이 조금씩 떠내려간다. + /// + private void StartReact(GameObject model, IEnumerator routine) + { + if (model == null) return; + if (activeReacts.TryGetValue(model, out Coroutine running) && running != null) + { + StopCoroutine(running); + RestoreReact(model); + } + reactRestPoses[model] = new Pose3 + { + Position = model.transform.position, + Rotation = model.transform.rotation, + Scale = model.transform.localScale + }; + activeReacts[model] = StartCoroutine(routine); + } + + private void RestoreReact(GameObject model) + { + if (model == null) return; + if (reactRestPoses.TryGetValue(model, out Pose3 rest)) + { + model.transform.position = rest.Position; + model.transform.rotation = rest.Rotation; + model.transform.localScale = rest.Scale; + } + SetFlash(model, Color.white, 0f); + activeReacts.Remove(model); + } + + private IEnumerator HitReactCoroutine(GameObject model, Vector3 dir, float weight, bool boss) + { + return HitReactCoroutine(model, dir, weight, boss, false, Color.white); + } + + /// + /// 피격 — 맞은 방향으로 튕겨 나갔다가(0.07초) 버티며 돌아오고(0.35초), 윗몸이 젖혀지고 눌렸다 편다. + /// 동시에 흰 섬광 → 붉은 기가 빠진다. 보스는 무거워서 덜 밀린다. 치명타는 첫 섬광이 강조색()으로 + /// 조금 더 길게(0.06 → 0.1초) 번쩍인다 — 히트스톱 동안 멈춰 있는 그 색이 "크게 맞았다"를 몸에 남긴다. + /// + private IEnumerator HitReactCoroutine(GameObject model, Vector3 dir, float weight, bool boss, bool critical, Color accent) + { + Pose3 rest = reactRestPoses[model]; + float flashHold = critical ? 0.1f : 0.06f; + Color flashTint = critical ? Color.Lerp(accent, Color.white, 0.25f) : Color.white; + float flashAmount = critical ? 0.85f : 0.78f; + float mass = boss ? 0.45f : 1f; + float distance = (0.22f + 0.30f * weight) * mass; + float tilt = (10f + 14f * weight) * mass; + bool motion = !BattlePresentation.ReducedMotion; + Vector3 axis = Vector3.Cross(Vector3.up, dir); + const float Out = 0.07f; + const float Total = 0.42f; + float t = 0f; + while (t < Total) + { + if (model == null) yield break; + t += PresentationDelta; + float k = t < Out + ? 1f - (1f - t / Out) * (1f - t / Out) // 튕겨 나감(감속) + : 1f - Mathf.SmoothStep(0f, 1f, (t - Out) / (Total - Out)); // 버티며 복귀 + if (motion) + { + model.transform.position = rest.Position + dir * (distance * k); + model.transform.rotation = Quaternion.AngleAxis(tilt * k, axis) * rest.Rotation; + float squash = 0.14f * weight * k * mass; + model.transform.localScale = Vector3.Scale(rest.Scale, + new Vector3(1f + squash * 0.6f, 1f - squash, 1f + squash * 0.6f)); + } + if (!BattlePresentation.ReducedFlashes) + { + if (t < flashHold) SetFlash(model, flashTint, flashAmount); + else SetFlash(model, new Color(1f, 0.32f, 0.26f), + 0.45f * (1f - Mathf.Clamp01((t - flashHold) / 0.26f))); + } + yield return null; + } + RestoreReact(model); + } + + /// 빗나감 — 대상이 옆으로 흘려 피했다가 돌아온다. + private IEnumerator DodgeCoroutine(GameObject model, Vector3 dir) + { + Pose3 rest = reactRestPoses[model]; + Vector3 side = Vector3.Cross(Vector3.up, dir).normalized; + const float Total = 0.45f; + float t = 0f; + while (t < Total && !BattlePresentation.ReducedMotion) + { + if (model == null) yield break; + t += PresentationDelta; + float k = Mathf.Sin(Mathf.Clamp01(t / Total) * Mathf.PI); + model.transform.position = rest.Position + side * (0.45f * k) + Vector3.up * (0.18f * k); + model.transform.rotation = Quaternion.AngleAxis(-12f * k, dir) * rest.Rotation; + yield return null; + } + RestoreReact(model); + } + + /// + /// 모델 전체에 색을 섞는다. 머티리얼 인스턴스를 만들지 않으려고 PropertyBlock을 쓴다 — + /// .material로 바꾸면 피격마다 인스턴스가 생기고, 겹친 플래시가 "섞인 색"을 원색으로 + /// 잡아 영구히 물드는 문제가 있었다(PlayHitFlashCoroutine의 방식). + /// + /// 쉬는 색(amount 0)은 보통 원색(PropertyBlock 없음)이고, 그림자 모습을 입은 모델은 그림자 톤이다 + /// (shadowStrength — Staging partial). 섞을 때도 그 쉬는 색에서 출발한다. 그림자 빛 부위(테두리·눈빛)는 건드리지 않는다. + /// + private void SetFlash(GameObject model, Color tint, float amount) + { + if (model == null) return; + MeshRenderer[] renderers = GetRenderersCached(model); + if (renderers == null) return; + if (flashBlock == null) flashBlock = new MaterialPropertyBlock(); + bool shadow = shadowStrength.TryGetValue(model, out float shadowAmount) && shadowAmount > 0f; + for (int i = 0; i < renderers.Length; i++) + { + MeshRenderer r = renderers[i]; + if (r == null) continue; + if (shadow && IsStagingGlow(r)) continue; + Material shared = r.sharedMaterial; + if (amount <= 0f) + { + if (!shadow || shared == null || !shared.HasProperty("_Color")) + { + r.SetPropertyBlock(null); + continue; + } + flashBlock.Clear(); + flashBlock.SetColor("_Color", ShadowRestColor(r, shared.color, shadowAmount)); + r.SetPropertyBlock(flashBlock); + continue; + } + if (shared == null || !shared.HasProperty("_Color")) continue; + Color baseColor = shadow ? ShadowRestColor(r, shared.color, shadowAmount) : shared.color; + Color mixed = Color.Lerp(baseColor, tint, amount); + mixed.a = baseColor.a; + flashBlock.Clear(); + flashBlock.SetColor("_Color", mixed); + r.SetPropertyBlock(flashBlock); + } + } + + // ── 임팩트 버스트 ── + + /// + /// 흰 섬광 구 + 방사 불꽃 줄기 + 화면을 향한 충격파 고리. 가산 합성이라 어두운 숲 아레나에서 + /// 빛처럼 읽힌다. 속성 임팩트(물보라·전격…)는 그대로 두고 그 위에 "맞았다"를 얹는다. + /// + private IEnumerator ImpactBurstCoroutine(Vector3 point, Color color, float weight) + { + if (arenaRoot == null) yield break; + Color hot = Color.Lerp(color, Color.white, 0.55f); + Material coreMat = CreateFxMaterial(new Color(1f, 0.97f, 0.86f, 1f), true); + Material sparkMat = CreateFxMaterial(hot, true); + Material ringMat = CreateFxMaterial(hot, true); + + GameObject core = FxPrimitive(PrimitiveType.Sphere, "ImpactCore", point, coreMat); + int sparkCount = 8 + Mathf.RoundToInt(6f * weight); + var sparks = new GameObject[sparkCount]; + var velocities = new Vector3[sparkCount]; + Camera cam = Camera.main; + Vector3 toCam = cam != null ? (cam.transform.position - point).normalized : Vector3.back; + for (int i = 0; i < sparkCount; i++) + { + // 화면 평면 쪽으로 퍼지게 — 카메라를 향한 성분을 줄여야 줄기가 점으로 안 보인다. + Vector3 v = Random.onUnitSphere; + v -= toCam * Vector3.Dot(v, toCam) * 0.7f; + v.y = Mathf.Abs(v.y) * 0.8f + 0.1f; + velocities[i] = v.normalized * Random.Range(4.5f, 7.5f) * (0.8f + 0.4f * weight); + sparks[i] = FxPrimitive(PrimitiveType.Cube, "ImpactStreak", point, sparkMat); + sparks[i].transform.rotation = Quaternion.LookRotation(velocities[i]); + } + + GameObject ringObj = new GameObject("ImpactRing"); + ringObj.transform.SetParent(arenaRoot.transform, false); + ringObj.transform.position = point; + ringObj.transform.rotation = Quaternion.LookRotation(-toCam); + LineRenderer ring = ringObj.AddComponent(); + ring.useWorldSpace = false; + ring.loop = true; + ring.positionCount = 36; + ring.sharedMaterial = ringMat; + ring.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + ring.receiveShadows = false; + + const float Duration = 0.32f; + float coreSize = 0.7f + 0.5f * weight; + float ringSize = 0.8f + 0.6f * weight; + float t = 0f; + while (t < Duration) + { + if (arenaRoot == null) yield break; + t += PresentationDelta; + float p = Mathf.Clamp01(t / Duration); + + float cp = Mathf.Clamp01(t / 0.14f); + if (core != null) + { + core.transform.localScale = Vector3.one * Mathf.Lerp(0.25f, coreSize, 1f - (1f - cp) * (1f - cp)); + coreMat.color = new Color(1f, 0.97f, 0.86f, 1f - cp); + if (cp >= 1f) { Destroy(core); core = null; } + } + + sparkMat.color = new Color(hot.r, hot.g, hot.b, 1f - p); + for (int i = 0; i < sparkCount; i++) + { + if (sparks[i] == null) continue; + velocities[i] *= Mathf.Max(0f, 1f - 2.5f * PresentationDelta); // 공기 저항 + sparks[i].transform.position += velocities[i] * PresentationDelta; + float len = Mathf.Lerp(0.55f, 0.08f, p); + sparks[i].transform.localScale = new Vector3(0.045f, 0.045f, len); + } + + float radius = Mathf.Lerp(0.25f, ringSize, 1f - (1f - p) * (1f - p)); + ring.widthMultiplier = Mathf.Lerp(0.16f, 0.01f, p); + ringMat.color = new Color(hot.r, hot.g, hot.b, 1f - p); + for (int i = 0; i < ring.positionCount; i++) + { + float a = i * Mathf.PI * 2f / ring.positionCount; + ring.SetPosition(i, new Vector3(Mathf.Cos(a) * radius, Mathf.Sin(a) * radius, 0f)); + } + yield return null; + } + if (core != null) Destroy(core); + for (int i = 0; i < sparkCount; i++) if (sparks[i] != null) Destroy(sparks[i]); + Destroy(ringObj); + } + + /// + /// 치명타 표식 — 맞은 자리 앞에서 별 빛살(십자로 긴 넷 + 사선으로 짧은 넷)이 번쩍 펴졌다가(0.07초) 돌며 잦아든다(0.5초). + /// 내 치명타는 금빛, 맞은 치명타는 붉은빛(). + /// + /// 섬광·불꽃·충격파()와 모양을 갈랐다 — 그쪽은 둥근 구·사방 불꽃·고리, 이쪽은 화면 + /// 평면의 빛살 넷뿐이다. 그리고 실제 시간(배속은 따른다)으로 돈다: 연출 시계로 돌리면 히트스톱 동안 펴지지 못하고 + /// 멈춤이 풀린 뒤에야 터지는데, 치명타는 "멈춘 순간 번쩍"이 보여야 한다. 밝은 섬광 줄이기면 호출부가 부르지 않는다. + /// + private IEnumerator CriticalFlareCoroutine(Vector3 point, Color accent, float size) + { + if (arenaRoot == null) yield break; + Camera cam = Camera.main; + Vector3 toCam = cam != null ? (cam.transform.position - point).normalized : Vector3.back; + GameObject root = new GameObject("CriticalFlare"); + root.transform.SetParent(arenaRoot.transform, false); + // 몸 앞으로 당긴다 — 모델 속에 묻히면 빛살 절반이 가려진다. + root.transform.position = point + toCam * (0.25f + 0.2f * size); + Quaternion facing = Quaternion.LookRotation(-toCam); + root.transform.rotation = facing; + Material mat = CreateFxMaterial(new Color(accent.r, accent.g, accent.b, 1f), true); + + // 정육면체 하나가 양쪽으로 뻗는다 — 0°·90°는 긴 빛살, 45°·135°는 짧은 빛살. + var rays = new Transform[4]; + for (int i = 0; i < rays.Length; i++) + { + GameObject ray = FxPrimitive(PrimitiveType.Cube, "CriticalRay", root.transform.position, mat); + ray.transform.SetParent(root.transform, false); + ray.transform.localPosition = Vector3.zero; + ray.transform.localRotation = Quaternion.Euler(0f, 0f, i * 45f); + ray.transform.localScale = Vector3.zero; + rays[i] = ray.transform; + } + + const float Pop = 0.07f; + const float Total = 0.5f; + float longRay = 1.9f * size; + float thickness = Mathf.Sqrt(size); + float t = 0f; + while (t < Total) + { + if (arenaRoot == null || root == null) yield break; + // 한 프레임이 길게 걸려도(로딩 끝 첫 프레임) 펴지는 순간을 건너뛰지 않게 자른다. + t += Mathf.Min(Time.unscaledDeltaTime, 0.05f) * Mathf.Max(0.1f, BattlePresentation.Speed); + float pop = Mathf.Clamp01(t / Pop); + float grow = 1f - (1f - pop) * (1f - pop); + float fade = t <= Pop ? 0f : Mathf.Clamp01((t - Pop) / (Total - Pop)); + float len = longRay * Mathf.Lerp(0.15f, 1f, grow) * Mathf.Lerp(1f, 0.45f, fade); + mat.color = new Color(accent.r, accent.g, accent.b, 1f - fade * fade); + root.transform.rotation = facing * Quaternion.Euler(0f, 0f, 28f * fade); + for (int i = 0; i < rays.Length; i++) + { + if (rays[i] == null) continue; + bool major = i % 2 == 0; + float width = (major ? 0.085f : 0.055f) * thickness * Mathf.Lerp(1f, 0.5f, fade); + rays[i].localScale = new Vector3(major ? len : len * 0.5f, width, 0.02f); + } + yield return null; + } + if (root != null) Destroy(root); + } + + private GameObject FxPrimitive(PrimitiveType type, string name, Vector3 position, Material material) + { + GameObject go = GameObject.CreatePrimitive(type); + go.name = name; + go.transform.SetParent(arenaRoot.transform, false); + go.transform.position = position; + Object.Destroy(go.GetComponent()); + MeshRenderer r = go.GetComponent(); + r.sharedMaterial = material; + r.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + r.receiveShadows = false; + return go; + } + + /// + /// 빛·불꽃용 머티리얼. Hidden/Internal-Colored는 런타임 GL 그리기용으로 플레이어 빌드에 + /// 항상 들어 있고 블렌드를 속성으로 바꿀 수 있다 — Standard의 투명 변형은 빌드에서 빠지면 + /// 불투명으로 그려진다(CreateBossAura 주석). 없으면 알파 블렌드인 Sprites/Default로. + /// + private Material CreateFxMaterial(Color color, bool additive) + { + Shader shader = Shader.Find("Hidden/Internal-Colored"); + Material mat; + if (shader != null) + { + mat = new Material(shader); + mat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha); + mat.SetInt("_DstBlend", additive + ? (int)UnityEngine.Rendering.BlendMode.One + : (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); + mat.SetInt("_Cull", (int)UnityEngine.Rendering.CullMode.Off); + mat.SetInt("_ZWrite", 0); + } + else + { + // 이 경로는 CreateTransparentMaterial로 돌아가면 안 된다 — 그쪽이 여기를 부른다. + shader = Shader.Find("Sprites/Default"); + if (shader == null) return CreateSafeMaterial(color); + mat = new Material(shader); + } + mat.renderQueue = 3100; + mat.color = color; + runtimeMaterials.Add(mat); // CleanupArena가 일괄 파기 + return mat; + } + + // ── 연출 카메라 ── + + private void RememberBaseCamera(Vector3 camPos, Vector3 lookTarget) + { + baseCamPos = camPos; + Vector3 d = lookTarget - camPos; + baseCamRot = d.sqrMagnitude > 0.0001f ? Quaternion.LookRotation(d) : Quaternion.identity; + hasBaseCam = true; + } + + /// + /// 스킬 타임라인 한 프레임의 샷을 카메라에 건다. 중심점은 연출 시작 때 한 번 잰 값을 쓴다 — + /// 돌진하는 시전자를 매 프레임 쫓으면 카메라가 덜컹거린다. + /// + private void ApplyCameraShot(float progress, float duration, Vector3 attackerCenter, + Vector3 targetCenter, float weight, bool support, float impactProgress, bool signature) + { + if (!hasBaseCam || BattlePresentation.ReducedMotion) return; + CameraFollower follower = ResolveFollower(); + if (follower == null) return; + BattleCameraDirector.Shot shot = signature + ? BattleCameraDirector.EvaluateSignature(progress, duration, baseCamPos, baseCamRot, attackerCenter, targetCenter, + weight, support, impactProgress) + : BattleCameraDirector.Evaluate(BattleCameraDirector.Current, progress, duration, baseCamPos, baseCamRot, + attackerCenter, targetCenter, weight, support, impactProgress); + follower.SetBattleShot(shot.Position, shot.Rotation, shot.Weight); + } + + private void ClearCameraShot() + { + CameraFollower follower = ResolveFollower(); + if (follower != null) follower.ClearBattleShot(); + } + + /// 아레나 정리 — 남은 정지·샷·외침·반응 기록을 버린다. + private void ClearImpactState() + { + BattlePresentation.ClearTimeEffects(); + ClearCameraShot(); + hasBaseCam = false; + activeShouts.Clear(); + faintShouts.Clear(); + activeReacts.Clear(); + reactRestPoses.Clear(); + telegraphRoutine = null; // StopAllCoroutines가 이미 멈췄다 — 참조만 버린다 + telegraphPoseSaved = false; + telegraphFx.Clear(); // 오브젝트는 arenaRoot와 함께 파기된다 + ClearStagingState(); // 등장·변신 연출과 그림자 모습(Staging partial) + ClearLifeState(); // 숨쉬기·상태 표시(Life partial) + ClearFlourishState(); // 전용기·승리·진입 표지(Flourish partial) + } + } +} diff --git a/Assets/Scripts/Battle/BattleArenaController.Impact.cs.meta b/Assets/Scripts/Battle/BattleArenaController.Impact.cs.meta new file mode 100644 index 0000000..4667750 --- /dev/null +++ b/Assets/Scripts/Battle/BattleArenaController.Impact.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8cdec60be8dff0a47a30b6dd43b72c40 \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleArenaController.Life.cs b/Assets/Scripts/Battle/BattleArenaController.Life.cs new file mode 100644 index 0000000..c077a15 --- /dev/null +++ b/Assets/Scripts/Battle/BattleArenaController.Life.cs @@ -0,0 +1,865 @@ +using System.Collections; +using System.Collections.Generic; +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.Battle +{ + /// + /// 살아 있는 몸 — 대기 숨쉬기(몸통이 부풀었다 줄고 더듬이가 까딱인다)와 상태이상·강화의 몸 표시, 회복 반짝임. + /// + /// + /// 독 — 몸 위로 보라 거품이 보글보글 올라 톡 터진다. + /// 기절 — 머리 위에서 노란 별 셋이 빙빙 돈다. + /// 공격 강화 — 발밑에서 붉은 주황 불꽃결이 위로 흐르고 발밑 고리가 은은히 빛난다. + /// 방어 강화 — 푸른 육각 막이 몸을 감싸고 천천히 돌며 띠처럼 일렁인다. + /// 공격·방어 약화 — 같은 색의 어두운 결이 머리 위에서 발밑으로 흘러내린다. + /// 회복 — 그 순간 초록 반짝임이 위로 솟는다(한 번). + /// + /// 자리가 겹쳐도 읽히게 나눴다 — 별은 머리 위, 거품은 몸 위, 오라·결은 발밑~몸 둘레, 막은 몸 바깥. + /// + /// 원천은 컨트롤러다 — 0.12초마다 읽는다(1대1 PlayerStunTurns·GetActiveEffects, 레이드 AttackStacks·DefenseStacks· + /// 보스 기절은 건너뛴 응답). 판정은 (순수)이고 HP 카드 상태 줄과 같은 규칙이다. 컨트롤러는 + /// ·로 받고, 비어 있으면 전투마다 한 번 찾는다. + /// + /// 머티리얼: 전부 (빌드에 늘 있는 FX 셰이더)를 종류마다 한 벌 만들어 나눠 쓰고 알파는 PropertyBlock으로 — + /// Standard 변형에 기대지 않는다. 몸 표시는 모델 밖(아레나 루트 아래)에 두고 매 프레임 몸을 따라간다 — 모델 안에 두면 돌진·피격의 + /// 기울기를 같이 타고, 쓰러짐 페이드·카메라 구도(BattleFraming.ModelBounds)에 섞인다. + /// 숨쉬기는 몸통 부위(Abdomen·Body·마디)의 크기와 더듬이 부위의 자세만 바꾼다 — 모델 루트는 돌진·피격·등장 연출의 몫이다. + /// + public partial class BattleArenaController + { + private InsectBattleController duelStatusSource; + private RaidBattleController raidStatusSource; + private bool statusSourcesSearched; + + /// 몸 상태를 읽을 1대1 컨트롤러. 안 주면 전투가 열릴 때 한 번 찾는다. + public void AutoWire(InsectBattleController duel) + { + if (duel != null) duelStatusSource = duel; + } + + /// 몸 상태를 읽을 레이드 컨트롤러. 안 주면 전투가 열릴 때 한 번 찾는다. + public void AutoWire(RaidBattleController raid) + { + if (raid != null) raidStatusSource = raid; + } + + /// 상태를 읽는 간격(실제 초) — GetActiveEffects가 배열을 새로 만들어 매 프레임 부르지 않는다. + private const float StatusPollSeconds = 0.12f; + /// 레이드 회복 반짝임이 타격 순간을 기다리는 상한(초) — 그 사이 연출이 없으면(아이템 등) 그냥 띄운다. + private const float HealFlushTimeout = 1.2f; + /// 상태 표시가 켜지고 꺼지는 속도(초당 몫) — 0.2초에 걸쳐 나타나고 사라진다. + private const float StatusFadeRate = 5f; + private const int StatusLayerCount = 6; // BattleStatusFlags의 비트 순서 + + private float lifeClock; + private float statusPollTimer; + private readonly Dictionary lifeRigs = new Dictionary(); + private readonly List lifeModels = new List(8); + private readonly List lifeStale = new List(4); + private readonly HashSet faintingModels = new HashSet(); + private readonly Dictionary statusMats = new Dictionary(); + private MaterialPropertyBlock lifeBlock; + private Mesh starMesh; + private Mesh flameMesh; + + private sealed class LifeRig + { + public GameObject Model; + public float Phase; + public int Salt; + public bool Boss; + public Transform[] Breath; + public Vector3[] BreathBase; + public float[] BreathLag; + public float[] BreathGain; + public AntennaRig[] Antennae; + /// 모델 루트 기준 몸 경계(루트의 위치·회전·크기와 무관) — 매 프레임 루트 변환을 곱해 월드로 옮긴다. + public Vector3 LocalCenter; + public Vector3 LocalExtents; + public BattleStatusFlags Wanted; + public GameObject FxRoot; + public readonly StatusLayer[] Layers = new StatusLayer[StatusLayerCount]; + public int LastHp = -1; + public bool PendingHeal; + public float PendingHealAge; + } + + private sealed class AntennaRig + { + public Transform[] Parts; + public Vector3[] BasePos; + public Quaternion[] BaseRot; + public Vector3 Pivot; + public float Phase; + } + + private sealed class StatusLayer + { + public GameObject Go; + public float Shown; + public Transform[] Parts; + public Renderer[] Renderers; + public LineRenderer[] Lines; + public float[] Seeds; + } + + /// 한 프레임의 몸 자리(월드). + private struct BodyFrame + { + public Vector3 Center; + public Vector3 Extents; + public float Horizontal; + public float Size; + public float Feet; + public float Top; + } + + // ───────────── 갱신 ───────────── + + /// Update가 매 프레임 부른다(스킬 연출 중에도) — 숨쉬기·상태 표시·회복 반짝임 대기. + private void UpdateLife() + { + if (arenaRoot == null) return; + float dt = PresentationDelta; + lifeClock += BattlePresentation.ReducedMotion ? dt * 0.35f : dt; + + GatherLifeModels(); + for (int i = 0; i < lifeModels.Count; i++) + { + LifeRig rig = RigFor(lifeModels[i]); + if (rig != null && rig.Model.activeInHierarchy) Breathe(rig); + } + + statusPollTimer -= Time.deltaTime; + if (statusPollTimer <= 0f) + { + statusPollTimer = StatusPollSeconds; + PollStatuses(); + } + + float realDt = Time.deltaTime; + for (int i = 0; i < lifeModels.Count; i++) + { + if (!lifeRigs.TryGetValue(lifeModels[i], out LifeRig rig)) continue; + AnimateStatus(rig, realDt); + if (rig.PendingHeal) + { + rig.PendingHealAge += realDt; + if (rig.PendingHealAge >= HealFlushTimeout && !playingSkill) FlushHeal(rig); + } + } + PruneLifeRigs(); + } + + private void GatherLifeModels() + { + lifeModels.Clear(); + if (playerModel != null) lifeModels.Add(playerModel); + if (enemyModel != null) lifeModels.Add(enemyModel); + if (bossModel != null) lifeModels.Add(bossModel); + if (teamModels != null) + for (int i = 0; i < teamModels.Length; i++) + if (teamModels[i] != null) lifeModels.Add(teamModels[i]); + } + + private void PruneLifeRigs() + { + lifeStale.Clear(); + foreach (GameObject key in lifeRigs.Keys) + if (key == null || !lifeModels.Contains(key)) lifeStale.Add(key); + for (int i = 0; i < lifeStale.Count; i++) ForgetLife(lifeStale[i]); + } + + /// 모델을 갈아끼우기 전에(ForgetModel) 그 몸의 표시를 걷는다. + private void ForgetLife(GameObject model) + { + if (!lifeRigs.TryGetValue(model, out LifeRig rig)) return; + if (rig.FxRoot != null) Destroy(rig.FxRoot); + lifeRigs.Remove(model); + faintingModels.Remove(model); + } + + /// 아레나 정리 — 오브젝트·머티리얼·메시는 아레나 루트·런타임 목록과 함께 파기된다. 참조만 버린다. + private void ClearLifeState() + { + lifeRigs.Clear(); + lifeModels.Clear(); + lifeStale.Clear(); + faintingModels.Clear(); + statusMats.Clear(); + starMesh = null; + flameMesh = null; + statusSourcesSearched = false; + statusPollTimer = 0f; + } + + // ───────────── 몸 틀 ───────────── + + private LifeRig RigFor(GameObject model) + { + if (model == null) return null; + if (lifeRigs.TryGetValue(model, out LifeRig rig)) return rig; + rig = new LifeRig + { + Model = model, + Boss = model == bossModel, + Salt = model.GetInstanceID() + }; + rig.Phase = Hash01(rig.Salt); + MeasureLocal(model, out rig.LocalCenter, out rig.LocalExtents); + BuildBreath(rig); + lifeRigs[model] = rig; + return rig; + } + + /// + /// 모델 루트 기준 경계 — 부위마다 루트까지의 로컬 변환을 곱해 메시 경계 꼭짓점을 모은다. 루트의 월드 변환(등장 중 크기 0 포함)과 무관하다. + /// 빛 부위(이름에 Glow)·반짝이 장식은 뺀다(BattleFraming.ModelBounds와 같은 기준). + /// + private static void MeasureLocal(GameObject model, out Vector3 center, out Vector3 extents) + { + Transform root = model.transform; + MeshFilter[] filters = model.GetComponentsInChildren(true); + bool found = false; + Bounds acc = new Bounds(Vector3.zero, Vector3.zero); + for (int i = 0; i < filters.Length; i++) + { + MeshFilter f = filters[i]; + if (f == null || f.sharedMesh == null) continue; + string n = f.name; + if (n.Contains("Glow") || n.Contains("Sparkle") || n.Contains("Aura")) continue; + Matrix4x4 m = Matrix4x4.identity; + for (Transform cur = f.transform; cur != null && cur != root; cur = cur.parent) + m = Matrix4x4.TRS(cur.localPosition, cur.localRotation, cur.localScale) * m; + Bounds mb = f.sharedMesh.bounds; + for (int c = 0; c < 8; c++) + { + Vector3 corner = mb.center + Vector3.Scale(mb.extents, + new Vector3((c & 1) == 0 ? -1f : 1f, (c & 2) == 0 ? -1f : 1f, (c & 4) == 0 ? -1f : 1f)); + Vector3 p = m.MultiplyPoint3x4(corner); + if (!found) { acc = new Bounds(p, Vector3.zero); found = true; } + else acc.Encapsulate(p); + } + } + center = found ? acc.center : Vector3.zero; + extents = found ? Vector3.Max(acc.extents, Vector3.one * 0.08f) : Vector3.one * 0.3f; + } + + private BodyFrame FrameOf(LifeRig rig) + { + Transform t = rig.Model.transform; + float scale = Mathf.Abs(t.lossyScale.x); + BodyFrame f; + f.Center = t.position + t.rotation * (rig.LocalCenter * scale); + f.Extents = rig.LocalExtents * scale; + f.Horizontal = Mathf.Max(0.18f, Mathf.Max(f.Extents.x, f.Extents.z) * 0.85f); + f.Size = Mathf.Max(f.Extents.x, Mathf.Max(f.Extents.y, f.Extents.z)); + f.Feet = f.Center.y - f.Extents.y; + f.Top = f.Center.y + f.Extents.y; + return f; + } + + // ───────────── 숨쉬기 ───────────── + + private static readonly string[] AntennaPartNames = { "AntBase", "AntMid", "AntTip", "AntFeather" }; + + /// + /// 숨 쉴 부위 — 배(Abdomen)가 있으면 배(+가슴 절반 세기), 없으면 몸통(Body), 그것도 없으면 마디(Seg*·Shell1~)를 물결처럼. + /// 더듬이는 좌우 각각 뿌리(AntBase·Antenna의 아래 끝)를 축으로 함께 까딱인다. + /// + private static void BuildBreath(LifeRig rig) + { + Transform root = rig.Model.transform; + var parts = new List(6); + var lags = new List(6); + var gains = new List(6); + Transform abdomen = root.Find("Abdomen"); + Transform body = root.Find("Body"); + Transform thorax = root.Find("Thorax"); + if (abdomen != null) + { + parts.Add(abdomen); lags.Add(0f); gains.Add(1f); + if (thorax != null) { parts.Add(thorax); lags.Add(0.12f); gains.Add(0.5f); } + } + else if (body != null) + { + parts.Add(body); lags.Add(0f); gains.Add(1f); + } + else + { + for (int i = 0; i < root.childCount && parts.Count < 8; i++) + { + Transform c = root.GetChild(i); + string n = c.name; + bool segment = n.StartsWith("Seg") || (n.StartsWith("Shell") && n.Length > 5 && char.IsDigit(n[5])); + if (!segment) continue; + parts.Add(c); lags.Add(0.08f * parts.Count); gains.Add(1f); + } + } + rig.Breath = parts.ToArray(); + rig.BreathLag = lags.ToArray(); + rig.BreathGain = gains.ToArray(); + rig.BreathBase = new Vector3[rig.Breath.Length]; + for (int i = 0; i < rig.Breath.Length; i++) rig.BreathBase[i] = rig.Breath[i].localScale; + + var antennae = new List(2); + for (int s = 0; s < 2; s++) + { + string side = s == 0 ? "L" : "R"; + var found = new List(4); + for (int k = 0; k < AntennaPartNames.Length; k++) + { + Transform p = root.Find(AntennaPartNames[k] + side); + if (p != null) found.Add(p); + } + Transform rootPart = found.Count > 0 ? found[0] : root.Find("Antenna" + side); + if (found.Count == 0 && rootPart != null) found.Add(rootPart); + if (rootPart == null || found.Count == 0) continue; + var a = new AntennaRig + { + Parts = found.ToArray(), + BasePos = new Vector3[found.Count], + BaseRot = new Quaternion[found.Count], + // 마디(MakeSegment)는 캡슐을 두 점 사이에 세운다 — 중심에서 축(위) 방향으로 반 길이(localScale.y) 내려간 곳이 뿌리다. + Pivot = rootPart.localPosition - rootPart.localRotation * Vector3.up * rootPart.localScale.y, + Phase = rig.Phase + s * 0.07f + }; + for (int k = 0; k < found.Count; k++) + { + a.BasePos[k] = found[k].localPosition; + a.BaseRot[k] = found[k].localRotation; + } + antennae.Add(a); + } + rig.Antennae = antennae.ToArray(); + } + + private void Breathe(LifeRig rig) + { + float period = rig.Boss ? BattleFlourish.BossBreathPeriod : BattleFlourish.BreathPeriod; + float amp = BattleFlourish.BreathAmplitude * (BattlePresentation.ReducedMotion ? 0.5f : 1f); + for (int i = 0; i < rig.Breath.Length; i++) + { + Transform part = rig.Breath[i]; + if (part == null) continue; + float s = BattleFlourish.Breath(lifeClock - rig.BreathLag[i], period, rig.Phase) * amp * rig.BreathGain[i]; + part.localScale = Vector3.Scale(rig.BreathBase[i], new Vector3(1f + s, 1f + 0.6f * s, 1f + s)); + } + for (int s = 0; s < rig.Antennae.Length; s++) + { + AntennaRig a = rig.Antennae[s]; + float angle = BattlePresentation.ReducedMotion ? 0f : BattleFlourish.AntennaNod(lifeClock, a.Phase); + Quaternion q = Quaternion.AngleAxis(angle, Vector3.right); + for (int k = 0; k < a.Parts.Length; k++) + { + Transform p = a.Parts[k]; + if (p == null) continue; + p.localPosition = a.Pivot + q * (a.BasePos[k] - a.Pivot); + p.localRotation = q * a.BaseRot[k]; + } + } + } + + // ───────────── 상태 읽기 ───────────── + + private void PollStatuses() + { + if (!statusSourcesSearched) + { + statusSourcesSearched = true; // 전투마다 한 번 — 없는 컨트롤러를 매번 찾지 않는다 + if (duelStatusSource == null) duelStatusSource = FindFirstObjectByType(); + if (raidStatusSource == null) raidStatusSource = FindFirstObjectByType(); + } + if (bossModel != null) PollRaidStatuses(); + else PollDuelStatuses(); + } + + private void PollDuelStatuses() + { + InsectBattleController c = duelStatusSource; + if (c == null) return; + InsectBattleController.EffectSnapshot[] effects = c.GetActiveEffects(); + SetWanted(playerModel, BattleStatusLook.FromDuel(c.PlayerStunTurns, effects, true)); + SetWanted(enemyModel, BattleStatusLook.FromDuel(c.EnemyStunTurns, effects, false)); + } + + private void PollRaidStatuses() + { + RaidBattleController c = raidStatusSource; + if (c == null) return; + InsectBattleStats[] team = c.TeamStats; + for (int i = 0; teamModels != null && i < teamModels.Length; i++) + { + InsectBattleStats stats = team != null && i < team.Length ? team[i] : null; + if (stats == null || teamModels[i] == null) continue; + SetWanted(teamModels[i], BattleStatusLook.FromStacks(stats.AttackStacks, stats.DefenseStacks, false)); + TrackHeal(teamModels[i], stats.CurrentHp); + } + InsectBattleStats boss = c.BossStats; + if (boss != null) + SetWanted(bossModel, BattleStatusLook.FromStacks(boss.AttackStacks, boss.DefenseStacks, + BattleStatusLook.RaidBossDazed(c.CurrentRoundResult))); + } + + private void SetWanted(GameObject model, BattleStatusFlags flags) + { + LifeRig rig = RigFor(model); + if (rig != null) rig.Wanted = flags; + } + + /// + /// 레이드 회복 — HP가 오른 팀원을 적어 두고 그 행동의 타격 순간()에 반짝인다. 행동은 커맨드 순간에 해결돼 + /// HP가 먼저 오르고 연출(0.46초 볼리)이 그 뒤에 돈다 — 읽은 순간 띄우면 시전자가 나서기도 전에 반짝인다. + /// + private void TrackHeal(GameObject model, int hp) + { + if (!lifeRigs.TryGetValue(model, out LifeRig rig)) return; + if (rig.LastHp >= 0 && hp > rig.LastHp && rig.LastHp > 0) + { + rig.PendingHeal = true; + rig.PendingHealAge = 0f; + } + rig.LastHp = hp; + } + + /// 적어 둔 회복 반짝임을 지금 띄운다 — 레이드 팀원 행동의 타격 순간이 부른다. + private void FlushPendingHeals() + { + for (int i = 0; i < lifeModels.Count; i++) + if (lifeRigs.TryGetValue(lifeModels[i], out LifeRig rig) && rig.PendingHeal) FlushHeal(rig); + } + + private void FlushHeal(LifeRig rig) + { + rig.PendingHeal = false; + if (rig.Model != null && rig.Model.activeInHierarchy) PlayHealSparkle(rig.Model); + } + + /// 쓰러지기 시작한 몸 — 상태 표시를 곧바로 거둔다(쓰러짐 페이드가 모델 안 렌더러만 지운다). + private void MarkFainting(GameObject model) + { + if (model != null) faintingModels.Add(model); + } + + // ───────────── 상태 표시 ───────────── + + private void AnimateStatus(LifeRig rig, float dt) + { + GameObject model = rig.Model; + bool visible = model != null && model.activeInHierarchy && !faintingModels.Contains(model); + BodyFrame f = default; + bool framed = false; + for (int i = 0; i < StatusLayerCount; i++) + { + bool want = visible && ((int)rig.Wanted & (1 << i)) != 0; + StatusLayer layer = rig.Layers[i]; + if (layer == null) + { + if (!want) continue; + layer = rig.Layers[i] = CreateStatusLayer(rig, i); + if (layer == null) continue; + } + layer.Shown = Mathf.MoveTowards(layer.Shown, want ? 1f : 0f, dt * StatusFadeRate); + if (layer.Shown <= 0f) + { + if (layer.Go.activeSelf) layer.Go.SetActive(false); + continue; + } + if (!layer.Go.activeSelf) layer.Go.SetActive(true); + if (!framed) + { + if (model == null) break; + f = FrameOf(rig); + framed = true; + } + switch (i) + { + case 0: AnimateBubbles(rig, layer, f); break; + case 1: AnimateStars(layer, f); break; + case 2: AnimateFlames(layer, f); break; + case 3: AnimateDownStreaks(layer, f, BattleStatusLook.AttackDownColor, 0f); break; + case 4: AnimateShield(layer, f); break; + default: AnimateDownStreaks(layer, f, BattleStatusLook.DefenseDownColor, Mathf.PI / 5f); break; + } + } + } + + private StatusLayer CreateStatusLayer(LifeRig rig, int index) + { + if (arenaRoot == null) return null; + if (rig.FxRoot == null) + { + rig.FxRoot = new GameObject("StatusFx_" + rig.Model.name); + rig.FxRoot.transform.SetParent(arenaRoot.transform, false); + } + switch (index) + { + case 0: return CreateMeshLayer(rig, "Poison", 5, null, StatusMat(0, BattleStatusLook.PoisonColor, false), null); + case 1: return CreateMeshLayer(rig, "Stun", 3, StarMesh(), StatusMat(1, BattleStatusLook.StunColor, false), + StatusMat(11, BattleStatusLook.StunEdgeColor, false)); + case 2: + { + StatusLayer layer = CreateMeshLayer(rig, "AttackUp", 7, FlameMesh(), StatusMat(2, BattleStatusLook.AttackUpColor, true), null); + LineRenderer ring = CreateFloorRing("AttackUpRing", layer.Go.transform.position, StatusMat(12, BattleStatusLook.AttackUpColor, true)); + ring.transform.SetParent(layer.Go.transform, true); + layer.Lines = new[] { ring }; + return layer; + } + case 3: return CreateMeshLayer(rig, "AttackDown", 5, FlameMesh(), StatusMat(3, BattleStatusLook.AttackDownColor, false), null); + case 4: return CreateShieldLayer(rig); + default: return CreateMeshLayer(rig, "DefenseDown", 5, FlameMesh(), StatusMat(5, BattleStatusLook.DefenseDownColor, false), null); + } + } + + /// + /// 같은 모양 개짜리 층. 가 null이면 구(거품). 이 있으면 같은 수의 + /// 뒷면 테두리(별의 진한 테두리)를 더 단다 — Parts의 뒤쪽 절반이 그것이다. + /// + private StatusLayer CreateMeshLayer(LifeRig rig, string name, int count, Mesh mesh, Material mat, Material edgeMat) + { + var go = new GameObject(name); + go.transform.SetParent(rig.FxRoot.transform, false); + int total = edgeMat != null ? count * 2 : count; + var layer = new StatusLayer + { + Go = go, + Parts = new Transform[total], + Renderers = new Renderer[total], + Seeds = new float[count] + }; + for (int i = 0; i < total; i++) + { + bool edge = i >= count; + GameObject part = mesh == null + ? FxPrimitive(PrimitiveType.Sphere, name + "Bit", go.transform.position, mat) + : FxMeshObject(name + (edge ? "Edge" : "Bit"), mesh, edge ? edgeMat : mat); + part.transform.SetParent(go.transform, true); + part.transform.localScale = Vector3.zero; + layer.Parts[i] = part.transform; + layer.Renderers[i] = part.GetComponent(); + } + for (int i = 0; i < count; i++) layer.Seeds[i] = Hash01(rig.Salt * 31 + i * 7919 + name.Length); + return layer; + } + + /// 방어 강화 — 몸을 감싸는 구 위의 육각 칸(피보나치 배치, 아래쪽은 땅이라 뺀다). + private StatusLayer CreateShieldLayer(LifeRig rig) + { + var go = new GameObject("DefenseUp"); + go.transform.SetParent(rig.FxRoot.transform, false); + Material mat = StatusMat(4, BattleStatusLook.DefenseUpColor, true); + const int Points = 26; + var tiles = new List(Points); + var seeds = new List(Points); + float golden = Mathf.PI * (3f - Mathf.Sqrt(5f)); + for (int i = 0; i < Points; i++) + { + float y = 1f - 2f * (i + 0.5f) / Points; + if (y < -0.35f) continue; + float r = Mathf.Sqrt(Mathf.Max(0f, 1f - y * y)); + float a = golden * i; + Vector3 dir = new Vector3(Mathf.Cos(a) * r, y, Mathf.Sin(a) * r); + var tile = new GameObject("ShieldHex"); + tile.transform.SetParent(go.transform, false); + tile.transform.localPosition = dir; + tile.transform.localRotation = Quaternion.LookRotation(dir); + LineRenderer line = tile.AddComponent(); + line.useWorldSpace = false; + line.loop = true; + line.positionCount = 6; + line.alignment = LineAlignment.TransformZ; + line.sharedMaterial = mat; + line.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + line.receiveShadows = false; + for (int k = 0; k < 6; k++) + { + float ha = (k + 0.5f) * Mathf.PI / 3f; + line.SetPosition(k, new Vector3(Mathf.Cos(ha) * 0.3f, Mathf.Sin(ha) * 0.3f, 0f)); + } + tiles.Add(line); + seeds.Add(y * 2.6f + a * 0.15f); + } + return new StatusLayer { Go = go, Lines = tiles.ToArray(), Seeds = seeds.ToArray() }; + } + + private void AnimateBubbles(LifeRig rig, StatusLayer layer, BodyFrame f) + { + Camera cam = Camera.main; + Vector3 right = cam != null ? cam.transform.right : Vector3.right; + float size = Mathf.Clamp(f.Size * 0.11f, 0.05f, 0.2f); + float climb = 0.3f + f.Extents.y * 0.9f; + for (int i = 0; i < layer.Seeds.Length; i++) + { + float cycle = lifeClock / 1.4f + layer.Seeds[i]; + float age = Mathf.Repeat(cycle, 1f); + int round = Mathf.FloorToInt(cycle); + float ox = Hash01(rig.Salt + round * 92821 + i * 68917) * 2f - 1f; + float oz = Hash01(rig.Salt * 7 + round * 31337 + i * 1009) * 2f - 1f; + BattleStatusLook.Bubble(age, out float rise, out float scale, out float alpha); + Vector3 start = new Vector3(f.Center.x + ox * f.Horizontal * 0.5f, f.Center.y + f.Extents.y * 0.15f, + f.Center.z + oz * f.Horizontal * 0.5f); + layer.Parts[i].position = start + Vector3.up * (rise * climb) + + right * (Mathf.Sin(age * Mathf.PI * 2f + layer.Seeds[i] * 10f) * 0.03f); + layer.Parts[i].localScale = Vector3.one * (size * scale); + SetFxAlpha(layer.Renderers[i], BattleStatusLook.PoisonColor, 0.82f * alpha * layer.Shown); + } + } + + private void AnimateStars(StatusLayer layer, BodyFrame f) + { + Camera cam = Camera.main; + Vector3 forward = cam != null ? cam.transform.forward : Vector3.forward; + Quaternion facing = Quaternion.LookRotation(forward); + int count = layer.Seeds.Length; + float ring = Mathf.Clamp(f.Horizontal * 0.6f, 0.2f, 0.9f); + float y = f.Top + Mathf.Clamp(f.Size * 0.2f, 0.1f, 0.35f); + float star = Mathf.Clamp(f.Size * 0.15f, 0.08f, 0.28f); + for (int i = 0; i < count; i++) + { + float a = lifeClock * Mathf.PI * 2f / 1.3f + i * Mathf.PI * 2f / count; + Vector3 p = new Vector3(f.Center.x + Mathf.Cos(a) * ring, y + 0.04f * Mathf.Sin(lifeClock * 5f + i * 2f), + f.Center.z + Mathf.Sin(a) * ring * 0.75f); + Quaternion spin = facing * Quaternion.Euler(0f, 0f, lifeClock * 200f + i * 40f); + layer.Parts[i].SetPositionAndRotation(p, spin); + layer.Parts[i].localScale = Vector3.one * star; + Transform edge = layer.Parts[i + count]; + edge.SetPositionAndRotation(p + forward * 0.012f, spin); + edge.localScale = Vector3.one * (star * 1.3f); + SetFxAlpha(layer.Renderers[i], BattleStatusLook.StunColor, layer.Shown); + SetFxAlpha(layer.Renderers[i + count], BattleStatusLook.StunEdgeColor, 0.9f * layer.Shown); + } + } + + private void AnimateFlames(StatusLayer layer, BodyFrame f) + { + Quaternion facing = YawFacing(); + float width = Mathf.Clamp(f.Size * 0.13f, 0.05f, 0.32f); + float climb = f.Extents.y * 1.7f + 0.1f; + for (int i = 0; i < layer.Seeds.Length; i++) + { + float age = Mathf.Repeat(lifeClock / 0.9f + layer.Seeds[i], 1f); + float a = i * Mathf.PI * 2f / layer.Seeds.Length + lifeClock * 0.6f; + BattleStatusLook.Streak(age, out float alpha, out float wf); + Vector3 p = new Vector3(f.Center.x + Mathf.Cos(a) * f.Horizontal * 0.9f, f.Feet + width * 1.2f + age * climb, + f.Center.z + Mathf.Sin(a) * f.Horizontal * 0.8f); + layer.Parts[i].SetPositionAndRotation(p, facing); + layer.Parts[i].localScale = new Vector3(width * wf, width * 2.6f * (0.7f + 0.5f * Mathf.Sin(age * Mathf.PI)), 1f); + SetFxAlpha(layer.Renderers[i], BattleStatusLook.AttackUpColor, 0.85f * alpha * layer.Shown); + } + if (layer.Lines != null && layer.Lines.Length > 0 && layer.Lines[0] != null) + { + LineRenderer ring = layer.Lines[0]; + ring.transform.position = new Vector3(f.Center.x, f.Feet + 0.03f, f.Center.z); + SetRingRadius(ring, f.Horizontal * 1.05f, Mathf.Clamp(f.Size * 0.05f, 0.025f, 0.08f)); + float glow = BattlePresentation.ReducedFlashes ? 0.3f : 0.3f + 0.15f * Mathf.Sin(lifeClock * 4f); + SetFxAlpha(ring, BattleStatusLook.AttackUpColor, glow * layer.Shown); + } + } + + private void AnimateDownStreaks(StatusLayer layer, BodyFrame f, Color color, float offset) + { + Quaternion down = YawFacing() * Quaternion.Euler(0f, 0f, 180f); // 뾰족한 끝이 아래로 + float width = Mathf.Clamp(f.Size * 0.07f, 0.03f, 0.16f); + float fall = f.Extents.y * 2f + 0.35f; + float top = f.Top + 0.2f; + for (int i = 0; i < layer.Seeds.Length; i++) + { + float age = Mathf.Repeat(lifeClock / 1.1f + layer.Seeds[i], 1f); + float a = (i + 0.5f) * Mathf.PI * 2f / layer.Seeds.Length + offset - lifeClock * 0.25f; + BattleStatusLook.Streak(age, out float alpha, out float wf); + Vector3 p = new Vector3(f.Center.x + Mathf.Cos(a) * f.Horizontal * 1.1f, top - age * fall, + f.Center.z + Mathf.Sin(a) * f.Horizontal * 1.0f); + layer.Parts[i].SetPositionAndRotation(p, down); + layer.Parts[i].localScale = new Vector3(width * wf, width * 4.2f, 1f); + SetFxAlpha(layer.Renderers[i], color, 0.8f * alpha * layer.Shown); + } + } + + private void AnimateShield(StatusLayer layer, BodyFrame f) + { + float radius = Mathf.Max(f.Horizontal, f.Extents.y) * 1.2f + 0.06f; + Transform root = layer.Go.transform; + root.SetPositionAndRotation(f.Center, Quaternion.Euler(0f, lifeClock * 14f, 0f)); + root.localScale = Vector3.one * radius; + float width = Mathf.Clamp(radius * 0.035f, 0.012f, 0.06f); + for (int i = 0; i < layer.Lines.Length; i++) + { + LineRenderer line = layer.Lines[i]; + if (line == null) continue; + line.widthMultiplier = width; + SetFxAlpha(line, BattleStatusLook.DefenseUpColor, + BattleStatusLook.ShieldAlpha(lifeClock, layer.Seeds[i], BattlePresentation.ReducedFlashes) * layer.Shown); + } + } + + // ───────────── 회복 반짝임 ───────────── + + /// + /// 회복 — 초록 별 반짝임 열둘이 몸 둘레에서 위로 솟고, 초록 고리가 발밑에서 몸 위로 올라간다(0.9초, 한 번). 1대1은 회복 기술의 타격 순간, + /// 레이드는 회복한 팀원 행동의 타격 순간에 뜬다. 밝은 섬광 줄이기면 가산 대신 알파 합성으로 은은하게. + /// + public void PlayHealSparkle(GameObject model) + { + if (!isActive || arenaRoot == null || model == null || !model.activeInHierarchy) return; + StartCoroutine(HealSparkleCoroutine(model)); + } + + private IEnumerator HealSparkleCoroutine(GameObject model) + { + LifeRig rig = RigFor(model); + if (rig == null) yield break; + bool additive = !BattlePresentation.ReducedFlashes; + Color heal = BattleStatusLook.HealColor; + Material mat = CreateFxMaterial(new Color(heal.r, heal.g, heal.b, 1f), additive); + Material ringMat = CreateFxMaterial(new Color(heal.r, heal.g, heal.b, 0.8f), additive); + const int Count = 12; + var bits = new Transform[Count]; + var renderers = new Renderer[Count]; + var angles = new float[Count]; + var radii = new float[Count]; + for (int i = 0; i < Count; i++) + { + GameObject bit = FxMeshObject("HealSparkle", StarMesh(), mat); + bit.transform.localScale = Vector3.zero; + bits[i] = bit.transform; + renderers[i] = bit.GetComponent(); + angles[i] = (i + Hash01(rig.Salt + i * 131) * 0.6f) * Mathf.PI * 2f / Count; + radii[i] = 0.55f + 0.5f * Hash01(rig.Salt * 3 + i * 977); + } + BodyFrame f0 = FrameOf(rig); + LineRenderer ring = CreateFloorRing("HealRing", new Vector3(f0.Center.x, f0.Feet + 0.03f, f0.Center.z), ringMat); + if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.BuffApply); + + const float Duration = 0.9f; + float t = 0f; + while (t < Duration) + { + if (arenaRoot == null || model == null) break; + t += PresentationDelta; + BodyFrame f = FrameOf(rig); + Camera cam = Camera.main; + Quaternion facing = cam != null ? Quaternion.LookRotation(cam.transform.forward) : Quaternion.identity; + float size = Mathf.Clamp(f.Size * 0.12f, 0.06f, 0.24f); + for (int i = 0; i < Count; i++) + { + if (bits[i] == null) continue; + float age = Mathf.Clamp01((t - i * 0.03f) / 0.7f); + float y = f.Center.y - f.Extents.y * 0.6f + age * (f.Extents.y * 1.9f + 0.4f); + float r = f.Horizontal * radii[i] * (1f - 0.3f * age); + bits[i].SetPositionAndRotation(new Vector3(f.Center.x + Mathf.Cos(angles[i] + age) * r, y, + f.Center.z + Mathf.Sin(angles[i] + age) * r), facing * Quaternion.Euler(0f, 0f, t * 160f + i * 30f)); + float twinkle = 0.8f + 0.2f * Mathf.Sin(t * 30f + i); + bits[i].localScale = Vector3.one * (size * Mathf.Sin(age * Mathf.PI) * twinkle); + } + float k = Mathf.Clamp01(t / 0.65f); + if (ring != null) + { + ring.transform.position = new Vector3(f.Center.x, Mathf.Lerp(f.Feet + 0.03f, f.Top, k), f.Center.z); + SetRingRadius(ring, f.Horizontal * Mathf.Lerp(1.15f, 0.6f, k), Mathf.Lerp(0.07f, 0.015f, k)); + ringMat.color = new Color(heal.r, heal.g, heal.b, 0.8f * (1f - k)); + } + yield return null; + } + for (int i = 0; i < Count; i++) if (bits[i] != null) Destroy(bits[i].gameObject); + if (ring != null) Destroy(ring.gameObject); + } + + // ───────────── 부품 ───────────── + + private Material StatusMat(int key, Color color, bool additive) + { + if (statusMats.TryGetValue(key, out Material mat) && mat != null) return mat; + mat = CreateFxMaterial(new Color(color.r, color.g, color.b, 1f), additive); + statusMats[key] = mat; + return mat; + } + + private void SetFxAlpha(Renderer renderer, Color color, float alpha) + { + if (renderer == null) return; + if (lifeBlock == null) lifeBlock = new MaterialPropertyBlock(); + lifeBlock.Clear(); + lifeBlock.SetColor("_Color", new Color(color.r, color.g, color.b, Mathf.Clamp01(alpha))); + renderer.SetPropertyBlock(lifeBlock); + } + + /// 카메라를 향해 세로로 선 판(불꽃결·내려가는 결) — 수평으로만 돌린다. + private static Quaternion YawFacing() + { + Camera cam = Camera.main; + Vector3 forward = cam != null ? cam.transform.forward : Vector3.forward; + forward.y = 0f; + return forward.sqrMagnitude > 0.0001f ? Quaternion.LookRotation(forward) : Quaternion.identity; + } + + /// 메시 하나짜리 FX 오브젝트(아레나 루트 아래, 그림자 없음). + private GameObject FxMeshObject(string name, Mesh mesh, Material material) + { + var go = new GameObject(name); + go.transform.SetParent(arenaRoot.transform, false); + go.AddComponent().sharedMesh = mesh; + MeshRenderer r = go.AddComponent(); + r.sharedMaterial = material; + r.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + r.receiveShadows = false; + return go; + } + + /// 다섯 뿔 별(XY 평면, 반지름 1) — 기절 별·회복 반짝임. 컬링이 꺼진 FX 셰이더라 양면으로 보인다. + private Mesh StarMesh() + { + if (starMesh != null) return starMesh; + var verts = new Vector3[11]; + var tris = new int[30]; + verts[0] = Vector3.zero; + for (int i = 0; i < 10; i++) + { + float a = Mathf.PI * 0.5f + i * Mathf.PI / 5f; + float r = i % 2 == 0 ? 1f : 0.45f; + verts[i + 1] = new Vector3(Mathf.Cos(a) * r, Mathf.Sin(a) * r, 0f); + } + for (int i = 0; i < 10; i++) + { + tris[i * 3] = 0; + tris[i * 3 + 1] = i + 1; + tris[i * 3 + 2] = (i + 1) % 10 + 1; + } + starMesh = new Mesh { name = "BattleStatusStar", vertices = verts, triangles = tris }; + starMesh.RecalculateBounds(); + runtimeMeshes.Add(starMesh); + return starMesh; + } + + /// 불꽃결 한 가닥(XY 평면) — 위가 뾰족하고 아래가 둥근 마름모. 뒤집으면 내려가는 결. + private Mesh FlameMesh() + { + if (flameMesh != null) return flameMesh; + flameMesh = new Mesh + { + name = "BattleStatusFlame", + vertices = new[] { new Vector3(0f, 1f, 0f), new Vector3(0.5f, -0.35f, 0f), new Vector3(0f, -1f, 0f), new Vector3(-0.5f, -0.35f, 0f) }, + triangles = new[] { 0, 1, 2, 0, 2, 3 } + }; + flameMesh.RecalculateBounds(); + runtimeMeshes.Add(flameMesh); + return flameMesh; + } + + /// 정수 → 0..1 결정적 난수(연출이 UnityEngine.Random을 소비하지 않게 — QA 캡처가 같은 장면이 된다). + private static float Hash01(int n) + { + unchecked + { + uint x = (uint)n; + x ^= x >> 16; + x *= 0x7feb352dU; + x ^= x >> 15; + x *= 0x846ca68bU; + x ^= x >> 16; + return (x & 0xFFFFFF) / 16777216f; + } + } + } +} diff --git a/Assets/Scripts/Battle/BattleArenaController.Life.cs.meta b/Assets/Scripts/Battle/BattleArenaController.Life.cs.meta new file mode 100644 index 0000000..0b4dd80 --- /dev/null +++ b/Assets/Scripts/Battle/BattleArenaController.Life.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c9b5245fff2167148872c9f3fea2e83c \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleArenaController.Raid.cs b/Assets/Scripts/Battle/BattleArenaController.Raid.cs new file mode 100644 index 0000000..dcc3e55 --- /dev/null +++ b/Assets/Scripts/Battle/BattleArenaController.Raid.cs @@ -0,0 +1,909 @@ +using System.Collections; +using System.Collections.Generic; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Spawning; +using UnityEngine; + +namespace InsectGame.Battle +{ + /// + /// 레이드 3D 연출 — 합체공격, 팀원 한 마리의 공격, 보스 공격 예고와 보스 공격. + /// + /// 예전 합체공격은 5마리가 0.42초 만에 동시에 돌진했다 돌아오고, 그 위에 UI가 2D용 사각형 그림과 + /// 화면 전체 노란 섬광을 따로 그렸다. 폭발은 불투명한 노란 공이라 보스를 가렸다. 팀원 공격과 보스 + /// 공격은 흔들림 한 번이 전부라 누가 누구를 쳤는지가 몸으로 읽히지 않았다. 여기선 1v1과 같은 부품 + /// (BattleArenaController.Impact.cs의 히트스톱·넉백·섬광·외침)을 레이드 박자에 맞춰 쓴다. + /// + public partial class BattleArenaController + { + private Coroutine telegraphRoutine; + private bool telegraphPoseSaved; + private Pose3 bossTelegraphRest; + private Vector3 telegraphShotPos; + private readonly List telegraphFx = new List(); + + /// 팀원 모델의 화면 좌표(픽셀, y 위쪽) — UI가 슬롯별 피해 숫자를 3D 위치에 붙인다. + public Vector3 GetTeamScreenPosition(int slot) + { + return ScreenCenterOf(GetTeamModel(slot)); + } + + /// + /// 아레나에 서 있는 모델인가 — 쓰러짐 연출()은 끝에 모델을 끄고, + /// 시작 때부터 기절해 있던 팀원()도 꺼 둔다. 꺼진 모델은 맞지도 돌진하지도 않는다. + /// + private static bool IsStanding(GameObject model) + { + return model != null && model.activeInHierarchy; + } + + /// + /// 레이드를 시작할 때 이미 기절해 있던 팀원을 세우지 않는다. 예전엔 HP 0인 곤충이 멀쩡히 서서 숨 쉬고 있었다 + /// (컨트롤러는 그 슬롯을 처음부터 "쓰러짐을 보여 줬다"로 쳐서 PresentPendingFaints가 눕히지도 않는다). + /// 감추는 쪽을 골랐다 — 전투 도중 쓰러진 팀원도 쓰러짐 연출 끝에 꺼지므로() 두 경우가 같게 보인다. + /// 카메라 구도는 다섯 자리를 다 세운 채 먼저 잡는다( 뒤에 부른다) — 기절 수에 따라 구도가 흔들리지 않게. + /// + private void HideMembersDownAtStart(bool[] teamDown) + { + if (teamDown == null || teamModels == null) return; + for (int i = 0; i < teamModels.Length && i < teamDown.Length; i++) + if (teamDown[i] && teamModels[i] != null) teamModels[i].SetActive(false); + } + + // ── 보스 변신(RaidBossForms) ── + + /// + /// 레이드 보스가 모습을 바꿨다 — 보스 모델을 의 모습으로 다시 세운다(기능만). + /// 처음 보스를 세운 경로( → 정면 회전 → 받침대 CreateBossRise → 오라)를 그대로 탄다 — + /// 받침대는 새 모델의 높이에 맞춰 다시 만든다. 진행 중인 예고는 접는다. 위치·카메라 구도는 그대로다. + /// 검은 연기·실루엣 색 같은 연출은 없다 — visual-dev가 붙인다(부르는 쪽: RaidBattleUI의 변신 단계 시작). + /// + public void RebuildRaidBoss(InsectData formInsect, int level, bool shiny) + { + if (!isActive || arenaRoot == null || formInsect == null || bossModel == null) return; + + StopTelegraph(true); + ForgetModel(bossModel); + Destroy(bossModel); + Transform oldRise = arenaRoot.transform.Find("BossRise"); + if (oldRise != null) Destroy(oldRise.gameObject); + + bossModel = CreateBattleInsect(formInsect, level, shiny, bossBattlePos, BossInsectScale); + bossModel.name = "RaidInsect_Boss"; + bossModel.transform.rotation = Quaternion.Euler(0f, 180f, 0f); + CreateBossRise(); + CreateBossAura(bossModel.transform); + } + + // ── 합체공격 ── + + public void PlayUniteAttackAnimation(System.Action onComplete) + { + CompleteStagingNow(); // 변신이 덜 끝났으면 새 모습으로 — 바뀌기 전 모습을 치지 않게 + StartCoroutine(UniteAttackCoroutine(onComplete)); + } + + /// + /// 기 모으기(전원 들썩임) → 차례 돌진(0.16초 간격, 각자 한 방) → 보스 앞에 떠서 대기 → 합동 일격 + /// (큰 히트스톱·슬로모션·섬광·충격파·보스 비명) → 복귀. 시각은 이 단일 출처다. + /// + private IEnumerator UniteAttackCoroutine(System.Action onComplete) + { + if (teamModels == null || bossModel == null) { onComplete?.Invoke(); yield break; } + + var members = new List(); + for (int i = 0; i < teamModels.Length; i++) + if (teamModels[i] != null && teamModels[i].activeInHierarchy) members.Add(i); + int n = members.Count; + if (n == 0) { onComplete?.Invoke(); yield break; } + + Bounds bossBounds = BattleFraming.ModelBounds(bossModel); + Vector3 bossCenter = bossBounds.center; + var starts = new Vector3[n]; + var strikes = new Vector3[n]; + var hovers = new Vector3[n]; + var gathered = new Vector3[n]; + var colors = new Color[n]; + var hit = new bool[n]; + Vector3 teamCenter = Vector3.zero; + for (int k = 0; k < n; k++) + { + starts[k] = teamModels[members[k]].transform.position; + teamCenter += starts[k]; + } + teamCenter /= n; + Vector3 toTeam = teamCenter - bossCenter; + toTeam.y = 0f; + toTeam = toTeam.sqrMagnitude > 0.0001f ? toTeam.normalized : Vector3.back; + Vector3 side = Vector3.Cross(Vector3.up, toTeam); + for (int k = 0; k < n; k++) + { + float spread = (k - (n - 1) * 0.5f) * 0.45f; + strikes[k] = bossCenter + toTeam * (bossBounds.extents.z * 0.6f + 0.25f) + side * spread; + hovers[k] = Vector3.Lerp(strikes[k], starts[k], 0.35f) + Vector3.up * 0.6f; + gathered[k] = Vector3.Lerp(bossCenter, strikes[k], 0.35f); + InsectEntity entity = teamModels[members[k]].GetComponent(); + colors[k] = GetElementColor3D(entity != null && entity.Data != null ? entity.Data.primaryType : InsectElement.Bug); + } + + playingSkill = true; + if (AudioManager.Instance != null) + for (int k = 0; k < n && k < 2; k++) + AudioManager.Instance.PlayCry(CryKey(CryOf(teamModels[members[k]], false), false)); + + float gatherAt = RaidUniteTimeline.FinalStrike - 0.15f; + float t = 0f; + bool finalDone = false; + try + { + while (t < RaidUniteTimeline.Total) + { + if (!isActive || bossModel == null) yield break; + t += PresentationDelta; + ApplyUniteCameraShot(t, teamCenter, bossCenter); + + for (int k = 0; k < n; k++) + { + GameObject m = teamModels[members[k]]; + if (m == null) continue; + float depart = RaidUniteTimeline.MemberStart + k * RaidUniteTimeline.MemberStagger; + float hitAt = RaidUniteTimeline.MemberHit(k); + if (!hit[k] && t >= hitAt) + { + hit[k] = true; + UniteMemberHit(strikes[k], toTeam, colors[k]); + } + + Vector3 pos; + if (t < depart) + { + // 기 모으기 — 제각각 들썩인다(같은 위상이면 기계처럼 보인다). + pos = starts[k] + Vector3.up * (Mathf.Abs(Mathf.Sin(t * 14f + k * 1.7f)) * 0.14f); + } + else if (t < hitAt) + { + float p = Mathf.Clamp01((t - depart) / RaidUniteTimeline.MemberTravel); + float eased = p * p * (3f - 2f * p); + pos = Vector3.Lerp(starts[k], strikes[k], eased) + Vector3.up * (Mathf.Sin(eased * Mathf.PI) * 0.9f); + CreateTrailParticle(pos, colors[k], 0.09f); + } + else if (t < gatherAt) + { + float r = Mathf.Clamp01((t - hitAt) / 0.22f); + pos = Vector3.Lerp(strikes[k], hovers[k], 1f - (1f - r) * (1f - r)) + + Vector3.up * (Mathf.Sin(t * 9f + k) * 0.05f); + } + else if (t < RaidUniteTimeline.FinalStrike) + { + float r = Mathf.Clamp01((t - gatherAt) / 0.15f); + pos = Vector3.Lerp(hovers[k], gathered[k], r * r); + CreateTrailParticle(pos, colors[k], 0.1f); + } + else + { + float r = Mathf.Clamp01((t - RaidUniteTimeline.FinalStrike - 0.2f) / 0.5f); + pos = Vector3.Lerp(gathered[k], starts[k], Mathf.SmoothStep(0f, 1f, r)) + + Vector3.up * (Mathf.Sin(r * Mathf.PI) * 0.6f); + } + m.transform.position = pos; + } + + if (!finalDone && t >= RaidUniteTimeline.FinalStrike) + { + finalDone = true; + UniteFinalStrike(bossCenter, toTeam); + } + yield return null; + } + } + finally + { + for (int k = 0; k < n; k++) + { + GameObject m = teamModels != null && members[k] < teamModels.Length ? teamModels[members[k]] : null; + if (m != null) m.transform.position = starts[k]; + } + ClearCameraShot(); + playingSkill = false; + } + onComplete?.Invoke(); + } + + private void UniteMemberHit(Vector3 point, Vector3 toTeam, Color color) + { + BattlePresentation.HitStop(0.035f); + if (!BattlePresentation.ReducedFlashes) StartCoroutine(ImpactBurstCoroutine(point, color, 0.35f)); + if (bossModel != null) StartReact(bossModel, HitReactCoroutine(bossModel, -toTeam, 0.3f, true)); + if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.Hit); + } + + /// + /// 합동 일격의 멈춤 — 팀원 한 마리의 치명타( 0.16~0.18초)보다 길게 둔다. + /// 다섯이 함께 친 정점이 혼자 친 치명타보다 짧게 멈추면 순서가 뒤집혀 보인다. 합체공격은 치명타를 굴리지 않는다. + /// + public const float UniteFinalHitStop = 0.2f; + + private void UniteFinalStrike(Vector3 bossCenter, Vector3 toTeam) + { + BattlePresentation.HitStop(UniteFinalHitStop); + BattlePresentation.SlowMotion(0.35f, 0.5f); + Color gold = new Color(1f, 0.85f, 0.35f); + if (!BattlePresentation.ReducedFlashes) + { + StartCoroutine(ImpactBurstCoroutine(bossCenter, gold, 1f)); + StartCoroutine(DefaultImpact3D(bossCenter, gold)); + } + if (bossModel != null) StartReact(bossModel, HitReactCoroutine(bossModel, -toTeam, 1f, true)); + PlayShout(BattleShout.Kind.Sound, bossCenter + toTeam * 0.9f, "콰과과광!!", gold, 1.1f, 0f, Random.Range(-8f, 8f)); + if (bossModel != null) + PlayShout(BattleShout.Kind.Hurt, HeadPoint(bossModel) + Vector3.up * 0.2f, + BattleShout.Hurt(BattleShout.Cry.Boss, 1f, Random.Range(0, 100)), Color.white, 1.2f, 0.15f); + if (AudioManager.Instance != null) + { + AudioManager.Instance.PlaySFX(SfxType.CriticalHit); + StartCoroutine(PlayCryDelayed(CryKey(BattleShout.Cry.Boss, true), 0.12f)); + } + CameraFollower follower = ResolveFollower(); + if (follower != null) follower.Shake(0.45f, 0.5f); + } + + private void ApplyUniteCameraShot(float seconds, Vector3 teamCenter, Vector3 bossCenter) + { + if (!hasBaseCam || BattlePresentation.ReducedMotion) return; + CameraFollower follower = ResolveFollower(); + if (follower == null) return; + BattleCameraDirector.Shot shot = BattleCameraDirector.EvaluateUnite(seconds, baseCamPos, baseCamRot, teamCenter, bossCenter); + follower.SetBattleShot(shot.Position, shot.Rotation, shot.Weight); + } + + // ── 팀원 한 마리의 공격 ── + + /// + /// 레이드 팀 행동을 같은 타임라인에 겹쳐 재생한다. 슬롯과 속성 배열은 같은 길이여야 하며, + /// 사망/누락 모델은 자동으로 건너뛴다. + /// + public void PlayRaidVolley(int[] slots, InsectElement[] elements, System.Action onComplete) + { + PlayRaidVolley(slots, elements, onComplete, HitCue.None); + } + + /// 한 마리 공격일 때의 외칠 기술명·타격 세기. 여러 마리면 외침은 생략한다. + public void PlayRaidVolley(int[] slots, InsectElement[] elements, System.Action onComplete, HitCue cue) + { + CompleteStagingNow(); + StartCoroutine(RaidVolleyCoroutine(slots, elements, onComplete, cue)); + } + + private IEnumerator RaidVolleyCoroutine(int[] slots, InsectElement[] elements, System.Action onComplete, HitCue cue) + { + int count = slots != null ? slots.Length : 0; + if (!isActive || bossModel == null || teamModels == null || count == 0) + { + onComplete?.Invoke(); + yield break; + } + + GameObject[] attackers = new GameObject[count]; + Vector3[] starts = new Vector3[count]; + Quaternion[] startRots = new Quaternion[count]; + Vector3[] contacts = new Vector3[count]; + GameObject[] projectiles = new GameObject[count]; + bool[] melee = new bool[count]; + bool[] valid = new bool[count]; + bool[] impacted = new bool[count]; + bool[] preStruck = new bool[count]; + BattleMotion.Kind[] kinds = new BattleMotion.Kind[count]; + BattleMotion.Family[] families = new BattleMotion.Family[count]; + Vector3 bossPos = bossModel.transform.position; + Vector3 bossCenter = BattleFraming.ModelBounds(bossModel).center; + int validCount = 0; + + for (int i = 0; i < count; i++) + { + int slot = slots[i]; + if (slot < 0 || slot >= teamModels.Length || teamModels[slot] == null) + continue; + + attackers[i] = teamModels[slot]; + starts[i] = attackers[i].transform.position; + startRots[i] = attackers[i].transform.rotation; + InsectElement element = elements != null && i < elements.Length ? elements[i] : InsectElement.Bug; + melee[i] = IsMeleeElement(element); + // 1대1과 같은 계열 몸짓 — 볼리 길이(0.46초, 끝에서 타격)는 그대로 두고 곡선만 그 안에 눌러 담는다. + // RaidBattleUI의 피해 숫자는 그 0.46초를 가정하고 뜬다. + InsectEntity entity = attackers[i].GetComponent(); + string species = entity != null && entity.Data != null ? entity.Data.insectId : null; + families[i] = BattleMotion.FamilyOf(species); + kinds[i] = BattleMotion.Resolve(species, melee[i], false); + // 1대1 공식과 같은 접점 — 보스 쪽으로 78%. + int spreadSlot = slots[i]; + float spreadX = (spreadSlot - (teamModels.Length - 1) * 0.5f) * 0.16f; + contacts[i] = Vector3.Lerp(starts[i], bossPos + Vector3.right * spreadX + Vector3.up * 0.15f, 0.78f); + valid[i] = true; + validCount++; + + if (!melee[i]) + { + projectiles[i] = CreateElementProjectile(element, GetElementColor3D(element)); + projectiles[i].transform.position = starts[i] + Vector3.up * 0.5f; + } + } + + if (validCount == 0) + { + onComplete?.Invoke(); + yield break; + } + + playingSkill = true; + bool single = validCount == 1; + // 0.035초의 짧은 스태거만 두어 충돌음은 연속으로 들리되, 한 마리가 복귀한 뒤 + // 다음 멤버가 출발하는 직렬 연출은 만들지 않는다. + const float stagger = 0.035f; + const float actionDuration = RaidVolleyImpactSeconds; + // 전용기 — 레이드는 볼리 길이가 정해져 있어(숫자 시각) 앞에 컷인 시간을 끼우지 않는다. 다가가는 동안 기를 모으고 + // 타격에 큰 이펙트, 카메라 반동은 최대 세기다. 컷인 글자 시각은 IsSignaturePlaying·SignatureCutInEndSeconds를 읽는다. + bool signature = single && cue.Signature; + if (single) + { + for (int i = 0; i < count; i++) + { + if (!valid[i]) continue; + InsectElement element = elements != null && i < elements.Length ? elements[i] : InsectElement.Bug; + BeginSkillPresentation(attackers[i], element, cue); + if (signature) + BeginSignature(attackers[i], element, cue, true, actionDuration, + BattleFlourish.SignatureCutInEndFor(actionDuration)); + } + } + else if (AudioManager.Instance != null) + { + AudioManager.Instance.PlaySFX(SfxType.SkillUse); + } + + float totalDuration = actionDuration + Mathf.Max(0, count - 1) * stagger; + float timer = 0f; + float impactAt = -1f; + Vector3 cameraFrom = Vector3.zero; + float motionScale = BattlePresentation.ReducedMotion ? 0.18f : 1f; + try + { + while (timer < totalDuration) + { + timer += PresentationDelta; + if (signature) signatureElapsed = timer; + for (int i = 0; i < count; i++) + { + if (!valid[i] || attackers[i] == null) continue; + float local = Mathf.Clamp01((timer - i * stagger) / actionDuration); + if (local <= 0f) continue; + + int slot = slots[i]; + float spread = (slot - (teamModels.Length - 1) * 0.5f) * 0.16f; + Vector3 target = bossPos + Vector3.right * spread + Vector3.up * 0.15f; + float eased = local * local * (3f - 2f * local); + InsectElement element = elements != null && i < elements.Length ? elements[i] : InsectElement.Bug; + Color elementColor = GetElementColor3D(element); + + // 몸짓 — 볼리 0..1이 몸짓의 0..타격으로 들어간다(마지막 프레임이 정확히 타격 자세). + float motionProgress = local * BattleMotion.ImpactOf(kinds[i]); + BattleMotion.Pose pose = BattleMotion.Evaluate(kinds[i], families[i], motionProgress).Scaled(motionScale); + Vector3 bodyPos = Vector3.LerpUnclamped(starts[i], contacts[i], pose.Travel) + Vector3.up * pose.Lift; + attackers[i].transform.SetPositionAndRotation(bodyPos, + startRots[i] * Quaternion.Euler(pose.Pitch, pose.Yaw, pose.Roll)); + if (!preStruck[i] && BattleMotion.HasPreStrike(kinds[i]) && motionProgress >= BattleMotion.SlashFirstSwing) + { + preStruck[i] = true; + PlaySlashArc(attackers[i], bossModel, elementColor, true); + } + + if (melee[i]) + { + CreateTrailParticle(bodyPos, elementColor, 0.08f); + } + else if (projectiles[i] != null) + { + Vector3 start = starts[i] + Vector3.up * 0.5f; + Vector3 pos = Vector3.Lerp(start, target, eased); + pos += GetElementTrajectoryOffset(element, local); + projectiles[i].transform.position = pos; + projectiles[i].transform.Rotate(Vector3.up, 720f * PresentationDelta); + CreateTrailParticle(pos, elementColor, 0.07f); + } + + if (!impacted[i] && local >= 0.98f) + { + impacted[i] = true; + if (projectiles[i] != null) + { + Destroy(projectiles[i]); + projectiles[i] = null; + } + if (kinds[i] == BattleMotion.Kind.Slash) PlaySlashArc(attackers[i], bossModel, elementColor, false); + if (!cue.Missed) CreateElementImpact3D(target, element, elementColor); + if (single) + { + impactAt = timer; + cameraFrom = attackers[i].transform.position; + // 피해 없는 행동(강화·회복·기절)은 보스를 치지 않는다 — 속성 임팩트만. + bool struck = cue.Missed || cue.Weight > 0f || cue.Finisher; + if (signature && struck && !cue.Missed) + PlaySignatureBurst(bossCenter, element, SignatureBurstSize(bossModel)); + if (struck) + PlayImpactFeel(attackers[i], bossModel, bossCenter, element, elementColor, cue); + // 속성 스킬음 — 1v1(SkillAttackCoroutine)과 같은 자리·같은 한 번. 예전엔 보스 공격만 냈다. + // 한 마리 공격에만 붙인다: 팀원 차례는 한 마리씩 0.85초 간격이라 겹치지 않고, + // 합체공격은 이 경로를 타지 않는다(UniteAttackCoroutine). 여러 마리 볼리는 출발 때 SkillUse 한 번이다. + if (AudioManager.Instance != null) AudioManager.Instance.PlaySkillSFX(element); + } + // 회복한 팀원의 반짝임은 이 행동이 닿는 순간에(Life partial — HP가 오른 걸 미리 적어 두었다). + FlushPendingHeals(); + } + } + // 전용기는 반동을 최대 세기로 — 보통 타격보다 확실히 크게. + if (impactAt >= 0f) ApplyPunchShot(timer - impactAt, cameraFrom, bossCenter, signature ? 1f : cue.Weight); + yield return null; + } + + if (!single) + { + StartCoroutine(ShakeModel(bossModel, 0.42f, 0.42f)); + CameraFollower follower = ResolveFollower(); + if (follower != null) follower.Shake(0.38f, 0.42f); + if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.Hit); + } + + // 복귀 — 몸짓의 타격 뒤 곡선(튕겨 물러나기·내려앉기)을 0.26초에 눌러 담는다. 원거리 시전 자세도 함께 풀린다. + const float returnDuration = 0.26f; + timer = 0f; + while (timer < returnDuration) + { + timer += PresentationDelta; + float progress = Mathf.Clamp01(timer / returnDuration); + if (signature) + { + signatureElapsed = totalDuration + timer; + if (signatureElapsed >= actionDuration + BattleFlourish.SignatureTailSeconds) EndSignature(); + } + for (int i = 0; i < count; i++) + { + if (!valid[i] || attackers[i] == null) continue; + float impactP = BattleMotion.ImpactOf(kinds[i]); + BattleMotion.Pose pose = BattleMotion.Evaluate(kinds[i], families[i], + Mathf.Lerp(impactP, BattleMotion.RestProgress, progress)).Scaled(motionScale); + attackers[i].transform.SetPositionAndRotation( + Vector3.LerpUnclamped(starts[i], contacts[i], pose.Travel) + Vector3.up * pose.Lift, + startRots[i] * Quaternion.Euler(pose.Pitch, pose.Yaw, pose.Roll)); + } + if (impactAt >= 0f) ApplyPunchShot(totalDuration - impactAt + timer, cameraFrom, bossCenter, signature ? 1f : cue.Weight); + yield return null; + } + } + finally + { + for (int i = 0; i < count; i++) + { + if (projectiles[i] != null) Destroy(projectiles[i]); + if (valid[i] && attackers[i] != null) + attackers[i].transform.SetPositionAndRotation(starts[i], startRots[i]); + } + if (signature) EndSignature(); + ClearCameraShot(); + playingSkill = false; + } + onComplete?.Invoke(); + } + + /// + /// 레이드 팀원 볼리의 타격 시각(연출 시계 초) — 볼리는 이 길이 끝에서 보스를 친다. RaidBattleUI가 3D 피해 숫자를 이 시각에 맞춰 띄운다 + /// (그쪽의 0.45초가 이 값의 사본이다). 계열 몸짓은 이 길이 안에 눌러 담으므로 계열이 달라도 이 시각은 그대로다. + /// + public const float RaidVolleyImpactSeconds = 0.46f; + + /// + /// 짧은 타격용 샷 — 레이드 팀원 공격은 0.7초에 한 번씩 몰아치므로 시전자까지 따라가지 않고 + /// 타격 순간에만 보스 쪽으로 튄다(). + /// + private void ApplyPunchShot(float secondsSinceImpact, Vector3 attacker, Vector3 target, float weight) + { + if (!hasBaseCam || BattlePresentation.ReducedMotion) return; + CameraFollower follower = ResolveFollower(); + if (follower == null) return; + // Evaluate의 타격 후 경과는 (진행도 - ImpactProgress) × 길이 — 길이 1초로 두면 진행도가 곧 초다. + BattleCameraDirector.Shot shot = BattleCameraDirector.Evaluate(BattleCameraDirector.Style.Punch, + BattleMotion.ImpactProgress + secondsSinceImpact, 1f, baseCamPos, baseCamRot, attacker, target, weight, false); + follower.SetBattleShot(shot.Position, shot.Rotation, shot.Weight); + } + + // ── 보스 공격 예고 ── + + /// + /// 보스 공격 예고(UI의 BossTelegraph 페이즈와 같은 길이). 보스가 몸을 젖히며 기를 모으고, 노리는 + /// 팀원 발밑에 붉은 경고 고리가 뛴다. 포효하고, 이름 있는 기술이면 외친다. 예고 자세는 공격이 + /// 이어받아 그 자리에서 달려든다 — 공격이 안 오면(보스 기절) 잠시 뒤 스스로 원래 자세로 돌아온다. + /// + public void PlayRaidBossTelegraph(int targetSlot, bool isAoe, InsectElement element, string skillName, float duration) + { + if (!isActive || bossModel == null) return; + CompleteStagingNow(); // 예고 자세의 원위치가 변신·등장 도중의 자세로 잡히지 않게 + StopTelegraph(true); + telegraphRoutine = StartCoroutine(BossTelegraphCoroutine(targetSlot, isAoe, element, skillName, Mathf.Max(0.2f, duration))); + } + + private IEnumerator BossTelegraphCoroutine(int targetSlot, bool isAoe, InsectElement element, string skillName, float duration) + { + bossTelegraphRest = new Pose3 + { + Position = bossModel.transform.position, + Rotation = bossModel.transform.rotation, + Scale = bossModel.transform.localScale + }; + telegraphPoseSaved = true; + + Bounds bounds = BattleFraming.ModelBounds(bossModel); + Vector3 bossCenter = bounds.center; + Vector3 toTeam = playerBattlePos - bossCenter; + toTeam.y = 0f; + toTeam = toTeam.sqrMagnitude > 0.0001f ? toTeam.normalized : Vector3.back; + Vector3 rearAxis = Vector3.Cross(Vector3.up, toTeam); + + if (!string.IsNullOrEmpty(skillName)) + PlayShout(BattleShout.Kind.Callout, HeadPoint(bossModel), BattleShout.Callout(skillName), GetUIElementColor(element), 1.1f); + if (AudioManager.Instance != null) AudioManager.Instance.PlayCry(CryKey(BattleShout.Cry.Boss, false)); + + Color charge = Color.Lerp(GetElementColor3D(element), new Color(1f, 0.25f, 0.2f), 0.4f); + Material glowMat = CreateFxMaterial(charge, true); + GameObject glow = BattlePresentation.ReducedFlashes ? null + : FxPrimitive(PrimitiveType.Sphere, "BossCharge", bossCenter + toTeam * (bounds.extents.z * 0.5f), glowMat); + if (glow != null) telegraphFx.Add(glow); + + var rings = new List(); + Material ringMat = CreateFxMaterial(new Color(1f, 0.2f, 0.15f, 0.9f), true); + for (int i = 0; teamModels != null && i < teamModels.Length; i++) + { + if (teamModels[i] == null || (!isAoe && i != targetSlot)) continue; + Vector3 p = teamModels[i].transform.position; + LineRenderer ring = CreateFloorRing("BossWarning", new Vector3(p.x, arenaCenter.y + 0.07f, p.z), ringMat); + telegraphFx.Add(ring.gameObject); + rings.Add(ring); + } + + float t = 0f; + bool motion = !BattlePresentation.ReducedMotion; + while (t < duration) + { + if (bossModel == null) yield break; + t += PresentationDelta; + float k = Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(t / duration)); + if (motion) + { + // 몸을 뒤로 젖히며 솟는다 — 달려들기 전의 예비 동작. + bossModel.transform.position = bossTelegraphRest.Position + Vector3.up * (0.28f * k) + + Vector3.right * (Mathf.Sin(t * 42f) * 0.02f * k); + bossModel.transform.rotation = Quaternion.AngleAxis(-12f * k, rearAxis) * bossTelegraphRest.Rotation; + } + if (glow != null) + { + glow.transform.localScale = Vector3.one * (Mathf.Lerp(0.2f, 1.3f, k) * (1f + 0.08f * Mathf.Sin(t * 30f))); + glowMat.color = new Color(charge.r, charge.g, charge.b, 0.3f + 0.45f * k); + } + // 굵게 — 0.07m 선은 초록 바닥 위에서 거의 안 보였다(QA 캡처). + float pulse = 0.5f + 0.5f * Mathf.Sin(t * 18f); + ringMat.color = new Color(1f, 0.18f, 0.12f, 0.6f + 0.4f * pulse); + foreach (LineRenderer ring in rings) SetRingRadius(ring, 0.85f + 0.12f * pulse, 0.14f); + + if (hasBaseCam && motion) + { + telegraphShotPos = Vector3.Lerp(baseCamPos, bossCenter, 0.12f); + CameraFollower follower = ResolveFollower(); + if (follower != null) + follower.SetBattleShot(telegraphShotPos, Quaternion.Slerp(baseCamRot, + Quaternion.LookRotation(bossCenter - telegraphShotPos), 0.5f), k); + } + yield return null; + } + + // 공격이 이어받지 않으면(보스 기절로 공격 생략) 잠시 버티다 스스로 푼다. + float hold = 0f; + while (hold < 1.0f) + { + hold += PresentationDelta; + yield return null; + } + telegraphRoutine = null; + StopTelegraph(true); + ClearCameraShot(); + } + + /// 예고를 멈춘다. 가 false면 젖힌 자세를 그대로 두고 공격이 이어받는다. + private void StopTelegraph(bool restorePose) + { + if (telegraphRoutine != null) + { + StopCoroutine(telegraphRoutine); + telegraphRoutine = null; + } + for (int i = 0; i < telegraphFx.Count; i++) + if (telegraphFx[i] != null) Destroy(telegraphFx[i]); + telegraphFx.Clear(); + if (restorePose && telegraphPoseSaved && bossModel != null) + { + bossModel.transform.SetPositionAndRotation(bossTelegraphRest.Position, bossTelegraphRest.Rotation); + bossModel.transform.localScale = bossTelegraphRest.Scale; + } + if (restorePose) telegraphPoseSaved = false; + } + + private LineRenderer CreateFloorRing(string name, Vector3 ground, Material material) + { + GameObject go = new GameObject(name); + go.transform.SetParent(arenaRoot.transform, false); + go.transform.position = ground; + go.transform.rotation = Quaternion.Euler(90f, 0f, 0f); // 선 면을 바닥에 눕힌다 + LineRenderer ring = go.AddComponent(); + ring.useWorldSpace = false; + ring.loop = true; + ring.positionCount = 40; + ring.alignment = LineAlignment.TransformZ; + ring.sharedMaterial = material; + ring.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + ring.receiveShadows = false; + return ring; + } + + private static void SetRingRadius(LineRenderer ring, float radius, float width) + { + if (ring == null) return; + ring.widthMultiplier = width; + for (int i = 0; i < ring.positionCount; i++) + { + float a = i * Mathf.PI * 2f / ring.positionCount; + ring.SetPosition(i, new Vector3(Mathf.Cos(a) * radius, Mathf.Sin(a) * radius, 0f)); + } + } + + // ── 보스 공격 ── + + /// + /// 레이드 보스의 예고된 단일/전체 공격을 실제 대상 모델에 동시 재생한다. + /// + public void PlayRaidBossAttack(InsectElement element, bool isAoe, int targetSlot, System.Action onComplete) + { + PlayRaidBossAttack(element, isAoe, targetSlot, onComplete, HitCue.None); + } + + /// 맞는 쪽 세기(피해 ÷ 팀원 최대 HP)와 쓰러짐 여부 — 넉백·히트스톱이 따른다. + public void PlayRaidBossAttack(InsectElement element, bool isAoe, int targetSlot, System.Action onComplete, HitCue cue) + { + CompleteStagingNow(); + StartCoroutine(RaidBossAttackCoroutine(element, isAoe, targetSlot, onComplete, cue)); + } + + private IEnumerator RaidBossAttackCoroutine(InsectElement element, bool isAoe, int targetSlot, + System.Action onComplete, HitCue cue) + { + if (!isActive || bossModel == null || teamModels == null) + { + StopTelegraph(true); + onComplete?.Invoke(); + yield break; + } + + // 이미 쓰러져 사라진 팀원(쓰러짐 연출 끝에 SetActive(false), 시작 때부터 기절)은 맞지 않는다 — + // 예전엔 전체 공격이 빈 자리에 속성 임팩트·비명을 띄웠다. + List targets = new List(); + if (isAoe) + { + for (int i = 0; i < teamModels.Length; i++) + if (IsStanding(teamModels[i])) + targets.Add(teamModels[i]); + } + else if (targetSlot >= 0 && targetSlot < teamModels.Length && IsStanding(teamModels[targetSlot])) + { + targets.Add(teamModels[targetSlot]); + } + else + { + GameObject fallback = ResolveBossTarget(); + if (fallback != null) targets.Add(fallback); + } + + if (targets.Count == 0) + { + StopTelegraph(true); + onComplete?.Invoke(); + yield break; + } + + // 예고 자세(젖혀 솟은 상태)에서 그대로 달려든다. 돌아올 곳은 예고 전의 원래 자리다. + bool fromTelegraph = telegraphPoseSaved; + Vector3 restPos = fromTelegraph ? bossTelegraphRest.Position : bossModel.transform.position; + Quaternion restRot = fromTelegraph ? bossTelegraphRest.Rotation : bossModel.transform.rotation; + Vector3 shotFrom = fromTelegraph ? telegraphShotPos : baseCamPos; + StopTelegraph(false); + telegraphPoseSaved = false; + + playingSkill = true; + if (AudioManager.Instance != null) + AudioManager.Instance.PlaySkillSFX(element); + // 보스의 전용기 — 예고(외침·젖히기)가 이미 컷인 몫을 했다. 달려드는 동안 기를 모으고 타격에 큰 이펙트를 얹는다. + const float BossCastSeconds = 0.42f; + bool signature = cue.Signature; + if (signature) + BeginSignature(bossModel, element, cue, false, BossCastSeconds, BattleFlourish.SignatureCutInEndFor(BossCastSeconds)); + + Vector3 bossStart = bossModel.transform.position; + Quaternion bossStartRot = bossModel.transform.rotation; + Vector3 targetCenter = Vector3.zero; + for (int i = 0; i < targets.Count; i++) + targetCenter += BattleFraming.ModelBounds(targets[i]).center; + targetCenter /= targets.Count; + + bool meleeAttack = IsMeleeElement(element); + GameObject[] projectiles = meleeAttack ? null : new GameObject[targets.Count]; + if (!meleeAttack) + { + Color color = GetElementColor3D(element); + for (int i = 0; i < targets.Count; i++) + { + projectiles[i] = CreateElementProjectile(element, color); + projectiles[i].transform.position = bossStart + Vector3.up * 0.6f; + } + } + + bool motion = !BattlePresentation.ReducedMotion; + CameraFollower follower = ResolveFollower(); + Vector3 shotTarget = Vector3.Lerp(baseCamPos, targetCenter, 0.15f); + // 1대1·팀원과 같은 계열 몸짓 — 보스는 크고 받침대 위라 달려드는 길(기존 돌진)은 그대로 두고 몸의 기울기·휘두름만 얹는다(0.6배). + InsectEntity bossEntity = bossModel.GetComponent(); + string bossSpecies = bossEntity != null && bossEntity.Data != null ? bossEntity.Data.insectId : null; + BattleMotion.Family bossFamily = BattleMotion.FamilyOf(bossSpecies); + BattleMotion.Kind bossKind = BattleMotion.Resolve(bossSpecies, meleeAttack, false); + float bossImpactP = BattleMotion.ImpactOf(bossKind); + float bossPoseScale = motion ? 0.6f : 0.12f; + bool bossPreStruck = false; + try + { + const float castDuration = BossCastSeconds; + float timer = 0f; + while (timer < castDuration) + { + if (!isActive || bossModel == null) yield break; + timer += PresentationDelta; + if (signature) signatureElapsed = timer; + float progress = Mathf.Clamp01(timer / castDuration); + float eased = progress * progress * (3f - 2f * progress); + BattleMotion.Pose bossPose = BattleMotion.Evaluate(bossKind, bossFamily, progress * bossImpactP).Scaled(bossPoseScale); + if (!bossPreStruck && BattleMotion.HasPreStrike(bossKind) && progress * bossImpactP >= BattleMotion.SlashFirstSwing) + { + bossPreStruck = true; + PlaySlashArc(bossModel, targets[0], GetElementColor3D(element), true); + } + if (meleeAttack) + { + Vector3 lungeTarget = Vector3.Lerp(bossStart, targetCenter, isAoe ? 0.22f : 0.52f); + Vector3 pos = Vector3.Lerp(bossStart, lungeTarget, eased); + pos.y += Mathf.Max(Mathf.Sin(progress * Mathf.PI) * (isAoe ? 1.4f : 0.8f), bossPose.Lift); + bossModel.transform.position = pos; + } + else + { + for (int i = 0; i < targets.Count; i++) + { + if (projectiles[i] == null || targets[i] == null) continue; + Vector3 end = targets[i].transform.position + Vector3.up * 0.4f; + Vector3 pos = Vector3.Lerp(bossStart + Vector3.up * 0.6f, end, eased); + pos += GetElementTrajectoryOffset(element, progress); + projectiles[i].transform.position = pos; + CreateTrailParticle(pos, GetElementColor3D(element), 0.09f); + } + } + // 젖힌 몸을 펴며 달려든다 — 그 위에 계열 몸짓(사마귀 베기·딱정벌레 머리 숙이기·나는 종 내리꽂기…). + bossModel.transform.rotation = Quaternion.Slerp(bossStartRot, restRot, eased) + * Quaternion.Euler(bossPose.Pitch, bossPose.Yaw, bossPose.Roll); + if (hasBaseCam && motion && follower != null) + { + Vector3 camPos = Vector3.Lerp(shotFrom, shotTarget, eased); + follower.SetBattleShot(camPos, Quaternion.Slerp(baseCamRot, + Quaternion.LookRotation(Vector3.Lerp(targetCenter, bossStart, 0.3f) - camPos), 0.5f), + fromTelegraph ? 1f : eased); + } + yield return null; + } + + // 1v1·팀원 공격(PlayImpactFeel)과 같은 규칙 — 센 비명·센 의성어·CriticalHit 소리는 치명타·마무리만(cue.Heavy). + // 전체 공격은 큰 폭발과 조금 더 큰 흔들림(HitCue.AreaShakeBonus)으로 "큰 기술"을 보이되, 치명타 한 방보다는 + // 작게 둔다. 예전엔 전체 공격이면 늘 CriticalHit·"콰광!!"·흔들림 0.55라 치명타 전체 공격과 구별이 없었다. + Color impactColor = GetElementColor3D(element); + if (bossKind == BattleMotion.Kind.Slash && targets[0] != null) PlaySlashArc(bossModel, targets[0], impactColor, false); + float w = cue.Weight; + float shoutWeight = cue.Heavy ? 1f : w; + Color accent = CriticalAccent(false); // 보스가 친 치명타 — 붉은빛 + Color burstColor = cue.Critical ? Color.Lerp(impactColor, accent, 0.65f) : impactColor; + for (int i = 0; i < targets.Count; i++) + { + if (projectiles != null && projectiles[i] != null) + Destroy(projectiles[i]); + if (targets[i] == null) continue; + Vector3 center = BattleFraming.ModelBounds(targets[i]).center; + CreateElementImpact3D(targets[i].transform.position, element, impactColor); + Vector3 dir = targets[i].transform.position - restPos; + dir.y = 0f; + dir = dir.sqrMagnitude > 0.0001f ? dir.normalized : Vector3.back; + StartReact(targets[i], HitReactCoroutine(targets[i], dir, w, false, cue.Critical, accent)); + if (!BattlePresentation.ReducedFlashes) + StartCoroutine(ImpactBurstCoroutine(center, burstColor, isAoe ? 0.5f : w)); + PlayShout(BattleShout.Kind.Hurt, HeadPoint(targets[i]) + dir * 0.4f, + BattleShout.Hurt(CryOf(targets[i], false), shoutWeight, Random.Range(0, 100)), + Color.white, 0.95f, 0.1f + i * 0.04f); + } + // 치명타 빛살은 한 번만 — 전체 공격이면 맞은 무리 한가운데 크게, 한 마리면 그 몸에. + if (cue.Critical && !BattlePresentation.ReducedFlashes && targets[0] != null) + StartCoroutine(CriticalFlareCoroutine( + isAoe ? targetCenter : BattleFraming.ModelBounds(targets[0]).center, accent, + isAoe ? 1.5f : FlareSize(targets[0]))); + Color soundColor = cue.Critical ? Color.Lerp(accent, Color.white, 0.2f) : Color.Lerp(impactColor, Color.white, 0.35f); + PlayShout(BattleShout.Kind.Sound, targetCenter + Vector3.down * 0.1f, + BattleShout.Sound(element, shoutWeight), soundColor, 0.85f, 0f, Random.Range(-10f, 10f)); + BattlePresentation.HitStop(cue.HitStopSeconds); + if (cue.Finisher) BattlePresentation.SlowMotion(0.3f, 0.6f); + + if (isAoe) + CreateBigExplosion(targetCenter); + if (signature && targets[0] != null) + PlaySignatureBurst(isAoe ? targetCenter : BattleFraming.ModelBounds(targets[0]).center, element, + SignatureBurstSize(isAoe ? bossModel : targets[0])); + if (follower != null) + follower.Shake(cue.ShakeAmplitude(isAoe), cue.ShakeSeconds(isAoe)); + if (AudioManager.Instance != null) + { + AudioManager.Instance.PlaySFX(cue.Heavy ? SfxType.CriticalHit : SfxType.Hit); + if (targets[0] != null) + StartCoroutine(PlayCryDelayed(CryKey(CryOf(targets[0], false), true), 0.1f)); + } + + const float recoverDuration = 0.30f; + Vector3 bossContact = bossModel.transform.position; + timer = 0f; + Vector3 camHit = shotTarget; + Vector3 kickDir = targetCenter - bossStart; + kickDir.y = 0f; + kickDir = kickDir.sqrMagnitude > 0.0001f ? kickDir.normalized : Vector3.back; + while (timer < recoverDuration) + { + if (bossModel == null) yield break; + timer += PresentationDelta; + if (signature) + { + signatureElapsed = BossCastSeconds + timer; + if (timer >= BattleFlourish.SignatureTailSeconds) EndSignature(); + } + float k = Mathf.Clamp01(timer / recoverDuration); + bossModel.transform.position = Vector3.Lerp(bossContact, restPos, k); + BattleMotion.Pose after = BattleMotion.Evaluate(bossKind, bossFamily, + Mathf.Lerp(bossImpactP, BattleMotion.RestProgress, k)).Scaled(bossPoseScale); + bossModel.transform.rotation = restRot * Quaternion.Euler(after.Pitch, after.Yaw, after.Roll); + if (hasBaseCam && motion && follower != null) + { + Vector3 camPos = camHit + BattleCameraDirector.Kick(kickDir, timer, 0.12f + 0.12f * w); + follower.SetBattleShot(camPos, Quaternion.Slerp(baseCamRot, + Quaternion.LookRotation(Vector3.Lerp(targetCenter, bossStart, 0.3f) - camPos), 0.5f), 1f - k); + } + yield return null; + } + } + finally + { + if (projectiles != null) + for (int i = 0; i < projectiles.Length; i++) + if (projectiles[i] != null) Destroy(projectiles[i]); + if (bossModel != null) bossModel.transform.SetPositionAndRotation(restPos, restRot); + if (signature) EndSignature(); + ClearCameraShot(); + playingSkill = false; + } + onComplete?.Invoke(); + } + } +} diff --git a/Assets/Scripts/Battle/BattleArenaController.Raid.cs.meta b/Assets/Scripts/Battle/BattleArenaController.Raid.cs.meta new file mode 100644 index 0000000..ad27df4 --- /dev/null +++ b/Assets/Scripts/Battle/BattleArenaController.Raid.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 246e3609c0fae684bbfa25e71a5e45df \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleArenaController.Staging.cs b/Assets/Scripts/Battle/BattleArenaController.Staging.cs new file mode 100644 index 0000000..21035c7 --- /dev/null +++ b/Assets/Scripts/Battle/BattleArenaController.Staging.cs @@ -0,0 +1,1123 @@ +using System.Collections; +using System.Collections.Generic; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Spawning; +using UnityEngine; + +namespace InsectGame.Battle +{ + /// + /// 이야기 전투의 등장·변신 연출 — ① 상대 교체 등장(1대1 팀 대결), ② 그림자 변신(레이드), ③ 수문장 등장(레이드 인트로), + /// 그리고 모습을 빌리는 보스의 ④ 그림자 모습. 시각표·곡선·색은 (순수), 카메라 샷은 + /// 가 낸다. 여기는 그걸 몸·연기·빛·카메라에 건다. + /// + /// 연출은 한 번에 하나다. 다른 연출(스킬·레이드 공격·예고·교체)이 시작되면 가 지금 연출을 + /// 끝 상태로 즉시 접는다 — 공중에 뜬 몸을 "원위치"로 잡은 돌진이 나오거나, 바뀌기 전 모습으로 공격하는 일이 없게. + /// 코루틴을 멈추면 finally가 돌지 않으므로(SkillAttackCoroutine 주석) 끝 상태는 따로 든 정리 함수가 만든다. + /// + /// 머티리얼: 반투명·빛은 전부 (빌드에 늘 있는 Hidden/Internal-Colored — 블렌드·컬링이 + /// 속성이라 셰이더 변형이 필요 없다)이고, 곤충 몸의 그림자 톤은 PropertyBlock으로만 건다(새 머티리얼·키워드 없음). + /// 그래서 Standard의 반투명·발광 변형(SceneryMaterials.BuildKeepers)에 기대는 곳이 없다. + /// + public partial class BattleArenaController + { + private Coroutine stagingRoutine; + private System.Action stagingFinish; + private MaterialPropertyBlock stagingBlock; + + // ── 그림자 모습 ── + /// 이 레이드 보스가 「그림자」(모습을 빌리는 보스)면 원래 곤충 ID, 아니면 null. + private string shadowBossId; + /// 그림자 톤을 입은 모델 → 덮는 세기. 가 쉬는 색을 여기서 낸다. + private readonly Dictionary shadowStrength = new Dictionary(); + private Material shadowRimMat; + private Material shadowEyeMat; + private Material shadowLanternMat; + private Coroutine shadowAuraRoutine; + /// + /// 발광 부위(반딧불이 발광기관 등) 렌더러 — 그림자 톤 대신 보랏빛으로 쉰다. 입힐 때 한 번 채운다 + /// (Renderer.name은 부를 때마다 문자열을 만든다 — 가 매 프레임 도는 동안 부르지 않는다). + /// + private readonly HashSet shadowLanterns = new HashSet(); + /// 새 모습이 울부짖을 때 눈·테두리가 번쩍이는 몫(1 → 0으로 잦아든다). + private float shadowEyeBoost; + + // 이름에 "Glow"가 들어가야 한다 — BattleFraming.ModelBounds가 그 이름을 경계에서 뺀다(카메라 구도·받침대 높이가 흔들리지 않게). + private const string RimGlowName = "ShadowRimGlow"; + private const string EyeGlowName = "ShadowEyeGlow"; + private const string LanternGlowName = "ShadowLanternGlow"; + + private static readonly Color RimColor = new Color(0.58f, 0.36f, 1f, 0.3f); + private static readonly Color EyeGlowColor = new Color(0.86f, 0.62f, 1f, 0.75f); + private static readonly Color LanternGlowColor = new Color(0.62f, 0.38f, 1f, 0.42f); + private static readonly Color SmokeColor = new Color(0.07f, 0.035f, 0.11f, 0.85f); + private static readonly Color WispColor = new Color(0.09f, 0.04f, 0.14f, 0.45f); + private static readonly Color DustColor = new Color(0.56f, 0.49f, 0.38f, 0.55f); + /// 테두리 두께(월드 m) — 부위마다 이만큼 큰 뒤집힌 껍질을 씌운다. + private const float RimPad = 0.03f; + /// 이보다 작은 부위(광택점·더듬이 끝)는 테두리를 씌우지 않는다 — 빛 덩어리가 된다. + private const float RimMinPartSize = 0.06f; + + private sealed class EntranceState + { + public GameObject Model; + public Pose3 Rest; + public float FootY; + public GameObject Player; + public Vector3 PlayerFrom; + public Vector3 PlayerTo; + public readonly List Fx = new List(); + } + + private sealed class TransformState + { + public InsectData Form; + public int Level; + public bool Shiny; + public bool Swapped; + public Pose3 Rest; + public float FootY; + public readonly List Fx = new List(); + } + + private sealed class GuardianState + { + public GameObject Boss; + public Pose3 Rest; + } + + // ───────────── 연출 제어 ───────────── + + /// + /// 지금 도는 등장·변신 연출을 끝 상태로 즉시 접는다(모델 교체·자세 복구·연기 정리·카메라 샷 해제). 돌고 있지 않으면 아무것도 안 한다. + /// 다른 연출의 시작점(스킬·레이드 공격·예고·합체공격·내 곤충 교체)이 부른다. + /// + public void CompleteStagingNow() + { + if (stagingRoutine != null) + { + StopCoroutine(stagingRoutine); + stagingRoutine = null; + } + System.Action finish = stagingFinish; + stagingFinish = null; + finish?.Invoke(); + } + + private void BeginStaging(IEnumerator routine, System.Action finish) + { + CompleteStagingNow(); + stagingFinish = finish; + stagingRoutine = StartCoroutine(routine); + } + + /// 연출 코루틴이 끝까지 돌았다 — 정리 함수를 한 번 부른다. + private void EndStaging() + { + stagingRoutine = null; + System.Action finish = stagingFinish; + stagingFinish = null; + finish?.Invoke(); + } + + /// 아레나 정리 — 코루틴은 StopAllCoroutines가, 오브젝트·머티리얼은 아레나 정리가 이미 치운다. 참조만 버린다. + private void ClearStagingState() + { + stagingRoutine = null; + stagingFinish = null; // 부르지 않는다 — 고칠 모델이 이미 없다 + shadowAuraRoutine = null; + shadowStrength.Clear(); + shadowLanterns.Clear(); + shadowBossId = null; + shadowRimMat = null; + shadowEyeMat = null; + shadowLanternMat = null; + shadowEyeBoost = 0f; + } + + private void ApplyStagingShot(BattleCameraDirector.Shot shot) + { + if (BattlePresentation.ReducedMotion) return; // 움직임 줄이기 — 구도 고정(흔들림도 CameraFollower가 끈다) + CameraFollower follower = ResolveFollower(); + if (follower != null) follower.SetBattleShot(shot.Position, shot.Rotation, shot.Weight); + } + + private MaterialPropertyBlock StagingBlock() + { + if (stagingBlock == null) stagingBlock = new MaterialPropertyBlock(); + stagingBlock.Clear(); + return stagingBlock; + } + + private static Pose3 PoseOf(GameObject model) + { + return new Pose3 + { + Position = model.transform.position, + Rotation = model.transform.rotation, + Scale = model.transform.localScale + }; + } + + private static void SetPose(GameObject model, Pose3 pose) + { + if (model == null) return; + model.transform.SetPositionAndRotation(pose.Position, pose.Rotation); + model.transform.localScale = pose.Scale; + } + + private static void DestroyAll(List objects) + { + for (int i = 0; i < objects.Count; i++) + if (objects[i] != null) Destroy(objects[i]); + objects.Clear(); + } + + // 결정적 난수 — 연출이 UnityEngine.Random을 소비하면 같은 시드의 QA 캡처가 다른 장면이 된다. + private static float NextRand(ref uint state) + { + state = state * 1664525u + 1013904223u; + return (state >> 8) * (1f / 16777216f); + } + + // ───────────── ① 상대 교체 등장 ───────────── + + /// + /// 팀 대결에서 상대가 다음 곤충을 내보낸다 — 새 곤충을 세우고(, 받침·구도는 그쪽 그대로) 등장을 건다. + /// BattleScreenUI의 교체 단계 시작이 부르고, 은 그 단계 길이(EnemySwitchSeconds)다. + /// + /// 0~0.34: 착지점에 빛살이 서고 카메라가 앞 구도에서 그쪽으로 옮겨 간다 · 0.12~0.52: 바깥 뒤쪽에서 포물선으로 뛰어든다 · + /// 0.52: 착지 — 먼지·땅 고리·빛살 퍼짐·흔들림·쿵·울음 · ~0.82: 눌렸다 펴지며 자세를 잡는다 · 0.72~0.94: 카메라가 새 구도로 풀린다. + /// 내 곤충은 그대로 서 있다(새 상대 크기에 맞춰 벌어진 간격만큼 0.3 동안 미끄러진다). 움직임 줄이기면 제자리에 서 있고 먼지만 인다. + /// + public void PlayEnemySwitchIn(InsectData newInsect, int newLevel, bool shiny, float duration) + { + if (!isActive || arenaRoot == null || newInsect == null) return; + CompleteStagingNow(); + RetireFaintShouts(); // 앞 곤충의 「털썩…」이 교체 배너(아래쪽 가운데) 자리에 남지 않게 + + bool hadCam = hasBaseCam; + Vector3 fromPos = baseCamPos; + Quaternion fromRot = baseCamRot; + GameObject player = playerModel; + Vector3 playerFrom = player != null ? player.transform.position : playerBattlePos; + + RebuildEnemyInsect(newInsect, newLevel, shiny); + if (enemyModel == null) return; + if (!hadCam) + { + fromPos = baseCamPos; + fromRot = baseCamRot; + } + + var st = new EntranceState + { + Model = enemyModel, + Rest = PoseOf(enemyModel), + FootY = BattleFraming.ModelBounds(enemyModel).min.y, + Player = player, + PlayerFrom = playerFrom, + PlayerTo = player != null ? player.transform.position : playerBattlePos + }; + BeginStaging(EnemyEntranceCoroutine(st, Mathf.Max(0.4f, duration), fromPos, fromRot), () => FinishEntrance(st)); + } + + private IEnumerator EnemyEntranceCoroutine(EntranceState st, float duration, Vector3 fromPos, Quaternion fromRot) + { + GameObject model = st.Model; + Pose3 rest = st.Rest; + Bounds bounds = BattleFraming.ModelBounds(model); + Vector3 landing = bounds.center; + Vector3 opponent = st.Player != null ? BattleFraming.ModelBounds(st.Player).center : playerBattlePos; + Vector3 away = rest.Position - st.PlayerTo; + away.y = 0f; + away = away.sqrMagnitude > 0.0001f ? away.normalized : Vector3.right; + // 바깥(내 곤충 반대쪽)·뒤(카메라 반대쪽 +z)에서 땅 높이로 출발한다 — 화면 밖에서 뛰어든다. + Vector3 launch = rest.Position + away * BattleStaging.EntranceLaunchOut + Vector3.forward * BattleStaging.EntranceLaunchBack; + bool motion = !BattlePresentation.ReducedMotion; + bool flashes = !BattlePresentation.ReducedFlashes; + + InsectEntity entity = model.GetComponent(); + InsectElement element = entity != null && entity.Data != null ? entity.Data.primaryType : InsectElement.Bug; + Color beamColor = Color.Lerp(GetElementColor3D(element), Color.white, 0.55f); + Vector3 floor = new Vector3(landing.x, arenaCenter.y + 0.06f, landing.z); + + // 착지점 빛살 — 새 곤충이 어디로 오는지 먼저 알린다. 가산 원기둥이라 밝은 섬광 줄이기면 없다. + GameObject beam = null; + Material beamMat = null; + if (flashes) + { + beamMat = CreateFxMaterial(new Color(beamColor.r, beamColor.g, beamColor.b, 0f), true); + beam = FxPrimitive(PrimitiveType.Cylinder, "EntranceBeam", floor + Vector3.up * 2.2f, beamMat); + beam.transform.localScale = new Vector3(0.5f, 2.2f, 0.5f); // 원기둥 높이 2 → 4.4m + st.Fx.Add(beam); + } + + bool landed = false; + float t = 0f; + while (true) + { + if (!isActive || arenaRoot == null || model == null) break; + float p = Mathf.Clamp01(t / duration); + + if (st.Player != null && st.Player == playerModel) + st.Player.transform.position = Vector3.Lerp(st.PlayerFrom, st.PlayerTo, Mathf.SmoothStep(0f, 1f, p / 0.3f)); + if (motion) PoseEntrance(model, rest, launch, st.FootY, p); + + if (!landed && p >= BattleStaging.EntranceLand) + { + landed = true; + EntranceLanding(model, floor, bounds, beamColor, flashes); + } + + if (beam != null) + { + float b = p / BattleStaging.EntranceBeamEnd; + if (b >= 1f) + { + st.Fx.Remove(beam); + Destroy(beam); + beam = null; + } + else + { + float width = Mathf.Lerp(0.55f, 0.12f, b); + beam.transform.localScale = new Vector3(width, 2.2f, width); + beamMat.color = new Color(beamColor.r, beamColor.g, beamColor.b, 0.7f * Mathf.Sin(b * Mathf.PI)); + } + } + + if (hasBaseCam) + ApplyStagingShot(BattleCameraDirector.EvaluateEntrance(p, duration, fromPos, fromRot, + baseCamPos, baseCamRot, landing, opponent)); + + if (p >= 1f) break; + yield return null; + t += PresentationDelta; + } + EndStaging(); + } + + /// 등장 자세 — 도약 전엔 숨기고, 도약 중엔 포물선·기울기, 착지 뒤엔 발을 땅에 붙인 채 눌렸다 편다. + private static void PoseEntrance(GameObject model, Pose3 rest, Vector3 launch, float footY, float p) + { + if (p < BattleStaging.EntranceLeapStart) + { + model.transform.localScale = Vector3.zero; + return; + } + if (p < BattleStaging.EntranceLand) + { + float u = (p - BattleStaging.EntranceLeapStart) / (BattleStaging.EntranceLand - BattleStaging.EntranceLeapStart); + model.transform.position = BattleStaging.LeapPosition(launch, rest.Position, BattleStaging.EntranceApex, u); + model.transform.rotation = rest.Rotation * Quaternion.Euler(BattleStaging.LeapPitch(u), 0f, 0f); + model.transform.localScale = rest.Scale * Mathf.Lerp(0.85f, 1f, Mathf.SmoothStep(0f, 1f, u / 0.3f)); + return; + } + float k = Mathf.Clamp01((p - BattleStaging.EntranceLand) / (BattleStaging.EntranceSettleEnd - BattleStaging.EntranceLand)); + float squash = BattleStaging.LandingSquash(k); + float width = BattleStaging.SquashWidth(squash); + Vector3 pos = rest.Position; + pos.y -= (1f - squash) * (rest.Position.y - footY); // 발은 땅에 붙인 채 눌린다 + model.transform.position = pos; + model.transform.rotation = rest.Rotation * Quaternion.Euler( + Mathf.Lerp(BattleStaging.LeapPitch(1f), 0f, Mathf.SmoothStep(0f, 1f, k / 0.6f)), 0f, 0f); + model.transform.localScale = Vector3.Scale(rest.Scale, new Vector3(width, squash, width)); + } + + private void EntranceLanding(GameObject model, Vector3 floor, Bounds bounds, Color rayColor, bool flashes) + { + float footprint = Mathf.Max(0.35f, Mathf.Max(bounds.extents.x, bounds.extents.z) * 0.7f); + StartCoroutine(DustBurstCoroutine(floor, footprint, 10, 1.5f, 0.6f, 0.34f, 2654435761u)); + StartCoroutine(GroundRingCoroutine(floor + Vector3.up * 0.01f, footprint * 0.6f, footprint + 1.4f, 0.16f, + DustColor, 0.45f, false)); + if (flashes) StartCoroutine(LightRaysCoroutine(floor + Vector3.up * 0.04f, rayColor, footprint + 1.1f)); + CameraFollower follower = ResolveFollower(); + if (follower != null) follower.Shake(0.14f, 0.24f); + if (AudioManager.Instance != null) + { + AudioManager.Instance.PlaySkillSFX(InsectElement.Earth); // 쿵 — 흙 기술음이 가장 무겁게 떨어진다 + StartCoroutine(PlayCryDelayed(CryKey(CryOf(model, false), false), 0.12f)); + } + } + + private void FinishEntrance(EntranceState st) + { + if (st.Model != null) SetPose(st.Model, st.Rest); + if (st.Player != null && st.Player == playerModel) st.Player.transform.position = st.PlayerTo; + DestroyAll(st.Fx); + ClearCameraShot(); + } + + // ───────────── ② 그림자 변신 ───────────── + + /// + /// 레이드 보스가 모습을 바꾼다 — 검은 연기가 감싸고, 한가운데()에서 모델을 + /// 로 갈아끼우고( — 받침대·오라도 그때 다시 선다, 연기 속이라 안 보인다), + /// 연기가 걷히며 빌린 모습이 드러난다. RaidBattleUI의 변신 단계 시작이 부르고 은 그 단계 길이다. + /// + /// 0~0.44: 연기·먹빛이 몸을 덮고 몸부림치며 움츠러든다(카메라가 다가붙는다) · 0.5: 교체 + 어두운 충격파·보랏빛 번쩍 · + /// 0.6: 새 모습이 펴지며 울부짖는다(눈빛 번쩍·흔들림·새 속성의 기술음) · ~0.86: 연기가 위로 흩어진다 · 0.72~0.94: 카메라가 풀린다. + /// 그림자 보스()면 새 모습에 그림자 톤·테두리·눈빛을 입힌다(). + /// + public void PlayBossTransform(InsectData formInsect, int level, bool shiny, float duration) + { + if (!isActive || arenaRoot == null || formInsect == null) return; + if (bossModel == null) + { + RebuildRaidBoss(formInsect, level, shiny); + return; + } + CompleteStagingNow(); + StopTelegraph(true); + var st = new TransformState { Form = formInsect, Level = level, Shiny = shiny }; + BeginStaging(BossTransformCoroutine(st, Mathf.Max(0.4f, duration)), () => FinishTransform(st)); + } + + private IEnumerator BossTransformCoroutine(TransformState st, float duration) + { + GameObject boss = bossModel; + st.Rest = PoseOf(boss); + Bounds bounds = BattleFraming.ModelBounds(boss); + st.FootY = bounds.min.y; + Vector3 center = bounds.center; + Vector3 cameraCenter = center; // 카메라 기준점은 처음 한 번 — 모델이 바뀌어도 샷이 덜컹거리지 않게 + float radius = Mathf.Max(0.7f, Mathf.Max(bounds.extents.x, bounds.extents.z)); + float height = Mathf.Max(0.8f, bounds.size.y); + bool motion = !BattlePresentation.ReducedMotion; + bool flashes = !BattlePresentation.ReducedFlashes; + + // 연기 — 한 머티리얼, 덩이마다 알파는 PropertyBlock. + const int SmokeCount = 14; + Material smokeMat = CreateFxMaterial(SmokeColor, false); + var puffs = new MeshRenderer[SmokeCount]; + var angles = new float[SmokeCount]; + var layers = new float[SmokeCount]; + var sizes = new float[SmokeCount]; + uint seed = 40503u; + for (int i = 0; i < SmokeCount; i++) + { + GameObject go = FxPrimitive(PrimitiveType.Sphere, "ShadowSmoke", center, smokeMat); + go.transform.localScale = Vector3.zero; + st.Fx.Add(go); + puffs[i] = go.GetComponent(); + angles[i] = i * Mathf.PI * 2f / SmokeCount + (NextRand(ref seed) - 0.5f) * 0.5f; + layers[i] = 0.1f + 0.78f * ((i * 0.618f) % 1f); + sizes[i] = 0.8f + 0.4f * NextRand(ref seed); + } + // 먹구슬 — 안쪽에서 부풀어 바꾸는 순간 몸을 다 가린다. 웅덩이 — 받침대 위로 번지는 그림자. + Material coreMat = CreateFxMaterial(new Color(BattleStaging.ShadowInk.r, BattleStaging.ShadowInk.g, BattleStaging.ShadowInk.b, 0f), false); + GameObject core = FxPrimitive(PrimitiveType.Sphere, "ShadowCore", center, coreMat); + core.transform.localScale = Vector3.zero; + st.Fx.Add(core); + Material poolMat = CreateFxMaterial(new Color(0.05f, 0.02f, 0.08f, 0f), false); + GameObject pool = FxPrimitive(PrimitiveType.Sphere, "ShadowPool", new Vector3(center.x, bossGroundY + 0.015f, center.z), poolMat); + pool.transform.localScale = Vector3.zero; + st.Fx.Add(pool); + + if (AudioManager.Instance != null) AudioManager.Instance.PlaySkillSFX(InsectElement.Dark); + + bool roared = false; + float t = 0f; + while (true) + { + if (!isActive || arenaRoot == null) break; + float p = Mathf.Clamp01(t / duration); + + if (!st.Swapped && p >= BattleStaging.TransformSwap) + { + SwapBossForm(st); + if (bossModel != null) + { + Bounds swapped = BattleFraming.ModelBounds(bossModel); + center = swapped.center; + radius = Mathf.Max(radius, Mathf.Max(swapped.extents.x, swapped.extents.z)); + height = Mathf.Max(height, swapped.size.y); + } + // 어두운 충격파 — 받침대 위로 퍼진다. 보랏빛 번쩍은 밝은 섬광 줄이기면 없다. + StartCoroutine(GroundRingCoroutine(new Vector3(center.x, bossGroundY + 0.03f, center.z), radius * 0.6f, + radius * 2.6f, 0.24f, new Color(0.12f, 0.05f, 0.2f, 0.85f), 0.45f, false)); + if (flashes) StartCoroutine(ShadowFlashCoroutine(center, radius)); + } + + GameObject model = bossModel; + if (model != null) + { + if (motion) PoseTransform(model, st, p, t); + SetFlash(model, BattleStaging.ShadowInk, BattleStaging.TransformVeil(p)); + } + + if (!roared && p >= BattleStaging.TransformRoar) + { + roared = true; + shadowEyeBoost = 1f; + CameraFollower follower = ResolveFollower(); + if (follower != null) follower.Shake(0.2f, 0.35f); + if (AudioManager.Instance != null) + { + AudioManager.Instance.PlayCry(CryKey(BattleShout.Cry.Boss, false)); + AudioManager.Instance.PlaySkillSFX(st.Form.primaryType); // 바뀐 상성을 귀로도 — 새 모습의 속성음 + } + } + + float cover = BattleStaging.SmokeCover(p); + UpdateSmoke(puffs, angles, layers, sizes, center, st.FootY, radius, height, cover, p, t); + float coreSize = Mathf.Max(height, radius * 2f) * 1.15f * Mathf.Pow(cover, 0.7f); + core.transform.position = center; + core.transform.localScale = Vector3.one * coreSize; + coreMat.color = new Color(BattleStaging.ShadowInk.r, BattleStaging.ShadowInk.g, BattleStaging.ShadowInk.b, + 0.92f * cover * cover); + float poolSize = radius * 2.6f * cover; + // 받침대는 교체 때 새 몸 높이로 다시 선다(RebuildRaidBoss) — 웅덩이도 그 윗면을 따른다. + pool.transform.position = new Vector3(center.x, bossGroundY + 0.015f, center.z); + pool.transform.localScale = new Vector3(poolSize, 0.03f, poolSize); + poolMat.color = new Color(0.05f, 0.02f, 0.08f, 0.75f * cover); + + if (hasBaseCam) + ApplyStagingShot(BattleCameraDirector.EvaluateBossTransform(p, duration, baseCamPos, baseCamRot, cameraCenter)); + + if (p >= 1f) break; + yield return null; + t += PresentationDelta; + } + EndStaging(); + } + + /// 변신 중 몸 — 발을 받침대에 붙인 채 움츠러들었다 펴지고, 삼켜지는 동안 몸부림친다. + private static void PoseTransform(GameObject model, TransformState st, float p, float seconds) + { + float s = BattleStaging.TransformScale(p); + Vector3 pos = st.Rest.Position; + pos.y -= (1f - s) * (st.Rest.Position.y - st.FootY); + if (p < BattleStaging.TransformSwap) + { + float k = Mathf.SmoothStep(0f, 1f, p / BattleStaging.TransformSwap); + pos.x += Mathf.Sin(seconds * 47f) * 0.035f * k; + pos.z += Mathf.Sin(seconds * 39f + 1f) * 0.02f * k; + } + model.transform.SetPositionAndRotation(pos, st.Rest.Rotation); + model.transform.localScale = st.Rest.Scale * s; + } + + private void UpdateSmoke(MeshRenderer[] puffs, float[] angles, float[] layers, float[] sizes, Vector3 center, + float footY, float radius, float height, float cover, float p, float seconds) + { + float inner = radius * 0.55f; + float r = p < BattleStaging.TransformSwap + ? Mathf.Lerp(radius * 1.5f, inner, Mathf.SmoothStep(0f, 1f, p / BattleStaging.TransformEngulfEnd)) + : Mathf.Lerp(inner, radius * 1.9f, Mathf.SmoothStep(0f, 1f, + (p - BattleStaging.TransformSwap - 0.04f) / (BattleStaging.TransformRevealEnd - BattleStaging.TransformSwap - 0.04f))); + float rise = p < BattleStaging.TransformSwap ? 0f + : 1.1f * Mathf.SmoothStep(0f, 1f, (p - BattleStaging.TransformSwap) / (BattleStaging.TransformRevealEnd - BattleStaging.TransformSwap)); + float peak = Mathf.Max(radius * 1.05f, height * 0.5f); + for (int i = 0; i < puffs.Length; i++) + { + MeshRenderer puff = puffs[i]; + if (puff == null) continue; + float a = angles[i] + seconds * 1.0f * (i % 2 == 0 ? 1f : -1f); + puff.transform.position = new Vector3(center.x + Mathf.Cos(a) * r, + footY + height * layers[i] + rise * (0.6f + 0.4f * sizes[i]), center.z + Mathf.Sin(a) * r); + puff.transform.localScale = Vector3.one * (Mathf.Lerp(0.3f, peak, cover) * sizes[i]); + MaterialPropertyBlock block = StagingBlock(); + block.SetColor("_Color", new Color(SmokeColor.r, SmokeColor.g, SmokeColor.b, SmokeColor.a * cover * (0.7f + 0.3f * sizes[i]))); + puff.SetPropertyBlock(block); + } + } + + private void SwapBossForm(TransformState st) + { + st.Swapped = true; + RebuildRaidBoss(st.Form, st.Level, st.Shiny); + if (bossModel == null) return; + if (shadowBossId != null) ApplyShadowLook(bossModel, st.Form.insectId != shadowBossId); + st.Rest = PoseOf(bossModel); + st.FootY = BattleFraming.ModelBounds(bossModel).min.y; + } + + private void FinishTransform(TransformState st) + { + if (!st.Swapped) SwapBossForm(st); + if (bossModel != null) + { + SetPose(bossModel, st.Rest); + SetFlash(bossModel, Color.white, 0f); // 먹빛을 걷는다 — 그림자 보스면 그림자 톤으로 쉰다 + } + DestroyAll(st.Fx); + ClearCameraShot(); + } + + /// 교체 순간의 보랏빛 번쩍 — 가산 구가 0.22초 만에 부풀며 사라진다. + private IEnumerator ShadowFlashCoroutine(Vector3 center, float radius) + { + if (arenaRoot == null) yield break; + Color glow = BattleStaging.ShadowGlow; + Material mat = CreateFxMaterial(new Color(glow.r, glow.g, glow.b, 0.8f), true); + GameObject flash = FxPrimitive(PrimitiveType.Sphere, "ShadowFlash", center, mat); + const float Duration = 0.22f; + float t = 0f; + while (t < Duration) + { + if (arenaRoot == null || flash == null) yield break; + t += PresentationDelta; + float k = Mathf.Clamp01(t / Duration); + flash.transform.localScale = Vector3.one * Mathf.Lerp(0.3f, radius * 1.6f, 1f - (1f - k) * (1f - k)); + mat.color = new Color(glow.r, glow.g, glow.b, 0.8f * (1f - k)); + yield return null; + } + if (flash != null) Destroy(flash); + } + + // ───────────── ③ 수문장 등장 ───────────── + + /// + /// 수문장 레이드의 인트로 컷(초, 안). 가 + /// 수문장일 때 부른다. 0~1.3: 낮은 데서 올려다보며 다가간다(수문장은 화면 위쪽 창 — 아래 1/3은 ui-dev 등장 배너 자리) · + /// 0.62: 포효(몸을 젖히며 솟고, 울고, 흔들리고, 받침대 위·아래에서 먼지가 일고, 포효 파동) · 1.3~1.85: 천천히 더 붙는다 · + /// 1.85~2.5: 원래 구도로 풀린다. 움직임 줄이기면 카메라·몸은 그대로고 먼지·소리만 난다. + /// + public void PlayGuardianIntro() + { + if (!isActive || arenaRoot == null || bossModel == null) return; + var st = new GuardianState { Boss = bossModel, Rest = PoseOf(bossModel) }; + BeginStaging(GuardianIntroCoroutine(st), () => FinishGuardianIntro(st)); + } + + private IEnumerator GuardianIntroCoroutine(GuardianState st) + { + GameObject boss = st.Boss; + Bounds bounds = BattleFraming.ModelBounds(boss); + Vector3 toTeam = playerBattlePos - bounds.center; + toTeam.y = 0f; + toTeam = toTeam.sqrMagnitude > 0.0001f ? toTeam.normalized : Vector3.back; + Vector3 rearAxis = Vector3.Cross(Vector3.up, toTeam); + bool motion = !BattlePresentation.ReducedMotion; + bool shot = TryGuardianShot(bounds, out Vector3 startPos, out Vector3 endPos, out Quaternion rotation); + Camera fitted = Camera.main; + float fittedFov = fitted != null ? fitted.fieldOfView : 0f; + float fittedAspect = fitted != null ? fitted.aspect : 0f; + + bool roared = false; + float t = 0f; + while (true) + { + if (!isActive || arenaRoot == null || boss == null) break; + // 화면비·FOV가 바뀌면(첫 프레임에 CameraFollower가 FOV를 맞추는 경우 포함) 그 화면으로 다시 맞춘다 — + // 아레나 구도(SetupBattleCamera)도 같은 조건으로 다시 잡힌다(Update). + Camera cam = Camera.main; + if (cam != null && (Mathf.Abs(cam.fieldOfView - fittedFov) > 0.01f || Mathf.Abs(cam.aspect - fittedAspect) > 0.01f)) + { + fittedFov = cam.fieldOfView; + fittedAspect = cam.aspect; + shot = TryGuardianShot(bounds, out startPos, out endPos, out rotation); + } + if (motion) + { + // 포효 — 몸을 뒤로 젖히며 솟는다(보스 공격 예고와 같은 축). + float k = BattleStaging.RoarPose(t); + boss.transform.SetPositionAndRotation(st.Rest.Position + Vector3.up * (0.28f * k), + Quaternion.AngleAxis(-15f * k, rearAxis) * st.Rest.Rotation); + } + if (!roared && t >= BattleStaging.GuardianRoarAt) + { + roared = true; + GuardianRoar(boss, bounds, toTeam); + } + if (shot) ApplyStagingShot(BattleCameraDirector.EvaluateGuardianIntro(t, startPos, endPos, rotation)); + if (t >= BattleStaging.GuardianReleaseEnd) break; + yield return null; + // 화면의 인트로 시계(RaidBattleUI.GuardianIntroClockScale)와 같은 배율 — 2배속에서도 별칭·등장 한 줄을 읽는 + // 최소 실제 시간(BattleReadPacing.GuardianIntroMinSeconds)만큼 늘어나 포효의 「쾅」이 배너와 같은 박자에 온다. + t += PresentationDelta * BattleReadPacing.ClockScale(BattleStaging.GuardianIntroSeconds, + BattleReadPacing.GuardianIntroMinSeconds, BattlePresentation.Speed); + } + EndStaging(); + } + + /// + /// 수문장 샷 — 올려다보는 회전()으로 수문장이 안전 영역의 위쪽 창 + /// (~)을 채우는 끝 자리를 잡고, + /// 거기서 물러난 출발 자리를 낸다. 카메라가 벽 밖에 서지 않게 벽을 넓히고 출발 자리를 벽 안쪽에서 자른다. + /// + private bool TryGuardianShot(Bounds bossBounds, out Vector3 startPos, out Vector3 endPos, out Quaternion rotation) + { + startPos = endPos = Vector3.zero; + rotation = Quaternion.identity; + Camera cam = Camera.main; + if (cam == null || !hasBaseCam) return false; + + Rect safe = SafeViewport(); + Rect window = new Rect(safe.x, safe.y + safe.height * BattleStaging.GuardianWindowBottom, safe.width, + safe.height * (BattleStaging.GuardianWindowTop - BattleStaging.GuardianWindowBottom)); + Bounds fit = bossBounds; + fit.Expand(0.3f); + float floorY = arenaCenter.y; + BattleStaging.GuardianEndShot(fit, cam.aspect, cam.fieldOfView, baseCamRot.eulerAngles.y, window, + playerBattlePos, floorY, out endPos, out Vector3 endTarget, out rotation); + + float span = Mathf.Max(CurrentWallSpan(), Mathf.Abs(endPos.x - arenaCenter.x) + 3f, + Mathf.Abs(endPos.z - arenaCenter.z) + 3f); + PlaceArenaWalls(span); + startPos = BattleStaging.GuardianApproachStart(endPos, rotation, Vector3.Distance(endPos, endTarget), + arenaCenter, span, floorY); + return true; + } + + private void GuardianRoar(GameObject boss, Bounds bounds, Vector3 toTeam) + { + if (AudioManager.Instance != null) AudioManager.Instance.PlayCry(CryKey(BattleShout.Cry.Boss, false)); + CameraFollower follower = ResolveFollower(); + if (follower != null) follower.Shake(0.26f, 0.5f); + + float footprint = Mathf.Max(0.5f, Mathf.Max(bounds.extents.x, bounds.extents.z) * 0.7f); + // 받침대 위 — 발밑 먼지가 가장자리 너머로 흩날린다(화면 위쪽 창 안이라 배너에 안 가린다). + Vector3 top = new Vector3(bounds.center.x, bossGroundY + 0.05f, bounds.center.z); + StartCoroutine(DustBurstCoroutine(top, footprint, 12, 1.7f, 0.7f, 0.45f, 2246822519u)); + // 받침대 아래 — 땅이 울린다. + Transform rise = arenaRoot.transform.Find("BossRise"); + float riseRadius = rise != null ? Mathf.Max(rise.localScale.x, rise.localScale.z) * 0.5f : footprint + 1f; + Vector3 floor = new Vector3(bounds.center.x, arenaCenter.y + 0.08f, bounds.center.z); + StartCoroutine(DustBurstCoroutine(floor, riseRadius, 14, 2.2f, 0.8f, 0.55f, 3266489917u)); + StartCoroutine(GroundRingCoroutine(floor, riseRadius, riseRadius + 3.5f, 0.2f, DustColor, 0.6f, false)); + if (!BattlePresentation.ReducedFlashes) + StartCoroutine(RoarWaveCoroutine(HeadPoint(boss) + toTeam * 0.3f, + Mathf.Max(bounds.extents.x, bounds.extents.y))); + } + + private void FinishGuardianIntro(GuardianState st) + { + if (st.Boss != null && st.Boss == bossModel) SetPose(st.Boss, st.Rest); + ClearCameraShot(); + } + + /// 포효 파동 — 머리에서 카메라를 향한 고리 둘이 0.12초 간격으로 퍼진다(가산, 옅은 보랏빛 흰색). + private IEnumerator RoarWaveCoroutine(Vector3 point, float size) + { + if (arenaRoot == null) yield break; + Camera cam = Camera.main; + Vector3 toCam = cam != null ? (cam.transform.position - point).normalized : Vector3.back; + Color pale = new Color(0.92f, 0.88f, 1f); + Material mat = CreateFxMaterial(new Color(pale.r, pale.g, pale.b, 0f), true); + var rings = new LineRenderer[2]; + for (int i = 0; i < rings.Length; i++) + { + GameObject go = new GameObject("RoarWave"); + go.transform.SetParent(arenaRoot.transform, false); + go.transform.position = point; + go.transform.rotation = Quaternion.LookRotation(-toCam); + rings[i] = go.AddComponent(); + rings[i].useWorldSpace = false; + rings[i].loop = true; + rings[i].positionCount = 40; + rings[i].sharedMaterial = mat; + rings[i].shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + rings[i].receiveShadows = false; + rings[i].widthMultiplier = 0f; + } + const float Each = 0.5f; + const float Stagger = 0.12f; + float t = 0f; + while (t < Each + Stagger) + { + if (arenaRoot == null) yield break; + t += PresentationDelta; + float alpha = 0f; + for (int i = 0; i < rings.Length; i++) + { + if (rings[i] == null) continue; + float k = Mathf.Clamp01((t - i * Stagger) / Each); + if (k <= 0f) continue; + float radius = Mathf.Lerp(0.4f, size * 2.4f, 1f - (1f - k) * (1f - k)); + SetFacingRingRadius(rings[i], radius, Mathf.Lerp(0.14f, 0.01f, k)); + alpha = Mathf.Max(alpha, 0.6f * (1f - k)); + } + mat.color = new Color(pale.r, pale.g, pale.b, alpha); + yield return null; + } + for (int i = 0; i < rings.Length; i++) + if (rings[i] != null) Destroy(rings[i].gameObject); + } + + private static void SetFacingRingRadius(LineRenderer ring, float radius, float width) + { + ring.widthMultiplier = width; + for (int i = 0; i < ring.positionCount; i++) + { + float a = i * Mathf.PI * 2f / ring.positionCount; + ring.SetPosition(i, new Vector3(Mathf.Cos(a) * radius, Mathf.Sin(a) * radius, 0f)); + } + } + + // ───────────── 공용 연출 부품 ───────────── + + /// + /// 먼지 — 땅()에서 둥글게 퍼지며 조금 솟고 옅어진다. 반투명(알파 블렌드) 덩이 하나의 머티리얼을 함께 쓴다. + /// + private IEnumerator DustBurstCoroutine(Vector3 ground, float startRadius, int count, float spread, float duration, + float size, uint seed) + { + if (arenaRoot == null) yield break; + Material mat = CreateFxMaterial(DustColor, false); + var puffs = new GameObject[count]; + var dirs = new Vector3[count]; + var scales = new float[count]; + for (int i = 0; i < count; i++) + { + float a = (i + NextRand(ref seed) * 0.6f) * Mathf.PI * 2f / count; + dirs[i] = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)); + scales[i] = size * (0.75f + 0.5f * NextRand(ref seed)); + puffs[i] = FxPrimitive(PrimitiveType.Sphere, "Dust", ground + dirs[i] * startRadius, mat); + puffs[i].transform.localScale = Vector3.one * (scales[i] * 0.5f); + } + float t = 0f; + while (t < duration) + { + if (arenaRoot == null) yield break; + t += PresentationDelta; + float k = Mathf.Clamp01(t / duration); + float ease = 1f - (1f - k) * (1f - k); + for (int i = 0; i < count; i++) + { + if (puffs[i] == null) continue; + puffs[i].transform.position = ground + dirs[i] * (startRadius + spread * ease) + Vector3.up * (0.06f + 0.3f * ease); + puffs[i].transform.localScale = Vector3.one * (scales[i] * Mathf.Lerp(0.5f, 1.4f, ease)); + } + mat.color = new Color(DustColor.r, DustColor.g, DustColor.b, DustColor.a * Mathf.Pow(1f - k, 1.5f)); + yield return null; + } + for (int i = 0; i < count; i++) + if (puffs[i] != null) Destroy(puffs[i]); + } + + /// 땅 고리 — 바닥에 누운 고리가 에서 로 퍼지며 가늘어지고 사라진다. + private IEnumerator GroundRingCoroutine(Vector3 ground, float from, float to, float width, Color color, + float duration, bool additive) + { + if (arenaRoot == null) yield break; + Material mat = CreateFxMaterial(color, additive); + LineRenderer ring = CreateFloorRing("StagingRing", ground, mat); + float t = 0f; + while (t < duration) + { + if (arenaRoot == null || ring == null) yield break; + t += PresentationDelta; + float k = Mathf.Clamp01(t / duration); + float ease = 1f - (1f - k) * (1f - k); + SetRingRadius(ring, Mathf.Lerp(from, to, ease), Mathf.Lerp(width, 0.02f, k)); + mat.color = new Color(color.r, color.g, color.b, color.a * (1f - k)); + yield return null; + } + if (ring != null) Destroy(ring.gameObject); + } + + /// 착지 빛살 — 바닥을 따라 여덟 갈래로 짧게 뻗었다 사라진다(가산). 치명타 별 빛살(화면을 향한 넷)과 모양을 갈랐다. + private IEnumerator LightRaysCoroutine(Vector3 origin, Color color, float length) + { + if (arenaRoot == null) yield break; + Material mat = CreateFxMaterial(new Color(color.r, color.g, color.b, 0.9f), true); + const int Count = 8; + var rays = new GameObject[Count]; + var dirs = new Vector3[Count]; + for (int i = 0; i < Count; i++) + { + float a = (i + 0.5f) * Mathf.PI * 2f / Count; + dirs[i] = new Vector3(Mathf.Cos(a), 0.16f, Mathf.Sin(a)).normalized; + rays[i] = FxPrimitive(PrimitiveType.Cube, "LandingRay", origin, mat); + rays[i].transform.rotation = Quaternion.LookRotation(dirs[i]); + rays[i].transform.localScale = Vector3.zero; + } + const float Duration = 0.36f; + float t = 0f; + while (t < Duration) + { + if (arenaRoot == null) yield break; + t += PresentationDelta; + float k = Mathf.Clamp01(t / Duration); + float grow = 1f - (1f - Mathf.Clamp01(k / 0.35f)) * (1f - Mathf.Clamp01(k / 0.35f)); + float len = length * grow; + float thick = Mathf.Lerp(0.07f, 0.02f, k); + for (int i = 0; i < Count; i++) + { + if (rays[i] == null) continue; + rays[i].transform.position = origin + dirs[i] * (len * 0.5f + 0.15f); + rays[i].transform.localScale = new Vector3(thick, thick, len * ((i % 2) == 0 ? 1f : 0.65f)); + } + mat.color = new Color(color.r, color.g, color.b, 0.9f * (1f - k * k)); + yield return null; + } + for (int i = 0; i < Count; i++) + if (rays[i] != null) Destroy(rays[i]); + } + + // ───────────── ④ 그림자 모습 ───────────── + + /// + /// 그림자 모습을 입힌다 — 몸은 검보라 톤(PropertyBlock — 머티리얼을 새로 만들지 않는다), 부위마다 보랏빛 테두리(뒤집힌 껍질), + /// 눈·발광기관은 보랏빛으로 빛나고, 몸 둘레로 그림자 김이 피어오른다(). + /// (빌린 모습)면 더 덮는다 — 진짜 호랑나비·반딧불이와 확실히 갈리게. + /// + private void ApplyShadowLook(GameObject model, bool borrowed) + { + if (model == null) return; + MeshRenderer[] parts = model.GetComponentsInChildren(true); + for (int i = 0; i < parts.Length; i++) + if (parts[i] != null && IsLanternPart(parts[i].name)) shadowLanterns.Add(parts[i]); + AddShadowRims(parts); + AddShadowGlows(parts); + rendererCache.Remove(model); // 새로 단 빛 부위까지 캐시에 담기게(쓰러짐 페이드가 함께 지운다) + shadowStrength[model] = borrowed ? BattleStaging.ShadowBorrowedStrength : BattleStaging.ShadowOriginalStrength; + SetFlash(model, Color.white, 0f); // 쉬는 색 = 그림자 톤 + if (shadowAuraRoutine == null && isActiveAndEnabled) shadowAuraRoutine = StartCoroutine(ShadowAuraCoroutine()); + } + + private static bool IsStagingGlow(string partName) + { + return partName == RimGlowName || partName == EyeGlowName || partName == LanternGlowName; + } + + /// 그림자 빛 부위(테두리·눈빛·발광기관 빛)인가 — 머티리얼 참조로 가른다(이름을 읽지 않는다, 매 프레임 불린다). + private bool IsStagingGlow(MeshRenderer renderer) + { + Material mat = renderer.sharedMaterial; + return mat != null && (ReferenceEquals(mat, shadowRimMat) || ReferenceEquals(mat, shadowEyeMat) + || ReferenceEquals(mat, shadowLanternMat)); + } + + private static bool IsEyePart(string partName) + { + return partName.Contains("Eye") && !partName.Contains("Spot") && !partName.Contains("Glint") + && !partName.Contains("Highlight") && !IsStagingGlow(partName); + } + + /// 반딧불이 발광기관처럼 원래 빛나는 부위 — 어둡게 덮지 않고 보랏빛으로 바꾼다. + private static bool IsLanternPart(string partName) + { + return partName == "LightOrgan" || partName.StartsWith("GlowOuter") || partName.StartsWith("GlowPulse"); + } + + // 무늬·광택점·효과 부위는 테두리를 씌우지 않는다 — 몸 안쪽에 빛 줄이 생긴다. + private static bool SkipRim(string partName) + { + return partName.StartsWith("Spot") || partName.Contains("EyeSpot") || partName.Contains("Glint") + || partName.Contains("Highlight") || partName.StartsWith("Vein") || partName.Contains("Sparkle") + || partName.Contains("Aura") || partName.Contains("Ring") || partName.StartsWith("Stripe") + || partName.Contains("Marker") || IsEyePart(partName) || IsLanternPart(partName) || IsStagingGlow(partName); + } + + private static float RimScale(float size) + { + return Mathf.Clamp(1f + 2f * RimPad / Mathf.Max(0.001f, size), 1.02f, 1.8f); + } + + private Color ShadowRestColor(MeshRenderer renderer, Color color, float strength) + { + return shadowLanterns.Contains(renderer) ? BattleStaging.ShadowGlowTint(color) : BattleStaging.ShadowTint(color, strength); + } + + private Material ShadowRimMaterial() + { + if (shadowRimMat != null) return shadowRimMat; + Material mat = CreateFxMaterial(RimColor, true); + // 뒤집힌 껍질 — 몸보다 조금 큰 복제의 뒷면만 그린다. 몸의 앞면이 가운데를 가려 가장자리만 빛난다. + // 컬링이 속성인 셰이더(Hidden/Internal-Colored)에서만 — 대체 셰이더(Sprites/Default)는 양면이라 몸 전체가 뿌옇게 덮인다. + if (!mat.HasProperty("_Cull")) return null; + mat.SetInt("_Cull", (int)UnityEngine.Rendering.CullMode.Front); + shadowRimMat = mat; + return shadowRimMat; + } + + private void AddShadowRims(MeshRenderer[] parts) + { + Material rim = ShadowRimMaterial(); + if (rim == null) return; + for (int i = 0; i < parts.Length; i++) + { + MeshRenderer part = parts[i]; + if (part == null || SkipRim(part.name)) continue; + MeshFilter filter = part.GetComponent(); + if (filter == null || filter.sharedMesh == null) continue; + Material shared = part.sharedMaterial; + // 반투명 막(날개·오라)은 테두리를 씌우지 않는다 — 막 너머로 껍질 뒷면이 비쳐 날개 전체가 빛난다. + if (shared != null && shared.HasProperty("_Color") && shared.color.a < 0.95f) continue; + Bounds mesh = filter.sharedMesh.bounds; + Vector3 lossy = part.transform.lossyScale; + Vector3 size = new Vector3(Mathf.Abs(mesh.size.x * lossy.x), Mathf.Abs(mesh.size.y * lossy.y), + Mathf.Abs(mesh.size.z * lossy.z)); + if (Mathf.Max(size.x, Mathf.Max(size.y, size.z)) < RimMinPartSize) continue; + + // 축마다 월드 두께 RimPad만큼 키우고, 메시 중심을 축으로 키운다(축이 중심에서 벗어난 조각 메시도 고르게 두른다). + Vector3 scale = new Vector3(RimScale(size.x), RimScale(size.y), RimScale(size.z)); + var go = new GameObject(RimGlowName); + go.transform.SetParent(part.transform, false); + go.transform.localPosition = Vector3.Scale(mesh.center, Vector3.one - scale); + go.transform.localScale = scale; + go.AddComponent().sharedMesh = filter.sharedMesh; + MeshRenderer hull = go.AddComponent(); + hull.sharedMaterial = rim; + hull.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + hull.receiveShadows = false; + } + } + + private void AddShadowGlows(MeshRenderer[] parts) + { + for (int i = 0; i < parts.Length; i++) + { + MeshRenderer part = parts[i]; + if (part == null) continue; + bool eye = IsEyePart(part.name); + bool lantern = part.name == "LightOrgan"; + if (!eye && !lantern) continue; + if (eye && shadowEyeMat == null) shadowEyeMat = CreateFxMaterial(EyeGlowColor, true); + if (lantern && shadowLanternMat == null) shadowLanternMat = CreateFxMaterial(LanternGlowColor, true); + GameObject glow = FxPrimitive(PrimitiveType.Sphere, eye ? EyeGlowName : LanternGlowName, part.transform.position, + eye ? shadowEyeMat : shadowLanternMat); + glow.transform.SetParent(part.transform, false); + MeshFilter filter = part.GetComponent(); + glow.transform.localPosition = filter != null && filter.sharedMesh != null ? filter.sharedMesh.bounds.center : Vector3.zero; + glow.transform.localRotation = Quaternion.identity; + glow.transform.localScale = Vector3.one * (eye ? 1.75f : 1.3f); + } + } + + /// + /// 쓰러짐 직전 — 그림자 톤을 인스턴스 머티리얼에 굽고 PropertyBlock을 걷는다. 쓰러짐 페이드는 머티리얼 색의 알파를 + /// 내리는데 PropertyBlock의 색이 그걸 덮어 그림자 보스만 투명해지지 않았다. + /// + private void BakeShadowTint(GameObject model) + { + if (model == null || !shadowStrength.TryGetValue(model, out float strength)) return; + MeshRenderer[] renderers = GetRenderersCached(model); + for (int i = 0; i < renderers.Length; i++) + { + MeshRenderer r = renderers[i]; + if (r == null || IsStagingGlow(r)) continue; + Material shared = r.sharedMaterial; + if (shared == null || !shared.HasProperty("_Color")) continue; + Color rest = ShadowRestColor(r, shared.color, strength); + r.SetPropertyBlock(null); + r.material.color = rest; // 쓰러짐이 어차피 인스턴스를 쓴다(PlayFaintCoroutine) + } + shadowStrength.Remove(model); + } + + /// + /// 그림자 김과 숨결 — 보스 몸 둘레에서 검보라 김이 피어올라 흩어지고(여섯 줄기, 1.8초 주기), 테두리·눈빛이 천천히 맥동한다. + /// 변신 직후엔 눈이 번쩍인다(). 보스가 쓰러지면(그림자 톤을 구웠다) 새 김을 피우지 않는다. + /// + private IEnumerator ShadowAuraCoroutine() + { + const int WispCount = 6; + const float WispLife = 1.8f; + Material wispMat = CreateFxMaterial(WispColor, false); + var wisps = new MeshRenderer[WispCount]; + var age = new float[WispCount]; + var offset = new Vector3[WispCount]; + var size = new float[WispCount]; + for (int i = 0; i < WispCount; i++) + { + GameObject go = FxPrimitive(PrimitiveType.Sphere, "ShadowWisp", arenaCenter, wispMat); + go.transform.localScale = Vector3.zero; + wisps[i] = go.GetComponent(); + age[i] = WispLife - i * (WispLife / WispCount); // 첫 줄기는 곧바로, 나머지는 0.3초 간격으로 흩어 피운다 + } + + GameObject measured = null; + Vector3 centerOffset = Vector3.zero; + Vector3 extents = Vector3.one * 0.5f; + uint seed = 97531u; + float t = 0f; + while (arenaRoot != null) + { + float dt = PresentationDelta; + t += dt; + GameObject boss = bossModel; + bool live = boss != null && boss.activeInHierarchy && shadowStrength.ContainsKey(boss); + if (live && boss != measured) + { + measured = boss; + Bounds b = BattleFraming.ModelBounds(boss); + centerOffset = b.center - boss.transform.position; + extents = b.extents; + } + + shadowEyeBoost = Mathf.Max(0f, shadowEyeBoost - dt * 1.4f); + float breath = 0.5f + 0.5f * Mathf.Sin(t * 2.3f); + if (shadowRimMat != null) + shadowRimMat.color = new Color(RimColor.r, RimColor.g, RimColor.b, + Mathf.Clamp01(Mathf.Lerp(0.22f, 0.38f, breath) + 0.35f * shadowEyeBoost)); + if (shadowEyeMat != null) + shadowEyeMat.color = new Color(EyeGlowColor.r, EyeGlowColor.g, EyeGlowColor.b, + Mathf.Clamp01(Mathf.Lerp(0.55f, 0.85f, breath) + 0.4f * shadowEyeBoost)); + if (shadowLanternMat != null) + shadowLanternMat.color = new Color(LanternGlowColor.r, LanternGlowColor.g, LanternGlowColor.b, + Mathf.Clamp01(Mathf.Lerp(0.3f, 0.5f, breath) + 0.3f * shadowEyeBoost)); + + for (int i = 0; i < WispCount; i++) + { + MeshRenderer wisp = wisps[i]; + if (wisp == null) continue; + age[i] += dt; + if (age[i] >= WispLife && live) + { + age[i] = 0f; + float a = NextRand(ref seed) * Mathf.PI * 2f; + float r = Mathf.Max(extents.x, extents.z) * Mathf.Lerp(0.45f, 0.85f, NextRand(ref seed)); + offset[i] = centerOffset + new Vector3(Mathf.Cos(a) * r, + extents.y * Mathf.Lerp(-0.6f, 0.35f, NextRand(ref seed)), Mathf.Sin(a) * r); + size[i] = Mathf.Max(extents.x, extents.y) * Mathf.Lerp(0.22f, 0.36f, NextRand(ref seed)); + } + if (age[i] < 0f || age[i] >= WispLife || boss == null || size[i] <= 0f) + { + wisp.transform.localScale = Vector3.zero; + continue; + } + float k = age[i] / WispLife; + wisp.transform.position = boss.transform.position + offset[i] + Vector3.up * (0.9f * k); + wisp.transform.localScale = Vector3.one * (size[i] * (0.5f + 0.8f * k)); + MaterialPropertyBlock block = StagingBlock(); + block.SetColor("_Color", new Color(WispColor.r, WispColor.g, WispColor.b, WispColor.a * Mathf.Sin(k * Mathf.PI))); + wisp.SetPropertyBlock(block); + } + yield return null; + } + shadowAuraRoutine = null; + } + + // ───────────── 구도 보조 ───────────── + + /// 안전 영역의 뷰포트 사각형(노치·제스처바를 뺀 0~1 좌표). + private static Rect SafeViewport() + { + Rect safe = Screen.safeArea; + return safe.width > 0f && safe.height > 0f + ? new Rect(safe.x / Mathf.Max(1, Screen.width), safe.y / Mathf.Max(1, Screen.height), + safe.width / Mathf.Max(1, Screen.width), safe.height / Mathf.Max(1, Screen.height)) + : new Rect(0f, 0f, 1f, 1f); + } + + /// 지금 경계벽까지의 거리 — 구도가 벽을 밀어냈으면 그 값. + private float CurrentWallSpan() + { + Transform wall = arenaRoot != null ? arenaRoot.transform.Find("WallN") : null; + return wall != null ? Mathf.Abs(wall.localPosition.z) : ArenaWallSpan; + } + + /// 경계벽을 에 세운다 — 카메라가 벽 밖에 서면 벽의 바깥 면이 화면을 막는다( 주석). + private void PlaceArenaWalls(float span) + { + if (arenaRoot == null) return; + foreach (string wallName in new[] { "WallN", "WallS", "WallE", "WallW" }) + { + Transform wall = arenaRoot.transform.Find(wallName); + if (wall == null) continue; + bool horizontal = wallName == "WallN" || wallName == "WallS"; + float sign = wallName == "WallN" || wallName == "WallE" ? 1f : -1f; + wall.localPosition = horizontal ? new Vector3(0f, ArenaWallHeight * 0.5f, span * sign) + : new Vector3(span * sign, ArenaWallHeight * 0.5f, 0f); + wall.localScale = horizontal ? new Vector3(span * 2f, ArenaWallHeight, 0.4f) + : new Vector3(0.4f, ArenaWallHeight, span * 2f); + } + } + } +} diff --git a/Assets/Scripts/Battle/BattleArenaController.Staging.cs.meta b/Assets/Scripts/Battle/BattleArenaController.Staging.cs.meta new file mode 100644 index 0000000..61d25be --- /dev/null +++ b/Assets/Scripts/Battle/BattleArenaController.Staging.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f755924629fbbdd408cb415bb54f8532 \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleArenaController.cs b/Assets/Scripts/Battle/BattleArenaController.cs index c0cda5d..3f43a00 100644 --- a/Assets/Scripts/Battle/BattleArenaController.cs +++ b/Assets/Scripts/Battle/BattleArenaController.cs @@ -1,4 +1,4 @@ -using System.Collections; +using System.Collections; using System.Collections.Generic; using InsectGame.Core; using InsectGame.Data; @@ -7,7 +7,7 @@ namespace InsectGame.Battle { - public class BattleArenaController : MonoBehaviour + public partial class BattleArenaController : MonoBehaviour { private GameObject arenaRoot; private GameObject playerModel; @@ -21,8 +21,13 @@ public class BattleArenaController : MonoBehaviour private Vector3 playerBattlePos; private Vector3 enemyBattlePos; private Vector3 bossBattlePos; + private float bossGroundY; private bool isActive; + private float framingAspect; + private float framingFov; + private Rect framingSafeArea; + private float PresentationDelta => BattlePresentation.DeltaTime; private CameraFollower cachedCameraFollower; private int selectedTeamIndex = -1; @@ -78,9 +83,9 @@ public void SetupNormalBattle(InsectData playerInsect, int playerLevel, // 아레나를 필드와 완전 분리된 위치에 생성 (필드 오브젝트와 겹침 방지) arenaCenter = new Vector3(1000f, 0f, 1000f); - // 플레이어 앞(가까이), 적 뒤(멀리) — 깊이감 있는 대결 구도 - playerBattlePos = arenaCenter + new Vector3(-1.2f, 0.5f, -2.5f); - enemyBattlePos = arenaCenter + new Vector3(1.2f, 0.5f, 2.5f); + // 화면 왼쪽 아군 / 오른쪽 적. 깊이 차는 실루엣을 읽을 만큼만 둔다. + playerBattlePos = arenaCenter + new Vector3(-2.1f, 0.5f, -0.35f); + enemyBattlePos = arenaCenter + new Vector3(2.1f, 0.5f, 0.35f); arenaRoot = new GameObject("BattleArena"); arenaRoot.transform.position = arenaCenter; @@ -97,6 +102,7 @@ public void SetupNormalBattle(InsectData playerInsect, int playerLevel, // 서로 마주보게 playerModel.transform.LookAt(enemyBattlePos); enemyModel.transform.LookAt(playerBattlePos); + ArrangeNormalCombatants(); // 카메라를 아레나 정면으로 이동 SetupBattleCamera(); @@ -107,6 +113,28 @@ public void SetupNormalBattle(InsectData playerInsect, int playerLevel, public void SetupRaidBattle(InsectData bossInsect, int bossLevel, bool bossShiny, InsectData[] teamInsects, int[] teamLevels, Vector3 worldBossPos) + { + SetupRaidBattle(bossInsect, bossLevel, bossShiny, teamInsects, teamLevels, worldBossPos, null); + } + + /// + /// 레이드를 시작할 때 이미 기절해 있던 슬롯(true) — 그 모델은 세우지 않는다(HideMembersDownAtStart). + /// null이면 전원을 세운다(옛 동작). + /// + public void SetupRaidBattle(InsectData bossInsect, int bossLevel, bool bossShiny, + InsectData[] teamInsects, int[] teamLevels, + Vector3 worldBossPos, bool[] teamDown) + { + SetupRaidBattle(bossInsect, bossLevel, bossShiny, teamInsects, teamLevels, worldBossPos, teamDown, null); + } + + /// + /// 수문장 레이드면 그 리전 ID(RaidBattleController.BossGuardianRegionId) — 인트로 동안 등장 컷을 건다 + /// (, 길이 ). 비면 없다. + /// + public void SetupRaidBattle(InsectData bossInsect, int bossLevel, bool bossShiny, + InsectData[] teamInsects, int[] teamLevels, + Vector3 worldBossPos, bool[] teamDown, string guardianRegionId) { CleanupArena(); @@ -131,7 +159,11 @@ public void SetupRaidBattle(InsectData bossInsect, int bossLevel, bool bossShiny bossModel.name = "RaidInsect_Boss"; bossModel.transform.rotation = Quaternion.Euler(0f, 180f, 0f); + CreateBossRise(); CreateBossAura(bossModel.transform); + // 모습을 빌리는 보스(이름 없는 사마귀)는 「그림자」다 — 원래 모습부터 검보라 톤·테두리·눈빛을 입는다(Staging partial). + shadowBossId = BattleStaging.IsShadowBoss(bossInsect.insectId) ? bossInsect.insectId : null; + if (shadowBossId != null) ApplyShadowLook(bossModel, false); int count = teamInsects != null ? teamInsects.Length : 0; teamModels = new GameObject[count]; @@ -151,7 +183,11 @@ public void SetupRaidBattle(InsectData bossInsect, int bossLevel, bool bossShiny } SetupBattleCamera(); + HideMembersDownAtStart(teamDown); // 구도를 잡은 뒤에 — 기절 수에 따라 구도가 흔들리지 않게 isActive = true; + // 구도(기본 카메라)를 잡은 뒤에 — 등장 컷·진입 샷은 그 구도로 돌아온다. 수문장은 등장 컷이 진입 샷을 대신한다. + if (!string.IsNullOrEmpty(guardianRegionId)) PlayGuardianIntro(); + else PlayBattleOpening(); } private GameObject CreateBattleInsect(InsectData insectData, int level, bool isShiny, Vector3 position, float scale) @@ -171,6 +207,7 @@ private GameObject CreateBattleInsect(InsectData insectData, int level, bool isS public void RebuildPlayerInsect(InsectData newInsect, int newLevel) { if (!isActive || arenaRoot == null || newInsect == null) return; + CompleteStagingNow(); // 상대 등장이 덜 끝났으면 끝 자세로 — 아래 배치가 그 자리를 기준으로 잡는다 if (playerModel != null) { @@ -185,59 +222,81 @@ public void RebuildPlayerInsect(InsectData newInsect, int newLevel) playerModel.transform.LookAt(enemyModel.transform.position); else playerModel.transform.LookAt(enemyBattlePos); + if (bossModel == null) ArrangeNormalCombatants(); + SetupBattleCamera(); + } + + /// + /// 팀 대결에서 상대가 다음 곤충을 내보낼 때 — 상대 모델을 새 곤충으로 다시 세운다(기능만). + /// 첫 상대를 세운 경로( → 마주보기 → ArrangeNormalCombatants → 카메라)를 그대로 탄다. + /// 쓰러진 모델(쓰러짐 연출 끝에 꺼져 있다)은 버린다. 등장 연출·파티클은 없다 — visual-dev가 붙인다 + /// (부르는 쪽: BattleScreenUI의 교체 단계 시작). + /// + public void RebuildEnemyInsect(InsectData newInsect, int newLevel, bool shiny) + { + if (!isActive || arenaRoot == null || newInsect == null) return; + + if (enemyModel != null) + { + ForgetModel(enemyModel); + Destroy(enemyModel); + enemyModel = null; + } + + enemyModel = CreateBattleInsect(newInsect, newLevel, shiny, enemyBattlePos, BattleInsectScale); + enemyModel.name = "BattleInsect_Enemy"; + if (playerModel != null) + { + playerModel.transform.LookAt(enemyBattlePos); + enemyModel.transform.LookAt(playerModel.transform.position); + } + else + { + enemyModel.transform.LookAt(playerBattlePos); + } + if (bossModel == null) ArrangeNormalCombatants(); + SetupBattleCamera(); + } + + /// + /// 모델을 갈아끼우기 전에 그 모델에 걸린 기록(반응 코루틴·원위치·렌더러 캐시)을 버린다 — + /// 남으면 파기된 오브젝트를 키로 쥔 채 반응이 끝날 때 원위치로 되돌리려 한다. + /// + private void ForgetModel(GameObject model) + { + if (model == null) return; + if (activeReacts.TryGetValue(model, out Coroutine running) && running != null) StopCoroutine(running); + activeReacts.Remove(model); + reactRestPoses.Remove(model); + rendererCache.Remove(model); + shadowStrength.Remove(model); + ForgetLife(model); // 숨쉬기·상태 표시(Life partial) + } + + private void ArrangeNormalCombatants() + { + if (playerModel == null || enemyModel == null) return; + float half = BattleFraming.DuelHalfSeparation(BattleFraming.ModelBounds(playerModel), + BattleFraming.ModelBounds(enemyModel)); + playerBattlePos.x = arenaCenter.x - half; + enemyBattlePos.x = arenaCenter.x + half; + playerModel.transform.position = playerBattlePos; + enemyModel.transform.position = enemyBattlePos; + playerModel.transform.LookAt(enemyBattlePos); + enemyModel.transform.LookAt(playerBattlePos); } private void CreateArenaFloor() { - arenaFloor = GameObject.CreatePrimitive(PrimitiveType.Cylinder); - arenaFloor.name = "ArenaFloor"; - arenaFloor.transform.SetParent(arenaRoot.transform, false); - arenaFloor.transform.localPosition = new Vector3(0f, -0.1f, 0f); - arenaFloor.transform.localScale = new Vector3(8f, 0.1f, 8f); - - Material floorMat = CreateSafeMaterial(new Color(0.15f, 0.2f, 0.12f)); - arenaFloor.GetComponent().material = floorMat; - - for (int i = 0; i < 24; i++) - { - float angle = i * Mathf.PI * 2f / 24f; - float r = 3.8f; - GameObject stone = GameObject.CreatePrimitive(PrimitiveType.Sphere); - stone.name = $"ArenaStone_{i}"; - stone.transform.SetParent(arenaRoot.transform, false); - stone.transform.localPosition = new Vector3(Mathf.Cos(angle) * r, 0.1f, Mathf.Sin(angle) * r); - stone.transform.localScale = new Vector3(0.4f, 0.25f, 0.4f); - Material stoneMat = CreateSafeMaterial(new Color(0.4f, 0.4f, 0.38f)); - stone.GetComponent().material = stoneMat; - Object.Destroy(stone.GetComponent()); - } - - GameObject centerLine = GameObject.CreatePrimitive(PrimitiveType.Cube); - centerLine.name = "CenterLine"; - centerLine.transform.SetParent(arenaRoot.transform, false); - centerLine.transform.localPosition = new Vector3(0f, 0.01f, 0f); - centerLine.transform.localScale = new Vector3(6f, 0.01f, 0.1f); - Material lineMat = CreateSafeMaterial(new Color(0.8f, 0.8f, 0.3f, 0.5f)); - centerLine.GetComponent().material = lineMat; - Object.Destroy(centerLine.GetComponent()); - - // 경기장 바깥 지면 — 밝은 원판(반지름 4)과 경계벽 사이의 빈 공간을 메운다. - // 이게 없으면 카메라를 뒤로 물린 만큼 원판 밖으로 필드가 비쳐 보인다. - GameObject outerGround = GameObject.CreatePrimitive(PrimitiveType.Cylinder); - outerGround.name = "ArenaOuterGround"; - outerGround.transform.SetParent(arenaRoot.transform, false); - outerGround.transform.localPosition = new Vector3(0f, -0.2f, 0f); - outerGround.transform.localScale = new Vector3(ArenaWallSpan * 2.2f, 0.1f, ArenaWallSpan * 2.2f); - outerGround.GetComponent().material = - CreateSafeMaterial(new Color(0.07f, 0.10f, 0.06f)); - Object.Destroy(outerGround.GetComponent()); + ForestBattleSet.Build(arenaRoot.transform, CreateSafeMaterial, runtimeMeshes); + arenaFloor = arenaRoot.transform.Find("ForestClearing").gameObject; // 아레나 경계벽 — 필드가 보이지 않도록 차단. // **카메라보다 멀리** 세운다: 전투 카메라는 1v1이 z≈-6.5, 레이드가 z≈-9.5로 아레나 뒤에 // 서는데 예전 벽은 ±5에 있었다. 그래서 카메라가 남쪽 벽의 **바깥 면**을 정면으로 마주 봐 // 화면이 통째로 벽에 막혔다 — 레이드에서 곤충이 하나도 보이지 않던 원인이다. // 벽을 옮길 때는 `RaidCamBackDistance`/1v1 카메라 오프셋보다 크게 유지할 것. - Material wallMat = CreateSafeMaterial(new Color(0.2f, 0.35f, 0.18f)); + Material wallMat = CreateSafeMaterial(new Color(0.105f, 0.17f, 0.16f)); string[] wallNames = { "WallN", "WallS", "WallE", "WallW" }; float span = ArenaWallSpan; float thick = 0.4f; @@ -276,32 +335,72 @@ private void CreateArenaFloor() private void CreateBattleLight() { - arenaLight = new GameObject("BattleLight"); + // Local lights cannot relight the exploration scene across the map. + arenaLight = new GameObject("BattleLightRig"); arenaLight.transform.SetParent(arenaRoot.transform, false); - arenaLight.transform.localPosition = new Vector3(0f, 8f, 0f); - arenaLight.transform.rotation = Quaternion.Euler(50f, 0f, 0f); - Light light = arenaLight.AddComponent(); - light.type = LightType.Directional; - light.color = new Color(1f, 0.95f, 0.85f); - light.intensity = 1.2f; + CreateArenaSpot("WarmKey", new Vector3(-3.5f, 7f, -4f), new Color(1f, .88f, .68f), 2.4f, 18f, 105f, true); + CreateArenaSpot("CoolFill", new Vector3(4f, 5f, -1f), new Color(.62f, .77f, 1f), 1.25f, 17f, 100f, false); + CreateArenaSpot("LeafRim", new Vector3(0f, 6f, 5f), new Color(.76f, .95f, .85f), 1.7f, 15f, 90f, false); + } + + private void CreateArenaSpot(string name, Vector3 position, Color color, float intensity, + float range, float angle, bool shadows) + { + var go = new GameObject(name); + go.transform.SetParent(arenaLight.transform, false); + go.transform.localPosition = position; + go.transform.localRotation = Quaternion.LookRotation(new Vector3(0f, .65f, 0f) - position); + Light light = go.AddComponent(); + light.type = LightType.Spot; + light.color = color; + light.intensity = intensity; + light.range = range; + light.spotAngle = angle; + light.innerSpotAngle = angle * .65f; + light.shadows = shadows ? LightShadows.Soft : LightShadows.None; + light.shadowStrength = .6f; + light.shadowBias = .035f; + } + + private void CreateBossRise() + { + // The raised boss framing needs visible support under a grounded species. + Bounds bounds = BattleFraming.ModelBounds(bossModel); + float height = Mathf.Max(0.1f, bounds.min.y - arenaCenter.y); + GameObject rise = GameObject.CreatePrimitive(PrimitiveType.Cylinder); + rise.name = "BossRise"; + rise.transform.SetParent(arenaRoot.transform, false); + rise.transform.position = new Vector3(bossBattlePos.x, arenaCenter.y + height * 0.5f, bossBattlePos.z); + rise.transform.localScale = new Vector3(Mathf.Max(3.2f, bounds.size.x + 0.65f), height * 0.5f, + Mathf.Max(3.2f, bounds.size.z + 0.65f)); + rise.GetComponent().sharedMaterial = CreateSafeMaterial(new Color(0.46f, 0.43f, 0.34f)); + Destroy(rise.GetComponent()); + bossGroundY = arenaCenter.y + height + 0.035f; } private void CreateBossAura(Transform parent) { - GameObject aura = GameObject.CreatePrimitive(PrimitiveType.Sphere); - aura.name = "BossAura"; + // An enclosing transparent sphere can render opaque in a player build + // when its blending variant is unavailable, hiding the entire boss. + // A ground marker remains readable with the ordinary opaque material. + GameObject aura = new GameObject("BossAura"); aura.transform.SetParent(parent, false); - aura.transform.localPosition = Vector3.zero; - aura.transform.localScale = Vector3.one * 2.5f; - Material auraMat = CreateSafeMaterial(new Color(0.9f, 0.2f, 0.1f, 0.08f)); - auraMat.SetFloat("_Mode", 3); - auraMat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha); - auraMat.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); - auraMat.SetInt("_ZWrite", 0); - auraMat.EnableKeyword("_ALPHABLEND_ON"); - auraMat.renderQueue = 3000; - aura.GetComponent().material = auraMat; - Object.Destroy(aura.GetComponent()); + var ring = aura.AddComponent(); + ring.useWorldSpace = false; + ring.loop = true; + ring.positionCount = 48; + ring.widthMultiplier = 0.035f; + ring.generateLightingData = true; + ring.sharedMaterial = CreateSafeMaterial(new Color(0.94f, 0.43f, 0.25f)); + ring.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + ring.receiveShadows = false; + float ground = (Mathf.Max(arenaCenter.y + 0.055f, bossGroundY) - parent.position.y) + / Mathf.Max(0.01f, parent.lossyScale.y); + for (int i = 0; i < ring.positionCount; i++) + { + float angle = i * Mathf.PI * 2f / ring.positionCount; + ring.SetPosition(i, new Vector3(Mathf.Cos(angle) * 0.78f, ground, Mathf.Sin(angle) * 0.78f)); + } } public void PlayAttackAnimation(bool isPlayerAttacking, System.Action onImpact = null) @@ -326,7 +425,7 @@ private IEnumerator AttackCoroutine(GameObject attacker, GameObject target, Syst float t = 0f; while (t < 0.3f) { - t += Time.deltaTime; + t += PresentationDelta; float progress = t / 0.3f; float eased = progress * progress * (3f - 2f * progress); Vector3 pos = Vector3.Lerp(startPos, targetPos, eased * 0.95f); @@ -346,7 +445,7 @@ private IEnumerator AttackCoroutine(GameObject attacker, GameObject target, Syst Vector3 contactPos = Vector3.Lerp(startPos, targetPos, 0.95f); while (t < 0.3f) { - t += Time.deltaTime; + t += PresentationDelta; float progress = t / 0.3f; attacker.transform.position = Vector3.Lerp(contactPos, startPos, progress); yield return null; @@ -357,11 +456,12 @@ private IEnumerator AttackCoroutine(GameObject attacker, GameObject target, Syst private IEnumerator ShakeModel(GameObject model, float duration, float magnitude) { + if (BattlePresentation.ReducedMotion || model == null) yield break; Vector3 original = model.transform.position; float t = 0f; while (t < duration) { - t += Time.deltaTime; + t += PresentationDelta; float x = Random.Range(-1f, 1f) * magnitude * (1f - t / duration); float z = Random.Range(-1f, 1f) * magnitude * (1f - t / duration); model.transform.position = original + new Vector3(x, 0f, z); @@ -402,7 +502,7 @@ private IEnumerator ImpactEffectCoroutine(Vector3 position) float t = 0f; while (t < 0.5f) { - t += Time.deltaTime; + t += PresentationDelta; float progress = t / 0.5f; float ringSize = 0.5f + progress * 4f; @@ -427,370 +527,6 @@ private IEnumerator ImpactEffectCoroutine(Vector3 position) if (s != null) Destroy(s); } - public void PlayUniteAttackAnimation(System.Action onComplete) - { - StartCoroutine(UniteAttackCoroutine(onComplete)); - } - - private IEnumerator UniteAttackCoroutine(System.Action onComplete) - { - if (teamModels == null || bossModel == null) { onComplete?.Invoke(); yield break; } - - Vector3 bossPos = bossModel.transform.position; - Vector3[] starts = new Vector3[teamModels.Length]; - bool[] active = new bool[teamModels.Length]; - for (int i = 0; i < teamModels.Length; i++) - { - if (teamModels[i] == null) continue; - starts[i] = teamModels[i].transform.position; - active[i] = true; - } - - // 모든 팀원이 같은 타임라인에서 동시에 돌진한다. 이전 구현은 멤버마다 - // yield한 뒤 복귀해 합체공격이 사실상 5연속 단일 공격처럼 보였다. - playingSkill = true; - const float rushDuration = 0.42f; - float t = 0f; - while (t < rushDuration) - { - t += Time.deltaTime; - float progress = Mathf.Clamp01(t / rushDuration); - float eased = progress * progress * (3f - 2f * progress); - for (int i = 0; i < teamModels.Length; i++) - { - if (!active[i] || teamModels[i] == null) continue; - float spread = (i - (teamModels.Length - 1) * 0.5f) * 0.18f; - Vector3 target = bossPos + Vector3.right * spread; - Vector3 pos = Vector3.Lerp(starts[i], target, eased * 0.78f); - pos.y += Mathf.Sin(eased * Mathf.PI) * 0.5f; - teamModels[i].transform.position = pos; - } - yield return null; - } - - CreateBigExplosion(bossPos); - StartCoroutine(ShakeModel(bossModel, 0.45f, 0.5f)); - if (cachedCameraFollower == null && Camera.main != null) - cachedCameraFollower = Camera.main.GetComponent(); - if (cachedCameraFollower != null) - cachedCameraFollower.Shake(0.55f, 0.55f); - - const float returnDuration = 0.28f; - t = 0f; - Vector3[] contacts = new Vector3[teamModels.Length]; - for (int i = 0; i < teamModels.Length; i++) - if (active[i] && teamModels[i] != null) - contacts[i] = teamModels[i].transform.position; - while (t < returnDuration) - { - t += Time.deltaTime; - float progress = Mathf.Clamp01(t / returnDuration); - for (int i = 0; i < teamModels.Length; i++) - { - if (!active[i] || teamModels[i] == null) continue; - teamModels[i].transform.position = Vector3.Lerp(contacts[i], starts[i], progress); - } - yield return null; - } - - for (int i = 0; i < teamModels.Length; i++) - if (active[i] && teamModels[i] != null) - teamModels[i].transform.position = starts[i]; - - playingSkill = false; - onComplete?.Invoke(); - } - - /// - /// 레이드 팀 행동을 같은 타임라인에 겹쳐 재생한다. 슬롯과 속성 배열은 같은 길이여야 하며, - /// 사망/누락 모델은 자동으로 건너뛴다. - /// - public void PlayRaidVolley( - int[] slots, - InsectElement[] elements, - System.Action onComplete) - { - StartCoroutine(RaidVolleyCoroutine(slots, elements, onComplete)); - } - - private IEnumerator RaidVolleyCoroutine( - int[] slots, - InsectElement[] elements, - System.Action onComplete) - { - int count = slots != null ? slots.Length : 0; - if (!isActive || bossModel == null || teamModels == null || count == 0) - { - onComplete?.Invoke(); - yield break; - } - - GameObject[] attackers = new GameObject[count]; - Vector3[] starts = new Vector3[count]; - Vector3[] contacts = new Vector3[count]; - GameObject[] projectiles = new GameObject[count]; - bool[] melee = new bool[count]; - bool[] valid = new bool[count]; - bool[] impacted = new bool[count]; - Vector3 bossPos = bossModel.transform.position; - int validCount = 0; - - for (int i = 0; i < count; i++) - { - int slot = slots[i]; - if (slot < 0 || slot >= teamModels.Length || teamModels[slot] == null) - continue; - - attackers[i] = teamModels[slot]; - starts[i] = attackers[i].transform.position; - InsectElement element = elements != null && i < elements.Length - ? elements[i] - : InsectElement.Bug; - melee[i] = IsMeleeElement(element); - valid[i] = true; - validCount++; - - if (!melee[i]) - { - projectiles[i] = CreateElementProjectile(element, GetElementColor3D(element)); - projectiles[i].transform.position = starts[i] + Vector3.up * 0.5f; - } - } - - if (validCount == 0) - { - onComplete?.Invoke(); - yield break; - } - - playingSkill = true; - if (AudioManager.Instance != null) - AudioManager.Instance.PlaySFX(SfxType.SkillUse); - - // 0.035초의 짧은 스태거만 두어 충돌음은 연속으로 들리되, 한 마리가 복귀한 뒤 - // 다음 멤버가 출발하는 직렬 연출은 만들지 않는다. - const float stagger = 0.035f; - const float actionDuration = 0.46f; - float totalDuration = actionDuration + Mathf.Max(0, count - 1) * stagger; - float timer = 0f; - while (timer < totalDuration) - { - timer += Time.deltaTime; - for (int i = 0; i < count; i++) - { - if (!valid[i] || attackers[i] == null) continue; - float local = Mathf.Clamp01((timer - i * stagger) / actionDuration); - if (local <= 0f) continue; - - int slot = slots[i]; - float spread = (slot - (teamModels.Length - 1) * 0.5f) * 0.16f; - Vector3 target = bossPos + Vector3.right * spread + Vector3.up * 0.15f; - float eased = local * local * (3f - 2f * local); - InsectElement element = elements != null && i < elements.Length - ? elements[i] - : InsectElement.Bug; - Color elementColor = GetElementColor3D(element); - - if (melee[i]) - { - Vector3 pos = Vector3.Lerp(starts[i], target, eased * 0.78f); - pos.y += Mathf.Sin(eased * Mathf.PI) * 0.65f; - attackers[i].transform.position = pos; - CreateTrailParticle(pos, elementColor, 0.08f); - } - else if (projectiles[i] != null) - { - Vector3 start = starts[i] + Vector3.up * 0.5f; - Vector3 pos = Vector3.Lerp(start, target, eased); - pos += GetElementTrajectoryOffset(element, local); - projectiles[i].transform.position = pos; - projectiles[i].transform.Rotate(Vector3.up, 720f * Time.deltaTime); - CreateTrailParticle(pos, elementColor, 0.07f); - } - - if (!impacted[i] && local >= 0.98f) - { - impacted[i] = true; - contacts[i] = attackers[i].transform.position; - if (projectiles[i] != null) - { - Destroy(projectiles[i]); - projectiles[i] = null; - } - CreateElementImpact3D(target, element, elementColor); - } - } - yield return null; - } - - StartCoroutine(ShakeModel(bossModel, 0.42f, 0.42f)); - if (cachedCameraFollower == null && Camera.main != null) - cachedCameraFollower = Camera.main.GetComponent(); - if (cachedCameraFollower != null) - cachedCameraFollower.Shake(0.38f, 0.42f); - if (AudioManager.Instance != null) - AudioManager.Instance.PlaySFX(SfxType.Hit); - - const float returnDuration = 0.26f; - timer = 0f; - while (timer < returnDuration) - { - timer += Time.deltaTime; - float progress = Mathf.Clamp01(timer / returnDuration); - for (int i = 0; i < count; i++) - { - if (!valid[i] || !melee[i] || attackers[i] == null) continue; - attackers[i].transform.position = Vector3.Lerp(contacts[i], starts[i], progress); - } - yield return null; - } - - for (int i = 0; i < count; i++) - { - if (projectiles[i] != null) Destroy(projectiles[i]); - if (valid[i] && attackers[i] != null) - attackers[i].transform.position = starts[i]; - } - - playingSkill = false; - onComplete?.Invoke(); - } - - /// - /// 레이드 보스의 예고된 단일/전체 공격을 실제 대상 모델에 동시 재생한다. - /// - public void PlayRaidBossAttack( - InsectElement element, - bool isAoe, - int targetSlot, - System.Action onComplete) - { - StartCoroutine(RaidBossAttackCoroutine(element, isAoe, targetSlot, onComplete)); - } - - private IEnumerator RaidBossAttackCoroutine( - InsectElement element, - bool isAoe, - int targetSlot, - System.Action onComplete) - { - if (!isActive || bossModel == null || teamModels == null) - { - onComplete?.Invoke(); - yield break; - } - - List targets = new List(); - if (isAoe) - { - for (int i = 0; i < teamModels.Length; i++) - if (teamModels[i] != null) - targets.Add(teamModels[i]); - } - else if (targetSlot >= 0 && targetSlot < teamModels.Length && teamModels[targetSlot] != null) - { - targets.Add(teamModels[targetSlot]); - } - else - { - GameObject fallback = ResolveBossTarget(); - if (fallback != null) targets.Add(fallback); - } - - if (targets.Count == 0) - { - onComplete?.Invoke(); - yield break; - } - - playingSkill = true; - if (AudioManager.Instance != null) - AudioManager.Instance.PlaySkillSFX(element); - - Vector3 bossStart = bossModel.transform.position; - Vector3 targetCenter = Vector3.zero; - for (int i = 0; i < targets.Count; i++) - targetCenter += targets[i].transform.position; - targetCenter /= targets.Count; - - bool meleeAttack = IsMeleeElement(element); - GameObject[] projectiles = meleeAttack ? null : new GameObject[targets.Count]; - if (!meleeAttack) - { - Color color = GetElementColor3D(element); - for (int i = 0; i < targets.Count; i++) - { - projectiles[i] = CreateElementProjectile(element, color); - projectiles[i].transform.position = bossStart + Vector3.up * 0.6f; - } - } - - const float castDuration = 0.42f; - float timer = 0f; - while (timer < castDuration) - { - timer += Time.deltaTime; - float progress = Mathf.Clamp01(timer / castDuration); - float eased = progress * progress * (3f - 2f * progress); - if (meleeAttack) - { - Vector3 lungeTarget = Vector3.Lerp(bossStart, targetCenter, isAoe ? 0.22f : 0.52f); - Vector3 pos = Vector3.Lerp(bossStart, lungeTarget, eased); - pos.y += Mathf.Sin(progress * Mathf.PI) * (isAoe ? 1.4f : 0.8f); - bossModel.transform.position = pos; - } - else - { - for (int i = 0; i < targets.Count; i++) - { - if (projectiles[i] == null || targets[i] == null) continue; - Vector3 end = targets[i].transform.position + Vector3.up * 0.4f; - Vector3 pos = Vector3.Lerp(bossStart + Vector3.up * 0.6f, end, eased); - pos += GetElementTrajectoryOffset(element, progress); - projectiles[i].transform.position = pos; - CreateTrailParticle(pos, GetElementColor3D(element), 0.09f); - } - } - yield return null; - } - - Color impactColor = GetElementColor3D(element); - for (int i = 0; i < targets.Count; i++) - { - if (projectiles != null && projectiles[i] != null) - Destroy(projectiles[i]); - if (targets[i] == null) continue; - CreateElementImpact3D(targets[i].transform.position, element, impactColor); - StartCoroutine(ShakeModel(targets[i], 0.42f, isAoe ? 0.32f : 0.42f)); - } - - if (isAoe) - CreateBigExplosion(targetCenter); - if (cachedCameraFollower == null && Camera.main != null) - cachedCameraFollower = Camera.main.GetComponent(); - if (cachedCameraFollower != null) - cachedCameraFollower.Shake(isAoe ? 0.55f : 0.35f, isAoe ? 0.55f : 0.35f); - if (AudioManager.Instance != null) - AudioManager.Instance.PlaySFX(SfxType.Hit); - - const float recoverDuration = 0.30f; - Vector3 bossContact = bossModel.transform.position; - timer = 0f; - while (timer < recoverDuration) - { - timer += Time.deltaTime; - bossModel.transform.position = Vector3.Lerp( - bossContact, - bossStart, - Mathf.Clamp01(timer / recoverDuration)); - yield return null; - } - bossModel.transform.position = bossStart; - - playingSkill = false; - onComplete?.Invoke(); - } - private void CreateBigExplosion(Vector3 position) { StartCoroutine(BigExplosionCoroutine(position)); @@ -798,18 +534,15 @@ private void CreateBigExplosion(Vector3 position) private IEnumerator BigExplosionCoroutine(Vector3 position) { - GameObject flash = GameObject.CreatePrimitive(PrimitiveType.Sphere); - flash.name = "Explosion"; - flash.transform.SetParent(arenaRoot.transform, false); - flash.transform.position = position; - Material flashMat = CreateSafeMaterial(new Color(1f, 0.9f, 0.3f, 0.9f)); - flash.GetComponent().material = flashMat; - Object.Destroy(flash.GetComponent()); + if (BattlePresentation.ReducedFlashes) yield break; + // 가산 FX — 예전 불투명 노란 공은 보스를 0.8초간 통째로 가렸다(합체공격 QA 캡처). + Material flashMat = CreateFxMaterial(new Color(1f, 0.9f, 0.3f, 0.9f), true); + GameObject flash = FxPrimitive(PrimitiveType.Sphere, "Explosion", position, flashMat); float t = 0f; while (t < 0.8f) { - t += Time.deltaTime; + t += PresentationDelta; float progress = t / 0.8f; float size = progress * 5f; flash.transform.localScale = Vector3.one * size; @@ -825,6 +558,7 @@ private void SetupBattleCamera() if (cam == null) return; bool isRaid = bossModel != null; + Rect safeViewport = SafeViewport(); Vector3 camPos; Vector3 lookTarget; @@ -833,37 +567,70 @@ private void SetupBattleCamera() // 레이드: 팀 뒤쪽 높은 곳에서 보스를 **정면으로** 본다. // 좌표는 실제 배치(팀 호 중심·보스 위치)에서 파생한다 — 값을 두 곳에 적지 않는다. ComputeRaidCameraFraming(playerBattlePos, bossBattlePos, out camPos, out lookTarget); + Quaternion rotation = Quaternion.LookRotation(lookTarget - camPos); + Bounds bounds = BattleFraming.ModelBounds(bossModel); + if (teamModels != null) + foreach (GameObject model in teamModels) + if (model != null) bounds.Encapsulate(BattleFraming.ModelBounds(model)); + bounds.Expand(0.3f); + BattleFraming.Compute(bounds, cam.aspect, cam.fieldOfView, rotation, safeViewport, out camPos, out lookTarget); } else { - // 1v1: 플레이어 뒤 어깨 너머에서 적을 바라보는 각도 - // 대결 긴장감 + 플레이어 곤충이 가까이 보임 - camPos = playerBattlePos + new Vector3(1.5f, 2.8f, -4f); - lookTarget = Vector3.Lerp(playerBattlePos, enemyBattlePos, 0.6f) + Vector3.up * 0.3f; - } + Bounds bounds = BattleFraming.ModelBounds(playerModel); + bounds.Encapsulate(BattleFraming.ModelBounds(enemyModel)); + bounds.Expand(0.3f); + BattleFraming.ComputeDuel(bounds, cam.aspect, cam.fieldOfView, Quaternion.Euler(12f, -12f, 0f), + safeViewport, out camPos, out lookTarget); + } + // Large wings on narrow screens may require more distance than the original arena. + // Move the backdrop with the framing instead of placing the camera behind a wall. + float span = Mathf.Max(ArenaWallSpan, Mathf.Abs(camPos.x - arenaCenter.x) + 3f, + Mathf.Abs(camPos.z - arenaCenter.z) + 3f); + PlaceArenaWalls(span); // 수문장 등장 컷도 같은 함수로 벽을 민다(Staging partial) + framingAspect = cam.aspect; + framingFov = cam.fieldOfView; + framingSafeArea = Screen.safeArea; + RememberBaseCamera(camPos, lookTarget); // 연출 샷은 이 구도에서 출발해 여기로 돌아온다 cam.transform.position = camPos; cam.transform.LookAt(lookTarget); CameraFollower follower = cam.GetComponent(); - if (follower != null) + if (follower != null) follower.EnterBattleModeFramed(camPos, lookTarget); + } + + private void Update() + { + if (!isActive) return; + UpdateLife(); // 숨쉬기·상태 표시는 스킬 연출 중에도 돈다(Life partial) + if (playingSkill || Camera.main == null) return; + if (Mathf.Abs(Camera.main.aspect - framingAspect) > 0.01f + || Mathf.Abs(Camera.main.fieldOfView - framingFov) > 0.01f || Screen.safeArea != framingSafeArea) SetupBattleCamera(); + } + + public Vector3 GetCombatantScreenPosition(bool player) + { + return ScreenCenterOf(player ? playerModel : (bossModel != null ? bossModel : enemyModel)); + } + + /// + /// 모델 중심의 화면 좌표. 렌더러 목록은 캐시를 쓴다 — UI가 OnGUI 패스마다 부르므로 + /// GetComponentsInChildren를 매번 돌리면 프레임마다 배열이 새로 생긴다. + /// + private Vector3 ScreenCenterOf(GameObject model) + { + if (Camera.main == null || model == null) return Vector3.zero; + MeshRenderer[] renderers = GetRenderersCached(model); + Bounds bounds = new Bounds(model.transform.position, Vector3.zero); + bool found = false; + foreach (MeshRenderer renderer in renderers) { - if (isRaid) - { - // 팔로워에게 이 구도를 그대로 넘긴다. 좌표 쌍 오버로드를 쓰면 팔로워가 대결 축의 - // **측면**에 카메라를 놓고 LateUpdate가 매 프레임 그걸 적용해, 위에서 잡은 정면 - // 구도가 같은 프레임에 덮인다(레이드가 옆에서 보이던 원인). - follower.EnterBattleModeFramed(camPos, lookTarget); - } - else - { - // 1v1은 팔로워의 측면 구도를 그대로 쓴다(사용자 선택 — 레이드만 정면으로 바꿨다). - // 그래서 아래 두 줄은 이 프레임 한 번만 유효하고 곧 팔로워 값으로 덮인다. - follower.EnterBattleMode(playerBattlePos, enemyBattlePos); - cam.transform.position = camPos; - cam.transform.LookAt(lookTarget); - } + if (renderer == null || !renderer.enabled || renderer.name == "BossAura") continue; + if (!found) { bounds = renderer.bounds; found = true; } + else bounds.Encapsulate(renderer.bounds); } + return Camera.main.WorldToScreenPoint(bounds.center); } // 레이드 카메라 구도 — 눈으로 보고 조정하는 값이다. @@ -918,145 +685,158 @@ public void PlaySkillEffect(bool isPlayerAttacking, InsectElement element, Skill PlaySkillEffect(isPlayerAttacking, element, effectType, onImpact, false); } - public void PlaySkillEffect(bool isPlayerAttacking, InsectElement element, SkillEffectType effectType, System.Action onImpact, bool isMelee) + public void PlaySkillEffect(bool isPlayerAttacking, InsectElement element, SkillEffectType effectType, System.Action onImpact, bool isMelee, float duration = 2.5f) { - if (!isActive) return; - - if (AudioManager.Instance != null) AudioManager.Instance.PlaySkillSFX(element); + PlaySkillEffect(isPlayerAttacking, element, effectType, onImpact, isMelee, duration, HitCue.None); + } - // 자기 강화(공격/방어 버프·회복)는 시전자에 버프 연출. 대상 약화(디버프)는 대상에 디버프 연출. - if (effectType == SkillEffectType.BuffAttack || effectType == SkillEffectType.DefenseBuff - || effectType == SkillEffectType.Heal) - { - PlayBuffEffect(isPlayerAttacking, element); - onImpact?.Invoke(); - return; - } - if (effectType == SkillEffectType.DebuffAttack) - { - PlayDebuffEffect(!isPlayerAttacking, element); - onImpact?.Invoke(); - return; - } - // Stun/PoisonDot은 대상을 때리는 공격 연출(아래 러시/투사체) 경유. + /// 외칠 기술 이름과 타격 세기 — 히트스톱·넉백·카메라 반동을 결과에 맞춘다. + public void PlaySkillEffect(bool isPlayerAttacking, InsectElement element, SkillEffectType effectType, System.Action onImpact, bool isMelee, float duration, HitCue cue) + { + if (!isActive) { onImpact?.Invoke(); return; } + CompleteStagingNow(); // 등장 연출이 남아 있으면 끝 자세로 — 돌진의 원위치가 공중에 잡히지 않게 + if (playingSkill) return; playingSkill = true; - StartCoroutine(SkillAttackCoroutine(isPlayerAttacking, element, onImpact, isMelee)); + StartCoroutine(SkillAttackCoroutine(isPlayerAttacking, element, effectType, onImpact, isMelee, duration, cue)); } - // 레이드 보스 공격 대상 팀 모델의 **폴백** — 첫 유효 팀 모델. + // 레이드 보스 공격 대상 팀 모델의 **폴백** — 첫 번째로 서 있는 팀 모델. // 피격 슬롯 지정은 호출부가 `PlayRaidBossAttack`의 targetSlot 인자로 넘기고 그쪽이 먼저 처리한다. - // 여기까지 오는 건 그 인자가 범위 밖이거나 해당 모델이 이미 파괴된 경우뿐이다. + // 여기까지 오는 건 그 인자가 범위 밖이거나 해당 모델이 이미 파괴됐거나 쓰러져 꺼진 경우뿐이다 — + // 꺼진 모델(쓰러짐 연출 끝·시작 때 기절)을 고르면 빈 자리를 때린다. private GameObject ResolveBossTarget() { if (teamModels == null) return null; for (int i = 0; i < teamModels.Length; i++) - if (teamModels[i] != null) return teamModels[i]; + if (IsStanding(teamModels[i])) return teamModels[i]; return null; } - private IEnumerator SkillAttackCoroutine(bool isPlayerAttacking, InsectElement element, System.Action onImpact, bool isMelee) + private IEnumerator SkillAttackCoroutine(bool isPlayerAttacking, InsectElement element, + SkillEffectType effectType, System.Action onImpact, bool isMelee, float duration, HitCue cue) { GameObject attacker = isPlayerAttacking ? playerModel : (bossModel != null ? bossModel : enemyModel); if (isPlayerAttacking && teamModels != null && selectedTeamIndex >= 0 && selectedTeamIndex < teamModels.Length) attacker = teamModels[selectedTeamIndex]; - - // 대상: 플레이어 공격→보스(1v1은 적), 적/보스 공격→플레이어(1v1) 또는 팀원(레이드). - GameObject target = isPlayerAttacking - ? (bossModel ?? enemyModel) + bool self = effectType == SkillEffectType.BuffAttack || effectType == SkillEffectType.DefenseBuff + || effectType == SkillEffectType.Heal; + bool support = self || effectType == SkillEffectType.DebuffAttack; + GameObject target = self ? attacker : isPlayerAttacking ? (bossModel ?? enemyModel) : (bossModel != null ? ResolveBossTarget() : playerModel); - if (attacker == null || target == null) { onImpact?.Invoke(); playingSkill = false; yield break; } - - Vector3 startPos = attacker.transform.position; - Vector3 targetPos = target.transform.position; - Color elemColor = GetElementColor3D(element); - - if (isMelee) - { - // 근접 스킬: 러시 → 임팩트 → 복귀 (투사체 없음) - float t = 0f; - while (t < 0.3f) + if (!isActive || attacker == null || target == null) { playingSkill = false; yield break; } + + duration = Mathf.Max(.5f, duration); + Vector3 start = attacker.transform.position; + Quaternion startRotation = attacker.transform.rotation; + Vector3 destination = target.transform.position; + Vector3 contact = Vector3.Lerp(start, destination, .72f); + InsectEntity entity = attacker.GetComponent(); + string species = entity != null && entity.Data != null ? entity.Data.insectId : null; + // 계열별 몸짓(사마귀 베기·딱정벌레 돌진·나는 종 내리꽂기·벌 찌르기·지네·거미 덮치기) — 타격 순간도 계열이 정한다. + // onImpact(피해 숫자·HP 감소)는 그 순간에 부르므로 숫자 시각이 따로 놀지 않는다. + BattleMotion.Family family = BattleMotion.FamilyOf(species); + BattleMotion.Kind motion = BattleMotion.Resolve(species, isMelee, support); + float impactAt = BattleMotion.ImpactOf(motion); + Color color = GetElementColor3D(element); + GameObject projectile = null; + bool impacted = false; + bool preStruck = false; + float elapsed = 0f; + // 샷의 기준점은 출발 때 한 번 잰다(돌진하는 시전자를 쫓으면 카메라가 덜컹거린다). + Vector3 attackerCenter = BattleFraming.ModelBounds(attacker).center; + Vector3 targetCenter = BattleFraming.ModelBounds(target).center; + // 전용기 — 시전자 클로즈업 컷인 → 기 모으기 → 큰 이펙트(Flourish partial). 판정은 부르는 쪽(cue.Signature)이 했다. + bool signature = cue.Signature; + if (signature) + BeginSignature(attacker, element, cue, isPlayerAttacking, impactAt * duration, + BattleFlourish.SignatureCutInEndFor(impactAt * duration)); + BeginSkillPresentation(attacker, element, cue); + try + { + while (elapsed < duration) { - 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); + if (!isActive || arenaRoot == null || attacker == null || target == null) yield break; + elapsed += BattlePresentation.DeltaTime; + if (signature) + { + signatureElapsed = elapsed; + if (impacted && elapsed >= impactAt * duration + BattleFlourish.SignatureTailSeconds) EndSignature(); + } + float progress = Mathf.Clamp01(elapsed / duration); + ApplyCameraShot(progress, duration, attackerCenter, targetCenter, cue.Weight, support, impactAt, signature); + BattleMotion.Pose pose = BattleMotion.Evaluate(motion, family, progress); + float motionScale = BattlePresentation.ReducedMotion ? .18f : 1f; + attacker.transform.position = Vector3.LerpUnclamped(start, contact, pose.Travel * motionScale) + + Vector3.up * (pose.Lift * motionScale); + attacker.transform.rotation = startRotation + * Quaternion.Euler(pose.Pitch * motionScale, pose.Yaw * motionScale, pose.Roll * motionScale); + + // 사마귀의 첫 번째 베기 — 칼날 궤적과 가벼운 움찔(숫자는 두 번째 베기에서 한 번). + if (!preStruck && BattleMotion.HasPreStrike(motion) && progress >= BattleMotion.SlashFirstSwing) + { + preStruck = true; + PlaySlashArc(attacker, target, color, true); + if (!cue.Missed && cue.Weight > 0f) + StartReact(target, HitReactCoroutine(target, SlashDirection(attacker, target), 0.1f, target == bossModel)); + if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.Attack); + } - // 복귀 - 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); + // Projectile travel is independent of the caster's anticipation/recoil. + if (motion == BattleMotion.Kind.Projectile && !impacted && progress >= .18f && progress < impactAt) + { + float travel = Mathf.SmoothStep(0f, 1f, (progress - .18f) / (impactAt - .18f)); + if (projectile == null) projectile = CreateElementProjectile(element, color); + projectile.transform.position = Vector3.Lerp(start + Vector3.up * .3f, + destination + Vector3.up * .3f, travel) + GetElementTrajectoryOffset(element, travel); + if (signature) projectile.transform.localScale = Vector3.one * 1.6f; // 전용기 탄은 크게 + } + if (!impacted && progress >= impactAt) + { + impacted = true; + if (projectile != null) { Destroy(projectile); projectile = null; } + onImpact?.Invoke(); + // An impact handler may close the battle; never emit orphan effects. + if (!isActive || arenaRoot == null || attacker == null || target == null) yield break; + if (motion == BattleMotion.Kind.Slash) PlaySlashArc(attacker, target, color, false); + if (support) + { + // 버프·회복은 쓴 쪽(제자리 = start), 약화는 맞는 쪽(destination)에 띄운다. 자리는 이 코루틴이 + // 이미 고른 시전자·대상에서 받는다 — 예전엔 진영 bool로 모델을 다시 골랐는데 상대 쪽 분기도 + // playerModel이라, 상대가 자기에게 건 버프가 내 곤충 위에 떴다(2026-10-02 기기 보고). + // 회복은 초록 반짝임(Life partial), 강화는 고리·화살표 — 그 뒤로는 몸 표시(불꽃결·육각 막)가 남는다. + if (effectType == SkillEffectType.Heal) PlayHealSparkle(attacker); + else if (self) PlayBuffEffect(start, element); + else PlayDebuffEffect(destination); + } + else + { + if (!cue.Missed) CreateElementImpact3D(destination, element, color); + if (signature && !cue.Missed) PlaySignatureBurst(targetCenter, element, SignatureBurstSize(target)); + // 넉백·섬광·히트스톱·의성어·비명·흔들림 — 예전 ShakeModel(0.12m)+카메라 0.12를 대신한다. + PlayImpactFeel(attacker, target, targetCenter, element, color, cue); + } + if (AudioManager.Instance != null) AudioManager.Instance.PlaySkillSFX(element); + } yield return null; } - attacker.transform.position = startPos; - playingSkill = false; - yield break; } - - // 원거리: 투사체 발사 - // Phase 1: element projectile (0~0.4s) - GameObject projectile = CreateElementProjectile(element, elemColor); - projectile.transform.position = startPos + Vector3.up * 0.5f; - - float tt = 0f; - while (tt < 0.4f) + finally { - 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); - projectile.transform.position = pos; - projectile.transform.Rotate(Vector3.up, 720f * Time.deltaTime); - CreateTrailParticle(pos, elemColor, 0.08f); - yield return null; - } - - Destroy(projectile); - - // Phase 2: element impact - 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 (projectile != null) Destroy(projectile); + if (attacker != null) attacker.transform.SetPositionAndRotation(start, startRotation); + if (signature) EndSignature(); + ClearCameraShot(); + playingSkill = false; } + } - if (AudioManager.Instance != null) - AudioManager.Instance.PlaySFX(SfxType.Hit); - - yield return new WaitForSeconds(0.4f); - playingSkill = false; + /// 시전자 → 대상 수평 방향(넉백·움찔 방향). + private static Vector3 SlashDirection(GameObject attacker, GameObject target) + { + Vector3 dir = target.transform.position - attacker.transform.position; + dir.y = 0f; + return dir.sqrMagnitude > 0.0001f ? dir.normalized : Vector3.forward; } private GameObject CreateElementProjectile(InsectElement element, Color color) @@ -1073,8 +853,9 @@ private GameObject CreateElementProjectile(InsectElement element, Color color) break; case InsectElement.Water: - CreateProjPart(proj, PrimitiveType.Sphere, Vector3.zero, 0.3f, color); - CreateProjPart(proj, PrimitiveType.Sphere, new Vector3(0f, 0.2f, 0f), 0.15f, new Color(0.5f, 0.8f, 1f)); + // 물 구체·금속 칼날만 광택을 남긴다 — 나머지 투사체 파츠는 무광(CreateSafeMaterial). + CreateProjPart(proj, PrimitiveType.Sphere, Vector3.zero, 0.3f, color, SurfaceKind.Wet); + CreateProjPart(proj, PrimitiveType.Sphere, new Vector3(0f, 0.2f, 0f), 0.15f, new Color(0.5f, 0.8f, 1f), SurfaceKind.Wet); break; case InsectElement.Leaf: @@ -1115,8 +896,8 @@ private GameObject CreateElementProjectile(InsectElement element, Color color) break; case InsectElement.Metal: - CreateProjPart(proj, PrimitiveType.Cube, Vector3.zero, new Vector3(0.5f, 0.03f, 0.15f), color); - CreateProjPart(proj, PrimitiveType.Cube, Vector3.zero, new Vector3(0.03f, 0.5f, 0.15f), new Color(0.9f, 0.9f, 0.95f)); + CreateProjPart(proj, PrimitiveType.Cube, Vector3.zero, new Vector3(0.5f, 0.03f, 0.15f), color, SurfaceKind.Metal); + CreateProjPart(proj, PrimitiveType.Cube, Vector3.zero, new Vector3(0.03f, 0.5f, 0.15f), new Color(0.9f, 0.9f, 0.95f), SurfaceKind.Metal); break; default: // Bug, None @@ -1153,6 +934,7 @@ private Vector3 GetElementTrajectoryOffset(InsectElement element, float progress private void CreateElementImpact3D(Vector3 position, InsectElement element, Color color) { + if (BattlePresentation.ReducedFlashes) return; StartCoroutine(ElementImpactCoroutine(position, element, color)); } @@ -1214,7 +996,7 @@ private IEnumerator PoisonImpact3D(Vector3 pos, Color color) float t = 0f; while (t < 0.7f) { - t += Time.deltaTime; + t += PresentationDelta; float progress = t / 0.7f; for (int i = 0; i < 5; i++) { @@ -1265,7 +1047,7 @@ private IEnumerator WaterImpact3D(Vector3 pos, Color color) float t = 0f; while (t < 0.7f) { - t += Time.deltaTime; + t += PresentationDelta; float progress = t / 0.7f; // Pillar grows then shrinks @@ -1311,7 +1093,7 @@ private IEnumerator LeafImpact3D(Vector3 pos, Color color) float t = 0f; while (t < 0.7f) { - t += Time.deltaTime; + t += PresentationDelta; float progress = t / 0.7f; for (int i = 0; i < 6; i++) { @@ -1319,8 +1101,8 @@ private IEnumerator LeafImpact3D(Vector3 pos, Color color) float dist = progress * 2.5f; float y = Mathf.Sin(progress * Mathf.PI) * 1.5f; leaves[i].transform.position = pos + new Vector3(Mathf.Cos(angles[i]) * dist, y, Mathf.Sin(angles[i]) * dist); - leaves[i].transform.Rotate(Vector3.up, 360f * Time.deltaTime); - leaves[i].transform.Rotate(Vector3.right, 180f * Time.deltaTime); + leaves[i].transform.Rotate(Vector3.up, 360f * PresentationDelta); + leaves[i].transform.Rotate(Vector3.right, 180f * PresentationDelta); float s = 1f - progress * 0.5f; leaves[i].transform.localScale = new Vector3(0.3f * s, 0.02f, 0.15f * s); } @@ -1365,14 +1147,14 @@ private IEnumerator WindImpact3D(Vector3 pos, Color color) float t = 0f; while (t < 0.7f) { - t += Time.deltaTime; + t += PresentationDelta; float progress = t / 0.7f; for (int i = 0; i < 3; i++) { if (rings[i] == null) continue; float expand = (0.5f + i * 0.3f) + progress * 2f; rings[i].transform.localScale = new Vector3(expand, 0.03f, expand); - rings[i].transform.Rotate(Vector3.up, (300f + i * 100f) * Time.deltaTime); + rings[i].transform.Rotate(Vector3.up, (300f + i * 100f) * PresentationDelta); float y = pos.y + i * 0.3f + progress * 1.5f; rings[i].transform.position = new Vector3(pos.x, y, pos.z); var mat = rings[i].GetComponent().material; @@ -1381,7 +1163,7 @@ private IEnumerator WindImpact3D(Vector3 pos, Color color) for (int i = 0; i < 5; i++) { if (debris[i] == null) continue; - debrisAngles[i] += 8f * Time.deltaTime; + debrisAngles[i] += 8f * PresentationDelta; float r = progress * 2f; float dy = Mathf.Sin(progress * Mathf.PI * 2f + i) * 0.5f + progress * 1f; debris[i].transform.position = pos + new Vector3(Mathf.Cos(debrisAngles[i]) * r, dy, Mathf.Sin(debrisAngles[i]) * r); @@ -1400,7 +1182,7 @@ private IEnumerator ElectricImpact3D(Vector3 pos, Color color) flash.transform.SetParent(arenaRoot.transform, false); flash.transform.position = pos; flash.transform.localScale = Vector3.one * 0.5f; - Material flashMat = CreateSafeMaterial(new Color(1f, 1f, 0.8f)); + Material flashMat = CreateFxMaterial(new Color(1f, 1f, 0.8f), true); flash.GetComponent().material = flashMat; Object.Destroy(flash.GetComponent()); @@ -1417,7 +1199,7 @@ private IEnumerator ElectricImpact3D(Vector3 pos, Color color) float ax = Random.Range(-60f, 60f); float az = Random.Range(-60f, 60f); bolts[i].transform.rotation = Quaternion.Euler(ax, 0f, az); - Material bMat = CreateSafeMaterial(color); + Material bMat = CreateFxMaterial(color, true); bolts[i].GetComponent().material = bMat; Object.Destroy(bolts[i].GetComponent()); boltDirs[i] = new Vector3(ax, 0f, az); @@ -1433,7 +1215,7 @@ private IEnumerator ElectricImpact3D(Vector3 pos, Color color) sparks[i].transform.SetParent(arenaRoot.transform, false); sparks[i].transform.position = pos; sparks[i].transform.localScale = Vector3.one * 0.06f; - Material sMat = CreateSafeMaterial(new Color(1f, 1f, 0.5f)); + Material sMat = CreateFxMaterial(new Color(1f, 1f, 0.5f), true); sparks[i].GetComponent().material = sMat; Object.Destroy(sparks[i].GetComponent()); sparkVels[i] = new Vector3(Random.Range(-3f, 3f), Random.Range(0f, 4f), Random.Range(-3f, 3f)); @@ -1442,13 +1224,13 @@ private IEnumerator ElectricImpact3D(Vector3 pos, Color color) float t = 0f; while (t < 0.6f) { - t += Time.deltaTime; + t += PresentationDelta; float progress = t / 0.6f; // Flash expands then fades - float flashScale = 0.5f + progress * 3f; + float flashScale = 0.5f + progress * 1.8f; // 가산 섬광은 3.5배까지 키우면 대상을 통째로 덮는다(QA 캡처) flash.transform.localScale = Vector3.one * flashScale; - flashMat.color = new Color(1f, 1f, 0.8f, 1f - progress); + flashMat.color = new Color(1f, 1f, 0.8f, 0.85f * (1f - progress) * (1f - progress)); // Bolts jitter for (int i = 0; i < 4; i++) @@ -1533,7 +1315,7 @@ private IEnumerator EarthImpact3D(Vector3 pos, Color color) float t = 0f; while (t < 0.8f) { - t += Time.deltaTime; + t += PresentationDelta; float progress = t / 0.8f; for (int i = 0; i < 5; i++) @@ -1588,7 +1370,7 @@ private IEnumerator LightImpact3D(Vector3 pos, Color color) crossH.transform.SetParent(arenaRoot.transform, false); crossH.transform.position = pos; crossH.transform.localScale = new Vector3(0.1f, 0.1f, 0.1f); - Material crossMat = CreateSafeMaterial(new Color(1f, 0.95f, 0.7f)); + Material crossMat = CreateFxMaterial(new Color(1f, 0.95f, 0.7f), true); crossH.GetComponent().material = crossMat; Object.Destroy(crossH.GetComponent()); @@ -1597,7 +1379,7 @@ private IEnumerator LightImpact3D(Vector3 pos, Color color) crossV.transform.SetParent(arenaRoot.transform, false); crossV.transform.position = pos; crossV.transform.localScale = new Vector3(0.1f, 0.1f, 0.1f); - Material crossVMat = CreateSafeMaterial(new Color(1f, 0.95f, 0.7f)); + Material crossVMat = CreateFxMaterial(new Color(1f, 0.95f, 0.7f), true); crossV.GetComponent().material = crossVMat; Object.Destroy(crossV.GetComponent()); @@ -1611,7 +1393,7 @@ private IEnumerator LightImpact3D(Vector3 pos, Color color) stars[i].transform.SetParent(arenaRoot.transform, false); stars[i].transform.position = pos; stars[i].transform.localScale = Vector3.one * 0.1f; - Material sMat = CreateSafeMaterial(new Color(1f, 1f, 0.6f)); + Material sMat = CreateFxMaterial(new Color(1f, 1f, 0.6f), true); stars[i].GetComponent().material = sMat; Object.Destroy(stars[i].GetComponent()); starAngles[i] = i * Mathf.PI * 2f / 6f; @@ -1620,7 +1402,7 @@ private IEnumerator LightImpact3D(Vector3 pos, Color color) float t = 0f; while (t < 0.8f) { - t += Time.deltaTime; + t += PresentationDelta; float progress = t / 0.8f; // Pillar descends and fades @@ -1700,7 +1482,7 @@ private IEnumerator DarkImpact3D(Vector3 pos, Color color) // Phase 1: contract (0~0.3s) while (t < 0.3f) { - t += Time.deltaTime; + t += PresentationDelta; float progress = t / 0.3f; float scale = 2f - progress * 1.5f; orb.transform.localScale = Vector3.one * scale; @@ -1712,7 +1494,7 @@ private IEnumerator DarkImpact3D(Vector3 pos, Color color) t = 0f; while (t < 0.5f) { - t += Time.deltaTime; + t += PresentationDelta; float progress = t / 0.5f; float scale = 0.5f + progress * 4f; @@ -1730,7 +1512,7 @@ private IEnumerator DarkImpact3D(Vector3 pos, Color color) { if (frags[i] == null) continue; frags[i].transform.position = pos + fragVels[i] * progress; - frags[i].transform.Rotate(Vector3.one, 500f * Time.deltaTime); + frags[i].transform.Rotate(Vector3.one, 500f * PresentationDelta); float fs = 0.15f * (1f - progress); frags[i].transform.localScale = Vector3.one * Mathf.Max(fs, 0.01f); } @@ -1750,7 +1532,7 @@ private IEnumerator MetalImpact3D(Vector3 pos, Color color) slash1.transform.position = pos; slash1.transform.localScale = new Vector3(0.01f, 0.01f, 0.01f); slash1.transform.rotation = Quaternion.Euler(0f, 0f, 45f); - Material s1Mat = CreateSafeMaterial(color); + Material s1Mat = CreateFxMaterial(color, true); slash1.GetComponent().material = s1Mat; Object.Destroy(slash1.GetComponent()); @@ -1760,7 +1542,7 @@ private IEnumerator MetalImpact3D(Vector3 pos, Color color) slash2.transform.position = pos; slash2.transform.localScale = new Vector3(0.01f, 0.01f, 0.01f); slash2.transform.rotation = Quaternion.Euler(0f, 0f, -45f); - Material s2Mat = CreateSafeMaterial(new Color(0.9f, 0.9f, 0.95f)); + Material s2Mat = CreateFxMaterial(new Color(0.9f, 0.9f, 0.95f), true); slash2.GetComponent().material = s2Mat; Object.Destroy(slash2.GetComponent()); @@ -1775,7 +1557,8 @@ private IEnumerator MetalImpact3D(Vector3 pos, Color color) frags[i].transform.position = pos; frags[i].transform.localScale = Vector3.one * 0.1f; float shade = 0.6f + Random.Range(0f, 0.3f); - Material fMat = CreateSafeMaterial(new Color(shade, shade, shade + 0.05f)); + // 쇳조각 — 광택이 제 질감이라 무광에서 뺀다. + Material fMat = CreateSheenMaterial(new Color(shade, shade, shade + 0.05f), SurfaceKind.Metal); frags[i].GetComponent().material = fMat; Object.Destroy(frags[i].GetComponent()); fragVels[i] = new Vector3(Random.Range(-2f, 2f), Random.Range(0.5f, 3f), Random.Range(-2f, 2f)); @@ -1790,7 +1573,7 @@ private IEnumerator MetalImpact3D(Vector3 pos, Color color) sparks[i].transform.SetParent(arenaRoot.transform, false); sparks[i].transform.position = pos; sparks[i].transform.localScale = Vector3.one * 0.08f; - Material spMat = CreateSafeMaterial(new Color(1f, 0.6f, 0.1f)); + Material spMat = CreateFxMaterial(new Color(1f, 0.6f, 0.1f), true); sparks[i].GetComponent().material = spMat; Object.Destroy(sparks[i].GetComponent()); } @@ -1798,7 +1581,7 @@ private IEnumerator MetalImpact3D(Vector3 pos, Color color) float t = 0f; while (t < 0.6f) { - t += Time.deltaTime; + t += PresentationDelta; float progress = t / 0.6f; // Slash grows @@ -1815,7 +1598,7 @@ private IEnumerator MetalImpact3D(Vector3 pos, Color color) Vector3 fPos = pos + fragVels[i] * progress; fPos.y -= 2f * progress * progress; frags[i].transform.position = fPos; - frags[i].transform.Rotate(Vector3.one, 400f * Time.deltaTime); + frags[i].transform.Rotate(Vector3.one, 400f * PresentationDelta); float fs = 0.1f * (1f - progress * 0.7f); frags[i].transform.localScale = Vector3.one * fs; } @@ -1840,60 +1623,69 @@ private IEnumerator MetalImpact3D(Vector3 pos, Color color) private IEnumerator DefaultImpact3D(Vector3 pos, Color color) { - // Similar to existing CreateImpactEffect (ring + sparks) - GameObject ring = GameObject.CreatePrimitive(PrimitiveType.Cylinder); - ring.name = "DefaultImpactRing"; - ring.transform.SetParent(arenaRoot.transform, false); - ring.transform.position = pos; - ring.transform.localScale = new Vector3(0.5f, 0.01f, 0.5f); - Material ringMat = CreateSafeMaterial(new Color(color.r, color.g, color.b, 0.7f)); - ring.GetComponent().material = ringMat; - Object.Destroy(ring.GetComponent()); - + // 바닥을 따라 퍼지는 충격 고리 + 튀는 파편. 예전엔 납작한 실린더(속이 찬 원판)를 **몸통 높이**에 + // 띄워 키웠는데, 불투명 머티리얼이라 페이드도 안 먹어서 반경 4.5m짜리 초록 접시가 0.5초간 + // 두 곤충을 덮었다(QA 캡처 실측). 고리는 선이라 가운데가 비고, 가산 합성이라 사라지며 빛난다. + Vector3 ground = new Vector3(pos.x, arenaCenter.y + 0.06f, pos.z); + Material ringMat = CreateFxMaterial(new Color(color.r, color.g, color.b, 0.9f), true); + GameObject ringObj = new GameObject("DefaultImpactRing"); + ringObj.transform.SetParent(arenaRoot.transform, false); + ringObj.transform.position = ground; + LineRenderer ring = ringObj.AddComponent(); + ring.useWorldSpace = false; + ring.loop = true; + ring.positionCount = 40; + ring.sharedMaterial = ringMat; + ring.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + ring.receiveShadows = false; + ring.alignment = LineAlignment.TransformZ; + ringObj.transform.rotation = Quaternion.Euler(90f, 0f, 0f); // 선 면을 바닥에 눕힌다 + + Material sMat = CreateFxMaterial(new Color(color.r, color.g, color.b, 0.9f), true); GameObject[] sparks = new GameObject[8]; for (int i = 0; i < 8; i++) { - sparks[i] = GameObject.CreatePrimitive(PrimitiveType.Sphere); - sparks[i].name = $"DefaultSpark_{i}"; - sparks[i].transform.SetParent(arenaRoot.transform, false); - sparks[i].transform.position = pos; + sparks[i] = FxPrimitive(PrimitiveType.Sphere, $"DefaultSpark_{i}", pos, sMat); sparks[i].transform.localScale = Vector3.one * 0.12f; - Material sMat = CreateSafeMaterial(new Color(color.r, color.g, color.b, 0.9f)); - sparks[i].GetComponent().material = sMat; - Object.Destroy(sparks[i].GetComponent()); } float t = 0f; while (t < 0.5f) { - t += Time.deltaTime; - float progress = t / 0.5f; - float ringSize = 0.5f + progress * 4f; - ring.transform.localScale = new Vector3(ringSize, 0.01f * (1f - progress), ringSize); - ringMat.color = new Color(color.r, color.g, color.b, 0.7f * (1f - progress)); + if (arenaRoot == null) yield break; + t += PresentationDelta; + float progress = Mathf.Clamp01(t / 0.5f); + float eased = 1f - (1f - progress) * (1f - progress); + float radius = Mathf.Lerp(0.3f, 2.2f, eased); + ring.widthMultiplier = Mathf.Lerp(0.16f, 0.02f, progress); + ringMat.color = new Color(color.r, color.g, color.b, 0.9f * (1f - progress)); + for (int i = 0; i < ring.positionCount; i++) + { + float a = i * Mathf.PI * 2f / ring.positionCount; + ring.SetPosition(i, new Vector3(Mathf.Cos(a) * radius, Mathf.Sin(a) * radius, 0f)); + } + sMat.color = new Color(color.r, color.g, color.b, 0.9f * (1f - progress)); for (int i = 0; i < 8; i++) { if (sparks[i] == null) continue; float a = i * Mathf.PI * 2f / 8f; - float dist = progress * 3f; - float sy = Mathf.Sin(progress * Mathf.PI) * 1.5f; + float dist = eased * 1.6f; + float sy = Mathf.Sin(progress * Mathf.PI) * 0.9f; sparks[i].transform.position = pos + new Vector3(Mathf.Cos(a) * dist, sy, Mathf.Sin(a) * dist); sparks[i].transform.localScale = Vector3.one * (0.12f * (1f - progress)); } yield return null; } - Destroy(ring); + Destroy(ringObj); foreach (var s in sparks) if (s != null) Destroy(s); } // --- Buff / Debuff Effects --- - private void PlayBuffEffect(bool onPlayer, InsectElement element) + private void PlayBuffEffect(Vector3 position, InsectElement element) { - GameObject target = onPlayer ? (teamModels != null && selectedTeamIndex >= 0 && selectedTeamIndex < teamModels.Length ? teamModels[selectedTeamIndex] : playerModel) : playerModel; - if (target == null) return; - StartCoroutine(BuffEffectCoroutine(target.transform.position, GetElementColor3D(element))); + StartCoroutine(BuffEffectCoroutine(position, GetElementColor3D(element))); } private IEnumerator BuffEffectCoroutine(Vector3 pos, Color color) @@ -1922,7 +1714,7 @@ private IEnumerator BuffEffectCoroutine(Vector3 pos, Color color) float t = 0f; while (t < 1f) { - t += Time.deltaTime; + t += PresentationDelta; float progress = t / 1f; for (int i = 0; i < 3; i++) { @@ -1946,11 +1738,9 @@ private IEnumerator BuffEffectCoroutine(Vector3 pos, Color color) AudioManager.Instance.PlaySFX(SfxType.BuffApply); } - private void PlayDebuffEffect(bool onTarget, InsectElement element) + private void PlayDebuffEffect(Vector3 position) { - GameObject target = onTarget ? (bossModel ?? enemyModel) : playerModel; - if (target == null) return; - StartCoroutine(DebuffEffectCoroutine(target.transform.position)); + StartCoroutine(DebuffEffectCoroutine(position)); } private IEnumerator DebuffEffectCoroutine(Vector3 pos) @@ -1976,7 +1766,7 @@ private IEnumerator DebuffEffectCoroutine(Vector3 pos) float t = 0f; while (t < 0.8f) { - t += Time.deltaTime; + t += PresentationDelta; float progress = t / 0.8f; float y = 3f - progress * 3f; darkOrb.transform.position = pos + Vector3.up * y; @@ -2033,19 +1823,24 @@ private Color GetElementColor3D(InsectElement element) } } - private void CreateProjPart(GameObject parent, PrimitiveType type, Vector3 localPos, float uniformScale, Color color) + private void CreateProjPart(GameObject parent, PrimitiveType type, Vector3 localPos, float uniformScale, Color color, + SurfaceKind? sheen = null) { - CreateProjPart(parent, type, localPos, Vector3.one * uniformScale, color); + CreateProjPart(parent, type, localPos, Vector3.one * uniformScale, color, sheen); } - private void CreateProjPart(GameObject parent, PrimitiveType type, Vector3 localPos, Vector3 scale, Color color) + /// 광택이 제 질감인 파츠만 채운다(물·금속). 비우면 무광. + private void CreateProjPart(GameObject parent, PrimitiveType type, Vector3 localPos, Vector3 scale, Color color, + SurfaceKind? sheen = null) { GameObject part = GameObject.CreatePrimitive(type); part.name = "ProjPart"; part.transform.SetParent(parent.transform, false); part.transform.localPosition = localPos; part.transform.localScale = scale; - Material mat = (color.a < 1f) ? CreateTransparentMaterial(color) : CreateSafeMaterial(color); + Material mat = (color.a < 1f) ? CreateTransparentMaterial(color) + : sheen.HasValue ? CreateSheenMaterial(color, sheen.Value) + : CreateSafeMaterial(color); part.GetComponent().material = mat; Object.Destroy(part.GetComponent()); } @@ -2069,7 +1864,7 @@ private IEnumerator TrailParticleCoroutine(Vector3 pos, Color color, float size) float t = 0f; while (t < 0.3f) { - t += Time.deltaTime; + t += PresentationDelta; float s = size * (1f - t / 0.3f); p.transform.localScale = Vector3.one * s; mat.color = new Color(color.r, color.g, color.b, 0.5f * (1f - t / 0.3f)); @@ -2078,18 +1873,14 @@ private IEnumerator TrailParticleCoroutine(Vector3 pos, Color color, float size) Destroy(p); } + /// + /// 반투명 이펙트 머티리얼. 예전엔 Standard를 Fade 모드로 바꿔 썼는데 **플레이어 빌드에선 불투명으로 + /// 그려졌다** — 물 기둥이 파란 원기둥, 어둠 구체가 보라 공, 독 구름이 보라 구슬로 대상을 통째로 + /// 가렸다(QA 캡처 실측, 속성 10종). 빌드에 항상 있는 FX 셰이더의 알파 블렌드로 바꾼다. + /// private Material CreateTransparentMaterial(Color color) { - Material mat = CreateSafeMaterial(color); - 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.DisableKeyword("_ALPHATEST_ON"); - mat.EnableKeyword("_ALPHABLEND_ON"); - mat.DisableKeyword("_ALPHAPREMULTIPLY_ON"); - mat.renderQueue = 3000; - return mat; + return CreateFxMaterial(color, false); } // ===== End Skill Effect System ===== @@ -2162,9 +1953,18 @@ public void PlayEffectText(string text, Color color) // 이 문구만 다른 UI보다 25% 작게 찍혔다. 그리기는 `BattleEffectTextOverlay`(UI)로 옮겼다. // 여기서 부를 수는 없다: UI가 이미 Battle을 참조하므로 반대 방향은 순환이 된다. + /// + /// 쓰러짐 연출 길이(연출 시계 초) — 옆으로 눕으며 가라앉고 투명해진 뒤 모델이 꺼진다. 레이드 UI의 쓰러짐 대기 + /// (RaidBattleUI.FaintHoldDuration)가 이 값에서 파생된다 — 바꾸면 대기도 따라온다. + /// + public const float FaintSeconds = 0.6f; + public IEnumerator PlayFaintCoroutine(GameObject model) { if (model == null) yield break; + AnnounceFaint(model); + MarkFainting(model); // 몸 밖에 서 있는 상태 표시(별·거품·막)를 곧바로 거둔다(Life partial) + BakeShadowTint(model); // 그림자 톤(PropertyBlock)이 아래 알파 페이드를 덮지 않게 머티리얼에 굽는다 Vector3 originalPos = model.transform.position; Quaternion originalRot = model.transform.rotation; @@ -2191,12 +1991,12 @@ public IEnumerator PlayFaintCoroutine(GameObject model) } } - float duration = 0.6f; + float duration = FaintSeconds; float t = 0f; while (t < duration) { if (model == null) yield break; - t += Time.deltaTime; + t += PresentationDelta; float progress = Mathf.Clamp01(t / duration); float eased = progress * progress * (3f - 2f * progress); @@ -2232,17 +2032,10 @@ public IEnumerator PlayFaintCoroutine(GameObject model) private static void SetMaterialTransparentFade(Material mat) { if (mat == null) return; - // Standard (Built-in) + // Standard (Built-in) — Fade 설정은 SceneryMaterials.MakeFade가 단일 출처다. + // _Mode 가드는 유지한다: 모델에 섞인 Standard 아닌 머티리얼(FX 셰이더 등)의 블렌드·키워드를 건드리지 않게. 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"); - } + SceneryMaterials.MakeFade(mat); // URP Lit if (mat.HasProperty("_Surface")) { @@ -2290,14 +2083,14 @@ public IEnumerator PlayHitFlashCoroutine(GameObject model) while (t < duration) { if (model == null) yield break; - t += Time.deltaTime; + t += PresentationDelta; 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); + model.transform.position = BattlePresentation.ReducedMotion ? originalPos : Vector3.Lerp(originalPos, backPos, backWeight); // 빨강 추가: 0 → 0.5 → 0 (피크 0.5) - float redWeight = Mathf.Sin(progress * Mathf.PI) * 0.5f; + float redWeight = BattlePresentation.ReducedFlashes ? 0f : Mathf.Sin(progress * Mathf.PI) * 0.5f; for (int i = 0; i < count; i++) { if (renderers[i] == null) continue; @@ -2350,12 +2143,18 @@ public void CleanupArena() bossModel = null; teamModels = null; activeEffectTexts.Clear(); + ClearImpactState(); rendererCache.Clear(); // 이 전투가 만든 머티리얼 일괄 파기 — GameObject를 지워도 머티리얼은 남는다. for (int i = 0; i < runtimeMaterials.Count; i++) if (runtimeMaterials[i] != null) Destroy(runtimeMaterials[i]); runtimeMaterials.Clear(); + for (int i = 0; i < runtimeMeshes.Count; i++) + if (runtimeMeshes[i] != null) Destroy(runtimeMeshes[i]); + runtimeMeshes.Clear(); + arenaFloor = null; + arenaLight = null; } public void HighlightTeamMember(int index) @@ -2388,17 +2187,33 @@ public void SetModelDead(int teamIndex) /// 계속 쌓였다. 전투 종료에 한 번에 정리해 누수를 전투 1회 수명으로 가둔다. /// private readonly List runtimeMaterials = new List(); + private readonly List runtimeMeshes = new List(); + /// + /// 불투명 아레나·연출 파츠 — 월드 소품과 같은 무광 마감이다(: + /// 셰이더 폴백 + ). 숲 공터 세트·벽·보스 받침·흙기둥·잎·파편이 전부 여기로 온다. + /// + /// 예전엔 Standard 기본 광택(0.5)이라 공터 흙과 벽이 필드와 달리 플라스틱처럼 반짝였다(2026-09-29 광택 통일). + /// 광택이 제 질감인 파츠(금속 칼날·조각, 물 구체)만 로 갈라 둔다. + /// 알파가 있는 색(임팩트 링·스파크)은 마감이 건너뛴다 — 불투명 셰이더라 알파는 어차피 안 보이고, 결과도 예전 그대로다. + /// private Material CreateSafeMaterial(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; + Material mat = SceneryMaterials.Create(color); runtimeMaterials.Add(mat); // CleanupArena가 일괄 파기 — 안 하면 연출마다 샌다 return mat; } + + /// + /// 광택이 제 질감인 스킬 파츠 — 금속()·물(). + /// 수치는 캐릭터 재질표()를 빌린다 — 여기 사본을 두지 않는다. + /// 반투명 색에는 걸지 않는다(무광 마감과 같은 규칙). + /// + private Material CreateSheenMaterial(Color color, SurfaceKind kind) + { + Material mat = CreateSafeMaterial(color); + if (!SceneryMaterials.IsTranslucent(color)) CharacterPalette.ApplySurface(mat, kind); + return mat; + } } } diff --git a/Assets/Scripts/Battle/BattleCameraDirector.cs b/Assets/Scripts/Battle/BattleCameraDirector.cs new file mode 100644 index 0000000..ab46393 --- /dev/null +++ b/Assets/Scripts/Battle/BattleCameraDirector.cs @@ -0,0 +1,412 @@ +using UnityEngine; + +namespace InsectGame.Battle +{ + /// + /// 전투 연출 카메라 — 스킬 한 번의 정규화 타임라인(0..1, 과 같은 축)을 + /// 샷(위치·회전·가중치)으로 바꾼다. 순수 계산이라 씬 없이 테스트로 고정한다. + /// + /// 배틀 구도(BattleFraming이 잡은 원거리 정면)는 건드리지 않는다. 샷은 그 위에 가중치로 + /// 얹히고(CameraFollower.SetBattleShot), 연출이 끝나면 가중치가 0으로 내려가 구도가 그대로 + /// 돌아온다. 예전엔 전투 내내 카메라가 한 자리에 서 있어서 스킬·피격·반격이 모두 같은 원경이었다. + /// + public static class BattleCameraDirector + { + public enum Style + { + /// 카메라는 구도에 고정 — 흔들림만. + Off, + /// + /// 구도 유지, 타격 순간에만 대상 쪽으로 짧게 튀었다 돌아온다. 1v1 기본값은 아니지만(2026-09-28 + /// 비교 후 시네마틱으로 결정) 레이드 팀원 공격처럼 한 라운드에 다섯 번 몰아치는 짧은 타격에 쓴다 — + /// 0.7초짜리 공격마다 카메라가 시전자와 보스를 오가면 어지럽다. + /// + Punch, + /// 시전자 클로즈업 → 대상 쪽으로 따라가 타격 클로즈업 → 원래 구도. + Cinematic + } + + /// 현재 연출 스타일. QA 캡처가 비교용으로 바꾼다. + public static Style Current { get; set; } = Style.Cinematic; + + public readonly struct Shot + { + public readonly Vector3 Position; + public readonly Quaternion Rotation; + public readonly float Weight; + public Shot(Vector3 position, Quaternion rotation, float weight) + { Position = position; Rotation = rotation; Weight = weight; } + public static readonly Shot None = new Shot(Vector3.zero, Quaternion.identity, 0f); + } + + // 타임라인 구간 — BattleMotion의 준비(0~0.18)·돌진(~0.40 타격)·복귀(~0.84)에 맞춘다. + private const float WindupEnd = 0.16f; + private const float HoldEnd = 0.58f; + private const float ReleaseEnd = 0.82f; + + /// 시전자 클로즈업 — 기본 구도에서 시전자 쪽으로 다가가는 비율. + public const float AttackerDolly = 0.30f; + /// 타격 클로즈업 — 기본 구도에서 대상 쪽으로 다가가는 비율(세기 1이면 더 붙는다). + public const float TargetDolly = 0.32f; + public const float TargetDollyHeavyBonus = 0.10f; + /// 펀치형 — 타격 순간 튀어 들어가는 비율. + public const float PunchDolly = 0.11f; + + /// 연출 스타일. + /// 스킬 타임라인 0..1(타격은 ). + /// 타임라인 전체 길이(초) — 타격 후 반동의 진동수를 초 단위로 맞춘다. + /// 배틀 구도 카메라 위치. + /// 배틀 구도 카메라 회전. + /// 시전자 중심(월드). + /// 대상 중심(월드). 자기 강화면 시전자와 같다. + /// 타격 세기 0..1(치명타·큰 피해·마무리일수록 1). + /// 강화·회복·약화 — 타격 클로즈업 없이 시전자만 살짝 본다. + public static Shot Evaluate(Style style, float progress, float durationSeconds, + Vector3 basePos, Quaternion baseRot, Vector3 attacker, Vector3 target, + float impactWeight, bool support) + { + return Evaluate(style, progress, durationSeconds, basePos, baseRot, attacker, target, impactWeight, support, + BattleMotion.ImpactProgress); + } + + /// 이 몸짓의 타격 진행률() — 계열마다 다르다(찌르기 0.36). + public static Shot Evaluate(Style style, float progress, float durationSeconds, + Vector3 basePos, Quaternion baseRot, Vector3 attacker, Vector3 target, + float impactWeight, bool support, float impactProgress) + { + float p = Mathf.Clamp01(progress); + float weight = Mathf.Clamp01(impactWeight); + if (style == Style.Off || p >= ReleaseEnd) return Shot.None; + float impactAt = Mathf.Clamp(impactProgress, WindupEnd + 0.02f, HoldEnd - 0.02f); + + float sinceImpact = (p - impactAt) * Mathf.Max(0.1f, durationSeconds); + Vector3 hitDir = target - attacker; + hitDir.y = 0f; + hitDir = hitDir.sqrMagnitude > 0.0001f ? hitDir.normalized : baseRot * Vector3.right; + + if (style == Style.Punch) + { + if (support || sinceImpact < 0f) return Shot.None; + // 순간 튀어 들어갔다가 지수로 빠진다 — 0.35초 안에 거의 원위치. + float w = Mathf.Exp(-sinceImpact * 9f); + if (w < 0.01f) return Shot.None; + Vector3 pos = Vector3.Lerp(basePos, target, PunchDolly + 0.05f * weight) + + Kick(hitDir, sinceImpact, 0.10f + 0.12f * weight); + Quaternion rot = Quaternion.Slerp(baseRot, LookAt(pos, target, baseRot), 0.25f); + return new Shot(pos, rot, w); + } + + // ── 시네마틱 ── + Vector3 attackerPos = Vector3.Lerp(basePos, attacker, AttackerDolly); + // 시선은 시전자 쪽에 둔다 — 대상 쪽으로 28% 당겼을 때 세로 화면(가로 시야가 좁다)에서 + // 시전자가 화면 끝에 반쯤 잘렸다(720×1280 QA 캡처). + Quaternion attackerRot = LookAt(attackerPos, Vector3.Lerp(attacker, target, 0.12f), baseRot); + + if (support) + { + // 강화는 몸을 부풀리는 순간이지 부딪치는 순간이 아니다 — 시전자만 가볍게 담는다. + float sw = Bell(p, 0f, 0.30f, 0.72f) * 0.7f; + if (sw <= 0f) return Shot.None; + Vector3 selfPos = Vector3.Lerp(basePos, attacker, AttackerDolly * 0.8f); + return new Shot(selfPos, LookAt(selfPos, attacker, baseRot), sw); + } + + float dolly = TargetDolly + TargetDollyHeavyBonus * weight; + Vector3 targetPos = Vector3.Lerp(basePos, target, dolly); + Quaternion targetRot = LookAt(targetPos, Vector3.Lerp(target, attacker, 0.12f), baseRot); + + if (p < WindupEnd) + { + float w = Mathf.SmoothStep(0f, 1f, p / WindupEnd); + return new Shot(attackerPos, attackerRot, w); + } + if (p < impactAt) + { + float k = Mathf.SmoothStep(0f, 1f, (p - WindupEnd) / (impactAt - WindupEnd)); + return new Shot(Vector3.Lerp(attackerPos, targetPos, k), + Quaternion.Slerp(attackerRot, targetRot, k), 1f); + } + Vector3 kicked = targetPos + Kick(hitDir, sinceImpact, 0.14f + 0.16f * weight); + if (p < HoldEnd) return new Shot(kicked, targetRot, 1f); + float release = 1f - Mathf.SmoothStep(0f, 1f, (p - HoldEnd) / (ReleaseEnd - HoldEnd)); + return new Shot(kicked, targetRot, release); + } + + /// + /// 레이드 합체공격 샷( 초 단위). 살짝 올라 팀 머리 위로 보스를 보고 → + /// 팀원이 차례로 때리는 동안 보스 쪽으로 서서히 들어가다 → 합동 일격 직전에 몰아쳐 붙고 → + /// 반동 뒤 원래 구도로 돌아온다. 레이드 기본 구도는 팀 뒤에서 보스를 정면으로 보므로 "보스 쪽"이 곧 + /// 카메라의 앞이다 — 붙는 비율(최대 0.38)은 카메라가 팀 줄(7.5m 앞)을 넘지 않게 잡았다. + /// + public static Shot EvaluateUnite(float seconds, Vector3 basePos, Quaternion baseRot, + Vector3 teamCenter, Vector3 bossCenter) + { + if (seconds <= 0f || seconds >= RaidUniteTimeline.Total) return Shot.None; + Vector3 axis = bossCenter - basePos; + axis.y = 0f; + axis = axis.sqrMagnitude > 0.0001f ? axis.normalized : baseRot * Vector3.forward; + + // 뒤로 물러나지 않는다 — 레이드 기본 구도가 이미 팀과 보스를 한 화면에 담는 원경이라, + // 더 물러나면 팀원이 점처럼 작아졌다(QA 캡처). 살짝 올라 팀 머리 위로 보스를 본다. + Vector3 wide = basePos + Vector3.up * 0.25f; + Vector3 approach = Vector3.Lerp(basePos, bossCenter, 0.2f) + Vector3.up * 0.3f; + Vector3 rush = Vector3.Lerp(basePos, bossCenter, 0.38f); + Quaternion wideRot = LookAt(wide, Vector3.Lerp(teamCenter, bossCenter, 0.55f), baseRot); + Quaternion bossRot = LookAt(rush, bossCenter, baseRot); + + float gather = RaidUniteTimeline.FinalStrike - 0.15f; + if (seconds < RaidUniteTimeline.MemberStart) + return new Shot(wide, wideRot, Mathf.SmoothStep(0f, 1f, seconds / RaidUniteTimeline.MemberStart)); + if (seconds < gather) + { + float k = Mathf.SmoothStep(0f, 1f, (seconds - RaidUniteTimeline.MemberStart) / (gather - RaidUniteTimeline.MemberStart)); + return new Shot(Vector3.Lerp(wide, approach, k), Quaternion.Slerp(wideRot, LookAt(approach, bossCenter, baseRot), k), 1f); + } + if (seconds < RaidUniteTimeline.FinalStrike) + { + float k = Mathf.SmoothStep(0f, 1f, (seconds - gather) / 0.15f); + return new Shot(Vector3.Lerp(approach, rush, k), Quaternion.Slerp(LookAt(approach, bossCenter, baseRot), bossRot, k), 1f); + } + float since = seconds - RaidUniteTimeline.FinalStrike; + Vector3 kicked = rush + Kick(axis, since, 0.35f); + float releaseStart = 0.35f; + float releaseSpan = RaidUniteTimeline.Total - RaidUniteTimeline.FinalStrike - releaseStart; + float weight = since < releaseStart ? 1f + : 1f - Mathf.SmoothStep(0f, 1f, (since - releaseStart) / Mathf.Max(0.01f, releaseSpan)); + return new Shot(kicked, bossRot, weight); + } + + // ── 등장·변신 연출 샷 — 시각표는 BattleStaging ── + // 셋 다 Style.Off면 걸지 않는다(구도 고정 비교용). 마지막엔 가중치가 0으로 내려가 배틀 구도가 그대로 돌아온다. + + /// + /// 상대 교체 등장(1대1 팀 대결) — 앞 구도(, 교체 전 배틀 구도)에서 출발해 착지점 클로즈업으로 + /// 옮겨 가고, 착지에 위아래로 튀고, 새 구도(, 새 곤충 크기로 다시 잡은 배틀 구도)로 풀린다. + /// 첫 프레임이 앞 구도라서 새 곤충 크기에 맞춰 구도가 바뀌어도 화면이 튀지 않는다. + /// + /// 교체 단계 진행률 0~1. + /// 교체 단계 길이(초) — 착지 반동의 진동수를 초 단위로 맞춘다. + /// 새 곤충이 서는 자리의 몸 중심. + /// 내 곤충 몸 중심 — 시선을 그쪽으로 조금 당겨 두 곤충의 관계를 남긴다. + public static Shot EvaluateEntrance(float progress, float durationSeconds, Vector3 fromPos, Quaternion fromRot, + Vector3 basePos, Quaternion baseRot, Vector3 landing, Vector3 opponent) + { + if (Current == Style.Off) return Shot.None; + float p = Mathf.Clamp01(progress); + if (p >= BattleStaging.EntranceCamReleaseEnd) return Shot.None; + + Vector3 close = Vector3.Lerp(basePos, landing, BattleStaging.EntranceDolly) + Vector3.up * 0.1f; + Quaternion closeRot = LookAt(close, Vector3.Lerp(landing, opponent, 0.2f), baseRot); + float sinceLand = (p - BattleStaging.EntranceLand) * Mathf.Max(0.1f, durationSeconds); + Vector3 kicked = close + Kick(Vector3.down, sinceLand, 0.1f); + + if (p < BattleStaging.EntranceCamIn) + { + float k = Mathf.SmoothStep(0f, 1f, p / BattleStaging.EntranceCamIn); + return new Shot(Vector3.Lerp(fromPos, close, k), Quaternion.Slerp(fromRot, closeRot, k), 1f); + } + if (p < BattleStaging.EntranceCamHoldEnd) return new Shot(kicked, closeRot, 1f); + float release = 1f - Mathf.SmoothStep(0f, 1f, (p - BattleStaging.EntranceCamHoldEnd) + / (BattleStaging.EntranceCamReleaseEnd - BattleStaging.EntranceCamHoldEnd)); + return new Shot(kicked, closeRot, release); + } + + /// + /// 그림자 변신(레이드) — 연기가 덮는 동안 보스 쪽으로 천천히 붙고, 새 모습이 울부짖을 때 뒤로 튀었다가, 원래 구도로 풀린다. + /// 레이드 기본 구도(팀 뒤 원경)에서 만 붙는다 — 카메라가 팀 줄을 넘지 않는다. + /// + public static Shot EvaluateBossTransform(float progress, float durationSeconds, Vector3 basePos, Quaternion baseRot, + Vector3 bossCenter) + { + if (Current == Style.Off) return Shot.None; + float p = Mathf.Clamp01(progress); + if (p >= BattleStaging.TransformCamReleaseEnd) return Shot.None; + + float push = Mathf.Lerp(BattleStaging.TransformDolly * 0.5f, BattleStaging.TransformDolly, + Mathf.SmoothStep(0f, 1f, p / BattleStaging.TransformSwap)); + Vector3 pos = Vector3.Lerp(basePos, bossCenter, push); + Quaternion rot = Quaternion.Slerp(baseRot, LookAt(pos, bossCenter, baseRot), 0.55f); + Vector3 axis = bossCenter - basePos; + axis.y = 0f; + axis = axis.sqrMagnitude > 0.0001f ? axis.normalized : baseRot * Vector3.forward; + // 새 모습이 울부짖는 순간 뒤로 밀린다(보스에게서 멀어지는 쪽). + pos += Kick(-axis, (p - BattleStaging.TransformRoar) * Mathf.Max(0.1f, durationSeconds), 0.18f); + + float weight; + if (p < BattleStaging.TransformCamIn) weight = Mathf.SmoothStep(0f, 1f, p / BattleStaging.TransformCamIn); + else if (p < BattleStaging.TransformCamHoldEnd) weight = 1f; + else weight = 1f - Mathf.SmoothStep(0f, 1f, (p - BattleStaging.TransformCamHoldEnd) + / (BattleStaging.TransformCamReleaseEnd - BattleStaging.TransformCamHoldEnd)); + return new Shot(pos, rot, weight); + } + + /// + /// 수문장 등장(레이드 인트로, 초) — (낮고 먼 곳)에서 (수문장이 화면 위쪽 + /// 창을 채우는 곳)로 감속하며 다가가고, 포효에 위로 튀고, 천천히 조금 더 붙었다가, 원래 구도로 풀린다. 회전은 내내 + /// (올려다보는 각) 하나다 — 그래서 다가가는 동안 수문장이 화면 위쪽 창에서 벗어나지 않는다. + /// 첫 프레임 가중치가 1이라 인트로 첫 장면부터 이 샷이다. + /// + public static Shot EvaluateGuardianIntro(float seconds, Vector3 startPos, Vector3 endPos, Quaternion rotation) + { + if (Current == Style.Off || seconds < 0f || seconds >= BattleStaging.GuardianReleaseEnd) return Shot.None; + float a = Mathf.Clamp01(seconds / BattleStaging.GuardianApproachEnd); + float k = 1f - (1f - a) * (1f - a) * (1f - a); // 감속 — 처음엔 성큼, 끝엔 조심스레 + Vector3 forward = rotation * Vector3.forward; + float creep = Mathf.SmoothStep(0f, 1f, (seconds - BattleStaging.GuardianApproachEnd) + / (BattleStaging.GuardianReleaseEnd - BattleStaging.GuardianApproachEnd)); + Vector3 pos = Vector3.Lerp(startPos, endPos, k) + forward * (BattleStaging.GuardianCreep * creep) + + Kick(Vector3.up, seconds - BattleStaging.GuardianRoarAt, BattleStaging.GuardianRoarKick); + float weight = seconds < BattleStaging.GuardianReleaseStart ? 1f + : 1f - Mathf.SmoothStep(0f, 1f, (seconds - BattleStaging.GuardianReleaseStart) + / (BattleStaging.GuardianReleaseEnd - BattleStaging.GuardianReleaseStart)); + return new Shot(pos, rotation, weight); + } + + // ── 전투 체감 샷 — 전용기·승리·진입. 시각표는 BattleFlourish ── + + /// + /// 전용기 — 첫 프레임에 시전자 클로즈업으로 끊어 들어가(0.08초) 컷인 동안() 천천히 더 붙고 + /// 옆으로 조금 돈다 → 컷인이 끝나면 대상 쪽으로 휘돌아 넘어가 타격 순간 대상 클로즈업(시네마틱보다 가깝다) → 반동 → 원래 구도. + /// 강화·회복 전용기는 시전자만 담고 풀린다. 면 걸지 않는다. + /// + /// 스킬 타임라인 0..1. + /// 타임라인 길이(연출 시계 초). + /// 타격 진행률(). + public static Shot EvaluateSignature(float progress, float durationSeconds, Vector3 basePos, Quaternion baseRot, + Vector3 attacker, Vector3 target, float impactWeight, bool support, float impactProgress) + { + if (Current == Style.Off) return Shot.None; + float p = Mathf.Clamp01(progress); + if (p >= ReleaseEnd) return Shot.None; + float dur = Mathf.Max(0.1f, durationSeconds); + float impactAt = Mathf.Clamp(impactProgress, WindupEnd + 0.02f, HoldEnd - 0.02f); + float seconds = p * dur; + float impactSeconds = impactAt * dur; + float cutEnd = BattleFlourish.SignatureCutInEndFor(impactSeconds); + float weight = Mathf.Clamp01(impactWeight); + + Vector3 toCaster = attacker - basePos; + toCaster.y = 0f; + Vector3 side = toCaster.sqrMagnitude > 0.0001f ? Vector3.Cross(Vector3.up, toCaster.normalized) : baseRot * Vector3.right; + float c = Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(seconds / cutEnd)); + Vector3 casterPos = Vector3.Lerp(Vector3.Lerp(basePos, attacker, BattleFlourish.SignatureCasterDollyStart), + Vector3.Lerp(basePos, attacker, BattleFlourish.SignatureCasterDollyEnd), c) + Vector3.up * 0.12f + side * (0.3f * c); + Quaternion casterRot = LookAt(casterPos, attacker + Vector3.up * 0.05f, baseRot); + float snapIn = Mathf.SmoothStep(0f, 1f, seconds / 0.08f); + + if (support) + { + if (seconds < cutEnd) return new Shot(casterPos, casterRot, snapIn); + float release = 1f - Mathf.SmoothStep(0f, 1f, (p - cutEnd / dur) / Mathf.Max(0.01f, HoldEnd - cutEnd / dur)); + return release <= 0f ? Shot.None : new Shot(casterPos, casterRot, release); + } + + Vector3 hitDir = target - attacker; + hitDir.y = 0f; + hitDir = hitDir.sqrMagnitude > 0.0001f ? hitDir.normalized : baseRot * Vector3.right; + Vector3 targetPos = Vector3.Lerp(basePos, target, BattleFlourish.SignatureTargetDolly + 0.06f * weight); + Quaternion targetRot = LookAt(targetPos, Vector3.Lerp(target, attacker, 0.12f), baseRot); + + if (seconds < cutEnd) return new Shot(casterPos, casterRot, snapIn); + if (p < impactAt) + { + float k = Mathf.SmoothStep(0f, 1f, (seconds - cutEnd) / Mathf.Max(0.01f, impactSeconds - cutEnd)); + return new Shot(Vector3.Lerp(casterPos, targetPos, k), Quaternion.Slerp(casterRot, targetRot, k), 1f); + } + Vector3 kicked = targetPos + Kick(hitDir, seconds - impactSeconds, 0.2f + 0.16f * weight); + if (p < HoldEnd) return new Shot(kicked, targetRot, 1f); + float fade = 1f - Mathf.SmoothStep(0f, 1f, (p - HoldEnd) / (ReleaseEnd - HoldEnd)); + return new Shot(kicked, targetRot, fade); + } + + /// + /// 승리 — 주인공(, 1대1 내 곤충·레이드 팀 한가운데) 둘레를 만큼 돌며 + /// 다가가 ·(주인공 중심 기준)에 선다. 얼굴 쪽()으로 돈다 — + /// 1대1은 상대가 있던 쪽에서 내 곤충의 얼굴을 비스듬히 본다. 0.3초에 걸쳐 원래 구도에서 넘어오고, 다 돈 뒤엔 그 자리에 머문다 + /// (시각 를 넘겨도 마지막 샷). + /// + public static Shot EvaluateVictoryOrbit(float seconds, Vector3 basePos, Quaternion baseRot, Vector3 pivot, Vector3 face, + float orbitDegrees, float endDistance, float endHeight) + { + if (Current == Style.Off || seconds < 0f) return Shot.None; + Vector3 off = basePos - pivot; + Vector3 flat = new Vector3(off.x, 0f, off.z); + float r0 = flat.magnitude; + Vector3 d0 = r0 > 0.01f ? flat / r0 : FlatOr(baseRot * Vector3.back, Vector3.back); + r0 = Mathf.Max(0.5f, r0); + Vector3 faceFlat = FlatOr(face, d0); + float sign = Mathf.Sign(Vector3.Cross(d0, faceFlat).y); + if (Mathf.Abs(Vector3.Cross(d0, faceFlat).y) < 0.0001f) sign = 1f; + + float u = Mathf.Clamp01(seconds / BattleFlourish.VictorySeconds); + float e = Mathf.SmoothStep(0f, 1f, u); + Vector3 dir = Quaternion.AngleAxis(sign * orbitDegrees * e, Vector3.up) * d0; + float r = Mathf.Lerp(r0, Mathf.Max(0.5f, endDistance), e); + float h = Mathf.Lerp(off.y, endHeight, e); + Vector3 pos = pivot + dir * r + Vector3.up * h; + Quaternion rot = LookAt(pos, pivot + Vector3.up * 0.1f, baseRot); + return new Shot(pos, rot, Mathf.SmoothStep(0f, 1f, seconds / 0.3f)); + } + + /// + /// 전투 진입(실제 초) — 상대() 바로 옆 낮은 자리, 배틀 구도에서 상대 쪽으로 + /// 돌아간 곳에서 상대를 보며 시작해, 감속하며 크게 휘돌아 물러나 배틀 구도에 정확히 내려앉는다(). + /// 첫 프레임 가중치 1 — 전투가 이 샷으로 열린다. 끝나면 (같은 자리라 튀지 않는다). + /// + /// 배틀 구도가 담는 한가운데(두 곤충·팀과 보스의 경계 중심). + public static Shot EvaluateOpening(float seconds, Vector3 basePos, Quaternion baseRot, Vector3 focus, Vector3 subject) + { + if (Current == Style.Off || seconds < 0f || seconds >= BattleFlourish.OpeningShotSeconds) return Shot.None; + float u = Mathf.Clamp01(seconds / BattleFlourish.OpeningShotSeconds); + float e = 1f - (1f - u) * (1f - u) * (1f - u); // 크게 휘돌다 감속하며 내려앉는다 + Vector3 off = basePos - focus; + Vector3 flat = new Vector3(off.x, 0f, off.z); + float r0 = flat.magnitude; + Vector3 d0 = r0 > 0.01f ? flat / r0 : FlatOr(baseRot * Vector3.back, Vector3.back); + r0 = Mathf.Max(0.5f, r0); + Vector3 toSubject = FlatOr(subject - focus, -d0); + float cross = Vector3.Cross(d0, toSubject).y; + float sign = Mathf.Abs(cross) < 0.0001f ? 1f : Mathf.Sign(cross); + + Vector3 pivot = Vector3.Lerp(subject, focus, e); + Vector3 dir = Quaternion.AngleAxis(sign * BattleFlourish.OpeningSwingDegrees * (1f - e), Vector3.up) * d0; + float r = Mathf.Lerp(r0 * BattleFlourish.OpeningStartDistance, r0, e); + float h = Mathf.Lerp(off.y * BattleFlourish.OpeningStartHeight, off.y, e); + Vector3 pos = pivot + dir * r + Vector3.up * h; + Quaternion look = LookAt(pos, Vector3.Lerp(subject, focus, e * e), baseRot); + Quaternion rot = Quaternion.Slerp(look, baseRot, Mathf.SmoothStep(0f, 1f, (u - 0.45f) / 0.55f)); + return new Shot(pos, rot, 1f); + } + + private static Vector3 FlatOr(Vector3 v, Vector3 fallback) + { + v.y = 0f; + return v.sqrMagnitude > 0.0001f ? v.normalized : fallback; + } + + /// + /// 타격 반동 — 맞은 방향으로 밀렸다가 감쇠 진동하며 돌아온다(무작위 흔들림과 달리 방향이 있다). + /// + public static Vector3 Kick(Vector3 direction, float secondsSinceImpact, float amplitude) + { + if (secondsSinceImpact < 0f || amplitude <= 0f) return Vector3.zero; + float t = secondsSinceImpact; + float wave = Mathf.Sin(t * 38f) * Mathf.Exp(-t * 9f); + return direction * (amplitude * wave); + } + + private static float Bell(float p, float start, float peak, float end) + { + if (p <= start || p >= end) return 0f; + return p < peak + ? Mathf.SmoothStep(0f, 1f, (p - start) / (peak - start)) + : 1f - Mathf.SmoothStep(0f, 1f, (p - peak) / (end - peak)); + } + + private static Quaternion LookAt(Vector3 from, Vector3 to, Quaternion fallback) + { + Vector3 d = to - from; + return d.sqrMagnitude > 0.0001f ? Quaternion.LookRotation(d) : fallback; + } + } +} diff --git a/Assets/Scripts/Battle/BattleCameraDirector.cs.meta b/Assets/Scripts/Battle/BattleCameraDirector.cs.meta new file mode 100644 index 0000000..6ce20d9 --- /dev/null +++ b/Assets/Scripts/Battle/BattleCameraDirector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9bd739069fdfa5b4792a60ea5906cf3d \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleCaptureChanceCalculator.cs b/Assets/Scripts/Battle/BattleCaptureChanceCalculator.cs index 154fef8..0861452 100644 --- a/Assets/Scripts/Battle/BattleCaptureChanceCalculator.cs +++ b/Assets/Scripts/Battle/BattleCaptureChanceCalculator.cs @@ -1,3 +1,4 @@ +using InsectGame.Core; using InsectGame.Data; using UnityEngine; @@ -38,7 +39,10 @@ internal static float Calculate( chance += Mathf.Max(0f, activeItemBonus); chance += Mathf.Max(0f, outfitBonus); - return Mathf.Clamp(chance, MinimumSuccessChance, MaximumSuccessChance); + // 최저 보장(10%)은 **레벨 유예 안에서만** 보장이다 — 캐릭터보다 훨씬 높은 곤충은 이겨도 + // 그 아래로 내려간다(미니게임 경로와 같은 TrainerLevelGap 배율). + return Mathf.Clamp(chance, MinimumSuccessChance, MaximumSuccessChance) + * TrainerLevelGap.CaptureMultiplier(playerLevel, insectLevel); } internal static float GetLevelModifier(int playerLevel, int insectLevel) diff --git a/Assets/Scripts/Battle/BattleEnvironment.cs b/Assets/Scripts/Battle/BattleEnvironment.cs new file mode 100644 index 0000000..bfc31b9 --- /dev/null +++ b/Assets/Scripts/Battle/BattleEnvironment.cs @@ -0,0 +1,135 @@ +using InsectGame.Core; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Battle +{ + /// + /// 한 곤충이 이번 전투에서 받는 낮·밤·날씨 보정 — 배수와 칩 문구. 전투 화면이 이 값을 그대로 칩으로 그린다. + /// 기본값(default)은 배수 0이라 가 거짓이다 — "아직 안 정함"도 "영향 없음"으로 읽힌다. + /// + public struct BattleEnvironmentNote + { + public float Multiplier; // 1 = 영향 없음, 1.1 = 유리, 0.9 = 불리 + public string Reason; // 칩 문구 — "밤 · 야행성", "비 · 비를 싫어함" 같은 짧은 말(기호·퍼센트 없음). 영향 없으면 빈 문자열 + public bool HasEffect => !UnityEngine.Mathf.Approximately(Multiplier, 1f) && Multiplier > 0f; + public int Percent => UnityEngine.Mathf.RoundToInt((Multiplier - 1f) * 100f); // +10 / -10 + public static BattleEnvironmentNote None => new BattleEnvironmentNote { Multiplier = 1f, Reason = string.Empty }; + } + + /// + /// 낮·밤·날씨 전투 보정의 순수 규칙 — 어디에 걸리는가()와 무엇을 곱하고 어떻게 말하는가 + /// (). 배수 자체는 가 단일 출처다 + /// (유리 ×1.10 · 불리 ×0.90). 컨트롤러(InsectBattleController)는 이 답을 받아 ATK·DEF에만 곱한다 — HP는 건드리지 않는다 + /// (HP바가 전투 시작에 튀지 않게). + /// + /// 야생 전투만. 수문장·NPC 대결(보스 포함)·샌드박스(꿈 챔피언전)·레이드는 저작된 밸런스라 시계·날씨로 흔들지 않는다. + /// 동굴 같은 실내 서브에리어도 제외다 — 그 안은 스폰도 시간·날씨를 안 본다. 나의 섬(분리 구역)은 하늘 아래라 걸린다. + /// + /// 날씨는 그 지역에서 보이는 날씨다(WorldStateProvider.GetWorldState(regionId)) — 설산의 비는 눈이다. + /// 섬은 지역이 없어 세계 날씨를 그대로 쓴다(). + /// + public static class BattleEnvironment + { + /// 이 전투에 낮·밤·날씨 보정을 거는가 — 야생 실외 전투일 때만 참이다. + public static bool Applies(bool duel, bool sandbox, bool guardian, bool inIndoorSubArea) + { + return !duel && !sandbox && !guardian && !inIndoorSubArea; + } + + /// 실내 서브에리어(동굴·방)인가. 분리 구역(나의 섬)은 하늘 아래라 실내가 아니다. + public static bool IsIndoor(SubAreaData subArea) + { + return subArea != null && !subArea.detached; + } + + /// + /// 전투가 날씨를 읽을 지역 ID — 서브에리어(섬)에 있으면 null(세계 날씨), 아니면 지금 리전. + /// 섬에 있는 동안 RegionManager.CurrentRegion은 마지막 리전에 붙어 있으므로 그걸 읽으면 안 된다. + /// 필드 HUD(WorldClockHUD)와 같은 기준이라 칩과 HUD의 날씨가 갈리지 않는다. + /// + public static string WeatherRegionId(RegionData region, SubAreaData subArea) + { + if (subArea != null) return null; + return region != null && !string.IsNullOrEmpty(region.regionId) ? region.regionId : null; + } + + /// 종 데이터의 이번 하늘 아래 보정(null이면 영향 없음). + public static BattleEnvironmentNote NoteFor(InsectData data, WorldState state) + { + if (data == null) return BattleEnvironmentNote.None; + return NoteFor(InsectHabits.For(data), state); + } + + /// + /// 성향의 이번 하늘 아래 보정. 문구는 시간대·날씨 가운데 실제로 배수를 만든 쪽만 적는다 — + /// 제 시간에 싫은 날씨처럼 서로 지운 쪽은 배수가 1이라 아예 칩이 없다. + /// + public static BattleEnvironmentNote NoteFor(InsectHabit habit, WorldState state) + { + float multiplier = InsectHabits.BattleStatMultiplier(habit, state); + if (Mathf.Approximately(multiplier, 1f) || !(multiplier > 0f)) return BattleEnvironmentNote.None; + + bool favorable = multiplier > 1f; + int timeFit = InsectHabits.TimeFit(habit, state.DayPhase); + int weatherFit = InsectHabits.WeatherFit(habit, state.Weather); + // 합친 궁합(InsectHabits.Fit)과 같은 방향인 쪽이 배수를 만들었다. 둘 다 0이면 배수도 1이라 여기 오지 않는다. + bool timeMade = favorable ? timeFit > 0 : timeFit < 0; + bool weatherMade = favorable ? weatherFit > 0 : weatherFit < 0; + + return new BattleEnvironmentNote + { + Multiplier = multiplier, + Reason = ReasonText(habit.Activity, state, timeMade, weatherMade, favorable) + }; + } + + // ── 문구 ── + // + // 칩이 짧아야 한다 — 시간만이면 "밤 · 야행성", 날씨만이면 "비 · 비를 싫어함", 둘 다면 "밤 · 야행성 · 비를 좋아함". + // 가장 긴 조합이 "아침 · 주행성 · 맑은 날을 좋아함"(20자, 공백 포함)이다 — BattleEnvironmentTests가 20자 상한을 본다. + // 방향(+/−)과 퍼센트는 칩이 Percent로 따로 붙인다. + + private static string ReasonText(InsectActivity activity, WorldState state, bool timeMade, bool weatherMade, + bool favorable) + { + string phase = WeatherForecast.DisplayName(state.DayPhase); + string activityName = ActivityName(activity); + string taste = WeatherObject(state.Weather) + (favorable ? " 좋아함" : " 싫어함"); + + if (timeMade && weatherMade && activityName.Length > 0) + return phase + " · " + activityName + " · " + taste; + if (timeMade && activityName.Length > 0) + return phase + " · " + activityName; + if (weatherMade) + return WeatherForecast.DisplayName(state.Weather) + " · " + taste; + + // 배수가 1이 아니면 둘 중 하나는 반드시 만든 쪽이다 — 표·규칙이 바뀌어 여기 와도 칩이 빈 채로 뜨지 않게 한다. + return phase + " · " + WeatherForecast.DisplayName(state.Weather); + } + + private static string ActivityName(InsectActivity activity) + { + switch (activity) + { + case InsectActivity.Nocturnal: return "야행성"; + case InsectActivity.Diurnal: return "주행성"; + default: return string.Empty; + } + } + + // 날씨를 목적어로 — "맑음을 좋아함"은 어색하고 "햇볕을 좋아함"은 밤(맑은 밤을 좋아하는 개미귀신·혜성나방)에 거짓이라 + // 맑은 날로 말한다. + private static string WeatherObject(WeatherType weather) + { + switch (weather) + { + case WeatherType.Rain: return "비를"; + case WeatherType.Fog: return "안개를"; + case WeatherType.Wind: return "센바람을"; + case WeatherType.Snow: return "눈을"; + default: return "맑은 날을"; + } + } + } +} diff --git a/Assets/Scripts/Battle/BattleEnvironment.cs.meta b/Assets/Scripts/Battle/BattleEnvironment.cs.meta new file mode 100644 index 0000000..616e62f --- /dev/null +++ b/Assets/Scripts/Battle/BattleEnvironment.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6a4618cfb8d8c6e4fb81c449389d90d7 \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleEscapeRules.cs b/Assets/Scripts/Battle/BattleEscapeRules.cs new file mode 100644 index 0000000..27397e7 --- /dev/null +++ b/Assets/Scripts/Battle/BattleEscapeRules.cs @@ -0,0 +1,25 @@ +using UnityEngine; + +namespace InsectGame.Battle +{ + /// + /// 전투에서 도망칠 확률의 순수 규칙 — 50%에서 레벨 차 1마다 5%씩 오르내리고 10%~90%로 가둔다. + /// 1v1 전투의 InsectBattleController.TryEscape와 습격 곤충에게서 도망치기가 같은 답을 읽는다(두 곳이 따로 계산하면 어긋난다). + /// + public static class BattleEscapeRules + { + public const float BaseChance = 0.5f; + public const float ChancePerLevel = 0.05f; + public const float MinChance = 0.1f; + public const float MaxChance = 0.9f; + + /// + /// 도망칠 확률(0.1~0.9). 레벨 차는 내 곤충 − 상대다 — 내가 높으면 잘 도망친다. + /// + public static float Chance(int playerLevel, int enemyLevel) + { + int levelDiff = playerLevel - enemyLevel; + return Mathf.Clamp(BaseChance + levelDiff * ChancePerLevel, MinChance, MaxChance); + } + } +} diff --git a/Assets/Scripts/Battle/BattleEscapeRules.cs.meta b/Assets/Scripts/Battle/BattleEscapeRules.cs.meta new file mode 100644 index 0000000..75a3da1 --- /dev/null +++ b/Assets/Scripts/Battle/BattleEscapeRules.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ea560e5bc7136ed499926c02545202a7 \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleFlourish.cs b/Assets/Scripts/Battle/BattleFlourish.cs new file mode 100644 index 0000000..4675903 --- /dev/null +++ b/Assets/Scripts/Battle/BattleFlourish.cs @@ -0,0 +1,197 @@ +using UnityEngine; + +namespace InsectGame.Battle +{ + /// + /// 전투 체감 연출의 순수 규칙 — 전용기 시각표, 승리 포즈·길이, 전투 진입 샷 길이, 대기 숨쉬기 곡선. + /// 씬 없이 BattleFlourishTests가 고정한다. 그리기는 BattleArenaController.Flourish·.Life, + /// 카메라 샷은 가 이 표를 읽는다. + /// + /// ui-dev가 맞출 값(같은 시각에 화면 글자를 그린다): + /// + /// 전용기 기술 이름 컷인 — ~(스킬 연출 시작부터 연출 시계 초). + /// 아레나의 IsSignaturePlaying·IsSignatureCutIn·SignatureCutInProgress가 지금 값을 준다. + /// 승리 — (실제 초) 동안 내 곤충이 포즈를 잡고 카메라가 반 바퀴 돈다. 아레나의 IsVictoryPlaying·VictoryFinished. + /// 전투 진입 샷 — (실제 초). 1대1 진입 구간() 안에서 끝난다. + /// + /// + public static class BattleFlourish + { + // ───────────── 전용기 ───────────── + + /// 전용기 컷인(시전자 클로즈업 + 기술 이름) 시작 — 스킬 연출 첫 프레임(연출 시계 초). + public const float SignatureCutInStart = 0f; + /// + /// 전용기 컷인 끝(연출 시계 초). 여기서 카메라가 대상 쪽으로 휘돌아 넘어가고 시전자가 달려든다. 타격이 이보다 이르면 + /// (짧은 연출) 타격 시각의 까지로 줄인다(). + /// + public const float SignatureCutInEnd = 0.5f; + /// 타격 시각 대비 컷인 비율의 상한 — 컷인 뒤에 달려들 시간이 남게. + public const float SignatureCutInShare = 0.62f; + /// 컷인 → 타격까지의 상한(초) — 아이가 지루하지 않게 1초 안쪽. 스킬 연출(2.5초)의 타격 진행률 0.4 이하가 이걸 지킨다. + public const float SignatureLeadMaxSeconds = 1.0f; + /// 타격 뒤에도 "전용기 연출 중"으로 남는 꼬리(초) — 큰 이펙트가 터지는 동안. + public const float SignatureTailSeconds = 0.4f; + /// 시전자 클로즈업 — 기본 구도에서 시전자 쪽으로 다가가는 비율(컷인 동안 시작 → 끝). + public const float SignatureCasterDollyStart = 0.4f; + public const float SignatureCasterDollyEnd = 0.52f; + /// 타격 클로즈업 비율(시네마틱 0.32보다 붙는다). + public const float SignatureTargetDolly = 0.4f; + /// 전용기 타격의 큰 이펙트 크기 배율(보통 속성 임팩트 대비). + public const float SignatureBurstScale = 1.8f; + + /// 이번 전용기 연출의 컷인 끝(초) — 타격 시각()을 넘지 않는다. + public static float SignatureCutInEndFor(float impactSeconds) + { + return Mathf.Max(0.05f, Mathf.Min(SignatureCutInEnd, impactSeconds * SignatureCutInShare)); + } + + // ───────────── 승리 ───────────── + + /// + /// 승리 연출 길이(실제 초 — 배속을 타지 않는다. 결과 화면의 ResultShownSeconds와 같은 시계). 1대1은 내 곤충의 포즈 + 카메라 반 바퀴, + /// 레이드는 팀 전원 점프 + 팀을 담는 카메라. 끝나면 카메라는 그 자리에 머문다(결과 화면 뒤). + /// + public const float VictorySeconds = 2.0f; + /// 몸 포즈가 끝나 제자리로 돌아오는 시각(초) — 보다 앞. + public const float VictoryPoseSeconds = 1.6f; + /// + /// 1대1 카메라가 내 곤충 둘레를 도는 각(도) — 얼굴 쪽으로 돈다. 배틀 구도는 내 곤충의 오른쪽 앞(얼굴에서 약 70°)에 있어서, + /// 120°를 돌면 얼굴을 지나 반대편 3/4 앞(얼굴에서 약 50°)에 선다. 150°면 얼굴에서 80° 넘게 지나 옆모습이 됐다(수치 모의). + /// + public const float VictoryOrbitDegrees = 120f; + /// 레이드 — 팀 뒤에서 팀 앞(보스가 있던 쪽)으로 도는 각(도). + public const float RaidVictoryOrbitDegrees = 140f; + /// 레이드 팀원이 차례로 뛰어오르는 간격(초). + public const float RaidVictoryStagger = 0.08f; + /// 1대1 끝 구도 — 처음 거리의 이 비율까지 다가간다. + public const float VictoryCloseRatio = 0.5f; + public const float VictoryMinDistance = 2.2f; + + /// + /// 승리 포즈(시각 초) — 계열마다 다르다. 0 이하· 이상은 제자리. + /// 딱정벌레: 두 번 깡충(정점에서 뿔을 쳐든다) · 사마귀: 앞다리를 쳐들고 서서 깡충 · 나는 종: 솟아올라 한 바퀴 돌며 떠 있다 내려온다 · + /// 벌: 솟아올라 윙윙 좌우로 몸을 흔든다 · 지네·거미: 몸을 세우고 깡충 · 그 밖: 크게 뛰며 한 바퀴. + /// + public static BattleMotion.Pose VictoryPose(BattleMotion.Family family, float seconds) + { + float s = seconds; + if (s <= 0f || s >= VictoryPoseSeconds) return new BattleMotion.Pose(0f, 0f, 0f, 0f, 0f); + switch (family) + { + case BattleMotion.Family.Beetle: + { + float h1 = Hop(s, 0.12f, 0.58f, 0.38f); + float h2 = Hop(s, 0.68f, 1.12f, 0.3f); + float toss = BattleMotion.Bump(s, 0.18f, 0.55f) + BattleMotion.Bump(s, 0.74f, 1.1f); + float settle = BattleMotion.Bump(s, 1.12f, 1.5f); + return new BattleMotion.Pose(0f, h1 + h2, -16f * toss + 5f * settle, 0f, 0f); + } + case BattleMotion.Family.Mantis: + { + float rear = Plateau(s, 0.08f, 0.4f, 1.15f, 1.5f); + float hop = Hop(s, 0.45f, 0.82f, 0.24f); + float sway = 6f * rear * Mathf.Sin(s * 9f); + return new BattleMotion.Pose(0f, hop + 0.08f * rear, -28f * rear, sway, 10f * BattleMotion.Bump(s, 0.85f, 1.3f)); + } + case BattleMotion.Family.Flier: + { + float up = Plateau(s, 0.08f, 0.5f, 1.1f, 1.55f); + float bob = 0.06f * up * Mathf.Sin(s * 8f); + float spin = 360f * Mathf.SmoothStep(0f, 1f, (s - 0.2f) / 0.9f); + return new BattleMotion.Pose(0f, 0.7f * up + bob, -12f * up, 10f * up * Mathf.Sin(s * 6f), spin); + } + case BattleMotion.Family.Bee: + { + float up = Plateau(s, 0.08f, 0.36f, 1.15f, 1.55f); + float zip = up * Mathf.Sin(s * 22f); + return new BattleMotion.Pose(0f, 0.45f * up + 0.03f * Mathf.Sin(s * 40f) * up, -8f * up, 18f * zip, 25f * zip); + } + case BattleMotion.Family.Crawler: + { + float rear = Plateau(s, 0.08f, 0.4f, 1.0f, 1.45f); + float hop = Hop(s, 0.48f, 0.9f, 0.3f); + return new BattleMotion.Pose(0f, 0.12f * rear + hop, -24f * rear, 0f, 0f); + } + default: + { + float big = Hop(s, 0.14f, 0.76f, 0.5f); + float small = Hop(s, 0.86f, 1.2f, 0.18f); + float spin = 360f * Mathf.SmoothStep(0f, 1f, (s - 0.14f) / 0.62f); + return new BattleMotion.Pose(0f, big + small, -10f * BattleMotion.Bump(s, 0.14f, 0.76f), 0f, spin); + } + } + } + + /// a~b 동안 꼭짓점 의 포물선 깡충. + private static float Hop(float s, float a, float b, float height) + { + if (s <= a || s >= b) return 0f; + float u = (s - a) / (b - a); + return height * 4f * u * (1f - u); + } + + /// a→b 동안 0→1로 오르고 c까지 머물다 d까지 0으로 내린다. + private static float Plateau(float s, float a, float b, float c, float d) + { + if (s <= a || s >= d) return 0f; + if (s < b) return Mathf.SmoothStep(0f, 1f, (s - a) / (b - a)); + if (s < c) return 1f; + return 1f - Mathf.SmoothStep(0f, 1f, (s - c) / (d - c)); + } + + /// + /// 무리 전체를 화면에 담는 카메라 거리 — 가로 반폭·세로 반높이(m)를 시야각·화면비에서 잰다(여유 배). + /// 레이드 승리 샷의 끝 거리가 쓴다(세로 화면은 가로 시야가 좁아 더 물러난다). + /// + public static float FitDistance(float halfWidth, float halfHeight, float verticalFov, float aspect, float margin = 1.15f) + { + float vHalf = Mathf.Max(1f, verticalFov) * 0.5f * Mathf.Deg2Rad; + float hHalf = Mathf.Atan(Mathf.Tan(vHalf) * Mathf.Max(0.2f, aspect)); + float byWidth = Mathf.Max(0.1f, halfWidth) / Mathf.Tan(hHalf); + float byHeight = Mathf.Max(0.1f, halfHeight) / Mathf.Tan(vHalf); + return Mathf.Max(byWidth, byHeight) * Mathf.Max(1f, margin); + } + + // ───────────── 전투 진입 샷 ───────────── + + /// + /// 전투 진입 샷 길이(실제 초 — 배속을 타지 않는다). 카메라가 상대 바로 옆에서 크게 휘돌며 물러나 배틀 구도에 내려앉는다. + /// 시계는 1대1 진입 구간과 같다(BattleScreenUI.EntryIntroProgress × = 이 샷의 경과 초). + /// 요구가 "1초 안쪽"이라 구간(실제 1.4초)의 앞 1초에 끝내고, 남은 0.4초는 ui-dev의 「야생 ○○이(가) 나타났다!」가 내려앉은 구도 위에서 읽힌다 + /// (BattleFlourishTests가 구간 안인지 본다). 레이드(수문장 아님)도 같은 길이 — 레이드 인트로도 실제 1.4초 하한이다. + /// + public const float OpeningShotSeconds = 1.0f; + /// 출발 각 — 배틀 구도에서 상대 쪽으로 이만큼 돌아간 자리에서 시작한다(도). + public const float OpeningSwingDegrees = 75f; + /// 출발 거리 — 기본 구도 거리의 이 비율(상대에게 바짝). + public const float OpeningStartDistance = 0.42f; + /// 출발 높이 — 기본 구도 높이의 이 비율. + public const float OpeningStartHeight = 0.55f; + + // ───────────── 대기 숨쉬기 ───────────── + + /// 숨 한 번의 길이(초). 보스는 . + public const float BreathPeriod = 1.9f; + public const float BossBreathPeriod = 2.5f; + /// 몸통이 부푸는 몫(가로·앞뒤). 세로는 그 60%. + public const float BreathAmplitude = 0.035f; + + /// 숨 -1..1 — (0..1)로 곤충마다 박자를 어긋낸다. + public static float Breath(float seconds, float period, float phase) + { + return Mathf.Sin((seconds / Mathf.Max(0.1f, period) + phase) * Mathf.PI * 2f); + } + + /// + /// 더듬이 까딱임(도, 양수 = 앞으로 숙임) — 느린 흔들림(±4°)에 2.6초마다 한 번 빠르게 까딱(14°). 좌우는 로 어긋낸다. + /// + public static float AntennaNod(float seconds, float phase) + { + float sway = 4f * Mathf.Sin((seconds / 1.7f + phase) * Mathf.PI * 2f); + float c = Mathf.Repeat(seconds / 2.6f + phase * 1.3f, 1f); + float flick = c < 0.14f ? Mathf.Sin(c / 0.14f * Mathf.PI) : 0f; + return sway + 14f * flick; + } + } +} diff --git a/Assets/Scripts/Battle/BattleFlourish.cs.meta b/Assets/Scripts/Battle/BattleFlourish.cs.meta new file mode 100644 index 0000000..e29a949 --- /dev/null +++ b/Assets/Scripts/Battle/BattleFlourish.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8e19615930b1972459581a969ca5063d \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleFraming.cs b/Assets/Scripts/Battle/BattleFraming.cs new file mode 100644 index 0000000..63440f3 --- /dev/null +++ b/Assets/Scripts/Battle/BattleFraming.cs @@ -0,0 +1,88 @@ +using UnityEngine; + +namespace InsectGame.Battle +{ + /// Fits combatant bounds above the action panel. Coordinates are viewport coordinates. + public static class BattleFraming + { + public static float DuelHalfSeparation(Bounds ally, Bounds opponent) + { + // Keep small combatants readable while leaving room for wide wings/horns. + return Mathf.Max(1.55f, (ally.extents.x + opponent.extents.x + 0.9f) * 0.5f); + } + public static void Compute(Bounds bounds, float aspect, float fieldOfView, + out Vector3 position, out Vector3 target) + { + ComputeDuel(bounds, aspect, fieldOfView, Quaternion.Euler(12f, -12f, 0f), + new Rect(0f, 0f, 1f, 1f), out position, out target); + } + + public static void Compute(Bounds bounds, float aspect, float fieldOfView, Quaternion rotation, + Rect safeViewport, out Vector3 position, out Vector3 target) + { + ComputeWindow(bounds, aspect, fieldOfView, rotation, safeViewport, 0.40f, 0.84f, out position, out target); + } + + /// + /// (뷰포트 좌표의 창) 안에 경계를 맞춘다 — 위의 둘과 달리 HUD 여백 비율(아래 패널 몫)을 따로 두지 않고 + /// 창을 그대로 쓴다(가로만 창 폭의 8%씩 안쪽). 수문장 등장 컷이 "화면 위쪽 2/3"을 창으로 넘긴다(). + /// + public static void ComputeInWindow(Bounds bounds, float aspect, float fieldOfView, Quaternion rotation, + Rect window, out Vector3 position, out Vector3 target) + { + ComputeWindow(bounds, aspect, fieldOfView, rotation, window, 0f, 1f, out position, out target); + } + + public static void ComputeDuel(Bounds bounds, float aspect, float fieldOfView, Quaternion rotation, + Rect safeViewport, out Vector3 position, out Vector3 target) + { + ComputeWindow(bounds, aspect, fieldOfView, rotation, safeViewport, 0.34f, 0.77f, out position, out target); + } + + private static void ComputeWindow(Bounds bounds, float aspect, float fieldOfView, Quaternion rotation, + Rect safeViewport, float lower, float upper, out Vector3 position, out Vector3 target) + { + Quaternion inverse = Quaternion.Inverse(rotation); + float tanV = Mathf.Tan(Mathf.Clamp(fieldOfView, 20f, 100f) * Mathf.Deg2Rad * 0.5f); + float tanH = tanV * Mathf.Max(0.5f, aspect); + float left = Mathf.Lerp(safeViewport.xMin, safeViewport.xMax, 0.08f) * 2f - 1f; + float right = Mathf.Lerp(safeViewport.xMin, safeViewport.xMax, 0.92f) * 2f - 1f; + float bottom = Mathf.Lerp(safeViewport.yMin, safeViewport.yMax, lower) * 2f - 1f; + float top = Mathf.Lerp(safeViewport.yMin, safeViewport.yMax, upper) * 2f - 1f; + float centerX = (left + right) * 0.5f; + float centerY = (bottom + top) * 0.5f; + float halfX = Mathf.Max(0.05f, (right - left) * 0.5f); + float halfY = Mathf.Max(0.05f, (top - bottom) * 0.5f); + float distance = 3f; + // Fit the requested HUD-free window, preserving the raid's separate layout. + for (int i = 0; i < 8; i++) + { + Vector3 corner = Vector3.Scale(bounds.extents, new Vector3( + (i & 1) == 0 ? -1f : 1f, (i & 2) == 0 ? -1f : 1f, (i & 4) == 0 ? -1f : 1f)); + Vector3 p = inverse * corner; + distance = Mathf.Max(distance, (p.x - right * tanH * p.z) / (halfX * tanH)); + distance = Mathf.Max(distance, (-p.x + left * tanH * p.z) / (halfX * tanH)); + distance = Mathf.Max(distance, (p.y - top * tanV * p.z) / (halfY * tanV)); + distance = Mathf.Max(distance, (-p.y + bottom * tanV * p.z) / (halfY * tanV)); + } + distance += 0.35f; + target = bounds.center - rotation * new Vector3(distance * tanH * centerX, distance * tanV * centerY, 0f); + position = target - rotation * Vector3.forward * distance; + } + + public static Bounds ModelBounds(GameObject model) + { + if (model == null) return new Bounds(Vector3.zero, Vector3.one); + Bounds result = new Bounds(model.transform.position, Vector3.one * 0.2f); + bool found = false; + foreach (Renderer renderer in model.GetComponentsInChildren()) + { + if (!renderer.enabled || !renderer.gameObject.activeInHierarchy || renderer is ParticleSystemRenderer + || renderer.name == "BossAura" || renderer.name.Contains("Glow")) continue; + if (!found) { result = renderer.bounds; found = true; } + else result.Encapsulate(renderer.bounds); + } + return result; + } + } +} diff --git a/Assets/Scripts/Battle/BattleFraming.cs.meta b/Assets/Scripts/Battle/BattleFraming.cs.meta new file mode 100644 index 0000000..365e78a --- /dev/null +++ b/Assets/Scripts/Battle/BattleFraming.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8aeec180e8ce5744494421374dd97ec9 \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleKind.cs b/Assets/Scripts/Battle/BattleKind.cs new file mode 100644 index 0000000..2e0771e --- /dev/null +++ b/Assets/Scripts/Battle/BattleKind.cs @@ -0,0 +1,71 @@ +using InsectGame.Core; + +namespace InsectGame.Battle +{ + /// + /// 1대1 전투의 종류 — 화면 문구(「야생 ○○이(가) 나타났다!」·「집게가 승부를 걸어왔다!」)와 연출을 고르는 값. + /// 화면에서는 BattleScreenUI.CurrentBattleKind로 읽는다(라온 단계 표를 아는 쪽이 UI라서 거기서 가른다). + /// 레이드는 이 값을 쓰지 않는다 — 수문장 레이드인지는 RaidBattleController.BossGuardianRegionId로 안다. + /// + public enum BattleKind + { + /// 필드의 야생 곤충(습격·나의 섬 손님 포함). + Wild = 0, + /// 리전 수문장(1대1 — 영웅·전설 수문장은 레이드다). + Guardian, + /// 곤충잡이 아이와의 대결. + KidDuel, + /// 명부회 간부·하수와의 대결(장부가 걸린다 — 팀 대결 포함). + BossDuel, + /// 라온 라이벌 대결. + RivalDuel, + /// 「챔피언의 꿈」 챔피언전(샌드박스 — 보상·도감·포획 없음). + Sandbox + } + + /// + /// 판정 — 순수. 컨트롤러의 시작 시점 표지만 받는다(IsSandbox·EnemyGuardianRegionId· + /// IsDuel·DuelOpponentId). 우선순위: 샌드박스 → 수문장 → 야생 → 아이(상대 표지 없음) → 라온(라이벌 단계) → 간부. + /// + /// 상대 표지는 시작 함수 다음에 선다(StartDuel → SetDuelOpponent) — 시작 콜백(BattleUpdated) 안에서 물으면 + /// 간부·라온도 아이로 보인다. 화면은 매 프레임 묻는다(첫 OnGUI 때는 이미 섰다). + /// + public static class BattleKinds + { + public static BattleKind Classify(bool sandbox, bool guardian, bool duel, bool hasOpponent, bool rivalStage) + { + if (sandbox) return BattleKind.Sandbox; + if (guardian) return BattleKind.Guardian; + if (!duel) return BattleKind.Wild; + if (!hasOpponent) return BattleKind.KidDuel; + return rivalStage ? BattleKind.RivalDuel : BattleKind.BossDuel; + } + + /// 사람이 거는 대결인가(아이·간부·라온) — 「○○이(가) 승부를 걸어왔다!」 쪽 문구. + public static bool IsTrainerDuel(BattleKind kind) + => kind == BattleKind.KidDuel || kind == BattleKind.BossDuel || kind == BattleKind.RivalDuel; + } + + /// + /// 전투 곡 고르기 — 순수. 전환은 전투를 연 쪽이 한다: 1대1 시작 신호에 화면이 을 먼저 걸고, + /// 대결을 연 NpcDuelController가 같은 프레임에 간부·라온 곡으로 덮는다(AudioManager.PlayBGM은 한 프레임 늦게 틀어 + /// 마지막 요청만 남는다). 레이드는 화면(RaidBattleUI)의 시작 핸들러가 한 번에 고른다. + /// 새 곡을 늘리면 AudioManager의 등록 4지점(문자열·전환 호출부·생성기·IsCombatBgm)은 visual-dev 몫이다. + /// + public static class BattleMusic + { + /// 1대1 — 수문장(초원 사마귀처럼 1대1로 맞서는 수문장)이면 수문장 곡, 아니면 전투 곡. 대결은 NpcDuelController가 덮는다. + public static BgmType OneVsOne(string guardianRegionId) + => string.IsNullOrEmpty(guardianRegionId) ? BgmType.Battle : BgmType.Guardian; + + /// 레이드 — 수문장 레이드면 수문장 곡, 아니면 레이드 곡. + public static BgmType Raid(string guardianRegionId) + => string.IsNullOrEmpty(guardianRegionId) ? BgmType.RaidBattle : BgmType.Guardian; + + /// 명부회 대결 — 최종전(관장 하월·무명)과 간부전을 가른다. + public static BgmType BossDuel(bool isFinal) => isFinal ? BgmType.BossFinal : BgmType.BossLedger; + + /// 라온 라이벌 대결. + public static BgmType RivalDuel => BgmType.Rival; + } +} diff --git a/Assets/Scripts/Battle/BattleKind.cs.meta b/Assets/Scripts/Battle/BattleKind.cs.meta new file mode 100644 index 0000000..583bfe0 --- /dev/null +++ b/Assets/Scripts/Battle/BattleKind.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7b747047403f47b41822a9e339bd5557 \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleMotion.cs b/Assets/Scripts/Battle/BattleMotion.cs new file mode 100644 index 0000000..d009348 --- /dev/null +++ b/Assets/Scripts/Battle/BattleMotion.cs @@ -0,0 +1,327 @@ +using UnityEngine; + +namespace InsectGame.Battle +{ + /// + /// 전투 몸짓 — 정규화 타임라인(0..1)의 몸 자세. 순수 계산이라 피해·턴을 정하지 않는다(BattleMotionTests·BattleFlourishTests). + /// + /// 계열()이 몸짓을 고른다 — 모델 빌더(InsectEntity.BuildModel)가 그 ID로 어떤 몸을 짓는지와 같은 순서로 가른다. + /// 사마귀는 앞다리로 두 번 벤다(), 딱정벌레·풍뎅이는 머리를 숙이고 돌진(), + /// 나비·나방·잠자리 같은 나는 종은 날아올라 내리꽂고(), 벌·말벌은 찌르기 돌진(), + /// 지네·거미·개미귀신은 덮친다(), 그 밖(개미·귀뚜라미·메뚜기·대벌레…)은 예전의 가벼운 돌진(). + /// 원거리 속성 기술()도 계열마다 시전 자세가 다르다 — 나는 종은 떠오르며, 사마귀는 베어 날리며 쏜다. + /// + /// 타격 순간은 가 계열마다 낸다(찌르기만 0.36, 나머지 0.40). 1대1은 아레나가 그 순간에 onImpact를 불러 + /// 피해 숫자·HP 감소가 따라온다 — 숫자가 따로 시각을 세지 않는다. 카메라 샷도 같은 값을 받는다(BattleCameraDirector.Evaluate). + /// 레이드 팀원 공격은 길이가 정해진 볼리(0.46초 끝에 타격 — RaidBattleUI의 숫자 시각이 그걸 가정한다) 안에 이 곡선을 눌러 담는다. + /// + public static class BattleMotion + { + /// 공용 타격 진행률 — 돌진·베기(두 번째)·내리꽂기·덮치기·원거리 발사. + public const float ImpactProgress = 0.4f; + /// 벌의 찌르기 — 짧게 띄웠다가 곧게 쏘아 들어가서 조금 이르다. + public const float StingImpactProgress = 0.36f; + /// 사마귀의 첫 번째 베기 — 다가가며 한 번(가벼운 움찔), 타격 순간()에 두 번째. + public const float SlashFirstSwing = 0.29f; + /// 이 진행률부터 몸이 제자리다(모든 몸짓의 끝). + public const float RestProgress = 0.84f; + + private const float Windup = 0.18f; + private const float RecoverStart = 0.55f; + + public enum Kind { Charge, Slash, Flight, Projectile, Support, Sting, Pounce, Lunge } + + /// 몸 모양 계열 — 모델 빌더의 분기와 같은 기준이다. + public enum Family { Beetle, Mantis, Flier, Bee, Crawler, Other } + + public readonly struct Pose + { + public readonly float Travel; + public readonly float Lift; + public readonly float Pitch; + public readonly float Roll; + /// 몸 돌리기(도) — 사마귀 베기의 좌우 휘두름, 승리 포즈의 제자리 돌기. + public readonly float Yaw; + public Pose(float travel, float lift, float pitch, float roll) + { Travel = travel; Lift = lift; Pitch = pitch; Roll = roll; Yaw = 0f; } + public Pose(float travel, float lift, float pitch, float roll, float yaw) + { Travel = travel; Lift = lift; Pitch = pitch; Roll = roll; Yaw = yaw; } + + public Pose Scaled(float k) => new Pose(Travel * k, Lift * k, Pitch * k, Roll * k, Yaw * k); + } + + private static readonly Pose Rest = new Pose(0f, 0f, 0f, 0f, 0f); + + /// + /// 곤충 ID → 몸 계열. InsectEntity.BuildModel의 분기 순서를 그대로 따른다 — 구체적인 것을 먼저 본다 + /// (antlion을 ant보다, beetle을 bee보다, dragonfly·butterfly·firefly를 fly보다 먼저). 모르는 ID는 그 빌더처럼 딱정벌레다. + /// + public static Family FamilyOf(string speciesId) + { + string id = (speciesId ?? string.Empty).ToLowerInvariant(); + if (id.Length == 0) return Family.Beetle; + if (id.Contains("antlion")) return Family.Crawler; + if (id.Contains("aphid")) return Family.Other; + if (id.Contains("butterfly") || id.Contains("alexandras")) return Family.Flier; + if (id.Contains("moth") || id.Contains("luna") || id.Contains("atlas")) return Family.Flier; + if (id.Contains("orchid") || id.Contains("ghost") || id.Contains("mantis")) return Family.Mantis; + if (id.Contains("damselfly") || id.Contains("dragonfly")) return Family.Flier; + if (id.Contains("firefly")) return Family.Flier; + if (id.Contains("bee") && !id.Contains("beetle")) return Family.Bee; + if (id.Contains("hornet") || id.Contains("wasp")) return Family.Bee; + if (id.Contains("rhinoceros") || id.Contains("hercules") || id.Contains("stag")) return Family.Beetle; + if (id.Contains("cicada")) return Family.Flier; + if (id.Contains("cricket") || id.Contains("katydid")) return Family.Other; + if (id.Contains("ant") && !id.Contains("phantom")) return Family.Other; + if (id.Contains("strider")) return Family.Other; + if (id.Contains("diving")) return Family.Beetle; + if (id.Contains("scarab") || id.Contains("jewel") || id.Contains("diamond") || id.Contains("celestial")) return Family.Beetle; + if (id.Contains("ladybug")) return Family.Beetle; + if (id.Contains("grasshopper") || id.Contains("locust")) return Family.Other; + if (id.Contains("spider")) return Family.Crawler; + if (id.Contains("stick_insect") || id.Contains("leaf_insect")) return Family.Other; + if (id.Contains("centipede")) return Family.Crawler; + if (id.Contains("pill_bug")) return Family.Beetle; + if (id.Contains("earwig")) return Family.Other; + if (id.Contains("longhorn")) return Family.Beetle; + if (id.Contains("caterpillar")) return Family.Other; + if (id.Contains("mosquito") || id.Contains("fly")) return Family.Flier; + return Family.Beetle; // dung·click·beetle_*·이름 모를 종 — 빌더의 GenericBeetle + } + + /// 계열의 근접 몸짓. + public static Kind MeleeKindOf(Family family) + { + switch (family) + { + case Family.Mantis: return Kind.Slash; + case Family.Flier: return Kind.Flight; + case Family.Bee: return Kind.Sting; + case Family.Crawler: return Kind.Pounce; + case Family.Other: return Kind.Lunge; + default: return Kind.Charge; + } + } + + public static Kind Resolve(string speciesId, bool melee, bool support) + { + if (support) return Kind.Support; + if (!melee) return Kind.Projectile; + return MeleeKindOf(FamilyOf(speciesId)); + } + + /// 그 몸짓의 타격 진행률(0..1). 1대1 onImpact·카메라 샷·전용기 시각표가 이 값을 함께 읽는다. + public static float ImpactOf(Kind kind) => kind == Kind.Sting ? StingImpactProgress : ImpactProgress; + + /// 타격 전에 한 번 더 휘두르는 몸짓인가(사마귀 베기 — ). + public static bool HasPreStrike(Kind kind) => kind == Kind.Slash; + + /// 예전 시그니처 — 원거리 시전 자세는 계열 없는 기본값(). + public static Pose Evaluate(Kind kind, float normalizedProgress) => Evaluate(kind, Family.Other, normalizedProgress); + + /// 몸짓 한 프레임. 0 이하· 이상은 제자리, 지원 몸짓은 늘 제자리. + public static Pose Evaluate(Kind kind, Family family, float normalizedProgress) + { + float t = Mathf.Clamp01(normalizedProgress); + if (t >= RestProgress || t <= 0f || kind == Kind.Support) return Rest; + switch (kind) + { + case Kind.Slash: return Slash(t); + case Kind.Flight: return Flight(t); + case Kind.Sting: return Sting(t); + case Kind.Pounce: return Pounce(t); + case Kind.Lunge: return Lunge(t); + case Kind.Projectile: return Cast(family, t); + default: return Charge(t); + } + } + + // ───────────── 곡선 부품 ───────────── + + private static float Wind(float t) => Mathf.Sin(Mathf.Clamp01(t / Windup) * Mathf.PI * 0.5f); + private static float Outbound(float t, float impact) => Mathf.SmoothStep(0f, 1f, (t - Windup) / (impact - Windup)); + private static float Recover(float t) => 1f - Mathf.SmoothStep(0f, 1f, (t - RecoverStart) / (RestProgress - RecoverStart)); + + /// a~b에서 0 → 1 → 0으로 솟는 반쪽 사인. 구간 밖은 0. + public static float Bump(float t, float a, float b) + { + if (t <= a || t >= b) return 0f; + return Mathf.Sin((t - a) / (b - a) * Mathf.PI); + } + + /// + /// 딱정벌레 — 뒤로 물러서며 머리를 숙이고(16°), 숙인 채 들이받고, 받는 순간 뿔을 쳐올린다(−14°). 낮게 간다(뜨지 않는다). + /// + private static Pose Charge(float t) + { + const float I = ImpactProgress; + float w = Wind(t); + float o = Outbound(t, I); + float r = Recover(t); + float reach = t < Windup ? -0.08f * w + : (Mathf.Lerp(-0.08f, 1f, o) + 0.06f * Bump(t, I, I + 0.14f)) * r; + float headDown = t < Windup ? w : t < I ? 1f : 1f - Mathf.SmoothStep(0f, 1f, (t - I) / 0.08f); + float pitch = 16f * headDown - 14f * Bump(t, I + 0.02f, I + 0.18f); + return new Pose(reach, 0f, pitch, 0f, 0f); + } + + /// 그 밖 — 예전 공용 돌진(가볍게 숙였다 든다)에 작은 깡충을 얹었다. + private static Pose Lunge(float t) + { + float w = Wind(t); + float o = Outbound(t, ImpactProgress); + float r = Recover(t); + float reach = t < Windup ? -0.065f * w : Mathf.Lerp(-0.065f, 1f, o) * r; + float tension = t < Windup ? w : (1f - o) * r; + return new Pose(reach, 0.12f * Bump(t, Windup, ImpactProgress), 7f * tension - 3f * o * r, 0f, 0f); + } + + /// + /// 사마귀 — 앞다리를 쳐들고(몸을 −16° 세운다) 다가가며 한 번(), 붙어서 반대 대각선으로 한 번 더 벤다. + /// 휘두를 때마다 몸이 기울고(±22°) 돌아간다(±14°). 근접 거리가 돌진보다 짧다(0.63). + /// + private static Pose Slash(float t) + { + const float I = ImpactProgress; + float w = Wind(t); + float o = Outbound(t, I); + float r = Recover(t); + float reach = t < Windup ? -0.065f * w : Mathf.Lerp(-0.065f, 1f, o) * r; + float rear = t < Windup ? w : t < I ? 1f : 1f - Mathf.SmoothStep(0f, 1f, (t - I) / (RecoverStart - I)); + float s1 = Bump(t, SlashFirstSwing - 0.06f, SlashFirstSwing + 0.06f); + float s2 = Bump(t, I - 0.06f, I + 0.08f); + return new Pose(reach * 0.63f, Mathf.Max(0f, reach) * 0.09f, + -16f * rear + 13f * s1 + 15f * s2, 22f * s1 - 22f * s2, 14f * s1 - 14f * s2); + } + + /// + /// 나는 종 — 살짝 웅크렸다가 높이 솟아올라(머리를 들고, ~1m) 가속하며 내리꽂고(머리를 34° 숙인다), 맞힌 뒤 튀어 올라 날아 돌아온다. + /// 날갯짓처럼 좌우로 흔들린다. + /// + private static Pose Flight(float t) + { + const float I = ImpactProgress; + const float RiseEnd = 0.27f; + float roll = -8f * Mathf.Sin(t / RestProgress * Mathf.PI * 2f); + if (t < Windup) + { + float w = Wind(t); + return new Pose(-0.06f * w, 0.12f * w, -6f * w, roll, 0f); + } + if (t < RiseEnd) + { + float u = Mathf.SmoothStep(0f, 1f, (t - Windup) / (RiseEnd - Windup)); + return new Pose(Mathf.Lerp(-0.06f, 0.28f, u), Mathf.Lerp(0.12f, 1f, u), Mathf.Lerp(-6f, -22f, u), roll, 0f); + } + if (t < I) + { + float u = (t - RiseEnd) / (I - RiseEnd); + float e = u * u; // 가속하며 내리꽂는다 + return new Pose(Mathf.Lerp(0.28f, 1f, e), Mathf.Lerp(1f, 0.06f, e), + Mathf.Lerp(-22f, 34f, Mathf.SmoothStep(0f, 1f, u)), roll, 0f); + } + float r = Recover(t); + float lift = 0.06f * (1f - Mathf.SmoothStep(0f, 1f, (t - I) / 0.1f)) + 0.32f * Bump(t, I + 0.02f, RestProgress); + float pitch = 34f * (1f - Mathf.SmoothStep(0f, 1f, (t - I) / 0.14f)) - 8f * Bump(t, I + 0.1f, RestProgress); + return new Pose(r, lift, pitch, roll, 0f); + } + + /// + /// 벌·말벌 — 뒤로 물러나 떠서(배를 치켜든다) 윙윙 떨며 노리다가, 곧은 선으로 쏘아 들어가 찌르고(앞으로 18° 기운다) 튕겨 물러난다. + /// + private static Pose Sting(float t) + { + const float Cock = 0.16f; + const float Hover = 0.27f; + const float I = StingImpactProgress; + const float Bounce = 0.46f; + if (t < Cock) + { + float w = Mathf.Sin(Mathf.Clamp01(t / Cock) * Mathf.PI * 0.5f); + return new Pose(-0.12f * w, 0.24f * w, -10f * w, 0f, 0f); + } + if (t < Hover) + { + float buzz = 0.025f * Bump(t, Cock, Hover) * Mathf.Sin((t - Cock) * 160f); + return new Pose(-0.12f, 0.24f + buzz, -10f, 0f, 0f); + } + if (t < I) + { + float u = (t - Hover) / (I - Hover); + float e = u * u; + return new Pose(Mathf.Lerp(-0.12f, 1.08f, e), Mathf.Lerp(0.24f, 0.08f, e), Mathf.Lerp(-10f, 18f, u), 0f, 0f); + } + if (t < Bounce) + { + float u = (t - I) / (Bounce - I); + return new Pose(Mathf.Lerp(1.08f, 0.72f, Mathf.SmoothStep(0f, 1f, u)), 0.08f + 0.14f * Mathf.Sin(u * Mathf.PI * 0.5f), + Mathf.Lerp(18f, 0f, u), 0f, 0f); + } + float k = Mathf.SmoothStep(0f, 1f, (t - Bounce) / (RestProgress - Bounce)); + return new Pose(Mathf.Lerp(0.72f, 0f, k), Mathf.Lerp(0.22f, 0f, k), 0f, 0f, 0f); + } + + /// + /// 지네·거미·개미귀신 — 낮게 웅크렸다가(머리를 숙인다) 포물선으로 뛰어올라(몸을 세웠다가) 상대 위로 덮치고, 누른 채 버둥거리다 물러난다. + /// + private static Pose Pounce(float t) + { + const float I = ImpactProgress; + if (t < Windup) + { + float w = Wind(t); + return new Pose(-0.05f * w, -0.03f * w, 10f * w, 0f, 0f); + } + if (t < I) + { + float u = (t - Windup) / (I - Windup); + float pitch = u < 0.5f ? Mathf.Lerp(10f, -18f, u * 2f) : Mathf.Lerp(-18f, 16f, (u - 0.5f) * 2f); + return new Pose(Mathf.Lerp(-0.05f, 1.02f, Mathf.SmoothStep(0f, 1f, u)), + 0.8f * 4f * u * (1f - u) + Mathf.Lerp(-0.03f, 0f, u), pitch, 0f, 0f); + } + if (t < RecoverStart) + { + float pin = 16f * (1f - Mathf.SmoothStep(0f, 1f, (t - I) / 0.1f)); + float wrestle = 5f * Bump(t, I, RecoverStart) * Mathf.Sin((t - I) * 70f); + return new Pose(1.02f, 0f, pin, wrestle, 0f); + } + float k = Mathf.SmoothStep(0f, 1f, (t - RecoverStart) / (RestProgress - RecoverStart)); + return new Pose(Mathf.Lerp(1.02f, 0f, k), 0.16f * Bump(t, RecoverStart, RestProgress), 0f, 0f, 0f); + } + + /// + /// 원거리 시전 — 몸은 제자리, 계열마다 쏘는 몸짓이 다르다. 나는 종은 떠오르며, 딱정벌레는 머리를 쳐올리며, 사마귀는 베어 날리며, + /// 벌은 앞으로 찌르며, 지네·거미는 몸을 세우며 쏜다. 그 밖은 예전 기본 자세. + /// + private static Pose Cast(Family family, float t) + { + const float I = ImpactProgress; + float w = Wind(t); + float o = Outbound(t, I); + float r = Recover(t); + float tension = t < Windup ? w : (1f - o) * r; + switch (family) + { + case Family.Flier: + return new Pose(0f, 0.32f * Bump(t, 0f, 0.62f), -8f * tension + 5f * o * r, + -6f * Mathf.Sin(t / RestProgress * Mathf.PI * 2f), 0f); + case Family.Beetle: + return new Pose(0f, 0f, 12f * tension - 14f * Bump(t, Windup, I + 0.1f), 0f, 0f); + case Family.Mantis: + { + float s = Bump(t, Windup - 0.02f, I + 0.04f); + return new Pose(0f, 0f, -12f * tension + 10f * s, 24f * s, 12f * s); + } + case Family.Bee: + { + float jab = Bump(t, Windup, I + 0.08f); + return new Pose(0.1f * jab, 0.12f * Bump(t, 0f, 0.62f), 14f * jab - 6f * tension, 0f, 0f); + } + case Family.Crawler: + return new Pose(0f, 0f, -20f * tension + 6f * o * r, 0f, 0f); + default: + return new Pose(0f, 0f, -7f * tension + 4f * o * r, 0f, 0f); + } + } + } +} diff --git a/Assets/Scripts/Battle/BattleMotion.cs.meta b/Assets/Scripts/Battle/BattleMotion.cs.meta new file mode 100644 index 0000000..03119bc --- /dev/null +++ b/Assets/Scripts/Battle/BattleMotion.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: aef4ff1bf7bf6d74495498f555282f70 \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleRandomSource.cs b/Assets/Scripts/Battle/BattleRandomSource.cs new file mode 100644 index 0000000..9a4c6f7 --- /dev/null +++ b/Assets/Scripts/Battle/BattleRandomSource.cs @@ -0,0 +1,27 @@ +using System; + +namespace InsectGame.Battle +{ + /// + /// Combat-owned random stream. Rendering, particle counts, frame rate and presentation + /// speed cannot consume it. The historical raid interface also serves ordinary battles. + /// + public sealed class BattleRandomSource : IRaidRandomSource + { + private readonly Random random; + + public BattleRandomSource() : this(Guid.NewGuid().GetHashCode()) { } + public BattleRandomSource(int seed) { random = new Random(seed); } + + public float Next01() + { + // Casting a double very close to one can round up; preserve the [0,1) contract. + return Math.Min(0.99999994f, (float)random.NextDouble()); + } + + public int NextInt(int minInclusive, int maxExclusive) + { + return random.Next(minInclusive, maxExclusive); + } + } +} diff --git a/Assets/Scripts/Battle/BattleRandomSource.cs.meta b/Assets/Scripts/Battle/BattleRandomSource.cs.meta new file mode 100644 index 0000000..bbc266e --- /dev/null +++ b/Assets/Scripts/Battle/BattleRandomSource.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8ee3390b525159f4a95534e96d05cb54 \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleReadPacing.cs b/Assets/Scripts/Battle/BattleReadPacing.cs new file mode 100644 index 0000000..d3bbf19 --- /dev/null +++ b/Assets/Scripts/Battle/BattleReadPacing.cs @@ -0,0 +1,148 @@ +using UnityEngine; + +namespace InsectGame.Battle +{ + /// + /// 전투 화면의 읽는 단계가 배속을 덜 타게 하는 순수 규칙. + /// + /// 전투 연출의 시계는 전부 BattlePresentation.DeltaTime(배속 × 히트스톱)이라, 2배속을 켜면 상대 교체(1.2초)·그림자 변신(1.5초)· + /// 수문장 등장(2.6초)이 절반으로 줄어 아이가 문구(「집게가 지네를 내보냈다!」·「그림자가 ○○의 모습을 빌렸다!」)를 못 읽었다. + /// 그래서 이 단계들은 배속과 무관한 최소 실제 시간을 갖는다 — 단계 시계를 늦출 뿐 늘리지 않으므로 1배속은 한 프레임도 안 바뀐다. + /// 공격 연출·턴 배너·HP 보간은 지금처럼 배속을 탄다. + /// + /// 쓰는 법: 단계 시계(phaseTimer·introTimer)를 DeltaTime × ClockScale(…)로 올린다. 진행률(시계 ÷ 길이)은 + /// 그대로 0→1이라 문구의 페이드·박자가 늘어난 길이 전체에 고르게 퍼진다. 같은 실제 시간에 아레나 연출이 끝나게 하려면 + /// 연출 길이로 를 넘긴다(연출 시계 초 — 아레나 코루틴도 배속 시계를 쓴다). + /// + /// 히트스톱(BattlePresentation.TimeScale)은 그대로 곱해진다 — 읽는 단계에는 타격이 없어 실제로는 1이다. + /// + public static class BattleReadPacing + { + /// + /// 1대1 진입 인트로의 실제 초 — 아레나 진입 샷(visual-dev, 1초 안쪽) + 화면 쓸기 전환과 큰 「야생 ○○이(가) 나타났다!」 + /// (ui-dev)가 들어갈 자리. 이 구간은 배속과 무관하게 실제 시간으로 흐른다(). + /// 대결·수문장 컷인이 있으면 컷인은 이 구간 뒤에 시작한다(). + /// + public const float EntryIntroSeconds = 1.4f; + + /// 대결·수문장 컷인(1대1, 1배속 3.2초)의 최소 실제 초 — 상대의 첫마디가 타자로 다 찍힌다. + public const float CutInMinSeconds = 2.0f; + + /// 상대 교체 단계(BattleScreenUI.EnemySwitchSeconds 1.2초)의 최소 실제 초. + public const float EnemySwitchMinSeconds = 1.0f; + + /// 그림자 변신 단계(RaidBattleUI.BossTransformDuration 1.5초)의 최소 실제 초. + public const float BossTransformMinSeconds = 1.0f; + + /// 수문장 레이드 등장 인트로(BattleStaging.GuardianIntroSeconds 2.6초)의 최소 실제 초 — 별칭·등장 한 줄을 읽는다. + public const float GuardianIntroMinSeconds = 2.0f; + + /// 일반 레이드 인트로(RAID BOSS → 이름 → FIGHT!, 1배속 2초)의 최소 실제 초 — 1대1 진입과 같은 값. + public const float RaidIntroMinSeconds = EntryIntroSeconds; + + /// + /// 단계 시계 배율(0~1] — 연출 시계(BattlePresentation.DeltaTime)에 곱한다. 1이면 배속 그대로(1배속이거나 하한에 안 걸린다). + /// 길이의 단계가 실제로 보다 빨리 끝나지 않을 만큼만 늦춘다. + /// + /// 단계 길이(연출 시계 초 = 1배속 실제 초). + /// 배속과 무관한 최소 실제 초. + /// 전투 배속(BattlePresentation.Speed, 1 또는 2). 1 미만은 1로 본다. + public static float ClockScale(float nominalSeconds, float minRealSeconds, float speed) + { + if (nominalSeconds <= 0f || minRealSeconds <= 0f) return 1f; + float s = Mathf.Max(1f, speed); + return Mathf.Clamp01(nominalSeconds / (minRealSeconds * s)); + } + + /// 그 단계가 실제로 걸리는 초(히트스톱 없음) — max(길이 ÷ 배속, 하한). 단, 길이가 하한보다 짧으면 하한. + public static float RealSeconds(float nominalSeconds, float minRealSeconds, float speed) + { + if (nominalSeconds <= 0f) return 0f; + float s = Mathf.Max(1f, speed); + return nominalSeconds / (s * ClockScale(nominalSeconds, minRealSeconds, speed)); + } + + /// + /// 아레나 연출(배속 시계로 도는 코루틴)에 넘길 길이 — 단계와 같은 실제 시간에 끝난다. + /// 1배속이면 그대로, 2배속이면 하한까지 늘어난 만큼(예: 교체 1.2 → 2.0). + /// + public static float StagingSeconds(float nominalSeconds, float minRealSeconds, float speed) + { + float scale = ClockScale(nominalSeconds, minRealSeconds, speed); + return scale > 0f ? nominalSeconds / scale : nominalSeconds; + } + + // ── 1대1 인트로 = 진입 구간(실제 시간) + [컷인 | 평범한 인트로의 나머지] ── + + /// + /// 1대1 인트로 전체 길이(인트로 시계 초). 컷인이 있으면 진입 구간 뒤에 컷인이 이어지고(1.4 + 3.2), + /// 없으면 평범한 인트로가 진입 구간을 품는다(더 짧으면 진입 구간 길이). + /// + /// 컷인 길이. 컷인이 없는 전투(야생·아이 대결·꿈)면 0. + /// 평범한 인트로 길이(컷인이 없을 때). + public static float IntroSeconds(float cutInSeconds, float plainSeconds) + { + return cutInSeconds > 0f + ? EntryIntroSeconds + cutInSeconds + : Mathf.Max(EntryIntroSeconds, plainSeconds); + } + + /// + /// 인트로 시계 배율 — 진입 구간( 전)은 실제 시간(배속을 되돌린다), 컷인 구간은 + /// 하한, 평범한 인트로의 나머지는 배속 그대로. + /// + /// 지금까지의 인트로 시계(초). + public static float IntroClockScale(float elapsed, float cutInSeconds, float speed) + { + if (elapsed < EntryIntroSeconds) return ClockScale(EntryIntroSeconds, EntryIntroSeconds, speed); + return cutInSeconds > 0f ? ClockScale(cutInSeconds, CutInMinSeconds, speed) : 1f; + } + + /// 진입 구간 진행률 0~1(인트로 시계 기준 — 이 구간은 실제 시간과 같은 속도로 흐른다). + public static float EntryProgress(float introElapsed) => Mathf.Clamp01(introElapsed / EntryIntroSeconds); + + /// 컷인 시계 — 진입 구간이 끝난 뒤부터 0. 진입 구간 중에는 음수다(컷인을 아직 그리지 않는다). + public static float CutInElapsed(float introElapsed) => introElapsed - EntryIntroSeconds; + } + + /// + /// 전투 결과 화면을 닫는 순수 규칙 — 1대1(BattleScreenUI)과 레이드(RaidBattleUI)가 함께 쓴다. + /// + /// 예전엔 결과 화면이 1대1 4초·레이드 5초 뒤 저절로 닫혀서, 보상·포획 결과·상대의 한마디를 다 읽기 전에 필드로 돌아갔다. + /// 이제는 탭·클릭·Space/Enter로만 닫힌다. 처음 는 누름을 받지 않는다(쌓아 두지도 않는다) — + /// 마지막 공격을 연타하던 손가락이 결과를 곧바로 닫지 않게. + /// + /// 받아들인 누름은 손을 뗄 때 닫는다(). 누른 그 프레임에 닫으면 화면이 플레이어 프리즈를 풀고, + /// 같은 프레임의 PlayerMovement가 그 탭(GetMouseButtonDown)을 클릭-이동으로 읽어 캐릭터가 탭한 자리로 걸어갔다 + /// (rules/ui-layout.md 「필드 위에 그리는 버튼」과 같은 계열). 뗀 프레임엔 그 판정이 거짓이다. + /// + /// 예외 하나 — 꿈 챔피언전(샌드박스)은 예전처럼 저절로 닫힌다. 꿈 지휘자(DreamPrologueDirector)가 결과 화면이 + /// 닫히는 것(IsBattleActive)을 신호로 섬으로 넘어가는데, 처음 켠 사람은 "눌러서 닫기"를 아직 모르고, 그 지휘자의 상한 + /// (14초)이 먼저 오면 결과 화면이 떠 있는 채로 섬이 열린다. 탭하면 더 일찍 닫힌다. + /// + public static class BattleResultRules + { + /// 결과가 뜬 뒤 이 실제 초 동안은 누름을 받지 않는다. + public const float InputLockSeconds = 0.6f; + + /// 받아들인 누름을 이만큼(실제 초) 계속 누르고 있으면 떼지 않아도 닫는다 — 포커스를 잃어 뗌을 못 받아도 갇히지 않게. + public const float ReleaseGraceSeconds = 0.5f; + + /// 꿈 챔피언전 결과가 저절로 닫히는 실제 초(예전 1대1 자동 닫힘과 같다). + public const float SandboxAutoCloseSeconds = 4f; + + /// 지금 누르면 받아들이는가(잠금이 끝났는가). + public static bool CanClose(float shownSeconds) => shownSeconds >= InputLockSeconds; + + /// 이번 프레임의 누름을 받아들이는가 — 잠금 중 누름은 버린다(잠금이 풀린 뒤 다시 눌러야 한다). + public static bool AcceptsPress(float shownSeconds, bool pressed) => pressed && CanClose(shownSeconds); + + /// 받아들인 누름 뒤 — 손을 뗐거나( 거짓) 충분히 오래 눌렀으면 닫는다. + public static bool ReleaseComplete(bool inputHeld, float secondsSincePress) + => !inputHeld || secondsSincePress >= ReleaseGraceSeconds; + + /// 누르지 않아도 닫는가 — 꿈 챔피언전(샌드박스)만. 다른 전투는 몇 초가 지나도 false. + public static bool ShouldAutoClose(float shownSeconds, bool sandbox) + => sandbox && shownSeconds > SandboxAutoCloseSeconds; + } +} diff --git a/Assets/Scripts/Battle/BattleReadPacing.cs.meta b/Assets/Scripts/Battle/BattleReadPacing.cs.meta new file mode 100644 index 0000000..4df641b --- /dev/null +++ b/Assets/Scripts/Battle/BattleReadPacing.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 700a192417517f84b83d03fe3986fa24 \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleShout.cs b/Assets/Scripts/Battle/BattleShout.cs new file mode 100644 index 0000000..dc6bb38 --- /dev/null +++ b/Assets/Scripts/Battle/BattleShout.cs @@ -0,0 +1,154 @@ +using System; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Battle +{ + /// + /// 전투 중 곤충의 외침 — 기술 이름 외치기, 맞았을 때 비명, 타격 의성어, 그리고 종별 울음 분류. + /// 문구 표와 판정만 들고 있는 순수 코드다. 언제 띄울지는 아레나가, 그리기는 UI + /// (BattleShoutOverlay)가 맡는다 — 효과 문구(EffectTextEntry)와 같은 분업이다. + /// + /// 예전엔 간부·수문장 NPC의 대사 말풍선 말고는 전투 내내 아무도 소리를 내지 않았다. + /// 곤충이 기술을 쓰든 치명타를 맞든 화면 위쪽 문구 한 줄과 하단 설명 줄이 전부였다. + /// + public static class BattleShout + { + public enum Kind + { + /// 기술 이름을 외친다 — 준비 동작과 함께, 시전자 머리 위. + Callout, + /// 맞은 쪽의 비명 — 타격 직후, 대상 머리 위. + Hurt, + /// 타격 의성어("콰광!!") — 타격 순간, 대상 몸통. + Sound + } + + /// 종별 울음 계열 — 비명 문구와 울음 효과음이 같은 분류를 쓴다. + public enum Cry { Beetle, Mantis, Flyer, Buzzer, Chirper, Crawler, Boss } + + public sealed class Entry + { + public string Text; + public Kind Kind; + /// + /// 띄운 순간의 월드 위치. 모델을 따라가지 않는다 — 돌진하는 시전자를 따라 말풍선이 화면을 + /// 가로지르면 못 읽는다. 카메라가 움직여도 이 점을 매 프레임 다시 투영하므로 그 자리에 붙어 있다. + /// + public Vector3 WorldPoint; + public Color Color; + public float StartTime; + public float Duration; + /// 의성어 기울기(도). 매번 같은 각이면 도장 찍은 듯 보인다. + public float Tilt; + } + + /// + /// 곤충 ID → 울음 계열. ID는 계열_수식어 꼴이라(mantis_ember, fly_hover) + /// 토막 단위로 본다 — 부분 문자열로 보면 mantis에 ant가, dragonfly에 + /// fly가 걸린다. 구체적인 것(날개 큰 비행형)을 날벌레보다 먼저 본다. + /// + public static Cry CryFor(string speciesId) + { + if (string.IsNullOrEmpty(speciesId)) return Cry.Beetle; + string[] tokens = speciesId.ToLowerInvariant().Split('_'); + if (Has(tokens, "mantis")) return Cry.Mantis; + if (Has(tokens, "dragonfly") || Has(tokens, "damselfly") || Has(tokens, "butterfly") + || Has(tokens, "moth") || Has(tokens, "firefly")) return Cry.Flyer; + if (Has(tokens, "bee") || Has(tokens, "wasp") || Has(tokens, "hornet") + || Has(tokens, "mosquito") || Has(tokens, "fly")) return Cry.Buzzer; + if (Has(tokens, "cricket") || Has(tokens, "grasshopper") || Has(tokens, "katydid") + || Has(tokens, "cicada") || Has(tokens, "locust")) return Cry.Chirper; + if (Has(tokens, "spider") || Has(tokens, "centipede") || Has(tokens, "ant") + || Has(tokens, "antlion") || Has(tokens, "caterpillar") || Has(tokens, "aphid") + || Has(tokens, "stick") || Has(tokens, "strider") || Has(tokens, "earwig")) return Cry.Crawler; + return Cry.Beetle; + } + + /// 기술 이름 외치기. 이름에 이미 느낌표가 있으면 겹치지 않게 정리한다. + public static string Callout(string skillName) + { + string name = string.IsNullOrEmpty(skillName) ? string.Empty : skillName.Trim().TrimEnd('!', '!'); + return name.Length == 0 ? "간다!!" : name + "!!"; + } + + private static readonly string[][] HurtLines = + { + // Beetle + new[] { "쿠웃!", "크읏!" }, + // Mantis + new[] { "끼익!", "키잇!" }, + // Flyer + new[] { "파닥!", "히잇!" }, + // Buzzer + new[] { "비잉?!", "즈즛!" }, + // Chirper + new[] { "찌익!", "찍!" }, + // Crawler + new[] { "끽!", "키익!" }, + // Boss + new[] { "그르륵!", "크르르!" }, + }; + + private static readonly string[][] HeavyHurtLines = + { + new[] { "크아악!!", "쿠오오…!!" }, + new[] { "끼이익!!", "캬아악!!" }, + new[] { "히이익!!", "파다닥!!" }, + new[] { "비이잉!!", "즈즈즛!!" }, + new[] { "찌이익!!", "끼리릭!!" }, + new[] { "끼이익!!", "키에엑!!" }, + new[] { "그오오오!!", "크아아아!!" }, + }; + + /// + /// 큰 비명·센 의성어로 넘어가는 세기. 평타의 세기 천장(BattleArenaController.HitCue.NormalCeiling)은 + /// 이 아래다 — 그래서 큰 문구는 치명타·마무리(그리고 합체공격 일격처럼 호출부가 1을 넘기는 자리)만 받는다. + /// 예전엔 평타도 최대 HP의 25~35%를 깎아 세기 0.6~0.9를 받았고, 매 타격이 "크아악!!"·"콰광!!"이었다. + /// + public const float HeavyWeight = 0.6f; + + /// + /// 맞았을 때 비명. 세기가 크면(치명타·마무리) 길게 끈다. 로 고르므로 + /// 같은 장면은 같은 문구 — 재현 가능한 캡처를 위해서다. + /// + public static string Hurt(Cry cry, float impactWeight, int seed) + { + string[][] table = impactWeight >= HeavyWeight ? HeavyHurtLines : HurtLines; + string[] row = table[Mathf.Clamp((int)cry, 0, table.Length - 1)]; + return row[Math.Abs(seed) % row.Length]; + } + + /// + /// 타격 의성어. 속성마다 소리가 다르고, 세기가 크면 센 쪽을 쓴다. + /// 표는 순서와 1:1이다(BattleShoutTests가 고정). + /// + public static string Sound(InsectElement element, float impactWeight) + { + bool heavy = impactWeight >= HeavyWeight; + switch (element) + { + case InsectElement.Leaf: return heavy ? "파사사삭!!" : "사각!"; + case InsectElement.Water: return heavy ? "콰쏴아!!" : "촤악!"; + case InsectElement.Wind: return heavy ? "휘이잉!!" : "휙!"; + case InsectElement.Electric: return heavy ? "콰지직!!" : "파직!"; + case InsectElement.Earth: return heavy ? "쿠구궁!!" : "쿵!"; + case InsectElement.Poison: return heavy ? "부글부글!!" : "치익!"; + case InsectElement.Light: return heavy ? "파아앗!!" : "번쩍!"; + case InsectElement.Dark: return heavy ? "콰드득!!" : "스윽!"; + case InsectElement.Metal: return heavy ? "콰앙!!" : "깡!"; + case InsectElement.Bug: + case InsectElement.None: + default: + return heavy ? "콰광!!" : "퍽!"; + } + } + + private static bool Has(string[] tokens, string word) + { + for (int i = 0; i < tokens.Length; i++) + if (tokens[i] == word) return true; + return false; + } + } +} diff --git a/Assets/Scripts/Battle/BattleShout.cs.meta b/Assets/Scripts/Battle/BattleShout.cs.meta new file mode 100644 index 0000000..6cddae6 --- /dev/null +++ b/Assets/Scripts/Battle/BattleShout.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cd9c6483e1f4b234eac914c0d0e22d64 \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleStaging.cs b/Assets/Scripts/Battle/BattleStaging.cs new file mode 100644 index 0000000..75d0e2b --- /dev/null +++ b/Assets/Scripts/Battle/BattleStaging.cs @@ -0,0 +1,304 @@ +using UnityEngine; + +namespace InsectGame.Battle +{ + /// + /// 이야기 전투의 등장·변신 연출 — 순수 규칙(시각표·곡선·그림자 색). 씬 없이 BattleStagingTests가 고정한다. + /// + /// 셋이다. + /// + /// 상대 교체 등장(1대1 팀 대결) — 빛살이 착지점을 알리고, 새 곤충이 바깥 뒤쪽에서 뛰어들어 먼지를 일으키며 내려앉는다. + /// 길이는 화면 단계 BattleScreenUI.EnemySwitchSeconds(1.2초)를 받아 쓴다 — 여기 시각은 그 길이의 진행률(0~1)이다. + /// 그림자 변신(레이드) — 검은 연기가 보스를 감싸고, 한가운데에서 모습이 바뀌고, 연기가 걷히며 빌린 모습이 드러난다. + /// 길이는 RaidBattleUI.BossTransformDuration(1.5초)의 진행률이다. + /// 수문장 등장(레이드 인트로) — 낮은 곳에서 수문장을 올려다보며 다가가고, 포효에 흔들리고, 원래 구도로 돌아온다. + /// 이건 초로 잰다 — 인트로 길이()를 이 표가 정하고 UI가 읽는다. + /// + /// 그리기는 BattleArenaController.Staging, 카메라 샷은 가 이 표를 읽는다. + /// + public static class BattleStaging + { + // ───────────── ① 상대 교체 등장 (진행률 0..1) ───────────── + + /// 착지점에 선 빛살이 사라지는 진행률 — 새 곤충이 "어디로" 오는지 먼저 알린다. + public const float EntranceBeamEnd = 0.34f; + /// 도약 시작. 그 전엔 몸을 보이지 않는다(출발점이 가로 화면 가장자리에 걸릴 수 있다). + public const float EntranceLeapStart = 0.12f; + /// 착지 — 먼지·땅 고리·빛살 퍼짐·흔들림·울음이 이 순간이다(1.2초면 0.62초). + public const float EntranceLand = 0.52f; + /// 착지 반동(눌렸다 펴짐)이 가라앉는 진행률. + public const float EntranceSettleEnd = 0.82f; + /// 도약 꼭짓점 높이(m). 출발·착지 높이를 잇는 선 위로 이만큼 솟는다. + public const float EntranceApex = 1.9f; + /// 출발점 — 상대 자리에서 바깥(내 곤충 반대쪽)으로 이만큼(m). + public const float EntranceLaunchOut = 3.0f; + /// 출발점 — 상대 자리에서 뒤(카메라 반대쪽)로 이만큼(m). + public const float EntranceLaunchBack = 1.6f; + + /// 카메라가 앞 구도에서 착지점 클로즈업으로 옮겨 가는 끝(진행률). + public const float EntranceCamIn = 0.34f; + /// 착지 클로즈업을 붙드는 끝 — 여기서부터 새 구도로 풀린다. + public const float EntranceCamHoldEnd = 0.72f; + /// 카메라가 새 구도로 완전히 돌아오는 진행률. 1보다 작아야 「당신의 턴」이 원래 구도에서 뜬다. + public const float EntranceCamReleaseEnd = 0.94f; + /// 착지 클로즈업 — 기본 구도에서 착지점 쪽으로 다가가는 비율(시네마틱 타격 클로즈업 0.32보다 조금 멀다). + public const float EntranceDolly = 0.26f; + + /// + /// 도약 궤적 — 에서 까지 포물선. 는 도약 구간 진행률(0~1). + /// 수평은 끝으로 갈수록 살짝 감속하고(착지에 무게가 실리게) 수직은 꼭짓점 의 포물선이다 — 끝점에서 정확히 . + /// + public static Vector3 LeapPosition(Vector3 launch, Vector3 rest, float apex, float u) + { + u = Mathf.Clamp01(u); + float h = Mathf.Lerp(u, 1f - (1f - u) * (1f - u), 0.35f); + Vector3 p = Vector3.Lerp(launch, rest, h); + p.y = Mathf.Lerp(launch.y, rest.y, u) + apex * 4f * u * (1f - u); + return p; + } + + /// 도약 중 몸 기울기(도, 양수 = 머리 숙임) — 오를 땐 머리를 들고 내려올 땐 숙인다. + public static float LeapPitch(float u) + { + return Mathf.Lerp(-20f, 12f, Mathf.Clamp01(u)); + } + + /// + /// 착지 반동의 세로 배율 — 는 착지 뒤 진행률(0~1). 0.78로 눌렸다가 1.1 가까이 튀고 1로 잦아든다 + /// (감쇠 진동, 끝점에서 정확히 1). 가로는 부피를 지키게 반대로 움직인다(). + /// + public static float LandingSquash(float k) + { + k = Mathf.Clamp01(k); + return 1f - 0.22f * (1f - k) * (1f - k) * Mathf.Cos(3f * Mathf.PI * k); + } + + /// 세로 배율 에 짝지은 가로 배율 — 눌리면 퍼지고 늘면 좁아진다. + public static float SquashWidth(float squash) + { + return 1f - (squash - 1f) * 0.5f; + } + + // ───────────── ② 그림자 변신 (진행률 0..1) ───────────── + + /// 연기가 보스를 완전히 덮는 진행률. + public const float TransformEngulfEnd = 0.44f; + /// 모델이 새 모습으로 바뀌는 순간 — 연기가 가장 짙은 한가운데(1.5초면 0.75초). + public const float TransformSwap = 0.5f; + /// 새 모습이 연기를 뚫고 울부짖는 순간(울음·흔들림·눈빛). + public const float TransformRoar = 0.6f; + /// 연기가 다 걷히는 진행률. + public const float TransformRevealEnd = 0.86f; + /// 몸을 덮는 먹빛의 최대 세기(0~1) — 바꾸는 순간 실루엣만 남는다. + public const float TransformVeilPeak = 0.9f; + /// 연기에 삼켜지며 움츠러드는 최소 배율. + public const float TransformShrink = 0.86f; + /// 새 모습이 펴지며 넘치는 최대 배율. + public const float TransformOvershoot = 1.07f; + + /// 카메라가 보스 쪽으로 다가붙는 끝. + public const float TransformCamIn = 0.2f; + /// 카메라를 붙드는 끝 — 여기서부터 원래 구도로 풀린다. + public const float TransformCamHoldEnd = 0.72f; + /// 카메라가 원래 구도로 완전히 돌아오는 진행률(1보다 작게 — 다음 차례가 원래 구도에서 열린다). + public const float TransformCamReleaseEnd = 0.94f; + /// 보스 쪽으로 다가붙는 비율(레이드 기본 구도는 팀 뒤 원경이라 조금만). + public const float TransformDolly = 0.16f; + + /// 연기 덮임 0~1 — 덮이고(~), 바꾸는 순간을 지나 잠깐 머물고, 걷힌다. + public static float SmokeCover(float p) + { + p = Mathf.Clamp01(p); + if (p < TransformEngulfEnd) return Mathf.SmoothStep(0f, 1f, p / TransformEngulfEnd); + float holdEnd = TransformSwap + 0.06f; + if (p < holdEnd) return 1f; + return 1f - Mathf.SmoothStep(0f, 1f, (p - holdEnd) / (TransformRevealEnd - holdEnd)); + } + + /// 몸을 덮는 먹빛 0~ — 바꾸는 순간에 가장 짙고 양 끝에서 0. + public static float TransformVeil(float p) + { + p = Mathf.Clamp01(p); + if (p < TransformSwap) + return TransformVeilPeak * Mathf.SmoothStep(0f, 1f, (p - 0.08f) / (TransformSwap - 0.08f)); + return TransformVeilPeak * (1f - Mathf.SmoothStep(0f, 1f, (p - TransformSwap - 0.04f) / (TransformRevealEnd - TransformSwap - 0.04f))); + } + + /// + /// 몸 배율 — 삼켜지며 까지 움츠러들고, 바뀐 뒤 그 크기에서 로 펴졌다가 + /// 1로 돌아온다. 바꾸는 순간 앞뒤가 같은 값이라 모델이 갈아끼워져도 크기가 튀지 않는다. + /// + public static float TransformScale(float p) + { + p = Mathf.Clamp01(p); + if (p < TransformSwap) + return Mathf.Lerp(1f, TransformShrink, Mathf.SmoothStep(0f, 1f, (p - 0.12f) / (TransformSwap - 0.12f))); + float peak = TransformRoar + 0.08f; + if (p < peak) + return Mathf.Lerp(TransformShrink, TransformOvershoot, Mathf.SmoothStep(0f, 1f, (p - TransformSwap) / (peak - TransformSwap))); + return Mathf.Lerp(TransformOvershoot, 1f, Mathf.SmoothStep(0f, 1f, (p - peak) / (TransformRevealEnd - peak))); + } + + // ───────────── ③ 수문장 등장 (초) ───────────── + + /// + /// 수문장 레이드의 인트로 길이(연출 시계 초). 보통 레이드는 2초다(RaidBattleUI) — 수문장만 0.6초 길다. + /// 등장 컷이 이 안에서 원래 구도로 돌아와야 첫 차례가 원래 구도에서 열린다(). + /// + public const float GuardianIntroSeconds = 2.6f; + /// 올려다보며 다가가기가 끝나는 시각 — 그 뒤엔 천천히 조금 더 붙는다(). + public const float GuardianApproachEnd = 1.3f; + /// 포효 — 몸을 젖히며 솟고, 울고, 화면이 흔들리고, 받침대에서 먼지가 인다. ui-dev 배너의 "쾅"도 이 박자에 맞춘다. + public const float GuardianRoarAt = 0.62f; + /// 원래 구도로 풀리기 시작하는 시각. + public const float GuardianReleaseStart = 1.85f; + /// 원래 구도로 완전히 돌아오는 시각 — 보다 앞이어야 한다. + public const float GuardianReleaseEnd = 2.5f; + /// + /// 올려다보는 각도(도)의 시작값. 수문장은 2m 넘는 받침대 위라, 화면 위쪽 창에 두면서 이 각을 지키려면 카메라가 땅속까지 + /// 내려가야 할 때가 많다 — 그러면 씩 낮춘다(). 각이 낮아져도 + /// 카메라가 받침대 윗면보다 낮게 서서 수문장을 올려다보는 구도는 남는다. + /// + public const float GuardianLookUpPitch = 8f; + /// 각을 낮추는 한 걸음(도). + public const float GuardianPitchStep = 2f; + /// 가장 낮춘 각(도, 음수 = 살짝 내려다봄). 넓은 날개의 세로 화면이 여기까지 온다. + public const float GuardianMinPitch = -4f; + /// 카메라가 팀 줄(팀 호 중심) 뒤로 떨어지는 거리(m) — 다가간 뒤 천천히 더 붙는 몫()까지 넉넉히. + public const float GuardianTeamClearance = 1.4f; + /// 출발점 — 끝 구도의 거리에서 이 비율만큼 더 물러난 데서 다가온다. + public const float GuardianApproachTravel = 0.55f; + /// 다가간 뒤 천천히 더 붙는 거리(m). + public const float GuardianCreep = 0.18f; + /// 포효 반동 세기(m) — 위로 튀었다 감쇠한다. + public const float GuardianRoarKick = 0.1f; + /// + /// 수문장을 담는 화면 창(안전 영역 세로 비율). 아래 1/3은 ui-dev의 등장 배너 자리라 비우고, 맨 위는 보스 HP 바 몫을 남긴다. + /// 가로 1280×720·세로 720×1280 모두 이 창 안에 들어오는지 BattleStagingTests가 잰다. + /// + public const float GuardianWindowBottom = 0.38f; + public const float GuardianWindowTop = 0.93f; + /// 카메라가 벽에서 떨어져 있어야 하는 거리(m) — 벽 밖에 서면 벽의 바깥 면이 화면을 막는다. + public const float GuardianWallMargin = 1.2f; + /// + /// 카메라의 최저 높이(바닥에서, m) — 팀 곤충 머리 위. 더 낮으면 앞에 선 팀원이 수문장의 발과 받침대를 가린다 + /// (팀 머리는 화면 0.3~0.53에 걸리고 수문장은 0.48 위에 선다 — 2026-10-04 수치 모의). + /// + public const float GuardianMinCameraHeight = 1.2f; + + /// 포효 자세 0~1 — 솟았다(0.22초) 버티고(0.28초) 내려온다(0.35초). + public static float RoarPose(float seconds) + { + float s = seconds - GuardianRoarAt; + if (s <= 0f) return 0f; + if (s < 0.22f) return Mathf.SmoothStep(0f, 1f, s / 0.22f); + if (s < 0.5f) return 1f; + return 1f - Mathf.SmoothStep(0f, 1f, (s - 0.5f) / 0.35f); + } + + /// + /// 수문장 샷의 끝 자리 — 수문장 경계()가 화면 창(, 뷰포트 좌표)을 채우는 카메라. + /// 올려다보는 각()에서 시작해, 카메라가 바닥에서 위에 설 때까지 + /// 각을 낮춘다. 카메라는 팀 줄() 뒤 에 선다 — 시선 축을 따라 + /// 물러나면 수문장은 화면 가운데 쪽으로 줄어들 뿐이라 창 아래(배너 자리)로 내려가지 않는다. + /// + public static void GuardianEndShot(Bounds fit, float aspect, float fieldOfView, float yaw, Rect window, + Vector3 teamCenter, float floorY, out Vector3 position, out Vector3 target, out Quaternion rotation) + { + float pitch = GuardianLookUpPitch; + while (true) + { + rotation = Quaternion.Euler(-pitch, yaw, 0f); + BattleFraming.ComputeInWindow(fit, aspect, fieldOfView, rotation, window, out position, out target); + Vector3 forward = rotation * Vector3.forward; + Vector3 flat = new Vector3(forward.x, 0f, forward.z); + flat = flat.sqrMagnitude > 0.0001f ? flat.normalized : Vector3.forward; + float ahead = Vector3.Dot(position - teamCenter, flat) + GuardianTeamClearance; + if (ahead > 0f) + { + float distance = Vector3.Distance(position, target) + ahead / Mathf.Max(0.2f, Vector3.Dot(forward, flat)); + position = target - forward * distance; + } + if (position.y >= floorY + GuardianMinCameraHeight || pitch <= GuardianMinPitch) break; + pitch = Mathf.Max(GuardianMinPitch, pitch - GuardianPitchStep); + } + position.y = Mathf.Max(position.y, floorY + GuardianMinCameraHeight); + } + + /// + /// 다가가기 출발점 — 끝 구도()에서 수평으로 물러난 자리. 벽 안쪽( − + /// )을 넘지 않게 물러나는 거리를 줄이고, 바닥에서 위에 둔다. + /// + public static Vector3 GuardianApproachStart(Vector3 endPos, Quaternion rotation, float endDistance, + Vector3 arenaCenter, float wallSpan, float floorY) + { + Vector3 flat = rotation * Vector3.forward; + flat.y = 0f; + flat = flat.sqrMagnitude > 0.0001f ? flat.normalized : Vector3.forward; + float limit = Mathf.Max(0.5f, wallSpan - GuardianWallMargin); + float back = Mathf.Max(0f, endDistance) * GuardianApproachTravel; + Vector3 start = endPos - flat * back; + for (int i = 0; i < 8 && !Inside(start, arenaCenter, limit); i++) + { + back *= 0.6f; + start = endPos - flat * back; + } + if (!Inside(start, arenaCenter, limit)) start = endPos; + start.y = Mathf.Max(start.y, floorY + GuardianMinCameraHeight); + return start; + } + + private static bool Inside(Vector3 p, Vector3 center, float limit) + { + return Mathf.Abs(p.x - center.x) <= limit && Mathf.Abs(p.z - center.z) <= limit; + } + + // ───────────── 그림자 색 ───────────── + // 모습을 빌리는 보스(지금은 이름 없는 사마귀 하나 — RaidBossForms)는 「그림자」다. 빌린 모습이 진짜 호랑나비·반딧불이와 + // 헷갈리지 않게, 그리고 원래 모습도 같은 존재로 읽히게 검보라 톤을 입힌다. 무늬(밝고 어두운 차)는 남긴다 — + // 「지워진 개체」(InsectEntity.Erase)처럼 "무엇이었는지는 알겠는" 실루엣이 요점이다. + + /// 그림자 톤의 가장 어두운 끝(원래 어두운 부위가 간다). + public static readonly Color ShadowDeep = new Color(0.06f, 0.03f, 0.10f); + /// 그림자 톤의 밝은 끝(원래 밝은 부위가 간다) — 검보라. + public static readonly Color ShadowLilac = new Color(0.32f, 0.21f, 0.48f); + /// 그림자 빛 — 눈·반딧불이 발광기관·테두리. 차가운 보랏빛. + public static readonly Color ShadowGlow = new Color(0.74f, 0.50f, 1.00f); + /// 변신 순간 몸을 덮는 먹빛. + public static readonly Color ShadowInk = new Color(0.035f, 0.02f, 0.06f); + /// 원래 모습(사마귀) — 같은 존재로 읽히되 사마귀였다는 건 보이게 조금 덜 덮는다. + public const float ShadowOriginalStrength = 0.7f; + /// 빌린 모습 — 진짜 그 곤충과 확실히 갈리게 더 덮는다. + public const float ShadowBorrowedStrength = 0.88f; + + /// + /// 이 레이드 보스가 「그림자」인가 — 모습을 빌리는 보스(). 표에 다른 보스가 들어와 + /// 그림자가 아니게 되면 여기서 가른다(지금 표의 유일한 항목이 이름 없는 사마귀다). + /// + public static bool IsShadowBoss(string bossInsectId) + { + return RaidBossForms.HasForms(bossInsectId); + } + + /// + /// 그림자 톤 — 밝기를 검보라 띠(~)에 옮기고 원색과 만큼 섞는다. + /// 밝기 순서가 지켜져 무늬가 남는다. 알파는 그대로(날개 막은 막대로). + /// + public static Color ShadowTint(Color color, float strength) + { + float lum = Mathf.Clamp01(color.r * 0.299f + color.g * 0.587f + color.b * 0.114f); + Color shade = Color.Lerp(ShadowDeep, ShadowLilac, lum); + Color mixed = Color.Lerp(color, shade, Mathf.Clamp01(strength)); + mixed.a = color.a; + return mixed; + } + + /// 발광 부위(반딧불이 발광기관 등)의 그림자 빛 — 어둡게 덮지 않고 보랏빛으로 바꾼다. + public static Color ShadowGlowTint(Color color) + { + Color mixed = Color.Lerp(color, ShadowGlow, 0.85f); + mixed.a = color.a; + return mixed; + } + } +} diff --git a/Assets/Scripts/Battle/BattleStaging.cs.meta b/Assets/Scripts/Battle/BattleStaging.cs.meta new file mode 100644 index 0000000..5239c73 --- /dev/null +++ b/Assets/Scripts/Battle/BattleStaging.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 77c501c0afd231649bb0a26e5dae38f0 \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleStatusLook.cs b/Assets/Scripts/Battle/BattleStatusLook.cs new file mode 100644 index 0000000..5d81c1c --- /dev/null +++ b/Assets/Scripts/Battle/BattleStatusLook.cs @@ -0,0 +1,110 @@ +using UnityEngine; + +namespace InsectGame.Battle +{ + /// 몸에 보이는 상태 — 한 곤충에 여럿이 함께 걸릴 수 있다. + [System.Flags] + public enum BattleStatusFlags + { + None = 0, + Poison = 1 << 0, + Stun = 1 << 1, + AttackUp = 1 << 2, + AttackDown = 1 << 3, + DefenseUp = 1 << 4, + DefenseDown = 1 << 5 + } + + /// + /// 상태이상·강화를 몸에 보이는 규칙 — 순수. 컨트롤러 상태를 플래그로 모으고, 색과 작은 반복 움직임의 곡선을 낸다. + /// 그리기는 BattleArenaController.Life가 한다(모양·자리: 별은 머리 위, 거품은 몸 위, 오라·결은 발밑~몸, 막은 몸 둘레). + /// + /// 1대1은 HP 카드 상태 줄(BattleStatusLine.Tally)과 같은 원천(PlayerStunTurns·GetActiveEffects)을 같은 규칙으로 읽는다 — + /// 카드에 「중독 3턴」이 떠 있으면 몸에도 거품이 오른다. 레이드는 버프 만료가 없어 누적 스택(AttackStacks·DefenseStacks)의 부호로 본다. + /// + public static class BattleStatusLook + { + public static readonly Color PoisonColor = new Color(0.74f, 0.4f, 0.98f); + public static readonly Color StunColor = new Color(1f, 0.87f, 0.22f); + public static readonly Color StunEdgeColor = new Color(0.55f, 0.3f, 0.04f); + public static readonly Color AttackUpColor = new Color(1f, 0.44f, 0.12f); + public static readonly Color AttackDownColor = new Color(0.6f, 0.15f, 0.08f); + public static readonly Color DefenseUpColor = new Color(0.4f, 0.74f, 1f); + public static readonly Color DefenseDownColor = new Color(0.13f, 0.23f, 0.6f); + public static readonly Color HealColor = new Color(0.45f, 1f, 0.55f); + + /// 1대1 한쪽의 몸 상태 — 기절은 컨트롤러의 따로 센 값, 나머지는 효과 목록(남은 턴이 있는 것만). + public static BattleStatusFlags FromDuel(int stunTurns, InsectBattleController.EffectSnapshot[] effects, bool forPlayer) + { + BattleStatusFlags flags = stunTurns > 0 ? BattleStatusFlags.Stun : BattleStatusFlags.None; + if (effects == null) return flags; + for (int i = 0; i < effects.Length; i++) + { + InsectBattleController.EffectSnapshot e = effects[i]; + if (e.targetIsPlayer != forPlayer || e.remainingTurns <= 0) continue; + switch (e.kind) + { + case InsectBattleController.EffectKind.Dot: + flags |= BattleStatusFlags.Poison; + break; + case InsectBattleController.EffectKind.DefBuff: + flags |= e.value >= 0f ? BattleStatusFlags.DefenseUp : BattleStatusFlags.DefenseDown; + break; + default: + flags |= e.value >= 0f ? BattleStatusFlags.AttackUp : BattleStatusFlags.AttackDown; + break; + } + } + return flags; + } + + /// 레이드 한 마리의 몸 상태 — 스택 부호(+ 강화, − 약화)와 보스 기절. + public static BattleStatusFlags FromStacks(int attackStacks, int defenseStacks, bool stunned) + { + BattleStatusFlags flags = stunned ? BattleStatusFlags.Stun : BattleStatusFlags.None; + if (attackStacks > 0) flags |= BattleStatusFlags.AttackUp; + else if (attackStacks < 0) flags |= BattleStatusFlags.AttackDown; + if (defenseStacks > 0) flags |= BattleStatusFlags.DefenseUp; + else if (defenseStacks < 0) flags |= BattleStatusFlags.DefenseDown; + return flags; + } + + /// + /// 레이드 보스가 지금 기절해 있는가 — 기절로 차례를 건너뛴 보스 응답(BossResponseSkipped)이 나온 뒤부터 그 라운드가 끝나도 다음 + /// 라운드가 시작될 때까지. 컨트롤러의 기절 표지(bossStunned)는 공개되지 않고, 기절 명중(StunApplied)은 면역 라운드에 + /// 저항될 수 있어 "건너뛰었다"만 확실하다. + /// + public static bool RaidBossDazed(RaidRoundResult round) + { + return round != null && round.BossResponseSkipped + && (round.Stage == RaidRoundStage.BossResolved || round.Stage == RaidRoundStage.Completed); + } + + // ── 움직임 곡선(나이 0..1) ── + + /// 독 거품 한 알 — 몸 위로 오르며(0→1) 커지고, 끝에서 톡 부풀며 사라진다. + public static void Bubble(float age01, out float rise, out float scale, out float alpha) + { + float a = Mathf.Clamp01(age01); + rise = a; + float pop = a > 0.85f ? (a - 0.85f) / 0.15f : 0f; + scale = (0.45f + 0.75f * a) * (1f + 0.5f * pop); + alpha = a < 0.12f ? a / 0.12f : a > 0.85f ? 1f - pop : 1f; + } + + /// 불꽃결·내려가는 결 한 가닥 — 나타났다(사인) 사라지고, 끝으로 갈수록 가늘어진다. + public static void Streak(float age01, out float alpha, out float width) + { + float a = Mathf.Clamp01(age01); + alpha = Mathf.Sin(a * Mathf.PI); + width = 1f - 0.5f * a; + } + + /// 육각 막의 은은한 일렁임 0.18~0.4 — 위상이 다른 칸마다 띠처럼 지나간다. 섬광 줄이기면 고정 0.26. + public static float ShieldAlpha(float seconds, float tilePhase, bool reducedFlashes) + { + if (reducedFlashes) return 0.26f; + return 0.18f + 0.22f * Mathf.Max(0f, Mathf.Sin(seconds * 2.4f - tilePhase)); + } + } +} diff --git a/Assets/Scripts/Battle/BattleStatusLook.cs.meta b/Assets/Scripts/Battle/BattleStatusLook.cs.meta new file mode 100644 index 0000000..dfd97d7 --- /dev/null +++ b/Assets/Scripts/Battle/BattleStatusLook.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d8c39baeb00ebbd46ac35455d7bf1cc0 \ No newline at end of file diff --git a/Assets/Scripts/Battle/BattleVisualCapture.cs b/Assets/Scripts/Battle/BattleVisualCapture.cs new file mode 100644 index 0000000..2a17ec7 --- /dev/null +++ b/Assets/Scripts/Battle/BattleVisualCapture.cs @@ -0,0 +1,486 @@ +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System; +using System.Collections; +using System.IO; +using System.Reflection; +using System.Collections.Generic; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.UI; +using UnityEngine; + +namespace InsectGame.Battle +{ + /// Standalone-only visual QA fixture. No bootstrap, authentication or save services. + public sealed class BattleVisualCapture : MonoBehaviour + { + private const BindingFlags PrivateInstance = BindingFlags.Instance | BindingFlags.NonPublic; + /// 프레임 사이 간격(실제 초). 히트스톱(0.06~0.2초)을 보려면 0.1보다 촘촘해야 한다. + internal static float CaptureInterval = 0.1f; + private float watchdogStart; + private float watchdogSeconds = 120f; + private void Awake() + { + watchdogStart = Time.realtimeSinceStartup; + if (Array.IndexOf(Environment.GetCommandLineArgs(), "-insectUiInteractive") >= 0) + watchdogSeconds = 210f; + } + private void Update() + { + if (Time.realtimeSinceStartup - watchdogStart > watchdogSeconds) Application.Quit(4); + } + private IEnumerator Start() + { + if (Application.isBatchMode || SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Null) + { Application.Quit(2); yield break; } + string[] args = Environment.GetCommandLineArgs(); + int arg = Array.IndexOf(args, "-battleCaptureOut"); + if (arg < 0 || arg + 1 >= args.Length) { Application.Quit(2); yield break; } + string output = Path.GetFullPath(args[arg + 1]); + if (Directory.Exists(output) && Directory.GetFileSystemEntries(output).Length > 0) + { Debug.LogError("Capture requires an empty output directory."); Application.Quit(2); yield break; } + Directory.CreateDirectory(output); + int scenarioArg = Array.IndexOf(args, "-battleScenario"); + string scenario = scenarioArg >= 0 && scenarioArg + 1 < args.Length ? args[scenarioArg + 1] : "duel"; + // 연출 비교용 — 같은 장면을 카메라 스타일만 바꿔 찍는다(off|punch|cinematic). + int styleArg = Array.IndexOf(args, "-battleCamStyle"); + if (styleArg >= 0 && styleArg + 1 < args.Length + && Enum.TryParse(args[styleArg + 1], true, out BattleCameraDirector.Style camStyle)) + BattleCameraDirector.Current = camStyle; + int intervalArg = Array.IndexOf(args, "-captureInterval"); + float interval = 0.1f; + if (intervalArg >= 0 && intervalArg + 1 < args.Length) + float.TryParse(args[intervalArg + 1], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out interval); + CaptureInterval = Mathf.Clamp(interval, 0.02f, 1f); + Application.runInBackground = true; + Application.targetFrameRate = 60; + QualitySettings.vSyncCount = 0; + UnityEngine.Random.InitState(8173); + // Override in memory only; setters persist user preferences. + typeof(BattlePresentation).GetField("speed", BindingFlags.Static | BindingFlags.NonPublic).SetValue(null, 1f); + typeof(BattlePresentation).GetField("reducedMotion", BindingFlags.Static | BindingFlags.NonPublic).SetValue(null, false); + typeof(BattlePresentation).GetField("reducedFlashes", BindingFlags.Static | BindingFlags.NonPublic).SetValue(null, false); + RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat; + RenderSettings.ambientLight = new Color(0.34f, 0.40f, 0.44f); + var cameraObject = new GameObject("QACamera"); + cameraObject.tag = "MainCamera"; + Camera camera = cameraObject.AddComponent(); + camera.backgroundColor = new Color(0.065f, 0.105f, 0.11f); + camera.clearFlags = CameraClearFlags.SolidColor; + camera.fieldOfView = 42f; + camera.farClipPlane = 200f; + CameraFollower follower = cameraObject.AddComponent(); + follower.SetTarget(new GameObject("QACameraTarget").transform); + if (scenario == "insect-ui") + { + follower.enabled = false; + yield return InsectDetailVisualCapture.Run(output, camera); + yield break; + } + if (scenario == "map") + { + follower.enabled = false; + yield return WorldMapVisualCapture.Run(output, camera); + yield break; + } + if (scenario == "story") + { + follower.enabled = false; + // 앞 컷: 「지난 이야기」 카드·HUD 목표 두 줄(할 일 + 왜) — 만든 것을 치우고 돌아온다. 뒤 컷: 대사 무대(끝에 종료). + yield return InsectGame.UI.StoryRecapVisualCapture.Run(output, camera); + yield return InsectGame.Story.StoryDialogueCapture.Run(output, camera); + yield break; + } + if (scenario == "materials") + { + // 월드 반투명·발광이 빌드에서 살아 있는가 — 수치 판정(SceneryMaterialVisualCapture 주석). + follower.enabled = false; + yield return SceneryMaterialVisualCapture.Run(output, camera); + yield break; + } + if (scenario == "badge") + { + follower.enabled = false; + yield return BadgeVisualCapture.Run(output, camera); + yield break; + } + if (scenario == "outfit") + { + // 의상 창·캐릭터 생성 화면 — IMGUI라 배치 캡처로는 창이 안 보인다(OutfitVisualCapture 주석). + follower.enabled = false; + yield return InsectGame.UI.OutfitVisualCapture.Run(output, camera); + yield break; + } + if (scenario == "field-ui") + { + // 필드 HUD·포획 선택·배틀팀 — IMGUI라 배치 캡처로는 안 보인다(FieldHudVisualCapture 주석). + follower.enabled = false; + yield return InsectGame.UI.FieldHudVisualCapture.Run(output, camera); + yield break; + } + if (scenario == "dream-island") + { + // 「챔피언의 꿈」 — 꾸며진 섬·도입 카드·안내·깨어남 자막. 전투는 dream-battle이 찍는다. + yield return InsectGame.UI.DreamVisualCapture.Run(output, camera); + yield break; + } + if (scenario == "story-video") { yield return InsectGame.UI.StoryVideoVisualCapture.Run(output, camera); yield break; } + if (scenario == "island-ui") + { + // 나의 섬 — HUD·꾸미기·상점·곤충·방문·가이드. 꾸미기 화면이 카메라를 직접 옮기므로 팔로워를 끄지 않는다. + yield return InsectGame.UI.IslandVisualCapture.Run(output, camera); + yield break; + } + BattleArenaController arena = new GameObject("QAArena").AddComponent(); + if (scenario == "raid" || scenario == "raid-unite" || scenario == "raid-forms" || scenario == "guardian-intro") + { + yield return RaidVisualCapture.Run(output, follower, arena, scenario); + yield break; + } + InsectBattleController controller = new GameObject("QAController").AddComponent(); + BattleScreenUI ui = new GameObject("QABattleUI").AddComponent(); + controller.AutoWire(arena); + arena.AutoWire(controller); // 몸 상태 표시의 원천(Life partial) — 안 주면 아레나가 한 번 찾는다 + ui.AutoWire(controller, follower, null); + ui.AutoWire(arena); + bool dreamBattle = scenario == "dream-battle"; + InsectData player = dreamBattle + ? Fixture(InsectGame.Core.DreamPrologueData.AceInsectId, "헤라클레스 천공각") + : Fixture("rhinoceros_beetle", "장수풍뎅이"); + InsectData enemy = dreamBattle + ? Fixture(InsectGame.Core.DreamPrologueData.ChallengerInsectId, "태고의 비천룡") + : Fixture("mantis", "사마귀"); + // 상성 표시 검수(4단계) — duel·victory는 내 「전력 돌진」(벌레)이 상대(풀·어둠)에게 「아주 잘 통했다!」, 상대의 벌레 기술은 + // 내 곤충(강철)에게 「별로 안 통했다…」로 뜨고, 스킬 카드의 벌레 기술 둘에 「아주 잘 통해요」 칩이 선다. 다른 장면은 그대로(무속성). + if (scenario == "duel" || scenario == "victory") + { + enemy.primaryType = InsectElement.Leaf; + enemy.secondaryType = InsectElement.Dark; + player.primaryType = InsectElement.Metal; + } + // 속성 임팩트 검수 — 같은 스킬의 속성만 매 턴 바꿔 10종을 차례로 쓴다. 한쪽이 먼저 쓰러지지 + // 않게 양쪽 HP를 크게 잡고 재사용 대기를 없앤다(연출만 보는 픽스처, 밸런스와 무관). + InsectElement[] elementCycle = (InsectElement[])Enum.GetValues(typeof(InsectElement)); + if (scenario == "elements") + { + player.baseHp = enemy.baseHp = 4000; + player.skills[0].cooldownTurns = 0; + } + // 4단계 전투 체감 검수 — 몸 상태 표시(status-fx)·전용기(signature)·승리(victory)·계열 몸짓(species-motions). + bool statusFx = scenario == "status-fx"; + bool signatureScene = scenario == "signature"; + bool victoryScene = scenario == "victory"; + bool speciesMotions = scenario == "species-motions"; + if (statusFx || signatureScene || speciesMotions) player.baseHp = enemy.baseHp = 4000; + if (signatureScene) MakeSignature(player, player.skills[0], "장수 대돌격"); + // 계열 몸짓 — 내 곤충 모델을 차례로 갈아 세우고 기본 공격(무속성 = 근접)을 한 번씩 쓴다. 상대(사마귀)의 반격이 베기를 보여 준다. + string[] motionIds = { "mantis_green", "rhinoceros_beetle", "butterfly_swallowtail", "bee_worker", "spider_garden" }; + string[] motionNames = { "사마귀", "장수풍뎅이", "호랑나비", "일벌", "왕거미" }; + bool statusInjected = false; + int statusInjectedFrame = -1; + InsectBattleStats playerStats = null, enemyStats = null; + PlayerInsectData first = scenario == "swap" ? ConfigureSwap(ui, player) : null; + controller.SetRandomSeed(8173); + if (scenario == "escape") controller.SetRandomSource(new EscapeSequence()); + controller.BattleUpdated += (p, e) => { playerStats = p; enemyStats = e; }; + bool teamDuel = scenario == "team-duel"; + int enemySwitches = 0; + if (teamDuel) + { + // 상대 교체 등장 검수 — 집게(ledger_grip)의 진짜 팀 순서·레벨로 연다. 교체를 빨리 보려고 앞 둘은 HP 1로 세우고 + // (첫째는 onStarted에서 — 화면이 첫 HP를 읽기 전), 에이스는 들어올 때 HP 1/4로 깎는다. 밸런스와 무관한 연출 픽스처다. + if (!TryStartTeamDuelFixture(controller, player, () => enemySwitches++)) + { Debug.LogError("[QA] team duel did not start"); Application.Quit(3); yield break; } + } + else if (dreamBattle) + { + // 진짜 지휘자가 샌드박스 전투를 연다 — 안내 문구·결과 화면·도망 버튼 숨김을 실제 코드로 본다. + var dreamDb = ScriptableObject.CreateInstance(); + dreamDb.insects.Add(player); + dreamDb.insects.Add(enemy); + var dreamDirector = new GameObject("QADreamDirector").AddComponent(); + dreamDirector.AutoWire(null, null, null, null, null, controller, ui, dreamDb, null); + if (!dreamDirector.StartBattleForCapture()) { Debug.LogError("[QA] dream battle did not start"); Application.Quit(3); yield break; } + } + else controller.StartDuel(player, 15, enemy, 15, null, null, first); + // 야생 전투로 보이게 — 진입 문구 「야생 사마귀가 나타났다!」와 승리 보상 줄(캔디·경험치·아이템·포획)을 보려고. 이 픽스처는 월드 개체 없이 + // 대결로 여므로 대결 표지만 끈다(야생 피해 배율 0.7이 걸린다 — 연출 픽스처라 밸런스와 무관). 보상은 컨트롤러가 야생 승리 때 채우는 + // 값을 미리 넣는다 — 월드 개체가 없으면 승리 경로가 이 값을 건드리지 않는다. 저장은 부르지 않는다(지갑·캔디·컬렉션 없음). + bool wildLook = scenario == "duel" || victoryScene; + // duel은 포획 실패 줄(「사마귀는 달아났다」), victory는 포획 성공 줄(「사마귀를 잡았다!」). + if (wildLook) PresentAsWild(controller, victoryScene); + // 승리 검수 — 상대를 HP 1로 세워 첫 기술이 마무리가 되게(쓰러짐 → 결과 → 승리 포즈·카메라 반 바퀴). + if (victoryScene && enemyStats != null) typeof(InsectBattleStats).GetProperty("CurrentHp").SetValue(enemyStats, 1); + // 연출 검수 — 간부전(컷인·장부·전투 중 한마디·결과 한마디)과 수문장전(곤충끼리 서는 컷인). + // 수문장 표식은 StartBattle(월드 개체)에서만 서므로 리플렉션으로 세운다 — 적 픽스처가 초원 수호자와 같은 사마귀다. + if (scenario == "boss") { controller.SetDuelOpponent("ledger_grip"); controller.ArmLedger(6); } + if (scenario == "rival") controller.SetDuelOpponent("rival_final"); + if (scenario == "guardian") + typeof(InsectBattleController).GetProperty("EnemyGuardianRegionId").SetValue(controller, "meadow"); + // 낮·밤·날씨 보정 칩 — 보정은 야생 전투(StartBattle, 월드 개체)에서만 걸리고 이 픽스처는 대결로 열므로 노트를 리플렉션으로 세운다. + // 유리(민트)·불리(코랄)를 한 장면에 둘 다 띄우고, 가장 긴 문구(BattleEnvironment 테스트의 20자 상한)를 내 쪽에 둔다. + if (scenario == "weather") + { + typeof(InsectBattleController).GetProperty("PlayerEnvironment").SetValue(controller, + new BattleEnvironmentNote { Multiplier = 1.1f, Reason = "아침 · 주행성 · 맑은 날을 좋아함" }); + typeof(InsectBattleController).GetProperty("EnemyEnvironment").SetValue(controller, + new BattleEnvironmentNote { Multiplier = 0.9f, Reason = "비 · 비를 싫어함" }); + } + FieldInfo phaseField = typeof(BattleScreenUI).GetField("phase", PrivateInstance); + FieldInfo shownPlayer = typeof(BattleScreenUI).GetField("displayPlayerHp", PrivateInstance); + FieldInfo shownEnemy = typeof(BattleScreenUI).GetField("displayEnemyHp", PrivateInstance); + FieldInfo impact = typeof(BattleScreenUI).GetField("impactRevealed", PrivateInstance); + FieldInfo phaseTimer = typeof(BattleScreenUI).GetField("phaseTimer", PrivateInstance); + MethodInfo attack = typeof(BattleScreenUI).GetMethod("TryBasicAttack", PrivateInstance); + MethodInfo skill = typeof(BattleScreenUI).GetMethod("TryUseSkill", PrivateInstance); + MethodInfo escape = typeof(BattleScreenUI).GetMethod("TryEscape", PrivateInstance); + MethodInfo swap = typeof(BattleScreenUI).GetMethod("TrySwapToSlot", PrivateInstance); + float started = Time.realtimeSinceStartup; + float nextFrame = 0f, turnAt = -1f, resultAt = -1f; + int frame = 0, turns = 0; + bool swapped = false, speedChanged = false; + string previous = ""; + using (var elementLog = new StreamWriter(Path.Combine(output, "elements.csv"))) + using (var timeline = new StreamWriter(Path.Combine(output, "timeline.csv"))) + { + elementLog.WriteLine("frame,elapsed,element"); + timeline.WriteLine("frame,elapsed,phase,actualPlayerHp,actualEnemyHp,displayPlayerHp,displayEnemyHp,impactRevealed,phaseTimer,width,height,speed,playerX,enemyX,enemyIndex,enemySwitchProgress,battleKind,entryProgress,signatureCutIn,resultShown,resultCanClose"); + while (Time.realtimeSinceStartup - started < 90f) + { + yield return new WaitForEndOfFrame(); + float elapsed = Time.realtimeSinceStartup - started; + string phase = phaseField.GetValue(ui).ToString(); + bool changed = phase != previous; + if (changed && (phase == "PlayerTurn" || phase == "SwapSelect")) turnAt = elapsed; + if (scenario == "speed" && !speedChanged && phase == "PlayerAttack" && (float)phaseTimer.GetValue(ui) > 0.5f) + { + typeof(BattlePresentation).GetField("speed", BindingFlags.Static | BindingFlags.NonPublic).SetValue(null, 2f); + speedChanged = true; + } + if (phase == "Result" && resultAt < 0f) resultAt = elapsed; + if (changed || elapsed >= nextFrame) + { + Texture2D shot = ScreenCapture.CaptureScreenshotAsTexture(); + File.WriteAllBytes(Path.Combine(output, $"frame-{frame:D5}.jpg"), shot.EncodeToJPG(85)); + if (changed) File.WriteAllBytes(Path.Combine(output, $"state-{frame:D5}-{phase}.png"), shot.EncodeToPNG()); + Destroy(shot); + float px = arena.PlayerModel != null ? camera.WorldToViewportPoint(arena.PlayerModel.transform.position).x : -1f; + float ex = arena.EnemyModel != null ? camera.WorldToViewportPoint(arena.EnemyModel.transform.position).x : -1f; + timeline.WriteLine(FormattableString.Invariant($"{frame},{elapsed:F3},{phase},{playerStats.CurrentHp},{enemyStats.CurrentHp},{shownPlayer.GetValue(ui)},{shownEnemy.GetValue(ui)},{impact.GetValue(ui)},{phaseTimer.GetValue(ui)},{Screen.width},{Screen.height},{BattlePresentation.Speed},{px},{ex},{controller.EnemyTeamIndex},{ui.EnemySwitchProgress:F3},{ui.CurrentBattleKind},{ui.EntryIntroProgress:F3},{arena.IsSignatureCutIn},{ui.ResultShownSeconds:F2},{ui.ResultCanClose}")); + timeline.Flush(); + frame++; + nextFrame = elapsed + CaptureInterval; + } + previous = phase; + // 몸 상태 검수 — 두 번째 행동(연속 2!)이 해결된 직후에 상태를 한꺼번에 건다: 내 곤충 기절 2턴·공격 강화, + // 상대 독·방어 강화. 다음 차례는 기절로 건너뛰고(「행동 불가!」) 그 라운드 끝에 독이 「-8 독」으로 뜬다. + if (statusFx && !statusInjected && turns >= 2 && phase == "PlayerAttack") + { + InjectStatuses(controller); + statusInjected = true; + statusInjectedFrame = frame; + } + if ((statusFx && turns >= 4 || signatureScene && turns >= 2) + && phase == "PlayerTurn" && elapsed - turnAt >= 0.6f) break; + if (phase == "PlayerTurn" && elapsed - turnAt >= 0.8f) + { + if (speciesMotions) + { + if (turns >= motionIds.Length) break; + // 계열마다 내 곤충 모델만 갈아 세운다(HP 카드는 장수풍뎅이 그대로 — 몸짓 검수용). + arena.RebuildPlayerInsect(Fixture(motionIds[turns], motionNames[turns]), 15); + turns++; + attack.Invoke(ui, null); + } + else if (scenario == "escape") escape.Invoke(ui, null); + else if (scenario == "elements") + { + if (turns >= elementCycle.Length) break; + InsectElement element = elementCycle[turns++]; + player.skills[0].element = element; + elementLog.WriteLine(FormattableString.Invariant($"{frame},{elapsed:F3},{element}")); + elementLog.Flush(); + skill.Invoke(ui, new object[] { 0 }); + } + else if (turns++ == 0) skill.Invoke(ui, new object[] { 0 }); + else attack.Invoke(ui, null); + turnAt = elapsed; + } + if (scenario == "swap" && phase == "SwapSelect" && !swapped && elapsed - turnAt >= 0.8f) + { swap.Invoke(ui, new object[] { 1 }); swapped = true; } + // 승리는 연출(BattleFlourish.VictorySeconds 2초) 뒤 머무는 구도까지 본다. + // 그 밖의 장면도 보상 줄(0.8초부터 0.3초 간격)과 「눌러서 계속」(0.6초부터)이 다 찍히게 2.6초. + if (resultAt >= 0f && elapsed - resultAt >= (victoryScene ? 3.2f : 2.6f)) break; + } + } + File.WriteAllText(Path.Combine(output, "manifest.txt"), + $"Actual standalone ScreenCapture including IMGUI\nScenario={scenario}\nCameraStyle={BattleCameraDirector.Current}\nSeed=8173\nSize={Screen.width}x{Screen.height}\nFrames={frame}\nResultReached={resultAt >= 0f}\nSwapped={swapped}\nSpeedChanged={speedChanged}\nEnemySwitches={enemySwitches}\nStatusInjectedAtFrame={statusInjectedFrame}\nVictoryFinished={arena.VictoryFinished}\nWildLook={wildLook}\nFixture={(teamDuel ? "ledger_grip team order/levels; first two at 1 HP, ace at 1/4 HP" : speciesMotions ? "player model rebuilt per turn: " + string.Join(",", motionIds) + " (HP card stays rhinoceros_beetle)" : "neutral level15 duel")}; not a balance certification\n"); + bool completed = scenario == "elements" ? turns >= elementCycle.Length + : teamDuel ? resultAt >= 0f && enemySwitches >= 2 + : statusFx ? statusInjected && turns >= 4 + : signatureScene ? turns >= 2 + : speciesMotions ? turns >= motionIds.Length + : resultAt >= 0f; + Application.Quit(completed ? 0 : 3); + } + + /// + /// 몸 상태 검수용 — 컨트롤러 안쪽 상태를 리플렉션으로 건다(저장 없는 픽스처, 밸런스와 무관): 내 곤충 기절 2턴·공격 강화 4턴, + /// 상대 독(턴당 8) 4턴·방어 강화 4턴. HP 카드 상태 줄(BattleStatusLine)과 아레나 몸 표시(BattleStatusLook)가 같은 원천을 읽는다. + /// + private static void InjectStatuses(InsectBattleController controller) + { + MethodInfo add = typeof(InsectBattleController).GetMethod("AddEffect", PrivateInstance); + FieldInfo stun = typeof(InsectBattleController).GetField("playerStunTurns", PrivateInstance); + if (add == null || stun == null) { Debug.LogError("[QA] status injection hooks missing"); return; } + add.Invoke(controller, new object[] { true, 0.3f, 4, InsectBattleController.EffectKind.AtkBuff }); + add.Invoke(controller, new object[] { false, 8f, 4, InsectBattleController.EffectKind.Dot }); + add.Invoke(controller, new object[] { false, 0.3f, 4, InsectBattleController.EffectKind.DefBuff }); + stun.SetValue(controller, 2); + Debug.Log("[QA] status-fx: player stun 2 + attack up, enemy poison + defense up"); + } + + /// + /// 야생 전투 모양 — 대결 표지(duelMode)만 끄고 야생 승리 보상 값을 미리 넣는다(캔디 12·경험치 34·야생 드랍 재료 mat_leaf ×2 — 화면엔 「재료 ×2」·포획 시도 — + /// 면 성공). 월드 개체가 없는 승리는 보상 경로를 건너뛰므로 이 값이 결과 화면까지 남는다. + /// 리플렉션이 못 찾으면 대결 모양 그대로 찍힌다(로그만). + /// + private static void PresentAsWild(InsectBattleController controller, bool captured) + { + FieldInfo duel = typeof(InsectBattleController).GetField("duelMode", PrivateInstance); + if (duel == null) { Debug.LogWarning("[QA] duelMode field missing — entry stays a kid duel"); return; } + duel.SetValue(controller, false); + void Set(string name, object value) + { + FieldInfo field = typeof(InsectBattleController).GetField(name, PrivateInstance); + if (field != null) field.SetValue(controller, value); + else Debug.LogWarning("[QA] reward field missing: " + name); + } + Set("lastCandyReward", 12); + Set("lastExpReward", 34); + Set("lastCoinReward", 3); // 야생 승리 코인(BattleVictoryCoins) — 승리 화면 코인 줄 + Set("lastItemId", "mat_leaf"); + Set("lastItemCount", 2); + Set("lastCaptureAttempted", true); + Set("lastCaptureSucceeded", captured); + } + + /// 전용기 검수용 — 그 곤충의 learnset에 전용기 칸을 세운다(가 보는 표시). + private static void MakeSignature(InsectData owner, InsectSkill skill, string displayName) + { + skill.isSignatureSkill = true; + skill.displayName = displayName; + skill.cooldownTurns = 0; + owner.learnset = new[] { new InsectLearnableSkill { skillId = skill.skillId, learnLevel = 1, skill = skill } }; + } + + private static PlayerInsectData ConfigureSwap(BattleScreenUI ui, InsectData starter) + { + // Inactive components never run Awake/Load. Only UI lookup is wired; + // collection.saveData stays null and controller has no persistence services. + var holder = new GameObject("QAInactiveTeam"); + holder.SetActive(false); + var collection = holder.AddComponent(); + var team = holder.AddComponent(); + var bench = Fixture("stag_beetle", "사슴벌레"); + var database = ScriptableObject.CreateInstance(); + database.insects.Add(starter); database.insects.Add(bench); + typeof(PlayerInsectCollection).GetField("database", PrivateInstance).SetValue(collection, database); + var lookup = (Dictionary)typeof(PlayerInsectCollection).GetField("lookup", PrivateInstance).GetValue(collection); + var first = new PlayerInsectData { instanceId = "qa-first", insectId = starter.insectId, level = 15, currentHp = 1 }; + var second = new PlayerInsectData { instanceId = "qa-bench", insectId = bench.insectId, level = 15, currentHp = -1 }; + foreach (var pid in new[] { first, second }) + { pid.learnedSkillIds.Add("qa_strike"); pid.equippedSkillIds.Add("qa_strike"); lookup[pid.instanceId] = pid; } + typeof(BattleTeamManager).GetField("saveData", PrivateInstance).SetValue(team, + new BattleTeamSave { slotIds = new List { first.instanceId, second.instanceId, "", "", "" } }); + ui.AutoWire(team, collection, null); + return first; + } + + /// + /// 집게(ledger_grip) 팀 대결 픽스처 — NpcBossDuels의 진짜 팀(내보내는 순서·레벨)을 픽스처 곤충으로 세우고 장부도 건다. + /// 교체 등장을 빨리 보려고 앞 곤충들은 HP 1로 세우고(첫째는 onStarted에서 — 화면이 첫 HP를 읽기 전에), 에이스는 HP 1/4로 들어온다. + /// + private static bool TryStartTeamDuelFixture(InsectBattleController controller, InsectData player, Action onSwitch) + { + if (!InsectGame.NPC.NpcBossDuels.TryGet("ledger_grip", out InsectGame.NPC.NpcBossDuels.BossDuel grip) || !grip.IsTeam) + return false; + int size = grip.RosterSize; + var team = new InsectData[size]; + var levels = new int[size]; + for (int i = 0; i < size; i++) + { + string id = grip.RosterInsectId(i); + team[i] = Fixture(id, TeamDuelLabel(id)); + team[i].primaryType = InsectElement.Earth; // 모래언덕 땅속 것들 — 착지 빛살 색이 이 속성을 따른다 + levels[i] = grip.RosterLevel(i); + } + PropertyInfo hp = typeof(InsectBattleStats).GetProperty("CurrentHp"); + if (!controller.StartTeamDuel(player, levels[size - 1], team, levels, (p, e) => hp.SetValue(e, 1))) + return false; + controller.SetDuelOpponent(grip.storyNpcId); + controller.ArmLedger(grip.ledgerThreshold); + controller.EnemySwitched += (outgoing, incoming) => + { + onSwitch(); + bool ace = controller.EnemyTeamIndex >= size - 1; + hp.SetValue(incoming, ace ? Mathf.Max(1, incoming.MaxHp / 4) : 1); + }; + return true; + } + + private static string TeamDuelLabel(string insectId) + { + switch (insectId) + { + case "scarab_sand": return "모래 풍뎅이"; + case "antlion_dune": return "모래언덕 개미귀신"; + case "centipede_sand": return "모래 지네"; + default: return insectId; + } + } + + private sealed class EscapeSequence : IRaidRandomSource + { + private int rolls; + public float Next01() => rolls++ == 0 ? 0.99f : 0f; + public int NextInt(int minInclusive, int maxExclusive) => minInclusive; + } + + internal static InsectData Fixture(string id, string label) + { + InsectSkill skill = ScriptableObject.CreateInstance(); + skill.skillId = "qa_strike"; + skill.displayName = "전력 돌진"; + skill.description = "힘을 모아 상대에게 돌진합니다."; + skill.element = InsectElement.Bug; + skill.power = 20; + skill.cooldownTurns = 2; + InsectData data = ScriptableObject.CreateInstance(); + data.insectId = id; + data.displayName = label; + data.baseHp = 140; + data.baseAtk = 30; + data.baseDef = 25; + InsectSkill guard = ScriptableObject.CreateInstance(); + guard.skillId = "qa_guard"; + guard.displayName = "단단한 갑각"; + guard.effectType = SkillEffectType.DefenseBuff; + guard.element = InsectElement.Bug; + InsectSkill heal = ScriptableObject.CreateInstance(); + heal.skillId = "qa_heal"; + heal.displayName = "자연의 회복"; + heal.effectType = SkillEffectType.Heal; + heal.element = InsectElement.Bug; + InsectSkill strike = ScriptableObject.CreateInstance(); + strike.skillId = "qa_strike_light"; + strike.displayName = "날카로운 일격"; + strike.element = InsectElement.Bug; + strike.power = 12; + data.skills = id == "rhinoceros_beetle" ? new[] { skill, guard, heal, strike } : new[] { skill }; + return data; + } + } +} +#endif diff --git a/Assets/Scripts/Battle/BattleVisualCapture.cs.meta b/Assets/Scripts/Battle/BattleVisualCapture.cs.meta new file mode 100644 index 0000000..61b9272 --- /dev/null +++ b/Assets/Scripts/Battle/BattleVisualCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 119cc9c60109d3e4bb657f4b4645f8e1 \ No newline at end of file diff --git a/Assets/Scripts/Battle/ElementMatchup.cs b/Assets/Scripts/Battle/ElementMatchup.cs new file mode 100644 index 0000000..59db14e --- /dev/null +++ b/Assets/Scripts/Battle/ElementMatchup.cs @@ -0,0 +1,69 @@ +using InsectGame.Data; + +namespace InsectGame.Battle +{ + /// + /// 상성 등급 — 피해기 한 번이 상대에게 얼마나 통했나. 아이용 문구(「아주 잘 통해요」·「잘 통해요」·「잘 안 통해요」…)와 + /// 연출 세기는 화면 쪽(ui-dev·visual-dev)이 이 값으로 고른다. 이 기본값(0)이다 — 피해기가 아니거나 + /// 빗나갔거나 상성을 안 타는 공격(레이드 보스 전체공격·독 일괄딜)은 이 값이다. + /// + public enum Matchup + { + /// 보통(배수 0.95~1.05 — 실제 표에서는 1.0과 「강점×약점」 1.005). + Neutral = 0, + /// 두 속성 모두에 강하다(실제 표에서는 2.25). + Super, + /// 강하다(실제 표에서는 1.5). + Good, + /// 약하다(실제 표에서는 0.67). + Weak, + /// 두 속성 모두에 약하다(실제 표에서는 0.45 — 하한 clamp). + Resisted + } + + /// + /// 상성 배수 → . 순수 — 1대1·레이드·스킬 카드가 같은 경계를 쓴다. + /// + /// 경계는 실제 상성표(: 강 ×1.5 · 약 ×0.67 · 복합 곱 · clamp 0.45~2.25)에서 나오는 여섯 값 + /// {2.25, 1.5, 1.005, 1.0, 0.67, 0.45}이 서로 다른 칸에 떨어지도록 잡았다. 「좋음/나쁨」 경계(1.05/0.95)는 전투 문구 + /// 타격 순간의 상성 표시(BattleFeelHud — 「잘 통했다!」 등)와 스킬 카드 칩이 같은 값을 읽는다 — 칩과 문구가 갈리지 않게. + /// 강점×약점 복합(1.5×0.67 = 1.005)은 보통이다. + /// + public static class ElementMatchup + { + /// 이 배수 이상이면 (2.25만 해당 — 1.5와 2.25 사이). + public const float SuperAtLeast = 2.0f; + /// 이 배수 초과면 이상. + public const float GoodAbove = 1.05f; + /// 이 배수 미만이면 이하. + public const float WeakBelow = 0.95f; + /// 이 배수 이하면 (0.45만 해당 — 0.45와 0.67 사이). + public const float ResistedAtMost = 0.5f; + + /// 상성 배수()의 등급. + public static Matchup Describe(float effectiveness) + { + if (effectiveness >= SuperAtLeast) return Matchup.Super; + if (effectiveness > GoodAbove) return Matchup.Good; + if (effectiveness <= ResistedAtMost) return Matchup.Resisted; + if (effectiveness < WeakBelow) return Matchup.Weak; + return Matchup.Neutral; // NaN도 여기로 온다(비교가 전부 거짓) + } + + /// + /// 속성 기술이 에게 통하는 등급. 상대를 모르면 보통. + /// 레이드 보스는 지금 모습(CombatData)을 넘긴다 — 모습이 바뀌면 약점이 바뀐다. + /// + public static Matchup Of(InsectElement attack, InsectData defender) + { + if (defender == null) return Matchup.Neutral; + return Describe(InsectTypeChart.GetEffectiveness(attack, defender.primaryType, defender.secondaryType)); + } + + /// 잘 통했다(·). + public static bool IsFavorable(Matchup matchup) => matchup == Matchup.Super || matchup == Matchup.Good; + + /// 잘 안 통했다(·). + public static bool IsUnfavorable(Matchup matchup) => matchup == Matchup.Weak || matchup == Matchup.Resisted; + } +} diff --git a/Assets/Scripts/Battle/ElementMatchup.cs.meta b/Assets/Scripts/Battle/ElementMatchup.cs.meta new file mode 100644 index 0000000..cf3e6b7 --- /dev/null +++ b/Assets/Scripts/Battle/ElementMatchup.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0f34375d37126f74698fb873e21245bd \ No newline at end of file diff --git a/Assets/Scripts/Battle/ForestBattleSet.cs b/Assets/Scripts/Battle/ForestBattleSet.cs new file mode 100644 index 0000000..9c2a38f --- /dev/null +++ b/Assets/Scripts/Battle/ForestBattleSet.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using InsectGame.Core; +using UnityEngine; +using UnityEngine.Rendering; + +namespace InsectGame.Battle +{ + /// Deterministic forest clearing, combined by material. No scene/global render settings are changed. + public static class ForestBattleSet + { + public static void Build(Transform parent, Func materialFactory, List ownedMeshes) + { + Mesh pebble = ProcMeshLibrary.LowSphere(1f, 1f, 1f, 5, 9); + Mesh crown = ProcMeshLibrary.LowSphere(1f, 1f, 1f, 5, 10); + Mesh trunk = ProcMeshLibrary.TaperedCapsule(0.13f, 0.23f, 1f, 3, 8); + var ground = new List(); + var clearing = new List(); + var moss = new List(); + var bark = new List(); + var nearLeaves = new List(); + var farLeaves = new List(); + var stones = new List(); + Add(ground, crown, new Vector3(0f, -0.24f, 0f), new Vector3(38f, 0.2f, 38f)); + Add(moss, crown, new Vector3(0f, -0.12f, 0f), new Vector3(8.6f, 0.16f, 6.8f)); + Add(clearing, crown, new Vector3(0f, -0.065f, 0f), new Vector3(6.6f, 0.10f, 4.8f)); + var random = new System.Random(8173); + for (int i = 0; i < 18; i++) + { + // Back and sides only: keep foreground center and the duel axis clear. + float angle = Mathf.Lerp(-112f, 112f, i / 17f) * Mathf.Deg2Rad; + float distance = 9f + (float)random.NextDouble() * 7f; + Vector3 foot = new Vector3(Mathf.Sin(angle) * distance, 0f, Mathf.Cos(angle) * distance); + float height = 4.4f + (float)random.NextDouble() * 3f; + Add(bark, trunk, foot + Vector3.up * (height * 0.5f), new Vector3(1.8f, height, 1.8f), (float)random.NextDouble() * 20f); + var leaves = i % 3 == 0 ? nearLeaves : farLeaves; + Add(leaves, crown, foot + Vector3.up * height, new Vector3(2.4f, 1.8f, 2.2f)); + Add(leaves, crown, foot + new Vector3(1.1f, height - 0.6f, 0.2f), new Vector3(1.7f, 1.3f, 1.6f)); + } + for (int side = -1; side <= 1; side += 2) + for (int i = 0; i < 8; i++) + { + Vector3 p = new Vector3(side * (5.5f + (float)random.NextDouble() * 1.9f), 0f, -2.5f + i * 1.2f); + float size = 0.22f + (float)random.NextDouble() * 0.35f; + Add(stones, pebble, p + Vector3.up * size * 0.22f, new Vector3(size * 1.8f, size * 0.7f, size), i * 31f); + Add(nearLeaves, crown, p + new Vector3(side * 0.6f, 0.18f, 0.3f), new Vector3(0.8f, 0.35f, 0.65f)); + } + Emit(parent, "ForestGround", ground, materialFactory(new Color(0.095f, 0.15f, 0.135f)), ownedMeshes, false); + Emit(parent, "ForestMoss", moss, materialFactory(new Color(0.19f, 0.27f, 0.19f)), ownedMeshes, false); + Emit(parent, "ForestClearing", clearing, materialFactory(new Color(0.33f, 0.32f, 0.23f)), ownedMeshes, false); + Emit(parent, "ForestTrunks", bark, materialFactory(new Color(0.18f, 0.23f, 0.21f)), ownedMeshes, false); + Emit(parent, "ForestNearLeaves", nearLeaves, materialFactory(new Color(0.16f, 0.29f, 0.23f)), ownedMeshes, false); + Emit(parent, "ForestFarLeaves", farLeaves, materialFactory(new Color(0.22f, 0.32f, 0.29f)), ownedMeshes, false); + Emit(parent, "ForestStones", stones, materialFactory(new Color(0.29f, 0.34f, 0.31f)), ownedMeshes, true); + } + + private static void Add(List batch, Mesh mesh, Vector3 position, Vector3 scale, float yaw = 0f) + { + batch.Add(new CombineInstance { mesh = mesh, transform = Matrix4x4.TRS(position, Quaternion.Euler(0f, yaw, 0f), scale) }); + } + + private static void Emit(Transform parent, string name, List batch, Material material, + List ownedMeshes, bool castShadows) + { + var mesh = new Mesh { name = name }; + mesh.CombineMeshes(batch.ToArray(), true, true); + ownedMeshes.Add(mesh); + var go = new GameObject(name, typeof(MeshFilter), typeof(MeshRenderer)); + go.transform.SetParent(parent, false); + go.GetComponent().sharedMesh = mesh; + var renderer = go.GetComponent(); + renderer.sharedMaterial = material; + renderer.shadowCastingMode = castShadows ? ShadowCastingMode.On : ShadowCastingMode.Off; + renderer.receiveShadows = true; + // 광택은 여기서 따로 정하지 않는다 — materialFactory(BattleArenaController.CreateSafeMaterial)가 + // 월드 소품과 같은 무광 마감(SceneryMaterials.MatteGloss)을 이미 입혀 온다. 예전의 0.15 사본은 그 전의 흔적이다. + } + } +} diff --git a/Assets/Scripts/Battle/ForestBattleSet.cs.meta b/Assets/Scripts/Battle/ForestBattleSet.cs.meta new file mode 100644 index 0000000..5627aea --- /dev/null +++ b/Assets/Scripts/Battle/ForestBattleSet.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 12e3a194a16a3de4691fa5bf4bc54298 \ No newline at end of file diff --git a/Assets/Scripts/Battle/InsectBattleController.cs b/Assets/Scripts/Battle/InsectBattleController.cs index a7300d1..4cca90f 100644 --- a/Assets/Scripts/Battle/InsectBattleController.cs +++ b/Assets/Scripts/Battle/InsectBattleController.cs @@ -30,7 +30,157 @@ public class InsectBattleController : MonoBehaviour public event Action BattleUpdated; public event Action PlayerFainted; + /// + /// 팀 대결()에서 상대 곤충이 쓰러지고 다음 곤충이 나왔다 — (나가는 곤충, 들어오는 곤충). + /// 그 라운드의 뒤에, 같은 행동 처리 안에서 동기로 울린다. 이 이벤트 뒤에 + /// 를 다시 쏘지 않는다 — 화면이 마지막 일격·쓰러짐을 마저 보여 주고 나서 들어오는 곤충으로 + /// 바꾸도록(BattleScreenUI의 교체 단계). 들어온 곤충은 이 라운드에 행동하지 않는다 — 다음 내 행동부터 반격한다. + /// 마지막 곤충이 쓰러지면 울리지 않고 평소처럼 (true)가 한 번 울린다. + /// + public event Action EnemySwitched; + // 적이 직전 턴에 사용한 스킬(null=기본공격/쿨다운) — UI가 EnemyAttack 페이즈 연출에 사용. + private IRaidRandomSource randomSource = new BattleRandomSource(); + + // 치명타 전용 줄기 — 명중·도주·포획 롤(randomSource)과 **따로 돈다.** 같은 줄기에서 뽑으면 + // 피해기마다 롤이 하나 끼어 명중 롤 순서가 밀리고, 시드·스크립트 난수로 고정한 시나리오가 전부 바뀐다. + // null이면 치명타가 없다(결정적) — 피해·HP를 정확히 단언하는 테스트가 이걸로 끈다. + private IRaidRandomSource critSource = new BattleRandomSource(); + + /// 명중·도주·포획 롤 줄기만 바꾼다. 치명타 줄기는 그대로다(). + public void SetRandomSource(IRaidRandomSource source) + { + randomSource = source ?? new BattleRandomSource(); + } + + /// + /// Set before starting a deterministic fixture; never reads Unity's visual RNG. + /// 두 줄기를 함께 시드한다 — 명중 줄기는 , 치명타 줄기는 그 파생값. + /// 치명타를 끄려면 이 뒤에 (null)을 부를 것. + /// + public void SetRandomSeed(int seed) + { + randomSource = new BattleRandomSource(seed); + critSource = new BattleRandomSource(CritSeedFor(seed)); + } + + /// 치명타 줄기를 바꾼다. null이면 치명타가 아예 없다(굴리지도 않는다). + public void SetCritSource(IRaidRandomSource source) + { + critSource = source; + } + + /// 시드 고정 시 치명타 줄기의 시드 — 명중 줄기와 같은 수열이 되지 않게 비튼다. + public static int CritSeedFor(int seed) => unchecked(seed ^ 0x6C8E9CF5); + + /// + /// 이번 라운드에 내 곤충의 행동이 치명타였는가. 행동을 시작할 때(BeginResolvedRound) 끈다 — + /// 에서 읽으면 방금 라운드 값이다. 피해기만 켤 수 있다(버프·회복·독·기절·빗나감·샌드박스 제외). + /// + public bool LastPlayerHitCritical { get; private set; } + + /// 이번 라운드에 상대의 반격이 치명타였는가. 규칙은 과 같다. + public bool LastEnemyHitCritical { get; private set; } + + // ── 마지막 행동의 전용기·상성(연출·문구가 읽는다) ── + // 치명타 표지와 같은 수명이다 — 행동을 시작할 때(BeginResolvedRound) 끄고, BattleUpdated에서 읽으면 방금 라운드 값이다. + + /// 이번 라운드에 내 곤충이 쓴 기술. 기본 공격·기절로 건너뛴 차례·도주면 null. + public InsectSkill LastPlayerSkill { get; private set; } + + /// + /// 이번 라운드에 내 곤충이 쓴 기술이 그 곤충의 전용기였는가(). + /// 빗나가도 true다 — 전용기를 꺼낸 것 자체가 연출거리다(맞았는지는 ·피해로 본다). + /// + public bool LastPlayerSkillIsSignature { get; private set; } + + /// + /// 이번 라운드 내 피해기가 상대에게 들어갔을 때의 상성 등급(). 피해기가 아니거나 + /// 빗나갔거나 기본 공격(상성 없음)이면 . 화면은 이 값 하나로 피해 숫자 위 상성 표시를 띄운다 + /// (위쪽 효과 문구로는 띄우지 않는다 — 한 타격에 두 번 보이지 않게). + /// + public Matchup LastPlayerHitMatchup { get; private set; } + + /// 이번 라운드 상대가 쓴 기술()이 상대 곤충의 전용기였는가. 규칙은 와 같다. + public bool LastEnemySkillIsSignature { get; private set; } + + /// 이번 라운드 상대 피해기가 내 곤충에게 들어갔을 때의 상성 등급. 규칙은 과 같다. + public Matchup LastEnemyHitMatchup { get; private set; } + + /// 기절로 내 곤충이 건너뛸 남은 행동 수(0이면 정상). HP 카드의 기절 표시가 읽는다. + public int PlayerStunTurns => playerStunTurns; + + /// 기절로 상대가 건너뛸 남은 행동 수(0이면 정상). + public int EnemyStunTurns => enemyStunTurns; + + /// + /// 이번 라운드 끝(TickEffects)에 독으로 내 곤충이 실제로 잃은 HP. 행동을 시작할 때 0으로 돌아간다. + /// 독 피해는 두 공격의 스냅샷() 뒤에 들어가므로, 연출 지연 모드에서도 + /// 두 연출이 끝난 뒤(라운드 정리 단계) 이 값을 숫자로 띄우면 HP바 감소와 맞는다. + /// + public int PlayerPoisonDamageThisRound { get; private set; } + + /// 이번 라운드 끝에 독으로 상대가 실제로 잃은 HP. 규칙은 와 같다. + public int EnemyPoisonDamageThisRound { get; private set; } + + + public bool DeferPresentation { get; set; } + public bool PlayerActedThisRound { get; private set; } + public int PlayerHpAfterEnemyAction { get; private set; } + public int EnemyHpAfterEnemyAction { get; private set; } + public bool EnemyActedThisRound { get; private set; } + public int PlayerHpAfterPlayerAction { get; private set; } + public int EnemyHpAfterPlayerAction { get; private set; } + private bool resolvingEnemyAction; + private readonly List<(bool enemy, string text, Color color)> presentationTexts = new List<(bool, string, Color)>(); + + private void BeginResolvedRound() + { + PlayerActedThisRound = false; + EnemyActedThisRound = false; + LastEnemySkill = null; + LastPlayerHitCritical = false; + LastEnemyHitCritical = false; + LastPlayerSkill = null; + LastPlayerSkillIsSignature = false; + LastPlayerHitMatchup = Matchup.Neutral; + LastEnemySkillIsSignature = false; + LastEnemyHitMatchup = Matchup.Neutral; + PlayerPoisonDamageThisRound = 0; + EnemyPoisonDamageThisRound = 0; + resolvingEnemyAction = false; + presentationTexts.Clear(); + SnapshotPlayerAction(); + SnapshotEnemyAction(); + } + + private void SnapshotPlayerAction() + { + PlayerHpAfterPlayerAction = playerStats.CurrentHp; + EnemyHpAfterPlayerAction = enemyStats.CurrentHp; + } + + private void SnapshotEnemyAction() + { + PlayerHpAfterEnemyAction = playerStats.CurrentHp; + EnemyHpAfterEnemyAction = enemyStats.CurrentHp; + } + + public void PresentResolvedAction(bool playerAction) + { + if (Arena == null) return; + for (int i = 0; i < presentationTexts.Count;) + { + var message = presentationTexts[i]; + if (message.enemy != playerAction) + { + presentationTexts.RemoveAt(i); + Arena.PlayEffectText(message.text, message.color); + } + else i++; + } + } + public InsectSkill LastEnemySkill { get; private set; } /// @@ -39,8 +189,9 @@ public class InsectBattleController : MonoBehaviour /// BattleEnded가 Action<bool>이라 "무엇을 이겼는가"를 못 싣는다. /// 시그니처를 바꾸면 구독자 셋(UI·튜토리얼 퀘스트·스토리)과 배치 검증 도구의 리플렉션이 /// 함께 따라오므로, 대신 발화 시점에 읽을 수 있는 상태로 노출한다. - /// enemyStats는 BeginBattleCommon에서 한 번만 잡히고 전투 중 교체되지 않으므로 - /// BattleEnded 시점에도 유효하다. + /// enemyStats는 BeginBattleCommon에서 잡히고, 바뀌는 건 팀 대결의 교체 + /// ()뿐이다 — 그래서 BattleEnded 시점엔 마지막으로 싸운 상대 + /// (팀 대결이면 에이스)다. /// /// StoryDirector가 BattleWin 트리거의 종 지정에 쓴다 — 그쪽은 결과 화면 /// 뒤로 발화를 미루므로 이 값을 그때 다시 읽으면 늦다. 미룰 때 param에 실어야 한다. @@ -71,9 +222,56 @@ private void ReleaseEnemyAfterBattle() /// 판정 근거는 InsectEntity.GuardianRegionId 주석 참조. /// public string EnemyGuardianRegionId { get; private set; } + + /// + /// 대결 상대 ID(NPC.DuelBanter의 키) — 야생 전투·아이 대결이면 빈 문자열. + /// 연출(컷인·전투 중 한마디·결과 한마디)만 읽는다. 승패 처리는 대결을 건 쪽이 따로 든다. + /// + public string DuelOpponentId { get; private set; } = string.Empty; private bool enemyShinyAtStart; // 시작 시점 스냅샷 — 도주/풀 재사용된 라이브 참조로 보상 오등록 방지 private bool duelMode; // NPC 대결 — 포획 롤·야생 아이템 드랍 없음(StartDuel 참조) + /// + /// 이번(또는 마지막) 전투가 NPC 대결(아이·간부·라온 — ·)인가. + /// 야생·수문장·샌드박스면 false. 다음 전투를 시작할 때까지 값을 지킨다. 종류 판정은 . + /// + public bool IsDuel => duelMode; + + // 이번 전투에서 내가 행동해 **차례가 넘어간** 횟수 — 조건부 퀘스트(BattleFeat "N번 안에 이기기")의 입력. + // 스턴으로 건너뛴 차례도 센다(턴은 흘렀다). 입력이 거절돼 차례가 안 넘어간 호출은 세지 않는다. + // 샌드박스의 sandboxPlayerActions와 다른 수다 — 그쪽은 실제로 친 행동만 세어 피해 배율에 쓴다. + private int playerActionCount; + + // ── 샌드박스(「챔피언의 꿈」) ── + // 보상·도감·HP 저장·퀘스트·스토리 어느 것도 건드리지 않는 연출용 전투. 구독자가 많은 BattleEnded는 + // 그대로 쏘되(결과 화면이 그 신호로 뜬다) 퀘스트·스토리 쪽 핸들러가 이 표지를 보고 건너뛴다. + private bool sandbox; + private int sandboxPlayerActions; + + /// + /// 이번(또는 마지막) 전투가 샌드박스인가. BattleEnded 시점에도 유효하다 — 다음 전투를 시작할 때까지 + /// 값을 지키므로 핸들러가 "방금 끝난 전투"를 물을 수 있다(와 같은 이유). + /// + public bool IsSandbox => sandbox; + + // ── 낮·밤·날씨 보정(BattleEnvironment) ── + // 야생 실외 전투에서만 건다(수문장·대결·샌드박스·실내 서브에리어 제외 — 규칙은 BattleEnvironment.Applies). + // 전투를 시작한 순간의 하늘로 끝까지 싸운다 — 교체로 들어온 곤충도 같은 하늘로 잰다(상대 표지는 시작 때 정해져 그대로다). + private WorldStateProvider worldStateProvider; + private RegionManager regionManager; + private bool environmentActive; + private WorldState environmentState; + + /// + /// 이번 전투에서 내 곤충이 받는 낮·밤·날씨 보정(전투 화면 칩). 영향이 없거나 보정이 안 걸리는 전투면 + /// . onStarted/보다 먼저 채워지고, + /// 교체()하면 새 곤충 것으로 바뀐다. + /// + public BattleEnvironmentNote PlayerEnvironment { get; private set; } = BattleEnvironmentNote.None; + + /// 이번 전투에서 상대 곤충이 받는 낮·밤·날씨 보정. 규칙은 와 같다. + public BattleEnvironmentNote EnemyEnvironment { get; private set; } = BattleEnvironmentNote.None; + // ── 「장부」 압박(명부회 보스전 전용) ───────────────────────────── // 규칙과 상수는 LedgerPressure(순수부)가 들고, 임계는 NpcBossDuels 표가 든다. // 여기는 그 둘을 잇는 **상태**만 갖는다. @@ -98,9 +296,14 @@ private void ReleaseEnemyAfterBattle() private const int PersistentPoisonTurns = 3; private readonly List effects = new List(); private int lastCandyReward; + // 이번 승리에 준 코인 — 승리 화면 보상 줄이 읽는다(지급은 AddCoins가 한다). + private int lastCoinReward; private int lastExpReward; // 종료 가드 — 승리/패배 처리(보상 지급·BattleEnded)를 1회로 제한. // 없으면 종료 후 액션이 한 번 더 들어올 때(rapid tap/입력 큐) 보상 이중 지급. + /// True only for a successful escape; available before BattleEnded is raised. + public bool DidEscape { get; private set; } + private bool battleEnded; private string lastItemId; private int lastItemCount; @@ -132,6 +335,8 @@ public void StartBattle(InsectData playerInsect, int playerLevel, InsectEntity e enemyShinyAtStart = enemy.IsShiny; EnemyGuardianRegionId = enemy.GuardianRegionId; // 같은 이유의 스냅샷 duelMode = false; + // 낮·밤·날씨 보정 — onStarted/BattleUpdated보다 먼저 걸어야 화면이 첫 프레임부터 칩과 보정된 능력치를 본다. + ApplyWildEnvironment(); onStarted?.Invoke(playerStats, enemyStats); BattleUpdated?.Invoke(playerStats, enemyStats); } @@ -158,6 +363,210 @@ public bool StartDuel(InsectData playerInsect, int playerLevel, InsectData enemy return true; } + // ── 상대 팀(간부 팀 대결) ── + // null이면 한 마리 전투다(야생·아이·라온·하수·샌드박스). BeginBattleCommon이 매번 비운다 — + // 남으면 다음 아이 대결이 간부의 남은 곤충을 이어 내보낸다(sandbox 표지와 같은 계열). + private InsectData[] enemyTeamData; + private int[] enemyTeamLevels; + private bool[] enemyTeamFainted; + private int enemyTeamIndex; + + /// 상대 팀 크기 — 한 마리 전투면 1, 전투를 시작한 적이 없으면 0. + public int EnemyTeamSize => enemyTeamData != null ? enemyTeamData.Length : (enemyStats != null ? 1 : 0); + + /// 여러 마리를 차례로 내보내는 팀 대결인가(, 2마리 이상). + public bool IsEnemyTeamBattle => enemyTeamData != null && enemyTeamData.Length > 1; + + /// 지금 나와 있는 상대가 팀의 몇 번째인가(0부터). 한 마리 전투면 0. + public int EnemyTeamIndex => enemyTeamData != null ? enemyTeamIndex : 0; + + /// 아직 쓰러지지 않은 상대 수 — 지금 나와 있는 곤충을 포함한다. 마지막 곤충이 쓰러지면 0. + public int EnemiesRemaining + { + get + { + if (enemyTeamData == null) return enemyStats != null && enemyStats.CurrentHp > 0 ? 1 : 0; + int count = 0; + for (int i = 0; i < enemyTeamFainted.Length; i++) + if (!enemyTeamFainted[i]) count++; + return count; + } + } + + /// 번째 상대가 쓰러졌는가. 범위 밖이면 false. + public bool IsEnemyTeamMemberFainted(int slot) + { + if (enemyTeamData == null) return slot == 0 && enemyStats != null && enemyStats.CurrentHp <= 0; + return slot >= 0 && slot < enemyTeamFainted.Length && enemyTeamFainted[slot]; + } + + /// 번째로 나오는(나왔던) 상대 곤충. 한 마리 전투면 0번이 지금 상대다. + public InsectData GetEnemyTeamInsect(int slot) + { + if (enemyTeamData == null) return slot == 0 && enemyStats != null ? enemyStats.Data : null; + return slot >= 0 && slot < enemyTeamData.Length ? enemyTeamData[slot] : null; + } + + /// 번째 상대의 레벨(0이면 없음). + public int GetEnemyTeamLevel(int slot) + { + if (enemyTeamData == null) return slot == 0 && enemyStats != null ? enemyStats.Level : 0; + return slot >= 0 && slot < enemyTeamLevels.Length ? Mathf.Max(1, enemyTeamLevels[slot]) : 0; + } + + /// + /// 팀 대결 — 상대가 을 앞에서부터 차례로 내보낸다(마지막이 에이스). + /// 상대 곤충이 쓰러져도 남은 곤충이 있으면 전투가 끝나지 않고 다음 곤충이 나온다(). + /// 마지막 곤충이 쓰러져야 승리이고, 그때 ·가 한 번 울린다. + /// + /// 나머지는 과 같다 — 포획·도감·야생 드랍 없음, 낮·밤·날씨 보정 없음. + /// 보상(캔디·EXP·코인)은 쓰러뜨린 상대마다 한 마리 대결과 같은 식으로 계산해 합산하고 이겼을 때만 준다. + /// 장부()·상대 표지()는 이 호출 뒤에 건다(StartDuel과 같다). + /// 지면 다음 도전은 처음부터다 — 진행 중이던 팀을 기억하지 않는다. + /// + /// 팀이 비었거나 빈 칸·레벨 누락이 있거나 내 곤충이 기절했으면 false(상태를 건드리지 않는다). + public bool StartTeamDuel(InsectData playerInsect, int playerLevel, InsectData[] enemyTeam, int[] enemyLevels, + Action onStarted = null, InsectSkill[] equippedSkills = null, + Core.PlayerInsectData playerPid = null) + { + if (playerInsect == null || enemyTeam == null || enemyTeam.Length == 0) return false; + if (enemyLevels == null || enemyLevels.Length < enemyTeam.Length) return false; + for (int i = 0; i < enemyTeam.Length; i++) + if (enemyTeam[i] == null) return false; + if (playerPid != null && playerPid.IsFainted) return false; + + BeginBattleCommon(playerInsect, playerLevel, enemyTeam[0], enemyLevels[0], equippedSkills, playerPid); + enemyTeamData = (InsectData[])enemyTeam.Clone(); + enemyTeamLevels = new int[enemyTeam.Length]; + System.Array.Copy(enemyLevels, enemyTeamLevels, enemyTeam.Length); + enemyTeamFainted = new bool[enemyTeam.Length]; + enemyTeamIndex = 0; + enemyEntity = null; + enemyShinyAtStart = false; + EnemyGuardianRegionId = string.Empty; // NPC 대결은 수문장이 아니다 + duelMode = true; + onStarted?.Invoke(playerStats, enemyStats); + BattleUpdated?.Invoke(playerStats, enemyStats); + return true; + } + + // 지금 상대 뒤에 아직 안 쓰러진 곤충이 있는가. 샌드박스는 팀을 쓰지 않는다. + private bool HasNextEnemy() + { + if (enemyTeamData == null || sandbox) return false; + for (int i = enemyTeamIndex + 1; i < enemyTeamData.Length; i++) + if (!enemyTeamFainted[i]) return true; + return false; + } + + /// + /// 쓰러진 상대를 거두고 다음 곤충을 내보낸다 — 가 지금 상대의 HP가 0이고 뒤가 남았을 때 부른다. + /// 상대 쪽만 새 곤충 기준으로 초기화한다: 상대에게 걸린 버프·디버프·독(효과 목록의 상대 몫)·기절·쿨다운. + /// 내 곤충의 버프·독·기절·쿨다운은 그대로, 장부도 그대로다 — 장부는 곤충이 아니라 인물의 것이다. + /// + private void SendOutNextEnemy() + { + InsectBattleStats outgoing = enemyStats; + enemyTeamFainted[enemyTeamIndex] = true; + int next = enemyTeamIndex + 1; + while (next < enemyTeamData.Length && enemyTeamFainted[next]) next++; + if (next >= enemyTeamData.Length) return; // HasNextEnemy가 막는다 — 방어선 + + TryPlayFaint(false); // 연출 지연 모드(BattleScreenUI)에서는 화면이 직접 눕힌다 — 여기선 no-op + enemyTeamIndex = next; + enemyStats = new InsectBattleStats(enemyTeamData[next], enemyTeamLevels[next]); + effects.RemoveAll(e => !e.targetIsPlayer); + enemyStunTurns = 0; + enemyCooldown = 0; + // 대결은 낮·밤·날씨 보정이 꺼져 있다 — 켜져 있는 전투라면 들어온 곤충도 같은 하늘로 잰다(교체와 같은 규칙). + if (environmentActive) + { + EnemyEnvironment = BattleEnvironment.NoteFor(enemyStats.Data, environmentState); + enemyStats.ApplyEnvironment(EnemyEnvironment.Multiplier); + } + else + { + EnemyEnvironment = BattleEnvironmentNote.None; + } + RecalculateBonuses(); + // 구독자 예외 격리 — 여기서 던지면 같은 라운드의 내 기절 처리(CheckEnd)가 건너뛰어져 대결이 멈춘다(PlayerFainted와 같은 이유). + try + { + EnemySwitched?.Invoke(outgoing, enemyStats); + } + catch (System.Exception e) + { + Debug.LogWarning($"[InsectBattle] EnemySwitched 핸들러 예외: {e.Message}"); + } + } + + /// + /// 대결 승리 보상 — 상대 팀의 곤충마다 한 마리 대결과 같은 식(캔디·EXP·코인)으로 계산해 합산하고 지급한다. + /// 한 마리 대결이면 지금 상대 하나다(예전과 같은 값). 곤충마다 반올림한 뒤 더한다 — 세 마리 팀의 보상이 + /// 같은 세 곤충과 따로 싸운 대결 셋의 합과 정확히 같다. 캐릭터 레벨은 지급 전 값 하나로 잰다. + /// + private void GrantDuelVictoryRewards() + { + float duelCandyMul = (itemEffects != null ? itemEffects.GetCandyMultiplier() : 1f) + * (outfitBonus != null ? outfitBonus.GetCandyMultiplier() : 1f); + float duelExpMul = (itemEffects != null ? itemEffects.GetExpMultiplier() : 1f) + * (outfitBonus != null ? outfitBonus.GetExpMultiplier() : 1f); + int duelTrainerLevel = playerProgress != null ? playerProgress.Level : playerStats.Level; + + int opponents = enemyTeamData != null ? enemyTeamData.Length : 1; + int candy = 0; + int exp = 0; + for (int i = 0; i < opponents; i++) + { + InsectData data = enemyTeamData != null ? enemyTeamData[i] : enemyStats.Data; + int level = enemyTeamData != null ? Mathf.Max(1, enemyTeamLevels[i]) : enemyStats.Level; + candy += Mathf.RoundToInt(InsectRewardCalculator.GetCandyReward(data) * duelCandyMul); + exp += Mathf.RoundToInt(InsectRewardCalculator.GetExpReward(data, level, duelTrainerLevel) * duelExpMul); + } + + lastCandyReward = candy; + lastExpReward = exp; + candyInventory?.AddCandy(lastCandyReward); + playerProgress?.GainXp(lastExpReward); + lastCoinReward = BattleVictoryCoins * opponents; + wallet?.AddCoins(lastCoinReward); + } + + /// + /// 샌드박스 전투 — 월드 개체 없이 데이터만으로 붙고, 규칙은 가 정한다. + /// 보상·도감·HP 저장을 하지 않고 도 쏘지 않는다. 내 곤충()은 + /// 컬렉션에 없는 메모리 개체여도 된다. + /// + public bool StartSandbox(InsectData playerInsect, int playerLevel, InsectData enemyInsect, int enemyLevel, + InsectSkill[] equippedSkills, Core.PlayerInsectData playerPid, + Action onStarted = null) + { + if (playerInsect == null || enemyInsect == null) return false; + + BeginBattleCommon(playerInsect, playerLevel, enemyInsect, enemyLevel, equippedSkills, playerPid); + enemyEntity = null; + enemyShinyAtStart = false; + EnemyGuardianRegionId = string.Empty; + duelMode = false; + sandbox = true; + sandboxPlayerActions = 0; + onStarted?.Invoke(playerStats, enemyStats); + BattleUpdated?.Invoke(playerStats, enemyStats); + return true; + } + + /// + /// 샌드박스 전투를 이긴 것으로 끝낸다(건너뛰기). 정상 종료와 같은 길()을 타므로 + /// 결과 화면·정리가 평소대로 돈다. 샌드박스가 아니거나 이미 끝났으면 아무것도 하지 않는다. + /// + public void ForceSandboxVictory() + { + if (!sandbox || battleEnded || enemyStats == null) return; + enemyStats.ApplyDamage(Mathf.Max(1, enemyStats.CurrentHp)); + BattleUpdated?.Invoke(playerStats, enemyStats); + CheckEnd(); + } + // StartBattle/StartDuel이 공유하는 초기화 — 야생/듀얼 차이는 호출부가 뒤에 덮는다. private void BeginBattleCommon(InsectData playerInsect, int playerLevel, InsectData enemyInsect, int enemyLevel, InsectSkill[] equippedSkills, Core.PlayerInsectData playerPid) @@ -187,8 +596,10 @@ private void BeginBattleCommon(InsectData playerInsect, int playerLevel, ledgerArmedThisTurn = false; ledgerSpentThisTurn = false; ledgerReadCount = 0; + DuelOpponentId = string.Empty; // 장부와 같은 이유 — 다음 야생 전투가 간부 컷인을 물려받지 않게 lastCandyReward = 0; lastExpReward = 0; + lastCoinReward = 0; lastItemId = string.Empty; lastItemCount = 0; lastPlayerWon = false; @@ -196,10 +607,82 @@ private void BeginBattleCommon(InsectData playerInsect, int playerLevel, lastCaptureSucceeded = false; lastCaptureChance = 0f; battleEnded = false; + DidEscape = false; + sandbox = false; // StartSandbox가 공통 초기화 뒤에 다시 세운다 — 야생·대결이 이전 샌드박스 표지를 물려받지 않게 + sandboxPlayerActions = 0; + playerActionCount = 0; // 이전 전투의 횟수를 물려받으면 "N번 안에 이기기"가 조용히 어긋난다 + // 상대 팀도 sandbox 표지와 같은 이유로 여기서 끈다 — StartTeamDuel만 공통 초기화 뒤에 다시 세운다. + enemyTeamData = null; + enemyTeamLevels = null; + enemyTeamFainted = null; + enemyTeamIndex = 0; + // 낮·밤·날씨 보정도 sandbox 표지와 같은 이유로 여기서 끈다 — 야생 전투 뒤 대결·꿈 챔피언전이 이전 칩을 물려받지 않게. + // 야생 전투만 StartBattle이 공통 초기화 뒤에 다시 건다(ApplyWildEnvironment). + environmentActive = false; + environmentState = default; + PlayerEnvironment = BattleEnvironmentNote.None; + EnemyEnvironment = BattleEnvironmentNote.None; + BeginResolvedRound(); // onStarted/BattleUpdated는 호출부가 야생/듀얼 고유 필드(enemyEntity 등)를 채운 뒤에 울린다 — // BattleScreenUI.OnBattleUpdated가 GetEnemyEntity()를 읽어 아레나 위치를 잡기 때문이다. } + /// + /// 야생 전투에 낮·밤·날씨 보정을 건다 — 이 듀얼·수문장 표지를 세운 뒤, + /// 시작 신호를 쏘기 전에 부른다. 적과 내 곤충이 각자의 성향으로 ATK·DEF를 보정받는다(HP는 그대로). + /// 시계·날씨를 모르면(제공자 없음) 걸지 않는다. + /// + private void ApplyWildEnvironment() + { + RegionManager regions = Regions; + SubAreaData subArea = regions != null ? regions.CurrentSubArea : null; + bool guardian = !string.IsNullOrEmpty(EnemyGuardianRegionId); + if (!BattleEnvironment.Applies(duelMode, sandbox, guardian, BattleEnvironment.IsIndoor(subArea))) return; + + WorldStateProvider provider = WorldStateSource; + if (provider == null) return; + + RegionData region = regions != null ? regions.CurrentRegion : null; + environmentState = provider.GetWorldState(BattleEnvironment.WeatherRegionId(region, subArea)); + environmentActive = true; + + EnemyEnvironment = BattleEnvironment.NoteFor(enemyStats.Data, environmentState); + enemyStats.ApplyEnvironment(EnemyEnvironment.Multiplier); + ApplyPlayerEnvironment(); + } + + // 지금 싸우는 내 곤충에 이번 전투의 하늘을 건다 — 시작과 교체가 같이 쓴다. 보정이 안 걸리는 전투면 표지만 비운다. + private void ApplyPlayerEnvironment() + { + if (!environmentActive || playerStats == null) + { + PlayerEnvironment = BattleEnvironmentNote.None; + return; + } + + PlayerEnvironment = BattleEnvironment.NoteFor(playerStats.Data, environmentState); + playerStats.ApplyEnvironment(PlayerEnvironment.Multiplier); + } + + // AutoWire가 안 됐으면(부트스트랩 순서·테스트) 씬에서 찾는다. 전투 시작 때만 불려 비용은 무시할 만하다. + private WorldStateProvider WorldStateSource + { + get + { + if (worldStateProvider == null) worldStateProvider = FindFirstObjectByType(); + return worldStateProvider; + } + } + + private RegionManager Regions + { + get + { + if (regionManager == null) regionManager = FindFirstObjectByType(); + return regionManager; + } + } + /// /// 이 전투에 「장부」를 건다 — 이 /// StartDuel 직후에 부른다. StartDuel의 인자로 받지 않는 이유는 @@ -215,6 +698,15 @@ public void ArmLedger(int threshold) ledgerReadCount = 0; } + /// + /// 이 대결의 상대를 알린다 — 와 같은 이유로 StartDuel 직후에 부른다 + /// (아이 대결이 같은 시작 함수를 쓴다). 야생 전투에는 걸리지 않는다. + /// + public void SetDuelOpponent(string duelId) + { + DuelOpponentId = duelMode && !string.IsNullOrEmpty(duelId) ? duelId : string.Empty; + } + /// 현재 장부 값 — UI 게이지가 읽는다. public int LedgerTally => ledgerTally; @@ -253,6 +745,10 @@ public void UseSkill(int skillIndex) return; } + BeginResolvedRound(); + // 쿨다운 거절(위 return)을 지난 입력만 센다. 기절로 건너뛰는 차례와 마무리 일격(적 차례 없음)도 한 번이다. + playerActionCount++; + // 기절 상태면 이번 행동 스킵(스킬 소모 없음) — 적은 그대로 반격. if (playerStunTurns > 0) { @@ -261,8 +757,12 @@ public void UseSkill(int skillIndex) } else { + PlayerActedThisRound = true; + if (sandbox) sandboxPlayerActions++; InsectSkill[] skills = GetPlayerSkills(); InsectSkill skill = skills != null && skillIndex < skills.Length ? skills[skillIndex] : GetSkill(playerStats.Data, skillIndex); + LastPlayerSkill = skill; + LastPlayerSkillIsSignature = SignatureSkills.IsSignature(playerStats.Data, skill); ApplySkill(playerStats, enemyStats, skill, true); if (skill != null) @@ -276,11 +776,13 @@ public void UseSkill(int skillIndex) NoteLedgerAction(skillIndex); } + SnapshotPlayerAction(); if (enemyStats.CurrentHp > 0) { UseEnemyTurn(); } + SnapshotEnemyAction(); TickEffects(); TickCooldowns(); BattleUpdated?.Invoke(playerStats, enemyStats); @@ -294,6 +796,8 @@ public void UseBasicAttack() return; } if (battleEnded) return; // 종료 후 액션 차단 + BeginResolvedRound(); + playerActionCount++; // UseSkill과 같은 이유 — 기절한 차례도 한 번 if (playerStunTurns > 0) { @@ -302,8 +806,11 @@ public void UseBasicAttack() } else { + PlayerActedThisRound = true; + if (sandbox) sandboxPlayerActions++; int damage = Mathf.Max(1, Mathf.RoundToInt(playerStats.Attack * 0.7f)); - enemyStats.ApplyDamage(damage, playerStats.Attack, enemyStats.Defense); + damage = RollCriticalHit(damage, true); + ApplyDirectDamage(enemyStats, playerStats, damage); TryPlayHitFlash(false); // 쿨다운 없는 기본공격 연타가 이 압박이 겨냥하는 바로 그 패턴이다. @@ -311,11 +818,13 @@ public void UseBasicAttack() NoteLedgerAction(LedgerPressure.BasicAttackKey); } + SnapshotPlayerAction(); if (enemyStats.CurrentHp > 0) { UseEnemyTurn(); } + SnapshotEnemyAction(); TickEffects(); TickCooldowns(); BattleUpdated?.Invoke(playerStats, enemyStats); @@ -329,10 +838,11 @@ public bool TryEscape() return false; } if (battleEnded) return false; // 종료 후 액션 차단 + if (sandbox) return false; // 챔피언전에서는 도망치지 않는다(화면도 도망 버튼을 그리지 않는다) - int levelDiff = playerStats.Level - enemyStats.Level; - float escapeChance = Mathf.Clamp(0.5f + levelDiff * 0.05f, 0.1f, 0.9f); - bool escaped = UnityEngine.Random.value < escapeChance; + BeginResolvedRound(); + float escapeChance = BattleEscapeRules.Chance(playerStats.Level, enemyStats.Level); + bool escaped = randomSource.Next01() < escapeChance; if (escaped) { @@ -341,6 +851,7 @@ public bool TryEscape() ReleaseEnemyAfterBattle(); PersistActivePlayer(); // 도주 시에도 남은 HP·감염 저장 battleEnded = true; + DidEscape = true; BattleEnded?.Invoke(false); // 도주도 대결의 한 결과다 — 여기서 안 알리면 NpcDuelController가 // MarkDuelFinished를 못 걸어 90초 쿨다운이 통째로 우회된다("결과와 무관하게"가 @@ -349,8 +860,10 @@ public bool TryEscape() return true; } + playerActionCount++; // 도주 성공은 위에서 끝났다 — 실패해 적 차례가 오는 경우만 한 번 NoteLedgerAction(LedgerPressure.EscapeKey); UseEnemyTurn(); + SnapshotEnemyAction(); TickEffects(); TickCooldowns(); BattleUpdated?.Invoke(playerStats, enemyStats); @@ -442,29 +955,27 @@ private void ApplySkill(InsectBattleStats attacker, InsectBattleStats defender, if (skill == null) { - int damage = GetDamage(attacker, 10); - defender.ApplyDamage(damage, attacker.Attack, defender.Defense); + int damage = RollCriticalHit(GetDamage(attacker, 10), isPlayer); + ApplyDirectDamage(defender, attacker, damage); TryPlayHitFlash(defenderIsPlayer); return; } - // 스킬 이름 효과 텍스트 (속성 색상) - if (!string.IsNullOrEmpty(skill.displayName)) - { - TryPlayEffectText($"{skill.displayName}!", BattleArenaController.GetUIElementColor(skill.element)); - } + // 스킬 이름은 여기서 띄우지 않는다 — 시전자가 준비 동작과 함께 머리 위 말풍선으로 외친다 + // (BattleArenaController.BeginSkillPresentation). 예전 "{기술}!" 효과 문구는 타격 순간 화면 + // 가운데에 떠서 피해 숫자·의성어·비명과 한 자리에 겹쳤다. switch (skill.effectType) { case SkillEffectType.BuffAttack: if (AddEffect(isPlayer, skill.effectValue, skill.effectDurationTurns, EffectKind.AtkBuff)) - TryPlayEffectText("공격력 상승!", new Color(1f, 0.8f, 0.3f)); + TryPlayEffectText(SidedText(isPlayer, "공격력 상승!"), new Color(1f, 0.8f, 0.3f)); else TryPlayEffectText("이미 최대치!", new Color(0.7f, 0.7f, 0.75f)); break; case SkillEffectType.DebuffAttack: if (AddEffect(!isPlayer, -skill.effectValue, skill.effectDurationTurns, EffectKind.AtkBuff)) - TryPlayEffectText("공격력 하락!", new Color(0.6f, 0.4f, 0.9f)); + TryPlayEffectText(SidedText(!isPlayer, "공격력 하락!"), new Color(0.6f, 0.4f, 0.9f)); else TryPlayEffectText("이미 최대치!", new Color(0.7f, 0.7f, 0.75f)); break; @@ -472,7 +983,7 @@ private void ApplySkill(InsectBattleStats attacker, InsectBattleStats defender, { int healAmt = Mathf.Max(1, Mathf.RoundToInt(attacker.MaxHp * Mathf.Clamp01(skill.effectValue))); attacker.Heal(healAmt); - TryPlayEffectText($"HP +{healAmt}!", new Color(0.4f, 1f, 0.5f)); + TryPlayEffectText(SidedText(isPlayer, $"HP +{healAmt}!"), new Color(0.4f, 1f, 0.5f)); break; } case SkillEffectType.PoisonDot: @@ -481,18 +992,18 @@ private void ApplySkill(InsectBattleStats attacker, InsectBattleStats defender, AddEffect(!isPlayer, skill.power, skill.effectDurationTurns, EffectKind.Dot); if (defenderIsPlayer) playerPoisoned = true; TryPlayHitFlash(defenderIsPlayer); - TryPlayEffectText("중독!", new Color(0.6f, 0.9f, 0.3f)); + TryPlayEffectText(SidedText(defenderIsPlayer, "중독!"), new Color(0.6f, 0.9f, 0.3f)); break; case SkillEffectType.Stun: if (!LandsHit(skill)) { TryPlayEffectText("빗나갔다!", new Color(0.7f, 0.7f, 0.75f)); break; } // 대상 다음 행동 1회 스킵(별도 카운터). 플레이어 피격 시 지속 마비(전투 후 유지). if (defenderIsPlayer) { playerStunTurns = 1; playerParalyzed = true; } else enemyStunTurns = 1; TryPlayHitFlash(defenderIsPlayer); - TryPlayEffectText("기절!", new Color(1f, 0.9f, 0.3f)); + TryPlayEffectText(SidedText(defenderIsPlayer, "기절!"), new Color(1f, 0.9f, 0.3f)); break; case SkillEffectType.DefenseBuff: if (AddEffect(isPlayer, skill.effectValue, skill.effectDurationTurns, EffectKind.DefBuff)) - TryPlayEffectText("방어력 상승!", new Color(0.4f, 0.7f, 1f)); + TryPlayEffectText(SidedText(isPlayer, "방어력 상승!"), new Color(0.4f, 0.7f, 1f)); else TryPlayEffectText("이미 최대치!", new Color(0.7f, 0.7f, 0.75f)); break; @@ -507,16 +1018,43 @@ private void ApplySkill(InsectBattleStats attacker, InsectBattleStats defender, ? InsectTypeChart.GetSameTypeBonus(skill.element, attacker.Data.primaryType, attacker.Data.secondaryType) : 1f; int damage = Mathf.Max(1, Mathf.RoundToInt(GetDamage(attacker, baseDamage) * effectiveness * sameTypeBonus)); - defender.ApplyDamage(damage, attacker.Attack, defender.Defense); + // 들어간 피해기만 등급을 남긴다(빗나감은 위에서 이미 돌아갔다) — 화면의 상성 표시·스킬 카드 칩이 이 등급 하나를 읽는다. + if (isPlayer) LastPlayerHitMatchup = ElementMatchup.Describe(effectiveness); + else LastEnemyHitMatchup = ElementMatchup.Describe(effectiveness); + damage = RollCriticalHit(damage, isPlayer); + ApplyDirectDamage(defender, attacker, damage); TryPlayHitFlash(defenderIsPlayer); - if (effectiveness > 1.05f) - TryPlayEffectText("효과가 굉장했다!", new Color(1f, 0.55f, 0.2f)); - else if (effectiveness < 0.95f) - TryPlayEffectText("효과가 별로인 듯하다...", new Color(0.55f, 0.65f, 0.8f)); + // 치명타도 상성도 여기서 문구로 띄우지 않는다 — 피해 숫자 위에 「치명타!」와 화살표 + 「아주 잘 통했다!」가 뜬다 + // (BattleScreenUI.Feel이 위의 LastPlayerHitMatchup/LastEnemyHitMatchup을 읽는다). 위쪽 효과 문구에도 띄우면 + // 한 타격에 같은 말이 두 번 보인다(예전 「효과가 굉장했다!」·「효과가 별로인 듯하다...」가 그 자리였다). break; } } + // Ordinary wild encounters only. Stored stats, duel, guardian and raid balance stay intact. + private void ApplyDirectDamage(InsectBattleStats defender, InsectBattleStats attacker, int amount) + { + if (sandbox) + { + ApplySandboxDamage(defender, attacker, amount); + return; + } + float pacing = !duelMode && string.IsNullOrEmpty(EnemyGuardianRegionId) + ? GameConstants.Battle.WildDamageMultiplier : 1f; + defender.ApplyDamage(Mathf.Max(1, Mathf.RoundToInt(amount * pacing)), attacker.Attack, defender.Defense); + } + + // 샌드박스 피해 — 공식이 낸 값(방어 비율 반영)에 SandboxBattleRules의 배율·상하한을 건다. + private void ApplySandboxDamage(InsectBattleStats defender, InsectBattleStats attacker, int amount) + { + int resolved = defender.ResolveDamage(amount, attacker.Attack, defender.Defense); + bool attackerIsPlayer = ReferenceEquals(attacker, playerStats); + int final = attackerIsPlayer + ? SandboxBattleRules.PlayerDamage(resolved, defender.CurrentHp, sandboxPlayerActions) + : SandboxBattleRules.EnemyDamage(resolved, defender.CurrentHp, defender.MaxHp); + if (final > 0) defender.ApplyDamage(final); + } + private int GetDamage(InsectBattleStats attacker, int baseDamage) { float multiplier = Mathf.Clamp(1f + attacker.AttackBonus, 0.3f, 3f); @@ -549,7 +1087,44 @@ private bool LandsHit(InsectSkill skill) { float acc = skill != null ? skill.accuracy : 1f; if (acc >= 0.999f) return true; // 완전명중 스킬은 롤 없이 통과 - return RollHit(acc, 0f, UnityEngine.Random.value); + return RollHit(acc, 0f, randomSource.Next01()); + } + + /// 치명타 판정(순수, 주입 롤). 이 CritChance(1/16) 미만이면 치명타. + public static bool RollCritical(float roll) + { + return roll < GameConstants.Battle.CritChance; + } + + /// + /// 치명타 한 번 — 소스가 있으면 한 번만 굴려 맞으면 CritMultiplier를 곱한다(최소 1). + /// 소스가 null이면 굴리지 않고 그대로 돌려준다(결정적). 1v1과 레이드()가 함께 쓴다 — + /// 을 함께 쓰는 것과 같은 이유다(두 모드의 판정이 갈리지 않게). + /// + public static int ApplyCritical(int damage, IRaidRandomSource critRandom, out bool critical) + { + critical = critRandom != null && RollCritical(critRandom.Next01()); + return critical + ? Mathf.Max(1, Mathf.RoundToInt(damage * GameConstants.Battle.CritMultiplier)) + : damage; + } + + // 피해기 한 번의 치명타 — 샌드박스(꿈 챔피언전)는 굴리지 않는다: SandboxBattleRules가 3~4행동 길이를 규칙으로 지키고, + // 그 위에 무작위 1.5배가 얹히면 "마지막 일격" 전 행동의 하한 자르기가 매번 다른 숫자를 낸다. + private int RollCriticalHit(int damage, bool attackerIsPlayer) + { + int result = ApplyCritical(damage, sandbox ? null : critSource, out bool critical); + if (critical) + { + if (attackerIsPlayer) LastPlayerHitCritical = true; + else LastEnemyHitCritical = true; + } + return result; + } + + private bool HitCritical(bool attackerIsPlayer) + { + return attackerIsPlayer ? LastPlayerHitCritical : LastEnemyHitCritical; } private void UseEnemyTurn() @@ -563,6 +1138,8 @@ private void UseEnemyTurn() return; } + resolvingEnemyAction = true; + EnemyActedThisRound = true; InsectSkill enemySkill = GetPrimarySkill(enemyStats.Data); if (enemyCooldown > 0) { @@ -586,6 +1163,7 @@ private void UseEnemyTurn() ledgerSpentThisTurn = false; LastEnemySkill = enemySkill; // UI가 EnemyAttack 연출(속성·근접여부)에 사용 + LastEnemySkillIsSignature = SignatureSkills.IsSignature(enemyStats.Data, enemySkill); ApplySkill(enemyStats, playerStats, enemySkill, false); if (ledgerSpentThisTurn) @@ -633,6 +1211,7 @@ private void SeedPersistentState(Core.PlayerInsectData pid) // 현재 활성 플레이어 곤충의 남은 HP·감염을 영구 저장(전투 종료/교체 시). private void PersistActivePlayer() { + if (sandbox) return; // 꿈속의 HP는 저장하지 않는다 — 메모리 개체이기도 하다 if (playerCollection == null || playerStats == null || playerStats.PlayerData == null) return; playerCollection.SetAfterBattle(playerStats.PlayerData, playerStats.CurrentHp, playerPoisoned, playerParalyzed); } @@ -692,7 +1271,11 @@ private void TickEffects() InsectBattleStats target = effect.targetIsPlayer ? playerStats : enemyStats; if (target != null && target.CurrentHp > 0) { + int hpBefore = target.CurrentHp; target.ApplyDamage(dot); + // 실제로 깎인 양(0에서 잘린 몫 제외) — UI가 독 숫자로 띄운다. + if (effect.targetIsPlayer) PlayerPoisonDamageThisRound += hpBefore - target.CurrentHp; + else EnemyPoisonDamageThisRound += hpBefore - target.CurrentHp; TryPlayHitFlash(effect.targetIsPlayer); } } @@ -754,21 +1337,24 @@ private void CheckEnd() } if (battleEnded) return; // 이미 종료 — 보상/이벤트 중복 차단 + // 독 같은 지속 피해는 ApplyDirectDamage를 거치지 않는다 — 샌드박스는 여기서 하한으로 되돌린다. + if (sandbox) playerStats.RaiseHpTo(SandboxBattleRules.HpFloor(playerStats.MaxHp)); + + // 팀 대결 — 지금 상대가 쓰러졌어도 뒤에 남은 곤충이 있으면 끝나지 않고 다음이 나온다. + // 같은 라운드에 내 곤충도 쓰러졌을 수 있다(독 틱 등) — 그쪽은 한 마리 전투와 같은 길(교체 또는 패배)로 간다. + if (enemyStats.CurrentHp <= 0 && HasNextEnemy()) + { + SendOutNextEnemy(); + if (playerStats.CurrentHp <= 0) HandlePlayerFainted(); + return; + } + bool playerWon = enemyStats.CurrentHp <= 0 && playerStats.CurrentHp > 0; if (playerWon && duelMode) { // NPC 대결 승리 — 상대는 NPC 소유라 포획도, 야생 드랍도 없다. - // 성장 재화(캔디·EXP)와 코인만 야생과 같은 계산으로 지급한다. - InsectData duelData = enemyStats.Data; - float duelCandyMul = (itemEffects != null ? itemEffects.GetCandyMultiplier() : 1f) - * (outfitBonus != null ? outfitBonus.GetCandyMultiplier() : 1f); - float duelExpMul = (itemEffects != null ? itemEffects.GetExpMultiplier() : 1f) - * (outfitBonus != null ? outfitBonus.GetExpMultiplier() : 1f); - lastCandyReward = Mathf.RoundToInt(InsectRewardCalculator.GetCandyReward(duelData) * duelCandyMul); - lastExpReward = Mathf.RoundToInt(InsectRewardCalculator.GetExpReward(duelData) * duelExpMul); - candyInventory?.AddCandy(lastCandyReward); - playerProgress?.GainXp(lastExpReward); - wallet?.AddCoins(BattleVictoryCoins); + // 성장 재화(캔디·EXP)와 코인만 야생과 같은 계산으로 지급한다 — 팀 대결이면 상대마다 합산. + GrantDuelVictoryRewards(); lastItemId = string.Empty; lastItemCount = 0; @@ -786,6 +1372,7 @@ private void CheckEnd() int itemCount = InsectRewardCalculator.GetItemRewardCount(enemyData); string itemId = enemyData.itemRewardId; // 같은 승리에서 지급하는 XP가 이번 포획 확률을 바꾸지 않도록 보상 지급 전에 고정한다. + // EXP의 레벨 차도 이 값으로 잰다(지급 뒤 레벨로 재면 레벨업 직후 EXP가 줄어든다). int capturePlayerLevel = playerProgress != null ? playerProgress.Level : playerStats.Level; @@ -796,12 +1383,14 @@ private void CheckEnd() float expMultiplier = (itemEffects != null ? itemEffects.GetExpMultiplier() : 1f) * (outfitBonus != null ? outfitBonus.GetExpMultiplier() : 1f); int candy = Mathf.RoundToInt(InsectRewardCalculator.GetCandyReward(enemyData) * candyMultiplier); - int exp = Mathf.RoundToInt(InsectRewardCalculator.GetExpReward(enemyData) * expMultiplier); + int exp = Mathf.RoundToInt( + InsectRewardCalculator.GetExpReward(enemyData, enemyLevel, capturePlayerLevel) * expMultiplier); candyInventory?.AddCandy(candy); playerProgress?.GainXp(exp); // 승리 소량 코인 — 상점 코인결제/베이직 의상 지속 수급(반복 faucet). AddCoins가 세이브 트리거. - wallet?.AddCoins(BattleVictoryCoins); + lastCoinReward = BattleVictoryCoins; + wallet?.AddCoins(lastCoinReward); if (!string.IsNullOrEmpty(itemId) && itemCount > 0) { itemInventory?.AddItem(itemId, itemCount); @@ -823,7 +1412,7 @@ private void CheckEnd() equippedOutfitBonus); bool captureRollSucceeded = BattleCaptureChanceCalculator.IsSuccessful( lastCaptureChance, - UnityEngine.Random.value); + randomSource.Next01()); if (dexController != null) { @@ -852,7 +1441,9 @@ private void CheckEnd() lastCaptureSucceeded = true; dexController?.RegisterCapture(enemyData.insectId); // 전투는 CaptureController/CaptureResolved를 우회하므로 성공한 실제 포획만 직접 알린다. - TutorialQuestManager.Instance?.NotifyCapture(enemyData.rarity); + // 조건부 퀘스트(크기·속성·이로치)는 저장된 개체(captured)의 값을 읽는다 — 지어낸 값이 아니다. + TutorialQuestManager.Instance?.NotifyCapture( + CaptureFacts.From(enemyData, captured, enemyShinyAtStart)); } } } @@ -865,49 +1456,81 @@ private void CheckEnd() TryPlayFaint(false); enemyEntity.Despawn(); } + else if (playerWon && sandbox) + { + // 보상도 정리할 월드 개체도 없다 — 쓰러지는 연출만. + TryPlayFaint(false); + } if (enemyStats.CurrentHp <= 0) { PersistActivePlayer(); // 승리 — 활성 곤충의 남은 HP·감염 영구 저장(전체치료 없음) battleEnded = true; lastPlayerWon = playerWon; + if (enemyTeamFainted != null) enemyTeamFainted[enemyTeamIndex] = true; + // 조건부 퀘스트(BattleFeat) — 야생·수문장·NPC 대결 승리가 모두 지나는 이 한 지점에서, 보상이 끝난 뒤 + // BattleEnded 전에 직접 알린다(NotifyCapture와 같은 이유: 구독자 예외에 진행이 삼켜지면 안 된다). + // 내 곤충은 교체됐을 수 있어 **끝났을 때 싸우던** playerStats의 레벨·HP를 읽는다. 꿈속(샌드박스) 전투는 알리지 않는다. + // 팀 대결이면 enemyStats는 **마지막(에이스) 상대**이고, 내 행동 수(playerActionCount)는 전투 전체다. + // 통지가 던져도 전투 종료는 계속돼야 한다 — battleEnded는 이미 켜졌는데 BattleEnded가 안 울리면 결과 화면이 + // 멈춘다. 진행은 매니저가 이벤트보다 먼저 저장하므로 예외를 삼켜도 잃는 것이 없다. + if (playerWon && !sandbox) + { + try + { + TutorialQuestManager.Instance?.NotifyBattleFeat(BattleFacts.From( + enemyStats.Data, enemyStats.Level, playerStats.Level, + playerActionCount, playerStats.CurrentHp, playerStats.MaxHp)); + } + catch (System.Exception e) + { + Debug.LogWarning($"[Battle] 조건부 퀘스트 통지 예외 — 전투 종료는 계속한다: {e.Message}"); + } + } BattleEnded?.Invoke(playerWon); if (duelMode) DuelEnded?.Invoke(playerWon); } else if (playerStats.CurrentHp <= 0) { - TryPlayFaint(true); - TryPlayEffectText("쓰러졌다!", new Color(0.9f, 0.2f, 0.2f)); - // 죽은 곤충의 0 HP를 즉시 영구 저장(멱등) — 전멸 패배(교체 팀원 없음)에서 스왑·!fainted 경로가 모두 - // 스킵돼 마지막 곤충이 무료 부활하던 누락을 차단. 교체 시 SwapPlayerInsect가 다시 0으로 persist(무해). - PersistActivePlayer(); - // 핸들러 예외 격리 — 한 구독자 예외가 BattleEnded fallback을 차단하지 않게 - bool fainted = false; - try - { - if (PlayerFainted != null) - { - PlayerFainted.Invoke(); - fainted = true; - } - } - catch (System.Exception e) - { - Debug.LogWarning($"[InsectBattle] PlayerFainted 핸들러 예외: {e.Message}"); - } - // fainted=true: UI(SwapSelect)가 처리 — 팀원 교체로 배틀을 이어갈 수 있으므로 - // battleEnded/Despawn/BattleEnded를 보류해야 한다. (옛: battleEnded를 무조건 set해 - // 교체 후 새 곤충이 액션 가드에 막혀 배틀이 멈추는 무한정지 버그.) - // !fainted: 교체 핸들러 없음 → 컨트롤러가 패배로 종료 + 적 Despawn(필드 잔존 방지). - if (!fainted) + HandlePlayerFainted(); + } + } + + // 내 곤충이 쓰러졌다 — 교체를 맡을 화면이 있으면 넘기고, 없으면 패배로 끝낸다. + // CheckEnd의 한 마리 경로와 팀 대결의 "상대 교체와 같은 라운드에 나도 쓰러짐" 경로가 함께 쓴다. + private void HandlePlayerFainted() + { + TryPlayFaint(true); + TryPlayEffectText("쓰러졌다!", new Color(0.9f, 0.2f, 0.2f)); + // 죽은 곤충의 0 HP를 즉시 영구 저장(멱등) — 전멸 패배(교체 팀원 없음)에서 스왑·!fainted 경로가 모두 + // 스킵돼 마지막 곤충이 무료 부활하던 누락을 차단. 교체 시 SwapPlayerInsect가 다시 0으로 persist(무해). + PersistActivePlayer(); + // 핸들러 예외 격리 — 한 구독자 예외가 BattleEnded fallback을 차단하지 않게 + bool fainted = false; + try + { + if (PlayerFainted != null) { - // (기절 0 HP는 위에서 이미 persist — 여기선 종료 처리만) - battleEnded = true; - ReleaseEnemyAfterBattle(); - BattleEnded?.Invoke(false); - if (duelMode) DuelEnded?.Invoke(false); + PlayerFainted.Invoke(); + fainted = true; } } + catch (System.Exception e) + { + Debug.LogWarning($"[InsectBattle] PlayerFainted 핸들러 예외: {e.Message}"); + } + // fainted=true: UI(SwapSelect)가 처리 — 팀원 교체로 배틀을 이어갈 수 있으므로 + // battleEnded/Despawn/BattleEnded를 보류해야 한다. (옛: battleEnded를 무조건 set해 + // 교체 후 새 곤충이 액션 가드에 막혀 배틀이 멈추는 무한정지 버그.) + // !fainted: 교체 핸들러 없음 → 컨트롤러가 패배로 종료 + 적 Despawn(필드 잔존 방지). + if (!fainted) + { + // (기절 0 HP는 위에서 이미 persist — 여기선 종료 처리만) + battleEnded = true; + ReleaseEnemyAfterBattle(); + BattleEnded?.Invoke(false); + if (duelMode) DuelEnded?.Invoke(false); + } } /// @@ -944,7 +1567,7 @@ public void ConcludeDefeatWithoutSwap() private void TryPlayHitFlash(bool isPlayerSide) { - if (Arena == null) return; + if (DeferPresentation || Arena == null) return; GameObject model = isPlayerSide ? Arena.PlayerModel : Arena.EnemyModel; if (model != null && model.activeInHierarchy) { @@ -954,7 +1577,7 @@ private void TryPlayHitFlash(bool isPlayerSide) private void TryPlayFaint(bool isPlayerSide) { - if (Arena == null) return; + if (DeferPresentation || Arena == null) return; GameObject model = isPlayerSide ? Arena.PlayerModel : Arena.EnemyModel; if (model != null && model.activeInHierarchy) { @@ -962,9 +1585,23 @@ private void TryPlayFaint(bool isPlayerSide) } } + /// + /// 상태 변화 문구에 누구에게 걸렸는지를 붙인다 — 내 곤충이면 그대로, 상대 곤충이면 앞에 "상대". + /// 문구는 화면 가운데 위쪽에 한 줄로 떠서(BattleEffectTextOverlay) 자리로는 편을 알 수 없다. + /// "공격력 상승!"만 뜨면 상대가 자기에게 건 버프도 내 것으로 읽힌다. + /// + internal static string SidedText(bool targetIsPlayer, string text) + => targetIsPlayer ? text : "상대 " + text; + private void TryPlayEffectText(string text, Color color) { - if (Arena == null || string.IsNullOrEmpty(text)) return; + if (string.IsNullOrEmpty(text)) return; + if (DeferPresentation) + { + presentationTexts.Add((resolvingEnemyAction, text, color)); + return; + } + if (Arena == null) return; Arena.PlayEffectText(text, color); } @@ -1006,6 +1643,18 @@ public void AutoWire(BattleArenaController a) if (arena == null) arena = a; } + /// + /// 낮·밤·날씨 보정이 읽을 시계·날씨()와 지금 리전·서브에리어(). + /// null로 넘기면 씬에서 찾는다(FindFirstObjectByType) — 그때도 없으면 전투 시작 때 다시 찾고, 끝내 없으면 보정을 걸지 않는다. + /// + public void AutoWire(WorldStateProvider provider, RegionManager regions) + { + if (worldStateProvider == null) + worldStateProvider = provider != null ? provider : FindFirstObjectByType(); + if (regionManager == null) + regionManager = regions != null ? regions : FindFirstObjectByType(); + } + private OutfitBonusProvider outfitBonus; public void AutoWire(OutfitBonusProvider bonus) @@ -1027,6 +1676,10 @@ public void SwapPlayerInsect(InsectData newInsect, int newLevel, InsectSkill[] e PersistActivePlayer(); // 교체 전 이전 곤충의 남은 HP·감염 저장(기절이면 0 그대로) playerStats = new InsectBattleStats(newInsect, newLevel, playerPid); + // 들어온 곤충도 이번 전투의 하늘로 잰다(야생에서만 — 대결·샌드박스는 environmentActive가 꺼져 있다). + // 아래 BattleUpdated보다 먼저라 화면이 새 곤충의 칩을 곧바로 본다. + ApplyPlayerEnvironment(); + BeginResolvedRound(); playerOverrideSkills = ResolvePlayerSkills(newInsect, equippedSkills, playerPid); int skillCount = playerOverrideSkills != null ? playerOverrideSkills.Length : (newInsect.skills != null ? newInsect.skills.Length : 0); playerCooldowns = new int[skillCount]; @@ -1102,6 +1755,12 @@ public int GetLastCandyReward() return lastCandyReward; } + /// 이번 승리에 준 코인(팀 대결은 상대 수만큼). 지면 0. + public int GetLastCoinReward() + { + return lastCoinReward; + } + public bool GetLastPlayerWon() { return lastPlayerWon; diff --git a/Assets/Scripts/Battle/InsectBattleStats.cs b/Assets/Scripts/Battle/InsectBattleStats.cs index 2aaad77..c276a4c 100644 --- a/Assets/Scripts/Battle/InsectBattleStats.cs +++ b/Assets/Scripts/Battle/InsectBattleStats.cs @@ -6,7 +6,19 @@ namespace InsectGame.Battle { public class InsectBattleStats { + /// + /// 이 곤충의 정체 — 도감·포획·보상·스토리 판정(BattleWin 종 지정)이 읽는다. 전투 중 바뀌지 않는다. + /// 상성·자속·기술은 로 잰다. + /// public InsectData Data { get; } + + /// + /// 상성·자속·기술을 잴 때 쓰는 곤충 — 보통 와 같다. 모습을 바꾸는 레이드 보스만 다르다 + /// (, 표는 ). 이름 없는 사마귀가 나비의 모습을 빌려도 + /// 이긴 상대는 사마귀다 — 그래서 를 갈아끼우지 않고 따로 둔다(갈아끼우면 레이드 승리가 + /// 나비를 도감·컬렉션에 올리고, 최종장 BattleWin 비트가 사마귀를 못 알아본다). + /// + public InsectData CombatData { get; protected set; } public PlayerInsectData PlayerData { get; } public int Level { get; } public int MaxHp { get; protected set; } @@ -53,6 +65,7 @@ private static bool CanStack(int current, float delta) public InsectBattleStats(InsectData data, int level, PlayerInsectData pid = null) { Data = data; + CombatData = data; PlayerData = pid; Level = Mathf.Max(1, level); @@ -94,6 +107,15 @@ public void Heal(int amount) } public void ApplyDamage(int amount, int attackerAtk = 0, int defenderDef = 0) + { + CurrentHp = Mathf.Max(0, CurrentHp - Mathf.Max(1, ResolveDamage(amount, attackerAtk, defenderDef))); + } + + /// + /// 공격력 대 방어력 비율까지 반영한 실제로 깎일 피해 — 가 쓰는 계산 그대로다. + /// 샌드박스 전투가 이 값에 배율·상한을 걸어야 해서 떼어 냈다(두 곳이 따로 계산하면 어긋난다). + /// + public int ResolveDamage(int amount, int attackerAtk = 0, int defenderDef = 0) { int finalDamage = amount; if (attackerAtk > 0 && defenderDef > 0) @@ -104,7 +126,44 @@ public void ApplyDamage(int amount, int attackerAtk = 0, int defenderDef = 0) finalDamage = Mathf.RoundToInt(amount * Mathf.Clamp(ratio, GameConstants.Battle.MinAtkDefRatio, GameConstants.Battle.MaxAtkDefRatio)); } - CurrentHp = Mathf.Max(0, CurrentHp - Mathf.Max(1, finalDamage)); + return finalDamage; + } + + // ── 낮·밤·날씨 보정(BattleEnvironment) ── + // 처음 걸 때의 공격·방어를 붙잡아 두고 언제나 그 값에 곱한다 — 두 번 불려도 누적되지 않고, 1을 걸면 원래 값으로 돌아간다. + // 생성자가 아니라 처음 걸 때 붙잡는 이유: 파생 스탯(레이드 보스)은 base 생성자 뒤에 공격·방어를 다시 정한다. + private bool environmentBaseCaptured; + private int unscaledAttack; + private int unscaledDefense; + + /// 지금 걸려 있는 낮·밤·날씨 배수(1 = 보정 없음). + public float EnvironmentMultiplier { get; private set; } = 1f; + + /// + /// 낮·밤·날씨 보정 — 원래 ·에 를 곱해 반올림한다(최소 1). + /// HP는 건드리지 않는다(HP바가 전투 시작에 튀지 않게). 의상·아이템·버프가 매 턴 다시 계산하는 + /// /와는 따로 논다 — 그쪽에 섞으면 다음 재계산이 지운다. + /// 0·음수·NaN·무한대는 1로 본다. + /// + public void ApplyEnvironment(float multiplier) + { + if (!environmentBaseCaptured) + { + unscaledAttack = Attack; + unscaledDefense = Defense; + environmentBaseCaptured = true; + } + + float m = multiplier > 0f && !float.IsInfinity(multiplier) ? multiplier : 1f; + EnvironmentMultiplier = m; + Attack = Mathf.Max(1, Mathf.RoundToInt(unscaledAttack * m)); + Defense = Mathf.Max(1, Mathf.RoundToInt(unscaledDefense * m)); + } + + /// HP가 보다 낮으면 그 값까지 올린다(샌드박스 전투의 하한 — 독 같은 지속 피해를 막는다). + public void RaiseHpTo(int floor) + { + if (CurrentHp < floor) CurrentHp = Mathf.Min(MaxHp, Mathf.Max(1, floor)); } } } diff --git a/Assets/Scripts/Battle/RaidBattleController.cs b/Assets/Scripts/Battle/RaidBattleController.cs index 77a0e94..bbdcdaf 100644 --- a/Assets/Scripts/Battle/RaidBattleController.cs +++ b/Assets/Scripts/Battle/RaidBattleController.cs @@ -28,6 +28,13 @@ public class RaidBattleController : MonoBehaviour public event Action RaidBossResponseResolved; public event Action RaidRoundCompleted; + /// + /// 쓰러지는 모습을 보여 줄 차례가 왔다 — -1이면 보스, 0 이상이면 팀 슬롯. 한 개체에 한 번만 울린다. + /// 판정(HP 0)은 행동을 해결하는 순간 끝나지만 연출은 그 뒤에 돌기 때문에, 연출 쪽이 공격 연출을 마친 뒤 + /// 로 부른다. 아레나가 없어도 울린다(HP 카드·소리가 들을 수 있게). + /// + public event Action FaintPresented; + // 보스가 직전 턴에 쓴 시그니처 스킬(null=기본/AOE) — UI가 BossAttack 페이즈 연출 속성에 사용. public InsectSkill LastBossSkill { get; private set; } @@ -42,7 +49,9 @@ public class RaidBattleController : MonoBehaviour /// /// 보스가 어느 리전의 수문장이었나(수문장이 아니면 빈 문자열). 1v1과 같은 스냅샷이다 — - /// 근거는 InsectEntity.GuardianRegionId 주석 참조. + /// 근거는 InsectEntity.GuardianRegionId 주석 참조. 첫 (화면이 Intro로 들어가는 신호)보다 + /// 먼저 채워지고 레이드가 끝난 뒤에도 다음 레이드까지 남는다 — 등장 화면(「○○의 수문장」)은 이 값으로 + /// RegionManager.GetRegionById → guardianDisplayName을 찾는다. /// public string BossGuardianRegionId { get; private set; } @@ -136,11 +145,151 @@ private void UpdateBossPhase() if (ratio <= GameConstants.Battle.RaidBossEnrageHpRatio) BossEnraged = true; } + + // ── 모습 바꾸기(RaidBossForms) ────────────────────────────────── + + /// + /// 보스가 모습을 바꿨다 — 팀원 한 마리의 행동(또는 합체공격)이 HP 임계를 넘긴 그 자리에서, 행동 이벤트 + /// (/)보다 먼저 울린다. + /// 임계 하나에 한 번만(래치 — 회복해도 돌아가지 않는다). 한 번의 피해로 임계를 둘 넘으면 마지막 모습으로 + /// 곧장 가고 이벤트도 한 번이다(). 보스가 쓰러지는 피해면 울리지 않는다. + /// + public event Action BossFormChanged; + + /// 지금 보스의 모습 번호 — 0이면 원래 모습. 표에 없는 보스는 늘 0. + public int BossFormIndex { get; private set; } + + /// 보스가 거칠 모습의 수(원래 모습 포함). 표에 없는 보스는 1. + public int BossFormCount { get; private set; } = 1; + + /// + /// 지금 모습의 곤충 — 상성·자속·기술을 이 곤충으로 잰다(). 아레나 모델도 이걸로 세운다. + /// 원래 모습이면 BossStats.Data와 같다. 정체(도감·포획·스토리)는 언제나 BossStats.Data다. + /// + public InsectData BossFormData => BossStats != null ? BossStats.CombatData : null; + + /// 직전 변신의 표시 한 줄("그림자가 호랑나비의 모습을 빌렸다!"). 아직 안 바뀌었으면 빈 문자열. + public string BossFormLine { get; private set; } = string.Empty; + + // 모습 곤충을 찾지 못했다는 경고를 한 번만 — 매 행동마다 찍으면 로그가 도배된다. + private bool bossFormLookupWarned; + private Func insectLookup; + + /// + /// 모습 곤충 ID → 데이터 조회를 바꾼다(테스트·검수 도구). null이면 보유 곤충 컬렉션의 DB를 쓴다 + /// ( — 부트스트랩이 이미 배선한다). + /// + public void SetInsectLookup(Func lookup) + { + insectLookup = lookup; + } + + private InsectData LookupInsect(string insectId) + { + if (string.IsNullOrEmpty(insectId)) return null; + if (insectLookup != null) return insectLookup(insectId); + return playerCollection != null ? playerCollection.GetInsectData(insectId) : null; + } + + /// + /// HP가 임계를 지났으면 다음 모습으로 바꾼다 — 팀의 피해가 들어간 직후 부른다(보스는 팀 턴에만 HP를 잃는다). + /// 바뀌면 이번 라운드에 이미 띄운 예고도 새 모습의 기술로 고쳐 쓴다 — 나비가 된 보스가 사마귀 기술을 쓰면 + /// "상성이 바뀌었다"가 보스 쪽에서는 거짓이 된다. 대상 슬롯은 그대로다(난수를 다시 굴리지 않는다). + /// + private void UpdateBossForm() + { + if (BossFormCount <= 1 || BossStats == null || BossStats.Data == null || BossStats.CurrentHp <= 0) return; + string bossId = BossStats.Data.insectId; + int target = RaidBossForms.NextFormIndex(bossId, BossFormIndex, BossStats.CurrentHp, BossStats.MaxHp); + if (target <= BossFormIndex) return; + + InsectData form = LookupInsect(RaidBossForms.FormInsectId(bossId, target)); + if (form == null) + { + if (!bossFormLookupWarned) + { + bossFormLookupWarned = true; + Debug.LogWarning($"[Raid] {bossId}의 {target}번째 모습({RaidBossForms.FormInsectId(bossId, target)})을 곤충 DB에서 못 찾았다 — 모습을 바꾸지 않는다"); + } + return; + } + + RaidBossStats formStats = BossStats as RaidBossStats; + if (formStats == null) return; + + RaidBossFormChange change = new RaidBossFormChange + { + FromIndex = BossFormIndex, + ToIndex = target, + FromData = BossStats.CombatData, + ToData = form, + Line = RaidBossForms.BuildLine(bossId, target, form.displayName) + }; + BossFormIndex = target; + formStats.ChangeForm(form); + BossFormLine = change.Line; + + RaidBossIntent pending = roundInProgress != null ? roundInProgress.BossIntent : NextBossIntent; + ReskinBossIntent(pending); + if (NextBossIntent != null && !ReferenceEquals(NextBossIntent, pending)) ReskinBossIntent(NextBossIntent); + + BossFormChanged?.Invoke(change); + } + + // 띄워 둔 예고를 지금 모습의 기술로 고쳐 쓴다. 전체공격 예고는 속성만 따라간다. + private void ReskinBossIntent(RaidBossIntent intent) + { + if (intent == null || BossStats == null) return; + if (intent.IsArea) + { + InsectData form = BossStats.CombatData; + intent.Element = form != null ? form.primaryType : InsectElement.Bug; + return; + } + RaidRoundResolver.ApplySingleTargetSkill(intent, PickBossSkill(TurnNumber), BossStats.CombatData); + } + + /// + /// 이번 라운드 보스가 쓸 기술 — 지금 모습의 해금된 전용기를 라운드 번호로 돌린다(). + /// 빌린 모습에 전용기가 없으면(호랑나비는 희귀라 전용기가 없다) 그 모습이 아는 가장 센 피해기를 쓴다 — + /// 없으면 무속성 「공격」이 되어 "기술이 그 모습의 것으로 바뀐다"가 화면에서 사라지고 피해도 크게 줄어든다. + /// 원래 모습은 옛 동작 그대로(전용기만)다 — 변신하지 않는 보스의 레이드는 한 글자도 안 바뀐다. + /// + private InsectSkill PickBossSkill(int rotation) + { + if (BossStats == null) return null; + InsectData form = BossStats.CombatData; + InsectSkill signature = GetUnlockedBossSignature(form, BossStats.Level, rotation); + if (signature != null || BossFormIndex == 0) return signature; + return GetStrongestUnlockedDamageSkill(form, BossStats.Level); + } + + private static InsectSkill GetStrongestUnlockedDamageSkill(InsectData data, int level) + { + if (data == null || data.learnset == null) return null; + InsectSkill best = null; + foreach (InsectLearnableSkill learnable in data.learnset) + { + if (learnable == null || learnable.skill == null || learnable.learnLevel > level) continue; + if (learnable.skill.effectType != SkillEffectType.Damage) continue; + // 동점이면 먼저 나온 것 — 결정적이다. + if (best == null || learnable.skill.power > best.power) best = learnable.skill; + } + return best; + } private bool bossShinyAtStart; // 시작 시점 스냅샷 — 도주/풀 재사용된 라이브 보스 참조로 이로치 오등록 방지 private RaidRoundStage roundStage = RaidRoundStage.Completed; - private IRaidRandomSource randomSource = new UnityRaidRandomSource(); + private IRaidRandomSource randomSource = new BattleRandomSource(); + // 치명타 전용 줄기 — 명중·보스 대상 롤(randomSource)과 따로 돈다. null이면 치명타 없음. + // 이유는 InsectBattleController.critSource 주석과 같다(같은 줄기면 시드 고정 시나리오의 롤 순서가 밀린다). + private IRaidRandomSource critSource = new BattleRandomSource(); private bool raidEndedRaised; + // 쓰러짐 연출을 이미 보여 줬는가 — PresentPendingFaints가 한 개체를 두 번 쓰러뜨리지 않게. + // 시작할 때 이미 기절해 있던 팀원은 "보여 준 것"으로 친다(방금 쓰러진 게 아니다). + private bool bossFaintPresented; + private bool[] teamFaintPresented; + // ── 순차 팀 턴 ── // 팀 5마리가 한 라운드 안에서 **하나씩 차례로** 행동한다. 예전엔 리더 한 마리의 스킬만 // 고르면 나머지가 같은 순간에 자동으로 딸려 나가 "팀 레이드"로 읽히지 않았다. @@ -284,6 +433,10 @@ public bool StartRaid(InsectEntity bossEntity, bossStunned = false; bossStunImmuneRounds = 0; BossEnraged = false; + BossFormIndex = 0; + BossFormCount = 1 + RaidBossForms.StageCount(bd.insectId); + BossFormLine = string.Empty; + bossFormLookupWarned = false; TeamStance = RaidTeamStance.Assault; LastBossSkill = null; RewardCandy = 0; @@ -294,6 +447,10 @@ public bool StartRaid(InsectEntity bossEntity, actedThisRound = null; roundInProgress = null; roundStunLanded = false; + bossFaintPresented = false; + teamFaintPresented = new bool[count]; + for (int i = 0; i < count; i++) + teamFaintPresented[i] = TeamStats[i] == null || TeamStats[i].CurrentHp <= 0; PrepareNextBossIntent(); BeginRound(); // 첫 라운드를 연다 — ActiveSlot이 첫 생존 슬롯에 선다 @@ -383,7 +540,7 @@ public RaidRoundResult ResolveTeamCommand(int skillIndex) int slot = ActiveSlot; InsectSkill skill = TeamSkills[slot][skillIndex]; RaidActionResult action = RaidRoundResolver.ResolveLeaderSkill( - slot, skillIndex, TeamStats[slot], BossStats, TeamStats, skill, randomSource); + slot, skillIndex, TeamStats[slot], BossStats, TeamStats, skill, randomSource, critSource); TeamCooldowns[slot][skillIndex] = skill.cooldownTurns; return CommitMemberAction(slot, action); } @@ -423,13 +580,13 @@ public RaidRoundResult ResolveAutoCommand() if (pick < 0) { // 스킬이 없거나 전부 쿨다운·0점 — 기본 지원 공격으로 폴백. - action = RaidRoundResolver.ResolveSupportAssist(slot, TeamStats[slot], BossStats); + action = RaidRoundResolver.ResolveSupportAssist(slot, TeamStats[slot], BossStats, critSource); } else { InsectSkill picked = slotSkills[pick]; action = RaidRoundResolver.ResolveSupportSkill( - slot, pick, TeamStats[slot], BossStats, TeamStats, picked, randomSource); + slot, pick, TeamStats[slot], BossStats, TeamStats, picked, randomSource, critSource); TeamCooldowns[slot][pick] = picked.cooldownTurns; } @@ -488,6 +645,10 @@ private RaidRoundResult CommitMemberAction(int slot, RaidActionResult action) UniteSlotDamages = null; LastActionText = BuildMemberActionText(action); + // 이 한 방이 임계를 넘겼으면 그 자리에서 모습을 바꾼다 — 행동 이벤트보다 먼저라, 행동을 받은 화면이 + // 이미 바뀐 모습(BossFormIndex)을 읽는다. 남은 팀원은 새 상성으로 친다. 쓰러졌으면 바꾸지 않는다. + UpdateBossForm(); + RaidMemberActionResolved?.Invoke(action); if (BossStats.CurrentHp <= 0) @@ -580,7 +741,8 @@ public RaidRoundResult ResolveBossResponse() { RaidActionResult bossAction = RaidRoundResolver.ResolveBossIntent( intent, BossStats, TeamStats, result.BossDamageBySlot, - BossEnraged ? GameConstants.Battle.RaidBossEnragedDamageMultiplier : 1f); + BossEnraged ? GameConstants.Battle.RaidBossEnragedDamageMultiplier : 1f, + critSource); result.BossAction = bossAction; result.BossResponseResolved = true; for (int i = 0; i < result.BossDamageBySlot.Length; i++) @@ -598,7 +760,7 @@ public RaidRoundResult ResolveBossResponse() LastDamageToTeam = result.TotalDamageToTeam; LastHitSlot = area ? -1 : (intent != null ? intent.TargetSlot : -1); BossUsedAoe = area; - LastActionText += BuildBossResponseText(intent); + LastActionText += BuildBossResponseText(intent, bossAction.Critical); UniteGauge = Mathf.Min( UniteGauge + (area ? 18f : 10f), UniteGaugeMax); @@ -643,9 +805,27 @@ public bool CompleteRoundPresentation() return true; } + /// 명중·보스 대상 롤 줄기만 바꾼다. 치명타 줄기는 그대로다(). public void SetRandomSource(IRaidRandomSource source) { - randomSource = source ?? new UnityRaidRandomSource(); + randomSource = source ?? new BattleRandomSource(); + } + + /// + /// Call before StartRaid so the first boss intent uses the same seeded stream. + /// 두 줄기를 함께 시드한다(치명타 줄기는 ) — + /// 치명타를 끄려면 이 뒤에 (null). + /// + public void SetRandomSeed(int seed) + { + randomSource = new BattleRandomSource(seed); + critSource = new BattleRandomSource(InsectBattleController.CritSeedFor(seed)); + } + + /// 치명타 줄기를 바꾼다. null이면 치명타가 아예 없다(굴리지도 않는다). + public void SetCritSource(IRaidRandomSource source) + { + critSource = source; } private void PrepareNextBossIntent() @@ -657,8 +837,8 @@ private void PrepareNextBossIntent() } UpdateBossPhase(); - InsectSkill signature = GetUnlockedBossSignature( - BossStats.Data, BossStats.Level, TurnNumber); + // 지금 모습의 기술 — 변신하지 않는 보스는 옛 동작(정체의 전용기 로테이션) 그대로다. + InsectSkill signature = PickBossSkill(TurnNumber); NextBossIntent = RaidRoundResolver.CreateBossIntent( TurnNumber + 1, BossStats, @@ -678,6 +858,7 @@ private string BuildMemberActionText(RaidActionResult action) if (action.Missed) return $"{actor}의 {action.DisplayName}! 빗나갔다!"; if (action.Capped) return $"{actor}의 {action.DisplayName}! 이미 최대치다!"; // 턴은 소비됐다 if (action.Healing > 0) return $"{actor}의 {action.DisplayName}! HP {action.Healing} 회복!"; + if (action.Damage > 0 && action.Critical) return $"{actor}의 {action.DisplayName}! 치명타! {action.Damage} 피해!"; if (action.Damage > 0) return $"{actor}의 {action.DisplayName}! {action.Damage} 피해!"; return $"{actor}의 {action.DisplayName}!"; } @@ -699,13 +880,14 @@ private string SlotDisplayName(int slot) : $"팀원 {slot + 1}"; } - private string BuildBossResponseText(RaidBossIntent intent) + private string BuildBossResponseText(RaidBossIntent intent, bool critical) { if (intent == null || BossStats == null || BossStats.Data == null) return string.Empty; + string crit = critical ? " 치명타!" : string.Empty; return intent.IsArea - ? $"\n{BossStats.Data.displayName}의 전체 공격!" - : $"\n{BossStats.Data.displayName}의 {intent.DisplayName}!"; + ? $"\n{BossStats.Data.displayName}의 전체 공격!{crit}" + : $"\n{BossStats.Data.displayName}의 {intent.DisplayName}!{crit}"; } /// @@ -720,16 +902,17 @@ private static InsectSkill GetUnlockedBossSignature(InsectData data, int level, { if (data == null || data.learnset == null) return null; + // 전용기 판정은 SignatureSkills 한 곳이다 — 1대1의 전용기 표지(LastPlayerSkillIsSignature)·전용기 연출과 같은 기준. int count = 0; foreach (InsectLearnableSkill learnable in data.learnset) - if (IsUnlockedSignature(learnable, level)) count++; + if (SignatureSkills.IsUnlocked(learnable, level)) count++; if (count == 0) return null; int pick = count > 1 ? ((rotation % count) + count) % count : 0; int seen = 0; foreach (InsectLearnableSkill learnable in data.learnset) { - if (!IsUnlockedSignature(learnable, level)) continue; + if (!SignatureSkills.IsUnlocked(learnable, level)) continue; if (seen == pick) return learnable.skill; seen++; } @@ -737,14 +920,6 @@ private static InsectSkill GetUnlockedBossSignature(InsectData data, int level, return null; } - private static bool IsUnlockedSignature(InsectLearnableSkill learnable, int level) - { - return learnable != null - && learnable.skill != null - && learnable.skill.isSignatureSkill - && learnable.learnLevel <= level; - } - private void TickCooldowns() { for (int s = 0; s < TeamCooldowns.Length; s++) @@ -795,7 +970,9 @@ private void OnRaidVictory() { InsectData bd = BossStats.Data; int candyBase = InsectRewardCalculator.GetCandyReward(bd); - int expBase = InsectRewardCalculator.GetExpReward(bd); + // 보스 레벨·캐릭터와의 레벨 차를 반영한 EXP(1v1·포획과 같은 공식) — 캐릭터 레벨은 지급 전 값. + int trainerLevel = playerProgress != null ? playerProgress.Level : 1; + int expBase = InsectRewardCalculator.GetExpReward(bd, BossStats.Level, trainerLevel); // EXP/캔디 부스터(아이템·아웃핏) 배율 — 포획 경로(CaptureController)와 동일 항목만 적용. // 레이드 ×3 보너스 위에 부스터를 곱하고, 표기/지급이 같도록 곱한 최종값을 저장. float candyMultiplier = (itemEffects != null ? itemEffects.GetCandyMultiplier() : 1f) @@ -879,6 +1056,8 @@ public RaidRoundResult ResolveUniteCommand() result.EndState = RaidRoundEndState.Victory; roundInProgress = result; + // 합체공격이 임계를 넘겼으면 여기서 — 팀 턴을 닫기(이벤트) 전에. 한 방에 둘을 넘기는 건 대개 이 경로다. + UpdateBossForm(); FinishTeamPhase(result, $"★ 합체공격! {string.Join(" + ", names)} ★"); return result; } @@ -949,20 +1128,52 @@ private void TryPlayTeamHitFlash(int index) StartCoroutine(Arena.PlayHitFlashCoroutine(m)); } - private void TryPlayBossFaint() + /// + /// HP가 0인데 아직 쓰러지는 모습을 안 보인 쪽(보스·팀원)을 쓰러뜨린다 — 연출 쪽이 공격 연출을 마친 뒤 부른다. + /// 행동은 커맨드 순간에 동기로 해결되지만(그때 이미 HP 0) 공격 연출은 그 뒤에 돌아서, 해결 시점에 쓰러뜨리면 + /// 마지막 일격이 닿기도 전에 보스가 눕는다(1v1이 DeferPresentation으로 같은 순서를 지킨다). + /// 한 개체에 한 번만 — 다시 불러도 이미 보여 준 쪽은 건너뛴다. + /// + /// 아레나에서 쓰러짐이 실제로 시작됐는가 — 호출부는 그동안 다음 화면(결과·다음 차례)을 미룬다. + public bool PresentPendingFaints() { - if (Arena == null) return; + bool started = false; + if (BossStats != null && BossStats.CurrentHp <= 0 && !bossFaintPresented) + { + bossFaintPresented = true; + started |= TryPlayBossFaint(); + FaintPresented?.Invoke(-1); + } + + if (TeamStats != null && teamFaintPresented != null) + { + for (int i = 0; i < TeamStats.Length && i < teamFaintPresented.Length; i++) + { + if (teamFaintPresented[i] || TeamStats[i] == null || TeamStats[i].CurrentHp > 0) continue; + teamFaintPresented[i] = true; + started |= TryPlayTeamFaint(i); + FaintPresented?.Invoke(i); + } + } + return started; + } + + private bool TryPlayBossFaint() + { + if (Arena == null || !Arena.IsActive) return false; GameObject m = Arena.BossModel; - if (m != null && m.activeInHierarchy) - StartCoroutine(Arena.PlayFaintCoroutine(m)); + if (m == null || !m.activeInHierarchy) return false; + StartCoroutine(Arena.PlayFaintCoroutine(m)); + return true; } - private void TryPlayTeamFaint(int index) + private bool TryPlayTeamFaint(int index) { - if (Arena == null) return; + if (Arena == null || !Arena.IsActive) return false; GameObject m = Arena.GetTeamModel(index); - if (m != null && m.activeInHierarchy) - StartCoroutine(Arena.PlayFaintCoroutine(m)); + if (m == null || !m.activeInHierarchy) return false; + StartCoroutine(Arena.PlayFaintCoroutine(m)); + return true; } private void TryPlayEffectText(string text, Color color) @@ -983,5 +1194,14 @@ public RaidBossStats(InsectData data, int level, int hp, int atk, int def) Defense = def; ResetHp(); } + + /// + /// 다른 곤충의 모습을 빌린다 — 속성(주·부)과 기술만 그 곤충의 것이 된다(). + /// HP·최대 HP·레벨·공격·방어·버프 스택은 그대로다. null이면 원래 모습으로 돌아간다. + /// + public void ChangeForm(InsectData form) + { + CombatData = form != null ? form : Data; + } } } diff --git a/Assets/Scripts/Battle/RaidBossForms.cs b/Assets/Scripts/Battle/RaidBossForms.cs new file mode 100644 index 0000000..ddbf94a --- /dev/null +++ b/Assets/Scripts/Battle/RaidBossForms.cs @@ -0,0 +1,153 @@ +using System.Collections.Generic; +using InsectGame.Data; + +namespace InsectGame.Battle +{ + /// + /// 체력에 따라 모습을 바꾸는 레이드 보스 표 — 보스 곤충 ID → 변신 단계 목록(순수 데이터·순수 판정). + /// + /// 첫 항목은 이름 없는 사마귀(mantis_unnamed)다. 이름 없는 자리의 수문장이자 최종장 「빈칸」의 상대인 + /// 그림자가, 빼앗은 이름의 모습을 차례로 빌린다 — 사마귀였다가, 나비였다가, 반딧불이였다가. + /// 표가 종 ID로 걸리므로 같은 종의 레이드는 전부 변신한다(수문장·빈칸·야생 모두). + /// + /// 바뀌는 것은 속성(주·부)과 기술뿐이다() — HP·최대 HP·레벨·격노는 + /// 그대로다. 정체()도 그대로라 이기면 잡히는 것은 사마귀다. + /// 상성이 바뀌므로 플레이어는 대응을 바꿔야 한다. + /// + /// 단계는 래치다 — 한 번 넘은 임계는 회복해도 되돌아가지 않는다(). + /// 판정·실행은 가, 표시는 RaidBattleUI(변신 단계)가 한다. + /// + public static class RaidBossForms + { + /// 변신 한 단계 — HP가 % 이하가 되면 의 모습을 빌린다. + public readonly struct Stage + { + /// 이 값(%) 이하로 떨어지면 이 모습. 단계는 내림차순이어야 한다(테스트가 고정한다). + public readonly int HpPercent; + /// 빌리는 모습의 곤충 ID — 곤충 DB에 실재해야 한다(RaidBossFormTests). + public readonly string FormInsectId; + /// 표시용 한 줄의 틀. {0}에 빌린 곤충의 표시명이 들어간다. + public readonly string LineFormat; + + public Stage(int hpPercent, string formInsectId, string lineFormat) + { + HpPercent = hpPercent; + FormInsectId = formInsectId; + LineFormat = lineFormat; + } + } + + private const string BorrowLine = "그림자가 {0}의 모습을 빌렸다!"; + + private static readonly Dictionary Table = new Dictionary + { + // 이름 없는 사마귀 — `gd_nameless`의 "몸이 여러 모양으로 번진다". 66% 나비, 33% 반딧불이. + ["mantis_unnamed"] = new[] + { + new Stage(66, "butterfly_swallowtail", BorrowLine), + new Stage(33, "firefly_blue", BorrowLine), + }, + }; + + /// 표에 오른 보스 곤충 ID 전부 — 검증용. + public static IEnumerable BossIds => Table.Keys; + + /// 이 보스가 모습을 바꾸는가. + public static bool HasForms(string bossInsectId) => StageCount(bossInsectId) > 0; + + /// 변신 단계 수(원래 모습 제외). 표에 없으면 0. + public static int StageCount(string bossInsectId) + { + return !string.IsNullOrEmpty(bossInsectId) && Table.TryGetValue(bossInsectId, out Stage[] stages) + ? stages.Length + : 0; + } + + /// 변신 단계 목록의 사본(원래 모습 제외). 표에 없으면 false·빈 배열. + public static bool TryGetStages(string bossInsectId, out Stage[] stages) + { + if (string.IsNullOrEmpty(bossInsectId) || !Table.TryGetValue(bossInsectId, out Stage[] found)) + { + stages = System.Array.Empty(); + return false; + } + stages = (Stage[])found.Clone(); + return true; + } + + /// + /// 지금 HP로 서야 할 모습 번호 — 0이면 원래 모습, k면 k번째 변신. HP가 낮을수록 번호가 크다(단조). + /// 비율은 정수로 잰다(currentHp × 100 ≤ maxHp × 임계) — 부동소수점 경계에서 한 끗 차로 갈리지 않게. + /// 이 함수는 래치를 모른다 — 실제로 쓸 번호는 . + /// + public static int FormIndexFor(string bossInsectId, int currentHp, int maxHp) + { + if (maxHp <= 0 || string.IsNullOrEmpty(bossInsectId) + || !Table.TryGetValue(bossInsectId, out Stage[] stages)) return 0; + + int index = 0; + long hp100 = (long)System.Math.Max(0, currentHp) * 100L; + for (int i = 0; i < stages.Length; i++) + { + if (hp100 <= (long)maxHp * stages[i].HpPercent) index = i + 1; + else break; // 내림차순이라 이 단계를 못 넘었으면 뒤 단계도 못 넘었다 + } + return index; + } + + /// + /// 래치를 건 다음 모습 번호 — 보다 작아지지 않는다(회복해도 돌아가지 않는다). + /// HP가 0 이하면 바꾸지 않는다 — 쓰러지는 보스는 변신 없이 쓰러진다. + /// 한 번에 임계를 둘 넘으면 마지막 모습 번호를 곧바로 돌려준다(중간 모습은 건너뛴다). + /// + public static int NextFormIndex(string bossInsectId, int currentIndex, int currentHp, int maxHp) + { + if (currentHp <= 0) return currentIndex; + int target = FormIndexFor(bossInsectId, currentHp, maxHp); + return target > currentIndex ? target : currentIndex; + } + + /// 모습 번호의 곤충 ID — 0이면 보스 자신, 범위 밖이면 null. + public static string FormInsectId(string bossInsectId, int formIndex) + { + if (formIndex == 0) return bossInsectId; + if (formIndex < 0 || string.IsNullOrEmpty(bossInsectId) + || !Table.TryGetValue(bossInsectId, out Stage[] stages) || formIndex > stages.Length) return null; + return stages[formIndex - 1].FormInsectId; + } + + /// + /// 표시용 한 줄 — "그림자가 호랑나비의 모습을 빌렸다!". 표시명이 비면 곤충 ID를 쓴다(빈칸으로 뜨는 것보다 낫다). + /// 원래 모습(0)·범위 밖이면 빈 문자열. + /// + public static string BuildLine(string bossInsectId, int formIndex, string formDisplayName) + { + if (formIndex <= 0 || string.IsNullOrEmpty(bossInsectId) + || !Table.TryGetValue(bossInsectId, out Stage[] stages) || formIndex > stages.Length) return string.Empty; + Stage stage = stages[formIndex - 1]; + string name = string.IsNullOrEmpty(formDisplayName) ? stage.FormInsectId : formDisplayName; + return string.IsNullOrEmpty(stage.LineFormat) ? string.Empty : string.Format(stage.LineFormat, name); + } + } + + /// + /// 보스가 모습을 바꿨다 — 가 싣는다. + /// 한 번의 피해로 임계를 둘 이상 넘으면 이벤트는 한 번이고 가 마지막 모습이다 + /// (가 true). 화면은 변신 단계를 한 번만 돌리면 된다. + /// + public sealed class RaidBossFormChange + { + /// 바뀌기 전 모습 번호(0 = 원래 모습). + public int FromIndex { get; internal set; } + /// 바뀐 뒤 모습 번호. + public int ToIndex { get; internal set; } + /// 바뀌기 전 모습의 곤충(상성·기술의 출처였던 것). + public InsectData FromData { get; internal set; } + /// 바뀐 뒤 모습의 곤충 — 아레나가 이 곤충으로 보스 모델을 다시 세운다. + public InsectData ToData { get; internal set; } + /// 표시용 한 줄("그림자가 반딧불이의 모습을 빌렸다!") — 마지막 모습의 것. + public string Line { get; internal set; } + /// 중간 모습을 건너뛰었는가(한 번에 임계 둘 이상). + public bool SkippedStages => ToIndex - FromIndex > 1; + } +} diff --git a/Assets/Scripts/Battle/RaidBossForms.cs.meta b/Assets/Scripts/Battle/RaidBossForms.cs.meta new file mode 100644 index 0000000..8e96657 --- /dev/null +++ b/Assets/Scripts/Battle/RaidBossForms.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dfe076d8ccd37d448b089d11fcf6b1ec \ No newline at end of file diff --git a/Assets/Scripts/Battle/RaidRoundModels.cs b/Assets/Scripts/Battle/RaidRoundModels.cs index e068129..5ebb318 100644 --- a/Assets/Scripts/Battle/RaidRoundModels.cs +++ b/Assets/Scripts/Battle/RaidRoundModels.cs @@ -81,6 +81,22 @@ public sealed class RaidActionResult public int Damage { get; internal set; } public int Healing { get; internal set; } public bool Missed { get; internal set; } + /// + /// 이 행동의 피해가 치명타(×GameConstants.Battle.CritMultiplier)였다. 리졸버가 치명타 줄기를 받았을 때만 + /// 켜질 수 있다 — 피해기(리더·서포트 스킬 피해, 지원 공격, 보스 단일·전체 공격)만, 합체공격·독·버프·회복·기절은 아니다. + /// + public bool Critical { get; internal set; } + /// + /// 이 행동이 쓴 기술()이 시전자의 전용기였다( — 1대1과 같은 기준). + /// 팀원은 그 곤충, 보스는 지금 모습(CombatData)의 learnset으로 잰다. 빗나가도 true다(전용기를 꺼낸 것 자체가 연출거리). + /// 기술이 없는 행동(지원 공격·합체공격·기술 없는 보스 공격)은 false. + /// + public bool IsSignature { get; internal set; } + /// + /// 피해가 들어간 상성 등급(). 팀 → 보스는 보스의 지금 모습, 보스 단일 공격은 맞은 팀원으로 잰다. + /// 피해기가 아니거나 빗나갔거나 상성을 안 타는 공격(보스 전체공격·독 일괄딜)이면 . + /// + public Matchup Matchup { get; internal set; } public bool KnockedOut { get; internal set; } // 버프·디버프가 스택 상한(GameConstants.Battle.MaxBuffStacks)에 걸려 값이 바뀌지 않았음. // 턴은 이미 소비됐으므로 UI가 "이미 최대치"를 알려야 한다. diff --git a/Assets/Scripts/Battle/RaidRoundResolver.cs b/Assets/Scripts/Battle/RaidRoundResolver.cs index 96dadc4..9f4593f 100644 --- a/Assets/Scripts/Battle/RaidRoundResolver.cs +++ b/Assets/Scripts/Battle/RaidRoundResolver.cs @@ -5,19 +5,6 @@ namespace InsectGame.Battle { - internal sealed class UnityRaidRandomSource : IRaidRandomSource - { - public float Next01() - { - return Random.value; - } - - public int NextInt(int minInclusive, int maxExclusive) - { - return Random.Range(minInclusive, maxExclusive); - } - } - public static class RaidRoundResolver { public const float SupportAssistPowerMultiplier = 0.25f; @@ -25,11 +12,8 @@ public static class RaidRoundResolver /// 합체공격 1인분 배율. 기여 자체가 스킬이 아니라 고정 위력이므로 여기서만 쓴다. public const float UniteContributionMultiplier = 1.5f; - /// - /// 난수원이 주입되지 않았을 때 쓰는 공유 인스턴스. 는 상태가 - /// 없어 공유해도 안전하다 — 호출마다 new하던 것을 없앤다(명중 판정이 늘어나면서 그 자리가 셋이 됐다). - /// - private static readonly IRaidRandomSource SharedRandom = new UnityRaidRandomSource(); + // Fallback for standalone resolver callers. Controllers always own/inject their stream. + private static readonly IRaidRandomSource SharedRandom = new BattleRandomSource(); /// /// 명중 판정. 명중률이 사실상 1이면 굴리지 않는다(난수 소비 순서를 바꾸지 않기 위해서다 — @@ -59,7 +43,8 @@ public static RaidBossIntent CreateBossIntent(int roundNumber, bool allowAreaAttack) { IRaidRandomSource source = random ?? SharedRandom; - InsectData bossData = boss != null ? boss.Data : null; + // 속성은 **지금 모습**으로 — 모습을 바꾸는 보스(RaidBossForms)는 정체(Data)와 모습(CombatData)이 다르다. + InsectData bossData = boss != null ? boss.CombatData : null; if (allowAreaAttack && roundsUntilAreaAttack <= 0) { @@ -93,30 +78,48 @@ public static RaidBossIntent CreateBossIntent(int roundNumber, targetSlot = aliveSlots[pick]; } - bool hasSignature = signatureSkill != null; - return new RaidBossIntent + RaidBossIntent intent = new RaidBossIntent { RoundNumber = roundNumber, - Kind = hasSignature ? RaidBossIntentKind.SignatureSkill : RaidBossIntentKind.SingleTarget, - TargetSlot = targetSlot, - Skill = signatureSkill, - Element = hasSignature - ? signatureSkill.element - : (bossData != null ? bossData.primaryType : InsectElement.Bug), - EffectType = hasSignature ? signatureSkill.effectType : SkillEffectType.Damage, - DisplayName = hasSignature && !string.IsNullOrEmpty(signatureSkill.displayName) - ? signatureSkill.displayName - : "공격" + TargetSlot = targetSlot }; + ApplySingleTargetSkill(intent, signatureSkill, bossData); + return intent; } + /// + /// 단일 대상 예고에 기술을 싣는다 — 기술이 있으면 그 기술(이름·속성·효과), 없으면 보스 주속성의 「공격」. + /// 와, 보스가 라운드 도중 모습을 바꿨을 때 이미 띄운 예고를 새 모습의 기술로 + /// 고쳐 쓰는 RaidBattleController가 함께 쓴다(두 곳이 따로 채우면 예고 표기가 갈린다). + /// 대상 슬롯·라운드 번호는 건드리지 않는다 — 난수를 다시 굴리지 않기 위해서다. + /// + internal static void ApplySingleTargetSkill(RaidBossIntent intent, InsectSkill skill, InsectData bossData) + { + if (intent == null) return; + bool hasSkill = skill != null; + intent.Kind = hasSkill ? RaidBossIntentKind.SignatureSkill : RaidBossIntentKind.SingleTarget; + intent.Skill = skill; + intent.Element = hasSkill + ? skill.element + : (bossData != null ? bossData.primaryType : InsectElement.Bug); + intent.EffectType = hasSkill ? skill.effectType : SkillEffectType.Damage; + intent.DisplayName = hasSkill && !string.IsNullOrEmpty(skill.displayName) + ? skill.displayName + : "공격"; + } + + /// + /// 치명타 줄기. null이면 굴리지 않는다(치명타 없음 — 결정적). 명중 줄기()와 + /// 따로 받는 이유는 주석 참조 — 같은 줄기면 명중 롤 순서가 밀린다. + /// 게임 컨트롤러()만 진짜 줄기를 넘긴다. + /// public static RaidActionResult ResolveLeaderSkill(int slot, int skillIndex, InsectBattleStats attacker, InsectBattleStats boss, InsectBattleStats[] team, - InsectSkill skill, IRaidRandomSource random) + InsectSkill skill, IRaidRandomSource random, IRaidRandomSource crit = null) { return ResolveTeamSkill( RaidActionKind.LeaderSkill, 1f, slot, skillIndex, - attacker, boss, team, skill, random); + attacker, boss, team, skill, random, crit); } /// @@ -125,12 +128,12 @@ public static RaidActionResult ResolveLeaderSkill(int slot, int skillIndex, /// public static RaidActionResult ResolveSupportSkill(int slot, int skillIndex, InsectBattleStats attacker, InsectBattleStats boss, InsectBattleStats[] team, - InsectSkill skill, IRaidRandomSource random) + InsectSkill skill, IRaidRandomSource random, IRaidRandomSource crit = null) { return ResolveTeamSkill( RaidActionKind.SupportSkill, GameConstants.Battle.RaidSupportSkillPowerMultiplier, - slot, skillIndex, attacker, boss, team, skill, random); + slot, skillIndex, attacker, boss, team, skill, random, crit); } /// @@ -145,7 +148,7 @@ public static RaidActionResult ResolveSupportSkill(int slot, int skillIndex, private static RaidActionResult ResolveTeamSkill(RaidActionKind kind, float powerMultiplier, int slot, int skillIndex, InsectBattleStats attacker, InsectBattleStats boss, InsectBattleStats[] team, - InsectSkill skill, IRaidRandomSource random) + InsectSkill skill, IRaidRandomSource random, IRaidRandomSource crit) { RaidActionResult result = new RaidActionResult { @@ -158,7 +161,8 @@ private static RaidActionResult ResolveTeamSkill(RaidActionKind kind, EffectType = skill != null ? skill.effectType : SkillEffectType.Damage, DisplayName = skill != null && !string.IsNullOrEmpty(skill.displayName) ? skill.displayName - : "공격" + : "공격", + IsSignature = attacker != null && SignatureSkills.IsSignature(attacker.Data, skill) }; if (attacker == null || boss == null || skill == null) @@ -173,7 +177,11 @@ private static RaidActionResult ResolveTeamSkill(RaidActionKind kind, return result; } - int damage = CalculateSkillDamage(attacker, boss, skill, powerMultiplier); + // 치명타는 **명중한 피해기에만** — 빗나감은 위에서 이미 돌아갔다(명중 롤 뒤에 굴린다). + int damage = InsectBattleController.ApplyCritical( + CalculateSkillDamage(attacker, boss, skill, powerMultiplier), crit, out bool critical); + result.Critical = critical; + result.Matchup = ElementMatchup.Of(skill.element, boss.CombatData); // CalculateTeamDamage와 같은 상대(지금 모습) result.Damage = ApplyDamageAndMeasure( boss, damage, attacker.Attack, boss.Defense); result.KnockedOut = result.Damage > 0 && boss.CurrentHp <= 0; @@ -258,8 +266,9 @@ private static RaidActionResult ResolveTeamSkill(RaidActionKind kind, } } + /// 치명타 줄기 — null이면 굴리지 않는다(와 같은 규칙). public static RaidActionResult ResolveSupportAssist(int slot, - InsectBattleStats attacker, InsectBattleStats boss) + InsectBattleStats attacker, InsectBattleStats boss, IRaidRandomSource crit = null) { InsectElement element = attacker != null && attacker.Data != null ? attacker.Data.primaryType @@ -283,7 +292,10 @@ public static RaidActionResult ResolveSupportAssist(int slot, // 팀 편성(속성 매칭)이 레이드 전 유일한 결정인데 피해의 4/5가 그 결정을 무시했다. int basePower = Mathf.Max( 1, Mathf.RoundToInt(attacker.Attack * SupportAssistPowerMultiplier)); - int baseDamage = CalculateTeamDamage(attacker, boss, element, basePower); + int baseDamage = InsectBattleController.ApplyCritical( + CalculateTeamDamage(attacker, boss, element, basePower), crit, out bool critical); + result.Critical = critical; + result.Matchup = ElementMatchup.Of(element, boss.CombatData); result.Damage = ApplyDamageAndMeasure( boss, baseDamage, attacker.Attack, boss.Defense); result.KnockedOut = result.Damage > 0 && boss.CurrentHp <= 0; @@ -311,6 +323,8 @@ public static RaidActionResult ResolveUniteContribution(int slot, if (attacker == null || boss == null || attacker.CurrentHp <= 0) return result; + // 치명타는 굴리지 않는다 — 합체공격은 이미 ×1.5(UniteContributionMultiplier)인 연출의 정점이라 + // 거기에 무작위 ×1.5가 얹히면 같은 게이지의 가치가 한 마리 단위로 들쭉날쭉해진다. // 합체공격도 상성·자속을 탄다. `result.Element`를 **표시**만 하고 계산엔 안 쓰던 자리 — // 화면엔 속성이 뜨는데 수식은 무속성이라 표시와 결과가 어긋나 있었다. int basePower = Mathf.Max( @@ -318,6 +332,7 @@ public static RaidActionResult ResolveUniteContribution(int slot, Mathf.RoundToInt((15 + attacker.Level * GameConstants.Battle.LevelDamageScale) * UniteContributionMultiplier)); int damage = CalculateTeamDamage(attacker, boss, element, basePower); + result.Matchup = ElementMatchup.Of(element, boss.CombatData); result.Damage = ApplyDamageAndMeasure( boss, damage, attacker.Attack, boss.Defense); result.KnockedOut = result.Damage > 0 && boss.CurrentHp <= 0; @@ -328,9 +343,12 @@ public static RaidActionResult ResolveUniteContribution(int slot, /// 격노(HP 절반 이하) 시 단일 대상 피해에만 곱한다. 전체공격은 그대로 두고 대신 /// 간격이 짧아진다 — 둘 다 세지면 격노 진입이 곧 전멸이 된다(레이드엔 부활·교체가 없다). /// + /// + /// 치명타 줄기 — null이면 굴리지 않는다. 보스의 한 행동에 한 번만 굴린다(전체공격이면 맞는 전원에 같은 판정). + /// public static RaidActionResult ResolveBossIntent(RaidBossIntent intent, InsectBattleStats boss, InsectBattleStats[] team, int[] damageBySlot, - float rageMultiplier = 1f) + float rageMultiplier = 1f, IRaidRandomSource crit = null) { RaidActionResult result = new RaidActionResult { @@ -343,7 +361,9 @@ public static RaidActionResult ResolveBossIntent(RaidBossIntent intent, Skill = intent != null ? intent.Skill : null, Element = intent != null ? intent.Element : InsectElement.Bug, EffectType = intent != null ? intent.EffectType : SkillEffectType.Damage, - DisplayName = intent != null ? intent.DisplayName : "공격" + DisplayName = intent != null ? intent.DisplayName : "공격", + // 빌린 모습의 전용기는 그 모습의 것이다 — 기술을 고른 쪽(PickBossSkill)과 같이 지금 모습으로 잰다. + IsSignature = intent != null && boss != null && SignatureSkills.IsSignature(boss.CombatData, intent.Skill) }; if (intent == null || boss == null || team == null) @@ -356,7 +376,9 @@ public static RaidActionResult ResolveBossIntent(RaidBossIntent intent, if (intent.IsArea) { - int areaDamage = Mathf.Max(1, bossDamage * 2 / 3); + int areaDamage = InsectBattleController.ApplyCritical( + Mathf.Max(1, bossDamage * 2 / 3), crit, out bool areaCritical); + result.Critical = areaCritical; bool knockedOutAny = false; for (int i = 0; i < team.Length; i++) { @@ -394,19 +416,24 @@ public static RaidActionResult ResolveBossIntent(RaidBossIntent intent, singleTarget.Data != null ? singleTarget.Data.secondaryType : InsectElement.None); - float sameTypeBonus = boss.Data != null + // 자속은 **지금 모습**의 속성으로 — 나비 모습을 빌린 보스는 나비의 바람 기술에 자속을 받는다. + InsectData bossForm = boss.CombatData; + float sameTypeBonus = bossForm != null ? InsectTypeChart.GetSameTypeBonus( intent.Skill.element, - boss.Data.primaryType, - boss.Data.secondaryType) + bossForm.primaryType, + bossForm.secondaryType) : 1f; singleTargetDamage = Mathf.Max( 1, Mathf.RoundToInt( (intent.Skill.power + boss.Level * GameConstants.Battle.LevelDamageScale) * bossMultiplier * effectiveness * sameTypeBonus * rage)); + result.Matchup = ElementMatchup.Describe(effectiveness); } + singleTargetDamage = InsectBattleController.ApplyCritical(singleTargetDamage, crit, out bool critical); + result.Critical = critical; result.Damage = ApplyDamageAndMeasure( singleTarget, singleTargetDamage, boss.Attack, singleTarget.Defense); if (damageBySlot != null && slot < damageBySlot.Length) @@ -500,10 +527,12 @@ private static int CalculateTeamDamage(InsectBattleStats attacker, float multiplier = Mathf.Clamp(1f + attacker.AttackBonus, 0.3f, 3f); int damage = Mathf.Max(1, Mathf.RoundToInt(basePower * multiplier)); + // 상성은 보스의 **지금 모습**으로 잰다 — 모습이 바뀌면 약점이 바뀌어 팀이 대응을 바꿔야 한다(RaidBossForms). + InsectData bossForm = boss.CombatData; float effectiveness = InsectTypeChart.GetEffectiveness( element, - boss.Data != null ? boss.Data.primaryType : InsectElement.None, - boss.Data != null ? boss.Data.secondaryType : InsectElement.None); + bossForm != null ? bossForm.primaryType : InsectElement.None, + bossForm != null ? bossForm.secondaryType : InsectElement.None); float sameTypeBonus = attacker.Data != null ? InsectTypeChart.GetSameTypeBonus( element, diff --git a/Assets/Scripts/Battle/RaidSupportPlanner.cs b/Assets/Scripts/Battle/RaidSupportPlanner.cs index b7596b0..a901587 100644 --- a/Assets/Scripts/Battle/RaidSupportPlanner.cs +++ b/Assets/Scripts/Battle/RaidSupportPlanner.cs @@ -242,12 +242,14 @@ private static int AliveCount(InsectBattleStats[] team) return count; } + // 보스의 **지금 모습**(CombatData)으로 잰다 — 리졸버의 피해 계산과 같은 속성을 봐야 AI가 고른 기술이 실제로 세다. private static float Effectiveness(InsectElement element, InsectBattleStats boss) { + InsectData form = boss.CombatData; return InsectTypeChart.GetEffectiveness( element, - boss.Data != null ? boss.Data.primaryType : InsectElement.None, - boss.Data != null ? boss.Data.secondaryType : InsectElement.None); + form != null ? form.primaryType : InsectElement.None, + form != null ? form.secondaryType : InsectElement.None); } private static float SameTypeBonus(InsectElement element, InsectBattleStats attacker) diff --git a/Assets/Scripts/Battle/RaidUniteTimeline.cs b/Assets/Scripts/Battle/RaidUniteTimeline.cs new file mode 100644 index 0000000..7a8c05a --- /dev/null +++ b/Assets/Scripts/Battle/RaidUniteTimeline.cs @@ -0,0 +1,33 @@ +namespace InsectGame.Battle +{ + /// + /// 레이드 합체공격 타임라인(연출 초) — 3D 아레나의 돌진·타격과 UI 오버레이의 슬롯별 피해 숫자·TOTAL이 + /// 같은 시각을 쓰게 하는 단일 출처다. 둘 다 BattlePresentation.DeltaTime으로 세므로 히트스톱· + /// 슬로모션 중에도 맞물린다. + /// + /// 예전엔 오버레이가 자기 숫자(0.3·0.2·0.45·1.5·1.8)를 박아 두고 아레나는 0.42초 만에 전원 동시 + /// 돌진을 끝내서, 3D에선 이미 돌아온 팀원 위로 2D 숫자가 차례로 떴다. 5인 팀이면 마지막 팀원의 + /// 타격(1.55초)이 "합동 일격"(1.5초)보다 늦기까지 했다. + /// + public static class RaidUniteTimeline + { + /// 기 모으기 — 전원이 제자리에서 들썩이는 시간. + public const float MemberStart = 0.3f; + /// 팀원끼리 출발 간격. + public const float MemberStagger = 0.16f; + /// 한 팀원의 돌진 시간. + public const float MemberTravel = 0.45f; + /// 마지막 합동 일격 — 5번째 팀원의 타격(1.39초) 뒤. + public const float FinalStrike = 1.6f; + /// TOTAL 표시 시작. + public const float TotalReveal = 1.85f; + /// 팀원이 제자리로 돌아와 연출이 끝나는 시각. RaidBattleUI.UniteRushMinDuration과 같다. + public const float Total = 2.5f; + + /// 살아 있는 팀원 중 번째가 보스를 때리는 시각. + public static float MemberHit(int order) + { + return MemberStart + order * MemberStagger + MemberTravel; + } + } +} diff --git a/Assets/Scripts/Battle/RaidUniteTimeline.cs.meta b/Assets/Scripts/Battle/RaidUniteTimeline.cs.meta new file mode 100644 index 0000000..2827969 --- /dev/null +++ b/Assets/Scripts/Battle/RaidUniteTimeline.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c349b8a1720b2a64c8a76f626a762e9b \ No newline at end of file diff --git a/Assets/Scripts/Battle/RaidVisualCapture.cs b/Assets/Scripts/Battle/RaidVisualCapture.cs new file mode 100644 index 0000000..cced2ca --- /dev/null +++ b/Assets/Scripts/Battle/RaidVisualCapture.cs @@ -0,0 +1,171 @@ +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System; +using System.Collections; +using System.IO; +using System.Linq; +using System.Reflection; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Spawning; +using InsectGame.UI; +using UnityEngine; + +namespace InsectGame.Battle +{ + /// + /// Actual raid UI fixture; deliberately has no collection or reward persistence. + /// + /// 장면: raid(보통 레이드) · raid-unite(첫 차례 합체공격) · raid-forms(이름 없는 사마귀 — 수문장 등장 컷 + 66%·33% + /// 그림자 변신 두 번 + 그림자 모습) · guardian-intro(숲의 수문장 헤라클레스 — 등장 컷만 보고 첫 차례 1초 뒤 끝낸다). + /// 4단계 연출도 여기서 보인다 — raid의 첫 장면이 진입 샷(보스 옆에서 팀 뒤로, 1초), 끝 장면이 레이드 승리(팀 전원 점프 + 팀 앞으로 + /// 도는 카메라 — 결과 뒤 2.4초까지 찍힌다), 팀원 강화 스킬 뒤엔 몸의 불꽃결·육각 막. + /// + public static class RaidVisualCapture + { + /// 첫 차례에 게이지를 채워 합체공격을 쏜다 — 팀 전원 공격 연출 검수용. + public static IEnumerator Run(string output, CameraFollower follower, BattleArenaController arena, bool unite = false) + { + return Run(output, follower, arena, unite ? "raid-unite" : "raid"); + } + + public static IEnumerator Run(string output, CameraFollower follower, BattleArenaController arena, string scenario) + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + bool unite = scenario == "raid-unite"; + bool forms = scenario == "raid-forms"; + bool guardianIntro = scenario == "guardian-intro"; + var controller = new GameObject("QARaidController").AddComponent(); + var ui = new GameObject("QARaidUI").AddComponent(); + controller.AutoWire(arena); + arena.AutoWire(controller); // 몸 상태 표시(강화 스택·보스 기절)의 원천 — Life partial + controller.SetRandomSeed(8173); + ui.AutoWire(controller, follower, null); + ui.AutoWire(arena); + string[] ids = { "rhinoceros_beetle", "stag_beetle", "mantis", "dragonfly", "ant" }; + string[] names = { "장수풍뎅이", "사슴벌레", "사마귀", "잠자리", "개미" }; + var team = new InsectData[5]; + var levels = new int[5]; + var skills = new InsectSkill[5][]; + for (int i = 0; i < 5; i++) + { + team[i] = BattleVisualCapture.Fixture(ids[i], names[i]); + levels[i] = 15; + skills[i] = new[] { team[i].skills[0] }; + } + InsectData boss; + string guardianRegion = null; + if (forms) + { + // 그림자 — 이름 없는 땅의 수문장이 66%에 호랑나비, 33%에 반딧불이의 모습을 빌린다(RaidBossForms). + boss = BattleVisualCapture.Fixture("mantis_unnamed", "이름 없는 사마귀"); + boss.rarity = InsectRarity.Legendary; + boss.primaryType = InsectElement.Leaf; + InsectData swallowtail = BattleVisualCapture.Fixture("butterfly_swallowtail", "호랑나비"); + swallowtail.rarity = InsectRarity.Rare; + swallowtail.primaryType = InsectElement.Wind; + InsectData firefly = BattleVisualCapture.Fixture("firefly_blue", "푸른 반딧불이"); + firefly.rarity = InsectRarity.Epic; + firefly.primaryType = InsectElement.Light; + controller.SetInsectLookup(id => id == swallowtail.insectId ? swallowtail : id == firefly.insectId ? firefly : null); + guardianRegion = "nameless"; + } + else if (guardianIntro) + { + boss = BattleVisualCapture.Fixture("beetle_hercules", "숲의 문지기 헤라클레스"); + boss.rarity = InsectRarity.Epic; + boss.primaryType = InsectElement.Earth; + guardianRegion = "forest"; + } + else boss = BattleVisualCapture.Fixture("rhinoceros_beetle", "숲의 수호자"); + boss.baseHp = 135; + var entity = new GameObject("QAOriginalBoss").AddComponent(); + entity.BuildForBattle(boss, 15, false); + // 수문장 표식은 BuildForBattle 뒤에 — 그쪽이 지운다. 표식이 있으면 인트로가 등장 컷 길이(BattleStaging.GuardianIntroSeconds)가 된다. + if (guardianRegion != null) entity.MarkAsGuardian(guardianRegion); + if (!controller.StartRaid(entity, team, levels, null, skills)) + { Debug.LogError("Raid fixture failed to start"); Application.Quit(3); yield break; } + var rise = GameObject.Find("BossRise"); + if (rise != null) + { + Renderer renderer = rise.GetComponent(); + Debug.Log($"QA BossRise: position={rise.transform.position}, scale={rise.transform.lossyScale}, bounds={renderer.bounds}, shader={renderer.sharedMaterial.shader.name}, active={renderer.enabled}"); + } + FieldInfo phaseField = typeof(RaidBattleUI).GetField("phase", flags); + FieldInfo bossHp = typeof(RaidBattleUI).GetField("displayBossHp", flags); + FieldInfo teamHp = typeof(RaidBattleUI).GetField("displayTeamHp", flags); + FieldInfo introTimer = typeof(RaidBattleUI).GetField("introTimer", flags); + MethodInfo auto = typeof(RaidBattleUI).GetMethod("TryAutoAll", flags); + MethodInfo uniteAttack = typeof(RaidBattleUI).GetMethod("TryUnite", flags); + PropertyInfo currentHp = typeof(InsectBattleStats).GetProperty("CurrentHp"); + bool uniteFired = false; + float start = Time.realtimeSinceStartup, next = 0f, readyAt = 0f, resultAt = -1f, firstReadyAt = -1f; + int frame = 0, transforms = 0; + bool bossAttackSeen = false, aoeSeen = false; + string previous = ""; + using (var csv = new StreamWriter(Path.Combine(output, "timeline.csv"))) + { + csv.WriteLine("frame,elapsed,phase,actualPlayerHp,actualEnemyHp,displayPlayerHp,displayEnemyHp,impactRevealed,phaseTimer,width,height,activeSlot,round,bossAoe,bossForm,transformProgress,introTimer,introProgress,resultShown,resultCanClose"); + while (Time.realtimeSinceStartup - start < 105f) + { + yield return new WaitForEndOfFrame(); + float time = Time.realtimeSinceStartup - start; + string phase = phaseField.GetValue(ui).ToString(); + bool changed = phase != previous; + if (changed && phase == "SelectSkill") + { + readyAt = time; + if (firstReadyAt < 0f) firstReadyAt = time; + } + if (changed && phase == "BossTransform") transforms++; + if (phase == "Result" && resultAt < 0f) resultAt = time; + bossAttackSeen |= phase == "BossAttack"; + aoeSeen |= controller.BossUsedAoe; + if (changed || time >= next) + { + Texture2D shot = ScreenCapture.CaptureScreenshotAsTexture(); + File.WriteAllBytes(Path.Combine(output, $"frame-{frame:D5}.jpg"), shot.EncodeToJPG(85)); + if (changed) File.WriteAllBytes(Path.Combine(output, $"state-{frame:D5}-{phase}.png"), shot.EncodeToPNG()); + UnityEngine.Object.Destroy(shot); + float[] displayed = (float[])teamHp.GetValue(ui); + csv.WriteLine(FormattableString.Invariant($"{frame},{time:F3},{phase},{controller.TeamStats.Sum(s => s.CurrentHp)},{controller.BossStats.CurrentHp},{(displayed == null ? 0f : displayed.Sum())},{bossHp.GetValue(ui)},,,{Screen.width},{Screen.height},{controller.ActiveSlot},{controller.TurnNumber},{controller.BossUsedAoe},{controller.BossFormIndex},{ui.BossTransformProgress:F3},{introTimer.GetValue(ui)},{ui.IntroProgress:F3},{ui.ResultShownSeconds:F2},{ui.ResultCanClose}")); + csv.Flush(); frame++; next = time + BattleVisualCapture.CaptureInterval; + } + previous = phase; + // 등장 컷만 보는 장면 — 첫 차례가 열리고(원래 구도로 돌아온 뒤) 1초를 더 찍고 끝낸다. + if (guardianIntro && firstReadyAt >= 0f && time - firstReadyAt >= 1.0f) break; + if (phase == "SelectSkill" && time - readyAt >= 0.6f) + { + // 변신 검수 — 다음 임계 바로 위로 HP를 맞춰 두면 첫 일격이 그 임계를 넘는다(피해량과 무관하게 결정적). + // 66% 위 → 첫 변신, 33% 위 → 둘째 변신, 둘 다 봤으면 4%로 깎아 결과까지 빨리 간다. + if (forms && controller.BossStats != null) + { + int max = controller.BossStats.MaxHp; + int target = controller.BossFormIndex == 0 ? Mathf.CeilToInt(max * 0.67f) + : controller.BossFormIndex == 1 ? Mathf.CeilToInt(max * 0.34f) + : Mathf.Max(1, Mathf.CeilToInt(max * 0.04f)); + if (controller.BossStats.CurrentHp > target) currentHp.SetValue(controller.BossStats, target); + } + if (unite && !uniteFired) + { + typeof(RaidBattleController).GetProperty("UniteGauge") + .SetValue(controller, RaidBattleController.UniteGaugeMax); + uniteAttack.Invoke(ui, null); + uniteFired = true; + } + else auto.Invoke(ui, null); + readyAt = time; + } + // 레이드 승리 연출(BattleFlourish.VictorySeconds 2초)이 끝까지 찍히게. + if (resultAt >= 0f && time - resultAt >= BattleFlourish.VictorySeconds + 0.4f) break; + } + } + File.WriteAllText(Path.Combine(output, "manifest.txt"), + $"Actual standalone IMGUI raid fixture\nScenario={scenario}\nGuardianRegion={guardianRegion ?? "-"}\nUnite={uniteFired}\nSeed=8173\nSize={Screen.width}x{Screen.height}\nFrames={frame}\nResultReached={resultAt >= 0f}\nBossAttackSeen={bossAttackSeen}\nAoeSeen={aoeSeen}\nBossTransforms={transforms}\nFinalBossForm={controller.BossFormIndex}\nIntroSeconds(firstSelectSkill)={(firstReadyAt >= 0f ? firstReadyAt.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) : "-")}\nNo audio/input hit-test/performance certification\n"); + bool ok = guardianIntro ? firstReadyAt >= 0f + : forms ? resultAt >= 0f && transforms >= 2 + : resultAt >= 0f && bossAttackSeen; + Application.Quit(ok ? 0 : 3); + } + } +} +#endif diff --git a/Assets/Scripts/Battle/RaidVisualCapture.cs.meta b/Assets/Scripts/Battle/RaidVisualCapture.cs.meta new file mode 100644 index 0000000..19c3fd0 --- /dev/null +++ b/Assets/Scripts/Battle/RaidVisualCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e83390bef4f12904dbe3e1f51ed30567 \ No newline at end of file diff --git a/Assets/Scripts/Battle/SandboxBattleRules.cs b/Assets/Scripts/Battle/SandboxBattleRules.cs new file mode 100644 index 0000000..3238db0 --- /dev/null +++ b/Assets/Scripts/Battle/SandboxBattleRules.cs @@ -0,0 +1,63 @@ +using UnityEngine; + +namespace InsectGame.Battle +{ + /// + /// 샌드박스 전투(「챔피언의 꿈」)의 순수 규칙 — 피해 배율과 길이. + /// + /// 이 전투는 처음 켠 사람에게 "이 게임의 전투가 이렇게 시원하다"를 보여 주는 자리라 두 가지를 보장한다. + /// ①반드시 이긴다(내 곤충은 쓰러지지 않는다) ②3~4턴 안에 끝난다(너무 길면 지루하고 한 턴에 + /// 끝나면 스킬을 눌러 볼 틈이 없다). 둘 다 확률이 아니라 규칙으로 지킨다 — 데미지 공식은 곤충 종·레벨에 따라 + /// 실제 전투 길이가 크게 달라서(balance.md 「전투 길이 3종」), 수치를 맞춰 놓고 믿으면 DB가 바뀔 때 깨진다. + /// + /// 실제 데미지 공식은 건드리지 않는다 — 공식이 낸 값에 배율과 상·하한만 건다. + /// + public static class SandboxBattleRules + { + /// 내 곤충이 주는 피해 배율 — 챔피언다운 시원한 숫자. + public const float PlayerDamageScale = 2.5f; + + /// 적이 주는 피해 배율 — 맞기는 하되 위협이 되지 않는다. + public const float EnemyDamageScale = 0.4f; + + /// 내 곤충 HP가 이 비율 아래로는 내려가지 않는다. + public const float PlayerHpFloorRatio = 0.30f; + + /// 이 번째 행동 전에는 적이 쓰러지지 않는다 — 스킬을 몇 개는 눌러 보게 한다. + public const int MinPlayerActions = 3; + + /// 이 번째 행동에서 반드시 끝난다. + public const int MaxPlayerActions = 4; + + /// + /// 내가 때릴 때의 최종 피해. + /// + /// 공식이 낸 피해(방어 비율까지 반영된 값). + /// 맞기 전 적 HP. + /// 이번이 내 몇 번째 행동인가(1부터). + public static int PlayerDamage(int damage, int enemyCurrentHp, int actionNumber) + { + int scaled = Mathf.Max(1, Mathf.RoundToInt(damage * PlayerDamageScale)); + if (enemyCurrentHp <= 0) return 0; + if (actionNumber >= MaxPlayerActions) return Mathf.Max(scaled, enemyCurrentHp); // 마지막 일격 + if (actionNumber < MinPlayerActions) return Mathf.Max(0, Mathf.Min(scaled, enemyCurrentHp - 1)); + return scaled; + } + + /// + /// 적이 때릴 때의 최종 피해 — 배율을 걸고, 내 HP가 하한 밑으로 내려가지 않게 자른다. + /// 0이면 아무 피해도 주지 않는다(호출부가 적용을 건너뛴다). + /// + public static int EnemyDamage(int damage, int playerCurrentHp, int playerMaxHp) + { + int scaled = Mathf.Max(1, Mathf.RoundToInt(damage * EnemyDamageScale)); + return Mathf.Clamp(playerCurrentHp - HpFloor(playerMaxHp), 0, scaled); + } + + /// 내 곤충 HP의 하한(최대 HP의 ). + public static int HpFloor(int playerMaxHp) + { + return Mathf.Max(1, Mathf.CeilToInt(playerMaxHp * PlayerHpFloorRatio)); + } + } +} diff --git a/Assets/Scripts/Battle/SandboxBattleRules.cs.meta b/Assets/Scripts/Battle/SandboxBattleRules.cs.meta new file mode 100644 index 0000000..f314c2d --- /dev/null +++ b/Assets/Scripts/Battle/SandboxBattleRules.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0d24dfdca13b69840b1385abdaa098de \ No newline at end of file diff --git a/Assets/Scripts/Battle/SignatureSkills.cs b/Assets/Scripts/Battle/SignatureSkills.cs new file mode 100644 index 0000000..01f4496 --- /dev/null +++ b/Assets/Scripts/Battle/SignatureSkills.cs @@ -0,0 +1,55 @@ +using InsectGame.Data; + +namespace InsectGame.Battle +{ + /// + /// 전용기(그 종만 쓰는 필살기) 판별 — 순수. 전용기 연출(visual-dev)·「전용기!」 문구(ui-dev)·레이드 보스의 기술 고르기가 + /// 같은 기준을 쓴다. + /// + /// 기준은 learnset의 표시다: 기술의 isSignatureSkill이 켜져 있고 그 곤충의 learnset에 그 기술이 있어야 한다. + /// 부트스트랩이 영웅 Lv15·전설 Lv20 칸에 {insectId}_signature를 넣는다(PlaySceneBootstrap.BuildLevelLearnset). + /// 훈련·디스크로는 전용기를 못 배운다(TrainingManager) — 그래서 "그 곤충의 learnset에 있다"가 곧 "그 곤충의 것"이다. + /// 기술은 ID로도 맞춘다 — 장착 기술(GetEquippedSkills)이 learnset과 다른 인스턴스로 올 수 있다. + /// + public static class SignatureSkills + { + /// + /// 이 의 전용기인가. 둘 중 하나라도 없으면 false. + /// 레이드 보스는 지금 모습(BossStats.CombatData)을 넘긴다 — 빌린 모습의 전용기는 그 모습의 것이다. + /// + public static bool IsSignature(InsectData owner, InsectSkill skill) + { + if (owner == null || skill == null || !skill.isSignatureSkill || owner.learnset == null) return false; + foreach (InsectLearnableSkill learnable in owner.learnset) + if (IsSignatureEntry(learnable) && SameSkill(learnable.skill, skill)) return true; + return false; + } + + /// learnset 한 칸이 전용기 칸인가(레벨 무관). + public static bool IsSignatureEntry(InsectLearnableSkill learnable) + { + return learnable != null && learnable.skill != null && learnable.skill.isSignatureSkill; + } + + /// learnset 한 칸이 에 이미 열린 전용기인가 — 레이드 보스의 기술 고르기가 쓴다. + public static bool IsUnlocked(InsectLearnableSkill learnable, int level) + { + return IsSignatureEntry(learnable) && learnable.learnLevel <= level; + } + + /// 의 첫 전용기(레벨 무관). 없으면 null(일반~희귀는 전용기가 없다). + public static InsectSkill FindSignature(InsectData owner) + { + if (owner == null || owner.learnset == null) return null; + foreach (InsectLearnableSkill learnable in owner.learnset) + if (IsSignatureEntry(learnable)) return learnable.skill; + return null; + } + + private static bool SameSkill(InsectSkill a, InsectSkill b) + { + if (ReferenceEquals(a, b)) return true; + return a != null && b != null && !string.IsNullOrEmpty(a.skillId) && a.skillId == b.skillId; + } + } +} diff --git a/Assets/Scripts/Battle/SignatureSkills.cs.meta b/Assets/Scripts/Battle/SignatureSkills.cs.meta new file mode 100644 index 0000000..845cccd --- /dev/null +++ b/Assets/Scripts/Battle/SignatureSkills.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 69570ad493b76f74c88afc3d67101d3b \ No newline at end of file diff --git a/Assets/Scripts/Capture/CaptureChanceCalculator.cs b/Assets/Scripts/Capture/CaptureChanceCalculator.cs index d03d3c0..868abdb 100644 --- a/Assets/Scripts/Capture/CaptureChanceCalculator.cs +++ b/Assets/Scripts/Capture/CaptureChanceCalculator.cs @@ -1,3 +1,4 @@ +using InsectGame.Core; using InsectGame.Data; using UnityEngine; @@ -76,10 +77,15 @@ internal static class CaptureChanceCalculator /// 의 MaximumLevelDelta와 /// 같은 값이다 — 같은 규칙의 두 구현이고, 전투 쪽에만 있었다. /// - /// 없을 때 무슨 일이 생겼나: 메인 필드는 리전이 아무리 높아도 **항상 Lv.1부터** 스폰한다 + /// 없을 때 무슨 일이 생겼나: 당시 메인 필드는 리전이 아무리 높아도 **항상 Lv.1부터** 스폰했다 /// (InsectSpawner의 지수 분포). 그래서 Lv.40 플레이어가 Lv.2 전설을 만나면 /// +38×0.02 = +0.76이 붙어 등급 페널티(0.32)와 난이도 페널티(0.40)를 통째로 상쇄했다. /// 미니게임이 주 포획 경로인데 고레벨 구간에서 등급·난이도 설계가 통째로 무력화됐다. + /// 지금은 필드 레벨이 리전 대역 안에 있지만(FieldSpawnRules.RollFieldLevel) 앞 리전으로 돌아가면 + /// 똑같이 수십 레벨 차가 난다 — 상한은 여전히 필요하다. + /// + /// 반대쪽(곤충이 높을 때)은 이 상한 너머를 가 + /// 이어받는다(GameConstants.TrainerLevel.CaptureGraceLevels가 이 값과 같다). /// internal const int MaximumLevelDelta = 5; @@ -112,7 +118,10 @@ internal static float Calculate( chance += tuning.PerfectTimingBonus; chance += Mathf.Max(0f, minigameBonus); - return Mathf.Clamp01(chance); + + // 레벨 차 제한은 **맨 마지막에 곱한다.** 위 덧셈 보정은 ±5에서 잘리고 최저 보장과 보너스가 + // 그 뒤에 얹혀서, 예전엔 20레벨 높은 전설도 퍼펙트 미니게임이면 34%였다(일반은 66%). + return Mathf.Clamp01(chance) * TrainerLevelGap.CaptureMultiplier(playerLevel, insectLevel); } /// diff --git a/Assets/Scripts/Capture/CaptureController.cs b/Assets/Scripts/Capture/CaptureController.cs index 5d687eb..08017d7 100644 --- a/Assets/Scripts/Capture/CaptureController.cs +++ b/Assets/Scripts/Capture/CaptureController.cs @@ -33,6 +33,22 @@ public class CaptureController : MonoBehaviour public event Action CaptureResolved; + /// + /// 직전 포획에서 실제로 지급한 EXP·캔디(부스터 포함, 실패면 0). 전에 채운다 — + /// 팝업이 공식을 다시 돌리면 지급 뒤 캐릭터 레벨로 레벨 차를 재서 지급값과 갈린다. + /// + public int LastExpReward { get; private set; } + public int LastCandyReward { get; private set; } + + /// + /// 직전 포획이 그 종의 첫 포획이었는가 — 팝업이 「NEW」를 붙인다. 도감 등록 전에 재야 한다: + /// 등록 뒤에 물으면 방금 올린 기록 때문에 늘 "이미 잡은 종"이다. + /// + public bool LastCaptureWasNewSpecies { get; private set; } + + /// 포획 공식이 쓰는 캐릭터 레벨 — 포획 선택 화면이 레벨 차 경고를 같은 값으로 판단한다. + public int TrainerLevel => playerProgress != null ? playerProgress.Level : 1; + public void AttemptCapture(InsectEntity target, float timing01, float extraBonus = 0f) { if (target == null || target.Data == null) @@ -40,6 +56,11 @@ public void AttemptCapture(InsectEntity target, float timing01, float extraBonus return; } + LastExpReward = 0; + LastCandyReward = 0; + LastCaptureWasNewSpecies = false; + // 확률과 EXP가 같은 레벨 차를 보도록 지급 전에 고정한다(GainXp가 레벨을 올린다). + int trainerLevel = TrainerLevel; float chance = CalculateSuccessChance(target.Data, target.Level, timing01, extraBonus); bool success = UnityEngine.Random.value <= chance; @@ -48,6 +69,8 @@ public void AttemptCapture(InsectEntity target, float timing01, float extraBonus dexController.RegisterEncounter(target.Data.insectId); if (success) { + LastCaptureWasNewSpecies = !dexController.TryGetRecord(target.Data.insectId, out Dex.DexRecord record) + || record == null || record.capturedCount <= 0; dexController.RegisterCapture(target.Data.insectId); } } @@ -56,10 +79,11 @@ public void AttemptCapture(InsectEntity target, float timing01, float extraBonus { if (playerProgress != null) { - int exp = InsectRewardCalculator.GetExpReward(target.Data); + int exp = InsectRewardCalculator.GetExpReward(target.Data, target.Level, trainerLevel); float expMultiplier = (itemEffects != null ? itemEffects.GetExpMultiplier() : 1f) * (outfitBonus != null ? outfitBonus.GetExpMultiplier() : 1f); - playerProgress.GainXp(Mathf.RoundToInt(exp * expMultiplier)); + LastExpReward = Mathf.RoundToInt(exp * expMultiplier); + playerProgress.GainXp(LastExpReward); } if (candyInventory != null) @@ -67,11 +91,15 @@ public void AttemptCapture(InsectEntity target, float timing01, float extraBonus int candy = InsectRewardCalculator.GetCandyReward(target.Data); float candyMultiplier = (itemEffects != null ? itemEffects.GetCandyMultiplier() : 1f) * (outfitBonus != null ? outfitBonus.GetCandyMultiplier() : 1f); - candyInventory.AddCandy(Mathf.RoundToInt(candy * candyMultiplier)); + LastCandyReward = Mathf.RoundToInt(candy * candyMultiplier); + candyInventory.AddCandy(LastCandyReward); } // 필드에서 본 이로치(색다른 곤충)를 그대로 저장 — 옛 2-인자 호출은 isShiny=false라 // 미니게임 포획 시 색다른 개체가 일반 개체로 유실됐음(배틀/레이드 경로는 정상 전달). - insectCollection?.AddCapturedInsect(target.Data.insectId, target.Level, target.IsShiny); + // 반환된 개체는 조건부 퀘스트(몸길이·이로치)가 실제 저장된 값을 보도록 통지에 넘긴다. + // 컬렉션이 없으면 null — 그래도 통지는 간다(CaptureFacts.From이 크기를 중간값으로 둔다). + PlayerInsectData captured = insectCollection?.AddCapturedInsect( + target.Data.insectId, target.Level, target.IsShiny); // **퀘스트 통지는 이벤트가 아니라 여기서 한다.** 예전엔 `CaptureFeedbackController` // (효과음·팝업을 담당하는 연출 컴포넌트) 안에 있었는데, 그건 `CaptureResolved`의 @@ -80,7 +108,7 @@ public void AttemptCapture(InsectEntity target, float timing01, float extraBonus // 포획 퀘스트 진행이 경고 한 줄만 남기고 영구 유실된다. // 진행에 필수인 통지는 연출과 같은 배를 타면 안 된다(`InsectBattleController`가 // 전투 경로에서 이미 같은 이유로 직접 부른다). - TutorialQuestManager.Instance?.NotifyCapture(target.Data.rarity); + TutorialQuestManager.Instance?.NotifyCapture(CaptureFacts.From(target.Data, captured, target.IsShiny)); } // **지급이 끝난 뒤에 알린다.** 예전엔 이 호출이 맨 앞이라, 팝업이 @@ -102,7 +130,7 @@ private float CalculateSuccessChance( float timing01, float minigameBonus) { - int playerLevel = playerProgress != null ? playerProgress.Level : 1; + int playerLevel = TrainerLevel; float activeItemBonus = itemEffects != null ? itemEffects.GetCaptureChanceBonus() : 0f; float equippedOutfitBonus = outfitBonus != null ? outfitBonus.GetCaptureChanceBonus() : 0f; CaptureChanceTuning tuning = new CaptureChanceTuning( diff --git a/Assets/Scripts/Capture/CaptureFeedbackController.cs b/Assets/Scripts/Capture/CaptureFeedbackController.cs index 72c2ba7..0f63297 100644 --- a/Assets/Scripts/Capture/CaptureFeedbackController.cs +++ b/Assets/Scripts/Capture/CaptureFeedbackController.cs @@ -128,7 +128,7 @@ private void PlayEffect(ParticleSystem effect) private static string NameOf(InsectEntity target) => target != null && target.Data != null && !string.IsNullOrEmpty(target.Data.displayName) - ? target.Data.displayName : "이름 없는 것"; + ? target.Data.displayName : "이름 모를 곤충"; private void ShowPopup(string message) { diff --git a/Assets/Scripts/Capture/CaptureInputController.cs b/Assets/Scripts/Capture/CaptureInputController.cs index 30d99af..94eb065 100644 --- a/Assets/Scripts/Capture/CaptureInputController.cs +++ b/Assets/Scripts/Capture/CaptureInputController.cs @@ -25,6 +25,11 @@ public class CaptureInputController : MonoBehaviour [Range(0f, 0.25f)] [SerializeField] private float distancePenaltyPerMeter = 0.07f; [SerializeField] private float idealApproachDistance = 1.2f; + [Header("Ambush")] + // 습격 판정에 쓰는 시간·날씨(플레이어가 있는 리전 기준)와 서브에리어 여부. AutoWire(WorldStateProvider, RegionManager)로 받는다. + [SerializeField] private WorldStateProvider worldStateProvider; + [SerializeField] private RegionManager regionManager; + private InsectEntity nearestInsect; private float nearCheckTimer; private float attemptCooldown; @@ -48,8 +53,53 @@ public class CaptureInputController : MonoBehaviour /// public bool HasCatchTarget => nearestInsect != null && nearestInsect.Data != null; + // ── 습격(AmbushRules) ── + // 습격형 곤충이 "지금 덤벼들어도 되나"를 묻는 판정(InsectEntity.AmbushGate)을 여기서 세우고, 닿으면 「습격!」 창을 연다. + // 판정의 전역 칸(시간·날씨·꿈·서브에리어·멈춤·창·교전·쿨다운·배틀팀)은 프레임마다 한 번만 채우고 곤충마다 개체 칸만 바꾼다. + private System.Func ambushGate; // 한 번만 묶는다 — OnEnable마다 새 대리자를 만들지 않게 + private bool ambushRefsSearched; + private int ambushFrame = -1; + private AmbushRules.Context ambushBase; + private bool ambushStateKnown; + private int ambushTeamFilled; + private int ambushTeamReady; + // 교전(선택 창·미니게임·전투·레이드)이 끝난 시각 — 전역 쿨다운의 기준. 습격으로 시작한 교전이면 습격 쿨다운도 함께 건다. + private int encounterTrackFrame = -1; + private bool inEncounterLastFrame; + private bool encounterIsAmbush; + private float lastEncounterEndTime; + private float lastAmbushEndTime = float.NegativeInfinity; + private bool feedbackIsWarn; // 현재 피드백이 습격 경고인지(스타일 분기) + private GUIStyle warnStyle; + + private void OnEnable() + { + // 구독은 여기서 걸고 OnDisable에서 푼다 — 꺼졌다 켜져도 되살아난다(subscription_lint). + if (ambushGate == null) ambushGate = EvaluateAmbushGate; + InsectEntity.AmbushGate = ambushGate; + InsectEntity.AmbushStarted -= OnAmbushStarted; + InsectEntity.AmbushStarted += OnAmbushStarted; + InsectEntity.AmbushReached -= OnAmbushReached; + InsectEntity.AmbushReached += OnAmbushReached; + } + + private void OnDisable() + { + // 판정은 static이라 씬을 다시 읽어도 남는다 — 파기된 이 컴포넌트로 들어가지 않게 내 것이면 비운다(null이면 습격이 없다). + if (InsectEntity.AmbushGate == ambushGate) InsectEntity.AmbushGate = null; + InsectEntity.AmbushStarted -= OnAmbushStarted; + InsectEntity.AmbushReached -= OnAmbushReached; + } + + private void Start() + { + // 씬이 막 열렸을 때도 숨 돌릴 틈을 둔다 — 로드 직후 곁의 사마귀가 바로 덮치지 않게(AfterEncounterGraceSeconds). + lastEncounterEndTime = Time.time; + } + private void Update() { + TrackEncounter(); if (attemptCooldown > 0f) attemptCooldown -= Time.deltaTime; if (feedbackTimer > 0f) feedbackTimer -= Time.deltaTime; if (swingTimer > 0f) swingTimer -= Time.deltaTime; @@ -82,8 +132,9 @@ private void Update() if (Input.GetKeyDown(KeyCode.Escape)) { + // Hide가 아니라 CloseModal — 습격 창에서 ESC는 「닫기」가 아니라 도망치기다(그냥 닫으면 도망 판정을 우회한다). if (choiceUi != null && choiceUi.IsChoiceOpen) - choiceUi.Hide(); + choiceUi.CloseModal(); else if (minigame != null && minigame.IsActive) minigame.CancelCapture(); } @@ -111,7 +162,7 @@ private void OnGUI() if (evt.keyCode == KeyCode.Escape) { if (choiceUi != null && choiceUi.IsChoiceOpen) - choiceUi.Hide(); + choiceUi.CloseModal(); // Update와 같은 이유 — 습격 창의 ESC는 도망치기다 else if (minigame != null && minigame.IsActive) minigame.CancelCapture(); evt.Use(); @@ -133,15 +184,13 @@ private void DrawCatchButton() { bool near = nearestInsect != null && nearestInsect.Data != null; - float vw = UIScale.VirtualScreenWidth; - float safeR = UIScale.VirtualSafeRight; - float radius = 96f; - // '계정' 버튼(AccountSettingsUI)은 raw 픽셀로 하단 62px에 고정 — 스케일이 작을수록 가상좌표와 - // 어긋나 겹친다. 가상 여백을 1/Scale로 환산해 화면 스케일과 무관하게 항상 그 위로 띄운다. - float accountClear = 92f / UIScale.Scale; - float cx = vw - safeR - radius - 40f; - float cy = UISafeLayout.ContentBottom - radius - accountClear; // 우하단 '계정' 버튼 위(스케일 보정) - Rect rect = new Rect(cx - radius, cy - radius, radius * 2f, radius * 2f); + // 자리는 CatchButtonLayout(순수 계산) — 전수 겹침 검사(HudOverlapSweepTests)와 같은 함수다. + HudFrame frame = HudFrame.Current; + float radius = CatchButtonLayout.Radius; + Vector2 center = CatchButtonLayout.Center(frame); + float cx = center.x; + float cy = center.y; + Rect rect = CatchButtonLayout.ButtonRect(frame); catchButtonRect = rect; // 멀티터치 raw 히트테스트 + 클릭-이동 억제용으로 공유 // PlayerMovement 클릭-이동이 이 버튼 위 탭을 월드 클릭으로 오인하지 않게 등록. FieldHudInput.RegisterBlockingRect(rect); @@ -155,7 +204,8 @@ private void DrawCatchButton() if (swingTimer > 0f) { float t = 1f - Mathf.Clamp01(swingTimer / 0.35f); // 0→1 - float er = radius * (1f + t * 0.85f); + // 이웃(상호작용 버튼·설정 버튼·피드백 글자)에 닿지 않는 반지름까지만 퍼진다(CatchButtonLayout.SwingRadius). + float er = Mathf.Lerp(radius, CatchButtonLayout.SwingRadius(frame), t); GUI.color = new Color(baseCol.r, baseCol.g, baseCol.b, (1f - t) * 0.65f); GUI.DrawTexture(new Rect(cx - er, cy - er, er * 2f, er * 2f), circleRingTex); } @@ -174,9 +224,19 @@ private void DrawCatchButton() // 피드백(버튼 위) — 미스는 크고 붉게, 그 외는 일반 안내. if (feedbackTimer > 0f && !string.IsNullOrEmpty(feedbackMessage)) { - GUIStyle fs = feedbackIsMiss ? missStyle : feedbackStyle; - float fh = feedbackIsMiss ? 72f : 52f; - GUI.Label(new Rect(cx - 260f, cy - radius - fh - 16f, 520f, fh), feedbackMessage, fs); + if (feedbackIsWarn) + { + // 습격 경고 — 곤충 이름이 길이를 정하므로 고정 상자에 LabelFit(ui-layout.md). 화면 오른쪽 밖으로 밀리지 않게 가둔다. + // 글자만 그린다(패널 없음) — 쫓기는 동안 이 자리를 탭해 달아날 수 있어야 해서 클릭-이동을 막지 않는다. + // 폭 520 — 가로 모바일에서 화면 가운데 줄의 섬 안내 배너(폭 760, IslandGuideUI.CoachRect)와 36px 띄운다. + // 560일 땐 Scale 0.667에서 배너 오른쪽 모서리와 4px 겹쳤다. + UIHelper.LabelFit(CatchButtonLayout.WarnRect(frame), feedbackMessage, warnStyle); + } + else + { + GUIStyle fs = feedbackIsMiss ? missStyle : feedbackStyle; + GUI.Label(CatchButtonLayout.FeedbackRect(frame, feedbackIsMiss), feedbackMessage, fs); + } } // 입력(투명 히트영역) — 데스크탑(마우스)은 GUI.Button으로 처리. 터치 기기는 Update의 raw @@ -286,6 +346,135 @@ private void ShowFeedback(string message, bool isMiss = false) feedbackMessage = message; feedbackTimer = 2.2f; feedbackIsMiss = isMiss; + feedbackIsWarn = false; + } + + /// 습격 경고 — 다가오는 동안(멈칫 끝 → 닿기까지 최대 수 초) 보이도록 보통 안내보다 조금 오래 둔다. + private void ShowWarning(string message) + { + feedbackMessage = message; + feedbackTimer = 3f; + feedbackIsMiss = false; + feedbackIsWarn = true; + } + + // ── 습격 판정·창 ───────────────────────────────────────────────── + + /// + /// 의 본체 — 이 곤충이 지금 덤벼들어도 되는가(막혔다면 까닭). 규칙은 . + /// 시간·날씨를 모르면(월드 상태 미배선) 덤벼들지 않는다 — 기본값(아침·맑음)으로 판정하면 주행성 말벌이 엉뚱한 때 깬다. + /// + private AmbushRefusal EvaluateAmbushGate(InsectEntity e) + { + if (e == null || e.Data == null) return AmbushRefusal.NotAwake; + RefreshAmbushBase(); + if (!ambushStateKnown) return AmbushRefusal.NotAwake; + + AmbushRules.Context c = ambushBase; + c.Habit = InsectGame.Data.InsectHabits.For(e.Data); + c.IsGuardian = e.IsGuardian; + c.IsEngaged = e.IsEngaged; + c.EntityCooldownLeft = e.AmbushCooldownLeft; + c.CanFight = AmbushRules.CanFight(CaptureChoiceUI.IsRaidRarity(e.Data.rarity), ambushTeamFilled, ambushTeamReady); + return AmbushRules.Check(c); + } + + /// 판정의 전역 칸을 프레임마다 한 번 채운다 — 다가오는 곤충이 여럿이어도 같은 값을 읽는다. + private void RefreshAmbushBase() + { + if (ambushFrame == Time.frameCount) return; + ambushFrame = Time.frameCount; + EnsureAmbushRefs(); + TrackEncounter(); + + ambushStateKnown = worldStateProvider != null; + float now = Time.time; + ambushBase = new AmbushRules.Context + { + // 플레이어가 있는 리전에서 보이는 날씨(설산의 눈·사막의 센바람) — 스폰과 같은 기준(rules/world-environment.md). + State = ambushStateKnown ? worldStateProvider.GetWorldState(CurrentRegionId()) : default(WorldState), + SinceLastAmbushEnded = now - lastAmbushEndTime, + SinceLastEncounterEnded = now - lastEncounterEndTime, + PlayerInEncounter = IsInEncounter(), + DreamActive = DreamPrologueState.Active, + InSubArea = regionManager != null && regionManager.CurrentSubArea != null, + PlayerFrozen = IsPlayerFrozen(), + ModalOpen = ModalUIRegistry.IsAnyOpen() + }; + + ambushTeamFilled = 0; + ambushTeamReady = 0; + if (choiceUi != null) choiceUi.GetFightReadiness(out ambushTeamFilled, out ambushTeamReady); + } + + private string CurrentRegionId() + { + if (regionManager == null) return null; + InsectGame.Data.RegionData region = regionManager.CurrentRegion; + return region != null ? region.regionId : null; + } + + /// 배선이 빠졌을 때 한 번만 찾는다(AutoWire가 우선 — FindFirstObjectByType는 폴백). + private void EnsureAmbushRefs() + { + if (ambushRefsSearched) return; + ambushRefsSearched = true; + if (worldStateProvider == null) worldStateProvider = FindFirstObjectByType(); + if (regionManager == null) regionManager = FindFirstObjectByType(); + } + + /// 플레이어가 교전(선택 창·미니게임·전투·레이드) 중인가. + private bool IsInEncounter() + { + return (choiceUi != null && choiceUi.IsChoiceOpen) + || (minigame != null && minigame.IsActive) + || (battleScreen != null && battleScreen.IsBattleActive) + || (raidScreen != null && raidScreen.IsRaidActive); + } + + /// + /// 교전이 끝난 순간을 적는다(프레임마다 한 번) — 전역 쿨다운의 기준. 습격 창 → 전투로 이어지는 동안은 한 교전이다 + /// (창을 닫고 전투를 여는 것이 한 호출 안에서 일어나 그 사이에 끊기지 않는다). + /// + private void TrackEncounter() + { + if (encounterTrackFrame == Time.frameCount) return; + encounterTrackFrame = Time.frameCount; + bool inEncounter = IsInEncounter(); + if (inEncounterLastFrame && !inEncounter) + { + lastEncounterEndTime = Time.time; + if (encounterIsAmbush) lastAmbushEndTime = Time.time; + encounterIsAmbush = false; + } + inEncounterLastFrame = inEncounter; + } + + /// 습격형이 멈칫을 끝내고 다가오기 시작했다 — 잡기 버튼 위에 경고를 띄운다(보고 달아날 틈). + private void OnAmbushStarted(InsectEntity e) + { + if (e == null || e.Data == null) return; + ShowWarning($"{e.DisplayNameForPlayer} Lv.{e.Level} — 덤벼든다! 달아나면 떼어 낼 수 있다"); + } + + /// + /// 습격형이 닿았다 — 판정을 한 번 더 묻고(다가오는 사이 다른 창이 열렸거나 쿨다운이 시작됐을 수 있다) 「습격!」 창을 연다. + /// 열지 않으면 곤충이 스스로 물러난다(InsectEntity.ReachPlayer). + /// + private void OnAmbushReached(InsectEntity e) + { + if (e == null || e.Data == null || choiceUi == null) return; + if (EvaluateAmbushGate(e) != AmbushRefusal.None) return; + + string reason = AmbushRules.ReasonLine(InsectGame.Data.InsectHabits.For(e.Data), ambushBase.State); + if (!choiceUi.ShowAmbush(e, reason)) return; + + // 이 창에서 시작하는 교전(창 → 전투·레이드)이 끝나면 습격 쿨다운이 걸린다. + encounterIsAmbush = true; + inEncounterLastFrame = true; + feedbackTimer = 0f; // "덤벼든다!" 경고는 창이 대신한다 + PlayerMovement pm = GetPlayerMovement(); + if (pm != null) pm.FaceTowards(e.transform.position); } private void EnsureStyles() @@ -329,6 +518,16 @@ private void EnsureStyles() richText = true }; missStyle.normal.textColor = new Color(1f, 0.32f, 0.3f); + + // 습격 경고 — 경고색 토큰(새 색을 만들지 않는다). 두 줄까지 들어가는 상자에 LabelFit으로 그린다. + warnStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 30, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter, + wordWrap = true + }; + warnStyle.normal.textColor = UITheme.Instance.accentCoral; } private void EnsureCircleTex() @@ -391,5 +590,95 @@ public void AutoWire(BattleScreenUI battle, RaidBattleUI raid, DexScreenUI dex) if (raidScreen == null) raidScreen = raid; if (dexScreen == null) dexScreen = dex; } + + /// + /// 습격 판정의 시간·날씨(플레이어가 있는 리전 기준)와 서브에리어 여부. 빠지면 처음 판정 때 한 번 찾아보고, 그래도 없으면 + /// 습격이 일어나지 않는다(곤충이 예전처럼 달아나기만 한다). + /// + public void AutoWire(WorldStateProvider worldState, RegionManager regions) + { + if (worldStateProvider == null) worldStateProvider = worldState; + if (regionManager == null) regionManager = regions; + } + } + + /// + /// 우하단 잡기 버튼과 그 위 글자(놓침·습격 경고), 탭 고리의 자리 — 순수 계산. 그리기()와 + /// 전수 겹침 검사(HudOverlapSweepTests)가 같은 함수를 부른다. 상호작용 버튼(WorldInteractionController)· + /// 가운데 무대(HudStage)·동굴 입구 버튼·꿈 안내 카드가 이 값을 기준으로 비켜 선다. + /// + public static class CatchButtonLayout + { + public const float Radius = 96f; + /// 오른쪽 안전 가장자리와 버튼 사이. + public const float RightGap = 40f; + /// + /// 아래 '설정' 버튼(AccountSettingsUI)을 피하는 여백 — 픽셀 92를 가상으로 환산한다(스케일이 작을수록 가상 여백이 크다). + /// 예전 계정 버튼이 픽셀 좌표로 하단에 고정돼 있던 때의 보정이다. + /// + public const float AccountClearPixels = 92f; + /// 잡기 버튼과 그 왼쪽 상호작용 버튼 사이(WorldInteractionController가 같은 값을 쓴다). + public const float NeighborGap = 36f; + public const float LabelWidth = 520f; + public const float LabelGap = 16f; + public const float WarnHeight = 96f; + public const float MissHeight = 72f; + public const float NoteHeight = 52f; + /// 탭 고리가 이웃이 없을 때 퍼질 수 있는 배율. + public const float SwingMaxScale = 1.85f; + + public static Vector2 Center(HudFrame f) + { + return new Vector2(f.Width - f.SafeRight - Radius - RightGap, + f.ContentBottom - Radius - AccountClearPixels / f.Scale); + } + + public static Rect ButtonRect(HudFrame f) + { + Vector2 c = Center(f); + return new Rect(c.x - Radius, c.y - Radius, Radius * 2f, Radius * 2f); + } + + // 글자 칸의 왼쪽 끝 — 버튼 가운데에 맞추되 화면 오른쪽 안전 가장자리(24) 밖으로 밀리지 않게 가둔다. + // (놓침 글자는 예전에 가두지 않아 오른쪽 180px가 화면 밖이었다.) + private static float LabelX(HudFrame f) + { + return Mathf.Min(Center(f).x - LabelWidth * 0.5f, f.Width - f.SafeRight - 24f - LabelWidth); + } + + /// 습격 경고 — 글자만(패널 없음). 곤충 이름이 길이를 정하므로 그리는 쪽이 LabelFit으로 맞춘다. + public static Rect WarnRect(HudFrame f) + { + return new Rect(LabelX(f), Center(f).y - Radius - WarnHeight - LabelGap, LabelWidth, WarnHeight); + } + + /// 잡기 결과 글자 — 놓쳤으면 크게(72), 그 밖의 안내는 52. + public static Rect FeedbackRect(HudFrame f, bool miss) + { + float h = miss ? MissHeight : NoteHeight; + return new Rect(LabelX(f), Center(f).y - Radius - h - LabelGap, LabelWidth, h); + } + + /// + /// 탭 고리가 퍼지는 최대 반지름 — 이웃에 닿지 않게 가둔다. 왼쪽 상호작용 버튼까지 Radius + NeighborGap, + /// 위 글자 칸까지 Radius + LabelGap, 아래 설정 버튼까지 Radius + 92/Scale − 46. 버튼보다 작아지면 버튼 크기다. + /// 예전엔 늘 1.85배(178)까지 퍼져 상호작용 버튼·설정 버튼·글자를 덮었다. + /// + public static float SwingRadius(HudFrame f) + { + float toInteract = Radius + NeighborGap - 2f; + float toLabel = Radius + LabelGap - 2f; + float toSettings = Radius + AccountClearPixels / f.Scale - AccountSettingsUI.OpenButtonHeight - 2f; + float r = Mathf.Min(Radius * SwingMaxScale, Mathf.Min(toInteract, Mathf.Min(toLabel, toSettings))); + return Mathf.Max(Radius, r); + } + + /// 탭 고리가 가장 크게 퍼졌을 때의 자리. + public static Rect SwingRect(HudFrame f) + { + Vector2 c = Center(f); + float r = SwingRadius(f); + return new Rect(c.x - r, c.y - r, r * 2f, r * 2f); + } } } diff --git a/Assets/Scripts/Capture/CaptureMinigame.cs b/Assets/Scripts/Capture/CaptureMinigame.cs new file mode 100644 index 0000000..7debd21 --- /dev/null +++ b/Assets/Scripts/Capture/CaptureMinigame.cs @@ -0,0 +1,442 @@ +using UnityEngine; + +namespace InsectGame.Capture +{ + /// 포획 미니게임 종류. 포획마다 하나가 무작위로 걸린다(). + public enum CaptureMinigameKind + { + /// 살금살금 — 누르고 있으면 다가가고, 곤충이 돌아보면 손을 뗀다. + Sneak, + /// 가두기 — 날아다니는 곤충을 그물 원 안에 붙잡아 게이지를 채운다. + Track, + /// 던지기 — 그물이 날아가는 시간이 있어 곤충이 갈 곳에 던진다. + Toss, + } + + /// + /// 한 판의 조건 — 곤충 등급과 채집망 보정. 채집망의 세 배율은 예전 타이밍 바 시절 그대로 온다 + /// (CaptureItemData): 속도는 "곤충이 얼마나 재빠른가", 구간은 "판정이 얼마나 넉넉한가", + /// 시간은 제한 시간이다. 세 게임이 같은 뜻으로 읽어야 은빛·황금 채집망이 어느 게임에서도 값을 한다. + /// + public readonly struct CaptureMinigameTuning + { + public readonly int Rarity; + public readonly float SpeedMult; + public readonly float ZoneMult; + public readonly float TimeMult; + + public CaptureMinigameTuning(int rarity, float speedMult, float zoneMult, float timeMult) + { + Rarity = Mathf.Clamp(rarity, 0, 4); + // 0 이하 배율은 "설정 안 함"으로 읽는다 — 나눗셈·제한 시간이 0이 되어 판이 시작하자마자 끝난다. + SpeedMult = speedMult > 0.01f ? speedMult : 1f; + ZoneMult = zoneMult > 0.01f ? zoneMult : 1f; + TimeMult = timeMult > 0.01f ? timeMult : 1f; + } + } + + /// 한 프레임의 조작. 는 놀이판 좌표( 기준). + public struct CaptureMinigameInput + { + public bool Down; + public bool Pressed; + public Vector2 Point; + } + + /// + /// 포획 미니게임 한 판의 순수 규칙. 그리기·입력 수집·포획 판정은 CaptureMinigameController가 하고, + /// 여기는 "무엇이 맞았는가"만 정한다 — 화면(IMGUI)은 테스트로 못 보므로 규칙을 떼어 고정한다. + /// + /// 결과는 셋 다 0~3이다. 그 값이 CaptureMinigameProbability로 들어가므로 + /// 게임을 바꿔도 포획 공식은 그대로다. + /// + public abstract class CaptureMinigame + { + /// 놀이판의 논리 크기. 화면에서는 이 비율로 늘여 그린다. + public const float BoardWidth = 360f; + public const float BoardHeight = 280f; + public const int MaxHits = 3; + + private readonly System.Random rng; + + protected CaptureMinigame(System.Random random, float timeLimit) + { + rng = random ?? new System.Random(); + TimeLimit = Mathf.Max(1f, timeLimit); + TimeLeft = TimeLimit; + } + + public abstract CaptureMinigameKind Kind { get; } + public int Hits { get; protected set; } + public bool Done { get; protected set; } + public float TimeLimit { get; } + public float TimeLeft { get; protected set; } + public float TimeRatio => Mathf.Clamp01(TimeLeft / TimeLimit); + + public abstract void Tick(float dt, CaptureMinigameInput input); + + protected float Range(float min, float max) => min + (float)rng.NextDouble() * (max - min); + protected bool Chance(float probability) => rng.NextDouble() < probability; + + /// 제한 시간을 깎는다. 다 됐으면 true — 그 시점까지의 로 끝난다. + protected bool RunOutOfTime(float dt) + { + TimeLeft -= dt; + if (TimeLeft > 0f) return false; + TimeLeft = 0f; + Done = true; + return true; + } + + public static CaptureMinigame Create(CaptureMinigameKind kind, CaptureMinigameTuning tuning, System.Random random) + { + switch (kind) + { + case CaptureMinigameKind.Track: return new TrackMinigame(tuning, random); + case CaptureMinigameKind.Toss: return new TossMinigame(tuning, random); + default: return new SneakMinigame(tuning, random); + } + } + + /// + /// 다음 판의 게임. 무작위지만 직전과 같은 게임은 연달아 주지 않는다 — 균등 추첨이면 + /// 세 번에 한 번은 같은 게임이 반복돼 "랜덤인데 또 이거"가 된다. + /// + public static CaptureMinigameKind PickNext(CaptureMinigameKind? last, System.Random random) + { + const int count = 3; + if (random == null) random = new System.Random(); + if (!last.HasValue) return (CaptureMinigameKind)random.Next(count); + return (CaptureMinigameKind)(((int)last.Value + 1 + random.Next(count - 1)) % count); + } + } + + /// + /// 살금살금. 누르고 있으면 다가간다. 곤충은 더듬이를 떨어 예고한 뒤 돌아보고, 그때 움직이면 들킨다. + /// 세 구간을 들키지 않고 지날 때마다 1점이다. 두 번 들키면 달아난다. + /// + public sealed class SneakMinigame : CaptureMinigame + { + public enum Watch { Calm, Twitch, Look } + + public const int MaxSpots = 2; + public const float FirstFlag = 2f / 3f; + public const float SecondFlag = 1f / 3f; + private const float SpotFreezeSeconds = 0.7f; + private const float SpotPushBack = 0.07f; + private const float SwoopSeconds = 0.45f; + private const float FleeSeconds = 0.7f; + + private readonly float creepPerSecond; + private readonly float telegraphSeconds; + private readonly float calmScale; + private readonly float lookBonus; + + private bool clean = true; + // 판이 열릴 때 이미 누르고 있었으면(채집망을 고른 그 손가락) 한 번 떼야 움직인다. + private bool needRelease = true; + private float nervous = 1f; + private float stateLeft; + private float swoopTime = -1f; + private float fleeTime; + + public SneakMinigame(CaptureMinigameTuning tuning, System.Random random) + // 등급이 오르면 걸음이 느려지고 돌아보는 시간이 길어진다 — 그만큼 제한 시간도 늘려야 + // 한 번도 안 들키고도 시간이 모자라 끝나는 판이 안 생긴다. + : base(random, (14f + tuning.Rarity * 1.5f) * tuning.TimeMult) + { + creepPerSecond = 0.30f - tuning.Rarity * 0.03f; + telegraphSeconds = Mathf.Max(0.28f, 0.55f - tuning.Rarity * 0.05f) * tuning.ZoneMult; + calmScale = (1f - tuning.Rarity * 0.07f) / tuning.SpeedMult; + lookBonus = tuning.Rarity * 0.06f; + State = Watch.Calm; + stateLeft = Range(1.2f, 2.2f) * calmScale; + } + + public override CaptureMinigameKind Kind => CaptureMinigameKind.Sneak; + + /// 곤충까지 남은 거리. 1에서 출발해 0이면 덮친다. + public float Distance { get; private set; } = 1f; + public Watch State { get; private set; } + public int Spots { get; private set; } + public bool Fled { get; private set; } + /// 방금 들켜 굳어 있는 시간(초). 0보다 크면 조작을 받지 않는다. + public float FreezeLeft { get; private set; } + /// 덮치는 연출 진행(0~1). 덮치기 전이면 음수. + public float Swoop01 => swoopTime < 0f ? -1f : Mathf.Clamp01(swoopTime / SwoopSeconds); + public float Flee01 => Mathf.Clamp01(fleeTime / FleeSeconds); + + public override void Tick(float dt, CaptureMinigameInput input) + { + if (Done || dt <= 0f) return; + + if (swoopTime >= 0f) + { + swoopTime += dt; + if (swoopTime >= SwoopSeconds) Done = true; + return; + } + if (Fled) + { + fleeTime += dt; + if (fleeTime >= FleeSeconds) Done = true; + return; + } + if (RunOutOfTime(dt)) return; + + stateLeft -= dt; + if (stateLeft <= 0f) AdvanceWatch(); + + if (FreezeLeft > 0f) { FreezeLeft -= dt; return; } + if (!input.Down) { needRelease = false; return; } + if (needRelease) return; + + if (State == Watch.Look) { GetSpotted(); return; } + + float before = Distance; + Distance -= creepPerSecond * dt; + CrossFlag(before, FirstFlag); + CrossFlag(before, SecondFlag); + if (Distance <= 0f) + { + Distance = 0f; + if (clean) Hits = Mathf.Min(MaxHits, Hits + 1); + swoopTime = 0f; + } + } + + private void CrossFlag(float before, float flag) + { + if (before <= flag || Distance > flag) return; + if (clean) Hits = Mathf.Min(MaxHits, Hits + 1); + clean = true; + } + + private void AdvanceWatch() + { + switch (State) + { + case Watch.Calm: + State = Watch.Twitch; + stateLeft = telegraphSeconds; + break; + case Watch.Twitch: + State = Watch.Look; + stateLeft = Range(0.7f, 1.2f) + lookBonus; + break; + default: + State = Watch.Calm; + stateLeft = Range(1.3f, 2.6f) * nervous * calmScale; + break; + } + } + + private void GetSpotted() + { + Spots++; + clean = false; + nervous *= 0.75f; + FreezeLeft = SpotFreezeSeconds; + needRelease = true; + // 밀려나되 **그 구간 안에서만**. 깃발을 되넘으면 다시 지날 때 구간이 새로 깨끗해져 벌점이 사라진다. + float top = Distance > FirstFlag ? 1f : (Distance > SecondFlag ? FirstFlag : SecondFlag); + Distance = Mathf.Min(top - 0.005f, Distance + SpotPushBack); + if (Spots >= MaxSpots) Fled = true; + } + } + + /// + /// 가두기. 곤충이 놀이판을 날아다니고, 누른 채 끄는 그물 원 안에 곤충이 있는 동안 게이지가 찬다. + /// 게이지의 3분의 1마다 1점이고, 한 번 얻은 점수는 놓쳐도 깎이지 않는다. + /// + public sealed class TrackMinigame : CaptureMinigame + { + private const float Margin = 34f; + private const float DrainPerSecond = 0.12f; + private const float NetFollow = 14f; + + private readonly float speed; + private readonly float fillSeconds; + private readonly float retargetScale; + private readonly float dartChance; + + private Vector2 target; + private float retargetLeft; + private float dartLeft; + + public TrackMinigame(CaptureMinigameTuning tuning, System.Random random) + : base(random, 9f * tuning.TimeMult) + { + speed = (105f + tuning.Rarity * 26f) * tuning.SpeedMult; + NetRadius = (48f - tuning.Rarity * 4f) * tuning.ZoneMult; + fillSeconds = 1.8f + tuning.Rarity * 0.25f; + retargetScale = 1f - tuning.Rarity * 0.08f; + dartChance = 0.3f + tuning.Rarity * 0.08f; + Insect = new Vector2(BoardWidth * 0.5f, BoardHeight * 0.4f); + Net = new Vector2(BoardWidth * 0.5f, BoardHeight * 0.8f); + target = Insect; + } + + public override CaptureMinigameKind Kind => CaptureMinigameKind.Track; + + public Vector2 Insect { get; private set; } + public Vector2 Net { get; private set; } + public float NetRadius { get; } + public float Gauge { get; private set; } + public bool Inside { get; private set; } + + public override void Tick(float dt, CaptureMinigameInput input) + { + if (Done || dt <= 0f) return; + if (RunOutOfTime(dt)) return; + + retargetLeft -= dt; + if (retargetLeft <= 0f) + { + retargetLeft = Range(0.45f, 1.1f) * retargetScale; + target = new Vector2(Range(Margin, BoardWidth - Margin), Range(Margin, BoardHeight - Margin)); + if (Chance(dartChance)) dartLeft = 0.25f; + } + dartLeft -= dt; + + Vector2 delta = target - Insect; + float distance = delta.magnitude; + if (distance > 4f) + { + float step = speed * (dartLeft > 0f ? 2.3f : 1f) * dt; + Insect += delta / distance * Mathf.Min(step, distance); + } + + if (input.Down) + { + Vector2 goal = new Vector2( + Mathf.Clamp(input.Point.x, 0f, BoardWidth), Mathf.Clamp(input.Point.y, 0f, BoardHeight)); + Net = Vector2.Lerp(Net, goal, Mathf.Min(1f, dt * NetFollow)); + } + + // 누르고 있을 때만 센다 — 놓아둔 그물로 곤충이 제 발로 들어와 별이 생기면 안 된다. + Inside = input.Down && (Insect - Net).sqrMagnitude <= NetRadius * NetRadius; + Gauge = Mathf.Clamp01(Gauge + (Inside ? dt / fillSeconds : -dt * DrainPerSecond)); + Hits = Mathf.Max(Hits, HitsForGauge(Gauge)); + if (Gauge >= 1f) Done = true; + } + + public static int HitsForGauge(float gauge) + { + if (gauge >= 1f) return 3; + if (gauge >= 2f / 3f) return 2; + return gauge >= 1f / 3f ? 1 : 0; + } + } + + /// + /// 던지기. 누른 자리로 그물이 날아가 뒤에 떨어진다. 그 순간 곤충이 그물 원 안이면 + /// 1점. 그물은 세 개다. 곤충이 지금 있는 곳이 아니라 갈 곳에 던져야 맞는다. + /// + public sealed class TossMinigame : CaptureMinigame + { + public const int Throws = 3; + public const float FlightSeconds = 0.45f; + private const float StunSeconds = 0.5f; + private const float EndSeconds = 0.6f; + private const float LandShowSeconds = 0.3f; + + private float pathTime; + private float pathSpeed; + private float direction = 1f; + private float flipLeft; + private float flightTime; + private float endTime = -1f; + + public TossMinigame(CaptureMinigameTuning tuning, System.Random random) + : base(random, 10f * tuning.TimeMult) + { + pathSpeed = (1.1f + tuning.Rarity * 0.22f) * tuning.SpeedMult; + NetRadius = (40f - tuning.Rarity * 3f) * tuning.ZoneMult; + pathTime = Range(0f, 6f); + flipLeft = Range(1f, 2f); + ThrowsLeft = Throws; + Insect = PathPoint(pathTime); + } + + public override CaptureMinigameKind Kind => CaptureMinigameKind.Toss; + + public Vector2 Insect { get; private set; } + public float NetRadius { get; } + public int ThrowsLeft { get; private set; } + public bool InFlight { get; private set; } + public Vector2 Target { get; private set; } + public float Flight01 => InFlight ? Mathf.Clamp01(flightTime / FlightSeconds) : 0f; + public float StunLeft { get; private set; } + /// 방금 떨어진 그물을 보여 줄 남은 시간(초). 0이면 안 그린다. + public float LandShowLeft { get; private set; } + public Vector2 LastLanding { get; private set; } + public bool LastHit { get; private set; } + + /// 그물이 떠나는 자리(놀이판 아래 가운데). + public static Vector2 Launcher => new Vector2(BoardWidth * 0.5f, BoardHeight - 18f); + + public static Vector2 PathPoint(float t) + { + return new Vector2( + BoardWidth * 0.5f + 125f * Mathf.Sin(t * 1.3f), + BoardHeight * 0.42f + 62f * Mathf.Sin(t * 2.1f + 0.7f)); + } + + public override void Tick(float dt, CaptureMinigameInput input) + { + if (Done || dt <= 0f) return; + + if (LandShowLeft > 0f) LandShowLeft -= dt; + + if (endTime >= 0f) + { + endTime += dt; + if (endTime >= EndSeconds) Done = true; + return; + } + if (RunOutOfTime(dt)) return; + + if (StunLeft > 0f) + { + StunLeft -= dt; + } + else + { + flipLeft -= dt; + if (flipLeft <= 0f) { flipLeft = Range(0.9f, 2.2f); direction = -direction; } + pathTime += dt * pathSpeed * direction; + Insect = PathPoint(pathTime); + } + + if (InFlight) + { + flightTime += dt; + if (flightTime >= FlightSeconds) Land(); + } + else if (input.Pressed && ThrowsLeft > 0) + { + ThrowsLeft--; + InFlight = true; + flightTime = 0f; + Target = new Vector2( + Mathf.Clamp(input.Point.x, 0f, BoardWidth), Mathf.Clamp(input.Point.y, 0f, BoardHeight)); + } + } + + private void Land() + { + InFlight = false; + LastLanding = Target; + LandShowLeft = LandShowSeconds; + LastHit = (Insect - Target).sqrMagnitude <= NetRadius * NetRadius; + if (LastHit) + { + Hits = Mathf.Min(MaxHits, Hits + 1); + StunLeft = StunSeconds; + pathSpeed *= 1.22f; // 한 번 놀란 곤충은 더 빨리 난다 + } + if (ThrowsLeft <= 0) endTime = 0f; + } + } +} diff --git a/Assets/Scripts/Capture/CaptureMinigame.cs.meta b/Assets/Scripts/Capture/CaptureMinigame.cs.meta new file mode 100644 index 0000000..c5b6ddc --- /dev/null +++ b/Assets/Scripts/Capture/CaptureMinigame.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6b5a1e6fbd913034c96525a1cbac8829 \ No newline at end of file diff --git a/Assets/Scripts/Capture/CaptureMinigameController.cs b/Assets/Scripts/Capture/CaptureMinigameController.cs index 9ef4ce7..5b02c74 100644 --- a/Assets/Scripts/Capture/CaptureMinigameController.cs +++ b/Assets/Scripts/Capture/CaptureMinigameController.cs @@ -2,86 +2,78 @@ using InsectGame.Spawning; using InsectGame.UI; using UnityEngine; -using UnityEngine.UI; namespace InsectGame.Capture { + /// + /// 포획 미니게임의 껍데기 — 게임을 고르고, 조작을 모아 넘기고, 화면을 그리고, 끝나면 포획을 판정한다. + /// 규칙은 (순수)에 있다. + /// + /// 예전엔 좌우로 오가는 타이밍 바 하나였다. 곤충이 화면에 나오지 않았고 어떤 종을 잡든 판이 같았다. + /// 지금은 살금살금·가두기·던지기 셋 중 하나가 포획마다 무작위로 걸리고(2026-10-02 결정), 잡으려는 + /// 곤충이 놀이판에 직접 나온다. 결과는 셋 다 0~3점이라 와 + /// 채집망 보정은 그대로 이어진다. + /// public class CaptureMinigameController : MonoBehaviour { [SerializeField] private CaptureController captureController; - [SerializeField] private Slider timingSlider; + // 옛 uGUI 포획 패널. 부트스트랩이 리플렉션으로 꽂아 주는데 아무도 켜지 않는다 — 끝날 때 숨기기만 한다. [SerializeField] private GameObject panelRoot; [SerializeField] private InsectGame.Core.PlayerMovement playerMovement; - [Header("Minigame Tuning")] - [SerializeField] private float baseSpeed = 1.4f; - [SerializeField] private float speedPerRarity = 0.5f; - [SerializeField] private float baseZoneSize = 0.35f; - [SerializeField] private float zoneShrinkPerRarity = 0.05f; - [SerializeField] private float timeLimit = 4f; + // 판이 열리고 조작을 받기까지. 채집망을 고른 그 누름이 첫 조작으로 새지 않게 하고, 무슨 게임인지 읽을 틈을 준다. + private const float IntroSeconds = 0.9f; + private const float ResultSeconds = 1.5f; + private const float HeaderHeight = 148f; + private const float BoardPad = 24f; + // 가두기에서 그물을 손가락 위로 띄우는 높이(가상 px). 손가락이 곤충을 가리지 않게 한다. + private const float TouchNetLift = 96f; + + private readonly System.Random random = new System.Random(); private InsectEntity currentTarget; - private float cursor; - private float direction = 1f; - private float currentSpeed; - private float zoneCenter; - private float zoneHalfSize; - private float timer; + private CaptureMinigame game; + private CaptureMinigameKind? lastKind; private bool isActive; public bool IsActive => isActive; + private float introLeft; + private float animTime; + private float itemCaptureBonus; + private string titleText = string.Empty; + private bool wantCancel; + // 옛 uGUI 포획 버튼(ConfirmCapture)의 누름 — 다음 Update가 한 번 소비한다. + private bool legacyPress; + private float resultTimer; private string resultMessage; private bool resultSuccess; - private int comboHits; - private bool wantConfirm; - private bool wantCancel; - private enum Phase { Ready, Attempt1, Attempt2, Attempt3, Done } - private Phase phase; - private int hits; - - private float itemSpeedMult = 1f; - private float itemZoneMult = 1f; - private float itemTimeMult = 1f; - private float itemCaptureBonus; + // 이번 프레임의 배치. Update(조작 좌표)와 OnGUI(그리기)가 같은 값을 쓴다. + private Rect panelRect; + private Rect boardRect; + private float boardScale = 1f; + private Rect cancelButtonRect; - // 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 GUIStyle titleStyle, starStyle, hintStyle, markStyle, boardTextStyle, introStyle, cancelStyle, resultStyle; + private bool stylesReady; - private void InitMinigameStyles() + private void InitStyles() { - if (stylesInitialized) return; - stylesInitialized = true; - - int titleSize = UIScale.IsMobileLayout ? 30 : 20; - int phaseSize = UIScale.IsMobileLayout ? 24 : 16; - int buttonSize = UIScale.IsMobileLayout ? 25 : 18; - titleStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = titleSize, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - - phaseStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = phaseSize, 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 = buttonSize, fontStyle = FontStyle.Bold }; - - cancelBtnCache = new GUIStyle(GUI.skin.button) - { fontSize = UIScale.IsMobileLayout ? 23 : 16 }; - - resultStyleCache = new GUIStyle(GUI.skin.label) - { fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + if (stylesReady) return; + stylesReady = true; + UITheme t = UITheme.Instance; + + titleStyle = new GUIStyle(GUI.skin.label) { fontSize = 32, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + starStyle = new GUIStyle(GUI.skin.label) { fontSize = 38, alignment = TextAnchor.MiddleCenter }; + hintStyle = new GUIStyle(GUI.skin.label) { fontSize = 26, alignment = TextAnchor.MiddleCenter }; + hintStyle.normal.textColor = t.textSecondary; + markStyle = new GUIStyle(GUI.skin.label) { fontSize = 44, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + boardTextStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + introStyle = new GUIStyle(GUI.skin.label) { fontSize = 44, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + introStyle.normal.textColor = t.textPrimary; + cancelStyle = new GUIStyle(GUI.skin.label) { fontSize = 26, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + cancelStyle.normal.textColor = t.textPrimary; + resultStyle = new GUIStyle(GUI.skin.label) { fontSize = 46, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; } public void StartMinigame(InsectEntity target) @@ -90,6 +82,12 @@ public void StartMinigame(InsectEntity target) } public void StartMinigame(InsectEntity target, float speedMult, float zoneMult, float timeMult, float captureBonus) + { + StartMinigame(target, speedMult, zoneMult, timeMult, captureBonus, CaptureMinigame.PickNext(lastKind, random)); + } + + private void StartMinigame(InsectEntity target, float speedMult, float zoneMult, float timeMult, + float captureBonus, CaptureMinigameKind kind) { // 수문장 포획 금지의 단일 출처. CaptureChoiceUI의 버튼/키 분기에만 있었는데 근접·레이캐스트· // 입력 컨트롤러 세 경로는 여기로 바로 들어온다 — 수문장을 잡아 버리면 표식 개체가 @@ -105,112 +103,104 @@ public void StartMinigame(InsectEntity target, float speedMult, float zoneMult, if (playerMovement != null) playerMovement.SetFrozen(true); resultTimer = 0f; resultMessage = null; - comboHits = 0; - hits = 0; - itemSpeedMult = speedMult; - itemZoneMult = zoneMult; - itemTimeMult = timeMult; itemCaptureBonus = captureBonus; + introLeft = IntroSeconds; + animTime = 0f; + wantCancel = false; + legacyPress = false; - int rarity = target != null && target.Data != null ? (int)target.Data.rarity : 0; - currentSpeed = (baseSpeed + rarity * speedPerRarity) * itemSpeedMult; - zoneHalfSize = Mathf.Max(0.08f, (baseZoneSize - rarity * zoneShrinkPerRarity) / 2f * itemZoneMult); - - BeginPhase(Phase.Attempt1); - } - - private void BeginPhase(Phase p) - { - phase = p; - cursor = 0f; - direction = 1f; - timer = timeLimit * itemTimeMult; - - float margin = zoneHalfSize + 0.05f; - zoneCenter = Random.Range(margin, 1f - margin); + // 지워진 개체는 포획 전까지 본명을 감춘다(`CaptureChoiceUI`와 같은 이유·같은 출처). + // 제목은 판 내내 같으므로 여기서 한 번만 만든다(OnGUI 패스마다 이어 붙이지 않는다). + string targetName = target != null ? target.DisplayNameForPlayer : "???"; + titleText = target != null && target.Data != null + ? $"{targetName} · {target.Data.rarity.Korean()}" : targetName; - if (p == Phase.Attempt2) - { - currentSpeed *= 1.15f; - zoneHalfSize = Mathf.Max(0.06f, zoneHalfSize * 0.85f); - } - else if (p == Phase.Attempt3) - { - currentSpeed *= 1.15f; - zoneHalfSize = Mathf.Max(0.05f, zoneHalfSize * 0.8f); - } + int rarity = target != null && target.Data != null ? (int)target.Data.rarity : 0; + lastKind = kind; + game = CaptureMinigame.Create(kind, new CaptureMinigameTuning(rarity, speedMult, zoneMult, timeMult), random); } private void Update() { if (resultTimer > 0f) { + // 결과 글자는 가운데 무대(HudStage)의 첫 고정 칸에 선다 — 서 있는 동안 포획 결과 카드 등이 차례를 기다린다. + InsectGame.UI.HudStage.Request(InsectGame.UI.HudStageItem.MinigameResult); resultTimer -= Time.deltaTime; if (resultTimer <= 0f) resultMessage = null; } - if (!isActive) return; + if (!isActive || game == null) return; - // 입력은 OnGUI 이벤트 패스(KeyDown/MouseDown)에서만 wantConfirm/wantCancel를 세팅한다. - // 옛은 여기서 Input.GetKeyDown/GetMouseButtonDown 폴링으로도 세팅 → 같은 누름을 Update(폴링)+ - // OnGUI(이벤트)가 이중 큐잉, 프레임당 1회만 소비돼 누름 1회당 ConfirmCapture가 2회 실행됨. - // 두 번째가 BeginPhase 직후 cursor=0에서 항상 miss→FinishCapture로 즉시 종료 → 3단계 콤보· - // 퍼펙트 타이밍 보너스 영구 불가였음. 단일 입력 소스(OnGUI)로 통일. - if (wantConfirm) { wantConfirm = false; ConfirmCapture(); } if (wantCancel) { wantCancel = false; CancelCapture(); return; } + // 대상이 사라졌으면(풀 회수·씬 정리) 판을 붙들고 있지 않는다 — 조작이 얼어붙은 채 남는다. + if (currentTarget == null) { StopMinigame(); return; } - timer -= Time.deltaTime; - if (timer <= 0f) { FinishCapture(); return; } + float dt = Time.deltaTime; + animTime += dt; - float speedMod = 1f + Mathf.Abs(cursor - 0.5f) * 0.6f; - cursor += direction * currentSpeed * speedMod * Time.deltaTime; + if (introLeft > 0f) + { + introLeft -= dt; + legacyPress = false; + return; + } +#if UNITY_EDITOR || DEVELOPMENT_BUILD + if (debugFrozen) return; +#endif - if (cursor >= 1f) { cursor = 1f; direction = -1f; } - else if (cursor <= 0f) { cursor = 0f; direction = 1f; } + game.Tick(dt, ReadInput()); + if (game.Done) FinishCapture(); + } - if (timingSlider != null) timingSlider.value = cursor; + // 조작은 여기 한 곳에서만 읽는다. 예전 타이밍 바는 Update 폴링과 OnGUI 이벤트가 같은 누름을 + // 따로 세어 한 번 누르면 두 번 확정됐다(3단계 콤보가 영영 불가능했다) — 출처를 둘로 늘리지 말 것. + private CaptureMinigameInput ReadInput() + { + Layout(); + Vector2 pointer = UIScale.VirtualMousePosition; + bool overCancel = cancelButtonRect.Contains(pointer); + bool pointerDown = Input.GetMouseButton(0) && !overCancel; + bool pointerPressed = Input.GetMouseButtonDown(0) && !overCancel; + bool keyDown = Input.GetKey(KeyCode.Space) || Input.GetKey(KeyCode.E); + bool keyPressed = Input.GetKeyDown(KeyCode.Space) || Input.GetKeyDown(KeyCode.E) + || Input.GetKeyDown(KeyCode.Return) || legacyPress; + legacyPress = false; + + // 가두기는 손가락이 곤충을 가린다 — 터치일 때만 그물을 손가락 위로 띄운다. + if (game.Kind == CaptureMinigameKind.Track && Input.touchCount > 0) pointer.y -= TouchNetLift; + + var input = new CaptureMinigameInput + { + Down = pointerDown || keyDown, + Pressed = pointerPressed || keyPressed, + Point = new Vector2((pointer.x - boardRect.x) / boardScale, (pointer.y - boardRect.y) / boardScale) + }; + // 던지기는 놀이판 안을 눌렀을 때만 던진다 — 판 밖(제목·힌트)을 눌러 그물을 버리지 않게. + if (game.Kind == CaptureMinigameKind.Toss && !boardRect.Contains(pointer)) input.Pressed = false; + return input; } + /// 옛 uGUI 포획 버튼의 진입점 — 지금은 "한 번 누름"으로 넘긴다. public void ConfirmCapture() { - if (!isActive || currentTarget == null) return; - - hits++; - bool inZone = Mathf.Abs(cursor - zoneCenter) <= zoneHalfSize; - - if (inZone) - { - comboHits++; - if (phase == Phase.Attempt1) - BeginPhase(Phase.Attempt2); - else if (phase == Phase.Attempt2) - BeginPhase(Phase.Attempt3); - else - FinishCapture(); - } - else - { - FinishCapture(); - } + if (isActive) legacyPress = true; } private void FinishCapture() { - float timing01 = CaptureMinigameProbability.GetTiming01(comboHits); - float extraBonus = CaptureMinigameProbability.GetExtraBonus(comboHits, itemCaptureBonus); + int hits = game != null ? game.Hits : 0; + float timing01 = CaptureMinigameProbability.GetTiming01(hits); + float extraBonus = CaptureMinigameProbability.GetExtraBonus(hits, itemCaptureBonus); if (captureController != null && currentTarget != null) { captureController.AttemptCapture(currentTarget, timing01, extraBonus); - if (comboHits >= 3) - ShowResult("PERFECT!", true); - else if (comboHits >= 2) - ShowResult("GREAT!", true); - else if (comboHits >= 1) - ShowResult("GOOD", false); - else - ShowResult("MISS...", false); + if (hits >= 3) ShowResult("PERFECT!", true); + else if (hits >= 2) ShowResult("GREAT!", true); + else if (hits >= 1) ShowResult("GOOD", false); + else ShowResult("MISS...", false); } StopMinigame(); @@ -220,7 +210,7 @@ private void ShowResult(string msg, bool success) { resultMessage = msg; resultSuccess = success; - resultTimer = 1.5f; + resultTimer = ResultSeconds; } public void CancelCapture() @@ -231,8 +221,7 @@ public void CancelCapture() private void StopMinigame() { isActive = false; - phase = Phase.Done; - hits = 0; + game = null; if (currentTarget != null) currentTarget.SetEngaged(false); // 미니게임 종료 — 도주 가능 상태 복귀 currentTarget = null; if (panelRoot != null) panelRoot.SetActive(false); @@ -251,183 +240,363 @@ private void OnGUI() if (isActive) { Event evt = Event.current; - if (evt != null && evt.type == EventType.KeyDown) - { - if (evt.keyCode == KeyCode.Space || evt.keyCode == KeyCode.E - || evt.keyCode == KeyCode.Tab || evt.keyCode == KeyCode.Return) - { - wantConfirm = true; - evt.Use(); - } - else if (evt.keyCode == KeyCode.Escape) - { - wantCancel = true; - evt.Use(); - } - } - - if (evt != null && evt.type == EventType.MouseDown && evt.button == 0) + if (evt != null && evt.type == EventType.KeyDown && evt.keyCode == KeyCode.Escape) { - // 어디든 탭=확정(누름 기반이라 반응성 좋음). 단 취소 버튼 영역은 제외(취소만 발화). - // 캡처 버튼의 MouseUp은 더 이상 확정을 세팅하지 않아(시각 전용) 단일 탭당 ConfirmCapture - // 가 정확히 1회 — 옛은 MouseDown(누름)+버튼 MouseUp(뗌)이 이중확정돼 페이즈 직후 cursor≈0 - // 에서 MISS로 즉시 종료, 3단계 콤보·퍼펙트 보너스 영구 불가였음. (취소 rect는 가상 좌표, - // evt.mousePosition은 Begin 전이라 raw → Scale로 나눠 가상좌표로 변환.) - Vector2 vp = evt.mousePosition / Mathf.Max(0.3f, UIScale.Scale); - if (!cancelButtonRect.Contains(vp)) - wantConfirm = true; + wantCancel = true; + evt.Use(); } } if (resultTimer <= 0f && !isActive) return; + InitStyles(); UIScale.Begin(); if (resultTimer > 0f && resultMessage != null) DrawResult(); - if (isActive) DrawMinigame(); + if (isActive && game != null) DrawMinigame(); + GUI.color = Color.white; UIScale.End(); } - // 취소 버튼 가상 rect — 전역 MouseDown 확정에서 취소 영역을 제외하기 위해 직전 DrawMinigame에서 갱신. - private Rect cancelButtonRect; + // ── 배치 ── + + private void Layout() + { + bool mobile = UIScale.IsMobileLayout; + float footer = mobile ? 132f : 116f; + float wantW = mobile ? Mathf.Min(900f, UIScale.ContentWidth(28f)) : 760f; + float wantBoardH = (wantW - BoardPad * 2f) * (CaptureMinigame.BoardHeight / CaptureMinigame.BoardWidth); + // 높이는 안전 영역 안으로 줄어들 수 있다 — 줄면 놀이판을 같은 비율로 줄인다. + panelRect = UISafeLayout.CenteredPanel(wantW, HeaderHeight + wantBoardH + footer); + float availH = Mathf.Max(1f, panelRect.height - HeaderHeight - footer); + boardScale = Mathf.Max(0.1f, Mathf.Min( + (panelRect.width - BoardPad * 2f) / CaptureMinigame.BoardWidth, availH / CaptureMinigame.BoardHeight)); + float bw = CaptureMinigame.BoardWidth * boardScale; + float bh = CaptureMinigame.BoardHeight * boardScale; + boardRect = new Rect(panelRect.x + (panelRect.width - bw) * 0.5f, panelRect.y + HeaderHeight, bw, bh); + + float cancelW = mobile ? 220f : 200f; + float cancelH = mobile ? 64f : 56f; + cancelButtonRect = new Rect(panelRect.x + (panelRect.width - cancelW) * 0.5f, + panelRect.yMax - cancelH - 16f, cancelW, cancelH); + } + + private Vector2 OnBoard(Vector2 p) => new Vector2(boardRect.x + p.x * boardScale, boardRect.y + p.y * boardScale); + private Vector2 OnBoard(float x, float y) => new Vector2(boardRect.x + x * boardScale, boardRect.y + y * boardScale); + private Rect OnBoardRect(float cx, float cy, float w, float h) + => new Rect(boardRect.x + (cx - w * 0.5f) * boardScale, boardRect.y + (cy - h * 0.5f) * boardScale, + w * boardScale, h * boardScale); + + // ── 그리기 ── private void DrawMinigame() { + Layout(); + UITheme t = UITheme.Instance; + InsectData data = currentTarget != null ? currentTarget.Data : null; + Color rarityCol = data != null ? t.GetInsectRarityColor(data.rarity) : t.textPrimary; + + UISurface.Dim(0.35f); + UISurface.Card(panelRect, t.surfaceCard, Color.Lerp(t.surfaceBorder, rarityCol, 0.55f)); + UISurface.Flat(new Rect(panelRect.x + UITheme.Radius.Card, panelRect.y + 3f, + panelRect.width - UITheme.Radius.Card * 2f, 4f), rarityCol); + + titleStyle.normal.textColor = rarityCol; + UIHelper.LabelFit(new Rect(panelRect.x + 24f, panelRect.y + 14f, panelRect.width - 48f, 46f), + titleText, titleStyle); + + // 별 세 개 — 지금까지 얻은 점수. + float starX = panelRect.center.x - 78f; + for (int i = 0; i < CaptureMinigame.MaxHits; i++) + { + starStyle.normal.textColor = i < game.Hits ? t.accentAmber : t.surfaceBorder; + GUI.Label(new Rect(starX + i * 52f, panelRect.y + 62f, 52f, 52f), "★", starStyle); + } + UISurface.Chip(new Rect(panelRect.x + 24f, panelRect.y + 70f, 148f, 36f), KindName(game.Kind), + t.surfaceRaised, t.textPrimary); + + float ratio = game.TimeRatio; + UISurface.Meter(new Rect(boardRect.x, panelRect.y + 124f, boardRect.width, 10f), ratio, + ratio > 0.4f ? t.accentMint : (ratio > 0.2f ? t.accentAmber : t.accentCoral)); + + Color boardBg = Color.Lerp(t.surfaceBase, t.accentMint, 0.14f); + UISurface.Rounded(boardRect, boardBg); + + switch (game.Kind) + { + case CaptureMinigameKind.Track: DrawTrack((TrackMinigame)game, data, boardBg); break; + case CaptureMinigameKind.Toss: DrawToss((TossMinigame)game, data, boardBg); break; + default: DrawSneak((SneakMinigame)game, data, boardBg); break; + } + + if (introLeft > 0f) DrawIntro(); + + UIHelper.LabelFit(new Rect(panelRect.x + 24f, boardRect.yMax + 8f, panelRect.width - 48f, 40f), + HintFor(game), hintStyle); + bool mobile = UIScale.IsMobileLayout; - float panelW = mobile ? Mathf.Min(900f, UIScale.ContentWidth(28f)) : 500f; - float panelH = UISafeLayout.ClampHeight(mobile ? 340f : 220f); - float x = (UIScale.VirtualScreenWidth - panelW) / 2f; - // 화면 상단 1/4 근처 비율 배치 — 단 안전 영역 밖으로는 나가지 않는다. - float y = Mathf.Clamp( - UIScale.VirtualScreenHeight * (mobile ? 0.24f : 0.28f), - UISafeLayout.ContentTop, - Mathf.Max(UISafeLayout.ContentTop, UISafeLayout.ContentBottom - panelH)); - - GUI.color = new Color(0, 0, 0, 0.88f); - GUI.DrawTexture(new Rect(x, y, panelW, panelH), Texture2D.whiteTexture); + if (UISurface.Button(cancelButtonRect, mobile ? "취소" : "취소 [ESC]", t.surfaceRaised, cancelStyle)) + wantCancel = true; + } - // 지워진 개체는 포획 전까지 본명을 감춘다(`CaptureChoiceUI`와 같은 이유·같은 출처). - string targetName = currentTarget != null - ? currentTarget.DisplayNameForPlayer : "???"; - string rarityName = currentTarget != null && currentTarget.Data != null - ? currentTarget.Data.rarity.ToString() : ""; + private static string KindName(CaptureMinigameKind kind) + { + switch (kind) + { + case CaptureMinigameKind.Track: return "가두기"; + case CaptureMinigameKind.Toss: return "던지기"; + default: return "살금살금"; + } + } - InitMinigameStyles(); - titleStyleCache.normal.textColor = GetRarityGUIColor(); + private static string HintFor(CaptureMinigame g) + { + bool mobile = UIScale.IsMobileLayout; + switch (g.Kind) + { + case CaptureMinigameKind.Track: + return "누른 채 끌어서 곤충을 그물 안에 가두세요"; + case CaptureMinigameKind.Toss: + return "곤충이 갈 곳을 눌러 그물을 던지세요"; + default: + return mobile ? "누르고 있으면 다가갑니다 · 돌아보면 손을 떼세요" + : "[Space]나 클릭을 누르고 있으면 다가갑니다 · 돌아보면 떼세요"; + } + } + + private void DrawIntro() + { + UITheme t = UITheme.Instance; + float a = Mathf.Clamp01(introLeft / 0.25f); + float w = Mathf.Min(420f, boardRect.width - 40f); + Rect pill = new Rect(boardRect.center.x - w * 0.5f, boardRect.center.y - 44f, w, 88f); + GUI.color = new Color(1f, 1f, 1f, a); + UISurface.Rounded(pill, new Color(t.surfaceBase.r, t.surfaceBase.g, t.surfaceBase.b, 0.9f)); GUI.color = Color.white; - GUI.Label(new Rect(x, y + (mobile ? 14f : 8f), panelW, mobile ? 44f : 30f), - $"{targetName} [{rarityName}]", titleStyleCache); + introStyle.normal.textColor = new Color(t.textPrimary.r, t.textPrimary.g, t.textPrimary.b, a); + UIHelper.LabelFit(new Rect(pill.x + 16f, pill.y + 12f, pill.width - 32f, 64f), KindName(game.Kind), introStyle); + } - float barX = x + 30; - float barY = y + (mobile ? 72f : 48f); - float barW = panelW - 60; - float barH = mobile ? 64f : 40f; + // 두 점을 잇는 막대. UIShapes.Capsule은 GUIUtility.RotateAroundPivot을 쓰는데 그 피벗은 화면 좌표라 + // UIScale 배율이 1이 아니면 엉뚱한 점을 축으로 돈다 — 지도 쐐기와 같은 행렬(부모 뒤에 곱한다)을 쓴다. + private static void DrawBar(Vector2 from, Vector2 to, float thickness, Color color) + { + Vector2 delta = to - from; + float length = delta.magnitude; + if (length < 0.01f) return; + Matrix4x4 saved = GUI.matrix; + GUI.matrix = MapMarkerProjection.PivotMatrix(saved, (from + to) * 0.5f, UIShapes.AngleDegrees(delta)); + Color prev = GUI.color; + GUI.color = color; + GUI.DrawTexture(new Rect(-length * 0.5f, -thickness * 0.5f, length, thickness), Texture2D.whiteTexture); + GUI.color = prev; + GUI.matrix = saved; + } - GUI.color = new Color(0.15f, 0.15f, 0.15f, 1f); - GUI.DrawTexture(new Rect(barX, barY, barW, barH), Texture2D.whiteTexture); + // 테두리 있는 원. IMGUI에는 고리 도형이 없어 큰 원 위에 속을 덮어 만든다 — 속은 불투명이어야 한다. + private static void DrawRing(Vector2 center, float radius, float thickness, Color edge, Color fill) + { + UIShapes.Ellipse(new Rect(center.x - radius, center.y - radius, radius * 2f, radius * 2f), edge); + float inner = Mathf.Max(0f, radius - thickness); + UIShapes.Ellipse(new Rect(center.x - inner, center.y - inner, inner * 2f, inner * 2f), fill); + } - float zoneLeft = barX + (zoneCenter - zoneHalfSize) * barW; - float zoneWidth = zoneHalfSize * 2f * barW; - GUI.color = new Color(0.1f, 0.7f, 0.2f, 0.6f); - GUI.DrawTexture(new Rect(zoneLeft, barY, zoneWidth, barH), Texture2D.whiteTexture); + private void DrawInsect(Vector2 center, float size, InsectData data, float alpha, bool mirrored = false) + { + if (data == null) + { + UIShapes.Ellipse(new Rect(center.x - size * 0.3f, center.y - size * 0.3f, size * 0.6f, size * 0.6f), + UITheme.Instance.accentAmber); + return; + } + bool shiny = currentTarget != null && currentTarget.IsShiny; + if (!mirrored) + { + InsectVisual.Draw(center.x, center.y, size, data, shiny, alpha); + return; + } + // 돌아볼 때 좌우를 뒤집는다 — 등을 보이던 곤충이 이쪽을 향한다. + Matrix4x4 saved = GUI.matrix; + GUI.matrix = saved * Matrix4x4.TRS(new Vector3(center.x, center.y, 0f), Quaternion.identity, new Vector3(-1f, 1f, 1f)); + InsectVisual.Draw(0f, 0f, size, data, shiny, alpha); + GUI.matrix = saved; + } - float outerLeft = barX + (zoneCenter - zoneHalfSize * 1.8f) * barW; - float outerWidth = zoneHalfSize * 3.6f * barW; - GUI.color = new Color(0.9f, 0.8f, 0.1f, 0.3f); - GUI.DrawTexture(new Rect(outerLeft, barY, (zoneLeft - outerLeft), barH), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(zoneLeft + zoneWidth, barY, - (outerLeft + outerWidth) - (zoneLeft + zoneWidth), barH), Texture2D.whiteTexture); + private void BoardText(float y, string text, Color color) + { + boardTextStyle.normal.textColor = color; + UIHelper.LabelFit(new Rect(boardRect.x + 12f, boardRect.y + y * boardScale, boardRect.width - 24f, 44f), + text, boardTextStyle); + } - float dist = Mathf.Abs(cursor - zoneCenter); - Color cursorColor = dist <= zoneHalfSize ? Color.green : - dist <= zoneHalfSize * 1.8f ? Color.yellow : Color.red; + // ── 살금살금 ── - float cursorX = barX + cursor * barW - 3f; - GUI.color = cursorColor; - GUI.DrawTexture(new Rect(cursorX, barY - 4, 6, barH + 8), Texture2D.whiteTexture); + private const float SneakInsectX = 292f; + private const float SneakInsectY = 138f; + private const float SneakTrackY = 226f; - GUI.color = Color.white; - string phaseLabel = phase == Phase.Attempt1 ? "1st" : - phase == Phase.Attempt2 ? "2nd - Faster!" : "FINAL!"; - phaseStyleCache.normal.textColor = phase == Phase.Attempt3 ? Color.yellow : Color.white; - GUI.Label(new Rect(x, barY + barH + 4, panelW, mobile ? 34f : 22f), phaseLabel, phaseStyleCache); + private static float SneakNetX(float distance) => Mathf.Lerp(250f, 44f, distance); - for (int i = 0; i < comboHits && i < 3; i++) + private void DrawSneak(SneakMinigame g, InsectData data, Color boardBg) + { + UITheme t = UITheme.Instance; + float s = boardScale; + bool looking = g.State == SneakMinigame.Watch.Look; + bool spotted = g.FreezeLeft > 0f; + + // 땅과 다가가는 길 + UISurface.Flat(new Rect(boardRect.x + 3f, OnBoard(0f, 206f).y, boardRect.width - 6f, 3f), + Color.Lerp(boardBg, t.textMuted, 0.5f)); + Vector2 pathFrom = OnBoard(SneakNetX(1f), SneakTrackY); + Vector2 pathTo = OnBoard(SneakNetX(0f), SneakTrackY); + UISurface.Flat(new Rect(pathFrom.x, pathFrom.y - 2f, pathTo.x - pathFrom.x, 4f), t.surfaceBorder); + DrawFlag(SneakMinigame.FirstFlag, g.Distance, boardBg); + DrawFlag(SneakMinigame.SecondFlag, g.Distance, boardBg); + DrawFlag(0f, g.Distance, boardBg); + + // 돌아보는 동안 시선이 닿는 자리 — 여기서 움직이면 들킨다. + if (looking && !g.Fled && g.Swoop01 < 0f) { - float starX = x + panelW / 2f - 45 + i * 30; - UIHelper.LabelFit(new Rect(starX, barY + barH + 22, 28, 28), "*", starStyleCache); + Rect gaze = new Rect(boardRect.x + 10f, OnBoard(0f, 96f).y, OnBoard(SneakInsectX, 0f).x - boardRect.x - 10f, 104f * s); + UISurface.Rounded(gaze, new Color(t.accentCoral.r, t.accentCoral.g, t.accentCoral.b, spotted ? 0.34f : 0.22f)); } - float timerRatio = Mathf.Clamp01(timer / timeLimit); - float timerBarY = y + panelH - (mobile ? 100f : 60f); - GUI.color = new Color(0.3f, 0.3f, 0.3f, 1f); - GUI.DrawTexture(new Rect(barX, timerBarY, barW, 8), Texture2D.whiteTexture); - Color timerColor = timerRatio > 0.4f ? new Color(0.2f, 0.7f, 1f) : - timerRatio > 0.2f ? Color.yellow : Color.red; - GUI.color = timerColor; - GUI.DrawTexture(new Rect(barX, timerBarY, barW * timerRatio, 8), Texture2D.whiteTexture); + // 곤충 + float flee = g.Fled ? g.Flee01 : 0f; + float twitch = g.State == SneakMinigame.Watch.Twitch ? Mathf.Sin(animTime * 42f) * 3f : 0f; + Vector2 insectAt = OnBoard(SneakInsectX + twitch + flee * 90f, SneakInsectY - flee * 130f); + DrawInsect(insectAt, 128f * s, data, 1f - flee, looking || g.Fled); + if (!g.Fled) + { + Rect mark = new Rect(insectAt.x - 40f, insectAt.y - 70f * s - 60f, 80f, 60f); + if (spotted) { markStyle.normal.textColor = t.accentCoral; GUI.Label(mark, "!", markStyle); } + else if (g.State == SneakMinigame.Watch.Twitch) { markStyle.normal.textColor = t.accentAmber; GUI.Label(mark, "?", markStyle); } + } - GUI.color = Color.white; - float btnY = y + panelH - (mobile ? 80f : 42f); - float btnW = mobile ? (panelW - 90f) * 0.5f : 140f; - float btnH = mobile ? 64f : 34f; - - GUI.backgroundColor = new Color(0.2f, 0.8f, 0.3f); - string captureText = mobile ? "지금 포획!" : "포획! [Space/클릭]"; - // 시각 전용 — 확정은 전역 MouseDown(누름)이 처리(버튼 MouseUp 이중확정 차단). - GUI.Button(new Rect(x + panelW / 2f - btnW - 10, btnY, btnW, btnH), captureText, captureBtnCache); - - GUI.backgroundColor = new Color(0.6f, 0.2f, 0.2f); - string cancelText = mobile ? "취소" : "취소 [ESC]"; - cancelButtonRect = new Rect(x + panelW / 2f + 10, btnY, btnW, btnH); - if (GUI.Button(cancelButtonRect, cancelText, cancelBtnCache)) + // 채집망을 든 손 — 덮칠 때는 곤충에게 내리꽂는다. + float swoop = Mathf.Max(0f, g.Swoop01); + Vector2 net = OnBoard(Mathf.Lerp(SneakNetX(g.Distance), SneakInsectX - 6f, swoop), + Mathf.Lerp(170f, SneakInsectY, swoop)); + DrawBar(net + new Vector2(-46f, 60f) * s, net, 6f * s, Color.Lerp(t.surfaceBase, t.accentAmber, 0.55f)); + DrawRing(net + new Vector2(6f, -12f) * s, 24f * s, 4f * s, t.textPrimary, Color.Lerp(boardBg, t.textPrimary, 0.16f)); + + // 기척 — 두 번 들키면 달아난다. + for (int i = 0; i < SneakMinigame.MaxSpots; i++) { - wantCancel = true; + Vector2 dot = OnBoard(20f + i * 18f, 258f); + UIShapes.Ellipse(new Rect(dot.x - 6f * s, dot.y - 6f * s, 12f * s, 12f * s), + i < SneakMinigame.MaxSpots - g.Spots ? t.accentMint : t.surfaceBorder); } - GUI.backgroundColor = Color.white; + + if (introLeft > 0f) return; + if (g.Fled) BoardText(24f, "달아났다!", t.accentCoral); + else if (spotted) BoardText(24f, "들켰다!", t.accentCoral); + else if (g.Swoop01 >= 0f) BoardText(24f, "덮쳤다!", t.accentMint); + else if (looking) BoardText(24f, "멈춰!", t.accentCoral); + else if (g.State == SneakMinigame.Watch.Twitch) BoardText(24f, "돌아보려 한다", t.accentAmber); + else BoardText(24f, "지금이야", t.accentMint); } - private void DrawResult() + private void DrawFlag(float flagDistance, float distance, Color boardBg) { - float alpha = Mathf.Clamp01(resultTimer / 0.3f); - float cx = UIScale.VirtualScreenWidth / 2f; - float baseY = UIScale.VirtualScreenHeight * 0.18f; + UITheme t = UITheme.Instance; + Vector2 at = OnBoard(SneakNetX(flagDistance), SneakTrackY); + float r = 8f * boardScale; + DrawRing(at, r, 3f * boardScale, distance <= flagDistance ? t.accentMint : t.textMuted, boardBg); + } - float progress = 1f - (resultTimer / 1.5f); - float bounce = 1f + Mathf.Sin(progress * Mathf.PI) * 0.15f; - int fontSize = (int)(42 * bounce); + // ── 가두기 ── - 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); + private void DrawTrack(TrackMinigame g, InsectData data, Color boardBg) + { + UITheme t = UITheme.Instance; + float s = boardScale; - float w = 400; - GUI.color = new Color(1, 1, 1, alpha); - GUI.Label(new Rect(cx - w / 2f, baseY, w, 60), resultMessage, resultStyleCache); + Color edge = g.Inside ? t.accentMint : t.textPrimary; + DrawRing(OnBoard(g.Net), g.NetRadius * s, 4f * s, edge, Color.Lerp(boardBg, edge, g.Inside ? 0.28f : 0.12f)); - if (resultSuccess) + Vector2 flutter = new Vector2(Mathf.Sin(animTime * 9f), Mathf.Cos(animTime * 7f)) * 3f; + DrawInsect(OnBoard(g.Insect + flutter), 66f * s, data, 1f); + + // 게이지 — 3분의 1마다 별 하나. + Rect gauge = new Rect(boardRect.x + 24f, boardRect.y + 16f, boardRect.width - 48f, 14f); + UISurface.Meter(gauge, g.Gauge, t.accentMint); + UISurface.Flat(new Rect(gauge.x + gauge.width / 3f - 1.5f, gauge.y, 3f, gauge.height), boardBg); + UISurface.Flat(new Rect(gauge.x + gauge.width * 2f / 3f - 1.5f, gauge.y, 3f, gauge.height), boardBg); + } + + // ── 던지기 ── + + private void DrawToss(TossMinigame g, InsectData data, Color boardBg) + { + UITheme t = UITheme.Instance; + float s = boardScale; + + if (g.LandShowLeft > 0f) { - float glowSize = 120f + progress * 60f; - 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); + Color land = g.LastHit ? t.accentMint : t.accentCoral; + DrawRing(OnBoard(g.LastLanding), g.NetRadius * s, 4f * s, land, Color.Lerp(boardBg, land, 0.3f)); } - GUI.color = Color.white; + Vector2 launcher = OnBoard(TossMinigame.Launcher); + if (g.InFlight) + { + // 떨어질 자리를 먼저 보여 준다 — 곤충이 그 안으로 들어올지를 보는 0.45초가 이 게임의 긴장이다. + DrawRing(OnBoard(g.Target), g.NetRadius * s, 3f * s, t.textMuted, boardBg); + float k = g.Flight01; + Vector2 at = Vector2.Lerp(launcher, OnBoard(g.Target), k) + new Vector2(0f, -Mathf.Sin(k * Mathf.PI) * 40f * s); + DrawRing(at, Mathf.Lerp(10f, g.NetRadius, k) * s, 3f * s, t.textPrimary, Color.Lerp(boardBg, t.textPrimary, 0.2f)); + } + + DrawInsect(OnBoard(g.Insect), 66f * s, data, 1f); + + // 남은 그물 + for (int i = 0; i < TossMinigame.Throws; i++) + { + Vector2 at = launcher + new Vector2((i - 1) * 34f * s, 0f); + DrawRing(at, 11f * s, 3f * s, i < g.ThrowsLeft ? t.textPrimary : t.surfaceBorder, boardBg); + } + + if (introLeft > 0f || g.LandShowLeft <= 0f) return; + BoardText(14f, g.LastHit ? "잡았다!" : "빗나갔다", g.LastHit ? t.accentMint : t.accentCoral); } - private Color GetRarityGUIColor() + private void DrawResult() { - if (currentTarget == null || currentTarget.Data == null) return Color.white; - switch (currentTarget.Data.rarity) + UITheme t = UITheme.Instance; + float alpha = Mathf.Clamp01(resultTimer / 0.3f); + InsectGame.UI.HudFrame frame = InsectGame.UI.HudFrame.Current; + InsectGame.UI.HudStage.Request(InsectGame.UI.HudStageItem.MinigameResult, InsectGame.UI.HudStage.Place(frame, + InsectGame.UI.HudStageItem.MinigameResult, InsectGame.UI.HudStage.MinigameSlotWidth, InsectGame.UI.HudStage.MinigameSlotHeight)); + Rect label = ResultLabelRect(frame); + float cx = label.center.x; + float baseY = label.y; + + float progress = 1f - (resultTimer / ResultSeconds); + float bounce = 1f + Mathf.Sin(progress * Mathf.PI) * 0.12f; + Color col = resultSuccess ? t.accentMint : t.accentCoral; + + if (resultSuccess) { - case InsectRarity.Common: return new Color(0.7f, 0.7f, 0.7f); - case InsectRarity.Uncommon: return new Color(0.4f, 0.9f, 0.4f); - case InsectRarity.Rare: return new Color(0.4f, 0.6f, 1f); - case InsectRarity.Epic: return new Color(0.8f, 0.4f, 1f); - case InsectRarity.Legendary: return new Color(1f, 0.8f, 0.2f); - default: return Color.white; + float glow = (140f + progress * 60f) * bounce; + UIShapes.Ellipse(new Rect(cx - glow, baseY + 36f - glow * 0.5f, glow * 2f, glow), new Color(col.r, col.g, col.b, 0.1f * alpha)); } + + resultStyle.normal.textColor = new Color(col.r, col.g, col.b, alpha); + UIHelper.LabelFit(label, resultMessage, resultStyle); + } + + public const float ResultLabelWidth = 440f; + public const float ResultLabelHeight = 72f; + + /// + /// 결과 글자(PERFECT! 등)의 자리 — 가운데 무대의 첫 고정 칸 한가운데. 둘레로 퍼지는 빛(최대 448×224)이 칸 안에 든다. + /// 예전엔 화면 높이 18% 가운데라 리전 배너·내기 점수판과 세로 화면의 단축 바를 덮었다. + /// + public static Rect ResultLabelRect(InsectGame.UI.HudFrame f) + { + Rect slot = InsectGame.UI.HudStage.Place(f, InsectGame.UI.HudStageItem.MinigameResult, + InsectGame.UI.HudStage.MinigameSlotWidth, InsectGame.UI.HudStage.MinigameSlotHeight); + float w = Mathf.Min(ResultLabelWidth, slot.width); + return new Rect(slot.center.x - w * 0.5f, slot.center.y - ResultLabelHeight * 0.5f, w, ResultLabelHeight); } public void AutoWire(CaptureController controller) @@ -440,5 +609,38 @@ public void AutoWire(InsectGame.Core.PlayerMovement pm) { if (playerMovement == null) playerMovement = pm; } + +#if UNITY_EDITOR || DEVELOPMENT_BUILD + // ── 검수 빌드 전용 ── 실제 IMGUI를 찍으려면 판을 원하는 순간에 세워 둘 수 있어야 한다. + private bool debugFrozen; + + /// + /// 지정한 게임을 열고 만큼 가짜 조작으로 돌린 뒤 멈춰 둔다. + /// 이면 살금살금에서 곤충이 돌아보는 순간까지만 돌린다. + /// + public void StartForCapture(InsectEntity target, CaptureMinigameKind kind, float seconds, bool hold, + Vector2 boardPoint, bool untilLook = false) + { + StartMinigame(target, 1f, 1f, 1f, 0f, kind); + if (game == null) return; + introLeft = 0f; + const float step = 1f / 60f; + game.Tick(step, new CaptureMinigameInput()); // 손을 뗀 상태에서 시작(살금살금은 한 번 떼야 움직인다) + bool pressed = true; + for (float elapsed = 0f; elapsed < seconds && !game.Done; elapsed += step) + { + game.Tick(step, new CaptureMinigameInput { Down = hold, Pressed = pressed && hold, Point = boardPoint }); + pressed = false; + if (untilLook && game is SneakMinigame sneak && sneak.State == SneakMinigame.Watch.Look) break; + } + debugFrozen = true; + } + + public void EndCapture() + { + debugFrozen = false; + StopMinigame(); + } +#endif } } diff --git a/Assets/Scripts/Core/AudioManager.cs b/Assets/Scripts/Core/AudioManager.cs index 6677f61..c73c053 100644 --- a/Assets/Scripts/Core/AudioManager.cs +++ b/Assets/Scripts/Core/AudioManager.cs @@ -33,7 +33,11 @@ public enum BgmType // **전투 계열이면 IsCombatBgm**. 앞의 셋만 하면 소리는 나지만 긴장 램프(피치)가 빠진다 — // 실제로 이 보스 2종이 그렇게 새어 2026-08-08 audit에서 잡혔다. BossLedger, - BossFinal + BossFinal, + // 전용 테마 — 수문장 레이드(느리고 무거운 북·낮은 금관)와 라온 대결(밝고 빠른 놀림 주제). + // 등록 4지점은 위와 같다. 곡은 ProceduralAudioGenerator.Music.cs. + Guardian, + Rival } public enum SfxType @@ -83,6 +87,8 @@ public class AudioManager : MonoBehaviour private float masterVolume; private float bgmVolume; + // 스토리 영상 같은 전면 연출이 BGM을 잠깐 낮추는 배율(1 = 원음). 볼륨 계산 전부에 곱한다. + private float bgmDuck = 1f; private float sfxVolume; private BgmType? currentBgmType; @@ -189,7 +195,9 @@ private static bool IsCombatBgm(BgmType type) => type == BgmType.Battle || type == BgmType.RaidBattle || type == BgmType.BossLedger - || type == BgmType.BossFinal; + || type == BgmType.BossFinal + || type == BgmType.Guardian + || type == BgmType.Rival; public void PlayBGM(BgmType type) { @@ -242,6 +250,16 @@ public void SetBGMVolume(float volume) ApplyVolumes(); } + /// + /// BGM·환경음을 잠깐 낮춘다(전면 영상 재생 등). 1이면 원복. + /// 크로스페이드 중이면 그 코루틴이 자기 목표값(더킹 반영)으로 끝내므로 여기서 억지로 잡지 않는다. + /// + public void SetBgmDuck(float factor) + { + bgmDuck = Mathf.Clamp01(factor); + if (crossfadeCoroutine == null) ApplyVolumes(); + } + // ── SFX ── public void PlaySFX(SfxType type) @@ -284,7 +302,7 @@ private System.Collections.IEnumerator CrossfadeAmbient(string environmentId, fl AudioSource oldSrc = useSecondaryAmbient ? ambientSource2 : ambientSource; useSecondaryAmbient = !useSecondaryAmbient; - float targetVol = masterVolume * bgmVolume * 0.4f; + float targetVol = masterVolume * bgmVolume * bgmDuck * 0.4f; newSrc.clip = clip; newSrc.volume = 0f; newSrc.Play(); @@ -397,13 +415,13 @@ public void SetMasterVolume(float vol) private void ApplyVolumes() { if (bgmSource != null) - bgmSource.volume = masterVolume * bgmVolume; + bgmSource.volume = masterVolume * bgmVolume * bgmDuck; if (sfxSource != null) sfxSource.volume = masterVolume * sfxVolume; if (ambientSource != null) - ambientSource.volume = masterVolume * bgmVolume * 0.4f; + ambientSource.volume = masterVolume * bgmVolume * bgmDuck * 0.4f; if (ambientSource2 != null) - ambientSource2.volume = masterVolume * bgmVolume * 0.4f; + ambientSource2.volume = masterVolume * bgmVolume * bgmDuck * 0.4f; } private IEnumerator CrossfadeBGM(AudioClip newClip, float duration) @@ -425,7 +443,7 @@ private IEnumerator CrossfadeBGM(AudioClip newClip, float duration) bgmSource.Play(); // 페이드 인 - float targetVolume = masterVolume * bgmVolume; + float targetVolume = masterVolume * bgmVolume * bgmDuck; elapsed = 0f; while (elapsed < duration * 0.5f) { @@ -517,6 +535,8 @@ private static string BgmTypeToString(BgmType type) case BgmType.ExploreNameless: return "explore_nameless"; case BgmType.BossLedger: return "boss_ledger"; case BgmType.BossFinal: return "boss_final"; + case BgmType.Guardian: return "guardian"; + case BgmType.Rival: return "rival"; default: return "explore"; } } @@ -575,6 +595,19 @@ public void PlaySkillSFX(InsectGame.Data.InsectElement element) sfxSource.PlayOneShot(clip, masterVolume * sfxVolume); } + /// + /// 전투 중 곤충 울음·비명 — 키는 cry_계열/hurt_계열(계열 분류는 BattleShout.CryFor). + /// 효과음보다 살짝 작게 — 타격음을 덮으면 "맞았다"가 흐려진다. + /// + public void PlayCry(string key) + { + EnsureInitialized(); + if (string.IsNullOrEmpty(key)) return; + AudioClip clip = ProceduralAudioGenerator.GetSFX(key); + if (clip != null) + sfxSource.PlayOneShot(clip, masterVolume * sfxVolume * 0.8f); + } + private static string ElementToString(InsectGame.Data.InsectElement element) { switch (element) diff --git a/Assets/Scripts/Core/AuthManager.cs b/Assets/Scripts/Core/AuthManager.cs index a3acc72..bfdab0c 100644 --- a/Assets/Scripts/Core/AuthManager.cs +++ b/Assets/Scripts/Core/AuthManager.cs @@ -877,6 +877,23 @@ public void DeleteAccount() private IEnumerator DeleteAccountCoroutine() { + // 0) 공개해 둔 섬을 내린다 — `islands/{uid}`는 서버 전용 컬렉션이라 클라이언트가 직접 못 지우고, + // Auth 계정이 사라진 뒤엔 토큰이 없어 부를 수도 없다. 안 내리면 탈퇴한 사람의 섬(닉네임 포함)이 + // 섬 코드로 계속 열린다. 실패해도 삭제는 계속한다(서버 미배포·오프라인이면 올린 섬도 없다). + if (FirebaseConfig.IsSocialPvpConfigured) + { + using (UnityWebRequest island = new UnityWebRequest(FirebaseConfig.SocialPvpApiUrl, "POST")) + { + island.uploadHandler = new UploadHandlerRaw( + System.Text.Encoding.UTF8.GetBytes("{\"action\":\"deleteIsland\"}")); + island.downloadHandler = new DownloadHandlerBuffer(); + island.SetRequestHeader("Content-Type", "application/json"); + island.SetRequestHeader("Authorization", "Bearer " + IdToken); + island.timeout = 15; + yield return island.SendWebRequest(); + } + } + // 1) Firestore 사용자 문서 삭제 (토큰 유효한 동안 먼저). string docUrl = FirebaseConfig.FirestoreBaseUrl + "/users/" + UserId; using (UnityWebRequest del = UnityWebRequest.Delete(docUrl)) @@ -938,7 +955,7 @@ private void ClearAllLocalData() GameConstants.SaveFiles.PlayerProgress, GameConstants.SaveFiles.PlayerInsects, GameConstants.SaveFiles.PlayerCandies, GameConstants.SaveFiles.PlayerCurrency, GameConstants.SaveFiles.PlayerItems, GameConstants.SaveFiles.BattleTeam, - GameConstants.SaveFiles.DexSave + GameConstants.SaveFiles.DexSave, GameConstants.SaveFiles.Island }; foreach (string f in files) { diff --git a/Assets/Scripts/Core/BattlePresentation.cs b/Assets/Scripts/Core/BattlePresentation.cs new file mode 100644 index 0000000..a0af266 --- /dev/null +++ b/Assets/Scripts/Core/BattlePresentation.cs @@ -0,0 +1,94 @@ +using UnityEngine; + +namespace InsectGame.Core +{ + /// Presentation preferences only; never used by combat resolution or turn counters. + public static class BattlePresentation + { + private const string SpeedKey = "BattlePresentation.Speed"; + private const string MotionKey = "BattlePresentation.ReducedMotion"; + private const string FlashKey = "BattlePresentation.ReducedFlashes"; + private static float speed = PlayerPrefs.GetInt(SpeedKey, 1) == 2 ? 2f : 1f; + private static bool reducedMotion = PlayerPrefs.GetInt(MotionKey, 0) != 0; + private static bool reducedFlashes = PlayerPrefs.GetInt(FlashKey, 0) != 0; + + public static float Speed + { + get => speed; + set { speed = value >= 2f ? 2f : 1f; PlayerPrefs.SetInt(SpeedKey, (int)speed); PlayerPrefs.Save(); } + } + public static bool ReducedMotion + { + get => reducedMotion; + set { reducedMotion = value; PlayerPrefs.SetInt(MotionKey, value ? 1 : 0); PlayerPrefs.Save(); } + } + public static bool ReducedFlashes + { + get => reducedFlashes; + set { reducedFlashes = value; PlayerPrefs.SetInt(FlashKey, value ? 1 : 0); PlayerPrefs.Save(); } + } + public static float DeltaTime => Time.deltaTime * Speed * TimeScale; + + // ── 히트스톱·슬로모션 ── + // + // 전투 연출의 모든 시계(아레나 코루틴·페이즈 타이머·HP 보간)가 위 DeltaTime 하나를 쓴다. + // 그래서 여기서 배율을 0으로 떨구면 **화면 전체가 같은 순간에 멈춘다** — 타격 순간 한두 + // 프레임 정지가 "맞았다"를 몸으로 느끼게 하는 장치다. Time.timeScale을 건드리지 않으므로 + // 필드·UI 트윈·오디오는 그대로 돈다(BattleScreenUI.OnDisable의 슬로모션 복구와도 무관). + // + // 만료는 **실제 시간**으로 센다 — 멈춘 시계로 세면 영영 안 풀린다. + + private static float stopUntil; + private static float slowUntil; + private static float slowScale = 1f; + + /// 테스트용 시계 이음매. 기본은 실제 시간. + internal static System.Func Clock = () => Time.unscaledTime; + + /// + /// 연출 시계 배율 — 히트스톱 중 0, 슬로모션 중 그 배율, 평소 1. + /// 전투 결과·턴 카운터는 이 값을 보지 않는다(표시 전용). + /// + public static float TimeScale + { + get + { + float now = Clock(); + if (now < stopUntil) return 0f; + if (now < slowUntil) return slowScale; + return 1f; + } + } + + /// + /// 타격 순간 연출을 잠깐 멈춘다(실제 초). 2배속이면 절반만 멈춘다 — 배속을 고른 사람은 + /// 멈춤도 짧기를 원한다. 겹쳐 부르면 더 늦게 끝나는 쪽을 따른다(연장이지 누적이 아니다). + /// + public static void HitStop(float seconds) + { + if (seconds <= 0f) return; + float until = Clock() + seconds / Mathf.Max(1f, speed); + if (until > stopUntil) stopUntil = until; + } + + /// + /// 일정 시간 연출을 느리게 돌린다(치명타·마무리 일격). 움직임 줄이기를 켠 사람에겐 생략한다 — + /// 느려진 화면 흔들림·돌진이 멀미를 부르는 쪽이라서다. + /// + public static void SlowMotion(float scale, float seconds) + { + if (seconds <= 0f || reducedMotion) return; + slowScale = Mathf.Clamp(scale, 0.05f, 1f); + float until = Clock() + seconds / Mathf.Max(1f, speed); + if (until > slowUntil) slowUntil = until; + } + + /// 전투가 끝나거나 아레나가 정리될 때 — 남은 정지가 다음 전투 첫 프레임을 먹지 않게. + public static void ClearTimeEffects() + { + stopUntil = 0f; + slowUntil = 0f; + slowScale = 1f; + } + } +} diff --git a/Assets/Scripts/Core/BattlePresentation.cs.meta b/Assets/Scripts/Core/BattlePresentation.cs.meta new file mode 100644 index 0000000..51ce9f3 --- /dev/null +++ b/Assets/Scripts/Core/BattlePresentation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ed2b0ac651b23e04a934560745bbe107 \ No newline at end of file diff --git a/Assets/Scripts/Core/BlightPolicy.cs b/Assets/Scripts/Core/BlightPolicy.cs index c46c492..cd6ff2e 100644 --- a/Assets/Scripts/Core/BlightPolicy.cs +++ b/Assets/Scripts/Core/BlightPolicy.cs @@ -45,11 +45,11 @@ public static class BlightPolicy public const float DrainBrightness = 0.62f; /// - /// 이 리전의 동시 출현 상한. 오염이면 로 나누되 + /// 이 리전의 곤충 수 상한. 오염이면 로 나누되 /// 아래로는 내려가지 않는다. /// - /// 를 상수로 가정하지 않는다 — 스포너의 기본값은 10이고 - /// ApplyTuning이 프로파일 값으로 덮는다. + /// 를 상수로 가정하지 않는다 — 스포너는 리전마다 설 수 있는 땅 × 밀도로 정한 + /// 슬롯 수(FieldSpawnRules.SlotCountFor, 8~40)를 넘긴다. /// public static int MaxActiveFor(bool blighted, int baseMax) { @@ -59,9 +59,8 @@ public static int MaxActiveFor(bool blighted, int baseMax) // 하한을 그대로 돌려주면 오염된 리전이 멀쩡한 리전보다 곤충이 많아진다 — // 줄이는 정책이 늘리는 정책이 된다. baseMax=1에서 실제로 2를 돌려주고 있었다. // - // 도달 가능한 값이다: GameplayTuningProfile.maxActivePerRegion이 - // [Range(1, 15)]라 인스펙터에서 1을 넣을 수 있고, - // InsectSpawner.ApplyTuning이 Mathf.Max(1, …)로 그대로 받는다. + // 당시엔 도달 가능한 값이었다(튜닝 프로파일 maxActivePerRegion이 [Range(1, 15)]). + // 지금 스포너는 8 이상을 넘기지만 정의역 전체에서 지키는 규칙이라 그대로 둔다. return Mathf.Min(baseMax, Mathf.Max(MinActive, baseMax / ScarcityDivisor)); } diff --git a/Assets/Scripts/Core/BlightVfx.cs b/Assets/Scripts/Core/BlightVfx.cs index d5e8f89..bd4f81d 100644 --- a/Assets/Scripts/Core/BlightVfx.cs +++ b/Assets/Scripts/Core/BlightVfx.cs @@ -548,21 +548,26 @@ private static GameObject Prim(PrimitiveType type, string name, Transform parent } /// - /// 셰이더 폴백 체인 — 이 저장소의 다른 빌더들과 같은 순서다. - /// Built-in 파이프라인이라 Standard가 정상 해석되고 나머지는 방어선이다. + /// 불투명 구조물(기둥·가로대·상자·장부대·붕괴 조각) — 월드 소품과 같은 무광 마감이다 + /// (: 셰이더 폴백 + ). + /// + /// 예전엔 결과 픽셀을 바꾸지 않으려고 Standard 기본 광택(0.5)을 남겨 뒀는데, 그래서 거점만 무광 지형 + /// 사이에서 플라스틱처럼 반짝였다(2026-09-29 광택 통일). 부품이 전부 나무·흙이라 광택 예외는 없다 — + /// 금속·유리 부품을 더하면 그 자리에서 로 재질을 명시할 것. + /// + /// 도 이걸 거치지만 알파가 있는 색은 가 + /// 건너뛰므로 그물·연무·정화 고리의 결과는 그대로다. /// private static 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 m = new Material(shader); - m.color = color; - if (m.HasProperty("_BaseColor")) m.SetColor("_BaseColor", color); - return m; + return SceneryMaterials.Create(color); } + /// + /// 그물·연무·붕괴 고리 같은 이펙트성 반투명. 설정은 와 같은 Standard Fade다. + /// 한때 Standard의 투명 변형이 플레이어 빌드에서 통째로 빠져 불투명으로 그려졌는데(BattleArenaController.CreateFxMaterial + /// 주석), 지금은 가 그 변형을 빌드에 남긴다(2026-09-29 QA 빌드 실측). + /// private static Material Transparent(Color color) { Material m = Mat(color); diff --git a/Assets/Scripts/Core/CameraFollower.cs b/Assets/Scripts/Core/CameraFollower.cs index 7de1324..5b873c0 100644 --- a/Assets/Scripts/Core/CameraFollower.cs +++ b/Assets/Scripts/Core/CameraFollower.cs @@ -36,6 +36,12 @@ public class CameraFollower : MonoBehaviour private Quaternion normalRot; private float battleTransition; + // 전투 연출 샷 — 배틀 구도(battlePos/battleRot) 위에 가중치로 얹는다. 구도 자체를 덮어쓰지 + // 않으므로 가중치만 0으로 내리면 원래 구도로 정확히 돌아온다(연출이 끊겨도 카메라가 남지 않는다). + private Vector3 shotPos; + private Quaternion shotRot = Quaternion.identity; + private float shotWeight; + private float shakeIntensity; private float shakeDuration; private float shakeTimer; @@ -91,12 +97,15 @@ public void EnterBattleMode(Vector3 playerPos, Vector3 enemyPos) public void EnterBattleModeFramed(Vector3 camPos, Vector3 lookTarget) { battleMode = true; + // A framed arena can be kilometres from the field. Never interpolate through the world. + battleTransition = 1f; focusTimer = 0f; // 진행 중 시네마틱 포커스 취소(배틀 카메라 우선) focusReleasing = false; normalPos = transform.position; normalRot = transform.rotation; battlePos = camPos; + shotWeight = 0f; // 새 구도 — 앞 연출의 샷을 물려받지 않는다 Vector3 toTarget = lookTarget - camPos; // 카메라와 시선 지점이 겹치면 LookRotation이 0벡터를 받아 회전이 무너진다. battleRot = toTarget.sqrMagnitude > 0.0001f @@ -104,9 +113,26 @@ public void EnterBattleModeFramed(Vector3 camPos, Vector3 lookTarget) : transform.rotation; } + /// + /// 전투 연출 샷을 건다 — 배틀 구도와 만큼 섞는다(0이면 원래 구도). + /// 매 프레임 불러 움직이는 샷을 만든다. 구도는 호출부(BattleCameraDirector)가 계산한다. + /// + public void SetBattleShot(Vector3 position, Quaternion rotation, float weight) + { + shotPos = position; + shotRot = rotation; + shotWeight = Mathf.Clamp01(weight); + } + + public void ClearBattleShot() + { + shotWeight = 0f; + } + public void ExitBattleMode() { battleMode = false; + shotWeight = 0f; // battle→normal 전환 시 baselinePos가 옛 battlePos 잔존 → normal 첫 프레임 lerpFrom이 // 멀리서 슬슬 들어오는 어색함. transition 보간이 별도 수행되므로 baseline은 reset. baselineValid = false; @@ -128,6 +154,44 @@ public void SetSubAreaMode(bool active) baselineValid = false; } + /// + /// 팔로우 오프셋을 잠깐 바꿔 쓴다 — 섬 꾸미기처럼 더 높은 부감이 필요한 화면용. + /// 끝나면 반드시 로 되돌릴 것(오프셋은 저장되지 않는 런타임 상태다). + /// + public void SetOffsetOverride(Vector3 overrideOffset) + { + offset = overrideOffset; + offsetOverridden = true; + baselineValid = false; + } + + public void ClearOffsetOverride() + { + offset = NormalOffset; + offsetOverridden = false; + baselineValid = false; + } + + /// + /// 대상이 먼 곳으로 순간이동했을 때 카메라도 그 자리에서 바로 따라붙인다. + /// 만으로는 부족하다 — 그건 보간의 출발점을 "지금 카메라 자리"로 되돌릴 뿐이라, + /// 수 km 떨어진 섬으로 옮기면 카메라가 옛 자리에서 1~2초 동안 날아온다(그동안 섬이 먼 점으로 보인다). + /// + public void SnapToTarget() + { + if (target == null) return; + Vector3 position = target.position + offset + target.forward * lookAheadDistance; + transform.position = position; + transform.rotation = Quaternion.LookRotation(target.position + Vector3.up * 0.85f - position, Vector3.up); + baselinePos = position; + baselineValid = true; + } + + // 오프셋을 덮어쓴 동안은 차폐 보정을 끈다. 덮어쓰는 쪽(섬 꾸미기)은 카메라 대상을 플레이어가 아닌 + // 보이지 않는 초점으로 바꾸는데, 그러면 "대상 자신은 차폐가 아니다" 예외에서 플레이어가 빠진다 — + // 초점이 플레이어 자리에서 출발하므로 플레이어 캡슐이 곧바로 차폐로 잡혀 카메라가 3.5m까지 끌려 들어온다. + private bool offsetOverridden; + public bool InBattleMode => battleMode; /// @@ -152,6 +216,7 @@ public void GetBattleFraming(out Vector3 position, out Quaternion rotation) /// public void Shake(float intensity, float duration) { + if (BattlePresentation.ReducedMotion) return; ResolveShake(shakeIntensity, shakeDuration, shakeTimer, intensity, duration, out shakeIntensity, out shakeDuration, out shakeTimer); } @@ -236,9 +301,16 @@ private void LateUpdate() float t = Mathf.SmoothStep(0f, 1f, battleTransition); Vector3 followPos = target.position + offset + target.forward * lookAheadDistance; - finalPos = Vector3.Lerp(followPos, battlePos, t); + Vector3 framedPos = battlePos; + Quaternion framedRot = battleRot; + if (shotWeight > 0f) + { + framedPos = Vector3.Lerp(battlePos, shotPos, shotWeight); + framedRot = Quaternion.Slerp(battleRot, shotRot, shotWeight); + } + finalPos = Vector3.Lerp(followPos, framedPos, t); Quaternion followRot = Quaternion.LookRotation(target.position + Vector3.up - followPos); - finalRot = Quaternion.Slerp(followRot, battleRot, t); + finalRot = Quaternion.Slerp(followRot, framedRot, t); } else { @@ -258,7 +330,7 @@ private void LateUpdate() Vector3 lookTarget = target.position + Vector3.up * 0.85f; // 카메라 시야 차단 보정 — 나무/벽/기둥 등 정적 scenery가 카메라-플레이어 // 사이를 가리면 카메라를 플레이어 쪽으로 당겨 시야 확보. - desiredPosition = ResolveObstruction(lookTarget, desiredPosition); + if (!offsetOverridden) desiredPosition = ResolveObstruction(lookTarget, desiredPosition); // 시네마틱 포커스(첫 조우 등) — focusPoint 쪽으로 잠깐 줌인 후 복귀(0→1→0 벨 이즈). // ReleaseFocus() 호출(모달 조기 닫힘) 시엔 현재 amt에서 짧게 이즈아웃. diff --git a/Assets/Scripts/Core/CharacterModelPreviewRenderer.cs b/Assets/Scripts/Core/CharacterModelPreviewRenderer.cs index c24b9ad..1440897 100644 --- a/Assets/Scripts/Core/CharacterModelPreviewRenderer.cs +++ b/Assets/Scripts/Core/CharacterModelPreviewRenderer.cs @@ -25,6 +25,9 @@ public class CharacterModelPreviewRenderer : MonoBehaviour private const int PreviewW = 512; private const int PreviewH = 768; // 캐릭터는 세로로 길다 + + /// 미리보기 텍스처의 세로/가로 비 — 그리는 쪽이 그림이 실제로 차지할 높이를 셀 때 쓴다. + public const float PreviewHeightPerWidth = PreviewH / (float)PreviewW; private const int ThumbSize = 160; // 카드 프리뷰 영역이 가상좌표 100px private const int ThumbCacheMax = 24; // 24 × 160² × 4B ≈ 2.5MB. 한 슬롯 최대 16장 + 탭 전환 여유 @@ -48,6 +51,10 @@ public class CharacterModelPreviewRenderer : MonoBehaviour private RenderTexture currentRT; private OutfitLoadout requestLoadout; private float requestAngle; + private bool requestCloseUp; + private bool shownCloseUp; + // 현재 장착 조합 — 상점·결투 컷인이 "지금 내 모습"을 그릴 때 쓰는 재사용 버퍼(OnGUI마다 새로 만들지 않는다). + private readonly OutfitLoadout equippedLoadout = new OutfitLoadout(); private int shownLoadoutHash = int.MinValue; private float shownAngle = float.NaN; /// 마네킹이 지금 입고 있는 조합. RT에 찍힌 것()과는 별개다. @@ -108,12 +115,33 @@ public override int GetHashCode() /// 돌려준다 — 드래그 중에 null을 내면 회전할 때마다 2D로 깜빡인다. /// public Texture GetPreview(OutfitLoadout loadout, float yAngle) + { + return GetPreview(loadout, yAngle, false); + } + + /// 상반신(머리 + 몸통)만 크게 — 모자·얼굴·상의 무늬를 볼 때. 전신과 바뀌면 다시 찍는다. + public Texture GetPreview(OutfitLoadout loadout, float yAngle, bool closeUp) { requestLoadout = loadout; requestAngle = yAngle; + requestCloseUp = closeUp; return currentRT; } + /// + /// **지금 장착한 모습** 그대로의 캐릭터. 상점의 캐릭터 칸·결투 컷인의 "나"가 쓴다 — 예전엔 둘 다 2D 도트였고 + /// 2D는 모자 11종·겉옷 형태·도구 대부분을 그리지 못해 필드 캐릭터와 다른 사람이었다. + /// 큰 패널 RT 하나를 공유하므로 **한 번에 한 화면만** 쓴다(모달이 겹치지 않는 자리에서만 부를 것). + /// + public Texture GetEquippedPreview(float yAngle) + { + equippedLoadout.CopyFrom(CharacterOutfitManager.Instance); + return GetPreview(equippedLoadout, yAngle, false); + } + + /// 상반신 확대의 초점 노드 — 머리(모자·머리카락 포함)와 몸통(셔츠 판·옷깃·가방 포함). + internal static readonly string[] CloseUpFocus = { "HeadPivot", "Body" }; + /// /// 카드 썸네일. 캐시에 있으면 즉시, 없으면 렌더 큐에 넣고 null(호출부는 2D 폴백을 그린다). /// 중립 기준(다른 슬롯 미장착)으로 렌더하므로 "이 아이템이 어떻게 생겼나"를 답한다 — @@ -164,8 +192,31 @@ public void InvalidatePreview() /// public void SetAppearanceOverride(AppearanceSpec? spec) { + // 생성 화면은 이걸 OnGUI 패스마다 부른다. 값이 같으면 아무것도 하지 않는다 — + // 매번 dirty를 세우면 렌더 판정이 늘 참이 돼 멈춰 있어도 매 프레임 다시 찍는다. + if (!OverrideChanged(appearanceOverride, spec)) return; appearanceOverride = spec; - appearanceDirty = true; // EnsureMannequin이 다시 판정하게 + appearanceDirty = true; // EnsureMannequin이 다시 판정하고, Update가 다시 찍게 + } + + /// 두 오버라이드가 다른 마네킹을 짓게 하는가. null(=PlayerPrefs)과 값은 늘 다르다. + internal static bool OverrideChanged(AppearanceSpec? current, AppearanceSpec? next) + { + if (current.HasValue != next.HasValue) return true; + return current.HasValue && current.Value.Hash() != next.Value.Hash(); + } + + /// + /// 큰 패널을 다시 찍어야 하는가. + /// + /// 가 오래 빠져 있었다 — 생성 화면에서 성별·머리·피부·표정을 + /// 바꾸면 오버라이드는 갱신되는데, 이 판정이 로드아웃과 각도만 봐서 드래그하기 전까지 그림이 + /// 그대로였다(세부 조정 5개 항목은 로드아웃을 안 바꾼다). + /// + internal static bool NeedsPanelRender(int loadoutHash, int shownHash, float angle, float shownAngle, + bool appearanceDirty) + { + return appearanceDirty || loadoutHash != shownHash || !Mathf.Approximately(shownAngle, angle); } // ── 렌더 루프 ── @@ -176,7 +227,8 @@ private void Update() if (requestLoadout != null) { int h = requestLoadout.Hash(); - if (h != shownLoadoutHash || !Mathf.Approximately(shownAngle, requestAngle)) + if (NeedsPanelRender(h, shownLoadoutHash, requestAngle, shownAngle, appearanceDirty) + || requestCloseUp != shownCloseUp) { EnsureRig(); EnsureMannequin(); @@ -187,8 +239,12 @@ private void Update() // 재배치라 그때마다 마네킹 계층을 여러 번 훑는다(OutfitShapeLibrary.FindDeep). // 마네킹이 이미 그 옷을 입고 있으면 카메라만 다시 찍으면 된다. if (appliedLoadoutHash != h) ApplyLoadout(requestLoadout); + // 큰 패널도 바뀔 때만 찍어 그 한 장이 남는다 — 깜빡이는 도중이면 감은 눈이 굳는다. + // 썸네일과 같은 처방(찍기 직전 눈을 뜬 상태로). + ResetFace(); if (currentRT == null) currentRT = CreateRT(PreviewW, PreviewH); - RenderMannequin(currentRT, requestAngle, null); + RenderMannequin(currentRT, requestAngle, requestCloseUp ? CloseUpFocus : null); + shownCloseUp = requestCloseUp; shownLoadoutHash = h; shownAngle = requestAngle; } @@ -215,14 +271,12 @@ private void RenderOneQueuedThumbnail() ThumbId key = new ThumbId(slot, itemId); if (thumbs.ContainsKey(key)) { Touch(key); return; } - soloLoadout.Clear(); - soloLoadout.Set(slot, itemId); + BuildThumbLoadout(soloLoadout, slot, itemId); ApplyLoadout(soloLoadout); // 썸네일은 한 장씩 구워져 캐시에 남는다 — 하필 눈을 감은 프레임에 찍히면 그 카드는 // 계속 감은 눈으로 보인다. 굽기 직전에 눈을 뜬 상태로 되돌린다. - CharacterFaceAnimator face = mannequin.GetComponent(); - if (face != null) face.ResetToNeutral(); + ResetFace(); RenderTexture rt = CreateRT(ThumbSize, ThumbSize); RenderMannequin(rt, ThumbAngleFor(slot), FocusNodesFor(slot)); @@ -236,6 +290,28 @@ private void RenderOneQueuedThumbnail() shownLoadoutHash = int.MinValue; } + private void ResetFace() + { + CharacterFaceAnimator face = mannequin != null ? mannequin.GetComponent() : null; + if (face != null) face.ResetToNeutral(); + } + + /// + /// 카드 썸네일이 입힐 중립 조합 — 그 아이템 하나만. + /// + /// 상의만 예외로 겉옷을 벗긴다. 겉옷 슬롯이 비면(null) 몸통·팔이 기본 자켓 색으로 칠해지고 + /// 상의는 가슴의 좁은 패널로만 보여서, 상의 카드 14장이 전부 "파란 자켓"으로 구워졌다. + /// outer_none이면 몸통이 상의 색을 받는다(CharacterOutfitManager.ApplyToCharacter). + /// + internal static void BuildThumbLoadout(OutfitLoadout target, OutfitSlot slot, string itemId) + { + target.Clear(); + target.Set(slot, itemId); + if (slot == OutfitSlot.Top) target.Set(OutfitSlot.Outerwear, OuterNoneId); + } + + internal const string OuterNoneId = "outer_none"; + /// /// LRU 갱신 — key를 가장 최근으로 옮긴다. 키 타입이 문자열에서 구조체로 바뀌면서 제네릭이 됐다 /// (순수 리스트 조작이라 타입과 무관하고, 기존 문자열 테스트도 그대로 돈다). @@ -337,7 +413,14 @@ private void EnsureMannequin() int h = spec.Hash(); if (mannequin != null && mannequinLookHash == h) return; - if (mannequin != null) Destroy(mannequin); + if (mannequin != null) + { + // **먼저 끈다.** Destroy는 프레임 끝이라 그대로 두면 이 프레임에 옛 마네킹과 새 마네킹이 같은 자리에서 + // 함께 찍히고, 그 한 장이 다음 변경까지 화면에 남는다 — 생성 화면에서 머리를 바꾸면 옛 머리가 겹쳐 보였다 + // (2026-09-30 검수 빌드 캡처에서 발견. PlayerVisualBuilder.RebuildFromPrefs와 같은 처방). + mannequin.SetActive(false); + Destroy(mannequin); + } ReleaseThumbs(); // 외형이 바뀌면 구운 썸네일이 전부 낡는다 GameObject go = new GameObject("OutfitMannequin"); diff --git a/Assets/Scripts/Core/CharacterOutfitManager.cs b/Assets/Scripts/Core/CharacterOutfitManager.cs index 55033ff..1b9d1a8 100644 --- a/Assets/Scripts/Core/CharacterOutfitManager.cs +++ b/Assets/Scripts/Core/CharacterOutfitManager.cs @@ -16,8 +16,47 @@ public class CharacterOutfitManager : MonoBehaviour, ICloudReloadable private PlayerCurrencyWallet wallet; + // 조건 해금(unlockCondition)의 출처. 셋 다 이 매니저보다 늦게 서므로 따로 배선한다. + private RegionManager unlockRegions; + private PlayerProgressController unlockProgress; + private TutorialQuestManager unlockQuests; + public event System.Action OutfitChanged; + /// + /// 조건을 채워 의상을 새로 얻었다(구매 제외). 의상 화면의 "NEW" 표시가 듣는다. + /// 구매는 사용자가 누른 버튼의 결과라 알릴 필요가 없어 여기로 오지 않는다. + /// + public event System.Action OutfitUnlocked; + + // ── "새로 얻음" 표시 ── + // 조건 해금으로 들어온 의상은 조용히 소유 목록에만 붙어 아무도 몰랐다. 의상 창의 NEW 배지·탭 점· + // 퀵바 점이 이 집합을 읽는다. **세션 한정**(저장하지 않는다) — 다시 켜면 사라지는 알림이면 충분하고, + // 저장 키를 늘리면 클라우드 DTO까지 따라 늘어야 한다(save-system.md). + private readonly HashSet newItems = new HashSet(); + + public bool IsNew(string itemId) => itemId != null && newItems.Contains(itemId); + public bool HasAnyNew => newItems.Count > 0; + + /// 사용자가 그 카드를 봤다(호버·선택) — 배지를 내린다. + public void MarkSeen(string itemId) + { + if (itemId != null) newItems.Remove(itemId); + } + + public bool HasNewInSlot(OutfitSlot slot) + { + foreach (string id in newItems) + if (outfitLookup.TryGetValue(id, out OutfitItem item) && item.slot == slot) return true; + return false; + } + + /// + /// 소유 목록이 바뀔 때마다 오른다(구매·해금·클라우드 재적재). 의상 창이 "보유/미보유" 필터 결과를 + /// 이 값이 같을 때만 재사용한다 — OnGUI마다 95벌을 다시 거르지 않게. + /// + public int OwnershipVersion { get; private set; } + private static string EquipKey => SaveScope.PrefsKey("InsectGame.Equipped"); private static string OwnedKey => SaveScope.PrefsKey("InsectGame.OwnedOutfits"); @@ -42,6 +81,7 @@ private void Awake() // `singleton_lint.py`가 이 짝을 강제한다. private void OnDestroy() { + UnsubscribeUnlockSources(); if (ReferenceEquals(Instance, this)) Instance = null; } @@ -50,12 +90,79 @@ public void AutoWire(PlayerCurrencyWallet w) if (wallet == null) wallet = w; } + /// + /// 조건 해금의 출처를 받는다. Bootstrap이 퀘스트 매니저를 만든 뒤에 부른다 — + /// 이 매니저는 UI 구간에서 먼저 생성돼 시점엔 퀘스트가 없다. + /// 받는 즉시 한 번 판정해 기존 세이브(이미 Lv.15인 계정 등)도 소급해서 푼다. + /// + public void AutoWireUnlockSources(RegionManager regions, PlayerProgressController progress, + TutorialQuestManager quests) + { + UnsubscribeUnlockSources(); + unlockRegions = regions; + unlockProgress = progress; + unlockQuests = quests; + if (unlockRegions != null) unlockRegions.RegionChanged += OnUnlockRegionChanged; + if (unlockProgress != null) unlockProgress.ProgressChanged += OnUnlockProgressChanged; + if (unlockQuests != null) unlockQuests.QuestCompleted += OnUnlockQuestCompleted; + EvaluateUnlocks(); + } + + private void UnsubscribeUnlockSources() + { + if (unlockRegions != null) unlockRegions.RegionChanged -= OnUnlockRegionChanged; + if (unlockProgress != null) unlockProgress.ProgressChanged -= OnUnlockProgressChanged; + if (unlockQuests != null) unlockQuests.QuestCompleted -= OnUnlockQuestCompleted; + } + + private void OnUnlockRegionChanged(Data.RegionData _) => EvaluateUnlocks(); + private void OnUnlockProgressChanged(PlayerProgressData _) => EvaluateUnlocks(); + private void OnUnlockQuestCompleted(TutorialQuest _) => EvaluateUnlocks(); + + /// + /// 아직 없는 조건부 의상을 전부 다시 판정한다. 대상은 네 벌뿐이라 이벤트마다 훑어도 싸다. + /// 레벨은 ProgressChanged가 XP 한 번마다 울려 가장 자주 오지만, 이미 가진 옷은 첫 줄에서 빠진다. + /// + public void EvaluateUnlocks() + { + if (allOutfits == null) return; + + string regionId = null; + int level = 0; + System.Func questDone = null; // 델리게이트는 후보가 있을 때만 만든다(XP마다 불린다) + bool gathered = false; + + for (int i = 0; i < allOutfits.Length; i++) + { + OutfitItem item = allOutfits[i]; + if (item == null || string.IsNullOrEmpty(item.unlockCondition)) continue; + if (ownedItems.Contains(item.itemId)) continue; + + if (!gathered) + { + gathered = true; + if (unlockRegions != null && unlockRegions.CurrentRegion != null) + regionId = unlockRegions.CurrentRegion.regionId; + if (unlockProgress != null) level = unlockProgress.Level; + if (unlockQuests != null) questDone = unlockQuests.IsQuestCompleted; + } + if (!OutfitUnlockRules.IsMet(item.unlockCondition, regionId, level, questDone)) continue; + + UnlockItem(item.itemId); + newItems.Add(item.itemId); + Debug.Log($"[Outfit] 조건 해금 — {item.displayName} ({item.unlockCondition})"); + OutfitUnlocked?.Invoke(item); + } + } + // 클라우드 로드 후 PlayerPrefs(소유/장착 의상)를 다시 읽어 인메모리 갱신 + 외형 재적용. // OutfitChanged 발화 → PlayerVisualBuilder/PortraitRenderer가 클라우드 의상으로 재구성. public void ReloadFromDisk() { LoadOwnership(); LoadEquipment(); + // 다른 기기에서 조건을 채웠는데 소유 목록이 그 전 것일 수 있다 — 다시 판정한다. + EvaluateUnlocks(); OutfitChanged?.Invoke(); } @@ -632,45 +739,98 @@ public void ApplyToCharacter(GameObject player, OutfitLoadout loadout) // 모자 — 색을 먼저 적용해 Cap/CapBrim을 되살린 뒤, 레시피가 있으면 그때 다시 숨긴다. // 순서가 뒤집히면 왕관 → 탐험가 캡으로 갈아입을 때 Cap이 숨겨진 채 남아 맨머리가 된다. OutfitItem hat = Resolve(loadout, OutfitSlot.Hat); + // 모자 선 위로 솟는 머리(올림머리)는 먼저 되살리고, 정수리를 덮는 모자면 아래에서 다시 숨긴다 — + // 레시피 hideNodes는 끄기만 하므로 여기서 켜 두지 않으면 헬멧을 벗어도 스파이크가 안 돌아온다. + Transform crown = FindDeep(player.transform, OutfitShapeLibrary.HairCrownNode); + if (crown != null) crown.gameObject.SetActive(true); ApplyPartColor(player, "Cap", hat != null ? hat.primaryColor : Color.clear); ApplyPartColor(player, "CapBrim", hat != null ? hat.primaryColor : Color.clear); ApplyShapeRecipe(player, OutfitSlot.Hat, hat); - - // 상의 + // 레시피 없는 모자(기본 캡 계열)는 Cap 노드 자체가 모자다 — 그게 보이면 정수리를 덮는다. + Transform capNode = FindDeep(player.transform, "Cap"); + if (crown != null && capNode != null && capNode.gameObject.activeSelf) crown.gameObject.SetActive(false); + + // ── 상의·겉옷 ── + // 겉옷의 **형태**(OutfitShapeLibrary.OuterFormOf)가 몸통·팔·셔츠 판을 누가 칠할지 정한다. + // 겉옷 없음(outer_none)·망토 → 몸통은 상의. 팔은 소매 길이대로(긴소매 = 상의, 반소매 = 피부 + 어깨 캡만 상의). + // 열린 자켓 → 몸통·팔은 겉옷, 앞섶 사이 셔츠 판에 상의(무늬 포함), 옷깃·라펠 표시. + // 로브 → 몸통·팔은 겉옷, 상의는 안 보인다. + // 예전엔 자켓이 셔츠 판을 폭 0.16의 좁은 조각으로만 남겨 상의 14벌이 사실상 안 보였다. OutfitItem top = Resolve(loadout, OutfitSlot.Top); - ApplyPartColor(player, "Shirt", top != null ? top.primaryColor : defaultShirt); - - // 겉옷: outer_none이면 Body는 셔츠 색, 팔은 피부색으로 (몸통/팔이 사라지지 않게) OutfitItem outer = Resolve(loadout, OutfitSlot.Outerwear); - Color bodyCol, armCol; - if (outer == null) + string topId = top != null ? top.itemId : null; + Color topCol = top != null ? top.primaryColor : defaultShirt; + Color topSec = top != null ? top.secondaryColor : defaultShirt; + SleeveLength sleeve = OutfitShapeLibrary.SleeveOf(topId); + Color sleeveCol = OutfitShapeLibrary.SleeveIsSecondary(topId) ? topSec : topCol; + + bool outerWorn = outer == null || outer.primaryColor.a >= 0.01f; // null(미장착)은 기본 자켓 + string outerId = outer != null ? outer.itemId : null; + Color outerCol = outer != null ? outer.primaryColor : defaultJacket; + Color outerSec = outer != null ? outer.secondaryColor : defaultJacket; + OuterForm form = OutfitShapeLibrary.OuterFormOf(outerId); + bool torsoIsTop = !outerWorn || form == OuterForm.Cape; + + if (torsoIsTop) { - 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; + ApplyPartColor(player, "Body", topCol); + ApplyPattern(player, "Body", topId, PatternSurface.Torso, topCol, topSec); + Color armCol = sleeve == SleeveLength.Long ? sleeveCol : skinColor; + string armPattern = sleeve == SleeveLength.Long ? topId : null; + ApplyPartColor(player, "ArmL", armCol); + ApplyPartColor(player, "ArmR", armCol); + ApplyPattern(player, "ArmL", armPattern, PatternSurface.Sleeve, topCol, topSec); + ApplyPattern(player, "ArmR", armPattern, PatternSurface.Sleeve, topCol, topSec); + // 어깨 캡 — 반소매는 상의 색(피부로 두면 팔이 어깨부터 맨살이라 민소매로 읽힌다), 민소매는 피부. + Color capCol = sleeve == SleeveLength.None ? skinColor : sleeveCol; + ApplyPartColor(player, "ShoulderL", capCol); + ApplyPartColor(player, "ShoulderR", capCol); + // 셔츠 판은 자켓 사이로 보이는 상의 조각이다 — 자켓이 없으면 몸통 앞에 네모 주머니로 남는다. + ApplyPartColor(player, "Shirt", Color.clear); + SetJacketTrim(player, Color.clear); } else { - bodyCol = outer.primaryColor; armCol = outer.primaryColor; + ApplyPartColor(player, "Body", outerCol); + ApplyPattern(player, "Body", outerId, PatternSurface.Torso, outerCol, outerSec); + ApplyPartColor(player, "ArmL", outerCol); + ApplyPartColor(player, "ArmR", outerCol); + ApplyPattern(player, "ArmL", outerId, PatternSurface.Sleeve, outerCol, outerSec); + ApplyPattern(player, "ArmR", outerId, PatternSurface.Sleeve, outerCol, outerSec); + ApplyPartColor(player, "ShoulderL", outerCol); + ApplyPartColor(player, "ShoulderR", outerCol); + bool open = form == OuterForm.OpenJacket; + ApplyPartColor(player, "Shirt", open ? topCol : Color.clear); + if (open) ApplyPattern(player, "Shirt", topId, PatternSurface.Panel, topCol, topSec); + SetJacketTrim(player, open ? OutfitShapeLibrary.Darken(outerCol) : Color.clear); } - ApplyPartColor(player, "Body", bodyCol); - ApplyPartColor(player, "ArmL", armCol); - ApplyPartColor(player, "ArmR", armCol); ApplyShapeRecipe(player, OutfitSlot.Outerwear, outer); // 망토·로브 자락 등 덧붙임 파츠 + // 상의 덧붙임(카우보이 술 장식 등)은 상의가 **보일 때만** — 자켓 밑단 아래로 삐져나오지 않게. + ApplyShapeRecipe(player, OutfitSlot.Top, torsoIsTop ? top : null); - // 하의 + // ── 하의 ── 반바지는 다리를 피부로, 허벅지 천은 레시피가 단다. OutfitItem bot = Resolve(loadout, OutfitSlot.Bottom); - ApplyPartColor(player, "LegL", bot != null ? bot.primaryColor : defaultPants); - ApplyPartColor(player, "LegR", bot != null ? bot.primaryColor : defaultPants); - - // 신발 + string botId = bot != null ? bot.itemId : null; + Color botCol = bot != null ? bot.primaryColor : defaultPants; + Color botSec = bot != null ? bot.secondaryColor : defaultPants; + bool shorts = OutfitShapeLibrary.LegFormOf(botId) == LegForm.Shorts; + ApplyPartColor(player, "LegL", shorts ? skinColor : botCol); + ApplyPartColor(player, "LegR", shorts ? skinColor : botCol); + ApplyPattern(player, "LegL", shorts ? null : botId, PatternSurface.Leg, botCol, botSec); + ApplyPattern(player, "LegR", shorts ? null : botId, PatternSurface.Leg, botCol, botSec); + ApplyShapeRecipe(player, OutfitSlot.Bottom, bot); + + // ── 신발 ── 샌들은 발을 피부로, 밑창·끈은 레시피가 단다. OutfitItem shoe = Resolve(loadout, OutfitSlot.Shoes); - ApplyPartColor(player, "BootL", shoe != null ? shoe.primaryColor : defaultBoot); - ApplyPartColor(player, "BootR", shoe != null ? shoe.primaryColor : defaultBoot); + string shoeId = shoe != null ? shoe.itemId : null; + Color shoeCol = shoe != null ? shoe.primaryColor : defaultBoot; + Color shoeSec = shoe != null ? shoe.secondaryColor : defaultBoot; + bool sandal = OutfitShapeLibrary.FootFormOf(shoeId) == FootForm.Sandal; + ApplyPartColor(player, "BootL", sandal ? skinColor : shoeCol); + ApplyPartColor(player, "BootR", sandal ? skinColor : shoeCol); + ApplyPattern(player, "BootL", sandal ? null : shoeId, PatternSurface.Boot, shoeCol, shoeSec); + ApplyPattern(player, "BootR", sandal ? null : shoeId, PatternSurface.Boot, shoeCol, shoeSec); + ApplyShapeRecipe(player, OutfitSlot.Shoes, shoe); // 가방 OutfitItem bag = Resolve(loadout, OutfitSlot.Backpack); @@ -733,6 +893,38 @@ private static void ApplyShapeRecipe(GameObject player, OutfitSlot slot, OutfitI OutfitShapeLibrary.Apply(player.transform, slot, recipe, primary, secondary); } + /// + /// 무늬 텍스처를 입힌다(). 무늬가 없으면 텍스처를 **비운다** — + /// 갤럭시 티 → 기본 셔츠로 갈아입었는데 별이 남으면 안 된다. 색은 텍스처에 구워져 있으므로 머티리얼 색은 흰색으로 둔다. + /// ApplyPartColor 다음에 부를 것 — 그쪽이 색을 다시 칠하고, 여기서 무늬가 있으면 흰색으로 덮는다. + /// 꺼진 노드(셔츠 판이 숨은 경우 등)는 건드리지 않는다. + /// + private void ApplyPattern(GameObject root, string partName, string itemId, PatternSurface surface, + Color primary, Color secondary) + { + Transform part = FindDeep(root.transform, partName); + if (part == null || !part.gameObject.activeSelf) return; + MeshRenderer renderer = part.GetComponent(); + if (renderer == null) return; + + Texture2D tex = string.IsNullOrEmpty(itemId) ? null : OutfitPatternLibrary.Get(itemId, surface, primary, secondary); + Material mat = renderer.material; + mat.mainTexture = tex; + if (tex != null) + { + mat.color = Color.white; + if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", Color.white); + } + } + + /// 열린 자켓의 옷깃·라펠(PlayerVisualBuilder가 몸통 자식으로 만든다). 알파 0이면 숨긴다. + private void SetJacketTrim(GameObject root, Color color) + { + ApplyPartColor(root, "LapelL", color); + ApplyPartColor(root, "LapelR", color); + ApplyPartColor(root, "Collar", color); + } + private void ApplyPartColor(GameObject root, string partName, Color color) { Transform part = FindDeep(root.transform, partName); @@ -809,6 +1001,7 @@ private void LoadEquipment() private void SaveOwnership() { + OwnershipVersion++; string joined = string.Join(",", ownedItems); PlayerPrefs.SetString(OwnedKey, joined); PlayerPrefs.Save(); @@ -816,6 +1009,7 @@ private void SaveOwnership() private void LoadOwnership() { + OwnershipVersion++; // unlockedByDefault=true 아이템은 항상 ownedItems에 자동 등록 // (Equip은 ownedItems 가드가 있어서 누락 시 기본 장착이 silent fail됨) bool addedDefault = false; diff --git a/Assets/Scripts/Core/CharacterPresetLibrary.cs b/Assets/Scripts/Core/CharacterPresetLibrary.cs index 5e30b9d..203836f 100644 --- a/Assets/Scripts/Core/CharacterPresetLibrary.cs +++ b/Assets/Scripts/Core/CharacterPresetLibrary.cs @@ -82,8 +82,9 @@ public AppearanceSpec ToAppearance() new Preset("밤의 채집가", 1, 3, 4, 3, 2, new[] { "hat_cap", "top_shirt", "outer_jacket", "bot_pants", "shoe_boots", "bag_basic", "tool_magnify" }), - new Preset("직접 만들기", 0, 0, 0, 0, 1, new[] - { "hat_cap", "top_shirt", "outer_jacket", "bot_pants", "shoe_boots", "bag_basic", "tool_net" }), + // 탐험가와 머리색만 다른 복제였다(2026-09-30) — 세부 조정의 출발점답게 모자·겉옷 없는 캐주얼 기본형으로. + new Preset("직접 만들기", 0, 1, 0, 0, 1, new[] + { "hat_none", "top_polo", "outer_none", "bot_pants", "shoe_sneakers", "bag_basic", "tool_net" }), }; // ── 에셋 오버라이드 ────────────────────────────────── diff --git a/Assets/Scripts/Core/CloudSaveManager.cs b/Assets/Scripts/Core/CloudSaveManager.cs index 907e0ad..b49cdf0 100644 --- a/Assets/Scripts/Core/CloudSaveManager.cs +++ b/Assets/Scripts/Core/CloudSaveManager.cs @@ -407,6 +407,9 @@ private GameSaveData CollectSaveData() // 오염 거점 정화 기록 — 안 올리면 기기를 바꿀 때 정화한 리전이 다시 오염으로 뜬다. blightCleansed = PlayerPrefs.GetString( AuthManager.ScopedKey(GameConstants.PrefsKeys.BlightCleansed), ""), + // 배지 이정표 보상 수령 상태 — 안 올리면 기기를 바꿀 때마다 같은 보상을 다시 받는다. + badgeMilestonesClaimed = PlayerPrefs.GetString( + AuthManager.ScopedKey(GameConstants.PrefsKeys.BadgeMilestonesClaimed), ""), // 퀘스트는 계정별 키에서 읽어 현재 계정의 진행만 클라우드에 올린다(교차 오염 방지). questProgress = PlayerPrefs.GetString( AuthManager.ScopedKey(GameConstants.PrefsKeys.QuestProgress), ""), @@ -421,6 +424,8 @@ private GameSaveData CollectSaveData() // 스토리 진행은 story_progress.json 파일(StoryDirector 저작) — 퀘스트와 달리 파일 기반이라 // dexData/playerItems처럼 LoadLocalFile로 수집(계정별 SaveScope 경로). storyProgress = LoadLocalFile(GameConstants.SaveFiles.StoryProgress), + // 섬(보관함·배치·방목·누적 수확) — story_progress.json과 같은 파일 블롭이다. + islandData = LoadLocalFile(GameConstants.SaveFiles.Island), // 캐릭터 외형(LoginUI가 PlayerPrefs "InsectGame.Character.*"에 저장) — 옛은 클라우드 // 미수집이라 다른 기기 접속 시 외형(피부/머리/표정/성별) 전부 초기화됐음. charCreated = PlayerPrefs.GetInt(SaveScope.PrefsKey("InsectGame.Character.Created"), 0), @@ -534,6 +539,10 @@ private void ApplyResolved(GameSaveData data, bool forceReplace = false) QuestSaveMerge.MaxIntDict); ApplyQuestField(GameConstants.PrefsKeys.QuestSideRepeat, data.questSideRepeat, forceReplace, QuestSaveMerge.MaxIntDict); + // 배지 이정표 수령도 단조 증가(받은 걸 도로 안 받는다)라 합집합이 안전하다 — 낡은 클라우드가 수령 + // 기록을 지우면 같은 보상을 다시 받는다. 병합 규칙이 퀘스트 완료와 같아 그 경로를 그대로 탄다. + ApplyQuestField(GameConstants.PrefsKeys.BadgeMilestonesClaimed, data.badgeMilestonesClaimed, forceReplace, + QuestSaveMerge.UnionCsv); // 캐릭터 외형 — 옛 클라우드 문서엔 없을 수 있어 sentinel(-1)이면 로컬 유지(초기화 방지). if (data.charCreated == 1) PlayerPrefs.SetInt(SaveScope.PrefsKey("InsectGame.Character.Created"), 1); @@ -569,6 +578,8 @@ private void ApplyResolved(GameSaveData data, bool forceReplace = false) ApplyCloudFile(GameConstants.SaveFiles.PlayerItems, data.playerItems, forceReplace); // 스토리 진행 파일 — dexData/playerItems와 동형. StoryDirector.ReloadFromDisk가 아래 reloadables 순회에서 인메모리 갱신. ApplyCloudFile(GameConstants.SaveFiles.StoryProgress, data.storyProgress, forceReplace); + // 섬 — IslandManager.ReloadFromDisk가 아래 reloadables 순회에서 다시 읽는다(곤충 컬렉션 뒤에 등록돼 있다). + ApplyCloudFile(GameConstants.SaveFiles.Island, data.islandData, forceReplace); // 파일/PlayerPrefs 갱신 후 인메모리 캐시 리로드 — 곤충/팀/도감/지역/의상 등이 // 다른 기기 첫 로그인에서도 즉시 반영(앱 재시작 불필요). @@ -681,12 +692,14 @@ private string ConvertToFirestoreDocument(GameSaveData data) sb.Append(","); AppendStringField(sb, "defeatedBosses", data.defeatedBosses); sb.Append(","); AppendStringField(sb, "weeklyContestClaimed", data.weeklyContestClaimed); sb.Append(","); AppendStringField(sb, "blightCleansed", data.blightCleansed); + sb.Append(","); AppendStringField(sb, "badgeMilestonesClaimed", data.badgeMilestonesClaimed); sb.Append(","); AppendStringField(sb, "questProgress", data.questProgress); sb.Append(","); AppendStringField(sb, "questCompleted", data.questCompleted); sb.Append(","); AppendStringField(sb, "activeQuest", data.activeQuest); sb.Append(","); AppendStringField(sb, "questSideProgress", data.questSideProgress); sb.Append(","); AppendStringField(sb, "questSideRepeat", data.questSideRepeat); sb.Append(","); AppendStringField(sb, "storyProgress", data.storyProgress); + sb.Append(","); AppendStringField(sb, "islandData", data.islandData); sb.Append(","); AppendIntField(sb, "charCreated", data.charCreated); sb.Append(","); AppendStringField(sb, "charName", data.charName); sb.Append(","); AppendStringField(sb, "charStarter", data.charStarter); @@ -731,12 +744,14 @@ private GameSaveData ParseFirestoreDocument(string json) data.defeatedBosses = ExtractStringValue(json, "defeatedBosses"); data.weeklyContestClaimed = ExtractStringValue(json, "weeklyContestClaimed"); data.blightCleansed = ExtractStringValue(json, "blightCleansed"); + data.badgeMilestonesClaimed = ExtractStringValue(json, "badgeMilestonesClaimed"); data.questProgress = ExtractStringValue(json, "questProgress"); data.questCompleted = ExtractStringValue(json, "questCompleted"); data.activeQuest = ExtractStringValue(json, "activeQuest"); data.questSideProgress = ExtractStringValue(json, "questSideProgress"); data.questSideRepeat = ExtractStringValue(json, "questSideRepeat"); data.storyProgress = ExtractStringValue(json, "storyProgress"); + data.islandData = ExtractStringValue(json, "islandData"); // 캐릭터 외형 — 옛 문서엔 없을 수 있어 sentinel(-1)로 받아 ApplySaveData에서 로컬 보존. data.charCreated = ExtractIntValueOrDefault(json, "charCreated", 0); data.charName = ExtractStringValue(json, "charName"); @@ -788,56 +803,23 @@ private string EscapeJson(string input) .Replace("\t", "\\t"); } - /// - /// Firestore 응답에서 stringValue를 추출하는 간이 파서. - /// {"fieldName":{"stringValue":"..."}} 형식에서 값을 꺼냅니다. - /// - private string ExtractStringValue(string json, string fieldName) + // 필드 추출은 FirestoreDocParser가 한다 — 공백 없는 형식과 줄바꿈·들여쓰기된 형식을 둘 다 읽는다. + // 예전 구현은 `"키":{"stringValue":"`라는 공백 없는 마커를 통째로 찾아서, 응답이 들여쓰기돼 오면 + // 한 필드도 못 찾고 전부 기본값(레벨 0·캔디 0·빈 문자열)으로 복원했다. + private static string ExtractStringValue(string json, string fieldName) { - string marker = "\"" + fieldName + "\":{\"stringValue\":\""; - int start = json.IndexOf(marker, StringComparison.Ordinal); - if (start < 0) return ""; - start += marker.Length; - int end = json.IndexOf("\"}", start, StringComparison.Ordinal); - if (end < 0) return ""; - - // 이스케이프된 따옴표를 건너뛰기 - while (end > 0 && json[end - 1] == '\\') - { - end = json.IndexOf("\"}", end + 1, StringComparison.Ordinal); - if (end < 0) return ""; - } - - return json.Substring(start, end - start) - .Replace("\\\"", "\"") - .Replace("\\\\", "\\") - .Replace("\\n", "\n") - .Replace("\\r", "\r") - .Replace("\\t", "\t"); + FirestoreDocParser.TryGetString(json, fieldName, out string value); + return value; } - /// - /// Firestore 응답에서 integerValue를 추출하는 간이 파서. - /// {"fieldName":{"integerValue":"123"}} 형식에서 값을 꺼냅니다. - /// - private int ExtractIntValue(string json, string fieldName) + private static int ExtractIntValue(string json, string fieldName) { return ExtractIntValueOrDefault(json, fieldName, 0); } - private int ExtractIntValueOrDefault(string json, string fieldName, int defaultValue) + private static int ExtractIntValueOrDefault(string json, string fieldName, int defaultValue) { - string marker = "\"" + fieldName + "\":{\"integerValue\":\""; - int start = json.IndexOf(marker, StringComparison.Ordinal); - if (start < 0) return defaultValue; - start += marker.Length; - int end = json.IndexOf("\"", start, StringComparison.Ordinal); - if (end < 0) return defaultValue; - - string numStr = json.Substring(start, end - start); - if (int.TryParse(numStr, out int result)) - return result; - return defaultValue; + return FirestoreDocParser.TryGetInt(json, fieldName, out int value) ? value : defaultValue; } // ── 로컬 파일 IO ── @@ -924,6 +906,10 @@ public class GameSaveData /// — 옛 문서에 없어도 무해하고, 그게 이 기능이 기존 유저에게 보이는 방식이다. public string blightCleansed; + /// 받은 배지 이정표("4,8"). 기본 ""이면 아무것도 안 받은 상태다 — 옛 문서에 없어도 무해하고, + /// 부트 로드는 로컬과 합집합이라 낡은 문서가 수령 기록을 지우지도 않는다. + public string badgeMilestonesClaimed = ""; + public string questProgress; public string questCompleted; public string activeQuest; @@ -931,6 +917,8 @@ public class GameSaveData public string questSideRepeat; // 스토리 진행(seenBeatIds) — story_progress.json 내용. 파일 기반이라 수집/적용은 dexData와 동형. public string storyProgress; + /// 나의 섬 — island.json 내용. 기본 ""이면 로컬을 덮지 않는다(옛 문서에 없어도 무해하다). + public string islandData = ""; // 캐릭터 외형 — int는 -1 sentinel(옛 클라우드 문서 누락 시 로컬 유지), charCreated만 0 기본. public int charCreated; public string charName; diff --git a/Assets/Scripts/Core/DreamPrologueData.cs b/Assets/Scripts/Core/DreamPrologueData.cs new file mode 100644 index 0000000..187ac9f --- /dev/null +++ b/Assets/Scripts/Core/DreamPrologueData.cs @@ -0,0 +1,164 @@ +using System.Collections.Generic; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 「챔피언의 꿈」의 저작 데이터 — 챔피언 팀, 적, 꾸며진 섬. 순수 데이터와 순수 조립만 둔다. + /// + /// 전부 코드 상수다(Story.json이 아니라). 꿈은 이야기 비트가 아니라 한 번의 연출이고, 종·물건 ID가 + /// 바뀌면 DreamPrologueDataTests가 바로 잡는다 — ID 오타는 "그 곤충만 조용히 안 나오는" 결함이라 + /// 눈으로는 안 보인다. + /// + public static class DreamPrologueData + { + // ── 챔피언 ── + /// 에이스 — 챔피언전에서 직접 싸우는 곤충. + public const string AceInsectId = "beetle_hercules"; + public const int AceLevel = 80; + public const int AceIv = 15; + + // ── 상대 ── + /// 도전자. 이름 있는 전설종이라 "엄청 강한 상대를 이긴다"는 느낌이 선다. + public const string ChallengerInsectId = "dragonfly_ancient"; + public const int ChallengerLevel = 72; + + // ── 도입 영상 ── + /// + /// 경기장 입장 영상의 길이(초). Tools/Video/dream_arena.py의 T_TOTAL과 같아야 한다 — + /// 테스트가 mp4·wav 헤더로 맞춰 본다(디코더가 필요 없다). + /// + public const float IntroSeconds = 8f; + + /// 내 곤충이 쓸 기술 수 — 전투 화면의 스킬 카드 수와 같다. + public const int SkillCount = 4; + + /// 섬을 돌아다니는 곤충들. 전설 위주에 영웅 둘, 색다른 개체 둘. 방목 상한(10)을 넘지 않는다. + public static readonly (string insectId, int level, bool shiny)[] IslandInsects = + { + ("beetle_hercules", 80, false), + ("butterfly_alexandras", 76, true), + ("beetle_golden_stag", 78, false), + ("dragonfly_ancient", 74, false), + ("atlas_moth_giant", 72, false), + ("gacha_celestial_beetle", 80, false), + ("gacha_rainbow_butterfly", 70, true), + ("gacha_diamond_beetle", 75, false), + ("mantis_ghost", 64, false), + ("firefly_blue", 60, false), + }; + + /// + /// 섬 단계. 가장 큰 섬(22×22)이다 — 처음 보여 주는 섬이 "나중에 이만큼 커진다"의 맛보기다. + /// + public static readonly int IslandSizeLevel = GameConstants.Island.MaxSizeLevel; + + /// + /// 꾸며진 섬 배치(id, x, z, 회전). 입구(남쪽 가운데)에서 분수 광장까지 가운데 두 칸이 열려 있고 + /// 서쪽은 마을(오두막·온실·모닥불), 동쪽은 연못·풍차·등대다. 겹침·경계는 테스트가 고정한다. + /// + public static readonly (string id, int x, int z, int rot)[] IslandObjects = + { + // 남쪽 입구 + ("f_sign", 2, -10, 0), ("f_mailbox", -4, -10, 0), ("f_lantern", -3, -8, 0), ("f_lantern", 2, -8, 0), + ("t_flowerbed", -7, -9, 0), ("t_flowerbed", 4, -10, 0), ("t_bush", -6, -11, 0), ("t_bush", 7, -11, 0), + // 서쪽 마을 + ("b_cabin", -10, -6, 0), ("f_campfire", -7, -4, 0), ("f_bench", -9, -2, 0), ("o_feeder", -4, -2, 0), + ("o_basket", -5, -6, 0), ("f_hammock", -10, 1, 0), ("b_greenhouse", -10, 4, 0), ("t_blossom", -6, 5, 0), + ("t_sapling", -8, 8, 0), ("t_oak", -10, 8, 0), + // 중앙 분수 광장 + ("f_fountain", FountainX, FountainZ, 0), ("f_lantern", -3, 2, 0), ("f_lantern", 3, 2, 0), + ("f_lantern", -3, 5, 0), ("f_lantern", 3, 5, 0), ("f_bench", -4, 0, 0), ("f_bench", 2, 0, 0), + ("t_flowerbed", -5, 2, 0), ("t_flowerbed", 4, 3, 0), ("t_blossom", -3, 7, 0), ("t_blossom", 1, 7, 0), + ("t_bush", -4, 9, 0), ("t_bush", 3, 9, 0), + // 동쪽 + ("f_table", 3, -4, 0), ("t_pond", 6, -6, 0), ("t_oak", 8, -2, 0), ("o_honeypot", 4, -1, 0), + ("o_water", 6, 0, 0), ("b_windmill", 6, 3, 0), ("b_lighthouse", 8, 8, 0), ("t_boulder", 9, 5, 0), + ("t_flowerbed", 5, 8, 0), ("f_fence", -1, 9, 0), ("f_fence", 1, 9, 0), + }; + + /// 분수 — 프롤로그의 마지막 목표 지점. 3×3 차지 칸의 왼쪽 아래 모서리. + public const int FountainX = -1; + public const int FountainZ = 2; + public const int FountainSize = 3; + + /// 쾌적도 표기용 값 — 방문 화면이 아니라 데이터 일관성을 위해 채운다. + private const int ShowcaseComfort = 100; + + /// 꾸며진 섬의 공개용 스냅샷. 은 챔피언(플레이어)의 이름이다. + public static IslandSnapshot BuildIslandSnapshot(string ownerName) + { + var snapshot = new IslandSnapshot + { + ownerName = string.IsNullOrEmpty(ownerName) ? "챔피언" : ownerName, + sizeLevel = IslandSizeLevel, + comfort = ShowcaseComfort, + }; + for (int i = 0; i < IslandObjects.Length; i++) + { + var o = IslandObjects[i]; + snapshot.placed.Add(new IslandPlacedRecord { id = o.id, x = o.x, z = o.z, rot = o.rot }); + } + for (int i = 0; i < IslandInsects.Length; i++) + { + var s = IslandInsects[i]; + snapshot.insects.Add(new IslandSnapshotInsect { insectId = s.insectId, level = s.level, shiny = s.shiny }); + } + return snapshot; + } + + /// 분수 광장 중심의 월드 좌표 — 마지막 목표 마커가 선다. + public static Vector3 FountainWorldPosition() + { + return IslandWorldBuilder.CellWorldCenter(FountainX, FountainZ, FountainSize, FountainSize); + } + + /// + /// 챔피언의 에이스 — 컬렉션에 들어가지 않는 메모리 개체. 개체값이 전부 최대라 같은 종의 누구보다 세다. + /// instanceId는 컬렉션과 겹치지 않는 접두를 쓴다(전투 UI가 이 값으로 개체를 조회한다). + /// + public static PlayerInsectData BuildChampionInsect(InsectData data) + { + var pid = new PlayerInsectData + { + instanceId = "dream_champion_ace", + insectId = data != null ? data.insectId : AceInsectId, + level = AceLevel, + ivHp = AceIv, + ivAtk = AceIv, + ivDef = AceIv, + sizeRoll = 90, + }; + pid.currentHp = pid.GetTotalHp(data != null ? data.baseHp : 100); // 풀 HP로 시작 — -1(미초기화) 센티넬로 두지 않는다 + return pid; + } + + /// + /// 챔피언이 쓸 기술 넷. 위력이 높은 피해기를 앞에서 셋(필살기가 맨 앞) 고르고 마지막 칸에는 능력 상승기를 둔다 — + /// "때리고, 올리고, 필살기"를 한 번씩 눌러 보게 한다. 상승기가 없으면 피해기로 채운다. + /// + public static InsectSkill[] PickChampionSkills(InsectData data, int level) + { + var damage = new List(); + InsectSkill support = null; + if (data != null && data.learnset != null) + { + foreach (InsectLearnableSkill learnable in data.learnset) + { + InsectSkill skill = learnable != null ? learnable.skill : null; + if (skill == null || learnable.learnLevel > level) continue; + if (skill.effectType == SkillEffectType.Damage) damage.Add(skill); + else if (support == null && skill.effectType == SkillEffectType.BuffAttack) support = skill; + } + } + damage.Sort((a, b) => b.power.CompareTo(a.power)); + + var picked = new List(); + int damageSlots = support != null ? SkillCount - 1 : SkillCount; + for (int i = 0; i < damage.Count && picked.Count < damageSlots; i++) picked.Add(damage[i]); + if (support != null) picked.Add(support); + return picked.ToArray(); + } + } +} diff --git a/Assets/Scripts/Core/DreamPrologueData.cs.meta b/Assets/Scripts/Core/DreamPrologueData.cs.meta new file mode 100644 index 0000000..c069c62 --- /dev/null +++ b/Assets/Scripts/Core/DreamPrologueData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c5e101d63677fda439d217fbbc481467 \ No newline at end of file diff --git a/Assets/Scripts/Core/DreamPrologueState.cs b/Assets/Scripts/Core/DreamPrologueState.cs new file mode 100644 index 0000000..45a43eb --- /dev/null +++ b/Assets/Scripts/Core/DreamPrologueState.cs @@ -0,0 +1,55 @@ +namespace InsectGame.Core +{ + /// + /// 「챔피언의 꿈」 프롤로그가 도는 동안 켜지는 전역 표지. 그동안 꿈 밖의 게임 상태는 하나도 바뀌지 않아야 한다. + /// + /// 읽는 쪽이 여럿이라(퀘스트 진행·필드 HUD·자동 주행·섬 HUD) 이벤트가 아니라 정적 표지 하나로 둔다 — + /// 꿈이 끝나는 길이 여럿이다(끝까지 봄·건너뜀·오류·씬 재로드). 켠 쪽이 를 안 부르면 + /// 퀘스트가 영영 안 올라가므로 지휘자(DreamPrologueDirector)가 OnDisable/OnDestroy에서도 끈다. + /// + public static class DreamPrologueState + { + /// 프롤로그가 도는 중인가. + public static bool Active { get; private set; } + + public static void Begin() => Active = true; + + public static void End() => Active = false; + } + + /// 프롤로그를 언제 시작하는가의 순수 판정. + public static class DreamPrologueRules + { + /// + /// 첫 퀘스트. 이 퀘스트가 아직 진행 0으로 활성이라는 것은 "이제 막 시작한 새 계정"이라는 뜻이다 — + /// 따로 "신규 계정" 플래그를 두지 않는다(기존 계정은 이 퀘스트를 이미 끝냈다). + /// + public const string StartQuestId = "q_move"; + + /// 계정별로 한 번 봤는가를 적는 PlayerPrefs 키(SaveScope.PrefsKey로 계정 스코프를 붙인다). + public const string PlayedPrefsKey = "InsectGame.DreamPrologue.Played"; + + /// 조건이 이 시간 동안 계속 참이어야 시작한다 — 로그인·월드 선택 화면이 닫히는 몇 프레임을 건너뛴다. + public const float StableSeconds = 1.5f; + + /// 지금 활성인 스토리 퀘스트(없으면 null). + /// 그 퀘스트의 진행. + /// 이 계정이 이미 봤는가. + /// 대화·메뉴·로그인 같은 모달이 떠 있는가. + /// 조작이 묶여 있는가(대화·연출 중). + /// 서브에리어(동굴·섬) 안인가 — 섬에 들어가는 연출이라 이미 안에 있으면 못 한다. + public static bool ShouldAutoStart(string activeQuestId, int activeProgress, bool alreadyPlayed, + bool modalOpen, bool playerFrozen, bool inSubArea) + { + if (alreadyPlayed) return false; + if (activeQuestId != StartQuestId || activeProgress > 0) return false; + return !modalOpen && !playerFrozen && !inSubArea; + } + + /// 설정 화면의 「다시 보기」가 눌러도 되는가 — 자동 시작과 같되 "이미 봤다"는 막지 않는다. + public static bool CanReplay(bool running, bool modalOpen, bool playerFrozen, bool inSubArea) + { + return !running && !modalOpen && !playerFrozen && !inSubArea; + } + } +} diff --git a/Assets/Scripts/Core/DreamPrologueState.cs.meta b/Assets/Scripts/Core/DreamPrologueState.cs.meta new file mode 100644 index 0000000..bc0ba9e --- /dev/null +++ b/Assets/Scripts/Core/DreamPrologueState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3f4eefa975e5ae84bbcb9e5d1b162441 \ No newline at end of file diff --git a/Assets/Scripts/Core/FieldGround.cs b/Assets/Scripts/Core/FieldGround.cs new file mode 100644 index 0000000..9d7f2ca --- /dev/null +++ b/Assets/Scripts/Core/FieldGround.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 콜라이더 없는 둔덕(사구·눈 언덕·능선·초원 언덕·재 더미·이끼 둔덕 …)의 지면 높이를 묻는 곳. + /// + /// 왜 필요한가: 둔덕은 일부러 콜라이더가 없다. PlayerMovement의 접지는 Max(pos.y, hit.y)라 + /// 올라가기만 하고 내려오지 않아서, 밟히는 콜라이더를 두면 둔덕을 지난 뒤에도 그 높이에 뜬 채 남는다. 그런데 + /// 둔덕이 지면 위로 드러나자(RegionTerrainBuilder.PlaceMound) 평면 y에 서는 것들이 그 속에 묻혔다 — + /// 모래언덕 사구 꼭대기는 바닥 위 0.62m인데 기어다니는 곤충의 몸 꼭대기는 바닥 위 0.25~0.48m다(보통 등급). + /// 레이캐스트로는 못 찾으니 둔덕을 지은 쪽이 모양을 여기 올리고, 서 있을 높이가 필요한 쪽이 좌표로 묻는다. + /// + /// 둔덕은 전부 반쯤 묻힌 타원체(내장 Sphere를 늘린 것)라 중심·반축·요(y축 회전)만 알면 윗면이 정해진다. + /// 월드는 씬마다 새로 지어지므로 는 짓는 쪽(RegionTerrainBuilder)이 빌드 시작과 파괴 때 부른다. + /// 서브에리어는 (2000,·,2000) 너머의 다른 좌표라 여기 둔덕과 겹치지 않는다 — 거기서 물으면 0이 나온다. + /// + public static class FieldGround + { + /// + /// 둔덕이 기준으로 삼는 리전 바닥 높이(월드 y). 실제 리전 평면은 PlaySceneBootstrap이 0.08 + 순번 × 0.001에 + /// 깔지만 둔덕·장식은 전부 이 값 위에 놓는다 — 1~2cm 차이는 눈에 안 띄고, 기준이 하나여야 높이가 서로 맞는다. + /// + public const float FloorY = 0.1f; + + private struct Dome + { + public Vector2 center; // XZ + public float centerY; + public float halfX, halfY, halfZ; // 반축(월드 m) + public Quaternion inverseYaw; + public float reach; // 빠른 배제용 — 가장 긴 수평 반축 + } + + private static readonly List domes = new List(); + + /// 등록된 둔덕 수(테스트·진단용). + public static int Count => domes.Count; + + public static void Clear() => domes.Clear(); + + /// + /// 타원체 둔덕 하나를 올린다. 는 월드 반축(내장 Sphere면 lossyScale × 0.5), + /// 는 y축 회전(Quaternion.Euler(0, yaw, 0)과 같은 뜻)이다. 윗면이 를 + /// 넘지 못하는 것(완전히 묻힌 옛 둔덕)은 올려도 높이를 바꾸지 않는다. + /// + public static void AddDome(Vector3 center, Vector3 semiAxes, float yawDeg) + { + if (semiAxes.x <= 0f || semiAxes.y <= 0f || semiAxes.z <= 0f) return; + if (center.y + semiAxes.y <= FloorY) return; + domes.Add(new Dome + { + center = new Vector2(center.x, center.z), + centerY = center.y, + halfX = semiAxes.x, + halfY = semiAxes.y, + halfZ = semiAxes.z, + inverseYaw = Quaternion.Inverse(Quaternion.Euler(0f, yawDeg, 0f)), + reach = Mathf.Max(semiAxes.x, semiAxes.z), + }); + } + + /// + /// (x, z)에 선 것이 발을 둘 높이(월드 y) — 둔덕 밖이면 . 둔덕이 겹치면 높은 쪽. + /// + public static float SurfaceY(float x, float z) + { + float best = FloorY; + for (int i = 0; i < domes.Count; i++) + { + Dome d = domes[i]; + float dx = x - d.center.x, dz = z - d.center.y; + if (dx * dx + dz * dz >= d.reach * d.reach) continue; + float y = DomeTop(d, dx, dz); + if (y > best) best = y; + } + return best; + } + + /// + /// (x, z)에서 둔덕이 바닥() 위로 올라온 높이(m, 0 이상). 평면 높이에 서 있던 것에 더하면 + /// 둔덕 윗면에 선다 — 스폰·이동 쪽은 이 값만 알면 된다. + /// + public static float LiftAt(float x, float z) => SurfaceY(x, z) - FloorY; + + /// 타원체 윗면 y. 수평 투영 밖이면 음의 무한대. + private static float DomeTop(in Dome d, float dx, float dz) + { + Vector3 local = d.inverseYaw * new Vector3(dx, 0f, dz); + float u = local.x / d.halfX, v = local.z / d.halfZ; + float rr = u * u + v * v; + if (rr >= 1f) return float.NegativeInfinity; + return d.centerY + d.halfY * Mathf.Sqrt(1f - rr); + } + } +} diff --git a/Assets/Scripts/Core/FieldGround.cs.meta b/Assets/Scripts/Core/FieldGround.cs.meta new file mode 100644 index 0000000..16db255 --- /dev/null +++ b/Assets/Scripts/Core/FieldGround.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 918949b7408ac6b429f1b9155c1b4c87 \ No newline at end of file diff --git a/Assets/Scripts/Core/FieldMomentFeed.cs b/Assets/Scripts/Core/FieldMomentFeed.cs new file mode 100644 index 0000000..45c3050 --- /dev/null +++ b/Assets/Scripts/Core/FieldMomentFeed.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Core +{ + /// 필드 소식의 종류 — 화면이 색과 기호를 고른다. + public enum FieldMomentKind + { + /// 캐릭터 레벨이 올랐다. + LevelUp, + /// 이야기·내기에서 보상을 받았다. + Reward, + /// 라온과의 내기 — 시작, 라온의 포획, 승패. + Rival, + /// 근처에 색다른 개체가 나타났다. + Discovery, + } + + public readonly struct FieldMoment + { + public readonly FieldMomentKind Kind; + public readonly string Title; + public readonly string Body; + + public FieldMoment(FieldMomentKind kind, string title, string body) + { + Kind = kind; + Title = title ?? string.Empty; + Body = body ?? string.Empty; + } + } + + /// + /// 필드 위에 잠깐 떠서 "방금 무슨 일이 있었는지"를 알리는 소식의 대기열. + /// + /// 도입부가 조용했던 이유 하나가 이것이다 — 이야기 보상은 StoryDirector.GrantReward가 말없이 넣었고 + /// 캐릭터 레벨이 올라도 HUD 숫자만 바뀌었다. 무언가 받았거나 달라진 순간을 한 줄로 띄운다. + /// + /// 이벤트가 아니라 대기열이다. 넣는 쪽(스토리·내기·조우)과 그리는 쪽(FieldMomentsUI)이 서로를 + /// 모르고, 화면은 구독 없이 꺼내 가기만 한다 — UI 루트가 꺼졌다 켜질 때 구독이 사라지는 계열 + /// (rules/ui-layout.md)에 걸리지 않고, 대사·영상이 화면을 덮은 동안 들어온 소식도 그 뒤에 차례로 뜬다. + /// + public class FieldMomentFeed : MonoBehaviour + { + /// 쌓아 둘 수 있는 소식 수. 넘치면 가장 오래된 것을 버린다 — 한참 지난 소식이 줄줄이 뜨는 것보다 낫다. + public const int MaxPending = 6; + + private readonly Queue pending = new Queue(); + private ItemDatabase itemDatabase; + + public int PendingCount => pending.Count; + + public void AutoWire(ItemDatabase items) + { + if (itemDatabase == null) itemDatabase = items; + } + + public void Push(FieldMomentKind kind, string title, string body = null) + { + if (string.IsNullOrEmpty(title)) return; + while (pending.Count >= MaxPending) pending.Dequeue(); + pending.Enqueue(new FieldMoment(kind, title, body)); + } + + public bool TryDequeue(out FieldMoment moment) + { + if (pending.Count == 0) + { + moment = default; + return false; + } + moment = pending.Dequeue(); + return true; + } + + /// 아이템 표시명(없으면 ID 그대로). 소식 문구를 만드는 쪽이 DB를 따로 들지 않게 여기서 풀어 준다. + public string ItemName(string itemId) + { + if (string.IsNullOrEmpty(itemId)) return string.Empty; + ItemData item = itemDatabase != null ? itemDatabase.FindById(itemId) : null; + return item != null && !string.IsNullOrEmpty(item.displayName) ? item.displayName : itemId; + } + } +} diff --git a/Assets/Scripts/Core/FieldMomentFeed.cs.meta b/Assets/Scripts/Core/FieldMomentFeed.cs.meta new file mode 100644 index 0000000..303e1be --- /dev/null +++ b/Assets/Scripts/Core/FieldMomentFeed.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5c0d740ec67e28a4d89503ea54cd918e \ No newline at end of file diff --git a/Assets/Scripts/Core/FirestoreDocParser.cs b/Assets/Scripts/Core/FirestoreDocParser.cs new file mode 100644 index 0000000..0c29ae0 --- /dev/null +++ b/Assets/Scripts/Core/FirestoreDocParser.cs @@ -0,0 +1,127 @@ +using System; +using System.Globalization; +using System.Text; + +namespace InsectGame.Core +{ + /// + /// Firestore REST 문서({"fields":{"키":{"stringValue":"…"}}})에서 최상위 필드 값을 꺼내는 간이 파서. + /// + /// 공백에 관대하다. 예전 구현은 "키":{"stringValue":"라는 공백 없는 마커를 통째로 찾았는데, + /// REST 응답이 줄바꿈·들여쓰기된 형식("키": { + 줄바꿈)으로 오면 한 필드도 못 찾고 전부 기본값이 된다. + /// 여기서는 키를 찾은 뒤 구조 문자 사이의 공백을 건너뛴다 — 두 형식 모두 읽는다. + /// + /// 이스케이프도 한 글자씩 푼다. 예전의 순차 Replace는 원문의 \ 뒤 n을 줄바꿈으로 잘못 되돌렸다. + /// + public static class FirestoreDocParser + { + /// 문자열 필드. 없으면 false이고 는 빈 문자열. + public static bool TryGetString(string json, string fieldName, out string value) + { + value = string.Empty; + int at = FindTypedValue(json, fieldName, "stringValue"); + if (at < 0) return false; + return TryReadJsonString(json, at, out value); + } + + /// 정수 필드(integerValue는 문자열로 온다). 없거나 숫자가 아니면 false. + public static bool TryGetInt(string json, string fieldName, out int value) + { + value = 0; + int at = FindTypedValue(json, fieldName, "integerValue"); + if (at < 0) return false; + if (!TryReadJsonString(json, at, out string text)) return false; + if (!long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out long parsed)) return false; + value = parsed > int.MaxValue ? int.MaxValue : parsed < int.MinValue ? int.MinValue : (int)parsed; + return true; + } + + /// + /// "필드": { "타입": "까지 맞는 자리를 찾아, 값 문자열을 여는 따옴표의 인덱스를 준다. 없으면 -1. + /// 문자열 값 안에 든 같은 이름(이스케이프된 \"필드\")은 앞 글자가 역슬래시라 걸러진다. + /// + private static int FindTypedValue(string json, string fieldName, string typeName) + { + if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(fieldName)) return -1; + string key = "\"" + fieldName + "\""; + int from = 0; + while (from < json.Length) + { + int idx = json.IndexOf(key, from, StringComparison.Ordinal); + if (idx < 0) return -1; + from = idx + key.Length; + if (idx > 0 && json[idx - 1] == '\\') continue; + + int p = SkipWhitespace(json, from); + if (!Expect(json, ref p, ':')) continue; + p = SkipWhitespace(json, p); + if (!Expect(json, ref p, '{')) continue; + p = SkipWhitespace(json, p); + + string typeKey = "\"" + typeName + "\""; + if (string.CompareOrdinal(json, p, typeKey, 0, typeKey.Length) != 0) continue; + p += typeKey.Length; + p = SkipWhitespace(json, p); + if (!Expect(json, ref p, ':')) continue; + p = SkipWhitespace(json, p); + if (p < json.Length && json[p] == '"') return p; + } + return -1; + } + + private static int SkipWhitespace(string s, int p) + { + while (p < s.Length && (s[p] == ' ' || s[p] == '\n' || s[p] == '\r' || s[p] == '\t')) p++; + return p; + } + + private static bool Expect(string s, ref int p, char c) + { + if (p >= s.Length || s[p] != c) return false; + p++; + return true; + } + + /// 의 따옴표에서 시작하는 JSON 문자열을 읽어 이스케이프를 푼다. + private static bool TryReadJsonString(string json, int openQuote, out string value) + { + value = string.Empty; + var sb = new StringBuilder(); + int i = openQuote + 1; + while (i < json.Length) + { + char c = json[i++]; + if (c == '"') + { + value = sb.ToString(); + return true; + } + if (c != '\\') + { + sb.Append(c); + continue; + } + if (i >= json.Length) return false; + char e = json[i++]; + switch (e) + { + case 'n': sb.Append('\n'); break; + case 'r': sb.Append('\r'); break; + case 't': sb.Append('\t'); break; + case 'b': sb.Append('\b'); break; + case 'f': sb.Append('\f'); break; + case 'u': + if (i + 4 > json.Length + || !ushort.TryParse(json.Substring(i, 4), NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out ushort code)) + return false; + sb.Append((char)code); + i += 4; + break; + default: sb.Append(e); break; // \" \\ \/ + } + } + return false; // 닫는 따옴표 없이 끝났다 + } + } +} diff --git a/Assets/Scripts/Core/FirestoreDocParser.cs.meta b/Assets/Scripts/Core/FirestoreDocParser.cs.meta new file mode 100644 index 0000000..8971c03 --- /dev/null +++ b/Assets/Scripts/Core/FirestoreDocParser.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f2b15e311f6b78b46be8463b129a30f2 \ No newline at end of file diff --git a/Assets/Scripts/Core/GameClock.cs b/Assets/Scripts/Core/GameClock.cs index c373715..e445a09 100644 --- a/Assets/Scripts/Core/GameClock.cs +++ b/Assets/Scripts/Core/GameClock.cs @@ -1,3 +1,4 @@ +using System; using UnityEngine; namespace InsectGame.Core @@ -17,17 +18,24 @@ public class GameClock : MonoBehaviour private float time01; private bool paused; // 모바일 백그라운드 시 시간 정지 + private bool held; // SetTime01(hold: true) — 검수·꿈 연출이 시각을 붙잡아 둔다 + private DayPhase lastPhase; public float Time01 => time01; + public float DayLengthMinutes => dayLengthMinutes; + + /// 시간대가 바뀌었다(아침→낮→저녁→밤). 스폰이 새 시간대의 종으로 갈아입는 신호다. + public event Action DayPhaseChanged; private void Awake() { time01 = startTime01; + lastPhase = GetDayPhase(); } private void Update() { - if (paused) return; + if (paused || held) return; if (dayLengthMinutes <= 0f) { return; @@ -35,19 +43,53 @@ private void Update() float delta = Time.deltaTime / (dayLengthMinutes * 60f); time01 = (time01 + delta) % 1f; + NotifyPhaseChange(); } private void OnApplicationPause(bool pauseStatus) { paused = pauseStatus; } private void OnApplicationFocus(bool hasFocus) { if (hasFocus) paused = false; } + /// + /// 시각을 바로 정한다(0~1, 0.5 = 정오). 가 참이면 까지 시계가 멈춘다. + /// 검수 캡처와 꿈 프롤로그(고정 한낮)가 쓴다. + /// + public void SetTime01(float value, bool hold = false) + { + time01 = Mathf.Repeat(value, 1f); + held = hold; + NotifyPhaseChange(); + } + + /// 붙잡은 시계를 다시 흐르게 한다. + public void Release() { held = false; } + + private void NotifyPhaseChange() + { + DayPhase phase = GetDayPhase(); + if (phase == lastPhase) return; + lastPhase = phase; + DayPhaseChanged?.Invoke(phase); + } + public int GetHour24() { return Mathf.FloorToInt(time01 * 24f); } + /// 소수 시각(0~24). 조명이 시간대 경계에서 뚝 끊기지 않게 연속값으로 읽는다. + public float GetHourFloat() + { + return time01 * 24f; + } + public DayPhase GetDayPhase() { - float hour = time01 * 24f; + return PhaseOfHour(time01 * 24f); + } + + /// 시각(0~24)의 시간대 — 순수 함수라 테스트가 시계 없이 경계를 본다. 아침 6~11 · 낮 11~17 · 저녁 17~21 · 밤 21~6. + public static DayPhase PhaseOfHour(float hour) + { if (hour >= 6f && hour < 11f) { return DayPhase.Morning; diff --git a/Assets/Scripts/Core/GameConstants.cs b/Assets/Scripts/Core/GameConstants.cs index 599e43d..112c55b 100644 --- a/Assets/Scripts/Core/GameConstants.cs +++ b/Assets/Scripts/Core/GameConstants.cs @@ -25,6 +25,8 @@ public static class SaveFiles public const string BattleTeam = "battle_team.json"; public const string DexSave = "dex_save.json"; public const string StoryProgress = "story_progress.json"; + // 나의 섬 — 보관함·배치·방목·누적 수확. 클라우드는 GameSaveData.islandData 블롭. + public const string Island = "island.json"; } // ── PlayerPrefs 키 ── @@ -52,6 +54,20 @@ public static class PrefsKeys // 정화한 명부회 오염 거점의 리전 ID CSV — 클라우드 동기(DTO blightCleansed). // 간부 격파 기록(DefeatedLedgerBosses)에서 파생하지 않는 이유는 RegionBlightManager 참조. public const string BlightCleansed = "InsectGame.BlightCleansed"; + // 수문장 배지 이정표 보상 수령 상태("4,8") — 클라우드 동기(DTO badgeMilestonesClaimed). + // 배지 자체는 저장하지 않는다(DefeatedGuardians에서 파생) — GuardianBadges 참조. + public const string BadgeMilestonesClaimed = "InsectGame.BadgeMilestonesClaimed"; + // 따라가는 마을 이야기(주민 storyNpcId) — **로컬 전용**(QuestUnseen과 같은 편의 상태). + // 기기마다 달라도 진행에 영향이 없고, 가리키던 이야기가 끝나면 트래커가 스스로 지운다. + public const string TrackedTale = "InsectGame.TrackedTale"; + // 마지막으로 쓴 채집망 itemId — **로컬 전용** 편의 상태. 포획 선택 창이 이걸로 바로 미니게임을 연다. + // 그 채집망이 떨어졌으면 가진 것 중 맨 앞(가장 흔한 것)으로 물러난다. + public const string LastCaptureNet = "InsectGame.LastCaptureNet"; + // 라온과의 포획 내기 상태("active"/"done") — **로컬 전용**. 한 계정에 한 번뿐인 짧은 내기라 + // 점수는 저장하지 않는다(도중에 닫으면 0:0에서 다시 붙는다). + public const string RivalRace = "InsectGame.RivalRace"; + // 첫 색다른 조우를 이미 줬는가("1") — **로컬 전용**. 한 번만 일어나야 하는 연출이다. + public const string FirstShinyGiven = "InsectGame.FirstShinyGiven"; } // ── 플레이어 ── @@ -78,10 +94,137 @@ public static class Leveling public const int FallbackCandyCostGrowth = 2; } + // ── 캐릭터(트레이너) 레벨 차 ── + // 공식은 TrainerLevelGap이 단일 출처이고 여기는 그 계수다. 실측 근거는 rules/balance.md + // 「포획 기준점」·「캐릭터 EXP 기준점」. 레벨 차는 늘 곤충 레벨 − 캐릭터 레벨이다(양수 = 곤충이 높다). + public static class TrainerLevel + { + /// + /// 포획 제한이 시작되는 레벨 차. 이 차까지는 포획 공식의 덧셈 보정(-3%/Lv, ±5 clamp)만 걸린다. + /// 그 상한(MaximumLevelDelta)과 같은 값이라야 +5 너머가 평평하지 않다 — 예전엔 + /// +5와 +20이 같은 확률이라 20레벨 높은 전설도 퍼펙트 미니게임이면 34%로 잡혔다. + /// + public const int CaptureGraceLevels = 5; + + /// 유예를 넘긴 1레벨마다 최종 포획 확률에서 깎는 비율 — +10이면 ×0.5, +14면 ×0.1. + public const float CaptureDropPerLevel = 0.10f; + + /// 포획 배율의 하한 — +15 이상은 아이템·미니게임 보너스를 다 얹어도 2~3%다. + public const float MinCaptureMultiplier = 0.05f; + + /// + /// 곤충 레벨 1당 EXP 증가 — 1 + (Lv − 1) × 이 값(Lv21 ×2, Lv61 ×4). 캐릭터 레벨업 필요량이 + /// 선형(50 + 15×(Lv−1))이라 같은 레벨 사냥의 레벨당 조우 수가 후반까지 20~27회로 거의 일정하다. + /// 이게 없던 때는 등급만 봐서 Lv60 일반 곤충도 Lv1과 같은 5였고, 레벨당 조우가 Lv5 13회 → Lv60 107회로 불었다. + /// + public const float ExpPerInsectLevel = 0.05f; + + /// 곤충이 캐릭터보다 높을 때 1레벨당 EXP 가산 — 최대 레벨까지(×2.0). + public const float ExpHigherBonusPerLevel = 0.10f; + public const int ExpHigherCapLevels = 10; + + /// + /// 곤충이 캐릭터보다 낮을 때 1레벨당 EXP 감산 — 하한 (−8레벨부터). + /// 약한 곤충 반복 사냥으로 캐릭터만 앞서 나가지 않게 하는 조절기다. 0.05/×0.5로는 사냥량 3배에서 + /// 캐릭터가 리전보다 20레벨 넘게 앞섰다(progression_sim 「캐릭터 EXP」 절). + /// + public const float ExpLowerPenaltyPerLevel = 0.10f; + public const float ExpLowerMinFactor = 0.2f; + } + + // ── 훈련 비용 ── + // 공식은 TrainingPricing이 단일 출처이고 여기는 그 계수다. 실측 근거는 rules/balance.md 「훈련 기준점」. + public static class Training + { + /// + /// 상태기(공격 상승·하락, 방어 상승)를 위력으로 환산하는 기준 위력 — effectValue × 지속턴 × 이 값. + /// 그 턴 동안 중위권 기술(위력 30 안팎)을 칠 때마다 얹히는 몫이다. 이게 없던 때는 상태기의 위력 칸(1)이 + /// 가격이 되어 광폭화(공격 +60%)가 26캔디 1회로 끝나고 파멸의 독침은 216캔디였다. + /// + public const float StatusRefPower = 30f; + + /// 회복기 환산 — 회복 비율(MaxHp 대비) × 이 값. 30% 회복 ≈ 위력 30. + public const float HealRefPower = 100f; + + /// 기절기 환산 — 상대 행동 1회를 지운다 ≈ 기준 위력 1타. + public const float StunRefPower = 30f; + + /// 필요 훈련 횟수 = 1 + 가치 / 이 값(최대 ). 옛 1 + power / 12와 같은 눈금이다. + public const int SessionValueStep = 12; + public const int MaxSessions = 5; + + /// 회당 최소 캔디. 옛 범용기 공식의 하한과 같다. + public const int MinSessionCost = 5; + + /// + /// 능력치(개체값) +1의 기준 비용과 복리 — 이 값 × 성장률^현재 개체값 × 등급 배율. + /// 평균 일반 개체(5/5/5)를 S급(14/14/13)으로: 약 2,900캔디(전투 ~950회) — Lv1→50 레벨업 전부(2,548)보다 조금 크다. + /// + public const int StatBaseCost = 20; + public const float StatCostGrowth = 1.2f; + } + + // ── 나의 섬 ── + // 공식은 IslandYield·IslandGrid가 단일 출처이고 여기는 그 계수다. 근거는 rules/balance.md 「섬 기준점」. + public static class Island + { + /// 섬이 타는 합성 서브에리어의 id — RegionManager.EnterDetachedSubArea로만 들어간다. + public const string SubAreaId = "player_island"; + + /// 섬을 열어 주는 스토리 퀘스트. 풀어놓을 곤충이 생긴 시점이다. + public const string UnlockQuestId = "q_capture3"; + + /// 격자 한 칸의 한 변(m). + public const float CellSize = 1.5f; + + /// 섬 한 변의 칸 수 = BaseGridSize + sizeLevel × GridSizeStep. 늘 짝수다(중심이 칸 경계). + public const int BaseGridSize = 10; + public const int GridSizeStep = 4; + public const int MaxSizeLevel = 3; + + public const int BaseInsectSlots = 3; + public const int MaxInsectSlots = 10; + + /// + /// 방목 곤충 1마리의 시간당 캔디(일반 등급 기준). 기본 섬(일반 3마리)이 하루 18 — + /// 평소 활동 수입(economy_sim 기준 하루 약 156)의 12%다. 섬만 돌려도 되는 게임이 되면 안 된다. + /// + public const float CandyPerInsectHour = 0.25f; + + /// 방목 곤충 1마리의 시간당 코인. 등급을 보지 않는다 — 코인은 꾸미기 재화라 수집 깊이와 묶지 않는다. + public const float CoinPerInsectHour = 0.15f; + + /// 수확 없이 쌓이는 시간의 상한(시간). 설비(창고·바구니)가 까지 늘린다. + public const float BaseCapHours = 8f; + public const float MaxCapHours = 16f; + + /// 쾌적도가 주는 생산 보너스의 상한과, 그 상한에 닿는 쾌적도. + public const float MaxComfortBonus = 0.30f; + public const int ComfortForMaxBonus = 100; + + /// 친밀도 하트 수와 하트 하나당 생산 보너스(5단계 = +20%). + public const int MaxBondLevel = 5; + public const float BondBonusPerLevel = 0.04f; + + /// + /// 친밀도 단계 경계(누적 방목 시간) = 이 값 × L × (L+1) / 2 — 6·18·36·60·90시간. + /// 상한 8시간짜리 섬을 꼬박꼬박 수확해도 다섯 하트까지 나흘 넘게 걸린다. + /// + public const float BondHoursUnit = 6f; + + /// + /// 기기 시계가 이만큼 넘게 뒤로 갔으면 마지막 정산 시각을 지금으로 당긴다(초). + /// 그보다 작게 되돌린 건 정산 시각을 그대로 둔다 — 시계를 앞뒤로 흔들어 상한분을 반복해 받는 걸 막는다. + /// + public const long ClockRollbackResetSeconds = 7L * 24L * 3600L; + } + // ── 전투 ── public static class Battle { public const int MaxTeamSlots = 5; + /// Ordinary wild direct-hit pacing; excludes saved stats, DOT, duels and raids. + public const float WildDamageMultiplier = 0.7f; public const float UniteGaugeMax = 100f; /// @@ -92,6 +235,22 @@ public static class Battle /// public const int MaxBuffStacks = 3; + // ── 치명타 ────────────────────────────────────────────────────────── + // + // 피해기(스킬 피해·기본 공격·레이드 팀원 스킬/지원 공격·보스 공격)에만 굴린다. + // 버프·회복·독·기절·빗나감, 샌드박스(꿈 챔피언전), 레이드 합체공격, 피해 추정치(서포트 AI)는 굴리지 않는다. + // 기대 피해는 1 + (1.5 − 1) / 16 = **+3.1%**뿐이다 — 전투 길이를 바꾸려는 값이 아니라 + // "가끔 크게 들어가는 한 방"이라는 체감용 변동이다. 예전 화면은 "최대 HP의 25% 이상"을 + // CRITICAL로 **표시만** 했는데 전투가 3~4라운드라 거의 매 타격이 그 선을 넘었다. + // 판정은 명중과 **다른 난수 줄기**로 한다(InsectBattleController/RaidBattleController.SetCritSource) — + // 같은 줄기면 명중 롤 순서가 밀려 시드 고정 시나리오가 전부 바뀐다. + + /// 피해기 한 번이 치명타일 확률(1/16). 롤이 이 값 미만이면 치명타다. + public const float CritChance = 1f / 16f; + + /// 치명타 피해 배율 — 공방 비율·야생 페이싱을 거치기 전 피해량에 곱한다. + public const float CritMultiplier = 1.5f; + // ── 전투 길이 ──────────────────────────────────────────────────────── // // 옛 값(레벨항 ×2 · 공방비 0.5~2.5 · HP +3/Lv)은 **양쪽이 서로를 한두 턴에 @@ -157,8 +316,13 @@ public static class Battle /// 4.5는 그 길이를 6/5/5턴으로 되돌리는 값이다 — 위 문단과 같은 취지로 /// 난이도를 올리지도 내리지도 않으려는 값이지 새 밸런스가 아니다. /// 위 세 상수 중 하나라도 건드리면 이 값을 다시 계산할 것. + /// + /// 4.5 → 4.65 (2026-10-04, 치명타 도입과 짝). ·가 + /// 팀 화력 기대값을 ×1.031 올려 6턴 구간(Epic Lv20 vs Uncommon)이 중앙값 5턴으로 내려왔다 — 그 구간은 + /// 원래 5.024턴(5턴 뒤 남는 HP 0.5%)으로 아슬아슬했다. 4.5 × 1.031을 올림한 4.65가 6/5/5를 되돌린다(4.6은 부족). + /// 치명타 확률·배율을 바꿔도 이 값을 다시 계산할 것. /// - public const float RaidBossHpMultiplier = 4.5f; + public const float RaidBossHpMultiplier = 4.65f; /// 보스 HP가 이 비율 이하로 떨어지면 격노(1회 래치, 회복해도 풀리지 않는다). public const float RaidBossEnrageHpRatio = 0.5f; diff --git a/Assets/Scripts/Core/GameplayTuningProfile.cs b/Assets/Scripts/Core/GameplayTuningProfile.cs index 60356eb..c7255ad 100644 --- a/Assets/Scripts/Core/GameplayTuningProfile.cs +++ b/Assets/Scripts/Core/GameplayTuningProfile.cs @@ -7,10 +7,9 @@ public class GameplayTuningProfile : ScriptableObject { [Header("Spawning")] // 기본값은 InsectSpawner 코드 기본값과 동기화 — 에셋을 새로 만들어도 하향 함정이 없도록 유지. - [Range(1f, 180f)] public float spawnIntervalSeconds = 5f; + // (스폰 간격·초기 수·리전 상한은 2026-09-29에 뺐다 — 필드 개체군이 슬롯 기록으로 바뀌어 리전 곤충 수는 + // 설 수 있는 땅이 정하고(FieldSpawnRules.SlotCountFor), 새 개체는 재생 지연·수명으로만 들어선다.) [Range(1, 60)] public int maxActiveTotal = 32; - [Range(1, 40)] public int initialSpawnCount = 20; - [Range(1, 15)] public int maxActivePerRegion = 10; [Range(1, 5)] public int subAreaActiveCount = 2; [Range(5f, 180f)] public float subAreaRespawnSeconds = 45f; diff --git a/Assets/Scripts/Core/GuardianBadgeService.cs b/Assets/Scripts/Core/GuardianBadgeService.cs new file mode 100644 index 0000000..dd903eb --- /dev/null +++ b/Assets/Scripts/Core/GuardianBadgeService.cs @@ -0,0 +1,161 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 수문장 배지의 런타임 — 새 배지를 알리고 이정표 보상()을 지급한다. + /// + /// 배지 자체는 의 격파 기록에서 파생한다( 참조). + /// 여기가 저장하는 건 어느 이정표 보상을 받았는가 하나다 — 계정 스코프 PlayerPrefs CSV + /// (BadgeMilestonesClaimed)이고 클라우드 DTO badgeMilestonesClaimed로 기기를 따라간다. + /// 빠뜨리면 기기를 바꿀 때마다 같은 보상을 다시 받는다(weeklyContestClaimed가 겪은 결함). + /// + /// 새 배지 신호는 RegionManager.GuardianBadgeEarned다 — GuardianDefeated가 아니다. + /// 후자는 이미 깬 수문장이 필드에 남아 있을 때 봉인을 걷으려고 다시 울리므로(TryDefeatGuardian), + /// 그걸 들으면 같은 배지 연출이 두 번 뜬다. + /// + /// 싱글턴이 아니다. 와 같은 계열(AutoWire + PlayerPrefs CSV + ICloudReloadable). + /// + public class GuardianBadgeService : MonoBehaviour, ICloudReloadable + { + /// 배지 한 개를 얻은 순간 — 연출(BadgeCeremonyUI)이 듣는다. + public struct Award + { + public string regionId; + /// 이 배지를 포함해 모은 수. + public int earned; + /// 이 순간 함께 지급한 이정표(오름차순). 대개 비어 있다. + public int[] milestones; + } + + private static string ClaimedKey => SaveScope.PrefsKey(GameConstants.PrefsKeys.BadgeMilestonesClaimed); + + private RegionManager regionManager; + private PlayerItemInventory itemInventory; + private HashSet claimed = new HashSet(); + private bool loaded; + private bool subscribed; + + /// 새 배지 — 일생 리전당 한 번(격파 기록의 idempotent 가드를 그대로 따른다). + public event System.Action BadgeAwarded; + + /// 이정표 보상을 받았다(연출 중 자동 지급·케이스에서 수동 수령 모두). + public event System.Action MilestoneClaimed; + + public void AutoWire(RegionManager region, PlayerItemInventory items) + { + if (regionManager == null) regionManager = region; + if (itemInventory == null) itemInventory = items; + Subscribe(); + } + + private void OnEnable() + { + Subscribe(); + } + + private void OnDisable() + { + if (regionManager != null) regionManager.GuardianBadgeEarned -= OnBadgeEarned; + subscribed = false; + } + + private void Subscribe() + { + if (regionManager == null || !isActiveAndEnabled) return; + regionManager.GuardianBadgeEarned -= OnBadgeEarned; + regionManager.GuardianBadgeEarned += OnBadgeEarned; + subscribed = true; + } + + // ── 조회 ── + + public bool IsEarned(string regionId) => + regionManager != null && GuardianBadges.IndexOf(regionId) >= 0 && regionManager.IsGuardianDefeated(regionId); + + public int EarnedCount => regionManager != null ? GuardianBadges.CountEarned(regionManager.IsGuardianDefeated) : 0; + + public bool IsClaimed(int milestone) + { + EnsureLoaded(); + return claimed.Contains(milestone); + } + + public bool CanClaim(int milestone) => + GuardianBadges.TryGetMilestone(milestone, out _) && EarnedCount >= milestone && !IsClaimed(milestone); + + /// 도달했지만 아직 안 받은 이정표 수 — 퀵메뉴 알림용이라 할당하지 않는다. + public int ClaimableCount + { + get + { + int earned = EarnedCount; + int n = 0; + for (int i = 0; i < GuardianBadges.MilestoneCount; i++) + { + int c = GuardianBadges.MilestoneAt(i).count; + if (earned >= c && !IsClaimed(c)) n++; + } + return n; + } + } + + /// + /// 이정표 보상을 받는다 — 도달했고 아직 안 받았을 때만. 케이스 화면의 [받기]가 부른다 + /// (이 기능 이전에 배지를 모은 세이브는 새 배지를 더 얻지 않으므로 연출의 자동 지급을 못 탄다). + /// + public bool TryClaim(int milestone) + { + if (!CanClaim(milestone)) return false; + GuardianBadges.TryGetMilestone(milestone, out GuardianBadges.Milestone m); + claimed.Add(milestone); + Save(); + // 기록을 먼저 남기고 지급한다 — 지급 중 예외가 나도 두 번 받는 쪽으로 새지 않는다. + if (itemInventory != null && m.rewards != null) + { + foreach (GuardianBadges.Reward r in m.rewards) + if (!string.IsNullOrEmpty(r.itemId) && r.count > 0) itemInventory.AddItem(r.itemId, r.count); + } + MilestoneClaimed?.Invoke(milestone); + return true; + } + + // ── 새 배지 ── + + private void OnBadgeEarned(string regionId) + { + if (GuardianBadges.IndexOf(regionId) < 0) return; + EnsureLoaded(); + int earned = EarnedCount; + // 도달한 이정표는 연출과 함께 바로 준다 — 앞서 못 받은 것(옛 세이브·다른 기기)까지 같이. + List granted = new List(); + foreach (int m in GuardianBadges.Claimable(earned, claimed)) + if (TryClaim(m)) granted.Add(m); + BadgeAwarded?.Invoke(new Award { regionId = regionId, earned = earned, milestones = granted.ToArray() }); + } + + // ── 저장 ── + + private void EnsureLoaded() + { + if (loaded) return; + claimed = GuardianBadges.ParseClaimed(PlayerPrefs.GetString(ClaimedKey, "")); + loaded = true; + } + + private void Save() + { + PlayerPrefs.SetString(ClaimedKey, GuardianBadges.FormatClaimed(claimed)); + PlayerPrefs.Save(); + } + + /// 클라우드 적용 뒤 — 수령 상태를 다시 읽는다. 연출은 내지 않는다(배지는 이미 격파 기록에 있다). + public void ReloadFromDisk() + { + loaded = false; + EnsureLoaded(); + if (!subscribed) Subscribe(); + } + } +} diff --git a/Assets/Scripts/Core/GuardianBadgeService.cs.meta b/Assets/Scripts/Core/GuardianBadgeService.cs.meta new file mode 100644 index 0000000..2e22c9b --- /dev/null +++ b/Assets/Scripts/Core/GuardianBadgeService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7f283cef58c3cd54facd2e7a63fe3be2 \ No newline at end of file diff --git a/Assets/Scripts/Core/GuardianBadges.cs b/Assets/Scripts/Core/GuardianBadges.cs new file mode 100644 index 0000000..e136cbf --- /dev/null +++ b/Assets/Scripts/Core/GuardianBadges.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; + +namespace InsectGame.Core +{ + /// + /// 수문장 배지 — 리전마다 하나, 그 리전의 수문장을 처음 이기면 얻는다. + /// + /// 배지는 저장하지 않는다. 수문장 격파 기록(RegionManager의 DefeatedGuardians)이 + /// 곧 배지 목록이다 — 그 집합은 이미 클라우드에 오르므로 배지도 기기를 따라간다. 따로 적으면 두 기록이 + /// 어긋나는 순간(클라우드 병합·계정 전환) "이겼는데 배지가 없다"가 생긴다. + /// + /// 저장하는 건 이정표 보상 수령 상태 하나다() — 그건 파생이 안 된다. + /// + /// 그림은 Tools/Badges/guardian_badges.py가 굽는다(Resources/UI/Badges/badge_{regionId}.png). + /// 여기 리전을 늘리면 그 스크립트의 표도 함께 고친다 — GuardianBadgeTests가 그림 누락을 잡는다. + /// 순수 데이터 + 순수 계산이다. + /// + public static class GuardianBadges + { + public const string ArtFolder = "UI/Badges/"; + + public struct Badge + { + public string regionId; + public string name; + /// 배지에 새겨진 한 줄 — 그 수문장과의 일을 떠올리게 한다(gd_* 대사와 결이 같다). + public string flavor; + } + + public struct Reward + { + public string itemId; + public int count; + } + + public struct Milestone + { + /// 필요한 배지 수. + public int count; + public string title; + public Reward[] rewards; + } + + // 표시 순서 = 리전 요구 레벨 순서(꽃밭은 숲과 습지 사이). 진행 순서대로 채워지는 게 보여야 한다. + private static readonly Badge[] Table = + { + new Badge { regionId = "meadow", name = "풀잎 배지", flavor = "처음으로 길을 내어 준 초원의 낫." }, + new Badge { regionId = "pond", name = "물결 배지", flavor = "수면을 스치던 날개가 길을 비켰다." }, + new Badge { regionId = "forest", name = "뿔 배지", flavor = "진 게 아니라 물러난 뿔의 무게." }, + new Badge { regionId = "garden", name = "꽃잎 배지", flavor = "들어오는 것을 막던 문지기의 허락." }, + new Badge { regionId = "swamp", name = "안개 배지", flavor = "안개가 걷힌 자리는 비어 있지 않았다." }, + new Badge { regionId = "mountain", name = "능선 배지", flavor = "산바람을 뒤집은 날개가 길을 열었다." }, + new Badge { regionId = "ruins", name = "태양 배지", flavor = "등껍질을 접은 파수꾼이 건넨 증표." }, + new Badge { regionId = "hollow", name = "침묵 배지", flavor = "길을 잃은 낫을 놓아준 기억." }, + new Badge { regionId = "dunes", name = "모래 배지", flavor = "모래바람 속으로 날아오른 침." }, + new Badge { regionId = "frostline", name = "서리 배지", flavor = "얼음 벽의 글자가 우리를 알아보았다." }, + new Badge { regionId = "emberfall", name = "잿불 배지", flavor = "재 밑에서 다시 움직이는 온기." }, + new Badge { regionId = "canopy", name = "우듬지 배지", flavor = "시험을 마친 세계수의 날갯짓." }, + new Badge { regionId = "nameless", name = "빈칸 배지", flavor = "칸은 있는데, 안이 비어 있다." }, + }; + + // 이정표 보상 — 한 번만 받는다. 4는 2막 전(숲·꽃밭 무렵), 8은 2막 초입, 13은 전부. + // 기술 디스크 등급이 이정표를 따라 오른다(Rare → Epic → Legendary). 가격표(젬)로 치면 + // 4: 약 350, 8: 약 400, 13: 약 1,200 — 캐시 상점을 대체하지 않을 만큼, 수집을 보람 있게 할 만큼. + private static readonly Milestone[] Milestones = + { + new Milestone + { + count = 4, title = "길잡이", + rewards = new[] + { + new Reward { itemId = "net_gold", count = 3 }, + new Reward { itemId = "disc_frenzy", count = 1 }, + }, + }, + new Milestone + { + count = 8, title = "먼 길의 여행자", + rewards = new[] + { + new Reward { itemId = "disc_mega_strike", count = 1 }, + new Reward { itemId = "guardian_totem", count = 1 }, + }, + }, + new Milestone + { + count = 13, title = "모든 길을 연 자", + rewards = new[] + { + new Reward { itemId = "disc_doom_sting", count = 1 }, + new Reward { itemId = "golden_censer", count = 1 }, + }, + }, + }; + + public static int Total => Table.Length; + + /// 표 전체(값 복사) — 표시 순서. + public static Badge[] All() + { + Badge[] copy = new Badge[Table.Length]; + Array.Copy(Table, copy, Table.Length); + return copy; + } + + public static int MilestoneCount => Milestones.Length; + + /// 할당 없는 조회 — OnGUI가 매 프레임 부른다(퀵메뉴의 받을 보상 표시). + public static Milestone MilestoneAt(int index) => Milestones[index]; + + public static Milestone[] AllMilestones() + { + Milestone[] copy = new Milestone[Milestones.Length]; + Array.Copy(Milestones, copy, Milestones.Length); + return copy; + } + + public static int IndexOf(string regionId) + { + if (string.IsNullOrEmpty(regionId)) return -1; + for (int i = 0; i < Table.Length; i++) + if (Table[i].regionId == regionId) return i; + return -1; + } + + public static bool TryGet(string regionId, out Badge badge) + { + int i = IndexOf(regionId); + badge = i >= 0 ? Table[i] : default; + return i >= 0; + } + + public static bool TryGetMilestone(int count, out Milestone milestone) + { + for (int i = 0; i < Milestones.Length; i++) + { + if (Milestones[i].count != count) continue; + milestone = Milestones[i]; + return true; + } + milestone = default; + return false; + } + + public static string ArtPath(string regionId) => ArtFolder + "badge_" + regionId; + + /// 얻은 배지 수 — 표에 있는 리전만 센다(격파 집합에 낯선 ID가 섞여도 13을 넘지 않는다). + public static int CountEarned(Func isDefeated) + { + if (isDefeated == null) return 0; + int n = 0; + for (int i = 0; i < Table.Length; i++) + if (isDefeated(Table[i].regionId)) n++; + return n; + } + + /// + /// 도달했지만 아직 안 받은 이정표(오름차순). 한 번에 여럿일 수 있다 — 이 기능이 생기기 전에 + /// 이미 배지를 모아 둔 세이브, 다른 기기에서 격파가 넘어온 세이브. + /// + public static List Claimable(int earned, ICollection claimed) + { + List list = new List(); + for (int i = 0; i < Milestones.Length; i++) + { + int c = Milestones[i].count; + if (earned >= c && (claimed == null || !claimed.Contains(c))) list.Add(c); + } + return list; + } + + /// "4,8" → {4, 8}. 숫자가 아니거나 이정표가 아닌 값은 버린다(조작·옛 포맷에 안전). + public static HashSet ParseClaimed(string csv) + { + HashSet set = new HashSet(); + if (string.IsNullOrEmpty(csv)) return set; + foreach (string raw in csv.Split(',')) + { + if (int.TryParse(raw.Trim(), out int v) && TryGetMilestone(v, out _)) set.Add(v); + } + return set; + } + + public static string FormatClaimed(IEnumerable claimed) + { + List list = claimed != null ? new List(claimed) : new List(); + list.Sort(); + return string.Join(",", list); + } + } +} diff --git a/Assets/Scripts/Core/GuardianBadges.cs.meta b/Assets/Scripts/Core/GuardianBadges.cs.meta new file mode 100644 index 0000000..b90895a --- /dev/null +++ b/Assets/Scripts/Core/GuardianBadges.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 77812d61605209148b3540b02c386ec3 \ No newline at end of file diff --git a/Assets/Scripts/Core/GuardianIntros.cs b/Assets/Scripts/Core/GuardianIntros.cs new file mode 100644 index 0000000..ab22a96 --- /dev/null +++ b/Assets/Scripts/Core/GuardianIntros.cs @@ -0,0 +1,77 @@ +namespace InsectGame.Core +{ + /// + /// 수문장 등장 화면의 글 — 리전마다 별칭 한 줄과 등장 한 줄. + /// + /// 수문장전은 대부분 레이드로 치르는데(영웅·전설), 레이드에는 대결 컷인(DuelBanter)이 없어 초원의 수호자도 + /// 이름 없는 사마귀도 필드의 곤충처럼 "나타났다" 한 줄로 시작했다. 아이가 "이건 특별한 싸움이다"를 느끼도록 + /// 시작 순간에 「○○의 수문장 · 별칭 / 곤충 이름 / 등장 한 줄」을 띄운다. 그리기는 ui-dev(레이드 시작 화면)가 하고, + /// 칭호 「○○의 수문장」은 리전 표시명, 곤충 이름은 RegionData.guardianDisplayName에서 온다 — 여기는 그 사이의 + /// 두 줄만 든다(같은 말을 두 곳에 두지 않는다). + /// + /// 쉬운 말 규칙(StoryBible 15장): 별칭 ≤ 자, 등장 ≤ 자. + /// 「그림자」는 울타리 밖의 그것에만 쓴다(2장) — 안개·어둠 묘사에 섞지 않는다. 「무명」·「봉인」은 쓰지 않는다. + /// 수문장이 있는 리전 전부를 덮는지와 길이는 GuardianIntroTests가 실제 리전 정의로 본다. + /// + /// 순수 데이터다. 배지 표()와 같은 리전 키를 쓴다. + /// + public static class GuardianIntros + { + public const int MaxEpithetChars = 16; + public const int MaxLineChars = 30; + + public struct Intro + { + public string regionId; + /// 별칭 — 칭호 옆에 붙는 짧은 이름("풀숲의 낫"). + public string epithet; + /// 등장 한 줄 — 수문장이 나서는 그림("풀숲이 갈라지고 큰 낫이 번쩍인다!"). + public string line; + } + + private static readonly Intro[] Table = + { + // ── 1막 ── + new Intro { regionId = "meadow", epithet = "풀숲의 낫", line = "풀숲이 갈라지고 큰 낫이 번쩍인다!" }, + new Intro { regionId = "pond", epithet = "물 위의 번개", line = "수면을 가르며 왕잠자리가 내려온다!" }, + new Intro { regionId = "forest", epithet = "숲의 큰 뿔", line = "땅이 울린다. 거대한 뿔이 길을 막는다!" }, + new Intro { regionId = "swamp", epithet = "안개 속 유령", line = "안개 속에서 하얀 사마귀가 나타난다!" }, + new Intro { regionId = "mountain", epithet = "산바람의 거인", line = "커다란 날개가 산바람을 뒤집는다!" }, + new Intro { regionId = "garden", epithet = "꽃길의 날개", line = "꽃잎 사이로 호랑나비가 길을 막는다!" }, + new Intro { regionId = "ruins", epithet = "모래 왕관", line = "기둥에서 모래가 흘러내린다. 깨어났다!" }, + // ── 2막 ── + new Intro { regionId = "hollow", epithet = "소리 없는 낫", line = "소리 없는 들판에 사마귀가 서 있다." }, + new Intro { regionId = "dunes", epithet = "모래바람 침", line = "모래바람 속에서 날갯소리가 울린다!" }, + new Intro { regionId = "frostline", epithet = "얼음빛 날개", line = "얼음 벽이 빛나고 오로라 날개가 펼쳐진다!" }, + new Intro { regionId = "emberfall", epithet = "불티의 침", line = "불티를 흩뿌리며 용암말벌이 날아든다!" }, + new Intro { regionId = "canopy", epithet = "하늘 나무의 날개", line = "우듬지가 술렁이고 큰 나비가 내려앉는다." }, + new Intro { regionId = "nameless", epithet = "이름 없는 낫", line = "모습이 자꾸 바뀌는 사마귀가 길을 막는다!" }, + }; + + /// 이 리전 수문장의 등장 글. 없으면 false — 그리는 쪽은 별칭·등장 줄 없이 칭호와 이름만 띄운다. + public static bool TryGet(string regionId, out Intro intro) + { + if (!string.IsNullOrEmpty(regionId)) + { + for (int i = 0; i < Table.Length; i++) + { + if (Table[i].regionId == regionId) + { + intro = Table[i]; + return true; + } + } + } + intro = default; + return false; + } + + /// 표 전체 — 테스트·검사용(값 복사). + public static Intro[] All() + { + Intro[] copy = new Intro[Table.Length]; + System.Array.Copy(Table, copy, Table.Length); + return copy; + } + } +} diff --git a/Assets/Scripts/Core/GuardianIntros.cs.meta b/Assets/Scripts/Core/GuardianIntros.cs.meta new file mode 100644 index 0000000..38b090f --- /dev/null +++ b/Assets/Scripts/Core/GuardianIntros.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: effe02ba90f1baf4a8bbee2d0b22e75d \ No newline at end of file diff --git a/Assets/Scripts/Core/IslandCatalog.cs b/Assets/Scripts/Core/IslandCatalog.cs new file mode 100644 index 0000000..15d973e --- /dev/null +++ b/Assets/Scripts/Core/IslandCatalog.cs @@ -0,0 +1,171 @@ +using System.Collections.Generic; + +namespace InsectGame.Core +{ + /// + /// 섬 물건 카탈로그 — 코드 정의(의상 카탈로그 CharacterOutfitManager.BuildCatalog와 같은 방식). + /// 3D 모양은 IslandObjectBuilder가 같은 id로 짓는다 — id를 늘리면 그쪽 switch도 늘릴 것 + /// (빠뜨리면 상자 모양 폴백으로 뜬다. IslandCatalogTests가 잡는다). + /// + public static class IslandCatalog + { + private static IslandObjectDef[] all; + private static Dictionary lookup; + + public static IReadOnlyList All + { + get + { + EnsureBuilt(); + return all; + } + } + + public static IslandObjectDef Get(string id) + { + if (string.IsNullOrEmpty(id)) return null; + EnsureBuilt(); + return lookup.TryGetValue(id, out IslandObjectDef def) ? def : null; + } + + /// 섬 첫 진입 때 주는 물건 — 가이드가 "보관함의 벤치를 놓아 보세요"로 바로 쓴다. + public static readonly (string id, int count)[] StarterKit = + { + ("f_bench", 1), + ("f_flowerpot", 2), + ("t_sapling", 1), + }; + + // ── 확장 가격 ── 코인으로도 다이아로도 살 수 있다(다이아가 유료 전용이라 코인 길을 막지 않는다). + + private static readonly int[] SizeCoinPrices = { 500, 1500, 3500 }; + private static readonly int[] SizeGemPrices = { 100, 250, 450 }; + private static readonly int[] SlotCoinPrices = { 200, 350, 550, 800, 1100, 1500, 2000 }; + private static readonly int[] SlotGemPrices = { 40, 60, 80, 110, 140, 180, 220 }; + + /// 현재 단계에서 다음 섬 크기로 가는 값. 더 못 늘리면 false. + public static bool SizePrice(int currentSizeLevel, out int coins, out int gems) + { + coins = gems = 0; + if (currentSizeLevel < 0 || currentSizeLevel >= GameConstants.Island.MaxSizeLevel) return false; + coins = SizeCoinPrices[currentSizeLevel]; + gems = SizeGemPrices[currentSizeLevel]; + return true; + } + + /// 방목 슬롯을 하나 더 여는 값. 는 지금까지 산 수. + public static bool SlotPrice(int extraSlots, out int coins, out int gems) + { + coins = gems = 0; + int max = GameConstants.Island.MaxInsectSlots - GameConstants.Island.BaseInsectSlots; + if (extraSlots < 0 || extraSlots >= max) return false; + coins = SlotCoinPrices[extraSlots]; + gems = SlotGemPrices[extraSlots]; + return true; + } + + private static void EnsureBuilt() + { + if (all != null) return; + all = Build(); + lookup = new Dictionary(all.Length); + for (int i = 0; i < all.Length; i++) lookup[all[i].id] = all[i]; + } + + private static IslandObjectDef[] Build() + { + return new[] + { + // ── 건물 ── + Coin("b_cabin", "곤충 오두막", "곤충들이 쉬어 가는 집. 친밀도가 더 빨리 쌓입니다.", + IslandObjectCategory.Building, 3, 3, 600, 12, IslandEffectKind.BondSpeed, 0.25f), + Coin("b_storage", "수확 창고", "수확물을 더 오래 쌓아 둡니다.", + IslandObjectCategory.Building, 3, 2, 900, 8, IslandEffectKind.CapHours, 4f), + Gem("b_greenhouse", "유리 온실", "곤충이 좋아하는 풀을 사철 기릅니다. 생산량이 늘어납니다.", + IslandObjectCategory.Building, 3, 3, 180, 14, IslandEffectKind.YieldBonus, 0.08f), + Coin("b_windmill", "풍차", "바닷바람에 천천히 도는 풍차.", + IslandObjectCategory.Building, 3, 3, 1200, 20, sizeLevel: 1), + Gem("b_lighthouse", "등대", "밤바다를 비추는 등대. 섬의 얼굴이 됩니다.", + IslandObjectCategory.Building, 2, 2, 320, 30, sizeLevel: 2), + + // ── 가구 ── + Coin("f_bench", "나무 벤치", "앉아서 섬을 바라보기 좋은 벤치.", + IslandObjectCategory.Furniture, 2, 1, 60, 4), + Coin("f_table", "통나무 탁자", "통나무를 잘라 만든 탁자와 그루터기 의자.", + IslandObjectCategory.Furniture, 2, 2, 120, 6), + Coin("f_lantern", "등불", "해가 져도 은은하게 빛납니다.", + IslandObjectCategory.Furniture, 1, 1, 80, 4), + Coin("f_fence", "나무 울타리", "길을 내거나 구역을 나눌 때 씁니다.", + IslandObjectCategory.Furniture, 2, 1, 40, 2), + Coin("f_flowerpot", "화분", "작은 꽃이 핀 화분.", + IslandObjectCategory.Furniture, 1, 1, 40, 3), + Coin("f_mailbox", "우체통", "빨간 우체통. 아직 편지는 오지 않습니다.", + IslandObjectCategory.Furniture, 1, 1, 90, 4), + Coin("f_sign", "나무 팻말", "섬 입구에 세워 두기 좋은 팻말.", + IslandObjectCategory.Furniture, 1, 1, 50, 3), + Coin("f_hammock", "해먹", "두 기둥 사이에 건 해먹.", + IslandObjectCategory.Furniture, 3, 1, 220, 8), + Coin("f_campfire", "모닥불", "둘러앉기 좋은 모닥불.", + IslandObjectCategory.Furniture, 2, 2, 180, 7), + Gem("f_fountain", "분수", "물줄기가 솟는 돌 분수.", + IslandObjectCategory.Furniture, 3, 3, 150, 16), + + // ── 지형지물 ── + Coin("t_sapling", "어린 나무", "이제 막 자라는 나무.", + IslandObjectCategory.Terrain, 1, 1, 50, 3), + Coin("t_oak", "큰 참나무", "그늘이 넓은 참나무.", + IslandObjectCategory.Terrain, 2, 2, 200, 8), + Gem("t_blossom", "벚나무", "분홍 꽃이 가득 핀 나무.", + IslandObjectCategory.Terrain, 2, 2, 90, 12), + Coin("t_rock", "바위", "이끼 낀 작은 바위.", + IslandObjectCategory.Terrain, 1, 1, 45, 2), + Coin("t_boulder", "큰 바위", "곤충이 숨기 좋은 큰 바위.", + IslandObjectCategory.Terrain, 2, 2, 110, 4), + Coin("t_pond", "연못", "수면에 하늘이 비치는 작은 연못.", + IslandObjectCategory.Terrain, 3, 3, 320, 10), + Coin("t_flowerbed", "꽃밭", "색색의 꽃이 핀 꽃밭. 지나다닐 수 있습니다.", + IslandObjectCategory.Terrain, 2, 2, 90, 6, blocks: false), + Coin("t_bush", "덤불", "둥근 덤불. 지나다닐 수 있습니다.", + IslandObjectCategory.Terrain, 1, 1, 35, 2, blocks: false), + + // ── 도구(설비) ── + Coin("o_feeder", "먹이통", "과일 조각을 담아 두는 먹이통. 생산량이 늘어납니다.", + IslandObjectCategory.Tool, 1, 1, 150, 2, IslandEffectKind.YieldBonus, 0.05f), + Coin("o_water", "급수대", "맑은 물이 고이는 급수대. 생산량이 늘어납니다.", + IslandObjectCategory.Tool, 1, 1, 150, 2, IslandEffectKind.YieldBonus, 0.05f), + Coin("o_basket", "수확 바구니", "수확물을 조금 더 오래 쌓아 둡니다.", + IslandObjectCategory.Tool, 1, 1, 250, 2, IslandEffectKind.CapHours, 2f), + Coin("o_honeypot", "꿀단지", "달콤한 꿀단지. 생산량이 늘어납니다.", + IslandObjectCategory.Tool, 1, 1, 400, 3, IslandEffectKind.YieldBonus, 0.06f), + Gem("o_sprinkler", "물뿌리개", "섬에 물을 골고루 뿌립니다. 친밀도가 훨씬 빨리 쌓입니다.", + IslandObjectCategory.Tool, 2, 1, 120, 4, IslandEffectKind.BondSpeed, 0.5f), + }; + } + + private static IslandObjectDef Coin(string id, string name, string desc, IslandObjectCategory category, + int width, int depth, int coinPrice, int comfort, + IslandEffectKind effect = IslandEffectKind.None, float effectValue = 0f, + int sizeLevel = 0, bool blocks = true) + { + return new IslandObjectDef + { + id = id, displayName = name, description = desc, category = category, + width = width, depth = depth, coinPrice = coinPrice, gemPrice = 0, comfort = comfort, + effect = effect, effectValue = effectValue, requiredSizeLevel = sizeLevel, blocksMovement = blocks, + }; + } + + private static IslandObjectDef Gem(string id, string name, string desc, IslandObjectCategory category, + int width, int depth, int gemPrice, int comfort, + IslandEffectKind effect = IslandEffectKind.None, float effectValue = 0f, + int sizeLevel = 0, bool blocks = true) + { + return new IslandObjectDef + { + id = id, displayName = name, description = desc, category = category, + width = width, depth = depth, coinPrice = 0, gemPrice = gemPrice, comfort = comfort, + effect = effect, effectValue = effectValue, requiredSizeLevel = sizeLevel, blocksMovement = blocks, + }; + } + } +} diff --git a/Assets/Scripts/Core/IslandCatalog.cs.meta b/Assets/Scripts/Core/IslandCatalog.cs.meta new file mode 100644 index 0000000..85cdf9b --- /dev/null +++ b/Assets/Scripts/Core/IslandCatalog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 776dc55859259a249aeda87880394e1a \ No newline at end of file diff --git a/Assets/Scripts/Core/IslandData.cs b/Assets/Scripts/Core/IslandData.cs new file mode 100644 index 0000000..d01e885 --- /dev/null +++ b/Assets/Scripts/Core/IslandData.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; + +namespace InsectGame.Core +{ + /// 섬에 놓는 물건의 분류 — 상점 탭과 같다. + public enum IslandObjectCategory + { + Building, + Furniture, + Terrain, + Tool, + } + + /// + /// 놓아두면 나는 효과. 같은 id를 여러 개 놓아도 한 번만 센다() — + /// 먹이통 열 개로 생산을 +50% 하는 섬이 되면 꾸미기가 아니라 도배가 된다. + /// + public enum IslandEffectKind + { + None, + /// 생산량 가산(0.05 = +5%). + YieldBonus, + /// 누적 상한 시간 가산(시간). + CapHours, + /// 친밀도가 쌓이는 속도 가산(0.25 = +25%). + BondSpeed, + } + + /// + /// 카탈로그 한 줄. 가격은 둘 중 하나만 양수다 — 코인 물건과 다이아 물건을 섞지 않는다 + /// (확장만 병행가를 둔다 — ). + /// + public class IslandObjectDef + { + public string id; + public string displayName; + public string description; + public IslandObjectCategory category; + /// 차지 칸(회전 0 기준). 가로 = x, 세로 = z. + public int width = 1; + public int depth = 1; + public int coinPrice; + public int gemPrice; + public int comfort; + public IslandEffectKind effect = IslandEffectKind.None; + public float effectValue; + /// 이 섬 크기 단계부터 상점에 뜬다(큰 건물이 좁은 섬을 다 덮지 않게). + public int requiredSizeLevel; + /// true면 캐릭터가 지나갈 수 없다. 꽃밭·덤불처럼 낮은 것은 false. + public bool blocksMovement = true; + + public bool IsPremium => gemPrice > 0; + } + + [Serializable] + public class IslandOwnedRecord + { + public string id; + /// 보유 총수 — 놓인 것까지 포함한다. 보관함 수량은 여기서 놓인 수를 뺀 값이다. + public int count; + } + + [Serializable] + public class IslandPlacedRecord + { + public string id; + /// 차지 영역의 최소 모서리 칸. 섬 중심이 (0,0)이라 확장해도 기존 좌표가 그대로 유효하다. + public int x; + public int z; + /// 0~3, 90° 단위. + public int rot; + } + + [Serializable] + public class IslandBondRecord + { + public string instanceId; + public float hours; + } + + /// + /// island.json — 섬 상태 전부. 클라우드엔 이 파일이 블롭 하나(GameSaveData.islandData)로 올라간다. + /// 새 필드는 의미 있는 기본값으로 선언할 것 — JsonUtility는 JSON에 없는 필드를 건드리지 않는다. + /// + [Serializable] + public class IslandSave + { + public int version = 1; + public int sizeLevel; + /// 기본 3칸 위에 산 방목 슬롯 수. + public int extraSlots; + public List owned = new List(); + public List placed = new List(); + /// 방목 중인 곤충의 instanceId. 곤충은 보유 목록에 그대로 있다(배틀팀과 같은 구조). + public List released = new List(); + public List bonds = new List(); + + /// 마지막 정산 시각(Unix 초, UTC). 0 = 한 번도 정산한 적 없음 → 첫 정산이 기준점만 잡는다. + public long lastSettleUnix; + /// 마지막 수확 뒤로 쌓인 시간. 상한에 닿으면 생산이 멈춘다. + public float accruedHours; + public float pendingCandy; + public float pendingCoin; + public int harvestCount; + + /// 가이드 진행 — 의 단계 번호. 끝났으면 guideDone. + public int guideStep; + public bool guideDone; + /// 스타터 키트를 받았는가. 퀘스트 보상과 별개로 섬 첫 진입에 한 번 준다. + public bool starterGranted; + /// 다른 사람이 내 섬을 볼 수 있는가. + public bool isPublic = true; + } + + /// + /// 공개용 축약본 — 서버(islands/{uid})에 올라가고 방문자가 받는다. + /// instanceId·재화·개인 정보는 싣지 않는다. 방목 곤충은 종·레벨·색다름만. + /// + [Serializable] + public class IslandSnapshot + { + public int version = 1; + public string ownerName = ""; + public int sizeLevel; + public int comfort; + public List placed = new List(); + public List insects = new List(); + } + + [Serializable] + public class IslandSnapshotInsect + { + public string insectId; + public int level = 1; + public bool shiny; + } +} diff --git a/Assets/Scripts/Core/IslandData.cs.meta b/Assets/Scripts/Core/IslandData.cs.meta new file mode 100644 index 0000000..91a168d --- /dev/null +++ b/Assets/Scripts/Core/IslandData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4e2552a89b80c2d4282197c732e65024 \ No newline at end of file diff --git a/Assets/Scripts/Core/IslandGrid.cs b/Assets/Scripts/Core/IslandGrid.cs new file mode 100644 index 0000000..db7eea2 --- /dev/null +++ b/Assets/Scripts/Core/IslandGrid.cs @@ -0,0 +1,161 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace InsectGame.Core +{ + /// 배치가 거부된 이유 — UI가 문구로 바꾼다. + public enum IslandPlaceResult + { + Ok, + UnknownObject, + OutOfBounds, + Reserved, + Overlap, + NotOwned, + } + + /// + /// 섬 격자의 순수 규칙 — 차지 칸·경계·겹침·도착 칸 보호. MonoBehaviour와 떼어 놓아 테스트로 고정한다 + /// (와 같은 성격). + /// + /// 좌표는 섬 중심이 원점이고 한 변이 짝수 칸이다 — 칸 번호는 -half부터 half - 1까지. + /// 중심 기준이라 섬을 넓혀도 놓아둔 물건의 좌표가 그대로 유효하다(마이그레이션이 없다). + /// + public static class IslandGrid + { + /// 섬 한 변의 칸 수. + public static int GridSize(int sizeLevel) + { + int level = Mathf.Clamp(sizeLevel, 0, GameConstants.Island.MaxSizeLevel); + return GameConstants.Island.BaseGridSize + level * GameConstants.Island.GridSizeStep; + } + + /// 회전을 반영한 차지 칸. 90°·270°면 가로·세로가 바뀐다. + public static void Footprint(IslandObjectDef def, int rot, out int width, out int depth) + { + bool swap = (((rot % 4) + 4) % 4) % 2 == 1; + width = swap ? def.depth : def.width; + depth = swap ? def.width : def.depth; + } + + public static bool InBounds(int x, int z, int width, int depth, int gridSize) + { + int half = gridSize / 2; + return x >= -half && z >= -half && x + width <= half && z + depth <= half; + } + + /// + /// 도착 칸(나루터 앞 2×2)과 겹치는가. 여길 막으면 섬에 들어오자마자 갇힌다 — + /// 캐릭터가 서는 자리이고, 끼임 복구도 이 자리로 보낸다. + /// + public static bool TouchesArrival(int x, int z, int width, int depth, int gridSize) + { + int half = gridSize / 2; + return RectsOverlap(x, z, width, depth, -1, -half, 2, 2); + } + + public static bool RectsOverlap(int ax, int az, int aw, int ad, int bx, int bz, int bw, int bd) + { + return ax < bx + bw && bx < ax + aw && az < bz + bd && bz < az + ad; + } + + /// + /// 이 자리에 놓을 수 있는가. 는 옮기는 중인 물건 자신(겹침 검사에서 뺀다, 없으면 -1). + /// 카탈로그에 없는 id의 기존 배치는 겹침 검사에서도 무시한다 — 구버전이 모르는 물건이 자리를 영영 막지 않게. + /// + public static IslandPlaceResult CanPlace(IReadOnlyList placed, int ignoreIndex, + IslandObjectDef def, int x, int z, int rot, int sizeLevel) + { + if (def == null) return IslandPlaceResult.UnknownObject; + int size = GridSize(sizeLevel); + Footprint(def, rot, out int w, out int d); + if (!InBounds(x, z, w, d, size)) return IslandPlaceResult.OutOfBounds; + if (TouchesArrival(x, z, w, d, size)) return IslandPlaceResult.Reserved; + + if (placed != null) + { + for (int i = 0; i < placed.Count; i++) + { + if (i == ignoreIndex) continue; + IslandPlacedRecord other = placed[i]; + if (other == null) continue; + IslandObjectDef otherDef = IslandCatalog.Get(other.id); + if (otherDef == null) continue; + Footprint(otherDef, other.rot, out int ow, out int od); + if (RectsOverlap(x, z, w, d, other.x, other.z, ow, od)) return IslandPlaceResult.Overlap; + } + } + return IslandPlaceResult.Ok; + } + + /// 그 칸을 덮고 있는 배치의 인덱스. 없으면 -1. + public static int FindAt(IReadOnlyList placed, int cellX, int cellZ) + { + if (placed == null) return -1; + for (int i = 0; i < placed.Count; i++) + { + IslandPlacedRecord p = placed[i]; + if (p == null) continue; + IslandObjectDef def = IslandCatalog.Get(p.id); + if (def == null) continue; + Footprint(def, p.rot, out int w, out int d); + if (cellX >= p.x && cellX < p.x + w && cellZ >= p.z && cellZ < p.z + d) return i; + } + return -1; + } + + /// 차지 영역의 중심(섬 원점 기준, y = 0). + public static Vector3 FootprintCenter(int x, int z, int width, int depth) + { + float cs = GameConstants.Island.CellSize; + return new Vector3((x + width * 0.5f) * cs, 0f, (z + depth * 0.5f) * cs); + } + + /// 섬 원점 기준 좌표가 속한 칸. + public static void CellAt(Vector3 local, out int cellX, out int cellZ) + { + float cs = GameConstants.Island.CellSize; + cellX = Mathf.FloorToInt(local.x / cs); + cellZ = Mathf.FloorToInt(local.z / cs); + } + + /// 캐릭터가 도착하는 자리(섬 원점 기준) — 남쪽 가장자리 가운데, 보호된 2×2의 중심. + public static Vector3 ArrivalPoint(int sizeLevel) + { + int half = GridSize(sizeLevel) / 2; + return new Vector3(0f, 0f, (-half + 1) * GameConstants.Island.CellSize); + } + + /// 섬 땅의 반 변 길이(m). + public static float HalfExtent(int sizeLevel) + { + return GridSize(sizeLevel) * 0.5f * GameConstants.Island.CellSize; + } + + /// + /// 비어 있는 칸 목록(도착 칸 제외) — 방목 곤충이 돌아다닐 자리. + /// 지나갈 수 있는 물건(꽃밭·덤불) 위는 빈 칸으로 친다. + /// + public static void CollectFreeCells(IReadOnlyList placed, int sizeLevel, + List result) + { + result.Clear(); + int size = GridSize(sizeLevel); + int half = size / 2; + for (int z = -half; z < half; z++) + { + for (int x = -half; x < half; x++) + { + if (TouchesArrival(x, z, 1, 1, size)) continue; + int at = FindAt(placed, x, z); + if (at >= 0) + { + IslandObjectDef def = IslandCatalog.Get(placed[at].id); + if (def != null && def.blocksMovement) continue; + } + result.Add(new Vector2Int(x, z)); + } + } + } + } +} diff --git a/Assets/Scripts/Core/IslandGrid.cs.meta b/Assets/Scripts/Core/IslandGrid.cs.meta new file mode 100644 index 0000000..46bad60 --- /dev/null +++ b/Assets/Scripts/Core/IslandGrid.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ad9eaeb5ff2addd4ca2cbea27305cf25 \ No newline at end of file diff --git a/Assets/Scripts/Core/IslandGuestRules.cs b/Assets/Scripts/Core/IslandGuestRules.cs new file mode 100644 index 0000000..18081b6 --- /dev/null +++ b/Assets/Scripts/Core/IslandGuestRules.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using InsectGame.Data; +using InsectGame.Spawning; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 나의 섬 손님 곤충의 순수 규칙 — 밤이거나 비·안개일 때 야생 곤충이 1~2마리 찾아와 잡을 수 있다. + /// 런타임(기록·몸)은 IslandWorldBuilder.Guests.cs가 든다. 규칙은 필드 스폰의 순수 함수를 그대로 다시 쓴다 — + /// 등급표·레벨·재생 지연·보너스 수를 여기서 새로 만들지 않는다(). + /// + /// + /// 수 = FieldSpawnRules.BonusSlots(세계 상태) 밤 +1 · 비·안개 +1 · 최대 2 (필드 보너스 슬롯과 같은 모양) + /// 후보 = 해금된 리전 풀의 합집합 ∩ 지금 시간·날씨에 나오는 종(InsectData.Matches) — 비면 합집합 전체 + /// 등급 = FieldSpawnRules.PickRarity(전역 표, 부스트 없음) — 영웅·전설은 후보에서 뺀다(아래 MaxGuestRarity) + /// 종 = 그 등급 안에서 spawnWeight × InsectHabits.SpawnWeightMultiplier(세계 상태) (FieldSpawnRules.PickWeighted) + /// 레벨 = 그 종이 사는 해금 리전 중 가장 낮은 리전의 대역 → FieldSpawnRules.RollFieldLevel + /// 재생 = 잡기·이기기·놓침 뒤 FieldSpawnRules.RespawnDelay(60~120초) · 수명 FieldSpawnRules.Lifetime(4~7분) + /// 도착 = 조건이 막 시작되면 FieldSpawnRules.PhaseSwapDelay(5~60초)로 흩어 온다 + /// 떠남 = 조건이 끝났거나 수명이 다했고, 붙잡히지·놀라지 않았고, 화면 밖일 때 + /// + /// + public static class IslandGuestRules + { + /// + /// 손님 등급 상한 — 희귀까지. 영웅·전설은 레이드로만 맞설 수 있는데(CaptureChoiceUI.IsRaidRarity), 섬 손님으로 오면 + /// ①집에서 이동 없이 영웅·전설 레이드(이기면 확정 포획)를 밤마다 받는 길이 생기고 ②섬은 곤충을 1배로 두는 곳인데 전설 야생 몸은 + /// 1.9배라 가구 사이에서 어색하고 ③섬에서 레이드 아레나로 갔다 돌아오는 길을 검증하지 못했다(Unity 미실행). + /// 빠진 두 등급의 몫(4%)은 등급표의 기존 대체 규칙( — 가까운 아래)대로 희귀로 내려온다 — + /// 섬 손님의 희귀 몫은 11% → 15%다. + /// + public const InsectRarity MaxGuestRarity = InsectRarity.Rare; + + /// 화면 밖 판정의 여유(뷰포트 비율) — 화면 가장자리에 걸친 곤충도 "보인다"로 친다. + public const float ViewportMargin = 0.08f; + + /// 섬 빈 칸을 따라 잴 때 한 걸음(m) — 칸(1.5m)의 6분의 1이라 모서리를 건너뛰지 않는다. + public const float FreeRunStep = 0.25f; + + /// 판정 주기(초) — 필드 스포너 틱과 같다. + public const float TickSeconds = FieldSpawnRules.TickSeconds; + + /// 최대 손님 수(). + public const int MaxGuests = FieldSpawnRules.MaxBonusSlots; + + /// 지금 세계 상태에서 섬에 와 있을 손님 수 — 필드 보너스 슬롯과 같은 식이다(단일 출처). + public static int WantedGuests(WorldState state) => FieldSpawnRules.BonusSlots(state); + + /// + /// 지금 손님이 섬에 들어설 수 있는가 — 내 섬이고(남의 섬 구경 아님), 꿈이 아니고, 꾸미기 중이 아닐 때. + /// 꾸미기 화면은 지면 탭이 「칸 고르기」라 곤충 탭과 겹치면 안 된다(이미 와 있는 손님은 그대로 두고 새로 들이지만 않는다). + /// + public static bool CanHostGuests(IslandMode mode, bool dreamMode, bool editing) + => mode == IslandMode.Own && !dreamMode && !editing; + + /// 떠날 차례인가 — 조건이 끝나 수가 줄었거나() 수명이 다했다. + public static bool ShouldLeave(bool surplus, bool expired) => surplus || expired; + + /// + /// 지금 떠나도 되는가 — 붙잡히거나(포획·전투) 놀라 있지 않고(경계·도주), 플레이어 눈 밖일 때. 필드의 "25m 밖에서만 바꾼다"와 + /// 같은 약속이지만 섬은 한 변이 15~33m라 거리로는 못 재서 화면으로 잰다. + /// + public static bool CanLeaveNow(bool busy, bool inView) => !busy && !inView; + + /// 뷰포트 좌표(Camera.WorldToViewportPoint)가 화면 안(여유 포함)인가. 카메라 뒤는 안 보인다. + public static bool IsInView(Vector3 viewport, float margin = ViewportMargin) + { + return viewport.z > 0f + && viewport.x >= -margin && viewport.x <= 1f + margin + && viewport.y >= -margin && viewport.y <= 1f + margin; + } + + /// + /// 해금된 리전 표만 남긴다( — RegionManager.IsRegionAccessible). 레벨 대역 고르기가 + /// "가장 낮은 리전"을 쓰므로 최소 레벨 오름차순으로 정렬해 둔다. + /// + public static void FilterAccessible(IReadOnlyList all, Func isAccessible, + List into) + { + into.Clear(); + if (all == null) return; + for (int i = 0; i < all.Count; i++) + { + FieldRegionTable t = all[i]; + if (t.Pool == null || t.Pool.Count == 0) continue; + if (isAccessible != null && !isAccessible(t.RegionId)) continue; + into.Add(t); + } + into.Sort((a, b) => a.MinLevel.CompareTo(b.MinLevel)); + } + + /// + /// 손님 후보 — 해금된 풀들의 합집합 가운데 지금 시간·날씨에 나오는 종( — 필드 후보와 같은 거르개). + /// 그렇게 걸러 아무것도 안 남으면 합집합 전체다(필드도 그렇게 물러난다 — 손님이 영영 안 오지 않게). + /// + public static void CollectCandidates(IReadOnlyList accessible, WorldState state, List into) + { + into.Clear(); + if (accessible == null) return; + for (int t = 0; t < accessible.Count; t++) + { + IReadOnlyList pool = accessible[t].Pool; + if (pool == null) continue; + for (int i = 0; i < pool.Count; i++) + { + InsectData d = pool[i]; + if (d == null || !d.Matches(state) || into.Contains(d)) continue; + into.Add(d); + } + } + if (into.Count > 0) return; + for (int t = 0; t < accessible.Count; t++) + { + IReadOnlyList pool = accessible[t].Pool; + if (pool == null) continue; + for (int i = 0; i < pool.Count; i++) + if (pool[i] != null && !into.Contains(pool[i])) into.Add(pool[i]); + } + } + + /// + /// 손님 종 하나 — 등급을 전역 표로 먼저 굴리고(, 위는 후보에서 뺀다) + /// 그 등급 안에서 spawnWeight × 성향 배수로 고른다(). 후보가 없으면 null. + /// 스크래치 목록·배열은 호출부가 들고 있다가 넘긴다(굴릴 때마다 할당하지 않게). + /// + public static InsectData PickSpecies(IReadOnlyList candidates, WorldState state, float rarityRoll, + float speciesRoll, List scratch, List scratchMultipliers, bool[] available) + { + if (candidates == null || candidates.Count == 0 || scratch == null || scratchMultipliers == null + || available == null || available.Length < FieldSpawnRules.RarityCount) return null; + + for (int r = 0; r < available.Length; r++) available[r] = false; + for (int i = 0; i < candidates.Count; i++) + { + InsectData d = candidates[i]; + if (d == null || d.rarity > MaxGuestRarity) continue; + int r = (int)d.rarity; + if (r >= 0 && r < available.Length) available[r] = true; + } + int rarity = FieldSpawnRules.PickRarity(rarityRoll, 1f, available); + if (rarity < 0) return null; + + scratch.Clear(); + scratchMultipliers.Clear(); + for (int i = 0; i < candidates.Count; i++) + { + InsectData d = candidates[i]; + if (d == null || (int)d.rarity != rarity) continue; + scratch.Add(d); + scratchMultipliers.Add(InsectHabits.SpawnWeightMultiplier(InsectHabits.For(d), state)); + } + return FieldSpawnRules.PickWeighted(scratch, scratchMultipliers, speciesRoll); + } + + /// + /// 손님 레벨의 대역 — 그 종이 사는 해금 리전 가운데 가장 낮은 리전의 대역(는 최소 레벨 + /// 오름차순). 그 종을 필드에서 처음 만나는 곳과 같은 레벨이라 섬이 고레벨 사냥터(가장 높은 해금 리전의 대역)도, + /// 뒤 리전 종을 낮은 레벨로 거저 주는 곳(가장 낮은 리전의 대역)도 되지 않는다. 어느 풀에도 없으면(있을 수 없다) false. + /// + public static bool TryLevelBand(InsectData species, IReadOnlyList accessibleByLevel, + out int minLevel, out int maxLevel) + { + minLevel = maxLevel = 1; + if (species == null || accessibleByLevel == null) return false; + for (int t = 0; t < accessibleByLevel.Count; t++) + { + IReadOnlyList pool = accessibleByLevel[t].Pool; + if (pool == null) continue; + for (int i = 0; i < pool.Count; i++) + { + if (!ReferenceEquals(pool[i], species)) continue; + minLevel = accessibleByLevel[t].MinLevel; + maxLevel = accessibleByLevel[t].MaxLevel; + return true; + } + } + return false; + } + + /// + /// 섬 원점 기준 에서 으로 빈 칸만 밟고 갈 수 있는 거리(m, 최대 + /// ). 손님의 도주가 이 거리를 넘지 않는다(InsectEntity.SetFleeArea) — 섬 밖·물·건물 칸으로 달아나지 않는다. + /// 출발 칸부터 빈 칸이 아니면 0. + /// + public static float FreeRun(Vector3 localStart, Vector3 direction, ICollection freeCells, + float maxDistance, float step = FreeRunStep) + { + if (freeCells == null || maxDistance <= 0f) return 0f; + direction.y = 0f; + if (direction.sqrMagnitude < 1e-6f) return 0f; + direction.Normalize(); + step = Mathf.Max(0.05f, step); + + IslandGrid.CellAt(localStart, out int sx, out int sz); + if (!freeCells.Contains(new Vector2Int(sx, sz))) return 0f; + + float run = 0f; + while (run + step <= maxDistance) + { + Vector3 p = localStart + direction * (run + step); + IslandGrid.CellAt(p, out int x, out int z); + if (!freeCells.Contains(new Vector2Int(x, z))) return run; + run += step; + } + return maxDistance; + } + + /// + /// 손님이 들어설 때 플레이어와 떨어뜨릴 최소 거리(칸, 1칸 1.5m). 전투 중에는 카메라가 아레나에 가 있어 섬 전체가 "화면 밖"이다 — + /// 그때 들어선 손님이 플레이어 바로 옆이면 돌아오자마자 놀라 달아난다. 가장 작은 섬(10칸)에서도 도착 자리에서 이만큼 떨어진 칸이 남는다. + /// + public const int ArrivalMinCells = 3; + + /// + /// 손님이 들어설 칸 — 플레이어에서 이상 떨어진 화면 밖 칸 가운데 하나(로), + /// 그런 칸이 없으면 플레이어에서 가장 먼 칸. 다른 손님이 선 칸()은 고르지 않는다. 고를 칸이 없으면 false. + /// 눈앞에 갑자기 생기지 않게 하는 필드의 "새 개체는 20m 밖"과 같은 약속이다(섬은 작아 거리 대신 화면으로 잰다). + /// + public static bool PickArrivalCell(IReadOnlyList freeCells, Func inView, + Vector2Int playerCell, ICollection taken, float roll01, out Vector2Int cell) + { + cell = default; + if (freeCells == null || freeCells.Count == 0) return false; + + int hidden = 0; + for (int i = 0; i < freeCells.Count; i++) + { + Vector2Int c = freeCells[i]; + if (!IsArrivalCandidate(c, playerCell, taken)) continue; + if (inView == null || !inView(c)) hidden++; + } + if (hidden > 0) + { + int pick = Mathf.Clamp(Mathf.FloorToInt(Mathf.Clamp01(roll01) * hidden), 0, hidden - 1); + for (int i = 0; i < freeCells.Count; i++) + { + Vector2Int c = freeCells[i]; + if (!IsArrivalCandidate(c, playerCell, taken)) continue; + if (inView != null && inView(c)) continue; + if (pick-- == 0) + { + cell = c; + return true; + } + } + } + + int best = -1; + int bestDistance = -1; + for (int i = 0; i < freeCells.Count; i++) + { + Vector2Int c = freeCells[i]; + if (taken != null && taken.Contains(c)) continue; + int dx = c.x - playerCell.x, dz = c.y - playerCell.y; + int d = dx * dx + dz * dz; + if (d > bestDistance) + { + bestDistance = d; + best = i; + } + } + if (best < 0) return false; + cell = freeCells[best]; + return true; + } + + private static bool IsArrivalCandidate(Vector2Int c, Vector2Int playerCell, ICollection taken) + { + if (taken != null && taken.Contains(c)) return false; + int dx = c.x - playerCell.x, dz = c.y - playerCell.y; + return dx * dx + dz * dz >= ArrivalMinCells * ArrivalMinCells; + } + } +} diff --git a/Assets/Scripts/Core/IslandGuestRules.cs.meta b/Assets/Scripts/Core/IslandGuestRules.cs.meta new file mode 100644 index 0000000..0656ded --- /dev/null +++ b/Assets/Scripts/Core/IslandGuestRules.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 92362b11d07539841bba262a770b01e1 \ No newline at end of file diff --git a/Assets/Scripts/Core/IslandGuideSteps.cs b/Assets/Scripts/Core/IslandGuideSteps.cs new file mode 100644 index 0000000..8216c67 --- /dev/null +++ b/Assets/Scripts/Core/IslandGuideSteps.cs @@ -0,0 +1,111 @@ +namespace InsectGame.Core +{ + /// 섬 첫 방문 안내의 단계. 저장은 정수()라 순서를 바꾸지 말 것 — 뒤에만 붙인다. + public enum IslandGuideStep + { + OpenEdit = 0, + PlaceFirst = 1, + ReleaseInsect = 2, + Harvest = 3, + OpenShop = 4, + Finish = 5, + Done = 6, + } + + /// 안내가 다음 단계로 넘어갈지 정하는 데 보는 사실들. 전부 실제 행동의 결과다. + public struct IslandGuideFacts + { + public bool editOpened; + public bool shopOpened; + public bool finishAcknowledged; + public int placedCount; + public int releasedCount; + public int harvestCount; + public int ownedInsectCount; + } + + /// + /// 섬 안내의 순수 단계 판정. "다음" 버튼이 아니라 행동으로 넘어간다 — + /// 벤치를 놓으면 다음 단계, 곤충을 풀면 그다음. 이미 한 행동의 단계는 건너뛴다 + /// (다른 기기에서 섬을 꾸민 뒤 클라우드로 받아 온 세이브가 "벤치를 놓아 보세요"에 멈추지 않게). + /// + /// 기존 GuidedTutorialController를 안 쓰는 이유: 그쪽은 Story 퀘스트의 QuestActivated에 + /// 묶여 있는데 섬 퀘스트는 Side라 그 이벤트가 울리지 않는다. + /// + public static class IslandGuideSteps + { + /// 지금 사실들로 갈 수 있는 데까지 나아간 단계. + public static IslandGuideStep Advance(IslandGuideStep step, IslandGuideFacts facts) + { + // 한 번에 여러 단계를 넘을 수 있다(이미 다 해 둔 세이브). 단계 수가 상한이라 무한 루프가 없다. + for (int guard = 0; guard <= (int)IslandGuideStep.Done; guard++) + { + if (step == IslandGuideStep.Done || !IsSatisfied(step, facts)) return step; + step = (IslandGuideStep)((int)step + 1); + } + return step; + } + + public static bool IsSatisfied(IslandGuideStep step, IslandGuideFacts facts) + { + switch (step) + { + // 이미 무언가 놓았다면 꾸미기 화면은 써 본 것이다. + case IslandGuideStep.OpenEdit: return facts.editOpened || facts.placedCount > 0; + case IslandGuideStep.PlaceFirst: return facts.placedCount > 0; + // 풀어놓을 곤충이 한 마리도 없으면 막지 않고 넘긴다(마스터 계정·빈 세이브). + case IslandGuideStep.ReleaseInsect: return facts.releasedCount > 0 || facts.ownedInsectCount <= 0; + case IslandGuideStep.Harvest: return facts.harvestCount > 0; + case IslandGuideStep.OpenShop: return facts.shopOpened; + case IslandGuideStep.Finish: return facts.finishAcknowledged; + default: return true; + } + } + + public static string Title(IslandGuideStep step) + { + switch (step) + { + case IslandGuideStep.OpenEdit: return "나의 섬에 오신 걸 환영합니다"; + case IslandGuideStep.PlaceFirst: return "물건 놓기"; + case IslandGuideStep.ReleaseInsect: return "곤충 풀어놓기"; + case IslandGuideStep.Harvest: return "수확하기"; + case IslandGuideStep.OpenShop: return "섬 상점"; + case IslandGuideStep.Finish: return "이제 마음대로 꾸며 보세요"; + default: return string.Empty; + } + } + + public static string Body(IslandGuideStep step) + { + switch (step) + { + case IslandGuideStep.OpenEdit: + // 방향을 적지 않는다 — [꾸미기] 버튼은 데스크톱·세로에선 아래, 가로 모바일에선 위쪽 판에 있다(IslandHudLayout). + return "보관함에 벤치와 화분을 넣어 두었어요. [꾸미기] 버튼을 눌러 보세요."; + case IslandGuideStep.PlaceFirst: + return "아래 보관함에서 물건을 고르고, 섬 위의 칸을 누른 뒤 [놓기]를 누르세요."; + case IslandGuideStep.ReleaseInsect: + return "[곤충]을 눌러 보유 곤충을 섬에 풀어놓으세요. 풀어도 전투에는 그대로 쓸 수 있어요."; + case IslandGuideStep.Harvest: + return "곤충이 머무는 동안 캔디와 코인이 쌓여요. 첫 선물을 넣어 두었으니 [수확]을 눌러 받으세요."; + case IslandGuideStep.OpenShop: + return "[상점]에서 건물·가구·지형지물·도구를 사고 섬과 곤충 자리를 넓힐 수 있어요."; + case IslandGuideStep.Finish: + return "[방문]으로 다른 사람의 섬을 구경하고, 궁금할 땐 [도움말]을 다시 열어 보세요."; + default: return string.Empty; + } + } + + /// 도움말 창에 한꺼번에 보여 주는 요약 — 단계 문구와 같은 내용을 다시 쓴 것이 아니라 "무엇을 어디서"만 적는다. + public static readonly (string title, string body)[] HelpTopics = + { + ("꾸미기", "보관함의 물건을 섬 칸에 놓습니다. 놓인 물건을 누르면 옮기기·돌리기·넣기를 할 수 있어요. 화면을 끌면 섬을 둘러봅니다."), + ("곤충", "보유 곤충을 섬에 풀어놓습니다. 풀어놓은 곤충은 전투·훈련에 그대로 쓸 수 있고, 오래 머물수록 친밀도(하트)가 올라 생산이 늘어요."), + ("수확", "쌓인 캔디와 코인을 받습니다. 가득 차면 더 쌓이지 않으니 가끔 들러 주세요. 창고와 수확 바구니가 쌓이는 시간을 늘립니다."), + ("쾌적도", "놓인 물건마다 쾌적도가 있고, 쾌적도가 높을수록 생산이 늘어요. 같은 물건을 여러 개 놓는 것보다 종류를 늘리는 쪽이 잘 오릅니다."), + ("상점", "건물·가구·지형지물·도구를 삽니다. [확장] 탭에서 섬 크기와 곤충 자리를 넓혀요. 넣어 둔 물건은 팔 수 없고 보관함에 남습니다."), + ("방문", "친구 목록이나 섬 코드로 다른 사람의 섬을 구경합니다. 내 섬 코드는 [방문] 창에 있고, 공개를 끄면 아무도 들어올 수 없어요."), + }; + } +} diff --git a/Assets/Scripts/Core/IslandGuideSteps.cs.meta b/Assets/Scripts/Core/IslandGuideSteps.cs.meta new file mode 100644 index 0000000..00d71ec --- /dev/null +++ b/Assets/Scripts/Core/IslandGuideSteps.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1577b940affb57a439a90dbc3eafbb2f \ No newline at end of file diff --git a/Assets/Scripts/Core/IslandInsectMood.cs b/Assets/Scripts/Core/IslandInsectMood.cs new file mode 100644 index 0000000..575363b --- /dev/null +++ b/Assets/Scripts/Core/IslandInsectMood.cs @@ -0,0 +1,91 @@ +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 섬 방목 곤충의 기분 — 시간대·날씨 성향()을 활동도 0~1로 바꾸고, 활동도를 배회의 숫자 + /// (쉬는 시간·걷는 속도·나는 높이)로 바꾸는 순수 규칙. 가 읽는다. 씬 없이 돌아 테스트가 고정한다. + /// + /// 몸의 위치·속도·쉬는 시간만 바꾼다 — 날갯짓 같은 모델 애니메이션은 visual-dev 영역이라 건드리지 않는다. + /// 섬은 지역이 없어 세계 날씨(WorldStateProvider.GetWorldState(null))로 잰다. 꿈속 섬은 늘 맑은 한낮이라 자연히 낮의 기분이다. + /// + /// 섬 수입과는 무관하다(사용자 결정 — 섬 생산 보너스 없음). 기분은 보이는 모습일 뿐 IslandYield를 건드리지 않는다. + /// + public static class IslandInsectMood + { + // ── 활동도 ── + // 제 시간(야행성의 밤·주행성의 아침·낮)이면 활발, 잠들 시간이면 대부분 쉬고, 어스름·시간 무관이면 보통이다. + // 날씨는 좋아하면 조금 오르고 싫어하면 더 내려간다 — "싫은 날씨엔 덜 움직인다"가 "좋은 날씨엔 활발"보다 눈에 잘 띄게. + + public const float LivelyActivity = 1.0f; + public const float NeutralActivity = 0.6f; + public const float SleepyActivity = 0.2f; + public const float LikedWeatherBonus = 0.15f; + public const float DislikedWeatherPenalty = 0.25f; + + // ── 활동도 → 배회. 세 기준점(졸림 0.2 · 보통 0.6 · 활발 1.0) 사이를 선형으로 잇는다 ── + // 보통(0.6)의 값은 성향을 넣기 전의 배회 그대로다(쉬기 1.2~4.5초 · 속도 ×1 · 나는 높이 ×1) — 시간·날씨를 안 타는 종은 예전과 같다. + + public const float SleepyRestMin = 5f, NeutralRestMin = 1.2f, LivelyRestMin = 0.6f; + public const float SleepyRestMax = 12f, NeutralRestMax = 4.5f, LivelyRestMax = 2f; + public const float SleepySpeed = 0.55f, NeutralSpeed = 1f, LivelySpeed = 1.3f; + + /// 나는 곤충의 높이 배수 — 졸리면 땅에 낮게 내려앉는다(0.25 × 0.9m ≈ 0.22m). + public const float SleepyHover = 0.25f, NeutralHover = 1f, LivelyHover = 1.1f; + + /// 배회의 숫자들. + public readonly struct Mood + { + public readonly float Activity; + public readonly float RestMin; + public readonly float RestMax; + public readonly float SpeedMultiplier; + public readonly float HoverMultiplier; + + public Mood(float activity, float restMin, float restMax, float speed, float hover) + { + Activity = activity; + RestMin = restMin; + RestMax = restMax; + SpeedMultiplier = speed; + HoverMultiplier = hover; + } + } + + /// 성향도 상태도 모를 때 — 예전 배회 그대로. + public static Mood Neutral => FromActivity(NeutralActivity); + + /// 이 종이 이 상태에서 얼마나 활발한가(0~1). + public static float Activity(InsectHabit habit, WorldState state) + { + int timeFit = InsectHabits.TimeFit(habit, state.DayPhase); + float a = timeFit > 0 ? LivelyActivity : timeFit < 0 ? SleepyActivity : NeutralActivity; + int weatherFit = InsectHabits.WeatherFit(habit, state.Weather); + if (weatherFit > 0) a += LikedWeatherBonus; + else if (weatherFit < 0) a -= DislikedWeatherPenalty; + return Mathf.Clamp01(a); + } + + /// 이 종이 이 상태에서 섬을 어떻게 돌아다니나. + public static Mood For(InsectHabit habit, WorldState state) => FromActivity(Activity(habit, state)); + + /// 활동도 → 배회 숫자. 기준점 바깥(0.2 아래·1.0)은 끝값으로 가둔다. + public static Mood FromActivity(float activity) + { + float a = Mathf.Clamp01(activity); + return new Mood(a, + Blend(a, SleepyRestMin, NeutralRestMin, LivelyRestMin), + Blend(a, SleepyRestMax, NeutralRestMax, LivelyRestMax), + Blend(a, SleepySpeed, NeutralSpeed, LivelySpeed), + Blend(a, SleepyHover, NeutralHover, LivelyHover)); + } + + private static float Blend(float a, float sleepy, float neutral, float lively) + { + if (a <= NeutralActivity) + return Mathf.Lerp(sleepy, neutral, Mathf.InverseLerp(SleepyActivity, NeutralActivity, a)); + return Mathf.Lerp(neutral, lively, Mathf.InverseLerp(NeutralActivity, LivelyActivity, a)); + } + } +} diff --git a/Assets/Scripts/Core/IslandInsectMood.cs.meta b/Assets/Scripts/Core/IslandInsectMood.cs.meta new file mode 100644 index 0000000..570aac5 --- /dev/null +++ b/Assets/Scripts/Core/IslandInsectMood.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cd2afc0a27c0fd448959403872a16533 \ No newline at end of file diff --git a/Assets/Scripts/Core/IslandInsectWalker.cs b/Assets/Scripts/Core/IslandInsectWalker.cs new file mode 100644 index 0000000..7d7b13b --- /dev/null +++ b/Assets/Scripts/Core/IslandInsectWalker.cs @@ -0,0 +1,146 @@ +using System.Collections.Generic; +using InsectGame.Data; +using InsectGame.Spawning; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 섬에 풀어놓은 곤충의 느긋한 배회. 빈 칸 하나를 골라 걸어가고, 잠깐 쉬고, 또 고른다. + /// + /// 모델은 InsectEntity.BuildForBattle로 짓는다 — 그 경로는 배회·도주 AI가 꺼져 있고 포획 대상도 + /// 아니다(야생 스폰 경로로 지으면 섬 위에서 플레이어를 보고 달아나고 잡기 버튼이 뜬다). + /// 그래서 몸을 옮기는 일은 이 컴포넌트가 맡는다. 날갯짓은 InsectEntity가 그대로 돌린다. + /// + /// 시간·날씨를 탄다() — 야행성은 밤에 활발하고 낮엔 대부분 쉰다(오래 쉬고 느리고, + /// 나는 종은 땅에 낮게 내려앉는다). 주행성은 반대다. 월드 빌더가 상태가 바뀔 때 로 알려 준다. + /// + public class IslandInsectWalker : MonoBehaviour + { + /// 곤충 모델의 원점이 지면에서 뜨는 높이 — 배틀 아레나가 세우는 높이와 같다. + internal const float GroundOffset = 0.45f; + + private const float FlyHeight = 0.9f; + private const int PickAttempts = 6; + private const int WanderCells = 4; + /// 나는 높이가 기분을 따라 바뀌는 속도(m/s) — 상태가 바뀐 순간 툭 떨어지지 않고 내려앉는다. + private const float HoverChangeSpeed = 0.5f; + + private Vector3 islandOrigin; + private IReadOnlyList freeCells; + private HashSet freeSet; + private Vector3 target; + private bool moving; + private float restTimer; + private float speed; + private bool flies; + private float bobPhase; + private InsectHabit habit = InsectHabit.Neutral; + private IslandInsectMood.Mood mood = IslandInsectMood.Neutral; + private float hover = FlyHeight; + + /// 지금 기분(테스트·검수가 읽는다). + public IslandInsectMood.Mood CurrentMood => mood; + + /// + /// 는 지금 섬의 빈 칸(월드 빌더가 배치가 바뀔 때마다 다시 넘긴다). + /// 목록을 복사하지 않는다 — 여러 마리가 같은 목록을 본다. + /// + public void Initialize(Vector3 origin, IReadOnlyList cells, HashSet cellSet, int seed) + { + islandOrigin = origin; + freeCells = cells; + freeSet = cellSet; + flies = transform.Find("WingL") != null; + InsectEntity entity = GetComponent(); + habit = entity != null ? InsectHabits.For(entity.Data) : InsectHabit.Neutral; + mood = IslandInsectMood.Neutral; + // 같은 프레임에 세워진 곤충들이 같은 박자로 움직이지 않게 개체마다 어긋나게 둔다. + var rng = new System.Random(seed); + speed = 0.55f + (float)rng.NextDouble() * 0.5f; + restTimer = (float)rng.NextDouble() * 2.5f; + bobPhase = (float)rng.NextDouble() * Mathf.PI * 2f; + moving = false; + hover = FlyHeight; + } + + /// + /// 지금 섬의 시간대·날씨를 알려 준다 — 기분을 다시 정한다. 쉬는 중이면 남은 쉬기를 새 기분의 범위로 가둔다 + /// (밤이 됐는데 낮에 잡은 12초 쉬기를 다 채우지 않게). 나는 높이는 가 천천히 따라간다. + /// + public void ApplyWorld(WorldState state) + { + mood = IslandInsectMood.For(habit, state); + if (!moving) restTimer = Mathf.Min(restTimer, mood.RestMax); + } + + private void Update() + { + if (freeCells == null || freeCells.Count == 0) return; + float dt = Time.deltaTime; + + Vector3 pos = transform.position; + if (flies) hover = Mathf.MoveTowards(hover, FlyHeight * mood.HoverMultiplier, HoverChangeSpeed * dt); + float baseY = islandOrigin.y + GroundOffset * transform.localScale.y + + (flies ? hover + Mathf.Sin(Time.time * 1.7f + bobPhase) * 0.12f * mood.HoverMultiplier : 0f); + + if (!moving) + { + restTimer -= dt; + if (restTimer <= 0f) PickTarget(pos); + pos.y = baseY; + transform.position = pos; + return; + } + + Vector3 to = target - pos; + to.y = 0f; + float dist = to.magnitude; + if (dist < 0.08f) + { + moving = false; + restTimer = Random.Range(mood.RestMin, mood.RestMax); + } + else + { + Vector3 dir = to / dist; + pos += dir * Mathf.Min(dist, speed * mood.SpeedMultiplier * dt); + Quaternion look = Quaternion.LookRotation(dir, Vector3.up); + transform.rotation = Quaternion.RotateTowards(transform.rotation, look, 240f * dt); + } + pos.y = baseY; + transform.position = pos; + } + + // 가까운 빈 칸 가운데 **가는 길이 전부 빈 칸**인 곳을 고른다 — 그러지 않으면 건물을 뚫고 지나간다. + private void PickTarget(Vector3 from) + { + IslandGrid.CellAt(from - islandOrigin, out int cx, out int cz); + for (int attempt = 0; attempt < PickAttempts; attempt++) + { + Vector2Int cell = freeCells[Random.Range(0, freeCells.Count)]; + if (Mathf.Abs(cell.x - cx) > WanderCells || Mathf.Abs(cell.y - cz) > WanderCells) continue; + Vector3 candidate = islandOrigin + IslandGrid.FootprintCenter(cell.x, cell.y, 1, 1); + if (!PathIsFree(from, candidate)) continue; + target = candidate; + moving = true; + return; + } + // 못 찾았다(좁은 틈에 끼었거나 섬이 꽉 찼다) — 조금 쉬고 다시 고른다. + restTimer = 1f; + } + + private bool PathIsFree(Vector3 from, Vector3 to) + { + float cs = GameConstants.Island.CellSize; + int steps = Mathf.Max(1, Mathf.CeilToInt(Vector3.Distance(from, to) / (cs * 0.5f))); + for (int i = 1; i <= steps; i++) + { + Vector3 p = Vector3.Lerp(from, to, i / (float)steps) - islandOrigin; + IslandGrid.CellAt(p, out int x, out int z); + if (!freeSet.Contains(new Vector2Int(x, z))) return false; + } + return true; + } + } +} diff --git a/Assets/Scripts/Core/IslandInsectWalker.cs.meta b/Assets/Scripts/Core/IslandInsectWalker.cs.meta new file mode 100644 index 0000000..9975a6b --- /dev/null +++ b/Assets/Scripts/Core/IslandInsectWalker.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ef3f8436917768c48b7525fe1d7b1fdd \ No newline at end of file diff --git a/Assets/Scripts/Core/IslandManager.cs b/Assets/Scripts/Core/IslandManager.cs new file mode 100644 index 0000000..de1d156 --- /dev/null +++ b/Assets/Scripts/Core/IslandManager.cs @@ -0,0 +1,929 @@ +using System; +using System.Collections.Generic; +using System.IO; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Core +{ + /// 구매·확장이 거부된 이유 — UI가 문구로 바꾼다. + public enum IslandBuyResult + { + Ok, + Unknown, + NotEnoughCoins, + NotEnoughGems, + Maxed, + Locked, + } + + /// 방목이 거부된 이유. + public enum IslandReleaseResult + { + Ok, + NoSuchInsect, + AlreadyReleased, + NoFreeSlot, + } + + /// + /// 섬 세이브의 순수 정리 규칙 — 손상·구버전·신버전 혼용에서 스스로 낫는다. + /// MonoBehaviour와 떼어 놓아 테스트로 고정한다. + /// + public static class IslandSaveRules + { + /// + /// null 목록·빈 기록·중복을 정리하고 범위를 가둔다. 고친 게 있으면 true. + /// + /// 카탈로그가 모르는 id는 지우지 않는다 — 새 버전에서 산 물건을 구버전이 열었다고 날리면 안 된다. + /// 그리기·효과·겹침 검사가 그 id를 건너뛸 뿐이다. + /// + public static bool Sanitize(IslandSave save) + { + if (save == null) return false; + bool dirty = false; + + if (save.owned == null) { save.owned = new List(); dirty = true; } + if (save.placed == null) { save.placed = new List(); dirty = true; } + if (save.released == null) { save.released = new List(); dirty = true; } + if (save.bonds == null) { save.bonds = new List(); dirty = true; } + + int maxSize = GameConstants.Island.MaxSizeLevel; + int clampedSize = Mathf.Clamp(save.sizeLevel, 0, maxSize); + if (clampedSize != save.sizeLevel) { save.sizeLevel = clampedSize; dirty = true; } + + int maxExtra = GameConstants.Island.MaxInsectSlots - GameConstants.Island.BaseInsectSlots; + int clampedExtra = Mathf.Clamp(save.extraSlots, 0, maxExtra); + if (clampedExtra != save.extraSlots) { save.extraSlots = clampedExtra; dirty = true; } + + // 보유 기록 — 빈 id 제거, 같은 id 합치기, 음수 막기. + for (int i = save.owned.Count - 1; i >= 0; i--) + { + IslandOwnedRecord r = save.owned[i]; + if (r == null || string.IsNullOrEmpty(r.id)) { save.owned.RemoveAt(i); dirty = true; continue; } + if (r.count < 0) { r.count = 0; dirty = true; } + for (int j = 0; j < i; j++) + { + IslandOwnedRecord first = save.owned[j]; + if (first == null || first.id != r.id) continue; + first.count += r.count; + save.owned.RemoveAt(i); + dirty = true; + break; + } + } + + // 배치 기록 — 빈 id 제거, 회전 정규화. + for (int i = save.placed.Count - 1; i >= 0; i--) + { + IslandPlacedRecord p = save.placed[i]; + if (p == null || string.IsNullOrEmpty(p.id)) { save.placed.RemoveAt(i); dirty = true; continue; } + int rot = ((p.rot % 4) + 4) % 4; + if (rot != p.rot) { p.rot = rot; dirty = true; } + } + + // 놓인 수가 보유 수를 넘으면 보유 수를 올린다 — 놓인 물건을 지우는 쪽보다 안전하다(섬 모양이 유지된다). + for (int i = 0; i < save.placed.Count; i++) + { + string id = save.placed[i].id; + int placedCount = CountPlaced(save, id); + IslandOwnedRecord owned = FindOwned(save, id); + if (owned == null) + { + save.owned.Add(new IslandOwnedRecord { id = id, count = placedCount }); + dirty = true; + } + else if (owned.count < placedCount) + { + owned.count = placedCount; + dirty = true; + } + } + + // 방목 목록 — 빈 값·중복 제거, 슬롯 수를 넘는 뒤쪽은 버린다. + for (int i = save.released.Count - 1; i >= 0; i--) + { + string id = save.released[i]; + if (string.IsNullOrEmpty(id) || save.released.IndexOf(id) != i) { save.released.RemoveAt(i); dirty = true; } + } + int slots = GameConstants.Island.BaseInsectSlots + save.extraSlots; + if (save.released.Count > slots) + { + save.released.RemoveRange(slots, save.released.Count - slots); + dirty = true; + } + + for (int i = save.bonds.Count - 1; i >= 0; i--) + { + IslandBondRecord b = save.bonds[i]; + if (b == null || string.IsNullOrEmpty(b.instanceId)) { save.bonds.RemoveAt(i); dirty = true; continue; } + if (b.hours < 0f || float.IsNaN(b.hours)) { b.hours = 0f; dirty = true; } + } + + // 수치 — NaN·음수는 0으로. 누적 시간은 최대 상한을 넘지 않는다. + if (!(save.pendingCandy >= 0f)) { save.pendingCandy = 0f; dirty = true; } + if (!(save.pendingCoin >= 0f)) { save.pendingCoin = 0f; dirty = true; } + if (!(save.accruedHours >= 0f)) { save.accruedHours = 0f; dirty = true; } + if (save.accruedHours > GameConstants.Island.MaxCapHours) + { + save.accruedHours = GameConstants.Island.MaxCapHours; + dirty = true; + } + if (save.harvestCount < 0) { save.harvestCount = 0; dirty = true; } + if (save.lastSettleUnix < 0) { save.lastSettleUnix = 0; dirty = true; } + int maxStep = (int)IslandGuideStep.Done; + int clampedStep = Mathf.Clamp(save.guideStep, 0, maxStep); + if (clampedStep != save.guideStep) { save.guideStep = clampedStep; dirty = true; } + + return dirty; + } + + public static IslandOwnedRecord FindOwned(IslandSave save, string id) + { + if (save == null || save.owned == null) return null; + for (int i = 0; i < save.owned.Count; i++) + if (save.owned[i] != null && save.owned[i].id == id) return save.owned[i]; + return null; + } + + public static int CountPlaced(IslandSave save, string id) + { + if (save == null || save.placed == null) return 0; + int n = 0; + for (int i = 0; i < save.placed.Count; i++) + if (save.placed[i] != null && save.placed[i].id == id) n++; + return n; + } + + /// + /// 공개용 축약본을 방문자 쪽에서 받았을 때의 정리 — 남이 만든 데이터라 믿지 않는다. + /// 모르는 id·경계 밖·겹치는 배치와 상한을 넘는 곤충을 버린다. + /// + public static void SanitizeSnapshot(IslandSnapshot snapshot) + { + if (snapshot == null) return; + if (snapshot.placed == null) snapshot.placed = new List(); + if (snapshot.insects == null) snapshot.insects = new List(); + if (snapshot.ownerName == null) snapshot.ownerName = ""; + if (snapshot.ownerName.Length > 24) snapshot.ownerName = snapshot.ownerName.Substring(0, 24); + snapshot.sizeLevel = Mathf.Clamp(snapshot.sizeLevel, 0, GameConstants.Island.MaxSizeLevel); + + var accepted = new List(); + for (int i = 0; i < snapshot.placed.Count; i++) + { + IslandPlacedRecord p = snapshot.placed[i]; + if (p == null) continue; + p.rot = ((p.rot % 4) + 4) % 4; + IslandObjectDef def = IslandCatalog.Get(p.id); + if (IslandGrid.CanPlace(accepted, -1, def, p.x, p.z, p.rot, snapshot.sizeLevel) != IslandPlaceResult.Ok) + continue; + accepted.Add(p); + } + snapshot.placed = accepted; + + for (int i = snapshot.insects.Count - 1; i >= 0; i--) + { + IslandSnapshotInsect s = snapshot.insects[i]; + if (s == null || string.IsNullOrEmpty(s.insectId)) { snapshot.insects.RemoveAt(i); continue; } + s.level = Mathf.Clamp(s.level, 1, GameConstants.Leveling.FallbackMaxLevel); + } + if (snapshot.insects.Count > GameConstants.Island.MaxInsectSlots) + snapshot.insects.RemoveRange(GameConstants.Island.MaxInsectSlots, + snapshot.insects.Count - GameConstants.Island.MaxInsectSlots); + } + } + + /// + /// 나의 섬의 상태와 규칙 — 보관함·배치·방목·수확·확장·가이드 진행. 세이브는 island.json. + /// 3D는 IslandWorldBuilder가, 화면은 UI/Island*가 이 컴포넌트의 이벤트를 보고 그린다. + /// 싱글턴이 아니다(AutoWire). + /// + public class IslandManager : MonoBehaviour, ICloudReloadable + { + [SerializeField] private PlayerInsectCollection insects; + [SerializeField] private PlayerCurrencyWallet wallet; + [SerializeField] private PlayerCandyInventory candy; + [SerializeField] private InsectDatabase database; + + /// 가이드가 수확 단계에 들어설 때 넣어 주는 첫 선물 — 기다리지 않고 수확을 해 보게 한다. + internal const float GuideGiftCandy = 10f; + internal const float GuideGiftCoin = 30f; + + private IslandSave save = new IslandSave(); + private IslandEffects effects; + private bool effectsDirty = true; + private readonly List releasedCache = new List(); + private bool releasedDirty = true; + // 시간당 생산량 — HUD가 OnGUI 패스마다(프레임당 2회 이상) 읽는다. 매번 다시 세면 곤충마다 종 DB를 + // 선형으로 훑게 되므로(GetById), 구성·배치·친밀도가 바뀔 때만 다시 계산한다. + private float cachedCandyRate; + private float cachedCoinRate; + private bool ratesDirty = true; + + // 놓인 물건이 바뀌었다 — 효과(쾌적도·설비)와 그걸 곱하는 생산량이 낡았다. + private void MarkLayoutDirty() + { + effectsDirty = true; + ratesDirty = true; + } + + // 방목 구성이나 곤충 목록이 바뀌었다 — 방목 캐시와 생산량이 낡았다. + private void MarkInsectsDirty() + { + releasedDirty = true; + ratesDirty = true; + } + + // 가이드의 일회성 신호 — 저장하지 않는다(단계 번호만 저장한다). + private bool guideEditOpened; + private bool guideShopOpened; + private bool guideFinishAcknowledged; + + /// 시계. 테스트가 바꿔 끼운다. + internal Func Clock = () => DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + /// + /// false면 디스크에 쓰지 않는다 — 검수 캡처·테스트 전용. 검수 빌드는 실제 게임과 같은 저장 폴더를 써서, + /// 끄지 않으면 메모리 fixture의 섬이 이 PC의 진짜 island.json을 덮는다. + /// + internal bool PersistenceEnabled = true; + + /// 검수 캡처·테스트 전용 — 디스크를 거치지 않고 세이브를 통째로 넣는다. + internal void LoadForCapture(IslandSave fixture) + { + save = fixture ?? new IslandSave(); + IslandSaveRules.Sanitize(save); + MarkLayoutDirty(); + MarkInsectsDirty(); + } + + /// 무엇이든 바뀌었다 — 화면이 다시 읽는다. + public event Action Changed; + /// 놓인 물건이나 섬 크기가 바뀌었다 — 월드를 다시 짓는다. + public event Action LayoutChanged; + /// 방목 곤충 구성이 바뀌었다 — 돌아다니는 곤충을 다시 세운다. + public event Action InsectsChanged; + /// 수확했다(캔디, 코인). + public event Action Harvested; + + public void AutoWire(PlayerInsectCollection collection, PlayerCurrencyWallet currencyWallet, + PlayerCandyInventory candyInventory, InsectDatabase insectDatabase) + { + if (insects != null) insects.InsectUpdated -= OnInsectUpdated; + if (insects == null) insects = collection; + if (wallet == null) wallet = currencyWallet; + if (candy == null) candy = candyInventory; + if (database == null) database = insectDatabase; + if (insects != null) insects.InsectUpdated += OnInsectUpdated; + MarkInsectsDirty(); + } + + private void Awake() + { + // 읽기만 한다. 로그인 전이라 전역 경로를 읽을 수 있는데, 여기서 저장까지 하면 그 전역 파일을 + // 만들어 버린다 — 진짜 값은 로그인 뒤 ReloadFromDisk가 계정 경로에서 다시 읽는다. + save = Load(); + IslandSaveRules.Sanitize(save); + } + + private void OnEnable() + { + if (insects == null) return; + insects.InsectUpdated -= OnInsectUpdated; + insects.InsectUpdated += OnInsectUpdated; + } + + private void OnDisable() + { + if (insects != null) insects.InsectUpdated -= OnInsectUpdated; + } + + private void OnApplicationPause(bool paused) + { + if (paused) SettleAndSave(); + } + + private void OnApplicationQuit() + { + SettleAndSave(); + } + + // 곤충 목록이 통째로 바뀌면(클라우드 리로드) 방목 캐시가 낡는다. 개별 갱신(레벨업 등)은 등급이 안 바뀌어 무시. + private void OnInsectUpdated(PlayerInsectData data) + { + if (data != null) return; + MarkInsectsDirty(); + InsectsChanged?.Invoke(); + Changed?.Invoke(); + } + + public void ReloadFromDisk() + { + save = Load(); + IslandSaveRules.Sanitize(save); + MarkLayoutDirty(); + MarkInsectsDirty(); + LayoutChanged?.Invoke(); + InsectsChanged?.Invoke(); + Changed?.Invoke(); + } + + // ── 조회 ── + + /// 섬이 열렸는가 — 「곤충 수집가」 완료 뒤. 마스터 계정은 바로 연다. + public bool IsUnlocked + { + get + { + if (IsMaster) return true; + TutorialQuestManager quests = TutorialQuestManager.Instance; + return quests != null && quests.IsQuestCompleted(GameConstants.Island.UnlockQuestId); + } + } + + private static bool IsMaster => + AuthManager.Instance != null && AuthManager.Instance.MasterPrivilegesActive; + + public int SizeLevel => save.sizeLevel; + public int GridSize => IslandGrid.GridSize(save.sizeLevel); + public int InsectSlots => GameConstants.Island.BaseInsectSlots + save.extraSlots; + public int ExtraSlots => save.extraSlots; + public IReadOnlyList Placed => save.placed; + public int HarvestCount => save.harvestCount; + public bool IsPublic => save.isPublic; + + public IslandEffects Effects + { + get + { + if (effectsDirty) + { + effects = IslandYield.CollectEffects(save.placed); + effectsDirty = false; + } + return effects; + } + } + + public float CapHours => IslandYield.CapHours(Effects); + + /// 섬에 한 번이라도 들어가 봤는가(스타터 키트를 받은 시점). + public bool HasEnteredOnce => save.starterGranted; + + /// 수확물이 상한까지 찼는가 — 곤충이 한 마리도 없으면 찰 것이 없으니 false. + public bool IsHarvestFull + { + get + { + if (ReleasedInsects.Count == 0) return false; + float cap = CapHours; + float dt = IslandYield.SettleHours(save.lastSettleUnix, Clock(), save.accruedHours, cap, out _); + return save.accruedHours + dt >= cap - 0.01f; + } + } + + public int GetOwnedCount(string id) + { + IslandOwnedRecord r = IslandSaveRules.FindOwned(save, id); + return r != null ? r.count : 0; + } + + public int GetPlacedCount(string id) => IslandSaveRules.CountPlaced(save, id); + + /// 보관함에 남은 수 — 보유 총수에서 놓인 수를 뺀 값. + public int GetStorageCount(string id) => Mathf.Max(0, GetOwnedCount(id) - GetPlacedCount(id)); + + /// 방목 중이면서 지금도 보유한 곤충. 보유 목록에서 사라진 id는 여기서 걸러진다(세이브에서 지우지는 않는다). + public IReadOnlyList ReleasedInsects + { + get + { + if (releasedDirty) + { + releasedCache.Clear(); + if (insects != null) + { + for (int i = 0; i < save.released.Count; i++) + { + PlayerInsectData data = insects.GetByInstanceId(save.released[i]); + if (data != null) releasedCache.Add(data); + } + } + releasedDirty = false; + } + return releasedCache; + } + } + + public bool IsReleased(string instanceId) + => !string.IsNullOrEmpty(instanceId) && save.released.Contains(instanceId); + + public float GetBondHours(string instanceId) + { + IslandBondRecord b = FindBond(instanceId); + return b != null ? b.hours : 0f; + } + + public int GetBondLevel(string instanceId) => IslandYield.BondLevel(GetBondHours(instanceId)); + + /// 지금 구성의 시간당 생산량. + public void GetRates(out float candyPerHour, out float coinPerHour) + { + IReadOnlyList list = ReleasedInsects; + if (ratesDirty) + { + cachedCandyRate = 0f; + cachedCoinRate = 0f; + IslandEffects e = Effects; + for (int i = 0; i < list.Count; i++) + { + int bond = GetBondLevel(list[i].instanceId); + cachedCandyRate += IslandYield.CandyPerHour(RarityOf(list[i]), bond, e); + cachedCoinRate += IslandYield.CoinPerHour(bond, e); + } + ratesDirty = false; + } + candyPerHour = cachedCandyRate; + coinPerHour = cachedCoinRate; + } + + /// + /// 지금 수확하면 받을 양을 저장을 건드리지 않고 계산한다 — HUD가 매 프레임 읽는다. + /// + public void Peek(out float pendingCandy, out float pendingCoin, out float accruedHours, out float capHours) + { + capHours = CapHours; + float dt = IslandYield.SettleHours(save.lastSettleUnix, Clock(), save.accruedHours, capHours, out _); + GetRates(out float candyRate, out float coinRate); + pendingCandy = save.pendingCandy + candyRate * dt; + pendingCoin = save.pendingCoin + coinRate * dt; + accruedHours = save.accruedHours + dt; + } + + private InsectRarity RarityOf(PlayerInsectData data) + { + InsectData def = database != null && data != null ? database.GetById(data.insectId) : null; + return def != null ? def.rarity : InsectRarity.Common; + } + + // ── 정산 ── + + /// + /// 흐른 시간만큼 수확물과 친밀도를 쌓는다. 생산에 영향을 주는 변경 전에 반드시 먼저 부른다 — + /// 안 그러면 방금 놓은 온실이 지난 여덟 시간에도 있었던 것처럼 계산된다. + /// + public void Settle() + { + float cap = CapHours; + float dt = IslandYield.SettleHours(save.lastSettleUnix, Clock(), save.accruedHours, cap, out long next); + save.lastSettleUnix = next; + if (dt <= 0f) return; + + GetRates(out float candyRate, out float coinRate); + save.pendingCandy += candyRate * dt; + save.pendingCoin += coinRate * dt; + save.accruedHours += dt; + + float bondGain = dt * (1f + Mathf.Max(0f, Effects.bondSpeed)); + IReadOnlyList list = ReleasedInsects; + for (int i = 0; i < list.Count; i++) + GetOrCreateBond(list[i].instanceId).hours += bondGain; + // 친밀도가 올랐으면 하트 단계가 바뀌었을 수 있다 — 생산량을 다시 센다. + ratesDirty = true; + } + + private void SettleAndSave() + { + if (!HasAnythingToPersist()) return; + Settle(); + Save(); + } + + // 한 번도 섬에 들어간 적 없는 세이브에 빈 island.json을 만들지 않는다. + private bool HasAnythingToPersist() + { + return save.starterGranted || save.owned.Count > 0 || save.released.Count > 0 || save.lastSettleUnix > 0; + } + + /// 쌓인 수확물을 받는다. 캔디·코인 어느 쪽도 1에 못 미치면 false. + public bool Harvest(out int candyGain, out int coinGain) + { + Settle(); + candyGain = Mathf.FloorToInt(save.pendingCandy); + coinGain = Mathf.FloorToInt(save.pendingCoin); + if (candyGain <= 0 && coinGain <= 0) return false; + + // 소수점 아래는 남긴다 — 버리면 자주 수확할수록 손해를 본다. + save.pendingCandy -= candyGain; + save.pendingCoin -= coinGain; + save.accruedHours = 0f; + save.harvestCount++; + // 지급보다 먼저 저장한다 — 지급 뒤 저장 전에 죽으면 같은 수확물을 다시 받는다. + Save(); + + // 지급처가 없으면 수확물이 사라진다 — 배선 누락을 조용히 넘기지 않는다(퀘스트 보상 지급과 같은 방식). + if (candyGain > 0) + { + if (candy != null) candy.AddCandy(candyGain); + else Debug.LogWarning($"[Island] candy null — 수확 캔디 손실 (+{candyGain})"); + } + if (coinGain > 0) + { + if (wallet != null) wallet.AddCoins(coinGain); + else Debug.LogWarning($"[Island] wallet null — 수확 코인 손실 (+{coinGain})"); + } + + TutorialQuestManager quests = TutorialQuestManager.Instance; + if (quests != null) quests.NotifyIslandHarvested(); + + Harvested?.Invoke(candyGain, coinGain); + AdvanceGuide(); + Changed?.Invoke(); + return true; + } + + // ── 구매 ── + + /// 섬 크기 단계가 모자라 아직 상점에 안 뜨는 물건인가. + public bool IsLockedBySize(IslandObjectDef def) => def != null && def.requiredSizeLevel > save.sizeLevel; + + public IslandBuyResult TryBuy(string id) + { + IslandObjectDef def = IslandCatalog.Get(id); + if (def == null) return IslandBuyResult.Unknown; + if (IsLockedBySize(def)) return IslandBuyResult.Locked; + + IslandBuyResult paid = Pay(def.coinPrice, def.gemPrice, def.IsPremium); + if (paid != IslandBuyResult.Ok) return paid; + + AddOwned(id, 1); + Save(); + ConfirmGemSpend(def.IsPremium); + + TutorialQuestManager quests = TutorialQuestManager.Instance; + if (quests != null) quests.NotifyIslandPurchase(); + + Changed?.Invoke(); + return IslandBuyResult.Ok; + } + + public IslandBuyResult TryExpandSize(bool useGems) + { + if (!IslandCatalog.SizePrice(save.sizeLevel, out int coins, out int gems)) return IslandBuyResult.Maxed; + IslandBuyResult paid = Pay(coins, gems, useGems); + if (paid != IslandBuyResult.Ok) return paid; + + save.sizeLevel++; + Save(); + ConfirmGemSpend(useGems); + LayoutChanged?.Invoke(); + Changed?.Invoke(); + return IslandBuyResult.Ok; + } + + public IslandBuyResult TryBuySlot(bool useGems) + { + if (!IslandCatalog.SlotPrice(save.extraSlots, out int coins, out int gems)) return IslandBuyResult.Maxed; + IslandBuyResult paid = Pay(coins, gems, useGems); + if (paid != IslandBuyResult.Ok) return paid; + + save.extraSlots++; + Save(); + ConfirmGemSpend(useGems); + Changed?.Invoke(); + return IslandBuyResult.Ok; + } + + private IslandBuyResult Pay(int coins, int gems, bool useGems) + { + if (IsMaster) return IslandBuyResult.Ok; + if (wallet == null) return useGems ? IslandBuyResult.NotEnoughGems : IslandBuyResult.NotEnoughCoins; + if (useGems) + return gems > 0 && wallet.SpendGems(gems) ? IslandBuyResult.Ok : IslandBuyResult.NotEnoughGems; + return coins > 0 && wallet.SpendCoins(coins) ? IslandBuyResult.Ok : IslandBuyResult.NotEnoughCoins; + } + + // SpendGems가 부른 클라우드 저장은 소유권을 적기 **전**의 스냅샷이다. 적은 뒤 한 번 더 올려야 + // "다이아는 빠졌는데 물건은 없는" 문서가 클라우드에 남지 않는다(연속 호출은 pendingSave가 합친다). + private static void ConfirmGemSpend(bool usedGems) + { + if (!usedGems || IsMaster) return; + CloudSaveManager cloud = CloudSaveManager.Instance; + if (cloud != null) cloud.SaveToCloud(); + } + + /// 물건을 그냥 준다 — 퀘스트 보상·스타터 키트. + public void GrantObject(string id, int count) + { + if (count <= 0 || IslandCatalog.Get(id) == null) return; + AddOwned(id, count); + Save(); + Changed?.Invoke(); + } + + private void AddOwned(string id, int count) + { + IslandOwnedRecord r = IslandSaveRules.FindOwned(save, id); + if (r == null) + { + r = new IslandOwnedRecord { id = id, count = 0 }; + save.owned.Add(r); + } + r.count += count; + } + + // ── 배치 ── + + public IslandPlaceResult CanPlace(string id, int x, int z, int rot, int ignoreIndex = -1) + { + return IslandGrid.CanPlace(save.placed, ignoreIndex, IslandCatalog.Get(id), x, z, rot, save.sizeLevel); + } + + /// 보관함의 물건을 놓는다. + public IslandPlaceResult TryPlace(string id, int x, int z, int rot) + { + if (GetStorageCount(id) <= 0) return IslandPlaceResult.NotOwned; + IslandPlaceResult result = CanPlace(id, x, z, rot); + if (result != IslandPlaceResult.Ok) return result; + + Settle(); + save.placed.Add(new IslandPlacedRecord { id = id, x = x, z = z, rot = ((rot % 4) + 4) % 4 }); + MarkLayoutDirty(); + Save(); + + TutorialQuestManager quests = TutorialQuestManager.Instance; + if (quests != null) quests.NotifyIslandObjectPlaced(); + + LayoutChanged?.Invoke(); + AdvanceGuide(); + Changed?.Invoke(); + return IslandPlaceResult.Ok; + } + + /// 놓인 물건을 옮기거나 돌린다. 퀘스트의 "놓기"로 세지 않는다(옮기기를 반복해 채우지 못하게). + public IslandPlaceResult TryMove(int index, int x, int z, int rot) + { + if (index < 0 || index >= save.placed.Count) return IslandPlaceResult.UnknownObject; + IslandPlacedRecord p = save.placed[index]; + IslandPlaceResult result = CanPlace(p.id, x, z, rot, index); + if (result != IslandPlaceResult.Ok) return result; + + p.x = x; + p.z = z; + p.rot = ((rot % 4) + 4) % 4; + Save(); + LayoutChanged?.Invoke(); + Changed?.Invoke(); + return IslandPlaceResult.Ok; + } + + /// 놓인 물건을 보관함에 넣는다. 되팔기는 없다 — 다이아 환급은 서버 규칙이 막는다. + public bool Store(int index) + { + if (index < 0 || index >= save.placed.Count) return false; + Settle(); + save.placed.RemoveAt(index); + MarkLayoutDirty(); + Save(); + LayoutChanged?.Invoke(); + Changed?.Invoke(); + return true; + } + + // ── 방목 ── + + public IslandReleaseResult TryRelease(string instanceId) + { + if (insects == null || insects.GetByInstanceId(instanceId) == null) return IslandReleaseResult.NoSuchInsect; + if (save.released.Contains(instanceId)) return IslandReleaseResult.AlreadyReleased; + + // 보유 목록에서 사라진 id가 슬롯을 차지하고 있으면 지금 치운다 — 사용자가 창을 열어 조작하는 + // 시점이라 곤충 목록이 확실히 로드돼 있다(부트 중에 치우면 로그인 전 빈 목록을 보고 전부 지운다). + for (int i = save.released.Count - 1; i >= 0; i--) + if (insects.GetByInstanceId(save.released[i]) == null) save.released.RemoveAt(i); + + if (save.released.Count >= InsectSlots) return IslandReleaseResult.NoFreeSlot; + + Settle(); + save.released.Add(instanceId); + MarkInsectsDirty(); + Save(); + + TutorialQuestManager quests = TutorialQuestManager.Instance; + if (quests != null) quests.NotifyIslandInsectReleased(); + + InsectsChanged?.Invoke(); + AdvanceGuide(); + Changed?.Invoke(); + return IslandReleaseResult.Ok; + } + + /// 곤충을 섬에서 거둔다. 친밀도는 남는다 — 다시 풀면 이어서 쌓인다. + public bool Unrelease(string instanceId) + { + if (!save.released.Contains(instanceId)) return false; + Settle(); + save.released.Remove(instanceId); + MarkInsectsDirty(); + Save(); + InsectsChanged?.Invoke(); + Changed?.Invoke(); + return true; + } + + private IslandBondRecord FindBond(string instanceId) + { + if (string.IsNullOrEmpty(instanceId)) return null; + for (int i = 0; i < save.bonds.Count; i++) + if (save.bonds[i].instanceId == instanceId) return save.bonds[i]; + return null; + } + + private IslandBondRecord GetOrCreateBond(string instanceId) + { + IslandBondRecord b = FindBond(instanceId); + if (b != null) return b; + b = new IslandBondRecord { instanceId = instanceId, hours = 0f }; + save.bonds.Add(b); + return b; + } + + // ── 드나들기 ── + + /// 내 섬에 들어왔다 — 스타터 키트 지급(1회), 밀린 시간 정산, 퀘스트 통지. + public void NotifyEnteredOwnIsland() + { + if (!save.starterGranted) + { + save.starterGranted = true; + for (int i = 0; i < IslandCatalog.StarterKit.Length; i++) + AddOwned(IslandCatalog.StarterKit[i].id, IslandCatalog.StarterKit[i].count); + } + Settle(); + Save(); + + TutorialQuestManager quests = TutorialQuestManager.Instance; + if (quests != null) quests.NotifyIslandVisited(); + + AdvanceGuide(); + Changed?.Invoke(); + } + + /// 다른 사람의 섬을 구경했다. + public void NotifyVisitedFriendIsland() + { + TutorialQuestManager quests = TutorialQuestManager.Instance; + if (quests != null) quests.NotifyFriendIslandVisited(); + } + + public void SetPublic(bool isPublic) + { + if (save.isPublic == isPublic) return; + save.isPublic = isPublic; + Save(); + Changed?.Invoke(); + } + + // ── 가이드 ── + + public bool GuideActive => !save.guideDone; + public IslandGuideStep GuideStep => save.guideDone ? IslandGuideStep.Done : (IslandGuideStep)save.guideStep; + + public void ReportEditOpened() { guideEditOpened = true; AdvanceGuide(); } + public void ReportShopOpened() { guideShopOpened = true; AdvanceGuide(); } + public void AcknowledgeGuideFinish() { guideFinishAcknowledged = true; AdvanceGuide(); } + + // 다시 보기 중인가(저장하지 않는다). 다시 보기에서는 이미 한 행동으로 단계를 건너뛰지 않고 + // [다음]으로만 넘긴다 — 건너뛰면 다시 보기를 눌러도 끝 단계만 뜬다. + private bool guideReplay; + + /// 안내를 처음부터 다시 본다(도움말의 "안내 다시 보기"). 선물은 다시 주지 않는다. + public void RestartGuide() + { + guideEditOpened = guideShopOpened = guideFinishAcknowledged = false; + save.guideDone = false; + save.guideStep = (int)IslandGuideStep.OpenEdit; + guideReplay = true; + Save(); + Changed?.Invoke(); + } + + private void AdvanceGuide() + { + if (save.guideDone) return; + var facts = new IslandGuideFacts + { + editOpened = guideEditOpened, + shopOpened = guideShopOpened, + finishAcknowledged = guideFinishAcknowledged, + placedCount = guideReplay ? 0 : save.placed.Count, + releasedCount = guideReplay ? 0 : ReleasedInsects.Count, + harvestCount = guideReplay ? 0 : save.harvestCount, + ownedInsectCount = insects != null ? insects.OwnedView.Count : 0, + }; + // 다시 보기에서는 [다음]으로만 넘긴다(AdvanceGuideReplay). + IslandGuideStep before = (IslandGuideStep)save.guideStep; + IslandGuideStep after = guideReplay ? before : IslandGuideSteps.Advance(before, facts); + ApplyGuideStep(before, after); + } + + /// 다시 보기에서 [다음]을 눌렀다 — 한 단계 넘긴다. + public void AdvanceGuideReplay() + { + if (save.guideDone || !guideReplay) return; + IslandGuideStep before = (IslandGuideStep)save.guideStep; + ApplyGuideStep(before, (IslandGuideStep)Mathf.Min((int)IslandGuideStep.Done, (int)before + 1)); + } + + /// + /// 다시 보기를 중간에 그만둔다(안내 배너의 ✕). 첫 안내에서는 아무것도 하지 않는다 — 그쪽 단계는 실제 행동으로만 + /// 넘어가고, 배너를 닫는 건 화면에서 치우는 것뿐이다. + /// + public void EndGuideReplay() + { + if (save.guideDone || !guideReplay) return; + ApplyGuideStep((IslandGuideStep)save.guideStep, IslandGuideStep.Done); + } + + public bool GuideIsReplay => guideReplay; + + private void ApplyGuideStep(IslandGuideStep before, IslandGuideStep after) + { + if (after == before) return; + + // 수확 단계에 처음 들어설 때 선물을 넣어 둔다 — 곤충 한 마리로는 캔디 1개에 네 시간이 걸려 + // "수확을 눌러 보세요"가 성립하지 않는다. 다시 보기에서는 주지 않는다. + if (!guideReplay && before < IslandGuideStep.Harvest && after >= IslandGuideStep.Harvest + && save.harvestCount == 0) + { + save.pendingCandy += GuideGiftCandy; + save.pendingCoin += GuideGiftCoin; + } + + save.guideStep = (int)after; + if (after == IslandGuideStep.Done) + { + save.guideDone = true; + guideReplay = false; + } + Save(); + Changed?.Invoke(); + } + + // ── 공유 ── + + /// 공개용 축약본. instanceId·재화는 싣지 않는다. + public IslandSnapshot BuildSnapshot(string ownerName) + { + var snapshot = new IslandSnapshot + { + ownerName = ownerName ?? "", + sizeLevel = save.sizeLevel, + comfort = Effects.comfort, + }; + for (int i = 0; i < save.placed.Count; i++) + { + IslandPlacedRecord p = save.placed[i]; + if (IslandCatalog.Get(p.id) == null) continue; + snapshot.placed.Add(new IslandPlacedRecord { id = p.id, x = p.x, z = p.z, rot = p.rot }); + } + IReadOnlyList list = ReleasedInsects; + for (int i = 0; i < list.Count; i++) + { + snapshot.insects.Add(new IslandSnapshotInsect + { + insectId = list[i].insectId, + level = list[i].level, + shiny = list[i].isShiny, + }); + } + return snapshot; + } + + // ── 저장 ── + + private IslandSave Load() + { + string path = GetPath(); + if (!File.Exists(path)) return new IslandSave(); + try + { + return JsonUtility.FromJson(File.ReadAllText(path)) ?? new IslandSave(); + } + catch (Exception e) + { + Debug.LogWarning($"[Island] 손상된 세이브 — 기본 섬으로 시작: {e.Message}"); + return new IslandSave(); + } + } + + private void Save() + { + if (save == null || !PersistenceEnabled) return; + // 컴팩트 JSON — 이 파일이 통째로 Firestore 문서의 문자열 필드 하나가 된다(문서 한도 1MiB를 곤충 블롭과 나눠 쓴다). + AtomicFileWriter.WriteAllText(GetPath(), JsonUtility.ToJson(save, false)); + } + + private static string GetPath() => SaveScope.FilePath(GameConstants.SaveFiles.Island); + } +} diff --git a/Assets/Scripts/Core/IslandManager.cs.meta b/Assets/Scripts/Core/IslandManager.cs.meta new file mode 100644 index 0000000..b7bf405 --- /dev/null +++ b/Assets/Scripts/Core/IslandManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b6bb606c43f812c42897d03682d69a95 \ No newline at end of file diff --git a/Assets/Scripts/Core/IslandMaterialCache.cs b/Assets/Scripts/Core/IslandMaterialCache.cs new file mode 100644 index 0000000..aca725d --- /dev/null +++ b/Assets/Scripts/Core/IslandMaterialCache.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 섬 한 채가 쓰는 머티리얼 모음 — 같은 색이면 같은 머티리얼을 돌려준다. + /// + /// 섬에는 물건이 수백 개까지 놓이고 물건 하나가 프리미티브 여러 개다. 조각마다 new Material을 하면 + /// 머티리얼이 천 개 단위가 되어 배칭이 전혀 안 걸린다. 색으로 묶으면 섬 전체가 수십 개로 끝난다. + /// + /// 머티리얼은 GameObject를 파괴해도 남는다 — 섬을 떠날 때 을 부를 것 + /// (SubAreaWorldBuilder.runtimeMaterials와 같은 이유). + /// 만드는 길은 하나다: 그쪽 키워드 조합만 플레이어 빌드에 변형이 남는다. + /// + public sealed class IslandMaterialCache + { + // 키는 정수로 접는다 — Color32를 그대로 키로 쓰면 조회마다 박싱이 생긴다. + private readonly Dictionary opaque = new Dictionary(); + private readonly Dictionary fade = new Dictionary(); + private readonly Dictionary glow = new Dictionary(); + + /// 불투명 무광. + public Material Get(Color color) + { + uint key = Pack(color); + if (opaque.TryGetValue(key, out Material mat) && mat != null) return mat; + mat = SceneryMaterials.Create(new Color(color.r, color.g, color.b, 1f)); + opaque[key] = mat; + return mat; + } + + /// 반투명(물·유리). 알파는 색의 a를 쓴다. + public Material GetFade(Color color) + { + Color32 c32 = color; + uint key = ((uint)c32.a << 24) | Pack(color); + if (fade.TryGetValue(key, out Material mat) && mat != null) return mat; + mat = SceneryMaterials.Create(color); + SceneryMaterials.MakeFade(mat); + fade[key] = mat; + return mat; + } + + /// 스스로 빛나는 것(등불·모닥불). + public Material GetGlow(Color color, Color emission) + { + long key = ((long)Pack(color) << 32) | Pack(emission); + if (glow.TryGetValue(key, out Material mat) && mat != null) return mat; + mat = SceneryMaterials.Create(new Color(color.r, color.g, color.b, 1f)); + SceneryMaterials.SetEmission(mat, emission); + glow[key] = mat; + return mat; + } + + public void DestroyAll() + { + DestroyIn(opaque.Values); + DestroyIn(fade.Values); + DestroyIn(glow.Values); + opaque.Clear(); + fade.Clear(); + glow.Clear(); + } + + private static void DestroyIn(IEnumerable materials) + { + foreach (Material mat in materials) + if (mat != null) Object.Destroy(mat); + } + + private static uint Pack(Color color) + { + Color32 c = color; + return ((uint)c.r << 16) | ((uint)c.g << 8) | c.b; + } + } +} diff --git a/Assets/Scripts/Core/IslandMaterialCache.cs.meta b/Assets/Scripts/Core/IslandMaterialCache.cs.meta new file mode 100644 index 0000000..0ec98b7 --- /dev/null +++ b/Assets/Scripts/Core/IslandMaterialCache.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3a4358bf1fc13d847968e8b9efa66265 \ No newline at end of file diff --git a/Assets/Scripts/Core/IslandObjectBuilder.cs b/Assets/Scripts/Core/IslandObjectBuilder.cs new file mode 100644 index 0000000..a6e63fa --- /dev/null +++ b/Assets/Scripts/Core/IslandObjectBuilder.cs @@ -0,0 +1,817 @@ +using System; +using UnityEngine; +using UnityEngine.Rendering; + +namespace InsectGame.Core +{ + /// + /// 섬 물건의 3D 모양 — 의 id마다 모델 하나. + /// + /// 좌표 규약: root 원점 = 차지 영역의 중심·지면(y = 0), +Z가 정면(회전 0). + /// 모델은 차지 칸(width × depth, 한 칸 ) 안쪽 0.1m 여유 안에 든다 — + /// 붙여 놓은 두 물건이 서로 파고들지 않게. 나무 수관만 위에서 0.3m까지 넘친다. + /// + /// 카메라는 (0, 9, −6) 고각이다. 세로로 선 디테일은 거의 안 보이므로 지붕 모양·윗면 색 대비·실루엣으로 읽히게 짓는다 + /// (먹이통과 급수대는 옆모습이 아니라 윗면이 과일 접시냐 물이냐로 갈린다). + /// + /// 콜라이더가 없다. 조각을 CreatePrimitive로 만들지 않고 내장 메시만 빌려 쓴다 — 콜라이더가 생겼다 지워지는 + /// 왕복이 없고(Destroy는 프레임 끝까지 미뤄져 그 사이 레이캐스트에 걸린다), 섬 하나에 조각이 수천 개까지 간다. + /// 통행 차단은 호출부(IslandWorldBuilder.BuildPlaced)가 BoxCollider 하나로 따로 단다. + /// + /// 머티리얼은 로만 받는다(같은 색 = 같은 머티리얼). 그래서 색을 여기 상수로 모아 + /// 물건끼리 나눠 쓴다 — 물건마다 살짝 다른 갈색을 쓰면 섬 전체 머티리얼 수가 곧 물건 수가 된다. + /// + public static class IslandObjectBuilder + { + // ── 섬 공용 색 ── + // 값은 감마 공간 반사율이다. 섬 조명(햇빛 1.2 × NdotL 0.79 + 평면 환경광 0.68)에 Standard의 비금속 확산 계수(0.78)를 + // 곱하면 윗면은 여기 적은 값의 약 1.25배로 나온다(RegionPalette의 필드 1.3배와 같은 계산). + // 0.8을 넘기면 윗면이 하얗게 날아가 형태 음영이 사라진다 — 흰색도 0.79에서 멈춘다. + + internal static readonly Color WoodDark = new Color(0.30f, 0.21f, 0.13f); + internal static readonly Color Wood = new Color(0.46f, 0.33f, 0.19f); + internal static readonly Color WoodLight = new Color(0.64f, 0.51f, 0.34f); + internal static readonly Color Stone = new Color(0.56f, 0.56f, 0.545f); + internal static readonly Color StoneDark = new Color(0.385f, 0.40f, 0.415f); + // 잎은 잔디(IslandTerrainBuilder)보다 어두워야 한다 — 같은 밝기면 위에서 본 수관이 잔디에 묻힌다. + internal static readonly Color Leaf = new Color(0.24f, 0.465f, 0.21f); + internal static readonly Color LeafDark = new Color(0.16f, 0.35f, 0.175f); + internal static readonly Color LeafLight = new Color(0.465f, 0.64f, 0.255f); + internal static readonly Color Red = new Color(0.70f, 0.24f, 0.19f); + internal static readonly Color Cream = new Color(0.77f, 0.72f, 0.61f); + internal static readonly Color White = new Color(0.79f, 0.79f, 0.77f); + internal static readonly Color Yellow = new Color(0.80f, 0.64f, 0.20f); + internal static readonly Color Pink = new Color(0.785f, 0.53f, 0.61f); + internal static readonly Color Clay = new Color(0.655f, 0.40f, 0.255f); + internal static readonly Color Soil = new Color(0.34f, 0.24f, 0.16f); + internal static readonly Color Teal = new Color(0.225f, 0.48f, 0.53f); + internal static readonly Color Sand = new Color(0.76f, 0.70f, 0.53f); + + // 반투명 — 알파가 곧 비침 정도다. 불투명 바닥(연못 바닥·분수 수반) 위에 얹어 깊이감을 낸다. + private static readonly Color WaterTint = new Color(0.335f, 0.61f, 0.785f, 0.78f); + private static readonly Color GlassTint = new Color(0.68f, 0.78f, 0.80f, 0.32f); + + // 발광 — 한낮 조명에선 "더 밝은 노랑"일 뿐이지만 그늘 면에서도 꺼지지 않아 불빛으로 읽힌다. + private static readonly Color LampTint = new Color(0.80f, 0.68f, 0.36f); + private static readonly Color LampEmission = new Color(0.45f, 0.34f, 0.10f); + private static readonly Color FlameTint = new Color(0.80f, 0.40f, 0.12f); + private static readonly Color FlameEmission = new Color(0.55f, 0.20f, 0.03f); + + /// 물건 하나의 모델. 반환 root: 원점 = 차지 영역의 중심·지면(y=0), +Z가 정면(회전 0). + public static GameObject Build(IslandObjectDef def, Transform parent, IslandMaterialCache materials) + { + if (def == null || materials == null) return null; + + GameObject root = new GameObject("IslandObj_" + def.id); + root.transform.SetParent(parent, false); + + Kit kit = new Kit(root.transform, materials); + Action build = Resolve(def.id); + if (build != null) build(kit); + else Fallback(kit, def); + return root; + } + + /// 그 id의 전용 모델이 있는가. 폴백 상자는 false. + public static bool HasModel(string id) + { + return Resolve(id) != null; + } + + // id → 모델. Build와 HasModel이 이 switch 하나를 읽는다 — 목록을 둘로 두면 "있다고 답했는데 폴백 상자가 뜨는" + // 어긋남이 테스트(HasModel만 본다)를 통과한 채 생긴다. + private static Action Resolve(string id) + { + switch (id) + { + case "b_cabin": return Cabin; + case "b_storage": return Storage; + case "b_greenhouse": return Greenhouse; + case "b_windmill": return Windmill; + case "b_lighthouse": return Lighthouse; + + case "f_bench": return Bench; + case "f_table": return Table; + case "f_lantern": return Lantern; + case "f_fence": return Fence; + case "f_flowerpot": return Flowerpot; + case "f_mailbox": return Mailbox; + case "f_sign": return Sign; + case "f_hammock": return Hammock; + case "f_campfire": return Campfire; + case "f_fountain": return Fountain; + + case "t_sapling": return Sapling; + case "t_oak": return Oak; + case "t_blossom": return Blossom; + case "t_rock": return Rock; + case "t_boulder": return Boulder; + case "t_pond": return Pond; + case "t_flowerbed": return Flowerbed; + case "t_bush": return Bush; + + case "o_feeder": return Feeder; + case "o_water": return WaterStand; + case "o_basket": return Basket; + case "o_honeypot": return Honeypot; + case "o_sprinkler": return Sprinkler; + + default: return null; + } + } + + // 더 새 버전에서 산 물건이 구버전에 내려와도 섬은 떠야 한다 — 자리만 차지하는 상자로 둔다. + private static void Fallback(Kit k, IslandObjectDef def) + { + float cs = GameConstants.Island.CellSize; + float w = Mathf.Max(1, def.width) * cs - 0.2f; + float d = Mathf.Max(1, def.depth) * cs - 0.2f; + k.Box("Placeholder", V(0f, 0.5f, 0f), V(w, 1f, d), Stone); + } + + // ── 건물 ── + + // 3×3. 마루가 앞뒤로 뻗은 붉은 박공지붕 + 돌 굴뚝 — 위에서 "집"으로 읽히는 건 지붕 두 면의 명암과 굴뚝이다. + private static void Cabin(Kit k) + { + k.Box("Wall", V(0f, 1.0f, -0.1f), V(3.3f, 2.0f, 3.0f), Wood); + k.Roof("Roof", V(0f, 1.9f, -0.1f), 4.0f, 1.2f, 3.6f, Red); + k.Box("Ridge", V(0f, 3.1f, -0.1f), V(0.3f, 0.12f, 3.7f), WoodDark); + k.Box("Chimney", V(0.95f, 2.75f, -0.9f), V(0.5f, 1.3f, 0.5f), Stone); + k.Box("ChimneyCap", V(0.95f, 3.44f, -0.9f), V(0.64f, 0.12f, 0.64f), StoneDark); + k.Box("Door", V(0f, 0.75f, 1.42f), V(0.75f, 1.5f, 0.08f), WoodDark); + k.Box("WindowFront", V(1.0f, 1.25f, 1.42f), V(0.55f, 0.5f, 0.06f), Yellow); + // 기본 방향에선 카메라가 뒷벽을 본다 — 뒤에도 창을 내야 통나무 벽 한 장으로 보이지 않는다. + k.Box("WindowBack", V(-0.7f, 1.25f, -1.62f), V(0.55f, 0.5f, 0.06f), Yellow); + k.Box("Porch", V(0f, 0.06f, 1.75f), V(1.7f, 0.12f, 0.66f), WoodLight); + for (int i = 0; i < 4; i++) + { + float x = (i % 2 == 0) ? -1.65f : 1.65f; + float z = (i < 2) ? -1.6f : 1.4f; + k.Cyl("CornerLog", V(x, 1.0f, z), 0.3f, 2.0f, WoodDark); + } + } + + // 3×2. 마루가 좌우로 뻗은 청록 지붕 — 오두막과 색·마루 방향이 둘 다 달라 위에서 바로 갈린다. 문 앞 상자·통·자루가 창고임을 말한다. + private static void Storage(Kit k) + { + k.Box("Wall", V(0f, 0.9f, -0.15f), V(3.9f, 1.8f, 2.0f), WoodLight); + k.Roof("Roof", V(0f, 1.7f, -0.15f), 2.5f, 1.0f, 4.2f, Teal, 90f); + k.Box("Ridge", V(0f, 2.7f, -0.15f), V(4.3f, 0.12f, 0.28f), WoodDark); + k.Box("Door", V(0f, 0.7f, 0.87f), V(1.5f, 1.4f, 0.08f), WoodDark); + NoShadow(k.Box("BraceA", V(0f, 0.7f, 0.92f), V(1.7f, 0.08f, 0.03f), Cream, V(0f, 0f, 43f))); + NoShadow(k.Box("BraceB", V(0f, 0.7f, 0.92f), V(1.7f, 0.08f, 0.03f), Cream, V(0f, 0f, -43f))); + k.Box("Crate", V(1.35f, 0.3f, 1.08f), V(0.55f, 0.6f, 0.48f), Wood, V(0f, 12f, 0f)); + k.Box("CrateTop", V(1.3f, 0.76f, 1.05f), V(0.36f, 0.32f, 0.34f), WoodLight, V(0f, -10f, 0f)); + k.Cyl("Barrel", V(-1.45f, 0.35f, 1.08f), 0.56f, 0.7f, Wood); + NoShadow(k.Cyl("BarrelGrain", V(-1.45f, 0.706f, 1.08f), 0.44f, 0.012f, Yellow)); + k.Ball("Sack", V(-0.85f, 0.2f, 1.12f), V(0.5f, 0.4f, 0.42f), Cream); + } + + // 3×3. 유리 지붕 너머로 흙 두둑과 풀이 비친다 — 흰 뼈대(마루·처마·박공 보)가 위에서 "유리 집"의 윤곽선이 된다. + private static void Greenhouse(Kit k) + { + Material glass = k.GlassMat(); + k.Box("Base", V(0f, 0.2f, 0f), V(4.0f, 0.4f, 4.0f), White); + k.Box("BedL", V(-0.95f, 0.44f, 0f), V(1.3f, 0.1f, 3.2f), Soil); + k.Box("BedR", V(0.95f, 0.44f, 0f), V(1.3f, 0.1f, 3.2f), Soil); + k.Ball("PlantA", V(-0.95f, 0.75f, -0.9f), V(0.9f, 0.7f, 0.9f), Leaf); + k.Ball("PlantB", V(-0.95f, 0.7f, 0.6f), V(0.8f, 0.6f, 0.9f), LeafLight); + k.Ball("PlantC", V(0.95f, 0.75f, 0.9f), V(0.9f, 0.7f, 0.9f), Leaf); + k.Ball("PlantD", V(0.95f, 0.68f, -0.6f), V(0.7f, 0.55f, 0.8f), LeafLight); + // 유리는 그림자를 끈다 — Standard Fade의 그림자는 디더 점묘라 바닥에 모래알 같은 얼룩이 진다. + NoShadow(k.Box("GlassWall", V(0f, 1.1f, 0f), V(3.8f, 1.4f, 3.8f), glass)); + NoShadow(k.Roof("GlassRoof", V(0f, 1.8f, 0f), 4.0f, 1.0f, 4.0f, glass)); + k.Box("FrameRidge", V(0f, 2.8f, 0f), V(0.12f, 0.12f, 4.1f), White); + k.Box("FrameEaveL", V(-1.94f, 1.8f, 0f), V(0.12f, 0.12f, 4.0f), White); + k.Box("FrameEaveR", V(1.94f, 1.8f, 0f), V(0.12f, 0.12f, 4.0f), White); + k.Box("FrameFront", V(0f, 1.8f, 1.94f), V(4.0f, 0.12f, 0.12f), White); + k.Box("FrameBack", V(0f, 1.8f, -1.94f), V(4.0f, 0.12f, 0.12f), White); + } + + // 3×3. 둥근 탑 + 붉은 원뿔 지붕 + 정면의 X자 날개. 날개는 세로 면이지만 고각 카메라에서도 높이의 절반쯤은 보인다. + private static void Windmill(Kit k) + { + k.Cyl("Base", V(0f, 0.25f, -0.3f), 2.6f, 0.5f, Stone); + k.Cyl("Tower", V(0f, 1.9f, -0.3f), 2.0f, 2.8f, Cream); + k.Cyl("Gallery", V(0f, 1.7f, -0.3f), 2.5f, 0.12f, WoodLight); + k.Cone("Cap", V(0f, 3.3f, -0.3f), 2.4f, 1.2f, Red); + k.Cyl("Hub", V(0f, 2.75f, 0.9f), 0.3f, 0.6f, WoodDark, V(90f, 0f, 0f)); + k.Box("SailA", V(0f, 2.75f, 1.08f), V(3.8f, 0.5f, 0.05f), Cream, V(0f, 0f, 45f)); + k.Box("SailB", V(0f, 2.75f, 1.08f), V(3.8f, 0.5f, 0.05f), Cream, V(0f, 0f, -45f)); + NoShadow(k.Box("SparA", V(0f, 2.75f, 1.13f), V(3.9f, 0.1f, 0.06f), WoodDark, V(0f, 0f, 45f))); + NoShadow(k.Box("SparB", V(0f, 2.75f, 1.13f), V(3.9f, 0.1f, 0.06f), WoodDark, V(0f, 0f, -45f))); + k.Box("Door", V(0f, 0.95f, 0.69f), V(0.6f, 1.0f, 0.1f), WoodDark); + k.Box("Window", V(0f, 2.3f, -1.29f), V(0.4f, 0.45f, 0.08f), Yellow); + } + + // 2×2. 흰·빨강 띠를 두른 탑이 위로 갈수록 가늘어진다 — 위에서는 동심원(돌 기단 > 흰 탑 > 난간 > 붉은 지붕)으로 읽힌다. + private static void Lighthouse(Kit k) + { + k.Cyl("Base", V(0f, 0.18f, 0f), 2.5f, 0.36f, Stone); + k.Cyl("TowerLow", V(0f, 1.0f, 0f), 1.9f, 1.3f, White); + k.Cyl("TowerMid", V(0f, 2.25f, 0f), 1.7f, 1.2f, Red); + k.Cyl("TowerTop", V(0f, 3.45f, 0f), 1.5f, 1.2f, White); + k.Cyl("Gallery", V(0f, 4.12f, 0f), 2.0f, 0.14f, StoneDark); + k.Cyl("Lamp", V(0f, 4.54f, 0f), 1.0f, 0.7f, k.LampMat()); + k.Cone("Roof", V(0f, 4.89f, 0f), 1.5f, 0.75f, Red); + k.Ball("Finial", V(0f, 5.68f, 0f), V(0.18f, 0.18f, 0.18f), Yellow); + k.Box("Door", V(0f, 0.85f, 0.93f), V(0.5f, 1.0f, 0.1f), WoodDark); + } + + // ── 가구 ── + + // 2×1. 밝은 좌판 두 장 + 뒤쪽의 짙은 등받이 — 위에서 긴 직사각형에 한쪽 띠가 진 모양. + private static void Bench(Kit k) + { + k.Box("SeatFront", V(0f, 0.45f, 0.2f), V(2.3f, 0.08f, 0.26f), WoodLight); + k.Box("SeatBack", V(0f, 0.45f, -0.08f), V(2.3f, 0.08f, 0.26f), WoodLight); + k.Box("Backrest", V(0f, 0.85f, -0.3f), V(2.3f, 0.4f, 0.1f), Wood); + k.Box("LegL", V(-1.0f, 0.22f, 0.02f), V(0.12f, 0.44f, 0.62f), WoodDark); + k.Box("LegR", V(1.0f, 0.22f, 0.02f), V(0.12f, 0.44f, 0.62f), WoodDark); + k.Box("ArmL", V(-1.0f, 0.66f, 0.02f), V(0.14f, 0.08f, 0.7f), WoodDark); + k.Box("ArmR", V(1.0f, 0.66f, 0.02f), V(0.14f, 0.08f, 0.7f), WoodDark); + } + + // 2×2. 나이테가 보이는 둥근 상판 + 대각선 네 귀의 그루터기 의자. + private static void Table(Kit k) + { + k.Cyl("Trunk", V(0f, 0.33f, 0f), 0.6f, 0.66f, Wood); + k.Cyl("Top", V(0f, 0.73f, 0f), 1.5f, 0.14f, WoodLight); + NoShadow(k.Cyl("RingOuter", V(0f, 0.805f, 0f), 1.05f, 0.012f, Wood)); + NoShadow(k.Cyl("RingInner", V(0f, 0.812f, 0f), 0.5f, 0.012f, WoodLight)); + for (int i = 0; i < 4; i++) + { + float x = (i % 2 == 0) ? -0.82f : 0.82f; + float z = (i < 2) ? -0.82f : 0.82f; + k.Cyl("Stool", V(x, 0.2f, z), 0.5f, 0.4f, Wood); + NoShadow(k.Cyl("StoolTop", V(x, 0.405f, z), 0.38f, 0.012f, WoodLight)); + } + } + + // 1×1. 갓이 등보다 작다 — 위에서 봐도 갓 둘레로 불빛이 고리처럼 남는다. + private static void Lantern(Kit k) + { + k.Cyl("Foot", V(0f, 0.06f, 0f), 0.4f, 0.12f, Stone); + k.Cyl("Post", V(0f, 0.75f, 0f), 0.1f, 1.3f, WoodDark); + k.Cyl("Plate", V(0f, 1.42f, 0f), 0.34f, 0.05f, WoodDark); + k.Ball("Light", V(0f, 1.66f, 0f), V(0.46f, 0.46f, 0.46f), k.LampMat()); + k.Cone("Cap", V(0f, 1.84f, 0f), 0.34f, 0.2f, WoodDark); + } + + // 2×1. 기둥 셋 + 가로대 둘. 양 끝 기둥이 칸 가장자리 바로 안쪽이라 이어 놓으면 한 줄로 이어진다. + private static void Fence(Kit k) + { + for (int i = -1; i <= 1; i++) + k.Box("Post", V(i * 1.3f, 0.45f, 0f), V(0.16f, 0.9f, 0.16f), Wood); + k.Box("RailHigh", V(0f, 0.68f, 0f), V(2.76f, 0.1f, 0.08f), WoodLight); + k.Box("RailLow", V(0f, 0.36f, 0f), V(2.76f, 0.1f, 0.08f), WoodLight); + } + + // 1×1. 토분 + 잎 뭉치 + 꽃 세 송이. + private static void Flowerpot(Kit k) + { + k.Cyl("Pot", V(0f, 0.17f, 0f), 0.46f, 0.34f, Clay); + k.Cyl("Rim", V(0f, 0.4f, 0f), 0.6f, 0.12f, Clay); + NoShadow(k.Cyl("Soil", V(0f, 0.465f, 0f), 0.5f, 0.012f, Soil)); + k.Ball("Leaves", V(0f, 0.62f, 0f), V(0.62f, 0.4f, 0.62f), Leaf); + NoShadow(k.Ball("FlowerA", V(0.12f, 0.84f, 0.08f), V(0.22f, 0.2f, 0.22f), Pink)); + NoShadow(k.Ball("FlowerB", V(-0.16f, 0.8f, -0.04f), V(0.2f, 0.18f, 0.2f), Yellow)); + NoShadow(k.Ball("FlowerC", V(0.02f, 0.8f, -0.18f), V(0.18f, 0.16f, 0.18f), Pink)); + } + + // 1×1. 둥근 지붕의 빨간 통 — 섬에서 빨강이 이만큼 뭉친 작은 물건은 이것뿐이라 색으로 읽힌다. + private static void Mailbox(Kit k) + { + k.Box("Post", V(0f, 0.5f, 0f), V(0.12f, 1.0f, 0.12f), Wood); + k.Box("Body", V(0f, 1.1f, 0f), V(0.44f, 0.24f, 0.62f), Red); + k.Cyl("Top", V(0f, 1.22f, 0f), 0.44f, 0.62f, Red, V(90f, 0f, 0f)); + NoShadow(k.Box("Lid", V(0f, 1.16f, 0.32f), V(0.34f, 0.3f, 0.03f), White)); + NoShadow(k.Box("Flag", V(0.25f, 1.32f, 0.05f), V(0.04f, 0.26f, 0.14f), Yellow)); + } + + // 1×1. 판자 위에 짙은 갓을 얹었다 — 위에서는 갓이 가로 막대 하나로 보인다. + private static void Sign(Kit k) + { + k.Box("Post", V(0f, 0.45f, 0f), V(0.1f, 0.9f, 0.1f), Wood); + k.Box("Board", V(0f, 1.0f, 0.04f), V(1.0f, 0.56f, 0.08f), WoodLight); + k.Box("Cap", V(0f, 1.31f, 0.04f), V(1.12f, 0.07f, 0.22f), WoodDark); + NoShadow(k.Box("LineA", V(0f, 1.08f, 0.09f), V(0.66f, 0.06f, 0.02f), WoodDark)); + NoShadow(k.Box("LineB", V(-0.1f, 0.92f, 0.09f), V(0.46f, 0.06f, 0.02f), WoodDark)); + } + + // 3×1. 두 기둥 사이에 처진 천 세 토막(가운데 평평, 양 끝 오르막) + 빨간 줄무늬 + 베개. + private static void Hammock(Kit k) + { + k.Cyl("PostL", V(-1.85f, 0.65f, 0f), 0.16f, 1.3f, Wood); + k.Cyl("PostR", V(1.85f, 0.65f, 0f), 0.16f, 1.3f, Wood); + k.Box("Cloth", V(0f, 0.55f, 0f), V(1.84f, 0.06f, 0.8f), Cream); + k.Box("ClothL", V(-1.3f, 0.775f, 0f), V(0.93f, 0.05f, 0.72f), Cream, V(0f, 0f, -29.4f)); + k.Box("ClothR", V(1.3f, 0.775f, 0f), V(0.93f, 0.05f, 0.72f), Cream, V(0f, 0f, 29.4f)); + NoShadow(k.Box("StripeA", V(-0.4f, 0.585f, 0f), V(0.18f, 0.02f, 0.8f), Red)); + NoShadow(k.Box("StripeB", V(0.4f, 0.585f, 0f), V(0.18f, 0.02f, 0.8f), Red)); + k.Ball("Pillow", V(0.68f, 0.64f, 0f), V(0.36f, 0.14f, 0.52f), White); + } + + // 2×2. 돌 고리 안의 불꽃 + 양옆 통나무 의자. + private static void Campfire(Kit k) + { + NoShadow(k.Cyl("Ash", V(0f, 0.02f, 0f), 1.5f, 0.04f, Soil)); + for (int i = 0; i < 6; i++) + { + float a = (60f * i + 30f) * Mathf.Deg2Rad; + k.Ball("RingStone", V(Mathf.Cos(a) * 0.72f, 0.13f, Mathf.Sin(a) * 0.72f), V(0.44f, 0.3f, 0.4f), + i % 2 == 0 ? Stone : StoneDark); + } + k.Cyl("LogA", V(0f, 0.14f, 0f), 0.16f, 0.95f, WoodDark, V(90f, 45f, 0f)); + k.Cyl("LogB", V(0f, 0.2f, 0f), 0.16f, 0.95f, WoodDark, V(90f, -45f, 0f)); + NoShadow(k.Cone("Flame", V(0f, 0.18f, 0f), 0.56f, 0.85f, k.FlameMat())); + // 속불은 겉불 안에 넣으면 가려진다 — 옆으로 빼서 두 갈래 불꽃으로 둔다. + NoShadow(k.Cone("FlameSmall", V(0.17f, 0.18f, 0.1f), 0.3f, 0.55f, k.LampMat())); + k.Cyl("SeatL", V(-1.15f, 0.17f, 0f), 0.34f, 1.2f, Wood, V(90f, 0f, 0f)); + k.Cyl("SeatR", V(1.15f, 0.17f, 0f), 0.34f, 1.2f, Wood, V(90f, 0f, 0f)); + } + + // 3×3. 돌 테 안에 물이 찬 큰 원 + 가운데 2단 수반. 물은 짙은 수반 바닥 위에 얹은 반투명 원판이다. + private static void Fountain(Kit k) + { + Material water = k.WaterMat(); + k.Cyl("Basin", V(0f, 0.2f, 0f), 4.0f, 0.4f, StoneDark); + k.Ring("Rim", V(0f, 0.42f, 0f), 3.8f, 0.3f, Stone); + k.Disc("Water", V(0f, 0.47f, 0f), 3.5f, 3.5f, water); + k.Cyl("Pedestal", V(0f, 0.75f, 0f), 0.6f, 1.1f, Stone); + k.Cyl("Bowl", V(0f, 1.3f, 0f), 1.5f, 0.16f, Stone); + k.Disc("BowlWater", V(0f, 1.39f, 0f), 1.25f, 1.25f, water); + k.Cyl("Spout", V(0f, 1.6f, 0f), 0.26f, 0.5f, StoneDark); + NoShadow(k.Ball("Jet", V(0f, 1.98f, 0f), V(0.5f, 0.55f, 0.5f), water)); + } + + // ── 지형지물 ── + + // 1×1. 연두 수관 + 지지대 — 참나무와는 크기와 잎색(연두)으로 갈린다. + private static void Sapling(Kit k) + { + NoShadow(k.Cyl("Mound", V(0f, 0.03f, 0f), 0.62f, 0.06f, Soil)); + k.Cyl("Trunk", V(0f, 0.5f, 0f), 0.1f, 1.0f, Wood); + k.Ball("Crown", V(0f, 1.12f, 0f), V(0.74f, 0.66f, 0.74f), LeafLight); + k.Ball("CrownTop", V(0.1f, 1.45f, 0.04f), V(0.44f, 0.42f, 0.44f), Leaf); + k.Box("Stake", V(0.2f, 0.42f, 0.08f), V(0.05f, 0.84f, 0.05f), WoodLight); + } + + // 2×2. 세 톤의 잎 뭉치 넷 — 수관만 칸 밖으로 0.2m까지 넘친다(그늘이 넓다는 설명대로). + private static void Oak(Kit k) + { + k.Cyl("Roots", V(0f, 0.1f, 0f), 0.95f, 0.2f, WoodDark); + k.Cyl("Trunk", V(0f, 0.9f, 0f), 0.6f, 1.8f, Wood); + k.Ball("Crown", V(0f, 2.6f, 0f), V(2.7f, 2.0f, 2.7f), Leaf); + k.Ball("CrownSideA", V(-0.75f, 2.15f, 0.55f), V(1.6f, 1.3f, 1.6f), LeafDark); + k.Ball("CrownSideB", V(0.85f, 2.3f, -0.5f), V(1.5f, 1.2f, 1.5f), LeafDark); + k.Ball("CrownTop", V(-0.3f, 3.35f, -0.3f), V(1.5f, 1.0f, 1.5f), LeafLight); + } + + // 2×2. 분홍 수관에 흰 꽃 뭉치, 발치에 떨어진 꽃잎 — 섬에서 분홍 덩어리는 이 나무뿐이다. + private static void Blossom(Kit k) + { + Material petal = k.Solid(Pink); + k.Cyl("Trunk", V(0f, 0.8f, 0f), 0.42f, 1.6f, WoodDark); + k.Cyl("Branch", V(0.45f, 1.75f, 0.1f), 0.2f, 1.0f, WoodDark, V(0f, 0f, -40f)); + k.Ball("Crown", V(0f, 2.45f, 0f), V(2.5f, 1.7f, 2.5f), Pink); + k.Ball("CrownSide", V(0.85f, 2.1f, 0.45f), V(1.5f, 1.15f, 1.5f), Pink); + k.Ball("CrownLight", V(-0.55f, 3.0f, -0.35f), V(1.4f, 0.95f, 1.4f), White); + k.Ball("CrownLow", V(-0.8f, 2.0f, 0.5f), V(1.3f, 1.0f, 1.3f), White); + k.Disc("PetalA", V(1.0f, 0.012f, 0.9f), 0.24f, 0.2f, petal); + k.Disc("PetalB", V(-1.1f, 0.012f, 0.3f), 0.2f, 0.24f, petal); + k.Disc("PetalC", V(0.5f, 0.012f, -1.15f), 0.22f, 0.22f, petal); + k.Disc("PetalD", V(-0.6f, 0.012f, 1.1f), 0.24f, 0.18f, petal); + } + + // 1×1. 납작한 돌 둘 + 윗면의 이끼. + private static void Rock(Kit k) + { + k.Ball("Rock", V(0f, 0.24f, 0f), V(0.95f, 0.6f, 0.8f), Stone, V(0f, 20f, 0f)); + k.Ball("RockSmall", V(0.32f, 0.13f, 0.26f), V(0.46f, 0.34f, 0.42f), StoneDark); + NoShadow(k.Ball("Moss", V(-0.08f, 0.5f, -0.02f), V(0.56f, 0.14f, 0.46f), Leaf)); + } + + // 2×2. 큰 덩어리 하나에 기대 선 작은 돌들. + private static void Boulder(Kit k) + { + k.Ball("Rock", V(-0.1f, 0.62f, -0.1f), V(2.2f, 1.5f, 1.85f), Stone, V(0f, 25f, 0f)); + k.Ball("RockSide", V(0.72f, 0.4f, 0.6f), V(1.25f, 0.95f, 1.15f), StoneDark); + k.Ball("RockLow", V(-0.85f, 0.28f, 0.7f), V(0.9f, 0.66f, 0.85f), Stone); + NoShadow(k.Ball("Moss", V(-0.2f, 1.3f, -0.15f), V(1.2f, 0.22f, 0.95f), Leaf)); + k.Ball("Pebble", V(0.35f, 0.12f, 1.2f), V(0.34f, 0.24f, 0.3f), StoneDark); + } + + // 3×3. 땅을 팔 수 없으니(잔디가 y=0의 한 장이다) 납작한 원판 세 겹으로 깊이를 낸다: 모래 물가 > 짙은 바닥 > 반투명 수면. + private static void Pond(Kit k) + { + k.Disc("Shore", V(0f, 0.015f, 0f), 4.2f, 4.0f, k.Solid(Sand)); + k.Disc("Bed", V(0f, 0.03f, 0f), 3.7f, 3.5f, k.Solid(Teal)); + k.Disc("WaterSurface", V(0f, 0.05f, 0f), 3.7f, 3.5f, k.WaterMat()); + float[] stoneAngles = { 20f, 110f, 200f, 290f }; + for (int i = 0; i < stoneAngles.Length; i++) + { + float a = stoneAngles[i] * Mathf.Deg2Rad; + k.Ball("ShoreStone", V(Mathf.Cos(a) * 1.85f, 0.12f, Mathf.Sin(a) * 1.75f), V(0.5f, 0.3f, 0.44f), + i % 2 == 0 ? Stone : StoneDark); + } + k.Disc("LilyPadA", V(0.5f, 0.065f, 0.4f), 0.5f, 0.5f, k.Solid(LeafLight)); + k.Disc("LilyPadB", V(-0.7f, 0.065f, -0.3f), 0.42f, 0.42f, k.Solid(LeafLight)); + NoShadow(k.Ball("Lotus", V(0.5f, 0.12f, 0.4f), V(0.2f, 0.14f, 0.2f), Pink)); + NoShadow(k.Cyl("ReedA", V(-1.45f, 0.45f, 0.95f), 0.06f, 0.9f, LeafDark)); + NoShadow(k.Cyl("ReedB", V(-1.3f, 0.38f, 1.1f), 0.06f, 0.76f, LeafDark)); + NoShadow(k.Cyl("ReedHeadA", V(-1.45f, 0.95f, 0.95f), 0.12f, 0.24f, WoodDark)); + NoShadow(k.Cyl("ReedHeadB", V(-1.3f, 0.8f, 1.1f), 0.12f, 0.22f, WoodDark)); + } + + // 2×2, 지나다닐 수 있다 — 캐릭터 무릎 아래(0.45m)로 낮게. 흙 판 위 잎 세 이랑 + 네 색 꽃. + private static void Flowerbed(Kit k) + { + Color[] blooms = { Pink, Yellow, White, Red }; + NoShadow(k.Box("Soil", V(0f, 0.035f, 0f), V(2.7f, 0.07f, 2.7f), Soil)); + for (int row = -1; row <= 1; row++) + { + float z = row * 0.85f; + NoShadow(k.Ball("Leaves", V(0f, 0.16f, z), V(2.4f, 0.24f, 0.5f), Leaf)); + for (int col = -1; col <= 1; col++) + { + int index = (row + 1) * 3 + (col + 1); + // 이랑마다 반 칸씩 어긋나게 — 격자로 맞추면 꽃밭이 아니라 단추 판으로 보인다. + float x = col * 0.8f + (row == 0 ? 0.2f : -0.1f); + NoShadow(k.Ball("Flower", V(x, 0.32f, z), V(0.3f, 0.24f, 0.3f), blooms[index % blooms.Length])); + } + } + } + + // 1×1, 지나다닐 수 있다 — 허리 아래로 낮게. 세 톤 잎 뭉치 + 빨간 열매. + private static void Bush(Kit k) + { + k.Ball("Bush", V(0f, 0.27f, 0f), V(1.1f, 0.62f, 1.0f), Leaf); + k.Ball("BushSide", V(0.28f, 0.22f, 0.24f), V(0.62f, 0.46f, 0.6f), LeafDark); + k.Ball("BushTop", V(-0.2f, 0.46f, -0.12f), V(0.52f, 0.34f, 0.5f), LeafLight); + NoShadow(k.Ball("BerryA", V(0.15f, 0.56f, 0.2f), V(0.1f, 0.1f, 0.1f), Red)); + NoShadow(k.Ball("BerryB", V(-0.3f, 0.4f, 0.36f), V(0.1f, 0.1f, 0.1f), Red)); + NoShadow(k.Ball("BerryC", V(0.38f, 0.42f, -0.12f), V(0.1f, 0.1f, 0.1f), Red)); + } + + // ── 도구(설비) ── + + // 1×1. 기둥 위 나무 접시에 과일 조각 — 지붕을 씌우지 않는다(위에서 과일이 안 보이면 급수대·바구니와 구별이 안 된다). + private static void Feeder(Kit k) + { + k.Cyl("Foot", V(0f, 0.03f, 0f), 0.5f, 0.06f, WoodDark); + k.Cyl("Post", V(0f, 0.25f, 0f), 0.16f, 0.5f, Wood); + k.Cyl("Tray", V(0f, 0.55f, 0f), 1.0f, 0.1f, WoodLight); + k.Ring("Rim", V(0f, 0.62f, 0f), 1.0f, 0.1f, Wood); + NoShadow(k.Ball("FruitA", V(0.14f, 0.69f, 0.1f), V(0.3f, 0.2f, 0.3f), Red)); + NoShadow(k.Ball("FruitB", V(-0.2f, 0.68f, -0.02f), V(0.28f, 0.18f, 0.26f), Yellow)); + NoShadow(k.Ball("FruitC", V(0.02f, 0.67f, -0.24f), V(0.22f, 0.16f, 0.22f), Pink)); + } + + // 1×1. 돌 수반에 고인 물 + 뒤에서 내려오는 대나무 물길. + private static void WaterStand(Kit k) + { + k.Cyl("Foot", V(0f, 0.06f, 0f), 0.8f, 0.12f, StoneDark); + k.Cyl("Basin", V(0f, 0.32f, 0f), 1.0f, 0.4f, Stone); + k.Ring("Rim", V(0f, 0.54f, 0f), 0.96f, 0.12f, StoneDark); + k.Disc("Water", V(0f, 0.56f, 0f), 0.88f, 0.88f, k.WaterMat()); + k.Box("SpoutPost", V(0f, 0.5f, -0.56f), V(0.1f, 1.0f, 0.1f), Wood); + NoShadow(k.Cyl("Spout", V(0f, 0.92f, -0.3f), 0.09f, 0.56f, LeafLight, V(107f, 0f, 0f))); + } + + // 1×1. 손잡이가 위로 걸친 바구니에 수확물(노랑·빨강·초록). + private static void Basket(Kit k) + { + k.Cyl("Body", V(0f, 0.25f, 0f), 0.9f, 0.5f, WoodLight); + k.Cyl("Band", V(0f, 0.25f, 0f), 0.94f, 0.1f, Wood); + k.Ring("Rim", V(0f, 0.5f, 0f), 0.92f, 0.1f, Wood); + // 세운 고리 — 아래 절반은 몸통에 묻히고 위 절반이 손잡이가 된다. + k.Ring("Handle", V(0f, 0.5f, 0f), 0.9f, 0.09f, Wood, V(0f, 0f, 90f)); + NoShadow(k.Ball("CropA", V(0.12f, 0.56f, 0.1f), V(0.34f, 0.26f, 0.34f), Yellow)); + NoShadow(k.Ball("CropB", V(-0.16f, 0.55f, -0.1f), V(0.3f, 0.24f, 0.3f), Yellow)); + NoShadow(k.Ball("CropC", V(0.02f, 0.57f, -0.22f), V(0.28f, 0.24f, 0.28f), Red)); + NoShadow(k.Ball("CropD", V(-0.18f, 0.56f, 0.14f), V(0.3f, 0.2f, 0.3f), Leaf)); + } + + // 1×1. 둥근 토기 단지 주둥이에 찬 금빛 꿀 + 옆에 내려놓은 뚜껑. + private static void Honeypot(Kit k) + { + k.Ball("Pot", V(0f, 0.38f, 0f), V(0.84f, 0.76f, 0.84f), Clay); + k.Cyl("Neck", V(0f, 0.76f, 0f), 0.52f, 0.14f, Clay); + NoShadow(k.Cyl("Honey", V(0f, 0.835f, 0f), 0.42f, 0.012f, Yellow)); + NoShadow(k.Ball("Drip", V(0.2f, 0.66f, 0.2f), V(0.18f, 0.3f, 0.18f), Yellow)); + NoShadow(k.Cyl("Dipper", V(0.1f, 0.98f, -0.04f), 0.05f, 0.5f, Wood, V(0f, 0f, -25f))); + k.Cyl("Lid", V(-0.36f, 0.04f, 0.32f), 0.44f, 0.08f, WoodLight); + } + + // 2×1. 왼쪽 물통 → 관 → 오른쪽 살수기와 물 우산. "물을 뿌리는 설비"라 물뿌리개(손 도구)가 아니라 고정 설비로 지었다. + private static void Sprinkler(Kit k) + { + Material water = k.WaterMat(); + k.Cyl("Tank", V(-0.8f, 0.42f, 0f), 0.95f, 0.84f, Wood); + k.Cyl("BandLow", V(-0.8f, 0.2f, 0f), 0.99f, 0.07f, Teal); + k.Cyl("BandHigh", V(-0.8f, 0.64f, 0f), 0.99f, 0.07f, Teal); + k.Ring("TankRim", V(-0.8f, 0.84f, 0f), 0.9f, 0.09f, WoodDark); + k.Disc("TankWater", V(-0.8f, 0.86f, 0f), 0.84f, 0.84f, water); + k.Cyl("Pipe", V(0.02f, 0.1f, 0f), 0.09f, 1.3f, Teal, V(0f, 0f, 90f)); + k.Cyl("Stand", V(0.72f, 0.3f, 0f), 0.1f, 0.6f, Teal); + k.Ball("Head", V(0.72f, 0.64f, 0f), V(0.22f, 0.22f, 0.22f), Teal); + NoShadow(k.Cone("Spray", V(0.72f, 0.3f, 0f), 1.2f, 0.4f, water)); + } + + // ── 조립 도구 ── + + private static Vector3 V(float x, float y, float z) => new Vector3(x, y, z); + + /// + /// 물건 하나를 짓는 동안의 문맥(root + 머티리얼). 치수는 실제 크기(m)로 받는다 — + /// 내장 원기둥은 높이가 2라 스케일 y가 반높이인데, 그 환산을 호출부마다 하면 반드시 한 곳은 틀린다. + /// + private readonly struct Kit + { + private readonly Transform root; + private readonly IslandMaterialCache mats; + + public Kit(Transform root, IslandMaterialCache mats) + { + this.root = root; + this.mats = mats; + } + + public Material Solid(Color color) => mats.Get(color); + public Material WaterMat() => mats.GetFade(WaterTint); + public Material GlassMat() => mats.GetFade(GlassTint); + public Material LampMat() => mats.GetGlow(LampTint, LampEmission); + public Material FlameMat() => mats.GetGlow(FlameTint, FlameEmission); + + public MeshRenderer Box(string name, Vector3 center, Vector3 size, Color color, Vector3 euler = default) + => Box(name, center, size, mats.Get(color), euler); + + public MeshRenderer Box(string name, Vector3 center, Vector3 size, Material material, Vector3 euler = default) + => AddPiece(root, name, CubeMesh, material, center, euler, size); + + /// 세운 원기둥(축 = Y). 눕히려면 euler (0,0,90) = X축, (90,0,0) = Z축. + public MeshRenderer Cyl(string name, Vector3 center, float diameter, float height, Color color, + Vector3 euler = default) + => Cyl(name, center, diameter, height, mats.Get(color), euler); + + public MeshRenderer Cyl(string name, Vector3 center, float diameter, float height, Material material, + Vector3 euler = default) + => AddPiece(root, name, CylinderMesh, material, center, euler, + new Vector3(diameter, height * 0.5f, diameter)); + + /// 타원체. 는 축별 지름. + public MeshRenderer Ball(string name, Vector3 center, Vector3 size, Color color, Vector3 euler = default) + => Ball(name, center, size, mats.Get(color), euler); + + public MeshRenderer Ball(string name, Vector3 center, Vector3 size, Material material, Vector3 euler = default) + => AddPiece(root, name, SphereMesh, material, center, euler, size); + + /// + /// 박공지붕(속이 찬 삼각기둥). 는 밑면 중심, 마루는 Z로 뻗는다. + /// 90이면 마루가 X로 눕고 그때 이 Z 폭, 가 X 길이다. + /// + public MeshRenderer Roof(string name, Vector3 basePos, float span, float height, float length, Color color, + float yaw = 0f) + => Roof(name, basePos, span, height, length, mats.Get(color), yaw); + + public MeshRenderer Roof(string name, Vector3 basePos, float span, float height, float length, + Material material, float yaw = 0f) + => AddPiece(root, name, PrismMesh, material, basePos, new Vector3(0f, yaw, 0f), + new Vector3(span, height, length)); + + /// 원뿔. 는 밑면 중심. + public MeshRenderer Cone(string name, Vector3 basePos, float diameter, float height, Color color) + => Cone(name, basePos, diameter, height, mats.Get(color)); + + public MeshRenderer Cone(string name, Vector3 basePos, float diameter, float height, Material material) + => AddPiece(root, name, ConeMesh, material, basePos, Vector3.zero, + new Vector3(diameter, height, diameter)); + + /// + /// 고리(누운 토러스). 는 관 중심선의 지름이라 바깥지름은 그 1.1배다. + /// 는 관의 세로 두께. + /// + public MeshRenderer Ring(string name, Vector3 center, float diameter, float thickness, Color color, + Vector3 euler = default) + => AddPiece(root, name, TorusMesh, mats.Get(color), center, euler, + new Vector3(diameter, thickness / (TorusTube * 2f), diameter)); + + /// 위를 보는 납작한 원판(두께 0). center.y가 곧 윗면이다. 그림자는 드리우지 않는다. + public MeshRenderer Disc(string name, Vector3 center, float sizeX, float sizeZ, Material material) + => AddFlatDisc(root, name, material, center, sizeX, sizeZ); + } + + // ── 메시·조각(IslandTerrainBuilder와 공유) ── + + private const float TorusTube = 0.05f; + private const int ConeSides = 12; + + // 내장 메시와 ProcMeshLibrary 캐시는 프로세스 수명이라 정적으로 쥐어도 된다. 그래도 Unity의 == null(파괴 검사)로 + // 다시 찾게 해 둔다(SceneryMaterials.LitShader와 같은 방어). ProcMeshLibrary를 조각마다 부르지 않는 건 + // 캐시 적중에도 람다 클로저가 하나씩 할당되기 때문이다. + private static Mesh cubeMesh; + private static Mesh cylinderMesh; + private static Mesh sphereMesh; + private static Mesh discMesh; + private static Mesh torusMesh; + private static Mesh prismMesh; + private static Mesh coneMesh; + + internal static Mesh CubeMesh + { + get + { + if (cubeMesh == null) cubeMesh = ExtractPrimitive(PrimitiveType.Cube); + return cubeMesh; + } + } + + internal static Mesh CylinderMesh + { + get + { + if (cylinderMesh == null) cylinderMesh = ExtractPrimitive(PrimitiveType.Cylinder); + return cylinderMesh; + } + } + + /// 지름 1의 구. 내장 Sphere(515정점) 대신 187정점짜리를 쓴다 — 나무 한 그루가 구 네 개다. + internal static Mesh SphereMesh + { + get + { + if (sphereMesh == null) sphereMesh = ProcMeshLibrary.LowSphere(0.5f, 0.5f, 0.5f, 10, 16); + return sphereMesh; + } + } + + /// 지름 1의 원판(+Z를 본다). 눕혀 쓰려면 . + internal static Mesh DiscMesh + { + get + { + if (discMesh == null) discMesh = ProcMeshLibrary.Disc(0.5f, 0.5f, 0f, 40); + return discMesh; + } + } + + private static Mesh TorusMesh + { + get + { + if (torusMesh == null) torusMesh = ProcMeshLibrary.Torus(TorusTube, 24, 6); + return torusMesh; + } + } + + private static Mesh PrismMesh + { + get + { + if (prismMesh == null) prismMesh = BuildPrism(); + return prismMesh; + } + } + + private static Mesh ConeMesh + { + get + { + if (coneMesh == null) coneMesh = BuildCone(); + return coneMesh; + } + } + + /// + /// 콜라이더 없는 조각 하나. sharedMaterial로 넣는다 — .material은 인스턴스를 복제해 + /// 캐시가 무의미해지고, 복제본은 GameObject를 지워도 남는다. + /// + internal static MeshRenderer AddPiece(Transform parent, string name, Mesh mesh, Material material, + Vector3 localPos, Vector3 localEuler, Vector3 localScale) + { + GameObject go = new GameObject(name); + Transform t = go.transform; + t.SetParent(parent, false); + t.localPosition = localPos; + t.localRotation = Quaternion.Euler(localEuler); + t.localScale = localScale; + go.AddComponent().sharedMesh = mesh; + MeshRenderer renderer = go.AddComponent(); + renderer.sharedMaterial = material; + return renderer; + } + + /// 위를 보는 원판. Disc 메시는 +Z를 보므로 X로 −90° 눕힌다(그러면 스케일 y가 Z 폭이 된다). + internal static MeshRenderer AddFlatDisc(Transform parent, string name, Material material, Vector3 localPos, + float sizeX, float sizeZ) + { + return NoShadow(AddPiece(parent, name, DiscMesh, material, localPos, new Vector3(-90f, 0f, 0f), + new Vector3(sizeX, sizeZ, 1f))); + } + + /// 작은 조각·납작한 판·반투명은 그림자를 끈다 — 섬에 물건이 수백 개라 그림자 드로우콜이 그만큼 준다. + internal static MeshRenderer NoShadow(MeshRenderer renderer) + { + renderer.shadowCastingMode = ShadowCastingMode.Off; + return renderer; + } + + /// + /// 플레이 중이면 Destroy, 아니면 DestroyImmediate. 배치 캡처(IslandModelCapture)는 + /// 플레이 모드 밖에서 빌더를 부르는데 거기선 Destroy가 오류만 찍고 아무것도 지우지 않는다. + /// + internal static void DestroySafe(UnityEngine.Object obj) + { + if (obj == null) return; + if (Application.isPlaying) UnityEngine.Object.Destroy(obj); + else UnityEngine.Object.DestroyImmediate(obj); + } + + // 내장 메시만 꺼내고 프리미티브는 버린다. 끄고 나서 지우는 건 Destroy가 프레임 끝까지 미뤄지는 동안 + // 원점에 콜라이더가 남지 않게 하려는 것이다. + private static Mesh ExtractPrimitive(PrimitiveType type) + { + GameObject temp = GameObject.CreatePrimitive(type); + Mesh mesh = temp.GetComponent().sharedMesh; + temp.SetActive(false); + DestroySafe(temp); + return mesh; + } + + // 단위 삼각기둥: x ∈ [−0.5, 0.5], y ∈ [0, 1], z ∈ [−0.5, 0.5], 마루는 (0, 1, z). + // 면마다 정점을 따로 둔다 — 공유하면 RecalculateNormals가 마루에서 노멀을 평균 내 지붕 두 면의 명암 차가 사라진다. + private static Mesh BuildPrism() + { + Vector3 a0 = new Vector3(-0.5f, 0f, -0.5f), b0 = new Vector3(0.5f, 0f, -0.5f), c0 = new Vector3(0f, 1f, -0.5f); + Vector3 a1 = new Vector3(-0.5f, 0f, 0.5f), b1 = new Vector3(0.5f, 0f, 0.5f), c1 = new Vector3(0f, 1f, 0.5f); + + Mesh mesh = new Mesh { name = "IslandPrism" }; + mesh.vertices = new[] + { + a0, c0, b0, // 뒤 박공 + a1, b1, c1, // 앞 박공 + a0, a1, c1, c0, // 왼 지붕면 + b0, c0, c1, b1, // 오른 지붕면 + a0, b0, b1, a1, // 밑면 + }; + // 와인딩은 cross(b − a, c − a)가 바깥을 향하는 순서(ProcMeshLibrary와 같은 규약). 뒤집히면 예외 없이 안 보인다. + mesh.triangles = new[] + { + 0, 1, 2, + 3, 4, 5, + 6, 7, 8, 6, 8, 9, + 10, 11, 12, 10, 12, 13, + 14, 15, 16, 14, 16, 17, + }; + mesh.RecalculateNormals(); + mesh.RecalculateBounds(); + mesh.hideFlags = HideFlags.HideAndDontSave; // 프로세스 수명 — 씬 저장·언로드 대상에서 뺀다 + return mesh; + } + + // 단위 원뿔: 밑면 지름 1(y = 0), 꼭짓점 (0, 1, 0). 옆면은 면마다 정점을 따로 둬 각진 저폴리로 둔다. + private static Mesh BuildCone() + { + Vector3[] verts = new Vector3[ConeSides * 3 + ConeSides + 1]; + int[] tris = new int[ConeSides * 6]; + + for (int i = 0; i < ConeSides; i++) + { + int s = i * 3; + verts[s] = ConeRim(i); + verts[s + 1] = Vector3.up; + verts[s + 2] = ConeRim(i + 1); + tris[s] = s; + tris[s + 1] = s + 1; + tris[s + 2] = s + 2; + } + + int center = ConeSides * 3; + verts[center] = Vector3.zero; + for (int i = 0; i < ConeSides; i++) verts[center + 1 + i] = ConeRim(i); + for (int i = 0; i < ConeSides; i++) + { + int t = ConeSides * 3 + i * 3; + tris[t] = center; + tris[t + 1] = center + 1 + i; + tris[t + 2] = center + 1 + (i + 1) % ConeSides; + } + + Mesh mesh = new Mesh { name = "IslandCone" }; + mesh.vertices = verts; + mesh.triangles = tris; + mesh.RecalculateNormals(); + mesh.RecalculateBounds(); + mesh.hideFlags = HideFlags.HideAndDontSave; + return mesh; + } + + private static Vector3 ConeRim(int index) + { + float a = (index % ConeSides) / (float)ConeSides * Mathf.PI * 2f; + return new Vector3(Mathf.Cos(a) * 0.5f, 0f, Mathf.Sin(a) * 0.5f); + } + } +} diff --git a/Assets/Scripts/Core/IslandObjectBuilder.cs.meta b/Assets/Scripts/Core/IslandObjectBuilder.cs.meta new file mode 100644 index 0000000..f15225c --- /dev/null +++ b/Assets/Scripts/Core/IslandObjectBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7612c860335832244938aaab116501eb \ No newline at end of file diff --git a/Assets/Scripts/Core/IslandShareClient.cs b/Assets/Scripts/Core/IslandShareClient.cs new file mode 100644 index 0000000..82814ba --- /dev/null +++ b/Assets/Scripts/Core/IslandShareClient.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections; +using System.Text; +using UnityEngine; +using UnityEngine.Networking; + +namespace InsectGame.Core +{ + /// 서버가 돌려주는 섬 공개 정보. 통신 규격은 Docs/SocialPvp.md 「섬 공유」. + [Serializable] + public class IslandInfo + { + public string ownerUid; + public string ownerName; + public string friendCode; + public bool isPublic; + public int likes; + public int visits; + public bool likedByMe; + public long updatedAtMs; + /// 섬 스냅샷 JSON(). getIsland만 채워서 준다. + public string snapshot; + } + + [Serializable] + internal class IslandApiRequest + { + public string action; + public string island; + public bool isPublic; + public string friendCode; + public string targetUid; + } + + [Serializable] + internal class IslandApiResponse + { + public bool success; + public string error; + public IslandInfo island; + } + + /// + /// 섬 공개·방문·좋아요의 서버 통신. 친구 기능과 같은 Cloud Function(socialPvpApi)에 액션으로 얹혀 있다 — + /// Firestore 규칙이 타인 문서 읽기를 막아서 클라이언트가 직접 읽을 길이 없다. + /// + /// 서버가 없어도 섬은 돈다. 미배포·오프라인·비로그인이면 요청을 보내지 않고 만 채운다. + /// + public class IslandShareClient : MonoBehaviour + { + /// 서버가 받는 스냅샷 문자열 길이 상한(functions/island.js와 같은 값). 넘으면 올리지 않는다. + internal const int MaxSnapshotChars = 48000; + + [SerializeField] private IslandManager island; + + private bool busy; + private bool publishQueued; + + /// 내 섬의 공개 정보(좋아요·방문 수·섬 코드). 아직 못 받았으면 null. + public IslandInfo MyInfo { get; private set; } + public string LastError { get; private set; } + public bool IsBusy => busy; + + /// 내 정보·오류·진행 상태가 바뀌었다. + public event Action StateChanged; + + public void AutoWire(IslandManager islandManager) + { + if (island == null) island = islandManager; + } + + /// 서버에 닿을 수 있는 상태인가. 아니면 이유를 에 적는다. + public bool CanRequest() + { + if (!FirebaseConfig.IsSocialPvpConfigured) + { + SetError("섬 공유 서버가 아직 준비되지 않았습니다."); + return false; + } + AuthManager auth = AuthManager.Instance; + if (auth == null || !auth.IsLoggedIn || auth.IsMasterAccount || string.IsNullOrEmpty(auth.IdToken)) + { + SetError("섬 공유는 로그인한 계정에서만 쓸 수 있습니다."); + return false; + } + return true; + } + + public void ClearError() + { + if (string.IsNullOrEmpty(LastError)) return; + LastError = null; + StateChanged?.Invoke(); + } + + /// 내 섬의 좋아요·방문 수·섬 코드를 받아 온다. + public void RefreshMyInfo() + { + if (busy || !CanRequest()) return; + StartCoroutine(Send(new IslandApiRequest { action = "getMyIsland" }, response => + { + if (response != null && response.success && response.island != null) MyInfo = response.island; + })); + } + + /// + /// 지금 섬 모습을 올린다(공개 설정 포함). 꾸미기 화면을 닫을 때·공개 설정을 바꿀 때 부른다. + /// 요청 중에 또 부르면 끝난 뒤 한 번만 더 올린다 — 마지막 모습이 올라가면 된다. + /// + public void Publish() + { + if (island == null) return; + if (busy) + { + publishQueued = true; + return; + } + // 조용히 실패한다 — 꾸미기를 닫을 때마다 "서버가 없습니다"를 띄우면 서버 없는 환경에서 계속 거슬린다. + if (!FirebaseConfig.IsSocialPvpConfigured) return; + AuthManager auth = AuthManager.Instance; + if (auth == null || !auth.IsLoggedIn || auth.IsMasterAccount || string.IsNullOrEmpty(auth.IdToken)) return; + + string json = JsonUtility.ToJson(island.BuildSnapshot(auth.DisplayName)); + if (json.Length > MaxSnapshotChars) + { + SetError("섬에 놓인 물건이 너무 많아 공개할 수 없습니다."); + return; + } + StartCoroutine(Send(new IslandApiRequest + { + action = "publishIsland", + island = json, + isPublic = island.IsPublic, + }, response => + { + if (response != null && response.success && response.island != null) MyInfo = response.island; + })); + } + + /// 섬 코드(친구 코드 8자리)로 남의 섬을 받아 온다. + public void FetchByCode(string friendCode, Action onDone) + { + string code = (friendCode ?? string.Empty).Trim().ToUpperInvariant(); + if (string.IsNullOrEmpty(code)) + { + SetError("섬 코드를 입력해 주세요."); + onDone?.Invoke(null, null); + return; + } + Fetch(new IslandApiRequest { action = "getIsland", friendCode = code }, onDone); + } + + /// uid로 남의 섬을 받아 온다(친구 목록에서 고를 때). + public void FetchByUid(string uid, Action onDone) + { + if (string.IsNullOrEmpty(uid)) + { + onDone?.Invoke(null, null); + return; + } + Fetch(new IslandApiRequest { action = "getIsland", targetUid = uid }, onDone); + } + + private void Fetch(IslandApiRequest request, Action onDone) + { + if (busy || !CanRequest()) + { + onDone?.Invoke(null, null); + return; + } + StartCoroutine(Send(request, response => + { + if (response == null || !response.success || response.island == null) + { + onDone?.Invoke(null, null); + return; + } + IslandSnapshot snapshot = ParseSnapshot(response.island); + if (snapshot == null) SetError("섬을 불러오지 못했습니다."); + onDone?.Invoke(snapshot != null ? response.island : null, snapshot); + })); + } + + /// + /// 받은 섬을 읽어 정리한다. 남이 만든 데이터라 모르는 물건·경계 밖·겹침을 여기서 버린다. + /// 주인 이름은 서버가 정한 값으로 덮는다(스냅샷 안의 이름은 서버가 버린다). + /// + internal static IslandSnapshot ParseSnapshot(IslandInfo info) + { + if (info == null || string.IsNullOrEmpty(info.snapshot)) return null; + IslandSnapshot snapshot; + try + { + snapshot = JsonUtility.FromJson(info.snapshot); + } + catch (Exception) + { + return null; + } + if (snapshot == null) return null; + snapshot.ownerName = string.IsNullOrEmpty(info.ownerName) ? "탐험가" : info.ownerName; + IslandSaveRules.SanitizeSnapshot(snapshot); + return snapshot; + } + + /// 남의 섬에 좋아요(하루 한 번). + public void Like(string targetUid, Action onDone) + { + if (busy || string.IsNullOrEmpty(targetUid) || !CanRequest()) + { + onDone?.Invoke(null); + return; + } + StartCoroutine(Send(new IslandApiRequest { action = "likeIsland", targetUid = targetUid }, response => + { + onDone?.Invoke(response != null && response.success ? response.island : null); + })); + } + + private IEnumerator Send(IslandApiRequest payload, Action onComplete, bool allowRetry = true) + { + busy = true; + LastError = null; + StateChanged?.Invoke(); + + IslandApiResponse response = null; + string transportError = null; + long status = 0; + using (var request = new UnityWebRequest(FirebaseConfig.SocialPvpApiUrl, "POST")) + { + request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(JsonUtility.ToJson(payload))); + request.downloadHandler = new DownloadHandlerBuffer(); + request.SetRequestHeader("Content-Type", "application/json"); + request.SetRequestHeader("Authorization", "Bearer " + AuthManager.Instance.IdToken); + // 무제한 대기면 busy가 영영 안 풀려 방문 창이 멈춘다. + request.timeout = 15; + yield return request.SendWebRequest(); + + status = request.responseCode; + if (request.result != UnityWebRequest.Result.Success) transportError = request.error; + string text = request.downloadHandler != null ? request.downloadHandler.text : null; + if (!string.IsNullOrEmpty(text)) + { + try { response = JsonUtility.FromJson(text); } + catch (Exception e) { Debug.LogWarning("[Island] 응답 파싱 실패: " + e.Message); } + } + } + + if (status == 401 && allowRetry && AuthManager.Instance != null) + { + bool refreshed = false; + yield return AuthManager.Instance.TryRefreshTokenForRetry(value => refreshed = value); + if (refreshed) + { + yield return Send(payload, onComplete, false); + yield break; + } + } + + busy = false; + if (response == null || !response.success) + { + // 404는 함수 주소 자체가 없다는 뜻이다 — 서버가 아직 배포되지 않았다(연결 실패와 구분해서 알린다). + LastError = status == 404 + ? "섬 공유 서버가 아직 준비되지 않았습니다." + : ToUserMessage(response != null ? response.error : null, transportError); + } + + onComplete?.Invoke(response); + StateChanged?.Invoke(); + + if (publishQueued) + { + publishQueued = false; + Publish(); + } + } + + private void SetError(string message) + { + LastError = message; + StateChanged?.Invoke(); + } + + private static string ToUserMessage(string error, string transportError) + { + switch (error) + { + case "island_not_found": return "그 섬을 찾을 수 없습니다. 아직 공개한 적이 없는 섬일 수 있어요."; + case "island_private": return "주인이 공개하지 않은 섬입니다."; + case "island_target_required": return "섬 코드를 입력해 주세요."; + case "island_invalid": return "섬 정보를 올리지 못했습니다."; + case "cannot_like_self": return "내 섬에는 좋아요를 누를 수 없습니다."; + case "already_liked_today": return "오늘은 이미 좋아요를 눌렀습니다."; + case "user_blocked": return "차단 관계인 사용자의 섬은 방문할 수 없습니다."; + case "unauthenticated": return "로그인이 만료되었습니다. 다시 로그인해 주세요."; + // 섬 액션을 모르는 옛 서버 — 함수가 아직 새로 배포되지 않았다. + case "unknown_action": return "섬 공유 서버가 아직 준비되지 않았습니다."; + default: + return string.IsNullOrEmpty(error) && !string.IsNullOrEmpty(transportError) + ? "서버에 연결하지 못했습니다." + : "서버 요청에 실패했습니다."; + } + } + } +} diff --git a/Assets/Scripts/Core/IslandShareClient.cs.meta b/Assets/Scripts/Core/IslandShareClient.cs.meta new file mode 100644 index 0000000..990454a --- /dev/null +++ b/Assets/Scripts/Core/IslandShareClient.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 639a438b9117d0743a68c2cab32b60d6 \ No newline at end of file diff --git a/Assets/Scripts/Core/IslandTerrainBuilder.cs b/Assets/Scripts/Core/IslandTerrainBuilder.cs new file mode 100644 index 0000000..1a5b226 --- /dev/null +++ b/Assets/Scripts/Core/IslandTerrainBuilder.cs @@ -0,0 +1,312 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 섬의 땅·모래톱·바다·나루터 모양만 짓는다. root 원점 = 섬 중심·지면(y = 0). + /// + /// 콜라이더가 없다. 밟는 땅과 경계 벽은 IslandWorldBuilder.BuildColliders가 따로 세운다 — + /// 플레이어 접지와 클릭-이동이 콜라이더를 보므로 모래톱이나 바다에 콜라이더가 있으면 그리로 걸어 나간다. + /// + /// 잔디는 윗면이 정확히 y = 0인 평면이다. 물건이 그 위에 y = 0 기준으로 놓이고 화면 좌표 → 칸 변환 + /// (IslandWorldBuilder.ScreenToCell)도 그 평면을 쓴다. 칸이 은은히 읽히도록 두 톤 체커로 칠하되 + /// 칸마다 오브젝트를 두지 않고 톤별로 한 메시에 굽는다(가장 큰 섬 484칸이 렌더러 3개). + /// + public static class IslandTerrainBuilder + { + // 색은 감마 반사율이고 윗면은 약 1.25배로 나온다(IslandObjectBuilder 머리 주석). 두 잔디 톤의 차는 6%쯤 — + // 더 벌리면 장기판이 되어 그 위 물건보다 바닥이 먼저 눈에 들어온다. + private static readonly Color GrassLight = new Color(0.36f, 0.545f, 0.265f); + private static readonly Color GrassDark = new Color(0.33f, 0.51f, 0.245f); + // 도착 칸(나루터 앞 2×2) — 물건을 놓을 수 없는 자리임을 바닥색으로 미리 알린다. + private static readonly Color ArrivalPad = new Color(0.64f, 0.56f, 0.40f); + + // 바다는 섬에서 멀어질수록 짙어지다가 맨 바깥 띠에서 다시 옅어진다. 맨 바깥 색은 안개(0.66, 0.84, 0.95 · Exp 0.006)를 + // 150m 거친 결과가 카메라 배경(0.56, 0.80, 0.96)에 닿도록 역산한 값이다 — 수평선에 금이 서지 않는다. + private static readonly Color SeaFloor = new Color(0.455f, 0.69f, 0.63f); + private static readonly Color SeaMid = new Color(0.29f, 0.575f, 0.69f); + private static readonly Color SeaDeep = new Color(0.175f, 0.415f, 0.64f); + private static readonly Color SeaFar = new Color(0.27f, 0.53f, 0.735f); + private static readonly Color SeaWater = new Color(0.375f, 0.695f, 0.735f, 0.5f); + + /// 수면 높이. 섬이 물 위로 이만큼 솟아 있다. + private const float SeaLevel = -0.5f; + + // 모래톱 단면은 타원이다: 잔디 가장자리에서 SandTopY로 시작해 바깥으로 SandReach만큼 가며 SandDrop만큼 떨어진다. + // 변(누운 원기둥)과 모서리(구)가 같은 단면이라 이음매 없이 둥글게 돈다. 수면과 만나는 자리가 잔디에서 약 2.8m다. + private const float SandTopY = -0.05f; + private const float SandReach = 3.6f; + private const float SandDrop = 1.2f; + + // 반투명 수면 한 장은 섬 둘레에만 깐다(모래가 물밑으로 비치는 띠). 그 아래·바깥은 전부 불투명이다 — + // 반투명을 겹겹이 깔면 정렬이 깨지고, 섬 위의 연못·온실 유리와도 순서가 얽힌다. + private const float ShallowMargin = 3.5f; + private const float ShallowFloorY = -0.97f; + private const float MidBand = 12f; + private const float DeepRadius = 72f; + private const float FarRadius = 150f; + + // 0.02가 아니라 0.025인 건 꾸미기 격자선(IslandWorldBuilder, y = 0.02) 때문이다 — 같은 높이면 남쪽 가장자리 선이 + // 첫 널판과 겹쳐 지글거린다. + private const float PierTopY = 0.025f; + private const float PierWidth = 1.9f; + private const int PierPlanks = 9; + private const float PierPlankPitch = 0.54f; + + // 체커 메시는 프로세스 수명 캐시다(ProcMeshLibrary와 같은 이유 — 소유자가 섬 인스턴스가 아니라 프로세스). + // 섬을 드나들 때마다 만들었다 지우면 "누가 아직 쓰는가"를 따져야 하는데, 조합이 크기 4단계 × 톤 3개로 끝난다. + private static readonly Dictionary tileMeshes = new Dictionary(); + + private const int ToneLight = 0; + private const int ToneDark = 1; + private const int ToneArrival = 2; + + /// 섬의 땅·모래톱·바다·나루터 모양만 짓는다. root 원점 = 섬 중심·지면(y=0). 콜라이더 없음. + public static GameObject Build(int sizeLevel, Transform parent, IslandMaterialCache materials) + { + GameObject root = new GameObject("IslandTerrain"); + root.transform.SetParent(parent, false); + if (materials == null) return root; + + int gridSize = IslandGrid.GridSize(sizeLevel); + float half = IslandGrid.HalfExtent(sizeLevel); + + BuildGrass(root.transform, gridSize, half, materials); + BuildSand(root.transform, half, materials); + BuildSea(root.transform, half, materials); + BuildPier(root.transform, half, materials); + BuildIslets(root.transform, materials); + return root; + } + + private static void BuildGrass(Transform root, int gridSize, float half, IslandMaterialCache materials) + { + AddTiles(root, "GrassTiles_Light", gridSize, ToneLight, materials.Get(GrassLight)); + AddTiles(root, "GrassTiles_Dark", gridSize, ToneDark, materials.Get(GrassDark)); + AddTiles(root, "ArrivalPad", gridSize, ToneArrival, materials.Get(ArrivalPad)); + + // 잔디 판의 옆면(흙 단면). 윗면은 타일 메시 바로 밑에 숨기고, 모래톱 원기둥·구의 섬 안쪽 절반도 이 상자가 삼킨다. + IslandObjectBuilder.NoShadow(IslandObjectBuilder.AddPiece(root, "GrassSkirt", IslandObjectBuilder.CubeMesh, + materials.Get(IslandObjectBuilder.Soil), new Vector3(0f, -0.335f, 0f), Vector3.zero, + new Vector3(half * 2f, 0.66f, half * 2f))); + } + + private static void AddTiles(Transform root, string name, int gridSize, int tone, Material material) + { + // 평평한 바닥은 그림자를 드리울 데가 없다(받기만 한다). + IslandObjectBuilder.NoShadow(IslandObjectBuilder.AddPiece(root, name, TileMesh(gridSize, tone), material, + Vector3.zero, Vector3.zero, Vector3.one)); + } + + /// + /// 그 톤에 해당하는 칸만 모은 메시(y = 0, 위를 본다). 체커는 섬 중심 기준이라 섬을 넓혀도 + /// 이미 놓인 물건 밑의 무늬가 바뀌지 않는다. 도착 칸 판정은 을 그대로 쓴다 — + /// 여기서 좌표를 다시 적으면 규칙이 바뀔 때 바닥색만 옛 자리에 남는다. + /// + private static Mesh TileMesh(int gridSize, int tone) + { + int key = gridSize * 4 + tone; + if (tileMeshes.TryGetValue(key, out Mesh cached) && cached != null) return cached; + + int half = gridSize / 2; + float cs = GameConstants.Island.CellSize; + List verts = new List(); + List norms = new List(); + List tris = new List(); + + for (int z = -half; z < half; z++) + { + for (int x = -half; x < half; x++) + { + int cellTone = IslandGrid.TouchesArrival(x, z, 1, 1, gridSize) ? ToneArrival + : ((x + z) & 1) == 0 ? ToneLight : ToneDark; + if (cellTone != tone) continue; + + // 이웃 칸과 같은 식((x + 1) * cs)으로 모서리를 내야 좌표가 비트 단위로 같아 틈이 안 생긴다. + float x0 = x * cs, x1 = (x + 1) * cs; + float z0 = z * cs, z1 = (z + 1) * cs; + int v = verts.Count; + verts.Add(new Vector3(x0, 0f, z0)); + verts.Add(new Vector3(x0, 0f, z1)); + verts.Add(new Vector3(x1, 0f, z1)); + verts.Add(new Vector3(x1, 0f, z0)); + for (int i = 0; i < 4; i++) norms.Add(Vector3.up); + // cross(b − a, c − a)가 +Y인 순서 — 뒤집히면 위에서 통째로 안 보인다. + tris.Add(v); tris.Add(v + 1); tris.Add(v + 2); + tris.Add(v); tris.Add(v + 2); tris.Add(v + 3); + } + } + + Mesh mesh = new Mesh { name = "IslandTiles_" + gridSize + "_" + tone }; + mesh.SetVertices(verts); + mesh.SetNormals(norms); + mesh.SetTriangles(tris, 0); + mesh.RecalculateBounds(); + mesh.hideFlags = HideFlags.HideAndDontSave; + tileMeshes[key] = mesh; + return mesh; + } + + private static void BuildSand(Transform root, float half, IslandMaterialCache materials) + { + Material sand = materials.Get(IslandObjectBuilder.Sand); + float centerY = SandTopY - SandDrop; + float across = SandReach * 2f; + float tall = SandDrop * 2f; + + // 변: 누운 원기둥의 위·바깥 4분면이 해변이 된다. 원기둥은 높이가 2라 스케일 y가 곧 반길이(half)다. + // (0,0,90)은 축을 X로 눕힌다 — 로컬 x가 세로, z가 폭. (90,0,0)은 축을 Z로 눕힌다 — 로컬 x가 폭, z가 세로. + Vector3 alongX = new Vector3(tall, half, across); + Vector3 alongZ = new Vector3(across, half, tall); + AddSand(root, "Sand_N", IslandObjectBuilder.CylinderMesh, sand, new Vector3(0f, centerY, half), + new Vector3(0f, 0f, 90f), alongX); + AddSand(root, "Sand_S", IslandObjectBuilder.CylinderMesh, sand, new Vector3(0f, centerY, -half), + new Vector3(0f, 0f, 90f), alongX); + AddSand(root, "Sand_E", IslandObjectBuilder.CylinderMesh, sand, new Vector3(half, centerY, 0f), + new Vector3(90f, 0f, 0f), alongZ); + AddSand(root, "Sand_W", IslandObjectBuilder.CylinderMesh, sand, new Vector3(-half, centerY, 0f), + new Vector3(90f, 0f, 0f), alongZ); + + // 모서리: 같은 단면의 타원체 — 변의 원기둥 끝과 정확히 맞물려 모서리가 둥글게 돈다. + Vector3 corner = new Vector3(across, tall, across); + for (int i = 0; i < 4; i++) + { + float x = (i % 2 == 0) ? -half : half; + float z = (i < 2) ? -half : half; + AddSand(root, "Sand_Corner", IslandObjectBuilder.SphereMesh, sand, new Vector3(x, centerY, z), + Vector3.zero, corner); + } + } + + private static void AddSand(Transform root, string name, Mesh mesh, Material sand, Vector3 pos, Vector3 euler, + Vector3 scale) + { + IslandObjectBuilder.NoShadow(IslandObjectBuilder.AddPiece(root, name, mesh, sand, pos, euler, scale)); + } + + private static void BuildSea(Transform root, float half, IslandMaterialCache materials) + { + // 마른 모래의 폭 = 타원 단면이 수면과 만나는 자리. 섬이 네모라 모서리가 가장 멀리 나간다. + float t = (SeaLevel - (SandTopY - SandDrop)) / SandDrop; + float dryWidth = SandReach * Mathf.Sqrt(Mathf.Max(0f, 1f - t * t)); + float shallow = half * Mathf.Sqrt(2f) + dryWidth + ShallowMargin; + + // 불투명 층은 바깥일수록 조금씩 낮다 — 같은 높이에 겹치면 z-fighting으로 띠 경계가 지글거린다. + // 얕은 바닥이 수면보다 0.47m 낮은 건 모래 비탈이 물밑으로 0.7m쯤 더 비쳐 보이게 하려는 것이다. + AddSeaDisc(root, "SeaFar", FarRadius, ShallowFloorY - 0.09f, materials.Get(SeaFar)); + AddSeaDisc(root, "SeaDeep", Mathf.Max(DeepRadius, shallow + MidBand + 20f), ShallowFloorY - 0.06f, + materials.Get(SeaDeep)); + AddSeaDisc(root, "SeaMid", shallow + MidBand, ShallowFloorY - 0.03f, materials.Get(SeaMid)); + AddSeaDisc(root, "SeaShallowFloor", shallow + 0.6f, ShallowFloorY, materials.Get(SeaFloor)); + AddSeaDisc(root, "SeaShallowWater", shallow, SeaLevel, materials.GetFade(SeaWater)); + } + + private static void AddSeaDisc(Transform root, string name, float radius, float y, Material material) + { + MeshRenderer renderer = IslandObjectBuilder.AddFlatDisc(root, name, material, new Vector3(0f, y, 0f), + radius * 2f, radius * 2f); + // 바다는 섬 물건의 그림자 거리 밖이 대부분이다 — 받는 쪽도 꺼서 넓은 판의 그림자 샘플링을 아낀다. + renderer.receiveShadows = false; + } + + // 나루터는 남쪽(−Z) 가장자리 가운데. 도착 칸(IslandGrid.ArrivalPoint) 바로 남쪽에서 바다로 뻗는다. + private static void BuildPier(Transform root, float half, IslandMaterialCache materials) + { + Transform pier = new GameObject("Pier").transform; + pier.SetParent(root, false); + pier.localPosition = new Vector3(0f, 0f, -half); + + Material light = materials.Get(IslandObjectBuilder.WoodLight); + Material mid = materials.Get(IslandObjectBuilder.Wood); + Material dark = materials.Get(IslandObjectBuilder.WoodDark); + Mesh cube = IslandObjectBuilder.CubeMesh; + Mesh cylinder = IslandObjectBuilder.CylinderMesh; + + // 널판은 잔디 위로 0.1m 걸쳐 시작한다 — 딱 가장자리에서 시작하면 잔디 옆면과 널판 사이로 모래가 실금처럼 보인다. + // 두 톤을 번갈아 깔아 위에서 "판자를 이어 붙인 길"로 읽히게 한다. + float length = PierPlanks * PierPlankPitch; + for (int i = 0; i < PierPlanks; i++) + { + float z = 0.1f - PierPlankPitch * (i + 0.5f); + IslandObjectBuilder.AddPiece(pier, "Pier_Plank", cube, i % 2 == 0 ? light : mid, + new Vector3(0f, PierTopY - 0.035f, z), Vector3.zero, + new Vector3(PierWidth, 0.07f, PierPlankPitch - 0.03f)); + } + + float centerZ = 0.1f - length * 0.5f; + for (int side = -1; side <= 1; side += 2) + { + IslandObjectBuilder.NoShadow(IslandObjectBuilder.AddPiece(pier, "Pier_Beam", cube, dark, + new Vector3(side * 0.8f, PierTopY - 0.13f, centerZ), Vector3.zero, + new Vector3(0.14f, 0.12f, length))); + + // 말뚝은 모래·바닷속까지 박힌다. 맨 끝 한 쌍만 높여 배를 매는 자리로 둔다. + for (int i = 0; i < 3; i++) + { + bool mooring = i == 2; + float top = mooring ? 0.75f : 0.3f; + const float bottom = -1.3f; + IslandObjectBuilder.AddPiece(pier, "Pier_Post", cylinder, dark, + new Vector3(side * (PierWidth * 0.5f + 0.07f), (top + bottom) * 0.5f, -1.0f - i * 1.8f), + Vector3.zero, new Vector3(0.2f, (top - bottom) * 0.5f, 0.2f)); + } + } + + BuildBoat(pier, light, mid, dark); + } + + // 작은 배 — 위에서 뱃머리가 뾰족한 윤곽 + 짙은 안쪽 + 밝은 좌판 둘로 읽힌다. 뱃머리는 45° 돌린 상자의 모서리다. + private static void BuildBoat(Transform pier, Material light, Material mid, Material dark) + { + Transform boat = new GameObject("Boat").transform; + boat.SetParent(pier, false); + boat.localPosition = new Vector3(2.15f, SeaLevel + 0.1f, -3.3f); + boat.localRotation = Quaternion.Euler(0f, 8f, 0f); + + Mesh cube = IslandObjectBuilder.CubeMesh; + Vector3 diagonal = new Vector3(0f, 45f, 0f); + IslandObjectBuilder.AddPiece(boat, "Hull", cube, mid, Vector3.zero, Vector3.zero, + new Vector3(1.0f, 0.36f, 1.7f)); + IslandObjectBuilder.AddPiece(boat, "Bow", cube, mid, new Vector3(0f, 0f, -0.85f), diagonal, + new Vector3(0.707f, 0.36f, 0.707f)); + IslandObjectBuilder.NoShadow(IslandObjectBuilder.AddPiece(boat, "Inside", cube, dark, + new Vector3(0f, 0.185f, 0.02f), Vector3.zero, new Vector3(0.78f, 0.03f, 1.5f))); + IslandObjectBuilder.NoShadow(IslandObjectBuilder.AddPiece(boat, "InsideBow", cube, dark, + new Vector3(0f, 0.185f, -0.75f), diagonal, new Vector3(0.55f, 0.03f, 0.55f))); + IslandObjectBuilder.NoShadow(IslandObjectBuilder.AddPiece(boat, "SeatFront", cube, light, + new Vector3(0f, 0.23f, -0.25f), Vector3.zero, new Vector3(0.9f, 0.05f, 0.2f))); + IslandObjectBuilder.NoShadow(IslandObjectBuilder.AddPiece(boat, "SeatBack", cube, light, + new Vector3(0f, 0.23f, 0.45f), Vector3.zero, new Vector3(0.9f, 0.05f, 0.2f))); + } + + // 먼바다의 바위섬 — 수평선이 비어 보이지 않게. 위치는 섬 크기와 무관하게 고정이다(가장 큰 섬의 얕은 바다 밖). + private static void BuildIslets(Transform root, IslandMaterialCache materials) + { + AddIslet(root, new Vector3(-40f, 0f, 52f), 1.0f, 20f, materials); + AddIslet(root, new Vector3(56f, 0f, 38f), 0.7f, -35f, materials); + AddIslet(root, new Vector3(34f, 0f, -52f), 1.25f, 70f, materials); + } + + private static void AddIslet(Transform root, Vector3 position, float scale, float yaw, + IslandMaterialCache materials) + { + Transform islet = new GameObject("Islet").transform; + islet.SetParent(root, false); + islet.localPosition = position; + islet.localRotation = Quaternion.Euler(0f, yaw, 0f); + islet.localScale = Vector3.one * scale; + + Mesh sphere = IslandObjectBuilder.SphereMesh; + IslandObjectBuilder.NoShadow(IslandObjectBuilder.AddPiece(islet, "Rock", sphere, + materials.Get(IslandObjectBuilder.Stone), new Vector3(0f, -0.4f, 0f), Vector3.zero, + new Vector3(11f, 6f, 8.5f))); + IslandObjectBuilder.NoShadow(IslandObjectBuilder.AddPiece(islet, "RockSide", sphere, + materials.Get(IslandObjectBuilder.StoneDark), new Vector3(4.2f, -0.6f, 2.2f), Vector3.zero, + new Vector3(6f, 3.6f, 5f))); + IslandObjectBuilder.NoShadow(IslandObjectBuilder.AddPiece(islet, "Green", sphere, + materials.Get(IslandObjectBuilder.Leaf), new Vector3(-0.6f, 2.25f, -0.2f), Vector3.zero, + new Vector3(5.5f, 1.0f, 4.4f))); + } + } +} diff --git a/Assets/Scripts/Core/IslandTerrainBuilder.cs.meta b/Assets/Scripts/Core/IslandTerrainBuilder.cs.meta new file mode 100644 index 0000000..47a07f1 --- /dev/null +++ b/Assets/Scripts/Core/IslandTerrainBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0b48f62c39580de42803413cd508ad0f \ No newline at end of file diff --git a/Assets/Scripts/Core/IslandWorldBuilder.Guests.cs b/Assets/Scripts/Core/IslandWorldBuilder.Guests.cs new file mode 100644 index 0000000..52af776 --- /dev/null +++ b/Assets/Scripts/Core/IslandWorldBuilder.Guests.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using InsectGame.Data; +using InsectGame.Spawning; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 나의 섬 — 방목 곤충의 시간·날씨 기분과 손님 곤충(규칙은 , 문서는 rules/island.md 「손님 곤충」). + /// + /// 손님은 진짜 야생 개체다 — InsectEntity.Initialize(야생 경로)로 세우므로 잡기 버튼·포획 선택 창·미니게임·전투·포획 보상· + /// 도감·퀘스트 통지가 필드와 같은 길을 탄다. 다른 점은 셋뿐이다: ①도주가 섬의 빈 칸을 벗어나지 않는다(SetFleeArea) + /// ②기록(칸)이 섬 밖으로 나가도 남는다(돌아오면 같은 손님 — 섬을 드나들어 다시 굴리지 못한다) ③세션 간에는 저장하지 않는다. + /// + /// 손님 몸은 섬 root 밖(이 컴포넌트 아래)의 작은 풀에 둔다 — 섬을 다시 지을 때(DestroyWorld) 같이 부서지면 붙잡혀 있던 + /// 전투·포획 쪽 참조가 끊긴다. 섬을 허물 때는 몸만 거두고(Recall — 게임플레이 퇴장이 아니다) 기록은 남긴다. + /// + public partial class IslandWorldBuilder + { + [Header("Guests & Mood")] + [SerializeField] private InsectSpawner insectSpawner; + [SerializeField] private WorldStateProvider worldStateProvider; + + /// 손님 한 칸 — 기록이 곧 개체이고 몸은 섬에 있을 때만 빌려 쓴다(필드 슬롯과 같은 원칙). + private sealed class GuestSlot + { + public bool Alive; + public InsectData Data; + public int Level; + public bool Shiny; + /// 비어 있을 때 — 다음 손님이 올 수 있는 시각. + public float ReadyAt; + /// 와 있을 때 — 수명이 다하는 시각. + public float ExpiresAt; + public InsectEntity Body; + public Vector2Int Cell; + } + + private readonly GuestSlot[] guestSlots = CreateGuestSlots(); + private readonly List guestTablesAll = new List(); + private readonly List guestTables = new List(); + private readonly List guestCandidates = new List(); + private readonly List guestScratch = new List(); + private readonly List guestScratchMultipliers = new List(); + private readonly bool[] guestRarityAvailable = new bool[FieldSpawnRules.RarityCount]; + private readonly List guestBodyPool = new List(); + private readonly HashSet guestTakenCells = new HashSet(); + private Transform guestBodyHolder; + private float guestNextTick; + private int guestLastWanted = -1; + private Func guestAccessibleProbe; + private Func guestCellInView; + private Func guestFleeArea; + private Action guestDespawned; + private Camera guestCamera; + + // 방목 곤충 기분 — 섬은 지역이 없어 세계 날씨다. 시간대·날씨가 바뀔 때만 곤충들에게 알린다. + private const float MoodCheckSeconds = 1f; + private float moodNextCheck; + private bool moodStateKnown; + private WorldState moodState; + + private static GuestSlot[] CreateGuestSlots() + { + var slots = new GuestSlot[IslandGuestRules.MaxGuests]; + for (int i = 0; i < slots.Length; i++) slots[i] = new GuestSlot(); + return slots; + } + + /// + /// 손님·기분이 읽는 것 — 필드 리전 표(풀·레벨 대역, )와 세계 시간·날씨. + /// 빠지면 손님이 오지 않고 방목 곤충은 예전처럼(시간·날씨 없이) 돌아다닌다. + /// + public void AutoWire(InsectSpawner spawner, WorldStateProvider worldState) + { + if (insectSpawner == null) insectSpawner = spawner; + if (worldStateProvider == null) worldStateProvider = worldState; + } + + /// 지금 섬에 몸이 선 손님 수(검수·테스트용). + public int ActiveGuestCount + { + get + { + int n = 0; + for (int i = 0; i < guestSlots.Length; i++) + if (guestSlots[i].Alive && guestSlots[i].Body != null) n++; + return n; + } + } + + // ── 방목 곤충의 기분 ── + + private void TickWorldMood() + { + if (worldStateProvider == null || Time.time < moodNextCheck) return; + moodNextCheck = Time.time + MoodCheckSeconds; + WorldState state = worldStateProvider.GetWorldState(null); + if (moodStateKnown && state.DayPhase == moodState.DayPhase && state.Weather == moodState.Weather) return; + moodStateKnown = true; + moodState = state; + if (insectsRoot == null) return; + for (int i = 0; i < insectsRoot.transform.childCount; i++) + { + IslandInsectWalker walker = insectsRoot.transform.GetChild(i).GetComponent(); + if (walker != null) walker.ApplyWorld(state); + } + } + + // ── 손님 곤충 ── + + /// + /// 섬에 막 들어섰다 — 떠나 있는 동안 조건이 끝났거나 수명이 다한 손님은 이미 떠난 것으로 친다(아무도 못 봤다). + /// 들어서는 순간엔 도착을 흩지 않는다 — 밤에 섬에 오면 손님이 이미 와 있다. + /// + private void OnIslandEnteredForGuests() + { + guestNextTick = 0f; + if (mode != IslandMode.Own || DreamMode || worldStateProvider == null) return; + float now = Time.time; + int wanted = IslandGuestRules.WantedGuests(worldStateProvider.GetWorldState(null)); + guestLastWanted = wanted; + int alive = 0; + for (int i = 0; i < guestSlots.Length; i++) + { + GuestSlot slot = guestSlots[i]; + if (!slot.Alive) continue; + if (alive >= wanted || now >= slot.ExpiresAt) + { + slot.Alive = false; + slot.Data = null; + slot.ReadyAt = now; + continue; + } + alive++; + } + } + + private void TickGuests() + { + if (Time.time < guestNextTick) return; + guestNextTick = Time.time + IslandGuestRules.TickSeconds; + // 남의 섬·꿈속 섬에서는 내 섬의 손님 기록을 건드리지 않는다(세우지도, 떠나보내지도). + if (mode != IslandMode.Own || DreamMode || worldStateProvider == null || root == null) return; + + float now = Time.time; + WorldState state = worldStateProvider.GetWorldState(null); + int wanted = IslandGuestRules.WantedGuests(state); + + // 조건이 막 시작됐다(밤이 왔다·비가 온다) — 비어 있던 칸의 도착을 5~60초에 흩는다. 한꺼번에 툭 나타나지 않게. + if (guestLastWanted >= 0 && wanted > guestLastWanted) + { + for (int i = 0; i < guestSlots.Length; i++) + if (!guestSlots[i].Alive) + guestSlots[i].ReadyAt = Mathf.Max(guestSlots[i].ReadyAt, + now + FieldSpawnRules.RollPhaseSwapDelay(UnityEngine.Random.value)); + } + guestLastWanted = wanted; + + // 떠나기 — 조건이 끝나 남거나 수명이 다한 손님은, 붙잡히지·놀라지 않았고 화면 밖일 때 조용히 떠난다. + int alive = CountAliveGuests(); + for (int i = 0; i < guestSlots.Length; i++) + { + GuestSlot slot = guestSlots[i]; + if (!slot.Alive) continue; + if (!IslandGuestRules.ShouldLeave(alive > wanted, now >= slot.ExpiresAt)) continue; + if (slot.Body != null) + { + bool busy = slot.Body.IsEngaged || slot.Body.IsAlerted; + if (!IslandGuestRules.CanLeaveNow(busy, IsWorldPointInView(slot.Body.transform.position))) continue; + RecallGuestBody(slot); + } + slot.Alive = false; + slot.Data = null; + slot.ReadyAt = now + FieldSpawnRules.RollRespawnDelay(UnityEngine.Random.value); + alive--; + } + + // 들어서기 — 꾸미기 중에는 새로 들이지 않는다(꾸미기의 지면 탭과 곤충 탭이 겹치지 않게). + if (!IslandGuestRules.CanHostGuests(mode, DreamMode, editCameraActive)) return; + for (int i = 0; i < guestSlots.Length && alive < wanted; i++) + { + GuestSlot slot = guestSlots[i]; + if (slot.Alive || now < slot.ReadyAt) continue; + if (!RollGuest(slot, state)) + { + slot.ReadyAt = now + FieldSpawnRules.RetrySeconds; + continue; + } + slot.Alive = true; + slot.ExpiresAt = now + FieldSpawnRules.RollLifetime(UnityEngine.Random.value); + alive++; + } + + for (int i = 0; i < guestSlots.Length; i++) + if (guestSlots[i].Alive && guestSlots[i].Body == null) MaterializeGuest(guestSlots[i]); + } + + private int CountAliveGuests() + { + int n = 0; + for (int i = 0; i < guestSlots.Length; i++) if (guestSlots[i].Alive) n++; + return n; + } + + /// 새 손님의 종·레벨·색다름을 굴린다 — 해금된 리전 풀만 본다. 표가 없거나 후보가 비면 false(잠시 뒤 다시). + private bool RollGuest(GuestSlot slot, WorldState state) + { + if (insectSpawner == null || regionManager == null) return false; + insectSpawner.CopyFieldRegionTables(guestTablesAll); + if (guestAccessibleProbe == null) guestAccessibleProbe = IsRegionIdAccessible; + IslandGuestRules.FilterAccessible(guestTablesAll, guestAccessibleProbe, guestTables); + if (guestTables.Count == 0) return false; + + IslandGuestRules.CollectCandidates(guestTables, state, guestCandidates); + InsectData data = IslandGuestRules.PickSpecies(guestCandidates, state, UnityEngine.Random.value, + UnityEngine.Random.value, guestScratch, guestScratchMultipliers, guestRarityAvailable); + if (data == null || !IslandGuestRules.TryLevelBand(data, guestTables, out int minLevel, out int maxLevel)) + return false; + + slot.Data = data; + slot.Level = FieldSpawnRules.RollFieldLevel(minLevel, maxLevel, UnityEngine.Random.value); + // 색다름은 필드와 같은 확률로 기록에 한 번 굴린다(몸을 다시 세울 때마다 굴리지 않는다). 「지워진 개체」는 섬에 오지 않는다. + slot.Shiny = UnityEngine.Random.value < InsectEntity.FieldShinyChance; + return true; + } + + private bool IsRegionIdAccessible(string regionId) + { + if (regionManager == null || string.IsNullOrEmpty(regionId)) return false; + RegionData region = regionManager.GetRegionById(regionId); + return region != null && regionManager.IsRegionAccessible(region); + } + + /// 손님 몸을 세운다 — 화면 밖 빈 칸(다 보이면 플레이어에서 가장 먼 칸)에, 야생 경로(Initialize)로. + private void MaterializeGuest(GuestSlot slot) + { + if (root == null || slot.Data == null || freeCells.Count == 0 || playerMovement == null) return; + + guestTakenCells.Clear(); + for (int i = 0; i < guestSlots.Length; i++) + if (guestSlots[i] != slot && guestSlots[i].Body != null) guestTakenCells.Add(guestSlots[i].Cell); + IslandGrid.CellAt(playerMovement.transform.position - Origin, out int px, out int pz); + if (guestCellInView == null) guestCellInView = IsCellInView; + if (!IslandGuestRules.PickArrivalCell(freeCells, guestCellInView, new Vector2Int(px, pz), guestTakenCells, + UnityEngine.Random.value, out Vector2Int cell)) + return; + + GameObject go = TakeGuestBody(); + // Initialize 전이어야 한다 — 지금 자리가 배회·풀 더미의 기준(basePosition)이 된다. 섬 땅 윗면은 원점 높이다. + go.transform.SetPositionAndRotation(Origin + IslandGrid.FootprintCenter(cell.x, cell.y, 1, 1), + Quaternion.Euler(0f, UnityEngine.Random.Range(0f, 360f), 0f)); + go.SetActive(true); + InsectEntity entity = go.GetComponent(); + if (entity == null) entity = go.AddComponent(); + if (guestDespawned == null) guestDespawned = OnGuestDespawned; + entity.Initialize(slot.Data, slot.Level, string.Empty, guestDespawned, slot.Shiny, false); + if (guestFleeArea == null) guestFleeArea = GuestFreeRun; + entity.SetFleeArea(guestFleeArea); + + slot.Body = entity; + slot.Cell = cell; + } + + /// + /// 게임플레이 퇴장(잡힘·전투 승패·놓침 도주) — 손님은 떠났고 다음 손님은 재생 지연(필드와 같은 60~120초) 뒤다. + /// 놓침도 같다 — 필드의 "같은 개체를 눈 밖 다른 자리로"는 섬이 작아 옮길 눈 밖이 없어서 사라지는 쪽을 택했다. + /// + private void OnGuestDespawned(InsectEntity entity) + { + if (ReferenceEquals(entity, null)) return; + for (int i = 0; i < guestSlots.Length; i++) + { + GuestSlot slot = guestSlots[i]; + if (!ReferenceEquals(slot.Body, entity)) continue; + slot.Body = null; + slot.Alive = false; + slot.Data = null; + slot.ReadyAt = Time.time + FieldSpawnRules.RollRespawnDelay(UnityEngine.Random.value); + break; + } + if (entity != null) ReturnGuestBody(entity.gameObject); + } + + /// 배치가 바뀌었다 — 손님이 선 칸이 막혔으면 그 몸만 거둔다(기록은 남아 다음 틱에 다른 빈 칸에 선다). + private void RevalidateGuestCells() + { + for (int i = 0; i < guestSlots.Length; i++) + { + GuestSlot slot = guestSlots[i]; + if (slot.Body == null || freeCellSet.Contains(slot.Cell)) continue; + if (slot.Body.IsEngaged) continue; // 포획·전투 중인 몸은 건드리지 않는다 + RecallGuestBody(slot); + } + } + + /// 섬을 허문다(나가기·다시 짓기·남의 섬으로) — 몸만 거둔다. 게임플레이 퇴장이 아니라 콜백을 부르지 않는다. + private void RecallAllGuestBodies() + { + for (int i = 0; i < guestSlots.Length; i++) + if (guestSlots[i].Body != null) RecallGuestBody(guestSlots[i]); + } + + private void RecallGuestBody(GuestSlot slot) + { + InsectEntity body = slot.Body; + slot.Body = null; + if (body == null) return; + body.Recall(); // 다중 호출 가드를 건다 — 이 몸을 쥔 쪽이 뒤늦게 Despawn을 불러도 콜백이 돌지 않는다 + ReturnGuestBody(body.gameObject); + } + + private GameObject TakeGuestBody() + { + for (int i = guestBodyPool.Count - 1; i >= 0; i--) + { + GameObject pooled = guestBodyPool[i]; + guestBodyPool.RemoveAt(i); + if (pooled != null) return pooled; + } + if (guestBodyHolder == null) + { + // 이름 접두어 주의 — Region_/Scenery_/SubArea_ 등은 서브에리어 진입 때 꺼진다(rules/island.md). + var holder = new GameObject("IslandGuestBodies"); + holder.transform.SetParent(transform, false); + guestBodyHolder = holder.transform; + } + var go = new GameObject("IslandGuest"); + go.transform.SetParent(guestBodyHolder, false); + return go; + } + + private void ReturnGuestBody(GameObject go) + { + if (go == null) return; + go.SetActive(false); + if (!guestBodyPool.Contains(go)) guestBodyPool.Add(go); + } + + /// 손님 도주 구역 — 섬의 빈 칸만 밟고 갈 수 있는 거리(). + private float GuestFreeRun(Vector3 worldStart, Vector3 direction) + => IslandGuestRules.FreeRun(worldStart - Origin, direction, freeCellSet, FleePath.ProbeLength); + + private bool IsCellInView(Vector2Int cell) + => IsWorldPointInView(Origin + IslandGrid.FootprintCenter(cell.x, cell.y, 1, 1)); + + /// 그 자리(몸 높이)가 지금 카메라 화면 안인가. 카메라가 없으면 아무도 못 본다. + private bool IsWorldPointInView(Vector3 world) + { + if (guestCamera == null) guestCamera = Camera.main; + if (guestCamera == null) return false; + return IslandGuestRules.IsInView(guestCamera.WorldToViewportPoint(world + Vector3.up * 0.4f)); + } + } +} diff --git a/Assets/Scripts/Core/IslandWorldBuilder.Guests.cs.meta b/Assets/Scripts/Core/IslandWorldBuilder.Guests.cs.meta new file mode 100644 index 0000000..c8a7010 --- /dev/null +++ b/Assets/Scripts/Core/IslandWorldBuilder.Guests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dfe848fbe085a55489c88d3e3fc04ecf \ No newline at end of file diff --git a/Assets/Scripts/Core/IslandWorldBuilder.cs b/Assets/Scripts/Core/IslandWorldBuilder.cs new file mode 100644 index 0000000..8ec0dc0 --- /dev/null +++ b/Assets/Scripts/Core/IslandWorldBuilder.cs @@ -0,0 +1,813 @@ +using System; +using System.Collections.Generic; +using InsectGame.Data; +using InsectGame.Spawning; +using UnityEngine; + +namespace InsectGame.Core +{ + public enum IslandMode + { + None, + /// 내 섬 — 꾸미기·수확이 된다. + Own, + /// 남의 섬 구경 — 저장된 모습을 걸어다니며 볼 뿐이다. + Visit, + } + + /// + /// 섬을 월드에 세우고 드나듦을 맡는다. 섬은 메인 월드와 떨어진 좌표에 짓고, 리전 상태는 합성 서브에리어 + /// ()로 탄다 — 이유는 . + /// + /// 모양은 ·가 짓고(콜라이더 없음), + /// 여기는 밟는 땅·경계 벽·물건 차단 콜라이더와 방목 곤충, 꾸미기 화면이 쓰는 미리보기를 맡는다. + /// + public partial class IslandWorldBuilder : MonoBehaviour + { + /// + /// 섬 중심(월드). 서브에리어 방(2000,·,2000)·배틀 아레나(1000,·,1000)·프리뷰 리그(·,-5000,·)와 겹치지 않고, + /// 메인 월드(±540)에서 카메라 원거리 절단면 밖이라 서로 보이지 않는다. + /// + public static readonly Vector3 Origin = new Vector3(3000f, 0f, 3000f); + + /// 꾸미기 화면의 카메라 오프셋 — 평소(0,9,-6)보다 높고 멀다. 섬을 넓게 봐야 놓을 자리가 보인다. + private static readonly Vector3 EditCameraOffset = new Vector3(0f, 14f, -8.5f); + + // 필드 곤충은 등급에 따라 1.0~1.9배로 커지지만 섬은 가구 사이를 다니는 곳이라 전부 1배로 둔다 — + // 키우면 전설 곤충이 벤치보다 커져 물건을 뚫고 다니는 것처럼 보인다. + private const float InsectScale = 1f; + private const float WallHeight = 4f; + + [SerializeField] private RegionManager regionManager; + [SerializeField] private IslandManager island; + [SerializeField] private CameraFollower cameraFollower; + [SerializeField] private PlayerMovement playerMovement; + [SerializeField] private InsectDatabase database; + + private readonly SubAreaData islandArea = new SubAreaData + { + subAreaId = GameConstants.Island.SubAreaId, + displayName = "나의 섬", + description = "나만의 섬", + detached = true, + environmentType = "island", + radius = 40f, + exclusiveInsectIds = new string[0], // 야생 스폰 없음 — 스포너가 몸을 거두고 아무것도 세우지 않는다 + }; + + private IslandMode mode = IslandMode.None; + private IslandSnapshot visitSnapshot; + private Vector3 savedPlayerPos; + private Quaternion savedPlayerRot; + + private GameObject root; + private GameObject terrainRoot; + private GameObject objectsRoot; + private GameObject insectsRoot; + private GameObject gridRoot; + private readonly List objectInstances = new List(); + private IslandMaterialCache materials; + private int builtSizeLevel = -1; + + private readonly List freeCells = new List(); + private readonly HashSet freeCellSet = new HashSet(); + + // 꾸미기 미리보기 + private GameObject ghostRoot; + private GameObject ghostModel; + private GameObject ghostPad; + private string ghostId; + private int ghostRot = -1; + private Transform editFocus; + private bool editCameraActive; + private int hiddenObjectIndex = -1; + + /// 섬에 들어오거나 나갔다, 또는 내 섬 ↔ 남의 섬이 바뀌었다. + public event Action ModeChanged; + + public IslandMode Mode => mode; + + /// + /// 「챔피언의 꿈」이 섬을 쓰는 중인가. 켜면 섬 HUD가 숨고 방문 통지(퀘스트)가 가지 않는다 — + /// 꿈속의 섬은 누구의 섬도 아니고 이 계정의 어떤 기록도 바꾸지 않는다. + /// + public bool DreamMode { get; set; } + + /// 섬 격자 칸(차지 칸 기준)의 월드 중심. 프롤로그가 목표 지점을 잡는 데 쓴다. + public static Vector3 CellWorldCenter(int x, int z, int width, int depth) + { + return Origin + IslandGrid.FootprintCenter(x, z, width, depth); + } + + /// + /// 지금 섬을 돌아다니는 곤충 중 에서 가장 가까운 것. 섬에 곤충이 없으면 false. + /// 위치는 매 호출 읽는다 — 곤충은 걸어 다니므로 한 번 읽어 둔 좌표를 가리키면 엉뚱한 곳을 가리킨다. + /// + public bool TryGetNearestInsect(Vector3 from, out Vector3 position, out InsectData data, out int level) + { + position = Vector3.zero; + data = null; + level = 1; + if (insectsRoot == null) return false; + + float best = float.MaxValue; + bool found = false; + for (int i = 0; i < insectsRoot.transform.childCount; i++) + { + Transform child = insectsRoot.transform.GetChild(i); + if (child == null || !child.gameObject.activeInHierarchy) continue; + InsectEntity entity = child.GetComponent(); + if (entity == null || entity.Data == null) continue; + Vector3 delta = child.position - from; + delta.y = 0f; + float d = delta.sqrMagnitude; + if (d >= best) continue; + best = d; + position = child.position; + data = entity.Data; + level = entity.Level; + found = true; + } + return found; + } + public bool IsOnIsland => mode != IslandMode.None; + public bool IsVisiting => mode == IslandMode.Visit; + + /// 꾸미기 화면이 열려 있는가(~). 손님 곤충이 그동안 들어서지 않는다. + public bool IsEditing => editCameraActive; + public IslandSnapshot VisitSnapshot => visitSnapshot; + + public int CurrentSizeLevel => + mode == IslandMode.Visit && visitSnapshot != null ? visitSnapshot.sizeLevel + : island != null ? island.SizeLevel : 0; + + private IReadOnlyList CurrentPlaced => + mode == IslandMode.Visit && visitSnapshot != null ? visitSnapshot.placed + : island != null ? island.Placed : null; + + public void AutoWire(RegionManager regions, IslandManager islandManager, CameraFollower camera, + PlayerMovement movement, InsectDatabase insectDatabase) + { + Unsubscribe(); + if (regionManager == null) regionManager = regions; + if (island == null) island = islandManager; + if (cameraFollower == null) cameraFollower = camera; + if (playerMovement == null) playerMovement = movement; + if (database == null) database = insectDatabase; + Subscribe(); + } + + // AutoWire는 Bootstrap에서 한 번만 불린다 — OnDisable에서 해지한 구독을 여기서 되살린다. + private void OnEnable() => Subscribe(); + private void OnDisable() => Unsubscribe(); + + private void Subscribe() + { + if (regionManager != null) + { + regionManager.SubAreaChanged -= OnSubAreaChanged; + regionManager.SubAreaChanged += OnSubAreaChanged; + } + if (island != null) + { + island.LayoutChanged -= OnLayoutChanged; + island.LayoutChanged += OnLayoutChanged; + island.InsectsChanged -= OnInsectsChanged; + island.InsectsChanged += OnInsectsChanged; + } + } + + private void Unsubscribe() + { + if (regionManager != null) regionManager.SubAreaChanged -= OnSubAreaChanged; + if (island != null) + { + island.LayoutChanged -= OnLayoutChanged; + island.InsectsChanged -= OnInsectsChanged; + } + } + + private void OnDestroy() + { + DestroyWorld(); + if (dockMaterials != null) + { + dockMaterials.DestroyAll(); + dockMaterials = null; + } + } + + // ── 본 마을 나루터 ── + + /// 본 마을 광장 기준 나루터 방향·거리. 242°는 집(215°)과 뽑기 오두막(270°) 사이의 빈 자리다. + private const float DockAngleDeg = 242f; + private const float DockDistance = 15.5f; + public const string DockInteractionId = "village_island_dock"; + + private IslandMaterialCache dockMaterials; + + /// + /// 본 마을에 섬으로 가는 나루터를 세우고 상호작용 지점을 돌려준다. 탐험 메뉴의 [내 섬]과 같은 창을 연다 — + /// 메뉴를 열지 않는 사람도 마을을 걷다가 섬을 만나게 하려는 두 번째 입구다. + /// + public InteractionPointDef BuildVillageDock(RegionData[] regions) + { + RegionData meadow = null; + if (regions != null) + for (int i = 0; i < regions.Length; i++) + if (regions[i] != null && regions[i].regionId == "meadow") meadow = regions[i]; + if (meadow == null) return null; + + Vector3 village = VillageBuilder.GetMainVillageCenter(meadow.centerPosition, meadow.radius); + float rad = DockAngleDeg * Mathf.Deg2Rad; + Vector3 pos = village + new Vector3(Mathf.Cos(rad) * DockDistance, 0f, Mathf.Sin(rad) * DockDistance); + pos.y = FieldGround.SurfaceY(pos.x, pos.z); + + if (dockMaterials == null) dockMaterials = new IslandMaterialCache(); + // 이름이 `Scenery_`·`Region_` 등으로 시작하면 서브에리어 진입 때 꺼진다(SubAreaWorldBuilder.HideMainWorld). + var dock = new GameObject("IslandDock"); + dock.transform.position = pos; + Vector3 toVillage = village - pos; + toVillage.y = 0f; + if (toVillage.sqrMagnitude > 0.01f) dock.transform.rotation = Quaternion.LookRotation(toVillage.normalized); + + Color wood = new Color(0.55f, 0.38f, 0.22f); + Color woodDark = new Color(0.36f, 0.24f, 0.14f); + Color water = new Color(0.30f, 0.62f, 0.80f); + Color sand = new Color(0.90f, 0.82f, 0.60f); + Color sail = new Color(0.96f, 0.94f, 0.86f); + + // 모래 깔개 위의 작은 물웅덩이와 배 — 위에서 내려다봐도 "배 타는 곳"으로 읽히게. + DockPart(dock.transform, PrimitiveType.Cylinder, new Vector3(0f, 0.02f, -1.6f), Vector3.zero, + new Vector3(5.2f, 0.02f, 5.2f), sand); + DockPart(dock.transform, PrimitiveType.Cylinder, new Vector3(0f, 0.05f, -2.0f), Vector3.zero, + new Vector3(3.6f, 0.02f, 3.6f), water); + DockPart(dock.transform, PrimitiveType.Cube, new Vector3(0f, 0.12f, -0.4f), Vector3.zero, + new Vector3(1.4f, 0.1f, 2.6f), wood); + DockPart(dock.transform, PrimitiveType.Cube, new Vector3(0.9f, 0.3f, -2.2f), new Vector3(0f, 25f, 0f), + new Vector3(0.9f, 0.4f, 2.0f), woodDark); + DockPart(dock.transform, PrimitiveType.Cube, new Vector3(0.9f, 1.0f, -2.2f), Vector3.zero, + new Vector3(0.08f, 1.4f, 0.08f), woodDark); + DockPart(dock.transform, PrimitiveType.Cube, new Vector3(1.15f, 1.15f, -2.2f), new Vector3(0f, 25f, 0f), + new Vector3(0.04f, 0.9f, 0.8f), sail); + + // 팻말 + DockPart(dock.transform, PrimitiveType.Cube, new Vector3(-1.2f, 0.9f, 0.6f), Vector3.zero, + new Vector3(0.14f, 1.8f, 0.14f), woodDark); + DockPart(dock.transform, PrimitiveType.Cube, new Vector3(-1.2f, 1.9f, 0.6f), Vector3.zero, + new Vector3(1.9f, 0.8f, 0.12f), wood); + var textObj = new GameObject("DockSignText"); + textObj.transform.SetParent(dock.transform, false); + textObj.transform.localPosition = new Vector3(-1.2f, 1.9f, 0.68f); + textObj.transform.localRotation = Quaternion.Euler(0f, 180f, 0f); + TextMesh text = textObj.AddComponent(); + text.text = "내 섬"; + text.characterSize = 0.14f; + text.fontSize = 48; + text.anchor = TextAnchor.MiddleCenter; + text.alignment = TextAlignment.Center; + text.color = new Color(1f, 0.95f, 0.80f); + + return new InteractionPointDef + { + id = DockInteractionId, + worldPosition = pos + dock.transform.forward * 1.2f, + radius = 3f, + label = "내 섬 나루터", + kind = InteractionKind.IslandDock, + }; + } + + private void DockPart(Transform parent, PrimitiveType type, Vector3 localPos, Vector3 localEuler, + Vector3 localScale, Color color) + { + GameObject part = GameObject.CreatePrimitive(type); + part.name = "DockPart"; + // 장식에 콜라이더를 남기지 않는다 — 걸어 지나갈 수 있어야 하고, 클릭-이동 레이가 걸리면 안 된다. + Collider col = part.GetComponent(); + if (col != null) Destroy(col); + part.transform.SetParent(parent, false); + part.transform.localPosition = localPos; + part.transform.localEulerAngles = localEuler; + part.transform.localScale = localScale; + part.GetComponent().sharedMaterial = dockMaterials.Get(color); + } + + // ── 드나들기 ── + + /// 지금 섬으로 떠날 수 있는가. 못 가면 이유 문구. + public bool CanTravel(out string reason) + { + reason = null; + if (regionManager == null || playerMovement == null || island == null) + { + reason = "섬을 불러오지 못했습니다."; + return false; + } + if (mode == IslandMode.None && regionManager.CurrentSubArea != null) + { + reason = "서브지역 안에서는 떠날 수 없습니다. 먼저 밖으로 나가 주세요."; + return false; + } + return true; + } + + public bool EnterOwnIsland() + { + if (!CanTravel(out _)) return false; + if (mode == IslandMode.Own) return true; + visitSnapshot = null; + return EnterInternal(IslandMode.Own); + } + + /// 남의 섬 구경. 은 받아서 이미 정리한 것(). + public bool EnterVisit(IslandSnapshot snapshot) + { + if (snapshot == null || !CanTravel(out _)) return false; + visitSnapshot = snapshot; + return EnterInternal(IslandMode.Visit); + } + + private bool EnterInternal(IslandMode next) + { + EndEditCamera(); + bool wasOnIsland = mode != IslandMode.None; + if (!wasOnIsland) + { + savedPlayerPos = playerMovement.transform.position; + savedPlayerRot = playerMovement.transform.rotation; + } + + mode = next; + // 끼임 복구(PlayerMovement.RecoverToSafePosition)가 이 자리로 보낸다 — 물건을 놓을 수 없게 보호된 칸이다. + islandArea.centerPosition = Origin + IslandGrid.ArrivalPoint(CurrentSizeLevel); + islandArea.displayName = next == IslandMode.Visit && visitSnapshot != null + ? visitSnapshot.ownerName + "의 섬" + : "나의 섬"; + + if (!wasOnIsland && !regionManager.EnterDetachedSubArea(islandArea)) + { + mode = IslandMode.None; + visitSnapshot = null; + return false; + } + + BuildWorld(); + TeleportPlayer(islandArea.centerPosition + Vector3.up * 0.5f, Quaternion.identity); + if (cameraFollower != null) + { + cameraFollower.SetSubAreaMode(true); + cameraFollower.SnapToTarget(); + } + + if (mode == IslandMode.Own) island.NotifyEnteredOwnIsland(); + else if (!DreamMode) island.NotifyVisitedFriendIsland(); + OnIslandEnteredForGuests(); // 손님 기록 정리(떠나 있는 동안 끝난 손님은 이미 떠났다) — IslandWorldBuilder.Guests.cs + + ModeChanged?.Invoke(); + return true; + } + + /// 남의 섬에서 내 섬으로 돌아온다(메인 월드로 나가지 않고). + public bool ReturnToOwnIsland() + { + if (mode != IslandMode.Visit) return false; + visitSnapshot = null; + return EnterInternal(IslandMode.Own); + } + + /// 메인 월드로 나간다. + public void ExitIsland() + { + if (mode == IslandMode.None || regionManager == null) return; + // 정리는 SubAreaChanged(null)을 받는 쪽에서 한다 — 다른 경로로 강제 종료돼도 같은 정리가 돈다. + regionManager.ForceExitSubArea(); + } + + private void OnSubAreaChanged(SubAreaData area) + { + if (area != null || mode == IslandMode.None) return; + + EndEditCamera(); + mode = IslandMode.None; + visitSnapshot = null; + DestroyWorld(); + TeleportPlayer(SnapToGround(savedPlayerPos), savedPlayerRot); + if (cameraFollower != null) + { + cameraFollower.SetSubAreaMode(false); + cameraFollower.SnapToTarget(); + } + ModeChanged?.Invoke(); + } + + private void TeleportPlayer(Vector3 position, Quaternion rotation) + { + if (playerMovement == null) return; + Physics.SyncTransforms(); + playerMovement.transform.SetPositionAndRotation(position, rotation); + playerMovement.StopNavigation(); + } + + private static Vector3 SnapToGround(Vector3 pos) + { + if (Physics.Raycast(new Vector3(pos.x, pos.y + 50f, pos.z), Vector3.down, out RaycastHit hit, 100f, + Physics.DefaultRaycastLayers, QueryTriggerInteraction.Ignore)) + pos.y = hit.point.y + 0.5f; + return pos; + } + + private void Update() + { + if (mode == IslandMode.None || playerMovement == null) return; + + // 경계 벽이 있어 여기 올 일은 없어야 한다. 그래도 바닥 밖으로 빠지면 도착 자리로 되돌린다 + // (서브에리어 방의 낙하 안전망은 SubAreaWorldBuilder 것이라 섬에서는 돌지 않는다). + Vector3 p = playerMovement.transform.position; + float limit = IslandGrid.HalfExtent(CurrentSizeLevel) + 3f; + if (p.y < Origin.y - 3f || Mathf.Abs(p.x - Origin.x) > limit || Mathf.Abs(p.z - Origin.z) > limit) + TeleportPlayer(islandArea.centerPosition + Vector3.up * 0.5f, Quaternion.identity); + + TickWorldMood(); // 방목 곤충의 시간·날씨 기분 + TickGuests(); // 손님 곤충 — IslandWorldBuilder.Guests.cs + } + + // ── 월드 짓기 ── + + private void BuildWorld() + { + DestroyWorld(); + materials = new IslandMaterialCache(); + root = new GameObject("PlayerIsland"); + root.transform.position = Origin; + + builtSizeLevel = CurrentSizeLevel; + terrainRoot = IslandTerrainBuilder.Build(builtSizeLevel, root.transform, materials); + BuildColliders(builtSizeLevel); + BuildGridOverlay(builtSizeLevel); + + objectsRoot = new GameObject("IslandObjects"); + objectsRoot.transform.SetParent(root.transform, false); + insectsRoot = new GameObject("IslandInsects"); + insectsRoot.transform.SetParent(root.transform, false); + + RebuildObjects(); + RebuildInsects(); + + SetLayerRecursively(root, SubAreaWorldBuilder.GetSubAreaEnvLayer()); + Physics.SyncTransforms(); + } + + private void DestroyWorld() + { + RecallAllGuestBodies(); // 손님 몸은 root 밖(풀)에 있다 — 섬을 허물 때 같이 거둔다. 기록은 남는다. + HideGhost(); + objectInstances.Clear(); + hiddenObjectIndex = -1; + if (root != null) + { + Destroy(root); + root = null; + } + terrainRoot = objectsRoot = insectsRoot = gridRoot = null; + if (materials != null) + { + materials.DestroyAll(); + materials = null; + } + builtSizeLevel = -1; + } + + // 밟는 땅과 경계. 모양 쪽(IslandTerrainBuilder)은 콜라이더를 남기지 않는다 — 플레이어 접지와 클릭-이동이 + // 콜라이더를 보므로, 장식에 콜라이더가 있으면 모래톱이나 바다로 걸어 나간다. + private void BuildColliders(int sizeLevel) + { + float half = IslandGrid.HalfExtent(sizeLevel); + var colliders = new GameObject("IslandColliders"); + colliders.transform.SetParent(root.transform, false); + + AddBox(colliders.transform, "IslandGroundCollider", + new Vector3(0f, -0.5f, 0f), new Vector3(half * 2f + 2f, 1f, half * 2f + 2f)); + + const float t = 1f; + AddBox(colliders.transform, "IslandWall_N", new Vector3(0f, WallHeight * 0.5f, half + t * 0.5f), + new Vector3(half * 2f + t * 2f, WallHeight, t)); + AddBox(colliders.transform, "IslandWall_S", new Vector3(0f, WallHeight * 0.5f, -half - t * 0.5f), + new Vector3(half * 2f + t * 2f, WallHeight, t)); + AddBox(colliders.transform, "IslandWall_E", new Vector3(half + t * 0.5f, WallHeight * 0.5f, 0f), + new Vector3(t, WallHeight, half * 2f + t * 2f)); + AddBox(colliders.transform, "IslandWall_W", new Vector3(-half - t * 0.5f, WallHeight * 0.5f, 0f), + new Vector3(t, WallHeight, half * 2f + t * 2f)); + } + + private static void AddBox(Transform parent, string name, Vector3 localCenter, Vector3 size) + { + var go = new GameObject(name); + go.transform.SetParent(parent, false); + go.transform.localPosition = localCenter; + go.AddComponent().size = size; + } + + // 꾸미기 화면에서만 켜는 격자선. 선은 얇은 Quad이고 칸 수 + 1 줄씩이라 가장 큰 섬(22칸)도 46개다. + private void BuildGridOverlay(int sizeLevel) + { + gridRoot = new GameObject("IslandGridOverlay"); + gridRoot.transform.SetParent(root.transform, false); + int size = IslandGrid.GridSize(sizeLevel); + int half = size / 2; + float cs = GameConstants.Island.CellSize; + float extent = size * cs; + Material lineMat = materials.GetFade(new Color(1f, 1f, 1f, 0.28f)); + for (int i = -half; i <= half; i++) + { + AddFlatQuad(gridRoot.transform, "GridLineX", new Vector3(i * cs, 0.02f, 0f), + new Vector3(0.05f, extent, 1f), lineMat); + AddFlatQuad(gridRoot.transform, "GridLineZ", new Vector3(0f, 0.02f, i * cs), + new Vector3(extent, 0.05f, 1f), lineMat); + } + gridRoot.SetActive(false); + } + + private static GameObject AddFlatQuad(Transform parent, string name, Vector3 localPos, Vector3 scale, + Material material) + { + GameObject quad = GameObject.CreatePrimitive(PrimitiveType.Quad); + quad.name = name; + Collider col = quad.GetComponent(); + if (col != null) Destroy(col); + quad.transform.SetParent(parent, false); + quad.transform.localPosition = localPos; + quad.transform.localRotation = Quaternion.Euler(90f, 0f, 0f); + quad.transform.localScale = scale; + MeshRenderer renderer = quad.GetComponent(); + renderer.sharedMaterial = material; + renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + renderer.receiveShadows = false; + return quad; + } + + private void OnLayoutChanged() + { + if (mode != IslandMode.Own || root == null) return; + // 섬 크기가 바뀌면 땅·벽·격자까지 다시 지어야 한다. + if (island.SizeLevel != builtSizeLevel) + { + bool gridWasOn = gridRoot != null && gridRoot.activeSelf; + BuildWorld(); + islandArea.centerPosition = Origin + IslandGrid.ArrivalPoint(CurrentSizeLevel); + TeleportPlayer(islandArea.centerPosition + Vector3.up * 0.5f, Quaternion.identity); + if (cameraFollower != null) cameraFollower.SnapToTarget(); + SetGridVisible(gridWasOn); + return; + } + RebuildObjects(); + RebuildInsects(); // 빈 칸이 달라졌다 — 곤충이 새 건물을 뚫고 다니지 않게 + RevalidateGuestCells(); // 손님이 선 칸에 물건이 놓였다 — 그 몸은 거두고 다른 빈 칸에 다시 선다 + SetLayerRecursively(root, SubAreaWorldBuilder.GetSubAreaEnvLayer()); + Physics.SyncTransforms(); + } + + private void OnInsectsChanged() + { + if (mode != IslandMode.Own || root == null) return; + RebuildInsects(); + SetLayerRecursively(insectsRoot, SubAreaWorldBuilder.GetSubAreaEnvLayer()); + } + + private void RebuildObjects() + { + for (int i = objectsRoot.transform.childCount - 1; i >= 0; i--) + Destroy(objectsRoot.transform.GetChild(i).gameObject); + objectInstances.Clear(); + hiddenObjectIndex = -1; + + IReadOnlyList placed = CurrentPlaced; + if (placed == null) return; + for (int i = 0; i < placed.Count; i++) + { + IslandPlacedRecord p = placed[i]; + IslandObjectDef def = p != null ? IslandCatalog.Get(p.id) : null; + // 카탈로그가 모르는 물건(더 새 버전에서 산 것)은 그리지 않는다. 인덱스는 맞춰 둔다. + if (def == null) + { + objectInstances.Add(null); + continue; + } + objectInstances.Add(BuildPlaced(def, p)); + } + } + + private GameObject BuildPlaced(IslandObjectDef def, IslandPlacedRecord p) + { + IslandGrid.Footprint(def, p.rot, out int w, out int d); + GameObject holder = new GameObject("Placed_" + def.id); + holder.transform.SetParent(objectsRoot.transform, false); + holder.transform.localPosition = IslandGrid.FootprintCenter(p.x, p.z, w, d); + + GameObject model = IslandObjectBuilder.Build(def, holder.transform, materials); + if (model != null) model.transform.localRotation = Quaternion.Euler(0f, p.rot * 90f, 0f); + + if (def.blocksMovement) + { + float cs = GameConstants.Island.CellSize; + // 차지 칸보다 조금 작게 — 딱 맞추면 붙여 놓은 두 물건 사이의 한 칸 길이 막힌다. + BoxCollider blocker = holder.AddComponent(); + blocker.size = new Vector3(w * cs - 0.3f, 2f, d * cs - 0.3f); + blocker.center = new Vector3(0f, 1f, 0f); + } + return holder; + } + + private void RebuildInsects() + { + for (int i = insectsRoot.transform.childCount - 1; i >= 0; i--) + DestroyInsect(insectsRoot.transform.GetChild(i).gameObject); + + IslandGrid.CollectFreeCells(CurrentPlaced, CurrentSizeLevel, freeCells); + freeCellSet.Clear(); + for (int i = 0; i < freeCells.Count; i++) freeCellSet.Add(freeCells[i]); + if (freeCells.Count == 0 || database == null) return; + + if (mode == IslandMode.Visit && visitSnapshot != null) + { + for (int i = 0; i < visitSnapshot.insects.Count; i++) + { + IslandSnapshotInsect s = visitSnapshot.insects[i]; + SpawnInsect(database.GetById(s.insectId), s.level, s.shiny, i); + } + } + else if (island != null) + { + IReadOnlyList list = island.ReleasedInsects; + for (int i = 0; i < list.Count; i++) + SpawnInsect(database.GetById(list[i].insectId), list[i].level, list[i].isShiny, i); + } + } + + private void SpawnInsect(InsectData data, int level, bool shiny, int index) + { + if (data == null) return; + // 시작 칸은 결정적으로 고른다 — 배치가 바뀌어 다시 세울 때마다 곤충들이 한꺼번에 딴 데로 튀지 않게. + Vector2Int cell = freeCells[(index * 7919 + 13) % freeCells.Count]; + var go = new GameObject("IslandInsect_" + data.insectId); + go.transform.SetParent(insectsRoot.transform, false); + go.transform.localScale = Vector3.one * InsectScale; + go.transform.position = Origin + IslandGrid.FootprintCenter(cell.x, cell.y, 1, 1) + + Vector3.up * (IslandInsectWalker.GroundOffset * InsectScale); + go.transform.rotation = Quaternion.Euler(0f, (index * 67) % 360, 0f); + + // 위치를 잡은 뒤에 짓는다 — BuildForBattle이 지금 자리를 기준점으로 삼는다. + InsectEntity entity = go.AddComponent(); + entity.BuildForBattle(data, Mathf.Max(1, level), shiny); + IslandInsectWalker walker = go.AddComponent(); + walker.Initialize(Origin, freeCells, freeCellSet, index * 31 + 7); + if (moodStateKnown) walker.ApplyWorld(moodState); // 다시 세운 곤충도 지금 시간·날씨의 기분으로 + } + + // 곤충 모델은 조각마다 자기 머티리얼 인스턴스를 든다(InsectEntity.ApplyColorRaw). GameObject만 지우면 + // 그 머티리얼이 남는다 — 배치를 바꿀 때마다 곤충을 다시 세우므로 여기서 같이 지운다. + private static void DestroyInsect(GameObject go) + { + Renderer[] renderers = go.GetComponentsInChildren(true); + for (int i = 0; i < renderers.Length; i++) + if (renderers[i] != null && renderers[i].sharedMaterial != null) Destroy(renderers[i].material); + Destroy(go); + } + + private static void SetLayerRecursively(GameObject go, int layer) + { + if (go == null) return; + go.layer = layer; + foreach (Transform child in go.transform) SetLayerRecursively(child.gameObject, layer); + } + + // ── 꾸미기 화면 지원 ── + + public void SetGridVisible(bool visible) + { + if (gridRoot != null) gridRoot.SetActive(visible); + } + + /// 화면 좌표가 가리키는 섬 칸. 카메라 시선이 지면과 만나지 않으면 false. + public bool ScreenToCell(Vector2 screenPos, out int cellX, out int cellZ) + { + cellX = cellZ = 0; + Camera cam = Camera.main; + if (cam == null) return false; + Ray ray = cam.ScreenPointToRay(screenPos); + // 콜라이더가 아니라 평면과 만나는 점을 쓴다 — 건물 지붕을 눌러도 그 아래 칸이 잡힌다. + var plane = new Plane(Vector3.up, Origin); + if (!plane.Raycast(ray, out float enter)) return false; + IslandGrid.CellAt(ray.GetPoint(enter) - Origin, out cellX, out cellZ); + return true; + } + + /// + /// 놓을 자리 미리보기. 모델은 물건·회전이 바뀔 때만 다시 짓고 평소엔 자리만 옮긴다. + /// 바닥 판 색으로 놓을 수 있는지를 알린다. + /// + public void ShowGhost(string id, int x, int z, int rot, bool valid) + { + IslandObjectDef def = IslandCatalog.Get(id); + if (def == null || root == null) { HideGhost(); return; } + + int r = ((rot % 4) + 4) % 4; + IslandGrid.Footprint(def, r, out int w, out int d); + float cs = GameConstants.Island.CellSize; + + if (ghostRoot == null) + { + ghostRoot = new GameObject("IslandGhost"); + ghostRoot.transform.SetParent(root.transform, false); + } + if (ghostModel == null || ghostId != id) + { + if (ghostModel != null) Destroy(ghostModel); + ghostModel = IslandObjectBuilder.Build(def, ghostRoot.transform, materials); + ghostId = id; + ghostRot = -1; + SetLayerRecursively(ghostRoot, SubAreaWorldBuilder.GetSubAreaEnvLayer()); + } + if (ghostRot != r && ghostModel != null) + { + ghostModel.transform.localRotation = Quaternion.Euler(0f, r * 90f, 0f); + ghostRot = r; + } + + Color padColor = valid ? new Color(0.31f, 0.79f, 0.54f, 0.55f) : new Color(0.96f, 0.39f, 0.31f, 0.6f); + if (ghostPad == null) + { + ghostPad = AddFlatQuad(ghostRoot.transform, "GhostPad", Vector3.zero, Vector3.one, + materials.GetFade(padColor)); + ghostPad.layer = SubAreaWorldBuilder.GetSubAreaEnvLayer(); + } + ghostPad.GetComponent().sharedMaterial = materials.GetFade(padColor); + ghostPad.transform.localPosition = new Vector3(0f, 0.04f, 0f); + ghostPad.transform.localScale = new Vector3(w * cs, d * cs, 1f); + + ghostRoot.transform.localPosition = IslandGrid.FootprintCenter(x, z, w, d); + ghostRoot.SetActive(true); + } + + public void HideGhost() + { + if (ghostRoot != null) Destroy(ghostRoot); + ghostRoot = ghostModel = ghostPad = null; + ghostId = null; + ghostRot = -1; + } + + /// 옮기는 중인 물건의 원래 모습을 잠깐 감춘다(-1이면 전부 다시 보인다). + public void SetObjectHidden(int placedIndex) + { + if (hiddenObjectIndex >= 0 && hiddenObjectIndex < objectInstances.Count + && objectInstances[hiddenObjectIndex] != null) + objectInstances[hiddenObjectIndex].SetActive(true); + hiddenObjectIndex = placedIndex; + if (placedIndex >= 0 && placedIndex < objectInstances.Count && objectInstances[placedIndex] != null) + objectInstances[placedIndex].SetActive(false); + } + + /// + /// 꾸미기 카메라 — 플레이어 대신 보이지 않는 초점을 따라가게 한다. 초점은 화면을 끌어 옮긴다. + /// 꾸미기 화면은 모달이라 캐릭터가 서 있고, 카메라가 캐릭터에 묶여 있으면 섬의 한쪽만 보인다. + /// + public void BeginEditCamera() + { + if (editCameraActive || cameraFollower == null || playerMovement == null) return; + if (editFocus == null) + { + var focus = new GameObject("IslandEditFocus"); + focus.transform.SetParent(transform, false); + editFocus = focus.transform; + } + Vector3 start = playerMovement.transform.position; + start.y = Origin.y; + editFocus.SetPositionAndRotation(start, Quaternion.identity); + cameraFollower.SetTarget(editFocus); + cameraFollower.SetOffsetOverride(EditCameraOffset); + editCameraActive = true; + } + + public void EndEditCamera() + { + if (!editCameraActive) return; + editCameraActive = false; + if (cameraFollower == null) return; + cameraFollower.ClearOffsetOverride(); + if (playerMovement != null) cameraFollower.SetTarget(playerMovement.transform); + } + + /// 꾸미기 카메라 초점을 옮긴다(월드 XZ 이동량). 섬 밖으로 나가지 않게 가둔다. + public void PanEditCamera(Vector3 worldDelta) + { + if (!editCameraActive || editFocus == null) return; + float half = IslandGrid.HalfExtent(CurrentSizeLevel); + Vector3 p = editFocus.position + new Vector3(worldDelta.x, 0f, worldDelta.z); + p.x = Mathf.Clamp(p.x, Origin.x - half, Origin.x + half); + p.z = Mathf.Clamp(p.z, Origin.z - half, Origin.z + half); + p.y = Origin.y; + editFocus.position = p; + } + } +} diff --git a/Assets/Scripts/Core/IslandWorldBuilder.cs.meta b/Assets/Scripts/Core/IslandWorldBuilder.cs.meta new file mode 100644 index 0000000..a16425c --- /dev/null +++ b/Assets/Scripts/Core/IslandWorldBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: faedd0ebef94ae44798b5869a29256be \ No newline at end of file diff --git a/Assets/Scripts/Core/IslandYield.cs b/Assets/Scripts/Core/IslandYield.cs new file mode 100644 index 0000000..ebfc33c --- /dev/null +++ b/Assets/Scripts/Core/IslandYield.cs @@ -0,0 +1,158 @@ +using System.Collections.Generic; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Core +{ + /// 놓인 물건이 섬에 주는 효과의 합. + public struct IslandEffects + { + public int comfort; + public float yieldBonus; + public float capHours; + public float bondSpeed; + } + + /// + /// 섬 생산의 순수 공식 — 계수는 . 근거는 rules/balance.md 「섬 기준점」. + /// + /// 캔디/h = 0.25 × 섬 등급 배율 × (1 + 쾌적도 보너스 + 설비 보너스) × (1 + 친밀도 보너스) + /// + public static class IslandYield + { + /// + /// 섬 전용 등급 배율. 보상 배율(C1.0~L2.8)을 그대로 쓰지 않는다 — 그 표를 쓰면 전설 10마리를 푼 + /// 섬이 하루 300캔디를 넘겨 평소 활동 수입의 두 배가 된다. 섬은 곁들이는 수입이라 폭을 눌러 둔다. + /// + public static float RarityFactor(InsectRarity rarity) + { + switch (rarity) + { + case InsectRarity.Uncommon: return 1.1f; + case InsectRarity.Rare: return 1.25f; + case InsectRarity.Epic: return 1.4f; + case InsectRarity.Legendary: return 1.6f; + default: return 1f; + } + } + + /// + /// 놓인 물건의 효과를 모은다. + /// 쾌적도는 같은 물건 첫 개는 온전히, 둘째는 절반, 셋째부터는 0 — 종류를 늘려야 오른다. + /// 설비 효과는 같은 id가 몇 개든 한 번만 센다. + /// + public static IslandEffects CollectEffects(IReadOnlyList placed) + { + var effects = new IslandEffects(); + if (placed == null) return effects; + + for (int i = 0; i < placed.Count; i++) + { + IslandPlacedRecord p = placed[i]; + if (p == null) continue; + IslandObjectDef def = IslandCatalog.Get(p.id); + if (def == null) continue; + + // 목록이 수백 개를 넘지 않아 앞쪽 재탐색으로 센다 — 사전을 만들면 호출마다 할당이 생긴다. + int earlier = 0; + for (int j = 0; j < i; j++) + if (placed[j] != null && placed[j].id == p.id) earlier++; + + if (earlier == 0) effects.comfort += def.comfort; + else if (earlier == 1) effects.comfort += def.comfort / 2; + + if (earlier > 0) continue; + switch (def.effect) + { + case IslandEffectKind.YieldBonus: effects.yieldBonus += def.effectValue; break; + case IslandEffectKind.CapHours: effects.capHours += def.effectValue; break; + case IslandEffectKind.BondSpeed: effects.bondSpeed += def.effectValue; break; + } + } + return effects; + } + + public static float ComfortBonus(int comfort) + { + float ratio = Mathf.Clamp01(comfort / (float)GameConstants.Island.ComfortForMaxBonus); + return ratio * GameConstants.Island.MaxComfortBonus; + } + + public static float CapHours(IslandEffects effects) + { + return Mathf.Min(GameConstants.Island.MaxCapHours, + GameConstants.Island.BaseCapHours + Mathf.Max(0f, effects.capHours)); + } + + /// 그 하트 수에 필요한 누적 방목 시간 — 6·18·36·60·90. + public static float BondHoursForLevel(int level) + { + int l = Mathf.Clamp(level, 0, GameConstants.Island.MaxBondLevel); + return GameConstants.Island.BondHoursUnit * l * (l + 1) * 0.5f; + } + + public static int BondLevel(float hours) + { + int level = 0; + for (int l = 1; l <= GameConstants.Island.MaxBondLevel; l++) + if (hours >= BondHoursForLevel(l)) level = l; + return level; + } + + public static float BondBonus(int bondLevel) + { + return Mathf.Clamp(bondLevel, 0, GameConstants.Island.MaxBondLevel) + * GameConstants.Island.BondBonusPerLevel; + } + + /// 섬 전체에 걸리는 배율(쾌적도 + 설비). 곤충별 친밀도 배율은 따로 곱한다. + public static float IslandMultiplier(IslandEffects effects) + { + return 1f + ComfortBonus(effects.comfort) + Mathf.Max(0f, effects.yieldBonus); + } + + public static float CandyPerHour(InsectRarity rarity, int bondLevel, IslandEffects effects) + { + return GameConstants.Island.CandyPerInsectHour * RarityFactor(rarity) + * IslandMultiplier(effects) * (1f + BondBonus(bondLevel)); + } + + public static float CoinPerHour(int bondLevel, IslandEffects effects) + { + return GameConstants.Island.CoinPerInsectHour + * IslandMultiplier(effects) * (1f + BondBonus(bondLevel)); + } + + /// + /// 이번 정산에서 쳐 줄 시간. 상한()에서 이미 쌓인 만큼을 뺀 범위 안에서만 준다. + /// + /// 는 저장할 새 기준 시각이다. + /// - 처음( ≤ 0)이면 기준점만 잡고 0을 준다. + /// - 시계가 뒤로 갔으면 0을 주고 기준 시각을 당기지 않는다(앞뒤로 흔들어 상한분을 반복해 받는 걸 막는다). + /// 다만 를 넘게 되돌아갔다면 시계를 바로잡은 + /// 것으로 보고 지금으로 당긴다 — 안 그러면 한 번 미래로 간 섬이 그 시각까지 영영 멈춘다. + /// + public static float SettleHours(long lastSettleUnix, long nowUnix, float accruedHours, float capHours, + out long nextSettleUnix) + { + if (lastSettleUnix <= 0) + { + nextSettleUnix = nowUnix; + return 0f; + } + + if (nowUnix < lastSettleUnix) + { + nextSettleUnix = lastSettleUnix - nowUnix > GameConstants.Island.ClockRollbackResetSeconds + ? nowUnix + : lastSettleUnix; + return 0f; + } + + nextSettleUnix = nowUnix; + float elapsed = (nowUnix - lastSettleUnix) / 3600f; + float room = Mathf.Max(0f, capHours - Mathf.Max(0f, accruedHours)); + return Mathf.Clamp(elapsed, 0f, room); + } + } +} diff --git a/Assets/Scripts/Core/IslandYield.cs.meta b/Assets/Scripts/Core/IslandYield.cs.meta new file mode 100644 index 0000000..062fae8 --- /dev/null +++ b/Assets/Scripts/Core/IslandYield.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6fd9c5779372bfc48802aa0b964f2564 \ No newline at end of file diff --git a/Assets/Scripts/Core/OutfitPatternLibrary.cs b/Assets/Scripts/Core/OutfitPatternLibrary.cs new file mode 100644 index 0000000..b3df3b3 --- /dev/null +++ b/Assets/Scripts/Core/OutfitPatternLibrary.cs @@ -0,0 +1,846 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace InsectGame.Core +{ + /// 무늬를 입힐 표면. 메시마다 UV 배치가 달라 같은 무늬도 표면별로 따로 굽는다. + public enum PatternSurface + { + /// 몸통(Body) — 둥근 상자 아틀라스: 왼쪽 절반 = 앞면, 오른쪽 절반 = 뒷면·옆면. + Torso, + /// 자켓 사이로 보이는 셔츠 판(Shirt) — 앞면 절반만 보인다. 몸통보다 좁아 무늬 배치가 다르다. + Panel, + /// 팔(ArmL/R) — 캡슐 원통 투영: u 둘레(0.5 = 앞, 이음매는 뒤), v 위(1)→손목(0). + Sleeve, + /// 다리(LegL/R) — 팔과 같은 원통 투영. + Leg, + /// 신발(BootL/R) — 몸통과 같은 아틀라스(앞 = 발끝, 뒤 = 뒤꿈치·옆). v 0 = 밑창. + Boot, + } + + public enum PatternKind + { + None, + Placket, Polo, Vest, Stripes, LabCoat, Galaxy, Leaf, Flame, WesternVest, Web, Ninja, PirateCoat, Circuit, Camo, + Cargo, Denim, Sheen, SideStripe, AnkleWrap, + BootSole, Sneaker, RocketBoot, Crystal, Western, + JacketTrim, RainCoat, Windbreaker, ShadowCoat, WizardRobe, LabOuter, + } + + /// + /// 의상 **표면 무늬**의 단일 출처 — 절차 생성 텍스처. 형태(실루엣)는 가 맡고, + /// 여기는 줄무늬·별·거미줄·위장처럼 "천에 인쇄된 것"만 맡는다. + /// + /// 왜 텍스처인가(2026-09-30 샘플 비교): 같은 무늬를 입체 파츠로 만들면 줄무늬는 몸에 두른 벨트, 거미줄은 몸 앞의 + /// 철사 우리, 갤럭시는 흩어진 점 몇 개가 됐다. 텍스처는 정점 비용이 0이고, 같은 무늬를 "겉옷 벗었을 때의 몸통"과 + /// "자켓 사이 셔츠 판"에 그대로 쓸 수 있다. + /// + /// 색은 텍스처에 **구워 넣는다**(아이템의 primary·secondary). 그래서 입힐 때 머티리얼 색을 흰색으로 둔다 + /// (CharacterOutfitManager.ApplyPattern). 결과는 (무늬, 표면, 두 색)으로 프로세스 수명 캐시한다 — + /// 굽는 데 수 ms가 들고 마네킹 썸네일은 같은 옷을 여러 번 입힌다. + /// + public static class OutfitPatternLibrary + { + private static readonly Dictionary Kinds = new Dictionary + { + // ── 상의 ── + ["top_shirt"] = PatternKind.Placket, + ["top_polo"] = PatternKind.Polo, + ["top_vest"] = PatternKind.Vest, + ["top_stripe"] = PatternKind.Stripes, + ["top_lab"] = PatternKind.LabCoat, + ["top_galaxy"] = PatternKind.Galaxy, + ["top_nature"] = PatternKind.Leaf, + ["top_flame"] = PatternKind.Flame, + ["top_cowboy"] = PatternKind.WesternVest, + ["top_hero_suit"] = PatternKind.Web, + ["top_ninja"] = PatternKind.Ninja, + ["top_pirate"] = PatternKind.PirateCoat, + ["top_cyber"] = PatternKind.Circuit, + ["top_military"] = PatternKind.Camo, + + // ── 하의 ── + ["bot_cargo"] = PatternKind.Cargo, + ["bot_jeans"] = PatternKind.Denim, + ["bot_galaxy"] = PatternKind.Galaxy, + ["bot_golden"] = PatternKind.Sheen, + ["bot_cowboy"] = PatternKind.Denim, + ["bot_hero_suit"] = PatternKind.SideStripe, + ["bot_ninja"] = PatternKind.AnkleWrap, + ["bot_pirate"] = PatternKind.Stripes, + ["bot_cyber"] = PatternKind.Circuit, + ["bot_military"] = PatternKind.Camo, + + // ── 신발 ── + ["shoe_boots"] = PatternKind.BootSole, + ["shoe_sneakers"] = PatternKind.Sneaker, + ["shoe_waders"] = PatternKind.BootSole, + ["shoe_rocket"] = PatternKind.RocketBoot, + ["shoe_crystal"] = PatternKind.Crystal, + ["shoe_cowboy"] = PatternKind.Western, + + // ── 겉옷 ── + ["outer_jacket"] = PatternKind.JacketTrim, + ["outer_raincoat"] = PatternKind.RainCoat, + ["outer_windbreaker"] = PatternKind.Windbreaker, + ["outer_labcoat"] = PatternKind.LabOuter, + ["outer_crystal"] = PatternKind.Crystal, + ["outer_shadow"] = PatternKind.ShadowCoat, + ["outer_wizard"] = PatternKind.WizardRobe, + }; + + /// 아이템의 무늬 종류. 없으면 None(색만). + public static PatternKind KindOf(string itemId) + { + return itemId != null && Kinds.TryGetValue(itemId, out PatternKind k) ? k : PatternKind.None; + } + + internal static IEnumerable> Entries() => Kinds; + + // ── 캐시 ── + + private readonly struct Key : System.IEquatable + { + private readonly PatternKind kind; + private readonly PatternSurface surface; + private readonly int primary; + private readonly int secondary; + + public Key(PatternKind kind, PatternSurface surface, Color p, Color s) + { + this.kind = kind; + this.surface = surface; + primary = Pack(p); + secondary = Pack(s); + } + + private static int Pack(Color c) + { + Color32 q = c; + return (q.r << 16) | (q.g << 8) | q.b; + } + + public bool Equals(Key o) => kind == o.kind && surface == o.surface && primary == o.primary && secondary == o.secondary; + public override bool Equals(object obj) => obj is Key o && Equals(o); + public override int GetHashCode() => (((int)kind * 31 + (int)surface) * 397 ^ primary) * 397 ^ secondary; + } + + private static readonly Dictionary cache = new Dictionary(); + + internal static int CachedCount => cache.Count; + + /// + /// 아이템 무늬 텍스처. 무늬가 없으면 null — 호출부는 텍스처를 비우고 색만 칠한다. + /// + public static Texture2D Get(string itemId, PatternSurface surface, Color primary, Color secondary) + { + PatternKind kind = KindOf(itemId); + if (kind == PatternKind.None || !HasSurface(kind, surface)) return null; + + Key key = new Key(kind, surface, primary, secondary); + if (cache.TryGetValue(key, out Texture2D tex) && tex != null) return tex; + + SizeOf(surface, out int w, out int h); + tex = new Texture2D(w, h, TextureFormat.RGBA32, true) + { + name = "OutfitPattern_" + kind + "_" + surface, + wrapMode = TextureWrapMode.Clamp, + filterMode = FilterMode.Trilinear, + anisoLevel = 2, + hideFlags = HideFlags.HideAndDontSave, // 프로세스 수명 캐시 — 씬 언로드 대상에서 뺀다(ProcMeshLibrary와 같은 규약) + }; + tex.SetPixels(Render(kind, surface, primary, secondary, w, h)); + tex.Apply(true); + cache[key] = tex; + return tex; + } + + internal static void SizeOf(PatternSurface surface, out int w, out int h) + { + switch (surface) + { + case PatternSurface.Sleeve: + case PatternSurface.Leg: + w = 64; h = 64; break; + case PatternSurface.Boot: + w = 128; h = 64; break; + default: + w = 256; h = 128; break; // 아틀라스: 앞 128 + 뒤 128 + } + } + + /// + /// 이 무늬가 그 표면에 그릴 게 있는가. 없으면 호출부는 색만 칠한다 — 예를 들어 조끼는 소매가 없고 + /// (소매는 속셔츠 색이다), 대부분의 하의 무늬는 신발에 안 쓴다. + /// + internal static bool HasSurface(PatternKind kind, PatternSurface surface) + { + switch (kind) + { + case PatternKind.None: return false; + case PatternKind.Vest: + case PatternKind.Polo: + case PatternKind.Placket: + return surface == PatternSurface.Torso || surface == PatternSurface.Panel; + case PatternKind.Cargo: + case PatternKind.Denim: + case PatternKind.Sheen: + case PatternKind.SideStripe: + case PatternKind.AnkleWrap: + return surface == PatternSurface.Leg; + case PatternKind.BootSole: + case PatternKind.Sneaker: + case PatternKind.RocketBoot: + case PatternKind.Western: + return surface == PatternSurface.Boot; + case PatternKind.Crystal: + return true; + default: + return surface != PatternSurface.Boot; + } + } + + // ── 굽기 ────────────────────────────────────────── + + /// + /// 픽셀 배열을 굽는다(순수 — 테스트가 결정성과 표면별 차이를 본다). 아틀라스 표면은 u < 0.5가 앞면이다. + /// + internal static Color[] Render(PatternKind kind, PatternSurface surface, Color p, Color s, int w, int h) + { + Color[] px = new Color[w * h]; + Ctx ctx = new Ctx(kind, surface, p, s, w, h); + bool atlas = surface == PatternSurface.Torso || surface == PatternSurface.Panel || surface == PatternSurface.Boot; + + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + float u = (x + 0.5f) / w; + float v = (y + 0.5f) / h; + bool front = true; + if (atlas) + { + front = u < 0.5f; + u = front ? u * 2f : (u - 0.5f) * 2f; + } + Color c = Shade(ctx, u, v, front); + c.a = 1f; + px[y * w + x] = c; + } + } + return px; + } + + /// 무늬마다 미리 뽑아 두는 것(별·잎·회로 선분). 픽셀마다 난수를 돌리지 않는다. + private sealed class Ctx + { + public readonly PatternKind kind; + public readonly PatternSurface surface; + public readonly Color p, s; + public readonly float texelU, texelV; // 한 텍셀의 크기(0..1 좌표) — 선 굵기를 텍셀로 준다 + public readonly List segs = new List(); + public readonly List dots = new List(); // (u, v, 크기/종류) + + public Ctx(PatternKind kind, PatternSurface surface, Color p, Color s, int w, int h) + { + this.kind = kind; + this.surface = surface; + this.p = p; + this.s = s; + bool atlas = surface == PatternSurface.Torso || surface == PatternSurface.Panel || surface == PatternSurface.Boot; + texelU = (atlas ? 2f : 1f) / w; + texelV = 1f / h; + + System.Random rng = new System.Random(((int)kind + 1) * 7919 + (int)surface * 131); + switch (kind) + { + case PatternKind.Galaxy: + case PatternKind.WizardRobe: + { + int n = surface == PatternSurface.Sleeve || surface == PatternSurface.Leg ? 26 : 70; + for (int i = 0; i < n; i++) + dots.Add(new Vector3((float)rng.NextDouble(), (float)rng.NextDouble(), i < n / 10 ? 1f : 0f)); + break; + } + case PatternKind.Leaf: + { + int n = surface == PatternSurface.Sleeve || surface == PatternSurface.Leg ? 8 : 18; + for (int i = 0; i < n; i++) + dots.Add(new Vector3((float)rng.NextDouble(), (float)rng.NextDouble(), (float)rng.NextDouble() * Mathf.PI)); + break; + } + case PatternKind.Crystal: + { + for (int i = 0; i < 7; i++) + dots.Add(new Vector3((float)rng.NextDouble(), 0.25f + 0.7f * (float)rng.NextDouble(), 1f)); + break; + } + case PatternKind.Web: + { + Vector2 hub = surface == PatternSurface.Sleeve ? new Vector2(0.5f, 0.5f) : new Vector2(0.5f, 0.62f); + int spokes = surface == PatternSurface.Sleeve ? 6 : 10; + for (int k = 0; k < spokes; k++) + { + float a = k * Mathf.PI * 2f / spokes; + segs.Add(new Vector4(hub.x, hub.y, hub.x + Mathf.Cos(a) * 1.2f, hub.y + Mathf.Sin(a) * 1.2f)); + } + for (int ring = 1; ring <= 6; ring++) + { + float r = ring * 0.13f; + for (int k = 0; k < spokes; k++) + { + float a0 = k * Mathf.PI * 2f / spokes, a1 = (k + 1) * Mathf.PI * 2f / spokes; + // 거미줄은 살 사이에서 **안쪽으로 처진다** — 가운데를 조금 당겨 곡선처럼 보이게 두 토막으로 긋는다. + Vector2 p0 = hub + new Vector2(Mathf.Cos(a0), Mathf.Sin(a0)) * r; + Vector2 p1 = hub + new Vector2(Mathf.Cos(a1), Mathf.Sin(a1)) * r; + float am = (a0 + a1) * 0.5f; + Vector2 pm = hub + new Vector2(Mathf.Cos(am), Mathf.Sin(am)) * r * 0.9f; + segs.Add(new Vector4(p0.x, p0.y, pm.x, pm.y)); + segs.Add(new Vector4(pm.x, pm.y, p1.x, p1.y)); + } + } + break; + } + case PatternKind.Circuit: + { + // 격자를 따라 꺾이는 회로 선 — 가로·세로 토막을 이어 붙이고 끝에 단자(점)를 둔다. + int lines = surface == PatternSurface.Sleeve || surface == PatternSurface.Leg ? 3 : 7; + for (int i = 0; i < lines; i++) + { + Vector2 cur = new Vector2(0.1f + 0.8f * (float)rng.NextDouble(), 0.05f + 0.9f * (float)rng.NextDouble()); + for (int step = 0; step < 4; step++) + { + bool horiz = (step + i) % 2 == 0; + float len = 0.1f + 0.25f * (float)rng.NextDouble(); + float dir = rng.NextDouble() < 0.5 ? -1f : 1f; + Vector2 next = horiz ? new Vector2(Mathf.Clamp01(cur.x + dir * len), cur.y) + : new Vector2(cur.x, Mathf.Clamp01(cur.y + dir * len)); + segs.Add(new Vector4(cur.x, cur.y, next.x, next.y)); + cur = next; + } + dots.Add(new Vector3(cur.x, cur.y, 1f)); + } + break; + } + } + } + } + + // ── 무늬별 음영 ── + + private static Color Shade(Ctx c, float u, float v, bool front) + { + Color p = c.p, s = c.s; + switch (c.kind) + { + case PatternKind.Placket: return Placket(c, u, v, front); + case PatternKind.Polo: return Polo(c, u, v, front); + case PatternKind.Vest: return Vest(c, u, v, front, s, false); + case PatternKind.WesternVest: + // 소매는 조끼 속 체크 셔츠다(조끼에는 소매가 없다). + return c.surface == PatternSurface.Sleeve ? Plaid(u * 2f, v * 2f) : Vest(c, u, v, front, Plaid(u, v), true); + case PatternKind.Stripes: + { + int bands = c.surface == PatternSurface.Leg ? 10 : c.surface == PatternSurface.Sleeve ? 7 : 9; + return (Mathf.FloorToInt(v * bands) % 2 == 1) ? s : p; + } + case PatternKind.LabCoat: return LabCoat(c, u, v, front); + case PatternKind.Galaxy: return Galaxy(c, u, v, front); + case PatternKind.Leaf: return Leaf(c, u, v); + case PatternKind.Flame: return Flame(c, u, v, front); + case PatternKind.Web: return Web(c, u, v); + case PatternKind.Ninja: return Ninja(c, u, v, front); + case PatternKind.PirateCoat: return Pirate(c, u, v, front); + case PatternKind.Circuit: return Circuit(c, u, v); + case PatternKind.Camo: return Camo(p, s, u, v, front ? 0 : 17); + case PatternKind.Cargo: return Cargo(c, u, v); + case PatternKind.Denim: return Denim(c, u, v); + case PatternKind.Sheen: + { + float d = Mathf.Abs(u - 0.5f); + float k = 0.82f + 0.45f * Mathf.Exp(-(u - 0.43f) * (u - 0.43f) / 0.004f) - 0.25f * d; + return Scale(p, k); + } + case PatternKind.SideStripe: + { + float d = Mathf.Min(Mathf.Abs(u - 0.25f), Mathf.Abs(u - 0.75f)); + if (d < 0.045f) return s; + if (d < 0.058f) return Scale(p, 0.6f); + return p; + } + case PatternKind.AnkleWrap: + { + if (v > 0.32f) return p; + float band = Frac(u * 3f + v * 7f); + return band < 0.5f ? Color.Lerp(p, s, 0.55f) : Scale(p, 0.8f); + } + case PatternKind.BootSole: return BootSole(c, u, v, front); + case PatternKind.Sneaker: return Sneaker(c, u, v, front); + case PatternKind.RocketBoot: return RocketBoot(c, u, v, front); + case PatternKind.Crystal: return Crystal(c, u, v); + case PatternKind.Western: return WesternBoot(c, u, v, front); + case PatternKind.JacketTrim: return JacketTrim(c, u, v, front); + case PatternKind.RainCoat: return RainCoat(c, u, v, front); + case PatternKind.Windbreaker: return Windbreaker(c, u, v, front); + case PatternKind.ShadowCoat: return ShadowCoat(c, u, v, front); + case PatternKind.WizardRobe: return WizardRobe(c, u, v, front); + case PatternKind.LabOuter: return LabOuter(c, u, v, front); + default: return p; + } + } + + // ── 상의 ── + + private static Color Placket(Ctx c, float u, float v, bool front) + { + Color p = c.p; + if (v > 0.93f) return Scale(p, 0.9f); // 목둘레 + if (!front) return p; + if (Mathf.Abs(u - 0.5f) < 0.012f) return Scale(p, 0.85f); // 단추 여밈선 + for (int i = 0; i < 4; i++) + { + if (Circle(u, v, 0.5f, 0.2f + i * 0.19f, 0.022f, c)) return Scale(p, 0.62f); + } + // 옷깃 — 목둘레에서 양옆으로 내려오는 삼각형 + float cu = Mathf.Abs(u - 0.5f); + if (v > 0.8f && cu < 0.2f && (v - 0.8f) > cu * 0.55f) return Scale(p, 0.92f); + return p; + } + + private static Color Polo(Ctx c, float u, float v, bool front) + { + Color p = c.p, s = c.s; + if (v > 0.9f) return s; // 흰 깃 + if (!front) return p; + float cu = Mathf.Abs(u - 0.5f); + if (v > 0.62f && cu < 0.032f) + { + if (Circle(u, v, 0.5f, 0.7f, 0.016f, c) || Circle(u, v, 0.5f, 0.8f, 0.016f, c)) return s; + if (cu > 0.024f || v < 0.63f) return Scale(p, 0.8f); // 여밈판 테두리 + } + return p; + } + + /// 조끼 — 가운데 속셔츠(흰 셔츠·체크 셔츠) + 양옆 조끼 + 주머니. 셔츠 판에서는 속셔츠 폭을 넓힌다. + private static Color Vest(Ctx c, float u, float v, bool front, Color shirt, bool leather) + { + Color p = c.p; + if (!front) + { + if (leather && Mathf.Abs(v - 0.8f) < 0.01f && Dashed(u, 0.04f)) return new Color(0.85f, 0.72f, 0.45f); + return p; + } + float open = c.surface == PatternSurface.Panel ? 0.3f : 0.13f; + float cu = Mathf.Abs(u - 0.5f); + // 속셔츠는 아래로 갈수록 조끼 앞섶이 열려 조금 더 넓게 보인다 + float edge = open + (1f - v) * 0.04f; + if (cu < edge) return shirt; + if (cu < edge + 0.02f) return Scale(p, 0.72f); // 앞섶 테두리 + if (leather && cu < edge + 0.04f && Dashed(v, 0.05f)) return new Color(0.85f, 0.72f, 0.45f); // 스티치 + if (c.surface == PatternSurface.Torso && v > 0.18f && v < 0.36f) + { + float pu = cu - 0.28f; // 주머니 중심 ±0.28 + if (Mathf.Abs(pu) < 0.08f) + { + if (Mathf.Abs(pu) > 0.068f || v < 0.195f || Mathf.Abs(v - 0.31f) < 0.012f) return Scale(p, 0.75f); + } + } + return p; + } + + private static Color Plaid(float u, float v) + { + Color baseC = new Color(0.94f, 0.9f, 0.82f); + Color red = new Color(0.72f, 0.2f, 0.18f); + bool bu = Frac(u * 8f) < 0.28f; + bool bv = Frac(v * 8f) < 0.28f; + if (bu && bv) return Color.Lerp(red, Color.black, 0.25f); + if (bu || bv) return Color.Lerp(baseC, red, 0.6f); + return baseC; + } + + private static Color LabCoat(Ctx c, float u, float v, bool front) + { + Color p = c.p, s = c.s; + if (c.surface == PatternSurface.Sleeve) return v < 0.12f ? Scale(p, 0.9f) : p; + if (!front) return (v < 0.3f && Mathf.Abs(u - 0.5f) < 0.006f) ? Scale(p, 0.8f) : p; + if (Mathf.Abs(u - 0.5f) < 0.01f) return Scale(p, 0.84f); + for (int i = 0; i < 4; i++) + if (Circle(u, v, 0.5f, 0.18f + i * 0.2f, 0.018f, c)) return Scale(s, 0.85f); + // 가슴 주머니 + 펜 + if (u > 0.64f && u < 0.66f && v > 0.66f && v < 0.8f) return new Color(0.2f, 0.35f, 0.8f); + if (u > 0.6f && u < 0.8f && v > 0.58f && v < 0.72f && (u < 0.608f || u > 0.792f || v < 0.588f || v > 0.71f)) + return Scale(s, 0.9f); + return p; + } + + private static Color Galaxy(Ctx c, float u, float v, bool front) + { + Color p = c.p, s = c.s; + Color col = Color.Lerp(p, Color.Lerp(p, s, 0.7f), v); + float cx = front ? 0.62f : 0.35f, cy = front ? 0.58f : 0.45f; + float neb = Mathf.Exp(-((u - cx) * (u - cx) + (v - cy) * (v - cy)) / 0.02f); + col = Color.Lerp(col, Scale(s, 1.3f), neb * 0.6f); + float su = front ? 0f : 0.37f; + for (int i = 0; i < c.dots.Count; i++) + { + Vector3 d = c.dots[i]; + float dx = (u - Frac(d.x + su)) / c.texelU, dy = (v - d.y) / c.texelV; + float d2 = dx * dx + dy * dy; + if (d.z > 0.5f) + { + bool cross = (Mathf.Abs(dx) < 0.8f && Mathf.Abs(dy) < 4.5f) || (Mathf.Abs(dy) < 0.8f && Mathf.Abs(dx) < 4.5f); + if (cross || d2 < 2.2f) return new Color(1f, 0.96f, 0.7f); + } + else if (d2 < 1.1f) col = Color.Lerp(col, Color.white, 0.9f); + } + return col; + } + + private static Color Leaf(Ctx c, float u, float v) + { + Color p = c.p, s = c.s; + for (int i = 0; i < c.dots.Count; i++) + { + Vector3 d = c.dots[i]; + float ca = Mathf.Cos(d.z), sa = Mathf.Sin(d.z); + float du = u - d.x, dv = v - d.y; + float lx = du * ca + dv * sa, ly = -du * sa + dv * ca; + float len = 0.075f, wid = 0.03f; + float e = (lx * lx) / (len * len) + (ly * ly) / (wid * wid); + if (e < 1f) + { + if (Mathf.Abs(ly) < 0.004f) return Scale(s, 0.72f); // 잎맥 + return Color.Lerp(s, p, 0.15f + 0.2f * e); + } + } + return p; + } + + private static Color Flame(Ctx c, float u, float v, bool front) + { + Color p = c.p, s = c.s; + Color dark = Scale(p, 0.5f); + float phase = front ? 0f : 1.7f; + bool sleeve = c.surface == PatternSurface.Sleeve; + float baseH = sleeve ? 0.28f : 0.38f; + float f = baseH + 0.14f * Mathf.Sin(u * Mathf.PI * 2f * 3f + phase) + 0.07f * Mathf.Sin(u * Mathf.PI * 2f * 7f + phase * 2f); + if (v > f) return dark; + float t = v / Mathf.Max(0.01f, f); // 0 밑단 → 1 불꽃 끝 + if (f - v < 0.025f) return Color.Lerp(p, dark, 0.3f); + return Color.Lerp(Scale(s, 1.05f), p, t * t); + } + + private static Color Web(Ctx c, float u, float v) + { + Color p = c.p, s = c.s; + if (c.surface != PatternSurface.Sleeve && (u < 0.13f || u > 0.87f)) p = s; // 옆구리 패널 + float best = SegDistTexels(c, u, v); + return best < 1.1f ? new Color(0.08f, 0.06f, 0.1f) : p; + } + + private static Color Ninja(Ctx c, float u, float v, bool front) + { + Color p = c.p, s = c.s; + if (c.surface == PatternSurface.Sleeve) return v < 0.12f ? Color.Lerp(p, s, 0.6f) : p; + if (v > 0.08f && v < 0.2f) return (front && Mathf.Abs(u - 0.5f) < 0.05f) ? Scale(s, 0.7f) : s; // 띠 + 매듭 + if (front) + { + // 겹쳐 여민 앞섶(왼쪽 위 → 오른쪽 아래) + float line = Mathf.Lerp(0.28f, 0.64f, (1f - v) / 0.8f); + float d = Mathf.Abs(u - line); + if (v > 0.2f && d < 0.022f) return Color.Lerp(p, s, 0.55f); + } + return p; + } + + private static Color Pirate(Ctx c, float u, float v, bool front) + { + Color p = c.p, s = c.s; + if (c.surface == PatternSurface.Sleeve) return (v > 0.06f && v < 0.16f) ? s : p; + if (v < 0.05f) return s; // 금테 밑단 + if (!front) return (Circle(u, v, 0.4f, 0.42f, 0.03f, c) || Circle(u, v, 0.6f, 0.42f, 0.03f, c)) ? s : p; + float cu = Mathf.Abs(u - 0.5f); + if (cu < 0.015f) return Scale(p, 0.55f); + if (cu < 0.035f) return s; // 앞섶 금테 + for (int i = 0; i < 4; i++) + { + float vy = 0.3f + i * 0.15f; + if (Circle(u, v, 0.36f, vy, 0.028f, c) || Circle(u, v, 0.64f, vy, 0.028f, c)) + return Circle(u, v, 0.36f, vy, 0.018f, c) || Circle(u, v, 0.64f, vy, 0.018f, c) ? s : Scale(s, 0.6f); + } + if (v > 0.8f && cu < 0.2f && (v - 0.8f) > (cu - 0.035f) * 0.9f) return Scale(p, 0.75f); // 라펠 + return p; + } + + private static Color Circuit(Ctx c, float u, float v) + { + Color p = c.p, s = c.s; + float best = SegDistTexels(c, u, v); + for (int i = 0; i < c.dots.Count; i++) + { + float dx = (u - c.dots[i].x) / c.texelU, dy = (v - c.dots[i].y) / c.texelV; + best = Mathf.Min(best, Mathf.Sqrt(dx * dx + dy * dy) - 2.2f); + } + if (best < 0.9f) return Scale(s, 1.1f); + if (best < 2.6f) return Color.Lerp(p, s, 0.3f); // 번짐(발광처럼 보이게) + return p; + } + + private static Color Camo(Color p, Color s, float u, float v, int seed) + { + float n = Fbm(u * 5f, v * 5f, seed); + if (n < 0.42f) return p; + if (n < 0.6f) return s; + if (n < 0.72f) return new Color(0.55f, 0.5f, 0.36f); + return Scale(s, 0.7f); + } + + // ── 하의 ── + + private static Color Cargo(Ctx c, float u, float v) + { + Color p = c.p; + float d = Mathf.Min(Mathf.Abs(u - 0.25f), Mathf.Abs(u - 0.75f)); + if (d < 0.004f) return Scale(p, 0.78f); // 옆솔기 + if (v > 0.4f && v < 0.64f && d < 0.1f) + { + if (d > 0.088f || v < 0.412f || Mathf.Abs(v - 0.57f) < 0.012f) return Scale(p, 0.72f); // 주머니 + 덮개 + return Scale(p, 0.95f); + } + return p; + } + + private static Color Denim(Ctx c, float u, float v) + { + Color p = c.p; + float twill = Hash(Mathf.FloorToInt((u + v) * 90f), Mathf.FloorToInt((u - v) * 30f), 3) * 0.1f - 0.05f; + Color col = Scale(p, 1f + twill); + float knee = Mathf.Exp(-((u - 0.5f) * (u - 0.5f) / 0.01f + (v - 0.5f) * (v - 0.5f) / 0.02f)); + col = Color.Lerp(col, Color.Lerp(p, Color.white, 0.35f), knee * 0.5f); + float d = Mathf.Min(Mathf.Abs(u - 0.25f), Mathf.Abs(u - 0.75f)); + if (d < 0.01f && Dashed(v, 0.06f)) return new Color(0.86f, 0.68f, 0.32f); // 주황 스티치 + return col; + } + + // ── 신발 ── + + private static Color Sole(Ctx c, float u, float v, Color sole, float top) + { + if (v < top) return Mathf.Abs(v - top) < 0.035f ? Scale(sole, 1.25f) : sole; + return c.p; + } + + private static Color BootSole(Ctx c, float u, float v, bool front) + { + Color p = c.p; + if (v < 0.24f) return Sole(c, u, v, new Color(0.17f, 0.12f, 0.08f), 0.24f); + if (front && v > 0.5f && Mathf.Abs(u - 0.5f) < 0.2f) + { + // 끈 — X자로 엇갈린 두 선 + float t = (v - 0.5f) * 5f; + float a = Mathf.Abs(Frac(t) - 0.5f) * 0.4f; + if (Mathf.Abs(Mathf.Abs(u - 0.5f) - a) < 0.018f) return new Color(0.88f, 0.82f, 0.66f); + } + if (!front && v < 0.45f) return Scale(p, 0.85f); // 뒤꿈치 보강 + return p; + } + + private static Color Sneaker(Ctx c, float u, float v, bool front) + { + Color p = c.p, s = c.s; + if (v < 0.26f) return Mathf.Abs(v - 0.16f) < 0.025f ? s : new Color(0.93f, 0.93f, 0.92f); + if (front) + { + if (v < 0.5f) return Scale(p, 0.94f); // 앞코 + if (Mathf.Abs(u - 0.5f) < 0.18f && Frac((v - 0.5f) * 8f) < 0.3f) return Scale(p, 0.8f); // 끈 + return p; + } + float curve = 0.45f + 0.18f * Mathf.Sin(u * Mathf.PI); + if (Mathf.Abs(v - curve) < 0.055f) return s; // 옆 스우시 + return p; + } + + private static Color RocketBoot(Ctx c, float u, float v, bool front) + { + Color p = c.p, s = c.s; + if (v < 0.2f) return new Color(0.18f, 0.18f, 0.2f); + if (front) + return (v < 0.58f && Mathf.Abs(u - 0.5f) < 0.3f) ? new Color(0.72f, 0.74f, 0.78f) : p; // 금속 앞코 + float f = 0.3f + 0.16f * Mathf.Sin(u * Mathf.PI * 6f) + 0.06f * Mathf.Sin(u * Mathf.PI * 14f); + if (v < f) return Color.Lerp(new Color(1f, 0.92f, 0.3f), s, (v - 0.2f) / Mathf.Max(0.01f, f - 0.2f)); + return p; + } + + private static Color Crystal(Ctx c, float u, float v) + { + Color p = c.p, s = c.s; + if (c.surface == PatternSurface.Boot && v < 0.18f) return s; + float a = Frac((u + v) * 4f), b = Frac((u - v) * 4f + 10f); + Color col = p; + if (a < 0.5f) col = Scale(p, 1.06f); + if (a < 0.035f || b < 0.035f) col = Color.Lerp(p, Color.white, 0.7f); + for (int i = 0; i < c.dots.Count; i++) + { + float dx = (u - c.dots[i].x) / c.texelU, dy = (v - c.dots[i].y) / c.texelV; + if ((Mathf.Abs(dx) < 0.7f && Mathf.Abs(dy) < 4f) || (Mathf.Abs(dy) < 0.7f && Mathf.Abs(dx) < 4f)) return Color.white; + } + return col; + } + + private static Color WesternBoot(Ctx c, float u, float v, bool front) + { + Color p = c.p; + if (v < 0.2f) return Sole(c, u, v, new Color(0.22f, 0.14f, 0.08f), 0.2f); + if (front) return v < 0.55f ? Scale(p, 0.88f) : p; + float curve = 0.74f + 0.1f * Mathf.Sin(u * Mathf.PI * 4f); + if (Mathf.Abs(v - curve) < 0.016f || Mathf.Abs(v - curve + 0.1f) < 0.012f) return new Color(0.9f, 0.78f, 0.5f); + return p; + } + + // ── 겉옷 ── + + private static Color Cuff(Ctx c, float v, float k) + { + return v < 0.1f ? Scale(c.p, k) : c.p; + } + + private static Color JacketTrim(Ctx c, float u, float v, bool front) + { + Color p = c.p; + if (c.surface == PatternSurface.Sleeve) return Cuff(c, v, 0.78f); + if (v < 0.06f) return Scale(p, 0.82f); // 밑단 시보리 + if (!front) return Mathf.Abs(v - 0.8f) < 0.008f ? Scale(p, 0.8f) : p; + float cu = Mathf.Abs(u - 0.5f); + if (cu > 0.2f && cu < 0.4f && Mathf.Abs(v - 0.3f) < 0.02f) return Scale(p, 0.7f); // 주머니 덮개 + return p; + } + + private static Color RainCoat(Ctx c, float u, float v, bool front) + { + Color p = c.p; + if (c.surface == PatternSurface.Sleeve) return Cuff(c, v, 0.85f); + if (Mathf.Abs(v - 0.2f) < 0.022f) return new Color(0.82f, 0.84f, 0.86f); // 반사띠 + if (!front) return p; + float cu = Mathf.Abs(u - 0.5f); + float slant = 0.36f + (cu - 0.3f) * 0.6f; + if (cu > 0.24f && cu < 0.4f && Mathf.Abs(v - slant) < 0.012f) return Scale(p, 0.7f); // 사선 주머니 + return p; + } + + private static Color Windbreaker(Ctx c, float u, float v, bool front) + { + Color p = c.p, s = c.s; + if (c.surface == PatternSurface.Sleeve) + { + float d = Mathf.Min(Mathf.Abs(u - 0.25f), Mathf.Abs(u - 0.75f)); // 양팔 바깥쪽 + if (d < 0.03f || (d > 0.05f && d < 0.07f)) return s; + return Cuff(c, v, 0.8f); + } + if (Mathf.Abs(v - 0.62f) < 0.035f) return s; // 가슴 띠 + if (v < 0.05f) return Scale(p, 0.8f); + return p; + } + + private static Color ShadowCoat(Ctx c, float u, float v, bool front) + { + Color p = c.p, s = c.s; + if (c.surface == PatternSurface.Sleeve) return v < 0.1f ? Color.Lerp(p, s, 0.7f) : p; + if (v < 0.04f) return Color.Lerp(p, s, 0.7f); + float d = Mathf.Min(Mathf.Abs(u - 0.3f), Mathf.Abs(u - 0.7f)); + return d < 0.006f ? Color.Lerp(p, s, 0.35f) : p; // 솔기 + } + + private static Color WizardRobe(Ctx c, float u, float v, bool front) + { + Color p = c.p, s = c.s; + if (c.surface == PatternSurface.Sleeve && v < 0.16f) return s; + if (v < 0.07f) return s; // 금테 + for (int i = 0; i < c.dots.Count; i++) + { + Vector3 d = c.dots[i]; + if (d.z < 0.5f && i % 4 != 0) continue; // 드문드문 + float dx = (u - d.x) / c.texelU, dy = (v - d.y) / c.texelV; + float r2 = dx * dx + dy * dy; + if (i % 8 == 0) + { + // 초승달: 큰 원에서 어긋난 원을 뺀다 + float ox = dx - 2.2f, r2b = ox * ox + dy * dy; + if (r2 < 30f && r2b > 22f) return Scale(s, 1.15f); + } + else if ((Mathf.Abs(dx) < 0.8f && Mathf.Abs(dy) < 3.5f) || (Mathf.Abs(dy) < 0.8f && Mathf.Abs(dx) < 3.5f)) + return Scale(s, 1.2f); + } + return p; + } + + private static Color LabOuter(Ctx c, float u, float v, bool front) + { + Color p = c.p; + if (c.surface == PatternSurface.Sleeve) return Cuff(c, v, 0.9f); + if (!front) return p; + float cu = Mathf.Abs(u - 0.5f); + if (cu > 0.2f && cu < 0.4f && v > 0.14f && v < 0.3f && (cu < 0.21f || cu > 0.39f || v < 0.15f || v > 0.29f)) + return Scale(p, 0.82f); // 아래 주머니 + if (u > 0.22f && u < 0.24f && v > 0.62f && v < 0.76f) return new Color(0.2f, 0.35f, 0.8f); // 펜 + return p; + } + + // ── 도구 ── + + private static float SegDistTexels(Ctx c, float u, float v) + { + float best = 1e9f; + for (int i = 0; i < c.segs.Count; i++) + { + Vector4 sg = c.segs[i]; + float ax = sg.x, ay = sg.y, bx = sg.z - sg.x, by = sg.w - sg.y; + float px = u - ax, py = v - ay; + float t = Mathf.Clamp01((px * bx + py * by) / Mathf.Max(1e-8f, bx * bx + by * by)); + float dx = (px - bx * t) / c.texelU, dy = (py - by * t) / c.texelV; + float d = dx * dx + dy * dy; + if (d < best) best = d; + } + return Mathf.Sqrt(best); + } + + private static bool Circle(float u, float v, float cu, float cv, float r, Ctx c) + { + // 아틀라스 앞면은 가로 텍셀이 세로의 2배 밀도(256×128의 절반 = 128×128)라 u·v 스케일이 같다. + float du = u - cu, dv = v - cv; + return du * du + dv * dv < r * r; + } + + private static bool Dashed(float t, float period) => Frac(t / period) < 0.55f; + + private static Color Scale(Color c, float k) => new Color(Mathf.Clamp01(c.r * k), Mathf.Clamp01(c.g * k), Mathf.Clamp01(c.b * k), 1f); + + private static float Frac(float x) => x - Mathf.Floor(x); + + private static float Hash(int x, int y, int seed) + { + unchecked + { + uint h = (uint)(x * 374761393 + y * 668265263 + seed * 144269504); + h = (h ^ (h >> 13)) * 1274126177u; + return ((h ^ (h >> 16)) & 0xFFFFFF) / 16777216f; + } + } + + private static float ValueNoise(float x, float y, int seed) + { + int ix = Mathf.FloorToInt(x), iy = Mathf.FloorToInt(y); + float fx = x - ix, fy = y - iy; + fx = fx * fx * (3f - 2f * fx); + fy = fy * fy * (3f - 2f * fy); + float a = Hash(ix, iy, seed), b = Hash(ix + 1, iy, seed); + float c = Hash(ix, iy + 1, seed), d = Hash(ix + 1, iy + 1, seed); + return Mathf.Lerp(Mathf.Lerp(a, b, fx), Mathf.Lerp(c, d, fx), fy); + } + + private static float Fbm(float x, float y, int seed) + { + return ValueNoise(x, y, seed) * 0.65f + ValueNoise(x * 2.3f, y * 2.3f, seed + 7) * 0.35f; + } + } +} diff --git a/Assets/Scripts/Core/OutfitPatternLibrary.cs.meta b/Assets/Scripts/Core/OutfitPatternLibrary.cs.meta new file mode 100644 index 0000000..ebe66d8 --- /dev/null +++ b/Assets/Scripts/Core/OutfitPatternLibrary.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cd11642b32bdba243ada168d70b56290 \ No newline at end of file diff --git a/Assets/Scripts/Core/OutfitShapeLibrary.cs b/Assets/Scripts/Core/OutfitShapeLibrary.cs index 76f4d49..d3c76c3 100644 --- a/Assets/Scripts/Core/OutfitShapeLibrary.cs +++ b/Assets/Scripts/Core/OutfitShapeLibrary.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using UnityEngine; namespace InsectGame.Core @@ -26,6 +26,53 @@ public enum OutfitAnchor { Root, HatRoot, + /// + /// 몸통(Body). 걷기에서 **몸통만 위아래로 튄다**(PlayerMovement bob ±6cm) — 옷깃·망토·멜빵처럼 몸에 붙은 것은 + /// 여기 붙어야 함께 움직인다. Body는 크기를 메시에 구운 노드라(스케일 1) 자식 좌표가 찌그러지지 않는다. + /// 좌표는 몸통 중심(루트 y 0.77) 기준이다. + /// + Body, + /// 왼다리 관절(LegLPivot, 루트 (−0.13, 0.48, 0)). 반바지·장화·박차처럼 다리와 함께 흔들려야 하는 것. + LegL, + /// 오른다리 관절(LegRPivot). + LegR, + } + + /// 상의 소매. Short면 어깨 캡만 상의 색(반소매), Long이면 팔 전체, None(민소매)이면 어깨까지 피부. + public enum SleeveLength { Short, Long, None } + + /// + /// 겉옷이 몸통을 어떻게 덮는가. OpenJacket = 몸통·팔은 겉옷, 앞섶 사이로 상의(셔츠 판)와 옷깃이 보인다. + /// Robe = 앞까지 여민 옷이라 상의가 안 보인다. Cape = 몸통은 상의 그대로 두고 등 뒤에 망토만 단다. + /// + public enum OuterForm { OpenJacket, Robe, Cape } + + /// 하의 길이. Shorts면 다리 노드를 피부로 칠하고 허벅지 천은 레시피가 단다. + public enum LegForm { Long, Shorts } + + /// 신발 형태. Sandal이면 발 노드를 피부로 칠하고 밑창·끈은 레시피가 단다. + public enum FootForm { Shoe, Sandal } + + /// + /// spawn 파츠의 메시 모양. 기본은 (내장 프리미티브, )이라 + /// 이 필드가 생기기 전의 레시피는 그대로다. 나머지는 생성기이고 인자는 + /// 에 담는다. + /// + public enum PartShape + { + Primitive, + /// 둥근 상자. 크기(scale)를 메시에 **굽고** 노드 스케일은 1로 둔다(모서리 왜곡 방지). args.x = 모서리 반경. + RoundedBox, + /// 테이퍼 캡슐(높이 2·반지름 0.5 규약). args.x = 아래 반지름 비율. + Capsule, + /// 늘어진 천 — . args = (감싸는 각도, 밑단 퍼짐, 주름 수, 주름 깊이). 원점 = 윗변 중앙, 아래로 늘어진다. + Drape, + /// 고리 — . args.x = 관 반지름(단위 큰 반지름 0.5 기준). + Torus, + /// 보석(팔면체) — . 단위 반지름 0.5, 위 0.7 / 아래 0.5. + Gem, + /// 잠자리채 머리(세운 테 + 뒤로 늘어진 그물) — . 원점 = 테 아래 끝. args = (관 반지름, 주머니 깊이). + NetHead, } /// @@ -42,6 +89,18 @@ public struct OutfitPart public PartColorRole role; public Color fixedColor; // role == Fixed 일 때만 의미 + /// 메시 모양. 기본 Primitive면 을 쓴다(2D 카드 투영도 prim을 근사로 쓴다). + public PartShape shape; + public Vector4 shapeArgs; + + /// + /// 파츠별 앵커. 가 false면 레시피 앵커를 따른다 — enum 기본값(Root)과 "지정 안 함"을 + /// 가르기 위해 플래그를 따로 둔다(모자 레시피의 파츠가 조용히 루트로 떨어지는 걸 막는다). + /// 레시피 앵커와 다르면 OP_{슬롯}_{앵커} 컨테이너에 들어간다. + /// + public bool hasAnchor; + public OutfitAnchor anchor; + public bool IsBind => !string.IsNullOrEmpty(bindName); } @@ -104,6 +163,20 @@ internal static string SpawnContainerName(OutfitSlot slot) : SpawnPrefix + slot; } + /// 파츠가 레시피와 다른 앵커를 지정했을 때의 컨테이너 이름(OP_{슬롯}_{앵커}). 이름도 미리 굽는다. + private static readonly Dictionary ExtraContainerNames = new Dictionary(); + + internal static string SpawnContainerName(OutfitSlot slot, OutfitAnchor anchor) + { + int key = (int)slot * 64 + (int)anchor; + if (!ExtraContainerNames.TryGetValue(key, out string name)) + { + name = SpawnPrefix + slot + "_" + anchor; + ExtraContainerNames[key] = name; + } + return name; + } + /// /// 파츠가 쓰는 색. 지금 이 role을 쓰는 레시피는 없다 /// (전부 Primary/Secondary/Fixed 계열) — 그래서 외형별 피부색을 여기까지 끌고 오지 않고 @@ -195,36 +268,20 @@ public static void Apply(Transform root, OutfitSlot slot, OutfitRecipe recipe, { if (root == null) return; - int needSpawn = CountSpawnParts(recipe); - string containerName = SpawnContainerName(slot); - Transform container = FindDeep(root, containerName); + OutfitAnchor recipeAnchor = recipe != null ? recipe.anchor : OutfitAnchor.Root; + string itemId = CountSpawnParts(recipe) > 0 ? ItemIdOf(recipe) : null; - // 도구처럼 bind만 쓰는 슬롯에 빈 컨테이너를 만들지 않는다 — - // 모든 플레이어·마네킹마다 쓸모없는 GameObject가 하나씩 늘어난다. - if (needSpawn > 0) - { - Transform anchor = ResolveAnchor(root, recipe.anchor); - if (anchor == null) anchor = root; - container = EnsureContainer(container, anchor, SpawnPrefix + slot); - } + // spawn 파츠는 앵커별 컨테이너로 간다. 기본 컨테이너(OP_{슬롯})는 레시피 앵커에, + // 파츠가 앵커를 따로 지정하면 OP_{슬롯}_{앵커}에. **모든 앵커를 매번 훑는다** — 반바지(다리) → + // 긴바지(파츠 없음)로 갈아입을 때 다리 컨테이너를 비워야 잔상이 안 남는다. + ApplySpawnGroup(root, slot, recipe, primary, secondary, itemId, recipeAnchor, true); + for (int a = 0; a < AllAnchors.Length; a++) + ApplySpawnGroup(root, slot, recipe, primary, secondary, itemId, AllAnchors[a], false); - int spawnIndex = 0; if (recipe != null && recipe.parts != null) { for (int i = 0; i < recipe.parts.Length; i++) - { - OutfitPart p = recipe.parts[i]; - Color c = ResolveColor(p, primary, secondary); - if (p.IsBind) - { - ApplyBound(root, p); - } - else - { - ApplySpawned(root, container, spawnIndex, p, c); - spawnIndex++; - } - } + if (recipe.parts[i].IsBind) ApplyBound(root, recipe.parts[i]); if (recipe.hideNodes != null) { @@ -235,8 +292,81 @@ public static void Apply(Transform root, OutfitSlot slot, OutfitRecipe recipe, } } } + } + + private static readonly OutfitAnchor[] AllAnchors = (OutfitAnchor[])System.Enum.GetValues(typeof(OutfitAnchor)); - if (container != null) TrimContainer(container, spawnIndex); + /// 파츠가 이 컨테이너 몫인가. 기본 컨테이너는 앵커 미지정 + 레시피 앵커와 같은 지정을 함께 받는다. + internal static bool BelongsTo(OutfitPart p, OutfitAnchor recipeAnchor, OutfitAnchor anchor, bool isDefault) + { + if (p.IsBind) return false; + bool own = !p.hasAnchor || p.anchor == recipeAnchor; + return isDefault ? own : (!own && p.anchor == anchor); + } + + private static void ApplySpawnGroup(Transform root, OutfitSlot slot, OutfitRecipe recipe, Color primary, Color secondary, + string itemId, OutfitAnchor anchor, bool isDefault) + { + OutfitAnchor recipeAnchor = recipe != null ? recipe.anchor : OutfitAnchor.Root; + string name = isDefault ? SpawnContainerName(slot) : SpawnContainerName(slot, anchor); + + int need = 0; + if (recipe != null && recipe.parts != null) + for (int i = 0; i < recipe.parts.Length; i++) + if (BelongsTo(recipe.parts[i], recipeAnchor, anchor, isDefault)) need++; + + Transform container = FindDeep(root, name); + // 도구처럼 bind만 쓰는 슬롯에 빈 컨테이너를 만들지 않는다 — + // 모든 플레이어·마네킹마다 쓸모없는 GameObject가 하나씩 늘어난다. + if (need == 0 && container == null) return; + if (need > 0) + { + Transform parent = ResolveAnchor(root, anchor); + if (parent == null) parent = root; + container = EnsureContainer(container, parent, name); + } + + int spawnIndex = 0; + if (need > 0) + { + for (int i = 0; i < recipe.parts.Length; i++) + { + OutfitPart p = recipe.parts[i]; + if (!BelongsTo(p, recipeAnchor, anchor, isDefault)) continue; + ApplySpawned(root, container, spawnIndex, p, ResolveColor(p, primary, secondary), SurfaceOf(slot, itemId, p.role)); + spawnIndex++; + } + } + TrimContainer(container, spawnIndex); + } + + /// 파츠 모양의 메시. 면 크기가 메시에 구워져 노드 스케일은 1이어야 한다. + internal static Mesh GetPartMesh(OutfitPart p, out bool bakedSize) + { + bakedSize = false; + Vector4 a = p.shapeArgs; + switch (p.shape) + { + case PartShape.RoundedBox: + bakedSize = true; + return ProcMeshLibrary.RoundedBox(p.scale, a.x, 2); + case PartShape.Capsule: + { + float taper = a.x > 0f ? a.x : 1f; + float rTop = 0.5f, rBottom = 0.5f * taper; + return ProcMeshLibrary.TaperedCapsule(rTop, rBottom, 2f - rTop - rBottom, 8, 10); + } + case PartShape.Drape: + return ProcMeshLibrary.DrapeShell(a.x > 0f ? a.x : 180f, a.y > 0f ? a.y : 1f, Mathf.RoundToInt(a.z), a.w); + case PartShape.Torus: + return ProcMeshLibrary.Torus(a.x > 0f ? a.x : 0.06f, 24, 8); + case PartShape.Gem: + return ProcMeshLibrary.Diamond(0.5f, 0.7f, 0.5f); + case PartShape.NetHead: + return ProcMeshLibrary.NetHead(a.x > 0f ? a.x : 0.05f, a.y > 0f ? a.y : 0.8f); + default: + return GetPrimMesh(p.prim); + } } internal static int CountSpawnParts(OutfitRecipe recipe) @@ -253,6 +383,9 @@ private static Transform ResolveAnchor(Transform root, OutfitAnchor anchor) switch (anchor) { case OutfitAnchor.HatRoot: return FindDeep(root, "HatRoot"); + case OutfitAnchor.Body: return FindDeep(root, "Body"); + case OutfitAnchor.LegL: return FindDeep(root, "LegLPivot"); + case OutfitAnchor.LegR: return FindDeep(root, "LegRPivot"); default: return root; } } @@ -291,13 +424,25 @@ private static void ApplyBound(Transform root, OutfitPart p) if (t == null) return; MeshFilter mf = t.GetComponent(); - if (mf != null) mf.sharedMesh = GetPrimMesh(p.prim); + Mesh mesh = GetPartMesh(p, out bool baked); + if (mf != null) mf.sharedMesh = mesh; t.localPosition = p.pos; - t.localScale = p.scale; + t.localScale = baked ? Vector3.one : p.scale; t.localRotation = Quaternion.Euler(p.euler); + + // 예외 하나: 도구 머리(NetRing)의 **재질**. 빌더는 금속으로 짓는다(총구·렌즈·칼날·오브). + // 그런데 잠자리채 그물까지 금속이면 주변을 반사해 그물 안쪽이 새까맣게 보인다 — + // 그물 머리일 때만 천, 나머지는 금속으로 되돌린다(색은 여전히 ApplyPartColor 몫이다). + if (p.bindName == "NetRing") + { + MeshRenderer r = t.GetComponent(); + if (r != null && r.sharedMaterial != null) + CharacterPalette.ApplySurface(r.sharedMaterial, p.shape == PartShape.NetHead ? SurfaceKind.Cloth : SurfaceKind.Metal); + } } - private static void ApplySpawned(Transform root, Transform container, int index, OutfitPart p, Color c) + private static void ApplySpawned(Transform root, Transform container, int index, OutfitPart p, Color c, + SurfaceKind surface) { Transform t = index < container.childCount ? container.GetChild(index) : null; if (t == null) @@ -314,9 +459,10 @@ private static void ApplySpawned(Transform root, Transform container, int index, t.gameObject.SetActive(true); MeshFilter mf = t.GetComponent(); - if (mf != null) mf.sharedMesh = GetPrimMesh(p.prim); + Mesh mesh = GetPartMesh(p, out bool baked); + if (mf != null) mf.sharedMesh = mesh; t.localPosition = p.pos; - t.localScale = p.scale; + t.localScale = baked ? Vector3.one : p.scale; t.localRotation = Quaternion.Euler(p.euler); MeshRenderer mr = t.GetComponent(); @@ -326,6 +472,9 @@ private static void ApplySpawned(Transform root, Transform container, int index, // renderer.material(getter)을 쓰면 파츠마다 인스턴스가 하나씩 더 생겨 샌다. mr.sharedMaterial.color = c; if (mr.sharedMaterial.HasProperty("_BaseColor")) mr.sharedMaterial.SetColor("_BaseColor", c); + // 재질도 색처럼 매번 다시 준다 — 같은 자식이 왕관(금속)에서 밀짚모자(천)로 재사용된다. + // 반투명 색에는 걸지 않는다(SceneryMaterials.ApplyFinish와 같은 규칙). 지금 레시피엔 그런 색이 없다. + if (!SceneryMaterials.IsTranslucent(c)) CharacterPalette.ApplySurface(mr.sharedMaterial, surface); } } @@ -370,17 +519,16 @@ private static void TrimContainer(Transform container, int used) /// /// spawn 파츠용 머티리얼. 셰이더는 캐릭터가 이미 쓰고 있는 것을 그대로 빌린다 — /// Standard/URP/Unlit 중 어느 것이 잡혔든 자동으로 맞고, 파이프라인 판정이 한 곳에만 있게 된다 - /// (PlayerVisualBuilder.MakeMaterial의 fallback 체인이 그 한 곳이다). + /// (의 폴백 체인이 그 한 곳이다 — PlayerVisualBuilder.MakeMaterial도 거기서 받는다). + /// 재질(광택)은 여기서 주지 않는다 — 파츠가 재사용될 때마다 바뀌므로 가 매번 준다 + /// (). /// private static Material CreatePartMaterial(Transform root, Color c) { Shader sh = null; MeshRenderer any = root.GetComponentInChildren(true); if (any != null && any.sharedMaterial != null) sh = any.sharedMaterial.shader; - if (sh == null) sh = Shader.Find("Standard"); - if (sh == null) sh = Shader.Find("Universal Render Pipeline/Lit"); - if (sh == null) sh = Shader.Find("Unlit/Color"); - if (sh == null) sh = Shader.Find("Sprites/Default"); + if (sh == null) sh = SceneryMaterials.LitShader; Material m = new Material(sh); m.color = c; @@ -407,6 +555,185 @@ internal static Color Darken(Color c) return new Color(c.r * 0.7f, c.g * 0.7f, c.b * 0.7f, c.a); } + // ── 파츠 재질(광택) ────────────────────────────────── + // + // spawn 파츠는 오래 셰이더 기본 광택(0.5)으로 그려졌다 — 밀짚모자·망토·스카프가 전부 플라스틱처럼 반짝였고, + // 바로 옆 bind 노드(Cap·Backpack·Body)는 PlayerVisualBuilder.MakeMaterial이 부위 재질을 줘서 한 옷이 두 재료로 + // 갈라져 보였다. 수치는 CharacterPalette 재질표를 그대로 쓴다(사본 없음). + // + // OutfitPart에 재질 필드를 두지 않는다(스키마는 data-architect 경계). 슬롯 기본값 + 아이템별 예외 표로 가른다. + // spawn 파츠엔 이름이 없어(bindName은 bind 전용) 파츠 단위가 아니라 **색 역할** 단위로 가른다 — + // 같은 역할이면 같은 색이고, 같은 색이면 대개 같은 재료다. + + /// + /// 아이템 하나의 재질 — 한 재질에 한 색 역할만 다를 수 있다(왕관의 보석, 수정구의 구슬). + /// 지금 레시피엔 예외 역할이 둘 이상 필요한 아이템이 없다. + /// + internal readonly struct ItemSurface + { + public SurfaceKind Kind { get; } + public bool HasExcept { get; } + public PartColorRole ExceptRole { get; } + public SurfaceKind ExceptKind { get; } + + public ItemSurface(SurfaceKind kind) + { + Kind = kind; + HasExcept = false; + ExceptRole = default; + ExceptKind = kind; + } + + public ItemSurface(SurfaceKind kind, PartColorRole exceptRole, SurfaceKind exceptKind) + { + Kind = kind; + HasExcept = true; + ExceptRole = exceptRole; + ExceptKind = exceptKind; + } + + public SurfaceKind For(PartColorRole role) => HasExcept && role == ExceptRole ? ExceptKind : Kind; + } + + /// + /// 광택이 제 질감인 아이템만 적는다 — 나머지는 (천·가죽)라 무광 계열이다. + /// 키가 실재하는 레시피인지, 예외 역할이 그 레시피에 실제로 있는지는 OutfitPartSurfaceTests가 고정한다 + /// (오타면 예외도 경고도 없이 조용히 천으로 그려진다). + /// + /// 일부러 뺀 것: 꽃 왕관의 금색 꽃술(꽃이다), 마법사 모자 끝 금색 구슬(수놓은 별 모자다), 히어로 마스크 눈 + /// (보조색 역할이 이마 장식과 같다), 오라·후광·네온 팔찌(빛이지 반사가 아니다 — 광택으로는 안 산다). + /// + private static readonly Dictionary SurfaceOverrides = new Dictionary + { + // 금속 — 왕관 금테·꼭지 장식, 배지, 군번줄. 왕관의 보석(보조색)만 유리 광택. + ["hat_crown"] = new ItemSurface(SurfaceKind.Metal, PartColorRole.Secondary, SurfaceKind.Wet), + ["acc_badge"] = new ItemSurface(SurfaceKind.Metal), + ["acc_dog_tag"] = new ItemSurface(SurfaceKind.Metal), + + // 줄·받침은 금속, 알(수정구·펜던트, 기본색)은 유리 광택. + ["acc_crystal_orb"] = new ItemSurface(SurfaceKind.Metal, PartColorRole.Primary, SurfaceKind.Wet), + ["acc_pendant"] = new ItemSurface(SurfaceKind.Metal, PartColorRole.Primary, SurfaceKind.Wet), + + // 렌즈 — 바이저 렌즈(와 같은 색의 안테나)만 유리 광택, 프레임은 모자 기본(천). + ["hat_cyber_visor"] = new ItemSurface(SurfaceKind.Cloth, PartColorRole.Secondary, SurfaceKind.Wet), + + // 잠금쇠·시료 탱크(보조색)는 금속, 손잡이는 가방 기본(가죽). + ["bag_science"] = new ItemSurface(SurfaceKind.Leather, PartColorRole.Secondary, SurfaceKind.Metal), + + // 가죽 — 카탈로그 설명이 가죽인 모자("가죽 모자"), 안대, 뿔테. + ["hat_cowboy"] = new ItemSurface(SurfaceKind.Leather), + ["bot_cowboy"] = new ItemSurface(SurfaceKind.Leather), // 레시피가 가죽 챕스뿐이다(청바지는 텍스처) + ["shoe_crystal"] = new ItemSurface(SurfaceKind.Wet), // 레시피가 발등 보석뿐이다 + ["outer_crystal"] = new ItemSurface(SurfaceKind.Wet), // 레시피가 어깨 결정뿐이다 + ["acc_eyepatch"] = new ItemSurface(SurfaceKind.Leather), + ["acc_glasses"] = new ItemSurface(SurfaceKind.Leather), + }; + + internal static IEnumerable> SurfaceOverrideEntries() => SurfaceOverrides; + + /// + /// 슬롯 기본 재질 — bind 노드가 PlayerVisualBuilder에서 받는 재질에 맞춘다(모자 Cap·옷 = 천, + /// 부츠·배낭 = 가죽). 덧붙인 파츠가 그 노드와 다르면 한 아이템이 두 재료로 갈라져 보인다. + /// 도구는 전부 bind라(손잡이 가죽·망 금속은 빌더 몫) 여기 올 일이 없다. + /// + // ── 몸 전체 형태(스타일) ───────────────────────────── + // + // 레시피가 "덧붙이는 것"을 정한다면, 스타일은 **기존 노드를 누가 칠하는지**를 정한다 — + // 소매 길이(팔을 피부로 둘지 상의 색으로 둘지), 겉옷 형태(몸통을 겉옷이 덮는지·앞이 열렸는지·망토라 안 덮는지), + // 반바지(다리를 피부로), 샌들(발을 피부로). 판정은 CharacterOutfitManager.ApplyToCharacter가 한다. + // 표에 없는 아이템은 기본값(반소매·열린 자켓·긴 바지·신발)이다. + + private static readonly Dictionary TopSleeves = new Dictionary + { + ["top_lab"] = SleeveLength.Long, + ["top_cowboy"] = SleeveLength.Long, // 조끼 속 체크 셔츠 + ["top_hero_suit"] = SleeveLength.Long, + ["top_ninja"] = SleeveLength.Long, + ["top_pirate"] = SleeveLength.Long, + ["top_cyber"] = SleeveLength.Long, + ["top_military"] = SleeveLength.Long, + }; + + /// 소매가 상의의 **보조색**인 옷 — 조끼는 소매가 속셔츠(흰색)다. + private static readonly HashSet SleeveUsesSecondary = new HashSet { "top_vest" }; + + private static readonly Dictionary OuterForms = new Dictionary + { + ["outer_legendary"] = OuterForm.Cape, + ["outer_wizard"] = OuterForm.Robe, + }; + + private static readonly Dictionary LegForms = new Dictionary + { + ["bot_shorts"] = LegForm.Shorts, + }; + + private static readonly Dictionary FootForms = new Dictionary + { + ["shoe_sandals"] = FootForm.Sandal, + }; + + public static SleeveLength SleeveOf(string topId) => + topId != null && TopSleeves.TryGetValue(topId, out SleeveLength v) ? v : SleeveLength.Short; + + public static bool SleeveIsSecondary(string topId) => topId != null && SleeveUsesSecondary.Contains(topId); + + public static OuterForm OuterFormOf(string outerId) => + outerId != null && OuterForms.TryGetValue(outerId, out OuterForm v) ? v : OuterForm.OpenJacket; + + public static LegForm LegFormOf(string bottomId) => + bottomId != null && LegForms.TryGetValue(bottomId, out LegForm v) ? v : LegForm.Long; + + public static FootForm FootFormOf(string shoeId) => + shoeId != null && FootForms.TryGetValue(shoeId, out FootForm v) ? v : FootForm.Shoe; + + internal static IEnumerable StyledItemIds() + { + foreach (string k in TopSleeves.Keys) yield return k; + foreach (string k in SleeveUsesSecondary) yield return k; + foreach (string k in OuterForms.Keys) yield return k; + foreach (string k in LegForms.Keys) yield return k; + foreach (string k in FootForms.Keys) yield return k; + } + + internal static SurfaceKind DefaultSurface(OutfitSlot slot) + { + switch (slot) + { + case OutfitSlot.Shoes: + case OutfitSlot.Backpack: + return SurfaceKind.Leather; + default: + return SurfaceKind.Cloth; + } + } + + /// spawn 파츠 하나의 재질. 순수 함수라 셰이더 없이 테스트한다. + internal static SurfaceKind SurfaceOf(OutfitSlot slot, string itemId, PartColorRole role) + { + if (itemId != null && SurfaceOverrides.TryGetValue(itemId, out ItemSurface s)) return s.For(role); + return DefaultSurface(slot); + } + + /// + /// 레시피 → itemId 역참조. 는 itemId를 받지 않는다 — 의 값이 + /// 아이템마다 고유 인스턴스라(테스트가 고정) 레시피가 곧 아이템이다. 호출부에 인자를 늘리면 레시피와 id가 + /// 어긋나게 넘어올 자리가 생긴다. 도구 레시피는 여러 id가 한 인스턴스를 나누지만 전부 bind라 재질을 묻지 않는다. + /// + private static Dictionary recipeItemIds; + + internal static string ItemIdOf(OutfitRecipe recipe) + { + if (recipe == null) return null; + if (recipeItemIds == null) + { + Dictionary map = new Dictionary(); + foreach (KeyValuePair kv in ExactRecipes) map[kv.Value] = kv.Key; + recipeItemIds = map; + } + return recipeItemIds.TryGetValue(recipe, out string id) ? id : null; + } + // ── 프리미티브 메시 캐시 ────────────────────────────── // // PrimitiveType별 sharedMesh를 1회 추출해 재사용. CreatePrimitive를 매 적용마다 부르면 @@ -473,8 +800,129 @@ private static OutfitPart SF(PrimitiveType prim, Vector3 pos, Vector3 scale, Col }; } + // 고정색은 레시피 표(ExactRecipes)보다 **먼저** 선언한다 — 정적 초기화는 적힌 순서라 뒤에 두면 표가 검정(0,0,0)을 읽는다. private static readonly Color Gold = new Color(1f, 0.9f, 0.32f); private static readonly Color Bone = new Color(0.95f, 0.94f, 0.9f); + private static readonly Color SoleWhite = new Color(0.95f, 0.95f, 0.94f); + private static readonly Color Steel = new Color(0.6f, 0.62f, 0.66f); + + /// spawn 파츠 + 고정색 + 회전. + private static OutfitPart SF(PrimitiveType prim, Vector3 pos, Vector3 scale, Color color, Vector3 euler) + { + OutfitPart p = SF(prim, pos, scale, color); + p.euler = euler; + return p; + } + + /// 파츠에 모양을 준다(bind 파츠에도 쓴다 — 잠자리채 머리처럼 노드 메시를 통째로 바꿀 때). + private static OutfitPart WithShape(OutfitPart p, PartShape shape, Vector4 args) + { + p.shape = shape; + p.shapeArgs = args; + return p; + } + + /// 보석(팔면체) — 크리스털 결정·왕관 뾰족 장식. + private static OutfitPart GemPart(Vector3 pos, Vector3 scale, Vector3 euler, PartColorRole role) + { + return new OutfitPart { prim = PrimitiveType.Sphere, shape = PartShape.Gem, pos = pos, scale = scale, euler = euler, role = role }; + } + + /// 모양 헬퍼로 만든 파츠에 고정색을 준다. + private static OutfitPart Fixed(OutfitPart p, Color color) + { + p.role = PartColorRole.Fixed; + p.fixedColor = color; + return p; + } + + // ── 모양·앵커 헬퍼(2026-09-30) ── + // prim은 2D 카드 투영(CharacterPortraitRenderer)이 근사로 쓰므로 모양에 가까운 것을 채워 둔다. + + /// 파츠에 앵커를 준다 — 레시피 앵커와 다르면 OP_{슬롯}_{앵커} 컨테이너로 간다. + private static OutfitPart At(OutfitAnchor anchor, OutfitPart p) + { + p.hasAnchor = true; + p.anchor = anchor; + return p; + } + + /// 둥근 상자 — 크기를 메시에 굽는다(노드 스케일 1). + private static OutfitPart RB(Vector3 pos, Vector3 size, float radius, Vector3 euler, PartColorRole role) + { + return new OutfitPart + { + prim = PrimitiveType.Cube, shape = PartShape.RoundedBox, shapeArgs = new Vector4(radius, 0f, 0f, 0f), + pos = pos, scale = size, euler = euler, role = role, + }; + } + + /// 테이퍼 캡슐(높이 2·반지름 0.5 규약 × scale). taper = 아래 반지름 비율. + private static OutfitPart Cap(Vector3 pos, Vector3 scale, float taper, Vector3 euler, PartColorRole role) + { + return new OutfitPart + { + prim = PrimitiveType.Capsule, shape = PartShape.Capsule, shapeArgs = new Vector4(taper, 0f, 0f, 0f), + pos = pos, scale = scale, euler = euler, role = role, + }; + } + + /// 늘어진 천 — 원점 = 윗변 중앙, 뒤(−Z)를 감싸며 아래로. scale = (지름 x, 길이, 지름 z). + private static OutfitPart Drape(Vector3 pos, Vector3 scale, float arcDeg, float flare, int folds, float foldDepth, + Vector3 euler, PartColorRole role) + { + return new OutfitPart + { + prim = PrimitiveType.Cube, shape = PartShape.Drape, shapeArgs = new Vector4(arcDeg, flare, folds, foldDepth), + pos = pos, scale = scale, euler = euler, role = role, + }; + } + + /// 고리(토러스) — scale = (바깥 지름 근사 x, 관 두께 배율 y, 지름 z). tube = 관 반지름(단위 큰 반지름 0.5 기준). + private static OutfitPart Ring(Vector3 pos, Vector3 scale, float tube, Vector3 euler, PartColorRole role) + { + return new OutfitPart + { + prim = PrimitiveType.Cylinder, shape = PartShape.Torus, shapeArgs = new Vector4(tube, 0f, 0f, 0f), + pos = pos, scale = scale, euler = euler, role = role, + }; + } + + /// 좌우 거울(x·Y회전·Z회전 반전). + internal static OutfitPart Mirror(OutfitPart p) + { + p.pos.x = -p.pos.x; + p.euler.y = -p.euler.y; + p.euler.z = -p.euler.z; + return p; + } + + /// + /// 양다리. 오른다리 관절 기준 좌표(다리 중심 x = 0, 바깥쪽 = +X)로 적으면 왼다리는 거울로 만든다. + /// 다리 관절 로컬: 무릎 ≈ y −0.14, 발목 ≈ y −0.3, 부츠 중심 (0, −0.36, 0.07). + /// + private static OutfitPart[] Legs(params OutfitPart[] rightLeg) + { + OutfitPart[] all = new OutfitPart[rightLeg.Length * 2]; + for (int i = 0; i < rightLeg.Length; i++) + { + all[i * 2] = At(OutfitAnchor.LegR, rightLeg[i]); + all[i * 2 + 1] = At(OutfitAnchor.LegL, Mirror(rightLeg[i])); + } + return all; + } + + private static OutfitPart[] Concat(params OutfitPart[][] groups) + { + List all = new List(); + for (int i = 0; i < groups.Length; i++) all.AddRange(groups[i]); + return all.ToArray(); + } + + // 몸통 앞면(몸통 로컬 z). 남 0.19 / 여 0.17 — 레시피는 성별을 모르므로 그 사이에 둔다(여자에선 0.01 뜬다). + private const float TorsoFront = 0.18f; + // 몸통 밑단(몸통 로컬 y). 몸통 높이 0.46의 절반. + private const float TorsoHem = -0.23f; // ── 도구(Tool) — 현행 ApplyToolShape 9분기의 파리티 이전 ── // @@ -520,7 +968,9 @@ internal struct ToolEntry parts = new[] { B("NetHandle", PrimitiveType.Cylinder, V(0.29f, 0.65f, 0f), V(0.04f, 0.25f, 0.04f), V(20f, 0f, -12f), PartColorRole.Primary), - B("NetRing", PrimitiveType.Cylinder, V(0.35f, 0.94f, 0.06f), V(0.28f, 0.02f, 0.28f), V(-20f, 0f, 0f), PartColorRole.Secondary), + // 고리는 토러스(밧줄 고리) — 원기둥 판이면 구멍이 막혀 올가미로 안 읽힌다. + WithShape(B("NetRing", PrimitiveType.Cylinder, V(0.35f, 0.94f, 0.06f), V(0.28f, 0.28f, 0.28f), V(-20f, 0f, 0f), PartColorRole.Secondary), + PartShape.Torus, new Vector4(0.07f, 0f, 0f, 0f)), }, }}, @@ -591,15 +1041,17 @@ internal struct ToolEntry }, }}, - // 기본 잠자리채(else). 망 디스크의 X축 -20°는 절대 건드리지 않는다 — 옛 rot(0,0,90)은 - // 법선이 ±X라 부감 카메라에서 edge-on으로 collapse해 "망이 사라지고 손잡이만 남던" 회귀의 원인. + // 기본 잠자리채(else). 머리는 **세운 테 + 뒤로 늘어진 그물**(ProcMeshLibrary.NetHead, 원점 = 테 아래 끝). + // 옛 모양은 막힌 원판이라 자루와 합쳐 망치로 보였다(2026-09-30 전수 캡처). 테가 XY 평면이라 + // 뒤에서 내려다보는 필드 카메라에 정면으로 잡힌다 — 옛 원판이 edge-on으로 사라지던 회귀(rot(0,0,90))와 반대 방향이다. new ToolEntry { keys = null, recipe = new OutfitRecipe { anchor = OutfitAnchor.Root, parts = new[] { - B("NetHandle", PrimitiveType.Cylinder, V(0.29f, 0.74f, 0.02f), V(0.04f, 0.40f, 0.04f), V(20f, 0f, -15f), PartColorRole.Primary), - B("NetRing", PrimitiveType.Cylinder, V(0.34f, 1.14f, 0.06f), V(0.20f, 0.02f, 0.20f), V(-20f, 0f, 0f), PartColorRole.Secondary), + B("NetHandle", PrimitiveType.Cylinder, V(0.29f, 0.74f, 0f), V(0.04f, 0.40f, 0.04f), Vector3.zero, PartColorRole.Primary), + WithShape(B("NetRing", PrimitiveType.Cylinder, V(0.29f, 1.14f, 0f), V(0.22f, 0.22f, 0.22f), V(-25f, 0f, 0f), PartColorRole.Secondary), + PartShape.NetHead, new Vector4(0.05f, 0.85f, 0f, 0f)), }, }}, }; @@ -612,26 +1064,37 @@ internal struct ToolEntry // y 0.22에서 0.53 / y 0.24에서 0.50 / y 0.28에서 0.37 / y 0.29에서 0.365. private static readonly string[] HideCap = { "Cap", "CapBrim" }; + + /// + /// 머리카락 중 모자 선 위로 솟는 부분(올림머리 스파이크·번)을 담는 PlayerVisualBuilder의 컨테이너. + /// 정수리를 덮는 모자는 이것까지 숨긴다. 머리띠·마스크·바이저처럼 정수리가 열린 모자는 만 쓴다 + /// (OutfitShapeLibraryTests가 "덮는 모자는 숨기고 열린 모자 목록은 명시"를 고정한다). + /// 레시피가 없는 모자(기본 캡 계열)는 CharacterOutfitManager가 Cap이 보일 때 숨긴다. + /// + public const string HairCrownNode = "HairCrown"; + + private static readonly string[] HideCapAndCrown = { "Cap", "CapBrim", HairCrownNode }; private static readonly string[] HideBackpack = { "Backpack" }; private static readonly Dictionary ExactRecipes = new Dictionary { - // 밀짚모자: 넓고 얇은 챙 + 낮은 돔 + 띠 + // 밀짚모자: 머리의 1.5배 넓은 챙 + 낮은 돔 + 띠. 옛 챙(지름 0.68)은 머리(0.70)보다 좁아 띠처럼 보였다. ["hat_straw"] = new OutfitRecipe { - anchor = OutfitAnchor.HatRoot, hideNodes = HideCap, + anchor = OutfitAnchor.HatRoot, hideNodes = HideCapAndCrown, parts = new[] { - S(PrimitiveType.Cylinder, V(0f, 0.20f, 0f), V(0.68f, 0.015f, 0.68f), PartColorRole.Primary), - S(PrimitiveType.Cylinder, V(0f, 0.30f, 0f), V(0.36f, 0.10f, 0.36f), PartColorRole.Primary), - S(PrimitiveType.Cylinder, V(0f, 0.245f, 0f), V(0.54f, 0.02f, 0.54f), PartColorRole.PrimaryDark), + S(PrimitiveType.Cylinder, V(0f, 0.20f, 0f), V(1.04f, 0.016f, 1.04f), PartColorRole.Primary), + S(PrimitiveType.Sphere, V(0f, 0.27f, 0f), V(0.66f, 0.34f, 0.66f), PartColorRole.Primary), + S(PrimitiveType.Cylinder, V(0f, 0.24f, 0f), V(0.67f, 0.035f, 0.67f), PartColorRole.PrimaryDark), + Ring(V(0f, 0.205f, 0f), V(1.04f, 0.2f, 1.04f), 0.02f, Vector3.zero, PartColorRole.PrimaryDark), }, }, // 사파리 헬멧: 중간 챙 + 반구 돔 + 정수리 능선 ["hat_safari"] = new OutfitRecipe { - anchor = OutfitAnchor.HatRoot, hideNodes = HideCap, + anchor = OutfitAnchor.HatRoot, hideNodes = HideCapAndCrown, parts = new[] { S(PrimitiveType.Cylinder, V(0f, 0.19f, 0.01f), V(0.62f, 0.022f, 0.60f), PartColorRole.Primary), @@ -640,6 +1103,19 @@ internal struct ToolEntry }, }, + // 프로 탐험가 모자: 앞뒤로 긴 챙 + 앞이 눌린 크라운 + 금색 띠(보조색). 오래 레시피가 없어 캡 색만 바뀌었다. + ["hat_explorer_pro"] = new OutfitRecipe + { + anchor = OutfitAnchor.HatRoot, hideNodes = HideCapAndCrown, + parts = new[] + { + SR(PrimitiveType.Cylinder, V(0f, 0.2f, 0.02f), V(0.86f, 0.02f, 0.94f), V(-4f, 0f, 0f), PartColorRole.Primary), + S(PrimitiveType.Sphere, V(0f, 0.3f, 0f), V(0.62f, 0.4f, 0.62f), PartColorRole.Primary), + S(PrimitiveType.Cube, V(0f, 0.44f, 0.04f), V(0.08f, 0.05f, 0.3f), PartColorRole.PrimaryDark), + S(PrimitiveType.Cylinder, V(0f, 0.25f, 0f), V(0.63f, 0.045f, 0.63f), PartColorRole.Secondary), + }, + }, + // 꽃 왕관: 줄기 링 + 꽃잎 4 + 꽃술 ["hat_flower"] = new OutfitRecipe { @@ -658,7 +1134,7 @@ internal struct ToolEntry // 장수풍뎅이 투구: 반구 투구 + 테 + 앞으로 뻗은 뿔 ["hat_beetle"] = new OutfitRecipe { - anchor = OutfitAnchor.HatRoot, hideNodes = HideCap, + anchor = OutfitAnchor.HatRoot, hideNodes = HideCapAndCrown, parts = new[] { S(PrimitiveType.Sphere, V(0f, 0.18f, -0.01f), V(0.78f, 0.48f, 0.78f), PartColorRole.Primary), @@ -668,19 +1144,21 @@ internal struct ToolEntry }, }, - // 곤충왕 왕관: 띠 + 뾰족 4 + 보석 + // 곤충왕 왕관: 머리(머리카락) 위에 얹히는 굵은 띠 + 보석 뾰족 장식 5 + 앞 보석 ["hat_crown"] = new OutfitRecipe { - anchor = OutfitAnchor.HatRoot, hideNodes = HideCap, + anchor = OutfitAnchor.HatRoot, hideNodes = HideCapAndCrown, parts = new[] { - S(PrimitiveType.Cylinder, V(0f, 0.29f, 0f), V(0.40f, 0.05f, 0.40f), PartColorRole.Primary), - S(PrimitiveType.Cube, V(0f, 0.40f, 0.17f), V(0.07f, 0.15f, 0.05f), PartColorRole.Primary), - S(PrimitiveType.Cube, V(0f, 0.40f, -0.17f), V(0.07f, 0.15f, 0.05f), PartColorRole.Primary), - S(PrimitiveType.Cube, V(-0.17f, 0.40f, 0f), V(0.05f, 0.15f, 0.07f), PartColorRole.Primary), - S(PrimitiveType.Cube, V(0.17f, 0.40f, 0f), V(0.05f, 0.15f, 0.07f), PartColorRole.Primary), - S(PrimitiveType.Sphere, V(0f, 0.30f, 0.20f), V(0.09f, 0.09f, 0.06f), PartColorRole.Secondary), - SF(PrimitiveType.Sphere, V(0f, 0.49f, 0.17f), V(0.055f, 0.055f, 0.055f), Gold), + S(PrimitiveType.Cylinder, V(0f, 0.31f, 0f), V(0.6f, 0.07f, 0.6f), PartColorRole.Primary), + Ring(V(0f, 0.25f, 0f), V(0.62f, 0.3f, 0.62f), 0.05f, Vector3.zero, PartColorRole.Primary), + GemPart(V(0f, 0.44f, 0.27f), V(0.1f, 0.2f, 0.1f), Vector3.zero, PartColorRole.Primary), + GemPart(V(0.26f, 0.43f, 0.08f), V(0.09f, 0.17f, 0.09f), Vector3.zero, PartColorRole.Primary), + GemPart(V(-0.26f, 0.43f, 0.08f), V(0.09f, 0.17f, 0.09f), Vector3.zero, PartColorRole.Primary), + GemPart(V(0.16f, 0.43f, -0.22f), V(0.09f, 0.17f, 0.09f), Vector3.zero, PartColorRole.Primary), + GemPart(V(-0.16f, 0.43f, -0.22f), V(0.09f, 0.17f, 0.09f), Vector3.zero, PartColorRole.Primary), + S(PrimitiveType.Sphere, V(0f, 0.31f, 0.3f), V(0.1f, 0.1f, 0.06f), PartColorRole.Secondary), + SF(PrimitiveType.Sphere, V(0f, 0.55f, 0.27f), V(0.05f, 0.05f, 0.05f), Gold), }, }, @@ -700,16 +1178,18 @@ internal struct ToolEntry }, }, - // 카우보이 모자: 앞뒤로 좁은 넓은 챙 + 높은 크라운 + 정수리 홈 + 띠 + // 카우보이 모자: 넓은 챙(양옆이 말려 올라감) + 높은 크라운 + 정수리 홈 + 띠 ["hat_cowboy"] = new OutfitRecipe { - anchor = OutfitAnchor.HatRoot, hideNodes = HideCap, + anchor = OutfitAnchor.HatRoot, hideNodes = HideCapAndCrown, parts = new[] { - S(PrimitiveType.Cylinder, V(0f, 0.20f, 0f), V(0.64f, 0.022f, 0.50f), PartColorRole.Primary), - S(PrimitiveType.Cylinder, V(0f, 0.33f, 0f), V(0.34f, 0.16f, 0.34f), PartColorRole.Primary), - S(PrimitiveType.Cube, V(0f, 0.45f, 0f), V(0.09f, 0.06f, 0.30f), PartColorRole.PrimaryDark), - S(PrimitiveType.Cylinder, V(0f, 0.28f, 0f), V(0.40f, 0.03f, 0.40f), PartColorRole.Secondary), + S(PrimitiveType.Cylinder, V(0f, 0.20f, 0f), V(0.62f, 0.02f, 0.92f), PartColorRole.Primary), + SR(PrimitiveType.Cylinder, V(-0.33f, 0.25f, 0f), V(0.32f, 0.02f, 0.78f), V(0f, 0f, -32f), PartColorRole.Primary), + SR(PrimitiveType.Cylinder, V(0.33f, 0.25f, 0f), V(0.32f, 0.02f, 0.78f), V(0f, 0f, 32f), PartColorRole.Primary), + S(PrimitiveType.Cylinder, V(0f, 0.34f, 0f), V(0.52f, 0.15f, 0.56f), PartColorRole.Primary), + S(PrimitiveType.Cube, V(0f, 0.48f, 0f), V(0.10f, 0.05f, 0.34f), PartColorRole.PrimaryDark), + S(PrimitiveType.Cylinder, V(0f, 0.25f, 0f), V(0.54f, 0.035f, 0.58f), PartColorRole.Secondary), }, }, @@ -731,7 +1211,7 @@ internal struct ToolEntry // 닌자 두건: 정수리~뒤통수만 덮는 납작한 구(얼굴 z 0.30은 비워 둔다) + 입가리개 + 이마띠 + 꼬리 ["hat_ninja"] = new OutfitRecipe { - anchor = OutfitAnchor.HatRoot, hideNodes = HideCap, + anchor = OutfitAnchor.HatRoot, hideNodes = HideCapAndCrown, parts = new[] { S(PrimitiveType.Sphere, V(0f, 0.14f, -0.04f), V(0.76f, 0.56f, 0.72f), PartColorRole.Primary), @@ -744,7 +1224,7 @@ internal struct ToolEntry // 해적 삼각모: 넓은 챙 + 낮은 크라운 + 세 방향 접힌 챙 + 해골 ["hat_pirate"] = new OutfitRecipe { - anchor = OutfitAnchor.HatRoot, hideNodes = HideCap, + anchor = OutfitAnchor.HatRoot, hideNodes = HideCapAndCrown, parts = new[] { S(PrimitiveType.Cylinder, V(0f, 0.24f, 0f), V(0.60f, 0.025f, 0.54f), PartColorRole.Primary), @@ -774,7 +1254,7 @@ internal struct ToolEntry // 마법사 모자: 넓은 챙 + 4단으로 좁아지며 앞으로 휘는 원뿔 + 띠 + 별 ["hat_wizard"] = new OutfitRecipe { - anchor = OutfitAnchor.HatRoot, hideNodes = HideCap, + anchor = OutfitAnchor.HatRoot, hideNodes = HideCapAndCrown, parts = new[] { S(PrimitiveType.Cylinder, V(0f, 0.22f, 0f), V(0.66f, 0.02f, 0.66f), PartColorRole.Primary), @@ -790,7 +1270,7 @@ internal struct ToolEntry // 군용 헬멧: 챙 없는 돔 + 테 + 턱끈 + 위장 밴드 ["hat_military"] = new OutfitRecipe { - anchor = OutfitAnchor.HatRoot, hideNodes = HideCap, + anchor = OutfitAnchor.HatRoot, hideNodes = HideCapAndCrown, parts = new[] { S(PrimitiveType.Sphere, V(0f, 0.20f, -0.01f), V(0.78f, 0.50f, 0.80f), PartColorRole.Primary), @@ -813,9 +1293,18 @@ internal struct ToolEntry anchor = OutfitAnchor.Root, parts = new[] { - S(PrimitiveType.Cube, V(-0.072f, 1.20f, 0.21f), V(0.10f, 0.09f, 0.02f), PartColorRole.Primary), - S(PrimitiveType.Cube, V(0.072f, 1.20f, 0.21f), V(0.10f, 0.09f, 0.02f), PartColorRole.Primary), - S(PrimitiveType.Cube, V(0f, 1.20f, 0.21f), V(0.05f, 0.02f, 0.02f), PartColorRole.Primary), + // 테(위·아래·바깥·안쪽 막대)만 — 렌즈 자리는 비운다. 예전엔 z 0.21의 속이 찬 판이라 + // 눈이 z 0.22~0.25(눈꺼풀 포함)로 앞에 나온 뒤로는 판이 눈 **뒤**에 묻혀 거의 안 보였다. + // 눈 중심 월드 (±0.072, 1.202), 크기 0.09×0.10 → 테는 한 치수 크게, 눈꺼풀(0.249) 앞 z 0.26. + S(PrimitiveType.Cube, V(-0.072f, 1.255f, 0.26f), V(0.115f, 0.014f, 0.012f), PartColorRole.Primary), + S(PrimitiveType.Cube, V(-0.072f, 1.150f, 0.26f), V(0.115f, 0.014f, 0.012f), PartColorRole.Primary), + S(PrimitiveType.Cube, V(-0.128f, 1.202f, 0.26f), V(0.014f, 0.118f, 0.012f), PartColorRole.Primary), + S(PrimitiveType.Cube, V(-0.016f, 1.202f, 0.26f), V(0.014f, 0.118f, 0.012f), PartColorRole.Primary), + S(PrimitiveType.Cube, V(0.072f, 1.255f, 0.26f), V(0.115f, 0.014f, 0.012f), PartColorRole.Primary), + S(PrimitiveType.Cube, V(0.072f, 1.150f, 0.26f), V(0.115f, 0.014f, 0.012f), PartColorRole.Primary), + S(PrimitiveType.Cube, V(0.128f, 1.202f, 0.26f), V(0.014f, 0.118f, 0.012f), PartColorRole.Primary), + S(PrimitiveType.Cube, V(0.016f, 1.202f, 0.26f), V(0.014f, 0.118f, 0.012f), PartColorRole.Primary), + S(PrimitiveType.Cube, V(0f, 1.215f, 0.262f), V(0.022f, 0.012f, 0.012f), PartColorRole.Primary), }, }, @@ -825,7 +1314,8 @@ internal struct ToolEntry anchor = OutfitAnchor.Root, parts = new[] { - S(PrimitiveType.Cube, V(-0.072f, 1.20f, 0.21f), V(0.11f, 0.10f, 0.02f), PartColorRole.Primary), + // 눈(눈꺼풀 포함 z ≤ 0.25) **앞**에 둔다 — 뒤에 두면 가려야 할 눈이 안대를 뚫고 보인다. + S(PrimitiveType.Cube, V(-0.072f, 1.20f, 0.258f), V(0.12f, 0.115f, 0.02f), PartColorRole.Primary), SR(PrimitiveType.Cube, V(0f, 1.23f, 0.16f), V(0.30f, 0.02f, 0.14f), V(0f, 0f, -8f), PartColorRole.PrimaryDark), }, }, @@ -984,110 +1474,270 @@ internal struct ToolEntry }, }, - // ── 가방(Backpack) — 루트 로컬. Backpack 노드(0, 0.80, -0.22)에 파츠를 덧붙인다 ── + // ── 가방(Backpack) — **몸통 로컬**(몸통 중심 = 루트 y 0.77). Backpack 노드가 몸통 자식(0, 0.03, −0.22)이라 + // 걷기에서 몸통과 함께 튄다 — 레시피도 같은 앵커여야 날개·가시가 가방에서 떨어지지 않는다(2026-09-30 루트 → 몸통). // 드래곤 배낭: 기본 상자 + 박쥐 날개 + 등뼈 가시 ["bag_dragon"] = new OutfitRecipe { - anchor = OutfitAnchor.Root, + anchor = OutfitAnchor.Body, parts = new[] { - SR(PrimitiveType.Cube, V(-0.26f, 0.98f, -0.28f), V(0.28f, 0.30f, 0.02f), V(0f, -25f, 28f), PartColorRole.Secondary), - SR(PrimitiveType.Cube, V(0.26f, 0.98f, -0.28f), V(0.28f, 0.30f, 0.02f), V(0f, 25f, -28f), PartColorRole.Secondary), - SR(PrimitiveType.Cube, V(0f, 0.96f, -0.32f), V(0.05f, 0.10f, 0.05f), V(20f, 0f, 0f), PartColorRole.PrimaryDark), - SR(PrimitiveType.Cube, V(0f, 0.84f, -0.32f), V(0.05f, 0.09f, 0.05f), V(20f, 0f, 0f), PartColorRole.PrimaryDark), + SR(PrimitiveType.Cube, V(-0.26f, 0.21f, -0.28f), V(0.28f, 0.30f, 0.02f), V(0f, -25f, 28f), PartColorRole.Secondary), + SR(PrimitiveType.Cube, V(0.26f, 0.21f, -0.28f), V(0.28f, 0.30f, 0.02f), V(0f, 25f, -28f), PartColorRole.Secondary), + SR(PrimitiveType.Cube, V(0f, 0.19f, -0.32f), V(0.05f, 0.10f, 0.05f), V(20f, 0f, 0f), PartColorRole.PrimaryDark), + SR(PrimitiveType.Cube, V(0f, 0.07f, -0.32f), V(0.05f, 0.09f, 0.05f), V(20f, 0f, 0f), PartColorRole.PrimaryDark), }, }, // 요정 날개 가방: 기본 상자 + 상하 요정 날개 4장 ["bag_fairy"] = new OutfitRecipe { - anchor = OutfitAnchor.Root, + anchor = OutfitAnchor.Body, parts = new[] { - SR(PrimitiveType.Cube, V(-0.20f, 1.00f, -0.26f), V(0.24f, 0.30f, 0.015f), V(0f, -20f, 20f), PartColorRole.Secondary), - SR(PrimitiveType.Cube, V(0.20f, 1.00f, -0.26f), V(0.24f, 0.30f, 0.015f), V(0f, 20f, -20f), PartColorRole.Secondary), - SR(PrimitiveType.Cube, V(-0.16f, 0.78f, -0.26f), V(0.16f, 0.20f, 0.015f), V(0f, -20f, 14f), PartColorRole.Secondary), - SR(PrimitiveType.Cube, V(0.16f, 0.78f, -0.26f), V(0.16f, 0.20f, 0.015f), V(0f, 20f, -14f), PartColorRole.Secondary), + SR(PrimitiveType.Cube, V(-0.20f, 0.23f, -0.26f), V(0.24f, 0.30f, 0.015f), V(0f, -20f, 20f), PartColorRole.Secondary), + SR(PrimitiveType.Cube, V(0.20f, 0.23f, -0.26f), V(0.24f, 0.30f, 0.015f), V(0f, 20f, -20f), PartColorRole.Secondary), + SR(PrimitiveType.Cube, V(-0.16f, 0.01f, -0.26f), V(0.16f, 0.20f, 0.015f), V(0f, -20f, 14f), PartColorRole.Secondary), + SR(PrimitiveType.Cube, V(0.16f, 0.01f, -0.26f), V(0.16f, 0.20f, 0.015f), V(0f, 20f, -14f), PartColorRole.Secondary), }, }, // 어깨가방: 등 상자를 숨기고 어깨끈 + 옆구리 가방으로 바꾼다 ["bag_satchel"] = new OutfitRecipe { - anchor = OutfitAnchor.Root, hideNodes = HideBackpack, + anchor = OutfitAnchor.Body, hideNodes = HideBackpack, parts = new[] { - SR(PrimitiveType.Cube, V(0f, 0.90f, 0.20f), V(0.07f, 0.40f, 0.03f), V(0f, 0f, 24f), PartColorRole.PrimaryDark), - SR(PrimitiveType.Cube, V(0f, 0.90f, -0.20f), V(0.07f, 0.40f, 0.03f), V(0f, 0f, -24f), PartColorRole.PrimaryDark), - S(PrimitiveType.Cube, V(-0.28f, 0.62f, -0.02f), V(0.16f, 0.20f, 0.24f), PartColorRole.Primary), - S(PrimitiveType.Cube, V(-0.28f, 0.71f, -0.02f), V(0.17f, 0.05f, 0.25f), PartColorRole.PrimaryDark), + SR(PrimitiveType.Cube, V(0f, 0.13f, 0.20f), V(0.07f, 0.40f, 0.03f), V(0f, 0f, 24f), PartColorRole.PrimaryDark), + SR(PrimitiveType.Cube, V(0f, 0.13f, -0.20f), V(0.07f, 0.40f, 0.03f), V(0f, 0f, -24f), PartColorRole.PrimaryDark), + S(PrimitiveType.Cube, V(-0.28f, -0.15f, -0.02f), V(0.16f, 0.20f, 0.24f), PartColorRole.Primary), + S(PrimitiveType.Cube, V(-0.28f, -0.06f, -0.02f), V(0.17f, 0.05f, 0.25f), PartColorRole.PrimaryDark), }, }, // 연구 장비함: 기본 상자 + 잠금쇠 + 시료 탱크 + 손잡이 ["bag_science"] = new OutfitRecipe + { + anchor = OutfitAnchor.Body, + parts = new[] + { + S(PrimitiveType.Cube, V(0f, 0.13f, -0.31f), V(0.16f, 0.03f, 0.02f), PartColorRole.Secondary), + S(PrimitiveType.Cube, V(0f, -0.01f, -0.31f), V(0.16f, 0.03f, 0.02f), PartColorRole.Secondary), + S(PrimitiveType.Cylinder, V(0.16f, 0.09f, -0.30f), V(0.09f, 0.13f, 0.09f), PartColorRole.Secondary), + S(PrimitiveType.Cube, V(0f, 0.23f, -0.24f), V(0.14f, 0.03f, 0.04f), PartColorRole.PrimaryDark), + }, + }, + + // ── 상의(Top) — 몸통 로컬. 무늬는 OutfitPatternLibrary(텍스처), 여기는 **실루엣이 바뀌는 것**만 ── + // 상의 레시피는 상의가 보일 때만 적용된다(겉옷을 벗었거나 망토일 때) — CharacterOutfitManager 참고. + + // 카우보이 조끼: 조끼 앞자락 밑단의 가죽 술(가운데 속셔츠 부분은 비운다) + ["top_cowboy"] = new OutfitRecipe + { + anchor = OutfitAnchor.Body, + parts = new[] + { + Cap(V(-0.20f, TorsoHem - 0.03f, TorsoFront + 0.004f), V(0.024f, 0.035f, 0.014f), 0.4f, V(0f, 0f, -4f), PartColorRole.Primary), + Cap(V(-0.16f, TorsoHem - 0.035f, TorsoFront + 0.006f), V(0.024f, 0.04f, 0.014f), 0.4f, Vector3.zero, PartColorRole.Primary), + Cap(V(-0.12f, TorsoHem - 0.03f, TorsoFront + 0.008f), V(0.024f, 0.035f, 0.014f), 0.4f, V(0f, 0f, 4f), PartColorRole.Primary), + Cap(V(0.12f, TorsoHem - 0.03f, TorsoFront + 0.008f), V(0.024f, 0.035f, 0.014f), 0.4f, V(0f, 0f, -4f), PartColorRole.Primary), + Cap(V(0.16f, TorsoHem - 0.035f, TorsoFront + 0.006f), V(0.024f, 0.04f, 0.014f), 0.4f, Vector3.zero, PartColorRole.Primary), + Cap(V(0.20f, TorsoHem - 0.03f, TorsoFront + 0.004f), V(0.024f, 0.035f, 0.014f), 0.4f, V(0f, 0f, 4f), PartColorRole.Primary), + }, + }, + + // ── 하의(Bottom) — 다리 관절 로컬(오른다리 기준, Legs가 왼다리를 거울로 만든다) + 몸통 로컬 ── + // 무늬(청바지·위장·줄무늬 등)는 텍스처. 여기는 다리 모양이 바뀌는 것만. + + // 반바지: 다리는 피부(LegForm.Shorts), 허벅지만 감싸는 천 + 밑단 접힘 + ["bot_shorts"] = new OutfitRecipe { anchor = OutfitAnchor.Root, + parts = Legs( + Cap(V(0f, -0.03f, 0f), V(0.235f, 0.075f, 0.235f), 0.95f, Vector3.zero, PartColorRole.Primary), + Ring(V(0f, -0.1f, 0f), V(0.245f, 0.3f, 0.245f), 0.06f, Vector3.zero, PartColorRole.PrimaryDark)), + }, + + // 카고 팬츠: 허벅지 바깥 입체 주머니(덮개 포함) + ["bot_cargo"] = new OutfitRecipe + { + anchor = OutfitAnchor.Root, + parts = Legs( + RB(V(0.1f, -0.13f, 0.005f), V(0.03f, 0.1f, 0.1f), 0.008f, Vector3.zero, PartColorRole.Primary), + RB(V(0.106f, -0.085f, 0.005f), V(0.028f, 0.025f, 0.105f), 0.006f, Vector3.zero, PartColorRole.PrimaryDark)), + }, + + // 멜빵바지: 가슴판 + 어깨끈(앞·어깨 위·등) + 금단추 + 허리 — 몸통 로컬. 자켓을 입으면 앞섶 사이로 보인다. + ["bot_overalls"] = new OutfitRecipe + { + anchor = OutfitAnchor.Body, parts = new[] { - S(PrimitiveType.Cube, V(0f, 0.90f, -0.31f), V(0.16f, 0.03f, 0.02f), PartColorRole.Secondary), - S(PrimitiveType.Cube, V(0f, 0.76f, -0.31f), V(0.16f, 0.03f, 0.02f), PartColorRole.Secondary), - S(PrimitiveType.Cylinder, V(0.16f, 0.86f, -0.30f), V(0.09f, 0.13f, 0.09f), PartColorRole.Secondary), - S(PrimitiveType.Cube, V(0f, 1.00f, -0.24f), V(0.14f, 0.03f, 0.04f), PartColorRole.PrimaryDark), + RB(V(0f, -0.02f, TorsoFront + 0.034f), V(0.21f, 0.2f, 0.016f), 0.01f, Vector3.zero, PartColorRole.Primary), + RB(V(0f, 0.065f, TorsoFront + 0.04f), V(0.09f, 0.06f, 0.008f), 0.006f, Vector3.zero, PartColorRole.PrimaryDark), // 가슴 주머니 + RB(V(-0.08f, 0.165f, TorsoFront + 0.03f), V(0.04f, 0.13f, 0.012f), 0.005f, Vector3.zero, PartColorRole.Primary), + RB(V(0.08f, 0.165f, TorsoFront + 0.03f), V(0.04f, 0.13f, 0.012f), 0.005f, Vector3.zero, PartColorRole.Primary), + RB(V(-0.08f, 0.236f, 0f), V(0.04f, 0.012f, 0.4f), 0.005f, Vector3.zero, PartColorRole.Primary), + RB(V(0.08f, 0.236f, 0f), V(0.04f, 0.012f, 0.4f), 0.005f, Vector3.zero, PartColorRole.Primary), + RB(V(-0.07f, 0.06f, -TorsoFront - 0.022f), V(0.04f, 0.33f, 0.012f), 0.005f, V(0f, 0f, 8f), PartColorRole.Primary), + RB(V(0.07f, 0.06f, -TorsoFront - 0.022f), V(0.04f, 0.33f, 0.012f), 0.005f, V(0f, 0f, -8f), PartColorRole.Primary), + RB(V(0f, -0.2f, 0f), V(0.47f, 0.065f, 0.4f), 0.02f, Vector3.zero, PartColorRole.Primary), // 허리 + SF(PrimitiveType.Sphere, V(-0.08f, 0.1f, TorsoFront + 0.044f), V(0.028f, 0.028f, 0.014f), Gold), + SF(PrimitiveType.Sphere, V(0.08f, 0.1f, TorsoFront + 0.044f), V(0.028f, 0.028f, 0.014f), Gold), }, }, - // ── 겉옷(Outerwear) — 루트 로컬. Body/ArmL/ArmR은 ApplyPartColor가 칠하고 여기선 덧붙인다 ── + // 카우보이 팬츠: 청바지(텍스처) 위에 앞·바깥을 감싸는 가죽 챕스 + ["bot_cowboy"] = new OutfitRecipe + { + anchor = OutfitAnchor.Root, + parts = Legs( + Drape(V(0f, 0.05f, 0f), V(0.25f, 0.34f, 0.25f), 170f, 1.06f, 0, 0f, V(0f, 200f, 0f), PartColorRole.Secondary)), + }, - // 전설의 망토: 등 뒤로 흐르는 망토 + 어깨 깃 + 밑단 - ["outer_legendary"] = new OutfitRecipe + // ── 신발(Shoes) — 다리 관절 로컬(오른다리 기준). 부츠 노드 중심 (0, −0.36, 0.07), 크기 0.21×0.15×0.30 ── + + // 운동화: 두툼한 흰 밑창 + ["shoe_sneakers"] = new OutfitRecipe + { + anchor = OutfitAnchor.Root, + parts = Legs( + Fixed(RB(V(0f, -0.428f, 0.072f), V(0.228f, 0.042f, 0.325f), 0.016f, Vector3.zero, PartColorRole.Fixed), SoleWhite)), + }, + + // 샌들: 발은 피부(FootForm.Sandal) + 가죽 밑창 + 발등 끈 둘 + 발목 끈 + ["shoe_sandals"] = new OutfitRecipe + { + anchor = OutfitAnchor.Root, + parts = Legs( + RB(V(0f, -0.43f, 0.07f), V(0.23f, 0.03f, 0.32f), 0.01f, Vector3.zero, PartColorRole.Primary), + RB(V(0f, -0.283f, 0.14f), V(0.225f, 0.022f, 0.045f), 0.008f, Vector3.zero, PartColorRole.PrimaryDark), + RB(V(0f, -0.283f, 0.03f), V(0.225f, 0.022f, 0.045f), 0.008f, Vector3.zero, PartColorRole.PrimaryDark), + Ring(V(0f, -0.27f, 0f), V(0.185f, 0.25f, 0.185f), 0.07f, Vector3.zero, PartColorRole.PrimaryDark)), + }, + + // 장화: 무릎 아래까지 오는 목 + 입구 테 + ["shoe_waders"] = new OutfitRecipe + { + anchor = OutfitAnchor.Root, + parts = Legs( + Cap(V(0f, -0.2f, 0f), V(0.235f, 0.12f, 0.235f), 0.95f, Vector3.zero, PartColorRole.Primary), + Ring(V(0f, -0.075f, 0f), V(0.25f, 0.3f, 0.25f), 0.07f, Vector3.zero, PartColorRole.PrimaryDark)), + }, + + // 로켓 부츠: 뒤꿈치 분사구 + 불꽃 + 바깥 날개 + ["shoe_rocket"] = new OutfitRecipe { anchor = OutfitAnchor.Root, + parts = Legs( + SF(PrimitiveType.Cylinder, V(0f, -0.35f, -0.1f), V(0.1f, 0.03f, 0.1f), Steel, V(90f, 0f, 0f)), + Cap(V(0f, -0.35f, -0.16f), V(0.075f, 0.06f, 0.075f), 0.2f, V(90f, 0f, 0f), PartColorRole.Secondary), + RB(V(0.11f, -0.33f, -0.05f), V(0.014f, 0.09f, 0.09f), 0.004f, V(0f, 0f, -12f), PartColorRole.Secondary)), + }, + + // 크리스털 구두: 발등 보석 + ["shoe_crystal"] = new OutfitRecipe + { + anchor = OutfitAnchor.Root, + parts = Legs( + SR(PrimitiveType.Sphere, V(0f, -0.29f, 0.17f), V(0.06f, 0.045f, 0.06f), V(0f, 45f, 0f), PartColorRole.Secondary)), + }, + + // 카우보이 부츠: 굽 + 뾰족한 앞코 + 박차(고리 + 축) + ["shoe_cowboy"] = new OutfitRecipe + { + anchor = OutfitAnchor.Root, + parts = Legs( + RB(V(0f, -0.445f, -0.035f), V(0.12f, 0.05f, 0.1f), 0.008f, Vector3.zero, PartColorRole.PrimaryDark), + Cap(V(0f, -0.385f, 0.2f), V(0.15f, 0.07f, 0.1f), 0.35f, V(-90f, 0f, 0f), PartColorRole.Primary), + SR(PrimitiveType.Cube, V(0f, -0.39f, -0.1f), V(0.012f, 0.012f, 0.04f), Vector3.zero, PartColorRole.Secondary), + Ring(V(0f, -0.39f, -0.125f), V(0.05f, 0.3f, 0.05f), 0.14f, V(0f, 0f, 90f), PartColorRole.Secondary)), + }, + + // ── 겉옷(Outerwear) — **몸통 로컬**(2026-09-30 루트 → 몸통: 걷기에서 몸통과 함께 튄다). + // 몸통·팔·셔츠 판을 누가 칠할지는 겉옷 형태(OuterForm)가 정하고 ApplyToCharacter가 칠한다. 여기선 덧붙인다 ── + + // 전설의 망토(망토 형태 — 몸통은 상의 그대로): 어깨에서 무릎까지 퍼지며 주름진 망토 + 선 깃 + 금 브로치 + ["outer_legendary"] = new OutfitRecipe + { + anchor = OutfitAnchor.Body, parts = new[] { - SR(PrimitiveType.Cube, V(0f, 0.72f, -0.22f), V(0.50f, 0.60f, 0.03f), V(-6f, 0f, 0f), PartColorRole.Primary), - S(PrimitiveType.Cube, V(0f, 1.00f, -0.10f), V(0.40f, 0.10f, 0.16f), PartColorRole.Secondary), - S(PrimitiveType.Cube, V(0f, 0.42f, -0.24f), V(0.52f, 0.08f, 0.04f), PartColorRole.Secondary), + Drape(V(0f, 0.2f, -0.02f), V(0.56f, 0.64f, 0.5f), 200f, 1.4f, 4, 0.025f, Vector3.zero, PartColorRole.Primary), + Drape(V(0f, 0.31f, -0.01f), V(0.38f, 0.11f, 0.34f), 230f, 1.3f, 0, 0f, Vector3.zero, PartColorRole.Secondary), + SF(PrimitiveType.Sphere, V(0f, 0.2f, TorsoFront + 0.02f), V(0.06f, 0.06f, 0.03f), Gold), + SR(PrimitiveType.Cube, V(-0.1f, 0.21f, TorsoFront - 0.03f), V(0.14f, 0.02f, 0.02f), V(0f, 20f, -12f), PartColorRole.Secondary), + SR(PrimitiveType.Cube, V(0.1f, 0.21f, TorsoFront - 0.03f), V(0.14f, 0.02f, 0.02f), V(0f, -20f, 12f), PartColorRole.Secondary), }, }, - // 마법사 로브: 발목까지 퍼지는 치맛단 + 등 망토 + 금테 + // 마법사 로브(로브 형태 — 앞까지 여민다): 발목까지 퍼지는 치마 + 금테 + 목 뒤 두건 ["outer_wizard"] = new OutfitRecipe { - anchor = OutfitAnchor.Root, + anchor = OutfitAnchor.Body, parts = new[] { - S(PrimitiveType.Cylinder, V(0f, 0.44f, 0f), V(0.56f, 0.22f, 0.50f), PartColorRole.Primary), - S(PrimitiveType.Cube, V(0f, 0.74f, -0.21f), V(0.46f, 0.52f, 0.03f), PartColorRole.Primary), - S(PrimitiveType.Cylinder, V(0f, 0.23f, 0f), V(0.58f, 0.02f, 0.52f), PartColorRole.Secondary), + Drape(V(0f, TorsoHem + 0.03f, 0f), V(0.48f, 0.37f, 0.42f), 360f, 1.38f, 6, 0.012f, Vector3.zero, PartColorRole.Primary), + Ring(V(0f, TorsoHem + 0.03f - 0.37f, 0f), V(0.66f, 0.3f, 0.58f), 0.035f, Vector3.zero, PartColorRole.Secondary), + Drape(V(0f, 0.3f, -0.03f), V(0.42f, 0.2f, 0.4f), 200f, 1.2f, 2, 0.01f, V(-10f, 0f, 0f), PartColorRole.Primary), + Ring(V(0f, 0.25f, 0f), V(0.34f, 0.35f, 0.3f), 0.08f, Vector3.zero, PartColorRole.Secondary), }, }, - // 그림자 코트: 갈라진 코트 자락 + 세운 깃 + 라펠 + // 그림자 코트(열린 코트): 정강이까지 내려오는 뒤트임 자락 + 세운 깃 ["outer_shadow"] = new OutfitRecipe { - anchor = OutfitAnchor.Root, + anchor = OutfitAnchor.Body, parts = new[] { - SR(PrimitiveType.Cube, V(-0.14f, 0.44f, -0.16f), V(0.20f, 0.42f, 0.06f), V(4f, 0f, 3f), PartColorRole.Primary), - SR(PrimitiveType.Cube, V(0.14f, 0.44f, -0.16f), V(0.20f, 0.42f, 0.06f), V(4f, 0f, -3f), PartColorRole.Primary), - S(PrimitiveType.Cube, V(0f, 1.00f, 0.06f), V(0.42f, 0.12f, 0.26f), PartColorRole.PrimaryDark), - SR(PrimitiveType.Cube, V(-0.10f, 0.88f, 0.20f), V(0.10f, 0.26f, 0.03f), V(0f, 0f, 10f), PartColorRole.PrimaryDark), - SR(PrimitiveType.Cube, V(0.10f, 0.88f, 0.20f), V(0.10f, 0.26f, 0.03f), V(0f, 0f, -10f), PartColorRole.PrimaryDark), + Drape(V(0f, TorsoHem + 0.03f, 0f), V(0.5f, 0.42f, 0.44f), 290f, 1.18f, 3, 0.01f, Vector3.zero, PartColorRole.Primary), + Drape(V(0f, 0.34f, -0.01f), V(0.36f, 0.14f, 0.33f), 240f, 1.18f, 0, 0f, Vector3.zero, PartColorRole.PrimaryDark), + Ring(V(0f, TorsoHem + 0.03f - 0.42f, 0f), V(0.59f, 0.25f, 0.52f), 0.02f, Vector3.zero, PartColorRole.Secondary), }, }, - // 연구원 코트: 앞자락 2장 + 뒷자락 + 주머니 + // 연구원 코트(열린 코트): 무릎까지 내려오는 앞트임 자락(주머니·펜은 텍스처) ["outer_labcoat"] = new OutfitRecipe { - anchor = OutfitAnchor.Root, + anchor = OutfitAnchor.Body, + parts = new[] + { + Drape(V(0f, TorsoHem + 0.03f, 0f), V(0.5f, 0.3f, 0.44f), 300f, 1.12f, 2, 0.008f, Vector3.zero, PartColorRole.Primary), + }, + }, + + // 비옷: 목 뒤로 넘긴 두건 + 무릎 위까지 오는 자락 + ["outer_raincoat"] = new OutfitRecipe + { + anchor = OutfitAnchor.Body, + parts = new[] + { + SR(PrimitiveType.Sphere, V(0f, 0.28f, -0.15f), V(0.36f, 0.24f, 0.24f), V(-15f, 0f, 0f), PartColorRole.Primary), + Drape(V(0f, TorsoHem + 0.03f, 0f), V(0.5f, 0.16f, 0.44f), 300f, 1.12f, 0, 0f, Vector3.zero, PartColorRole.Primary), + }, + }, + + // 바람막이: 목을 감싸 세운 깃(토러스) + ["outer_windbreaker"] = new OutfitRecipe + { + anchor = OutfitAnchor.Body, + parts = new[] + { + Ring(V(0f, 0.25f, 0f), V(0.3f, 0.5f, 0.28f), 0.13f, Vector3.zero, PartColorRole.Primary), + }, + }, + + // 크리스털 자켓: 어깨 위로 솟은 결정(보석) + ["outer_crystal"] = new OutfitRecipe + { + anchor = OutfitAnchor.Body, parts = new[] { - S(PrimitiveType.Cube, V(-0.13f, 0.66f, 0.20f), V(0.20f, 0.50f, 0.03f), PartColorRole.Primary), - S(PrimitiveType.Cube, V(0.13f, 0.66f, 0.20f), V(0.20f, 0.50f, 0.03f), PartColorRole.Primary), - S(PrimitiveType.Cube, V(0f, 0.62f, -0.20f), V(0.46f, 0.54f, 0.03f), PartColorRole.Primary), - S(PrimitiveType.Cube, V(-0.14f, 0.48f, 0.22f), V(0.12f, 0.09f, 0.02f), PartColorRole.PrimaryDark), + GemPart(V(-0.19f, 0.25f, 0.02f), V(0.06f, 0.13f, 0.06f), V(0f, 20f, 18f), PartColorRole.Secondary), + GemPart(V(-0.23f, 0.22f, -0.05f), V(0.045f, 0.09f, 0.045f), V(10f, 0f, 35f), PartColorRole.Secondary), + GemPart(V(0.19f, 0.25f, 0.02f), V(0.06f, 0.13f, 0.06f), V(0f, -20f, -18f), PartColorRole.Secondary), + GemPart(V(0.23f, 0.22f, -0.05f), V(0.045f, 0.09f, 0.045f), V(10f, 0f, -35f), PartColorRole.Secondary), }, }, }; diff --git a/Assets/Scripts/Core/OutfitUnlockRules.cs b/Assets/Scripts/Core/OutfitUnlockRules.cs new file mode 100644 index 0000000..ec036cc --- /dev/null +++ b/Assets/Scripts/Core/OutfitUnlockRules.cs @@ -0,0 +1,68 @@ +namespace InsectGame.Core +{ + /// + /// OutfitItem.unlockCondition 토큰의 판정. 순수 함수라 씬 없이 테스트한다. + /// + /// 이 판정은 오래 없었다. 카탈로그에 조건 4개(region_garden·level_15· + /// region_pond·quest_q_complete)가 적혀 있고 의상 화면은 그걸 "잠김 — ~시 해금"으로 + /// 보여 주는데, 조건을 평가해 소유를 주는 코드가 저장소 어디에도 없어 네 벌이 영영 잠겨 있었다 + /// (top_lab이 그중 하나라 연구원 세트는 완성 자체가 불가능했다). + /// + /// 형식은 CharacterOutfitUI.DescribeUnlockCondition의 문구와 1:1이다 — + /// 새 형식을 늘리면 두 곳을 함께 고친다(OutfitUnlockRulesTests가 카탈로그의 모든 조건이 + /// 이 판정기로 해석되는지 고정한다). + /// + public static class OutfitUnlockRules + { + public const string RegionPrefix = "region_"; + public const string LevelPrefix = "level_"; + public const string QuestPrefix = "quest_"; + + /// + /// 조건이 지금 충족됐는가. 모르는 형식이나 빈 조건은 false다 — 잘못 적은 토큰이 + /// 의상을 공짜로 풀어 버리지 않게 한다. + /// + /// + /// 플레이어가 지금 서 있는 리전. 문구가 "~ 도달 시 해금"이라 개방(접근 가능)이 아니라 + /// 실제로 들어간 순간을 본다 — 개방으로 판정하면 가 보지도 않은 곳의 기념품을 먼저 받는다. + /// + public static bool IsMet(string condition, string currentRegionId, int playerLevel, + System.Func isQuestCompleted) + { + if (string.IsNullOrEmpty(condition)) return false; + + if (condition.StartsWith(RegionPrefix)) + { + string regionId = condition.Substring(RegionPrefix.Length); + return regionId.Length > 0 && regionId == currentRegionId; + } + if (condition.StartsWith(LevelPrefix)) + { + return TryParseLevel(condition, out int need) && playerLevel >= need; + } + if (condition.StartsWith(QuestPrefix)) + { + string questId = condition.Substring(QuestPrefix.Length); + return questId.Length > 0 && isQuestCompleted != null && isQuestCompleted(questId); + } + return false; + } + + /// "level_15" → 15. 형식이 틀리면 false. + public static bool TryParseLevel(string condition, out int level) + { + level = 0; + if (string.IsNullOrEmpty(condition) || !condition.StartsWith(LevelPrefix)) return false; + return int.TryParse(condition.Substring(LevelPrefix.Length), out level) && level > 0; + } + + /// 이 판정기가 이해하는 형식인가(테스트·검사용). + public static bool IsKnownFormat(string condition) + { + if (string.IsNullOrEmpty(condition)) return false; + if (condition.StartsWith(RegionPrefix)) return condition.Length > RegionPrefix.Length; + if (condition.StartsWith(QuestPrefix)) return condition.Length > QuestPrefix.Length; + return TryParseLevel(condition, out _); + } + } +} diff --git a/Assets/Scripts/Core/OutfitUnlockRules.cs.meta b/Assets/Scripts/Core/OutfitUnlockRules.cs.meta new file mode 100644 index 0000000..adf7980 --- /dev/null +++ b/Assets/Scripts/Core/OutfitUnlockRules.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 335ed4ea93381324dba56cce629d44ae \ No newline at end of file diff --git a/Assets/Scripts/Core/PlaySceneBootstrap.cs b/Assets/Scripts/Core/PlaySceneBootstrap.cs index 4d47974..46477c0 100644 --- a/Assets/Scripts/Core/PlaySceneBootstrap.cs +++ b/Assets/Scripts/Core/PlaySceneBootstrap.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using InsectGame.Capture; using InsectGame.Data; using InsectGame.Dex; @@ -37,46 +37,48 @@ public class PlaySceneBootstrap : MonoBehaviour, ICloudReloadable private PlayerStartPose initialPlayerStartPose = PlayerStartPlacement.FallbackPose; private bool initialPlayerStartResolved; private bool initialSpawnApplied; + private Mesh regionGroundMesh; + private Data.RegionData[] sceneryRouteRegions; + + private Mesh GetRegionGroundMesh() + { + if (regionGroundMesh != null) return regionGroundMesh; + const int segments = 64; + var vertices = new Vector3[segments + 1]; + var triangles = new int[segments * 3]; + var uv = new Vector2[segments + 1]; + uv[0] = new Vector2(.5f, .5f); + for (int i = 0; i < segments; i++) + { + float angle = i * Mathf.PI * 2 / segments; + vertices[i + 1] = new Vector3(Mathf.Cos(angle), 0, Mathf.Sin(angle)) * 5f; + uv[i + 1] = new Vector2(vertices[i + 1].x / 10 + .5f, vertices[i + 1].z / 10 + .5f); + triangles[i * 3] = 0; + triangles[i * 3 + 1] = (i + 1) % segments + 1; + triangles[i * 3 + 2] = i + 1; + } + regionGroundMesh = new Mesh { name = "RegionCircularGround", vertices = vertices, triangles = triangles, uv = uv }; + regionGroundMesh.RecalculateNormals(); + regionGroundMesh.RecalculateBounds(); + return regionGroundMesh; + } /// - /// 셰이더 폴백 4단계를 거쳐 머티리얼을 만든다. 알파 < 1이면 투명 렌더로 전환한다. + /// 셰이더 폴백·무광 마감은 가 한다. 알파 < 1이면 투명 렌더로 전환한다. /// /// Standard 셰이더는 기본이 Opaque라 mat.color에 알파를 넣어도 **무시된다** — - /// 렌더 모드·블렌드·ZWrite·렌더큐를 함께 세워야 실제로 비친다. 그 설정이 없어서 + /// 렌더 모드·블렌드·ZWrite·렌더큐를 함께 세워야 실제로 비친다(). 그 설정이 없어서 /// 반투명을 의도한 10곳(물웅덩이·호수·물결·거미줄·분수·늪안개·발광체·구름·수문장 아우라)이 /// 전부 **불투명 덩어리**로 그려지고 있었다. 수문장 아우라(알파 0.15)는 지름 5m 불투명 /// 빨간 구체가 되어 그 안의 수문장 곤충을 통째로 가렸다. /// private static Material CreateSafeMaterial(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; - - if (color.a < 0.999f) MakeTransparent(mat); + Material mat = SceneryMaterials.Create(color); + if (SceneryMaterials.IsTranslucent(color)) SceneryMaterials.MakeFade(mat); return mat; } - /// - /// Standard 셰이더를 Fade 모드로 돌린다 — Unity 표준 머티리얼 인스펙터가 하는 것과 같은 설정이다. - /// 프로퍼티가 없는 폴백 셰이더(Unlit/Color 등)에서는 HasProperty 가드가 조용히 넘어간다. - /// - private static void MakeTransparent(Material mat) - { - if (mat == null) return; - if (mat.HasProperty("_Mode")) mat.SetFloat("_Mode", 2f); // 2 = Fade - if (mat.HasProperty("_SrcBlend")) mat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha); - if (mat.HasProperty("_DstBlend")) mat.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); - if (mat.HasProperty("_ZWrite")) mat.SetInt("_ZWrite", 0); - mat.DisableKeyword("_ALPHATEST_ON"); - mat.EnableKeyword("_ALPHABLEND_ON"); - mat.DisableKeyword("_ALPHAPREMULTIPLY_ON"); - mat.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent; - } - private void Awake() { Build(); @@ -433,6 +435,7 @@ private void BuildSystems(GameObject player, Camera camera) InsectGame.UI.CollectionUI collectionUi = EnsureComponent("UI/CollectionUI"); collectionUi.AutoWire(insectCollection, candyInventory, progress); collectionUi.AutoWire(battleTeam); // 목록에서 배틀팀을 맨 위로 올리기 위한 조회 + TeamChanged 구독 + dexScreen.AutoWire(collectionUi); // 도감 보유 카드에서 동일 개체의 상세로 이동 InsectGame.UI.CapturePopupUI capturePopup = EnsureComponent("UI/CapturePopup"); capturePopup.AutoWire(capture); @@ -447,6 +450,7 @@ private void BuildSystems(GameObject player, Camera camera) socialPvpUi.AutoWire(socialPvp); Data.RegionData[] regionDefs = RegionDefinitions.CreateAll(); + sceneryRouteRegions = regionDefs; RegionManager regionMgr = EnsureComponent("World/RegionManager"); regionMgr.Initialize(regionDefs); regionMgr.AutoWire(progress); @@ -461,6 +465,14 @@ private void BuildSystems(GameObject player, Camera camera) SubAreaEnvironment subAreaEnv = EnsureComponent("World/SubAreaEnvironment"); subAreaEnv.AutoWire(regionMgr); + // 낮밤·날씨 하늘 보정 — 메인 필드와 나의 섬에서만 걸린다(시계·날씨는 위 World 단계에서 만들어졌다). + subAreaEnv.AutoWire(worldState); + WeatherEffects weatherFx = EnsureComponent("World/WeatherEffects"); + weatherFx.AutoWire(worldState, regionMgr); + // 야생 전투의 낮·밤·날씨 보정 — 리전 기준 날씨를 읽으므로 RegionManager가 생긴 여기서 잇는다. + battleController.AutoWire(worldState, regionMgr); + // 습격 판정 — 깨어 있는 습격형이 다가와 닿으면 「습격!」 창을 연다(rules/world-environment.md 「습격」). + inputController.AutoWire(worldState, regionMgr); SubAreaWorldBuilder subAreaWorld = EnsureComponent("World/SubAreaWorld"); subAreaWorld.AutoWire(regionMgr, camFollower); @@ -512,6 +524,7 @@ private void BuildSystems(GameObject player, Camera camera) InsectGame.UI.QuickAccessBarUI quickBar = EnsureComponent("UI/QuickAccessBar"); quickBar.AutoWire(dexScreen, battleTeamUi, trainingUi, collectionUi, mapUi); quickBar.AutoWire(socialPvpUi); + quickBar.AutoWire(accountSettingsUi); quickBar.AutoWire(battleScreen, raidBattleUi, playerMov); // 가방(IMGUI) — 보유 아이템을 보고 쓰는 화면. 퀵바 [I]가 유일한 진입점이다. @@ -533,6 +546,10 @@ private void BuildSystems(GameObject player, Camera camera) // 메인퀘스트 목표 쐐기만 아래쪽 StoryObjectiveTracker에서 주입받는다. InsectGame.UI.MinimapUI minimapUi = EnsureComponent("UI/Minimap"); + // 우측 열 맨 아래의 시각·날씨 칩과 변화 알림 — 시계·날씨는 위 World 단계에서 이미 만들어져 있다. + InsectGame.UI.WorldClockHUD clockHud = EnsureComponent("UI/WorldClockHUD"); + clockHud.AutoWire(worldState, regionMgr, playerMov); + CharacterOutfitManager outfitManager = EnsureComponent("World/CharacterOutfit"); outfitManager.AutoWire(wallet); cloudSave.RegisterReloadable(outfitManager); @@ -558,6 +575,8 @@ private void BuildSystems(GameObject player, Camera camera) // 스스로 찾을 수 없다 — 렌더러가 생긴 이 시점에 넘긴다. // (배선이 없으면 LoginUI가 2D 초상화로 물러나므로 실패해도 회귀는 아니다.) loginUI.AutoWire(characterPreview); + // 결투 컷인의 "나"도 같은 3D로(2D 도트는 모자·겉옷·도구 대부분을 못 그렸다). + battleScreen.AutoWire(characterPreview); if (buildWorld) { @@ -573,6 +592,8 @@ private void BuildSystems(GameObject player, Camera camera) questManager.AutoWire(insectCollection, candyInventory, progress, itemInventory, battleController, raidController, dex, trainingMgr, battleTeam, regionMgr); cloudSave.RegisterReloadable(questManager); + // 조건부 의상(지역 도달·레벨·퀘스트) 해금 — 퀘스트 매니저가 서야 배선할 수 있다. + outfitManager.AutoWireUnlockSources(regionMgr, progress, questManager); // 주간 크기 대결 — 매주 저레어 종 하나를 지정하고 그 종 포획 시 기록이 자동 갱신된다. // 기록은 저장하지 않고 player_insects.json의 capturedUnix로 파생하므로 별도 세이브가 없다. @@ -599,6 +620,9 @@ private void BuildSystems(GameObject player, Camera camera) InsectGame.Story.StoryDirector storyDirector = EnsureComponent("World/StoryDirector"); storyDirector.AutoWire(regionMgr, battleController, progress, insectCollection, questManager); + // 필드 스포너의 스토리 포획 보조 — 발화를 기다리는 종 지정 포획 비트의 종을 리전별로 묻는다(시간 기반 재생이라 + // 리전을 오가며 다시 굴릴 수 없다). 스포닝이 스토리를 직접 참조하지 않게 정적 훅이다(도주 방지 훅과 같은 형태). + InsectSpawner.StoryCaptureTargetProvider = storyDirector.CollectPendingCaptureSpecies; // RegionCleansed 트리거 소스 — DexController와 같은 이유로 Start 전에 주입한다. storyDirector.AutoWire(blight); storyDirector.AutoWire(candyInventory, itemInventory); @@ -609,11 +633,47 @@ private void BuildSystems(GameObject player, Camera camera) // **전투 화면이 떠 있는지**를 알려 주는 유일한 신호. 이게 없으면 전투 승리와 같은 // 프레임에 갱신되는 LevelReach·DexProgress·QuestComplete가 결과 화면 위로 대사를 띄운다. storyDirector.AutoWire(camFollower); + // 결과 화면이 떠 있는가 — 눌러야 닫혀서(BattleResultRules) 미뤄 둔 대사·컷신·영상의 "굳은 화면" 상한이 그 시간을 + // 세지 않게 한다(StoryBattleWait). Story가 UI 타입을 모르도록 함수 하나로 넘긴다. 아래 컷신·영상 지휘자도 같은 것을 받는다. + System.Func battleResultShowing = () => + (battleScreen != null && battleScreen.ResultShownSeconds > 0f) + || (raidBattleUi != null && raidBattleUi.ResultShownSeconds > 0f); + storyDirector.AutoWire(battleResultShowing); cloudSave.RegisterReloadable(storyDirector); // 전투 결과 화면이 닫힌 뒤에 BattleWin·GuardianDefeat 비트를 띄우기 위한 통지 경로. battleScreen.AutoWire(storyDirector); raidBattleUi.AutoWire(storyDirector); + // 필드 소식 — 레벨업·이야기 보상·라온과의 내기·첫 색다른 조우. 넣는 쪽(World/)과 그리는 쪽(UI/)이 + // 대기열 하나(FieldMomentFeed)로만 이어진다. 마을/NPC 블록 밖에 둔다 — 그쪽이 예외로 건너뛰어도 + // 보상 알림은 살아 있어야 한다. + FieldMomentFeed momentFeed = EnsureComponent("World/FieldMomentFeed"); + momentFeed.AutoWire(itemDatabase); + storyDirector.AutoWire(momentFeed); + InsectGame.Story.RivalRaceController rivalRace = + EnsureComponent("World/RivalRaceController"); + rivalRace.AutoWire(storyDirector, insectCollection, candyInventory, itemInventory, playerMov, momentFeed); + FirstShinyEncounter firstShiny = EnsureComponent("World/FirstShinyEncounter"); + firstShiny.AutoWire(spawner, questManager, regionMgr, playerMov, player.transform, momentFeed); + InsectGame.UI.FieldMomentsUI momentsUi = + EnsureComponent("UI/FieldMomentsUI"); + momentsUi.AutoWire(momentFeed, progress, rivalRace, playerMov); + + // 수문장 배지 — 배지는 격파 기록에서 파생하고(저장 없음), 이정표 보상 수령 상태만 계정 스코프로 둔다. + // 클라우드 재로드는 RegionManager 뒤에 등록돼야 한다(여기는 이미 뒤다). + GuardianBadgeService badgeService = EnsureComponent("World/GuardianBadgeService"); + badgeService.AutoWire(regionMgr, itemInventory); + cloudSave.RegisterReloadable(badgeService); + // 획득 연출 — 전투 화면이 닫히는 순간, 스토리 대사보다 먼저 뜬다(StoryDirector.BattlePresentationClosed). + InsectGame.UI.BadgeCeremonyUI badgeCeremony = + EnsureComponent("UI/BadgeCeremony"); + badgeCeremony.AutoWire(badgeService, regionMgr, itemDatabase); + badgeCeremony.AutoWire(storyDirector, camFollower, playerMov); + // 배지 케이스 — 퀵메뉴 [배지](K). + InsectGame.UI.BadgeCaseUI badgeCase = EnsureComponent("UI/BadgeCase"); + badgeCase.AutoWire(badgeService, regionMgr, itemDatabase); + quickBar.AutoWire(badgeCase); + // 캐시 상점 + 가챠 시스템 CashShopManager cashShop = EnsureComponent("World/CashShop"); cashShop.AutoWire(wallet); // 보석 동기화 (PlayerCurrencyWallet ↔ CashShopManager) @@ -625,10 +685,51 @@ private void BuildSystems(GameObject player, Camera camera) gachaBox.AutoWire(database); // PickRandomInsect 결과 검증 + DisplayName 캐싱 InsectGame.UI.CashShopUI cashShopUI = EnsureComponent("UI/CashShopUI"); + cashShopUI.AutoWire(characterPreview); // 왼쪽 캐릭터 칸 = 지금 장착한 3D quickBar.AutoWire(outfitUi, cashShopUI); quickBar.AutoWire(questUi); + // 나의 섬 — 상태·규칙(IslandManager) → 월드(IslandWorld) → 화면 여섯 개. + // 병원 UI와 같은 이유로 **buildWorld·try 밖**에 둔다: 마을 빌더가 예외를 던져도 탐험 메뉴의 [내 섬]은 살아 있어야 한다. + // 클라우드 재로드는 곤충 컬렉션 뒤에 등록돼야 한다(방목 목록이 instanceId로 그쪽을 본다 — 이미 위에서 등록됐다). + IslandManager islandManager = EnsureComponent("World/IslandManager"); + islandManager.AutoWire(insectCollection, wallet, candyInventory, database); + cloudSave.RegisterReloadable(islandManager); + // 섬 퀘스트의 코인·섬 물건 보상 지급처. 진행 통지는 IslandManager가 Notify___로 직접 한다. + questManager.AutoWire(wallet, islandManager); + + IslandWorldBuilder islandWorld = EnsureComponent("World/IslandWorld"); + islandWorld.AutoWire(regionMgr, islandManager, camFollower, playerMov, database); + clockHud.AutoWire(islandWorld); // 섬에서의 시계 칩 자리 — 섬 HUD 열을 피한다 + // 밤·비·안개에 찾아오는 손님 곤충과 방목 곤충의 시간·날씨 행동(rules/island.md 「손님 곤충」) + islandWorld.AutoWire(spawner, worldState); + IslandShareClient islandShare = EnsureComponent("World/IslandShare"); + islandShare.AutoWire(islandManager); + + InsectGame.UI.IslandEditUI islandEditUi = EnsureComponent("UI/IslandEdit"); + islandEditUi.AutoWire(islandManager, islandWorld, islandShare); + InsectGame.UI.IslandShopUI islandShopUi = EnsureComponent("UI/IslandShop"); + islandShopUi.AutoWire(islandManager, wallet); + InsectGame.UI.IslandInsectUI islandInsectUi = EnsureComponent("UI/IslandInsects"); + islandInsectUi.AutoWire(islandManager, insectCollection); + InsectGame.UI.IslandGuideUI islandGuideUi = EnsureComponent("UI/IslandGuide"); + islandGuideUi.AutoWire(islandManager, islandWorld, islandEditUi); + InsectGame.UI.IslandHudUI islandHudUi = EnsureComponent("UI/IslandHud"); + islandHudUi.AutoWire(islandManager, islandWorld, playerMov, islandShare); + InsectGame.UI.IslandVisitUI islandVisitUi = EnsureComponent("UI/IslandVisit"); + islandVisitUi.AutoWire(islandManager, islandWorld, islandShare, socialPvp, islandHudUi); + islandHudUi.AutoWire(islandEditUi, islandShopUi, islandInsectUi, islandVisitUi, islandGuideUi); + quickBar.AutoWire(islandVisitUi); + + // 「챔피언의 꿈」 프롤로그 — 새 계정이 처음 필드에 서면 한 번 도는 연출(설정에서 다시 볼 수 있다). + // 섬·전투·퀘스트·소식 대기열이 다 만들어진 뒤여야 하고, World/ 아래에 둔다(UI 루트는 오프닝 다시보기가 끈다). + InsectGame.Story.DreamPrologueDirector dreamPrologue = + EnsureComponent("World/DreamPrologueDirector"); + dreamPrologue.AutoWire(questManager, playerMov, player.transform, regionMgr, islandWorld, + battleController, battleScreen, database, momentFeed); + accountSettingsUi.AutoWire(dreamPrologue); + // 마스터 계정이면 보석 99999 지급 ("특권 없이" 모드에서는 주지 않는다) if (AuthManager.Instance != null && AuthManager.Instance.MasterPrivilegesActive) { @@ -666,12 +767,21 @@ private void BuildSystems(GameObject player, Camera camera) worldInteract.RegisterPoints(villageResult.interactions); } + // 본 마을 나루터 — 탐험 메뉴의 [내 섬]과 같은 창을 여는 두 번째 입구. + worldInteract.AutoWire(islandVisitUi); + InteractionPointDef islandDock = islandWorld.BuildVillageDock(regionDefs); + if (islandDock != null) + worldInteract.RegisterPoints(new List { islandDock }); + // [E] 삼자 충돌 해소 — 서브에리어 진입은 포획·상호작용에 양보한다(전용 버튼이 있다). subAreaWorld.AutoWire(worldInteract, inputController); InsectGame.NPC.NpcManager npcManager = EnsureComponent("World/NpcManager"); npcManager.AutoWire(spawner, regionMgr, player.transform); + mapUi.AutoWire(npcManager); + minimapUi.AutoWire(npcManager); + minimapUi.AutoWire(regionMgr); worldInteract.AutoWire(npcManager); worldInteract.AutoWire(storyDirector); // 스토리 NPC 대화 → NpcTalk 트리거 @@ -688,6 +798,10 @@ private void BuildSystems(GameObject player, Camera camera) cloudSave.RegisterReloadable(npcDuel); worldInteract.AutoWire(npcDuel); storyDirector.AutoWire(npcDuel); // DuelWin 트리거 소스 — Start 전(같은 프레임)이라 구독이 걸린다 + // 라온 라이벌 단계의 열림·닫힘 — 조회 함수만 넘긴다(StoryDirector가 이미 npcDuel을 참조해 역참조는 순환). + npcDuel.AutoWireStoryGate(storyDirector.HasSeen); + // 이야기 잠금 — 서릿길·잿불 골짜기는 앞 수문장에 더해 간부(집게·저울)를 이겨야 열린다. Core가 NPC를 모르게 조회 함수만. + regionMgr.AutoWireDuelGate(npcDuel.IsBossDefeated); // 오염 거점 비주얼 — 구조물·안개·지면 탈색, 정화 시 붕괴. // NpcManager가 필요해 여기(NPC 생성 뒤)에 둔다: 거점 좌표를 하수 실물에서 @@ -699,6 +813,7 @@ private void BuildSystems(GameObject player, Camera camera) EnsureComponent("UI/NpcDialogueUI"); npcDialogue.AutoWire(playerMov); npcDialogue.AutoWire(storyDirector); // 스토리 비트 lines[] 모달 렌더 + 닫힘 시 완료 콜백 + npcDialogue.AutoWire(npcDuel); // 라온 라이벌 대결 [승부하기] 버튼 worldInteract.AutoWire(npcDialogue); // 스토리 저널 — 챕터별 진행 열람 + 다시 읽기(NpcDialogueUI 렌더러 재사용). @@ -716,6 +831,7 @@ private void BuildSystems(GameObject player, Camera camera) // 목표 문구 구체화 — 곤충 표시명·퀘스트 제목·현재 레벨/도감 종수. // 없어도 목표는 나오지만 "모험을 이어가세요"로 뭉개진다. objectiveTracker.AutoWire(progress, dex, questManager, database); + npcDialogue.AutoWire(objectiveTracker); questUi.AutoWire(objectiveTracker); // 퀘스트 칩 아래 목표 행 questUi.AutoWire(mapUi); // 목표가 타 리전이면 지도를 그 리전으로 연다 minimapUi.AutoWire(objectiveTracker); // 미니맵 목표 방향 쐐기 @@ -727,6 +843,16 @@ private void BuildSystems(GameObject player, Camera camera) InsectGame.Story.CutsceneDirector cutscene = EnsureComponent("World/CutsceneDirector"); cutscene.AutoWire(storyDirector, camFollower, playerMov, player.transform); + cutscene.AutoWire(battleResultShowing); + + // 스토리 영상(mp4) — 컷신과 같은 시점(StoryBeatCompleted)에 화면을 통째로 덮는다. + // **World/ 아래여야 한다** — UI 루트 자식이면 오프닝 다시보기가 루트를 끌 때 함께 죽어 복구가 안 된다. + InsectGame.Story.StoryVideoDirector storyVideo = + EnsureComponent("World/StoryVideoDirector"); + storyVideo.AutoWire(storyDirector, camFollower, playerMov); + storyVideo.AutoWire(battleResultShowing); + // 저널 「▶ 영상」 다시보기 — 스토리 부수효과 없이 같은 재생기로 튼다(StoryVideoDirector.PlayReplay). + storyJournal.AutoWire(storyVideo); // NPC 연출 지휘 — 조우 접근(규칙)과 등장/퇴장(저작)을 한 컴포넌트가 맡는다. // 둘 다 같은 VillagerNpc의 Scripted 상태를 쓰므로 나누면 명령이 서로 덮인다. @@ -734,8 +860,16 @@ private void BuildSystems(GameObject player, Camera camera) InsectGame.Story.StoryStageDirector stageDirector = EnsureComponent("World/StoryStageDirector"); stageDirector.AutoWire(storyDirector, objectiveTracker, npcManager, playerMov, player.transform); - // 대사 앞 연출 게이트 — stageEnterId가 있으면 모달보다 먼저 돌린다. - npcDialogue.AutoWire(stageDirector); + // 대사 앞 연출 게이트 — 대화창의 슬롯은 하나라 고리로 잇는다: 영상(introVideoId) → NPC 등장 연출(stageEnterId). + // 영상이 설명하고, 인물이 걸어 들어와, (장을 여는 비트면 「지난 이야기」 카드 뒤) 짧게 말한다. + npcDialogue.AutoWire(new InsectGame.Story.StoryPreludeChain(storyVideo, stageDirector)); + + // 대사 직후 대결(StoryBeat.duelAfter) — 영상·컷신·연출·선택 결과가 다 끝난 첫 순간에 간부전·라온전을 연다. + InsectGame.Story.StoryDuelLauncher duelLauncher = + EnsureComponent("World/StoryDuelLauncher"); + duelLauncher.AutoWire(storyDirector, npcDuel, regionMgr, camFollower, playerMov); + // 대화창 마지막 버튼을 「승부!」로 — 선택 결과 대사처럼 자기 duelAfter가 빈 비트 뒤의 대기 대결도 본다. + npcDialogue.AutoWire(duelLauncher); // 스폰은 배선 완료 후 (컬링 타깃/예약 시스템이 준비된 상태에서) if (villageResult != null) @@ -820,7 +954,16 @@ private Camera EnsureCamera(Transform target) private void EnsureLight() { // 그늘(그림자 지는 곳)이 새까매지지 않도록 그림자 강도를 낮추고 환경광을 약간 올린다. - // 메인 필드는 Skybox 환경광이라 ambientIntensity가 그늘 밝기에 직접 기여. + // + // **Skybox 환경광을 쓰지 않는다.** 이 씬엔 라이팅 데이터(LightingDataAsset)가 없어 + // 하늘에서 구운 환경광 프로브가 비어 있다 — ambientIntensity를 아무리 올려도 0에 곱해진다. + // 배치 캡처에서 해를 등진 벽과 NPC 정면이 완전한 검정으로 찍혔다(NPC 70명 전원이 실루엣). + // Trilight는 프로브 없이 색 세 개로 바로 비추므로 굽기 없이도 그늘이 산다. + // 서브에리어 진입 시 SubAreaEnvironment가 Flat으로 바꿨다가 이 모드로 되돌린다. + RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Trilight; + RenderSettings.ambientSkyColor = new Color(0.50f, 0.55f, 0.62f); + RenderSettings.ambientEquatorColor = new Color(0.42f, 0.43f, 0.41f); + RenderSettings.ambientGroundColor = new Color(0.26f, 0.24f, 0.21f); RenderSettings.ambientIntensity = Mathf.Max(RenderSettings.ambientIntensity, 1.25f); Light existing = FindFirstObjectByType(); @@ -966,11 +1109,14 @@ private void EnsureGround() for (int ri = 0; ri < regionDefs.Length; ri++) { var region = regionDefs[ri]; - Color col = region.themeColor; - Material mat = CreateSafeMaterial(new Color(col.r * 0.5f + 0.1f, col.g * 0.5f + 0.1f, col.b * 0.4f + 0.08f)); + // 바닥색은 RegionPalette가 단일 출처 — 옛 공식(themeColor × 0.5)은 서릿길을 회녹색, + // 연못을 리전 전체가 파란 "물"로 칠했다. + Material mat = CreateSafeMaterial(RegionPalette.Ground(region.regionId, region.themeColor)); GameObject regionGround = GameObject.CreatePrimitive(PrimitiveType.Plane); regionGround.name = $"Region_{region.regionId}"; - regionGround.transform.position = region.centerPosition + new Vector3(0f, 0.08f, 0f); + regionGround.transform.position = region.centerPosition + new Vector3(0f, 0.08f + ri * 0.001f, 0f); + regionGround.GetComponent().sharedMesh = GetRegionGroundMesh(); + regionGround.GetComponent().sharedMesh = GetRegionGroundMesh(); float s = region.radius / 5f; regionGround.transform.localScale = new Vector3(s, 1f, s); regionGround.GetComponent().material = mat; @@ -988,19 +1134,26 @@ private void EnsureGround() { float ba = Mathf.PI * 2f * bi / barrierCount; Vector3 bPos = region.centerPosition + new Vector3(Mathf.Cos(ba) * bRad, 0f, Mathf.Sin(ba) * bRad); + if (WorldRouteLayout.IsOnRoute(regionDefs, bPos, 2f)) continue; + // 겹친 리전(초원·습지 약 42m)에서 남의 리전 울타리 줄 안에 떨어지는 것은 짓지 않는다 — 울타리 기둥과 같은 + // 판정(RegionTerrainBuilder.IsInsideOtherRegionFence). 경계가 아니라 0.85R 고리 장식이다(45° 간격, 통행 차단 없음). + bool inOtherRegion = RegionTerrainBuilder.IsInsideOtherRegionFence(regionDefs, region, bPos); if (bi % 3 == 0) { + // 크기 난수는 짓지 않아도 뽑는다 — 전역 난수 줄기라 건너뛰면 뒤따르는 필드 소품이 전부 밀린다 + float bs = Random.Range(1.2f, 2f); + if (inOtherRegion) continue; // Large boulder barrier GameObject boulder = GameObject.CreatePrimitive(PrimitiveType.Sphere); boulder.name = $"Barrier_{region.regionId}_{bi}_Rock"; - float bs = Random.Range(1.2f, 2f); boulder.transform.position = bPos + new Vector3(0f, bs * 0.2f, 0f); boulder.transform.localScale = new Vector3(bs * 1.5f, bs * 0.6f, bs); boulder.GetComponent().material = barrierRockMat; } else { + if (inOtherRegion) continue; // 말뚝은 난수를 안 쓴다 // Low fence post GameObject fPost = GameObject.CreatePrimitive(PrimitiveType.Cube); fPost.name = $"Barrier_{region.regionId}_{bi}_Post"; @@ -1012,12 +1165,7 @@ private void EnsureGround() } } - // --- Paths (gravel style) --- - Material pathMat = CreateSafeMaterial(new Color(0.6f, 0.5f, 0.35f)); - - CreatePath(pathMat, Vector3.zero, regionDefs[1].centerPosition, 2.5f); - CreatePath(pathMat, Vector3.zero, regionDefs[2].centerPosition, 2.5f); - CreatePath(pathMat, Vector3.zero, regionDefs[3].centerPosition, 2.5f); + // WorldTerrainBuilder owns inter-region paths and shares routes with the map. // 단계별 격리: 한 빌더의 예외가 나머지 지형/수문장 생성을 막지 않도록 + 어느 단계가 실패했는지 로깅. GroundStep("AddSceneryObjects", () => AddSceneryObjects(baseMat)); @@ -1031,14 +1179,26 @@ private void EnsureGround() }); // 리전별 게임 필드 지형 (언덕, 길, 바위, 나무 등) + RegionTerrainBuilder regionTerrain = null; GroundStep("RegionTerrainBuilder", () => { - RegionTerrainBuilder regionTerrain = new GameObject("RegionTerrainBuilder").AddComponent(); + regionTerrain = new GameObject("RegionTerrainBuilder").AddComponent(); regionTerrain.BuildAllRegions(regionDefs); }); + // 리전 표면 장식(바닥 얼룩·발밑 디테일·호수·울타리 밖 테두리). 마을·전초기지를 피해야 해서 + // 실제 배치는 그것들이 지어진 뒤(Start)에 한다 — 여기선 설정만 건넨다. + GroundStep("RegionDressingBuilder", () => + new GameObject("RegionDressingBuilder").AddComponent().Configure(regionDefs, regionTerrain)); + + // 원경 — 리전·길을 피해 월드 둘레에 산맥, 하늘에 뭉게구름 + GroundStep("WorldBackdropBuilder", () => + new GameObject("WorldBackdropBuilder").AddComponent().Build(regionDefs)); + // 수문장은 BuildSystems에서 database와 함께 생성 GroundStep("CreateSubAreaEntries", () => CreateSubAreaEntries(regionDefs)); + GroundStep("SubAreaGateBuilder", () => + new GameObject("SubAreaGateBuilder").AddComponent().Build(regionDefs)); } // 지형 생성 단계 격리 실행 — 실패 시 해당 단계명+예외를 로그/배너로 남기고 다음 단계 진행. @@ -1162,7 +1322,7 @@ private void AddRegionScenery(Data.RegionData[] regions) } else if (r.regionId == "pond") { - AddPondScenery(c, rad); + AddPondScenery(c, rad, r.radius); } else if (r.regionId == "forest") { @@ -1425,7 +1585,9 @@ private void AddMeadowScenery(Vector3 c, float rad) Object.Destroy(puddle.GetComponent()); } - private void AddPondScenery(Vector3 c, float rad) + /// 소품 배치 반경(리전 반경 × 0.7 — AddRegionScenery가 넘긴다) + /// 리전 반경 그대로 — 호수 윤곽(RegionDressingBuilder.PondLake)이 이 값의 배율로 잡혀 있다 + private void AddPondScenery(Vector3 c, float rad, float regionRadius) { Material waterMat = CreateSafeMaterial(new Color(0.2f, 0.4f, 0.7f, 0.6f)); Material reedMat = CreateSafeMaterial(new Color(0.35f, 0.55f, 0.2f)); @@ -1446,6 +1608,10 @@ private void AddPondScenery(Vector3 c, float rad) water.transform.position = c + new Vector3(0f, 0.05f, 0f); water.transform.localScale = new Vector3(15f, 0.05f, 15f); water.GetComponent().material = waterMat; + // A flattened primitive capsule still blocks the walking corridor. + Collider waterCollider = water.GetComponent(); + waterCollider.enabled = false; + Object.Destroy(waterCollider); // --- Existing reeds (10) --- for (int i = 0; i < 10; i++) @@ -1522,6 +1688,10 @@ private void AddPondScenery(Vector3 c, float rad) GameObject rock = GameObject.CreatePrimitive(PrimitiveType.Sphere); rock.name = $"Pond_ShoreRock_{i}"; float rs = Random.Range(0.4f, 0.9f); + // 커진 호수(RegionDressingBuilder.PondLake — 중심이 남동으로 옮겨 앉았다)에 잠기는 바위(0°·300° 쪽)는 같은 + // 방위의 진흙 띠 바깥 가장자리로 옮긴다. 자리만 바꾸고 난수는 그대로 뽑는다(뒤따르는 소품이 밀리지 않게). + // 몸 반경은 스케일 긴 축(1.2rs)의 절반. + rockPos = RegionDressingBuilder.PondShoreSpot(c, regionRadius, rockPos, rs * 0.6f); rock.transform.position = rockPos + new Vector3(0f, rs * 0.2f, 0f); rock.transform.localScale = new Vector3(rs * 1.2f, rs * 0.5f, rs); rock.GetComponent().material = shoreMat; @@ -1978,35 +2148,40 @@ private void AddGardenScenery(Vector3 c, float rad) Object.Destroy(bigPetal.GetComponent()); } - // --- NEW: Fountain (center) --- + // Keep the landmark beside the shared entrance road. + RegionData gardenRegion = WorldRouteLayout.Find(sceneryRouteRegions, "garden"); + Vector3 entranceDirection = gardenRegion != null + ? (WorldRouteLayout.GetGateway(gardenRegion, sceneryRouteRegions) - c).normalized + : Vector3.forward; + Vector3 fountainCenter = c + Vector3.Cross(Vector3.up, entranceDirection) * 6f; GameObject fountainBase = GameObject.CreatePrimitive(PrimitiveType.Cylinder); fountainBase.name = "Garden_Fountain_Base"; - fountainBase.transform.position = c + new Vector3(0f, 0.3f, 0f); + fountainBase.transform.position = fountainCenter + new Vector3(0f, 0.3f, 0f); fountainBase.transform.localScale = new Vector3(2.5f, 0.3f, 2.5f); fountainBase.GetComponent().material = fountainStoneMat; GameObject fountainPillar = GameObject.CreatePrimitive(PrimitiveType.Cylinder); fountainPillar.name = "Garden_Fountain_Pillar"; - fountainPillar.transform.position = c + new Vector3(0f, 1.2f, 0f); + fountainPillar.transform.position = fountainCenter + new Vector3(0f, 1.2f, 0f); fountainPillar.transform.localScale = new Vector3(0.4f, 0.9f, 0.4f); fountainPillar.GetComponent().material = fountainStoneMat; GameObject fountainBowl = GameObject.CreatePrimitive(PrimitiveType.Cylinder); fountainBowl.name = "Garden_Fountain_Bowl"; - fountainBowl.transform.position = c + new Vector3(0f, 2.2f, 0f); + fountainBowl.transform.position = fountainCenter + new Vector3(0f, 2.2f, 0f); fountainBowl.transform.localScale = new Vector3(1.5f, 0.15f, 1.5f); fountainBowl.GetComponent().material = fountainStoneMat; GameObject fountainWater = GameObject.CreatePrimitive(PrimitiveType.Sphere); fountainWater.name = "Garden_Fountain_Water"; - fountainWater.transform.position = c + new Vector3(0f, 2.6f, 0f); + fountainWater.transform.position = fountainCenter + new Vector3(0f, 2.6f, 0f); fountainWater.transform.localScale = new Vector3(0.5f, 0.8f, 0.5f); fountainWater.GetComponent().material = fountainWaterMat; Object.Destroy(fountainWater.GetComponent()); GameObject fountainTop = GameObject.CreatePrimitive(PrimitiveType.Sphere); fountainTop.name = "Garden_Fountain_Top"; - fountainTop.transform.position = c + new Vector3(0f, 3.1f, 0f); + fountainTop.transform.position = fountainCenter + new Vector3(0f, 3.1f, 0f); fountainTop.transform.localScale = new Vector3(0.3f, 0.3f, 0.3f); fountainTop.GetComponent().material = fountainStoneMat; Object.Destroy(fountainTop.GetComponent()); @@ -2532,8 +2707,6 @@ private void AddSceneryObjects(Material grassMat) Material bushDarkMat = CreateSafeMaterial(new Color(0.12f, 0.4f, 0.1f)); Material logMat = CreateSafeMaterial(new Color(0.4f, 0.28f, 0.12f)); Material grassTuftMat = CreateSafeMaterial(new Color(0.3f, 0.6f, 0.2f)); - Material cloudMat = CreateSafeMaterial(new Color(1f, 1f, 1f, 0.4f)); - Material mountainMat = CreateSafeMaterial(new Color(0.35f, 0.42f, 0.3f)); Material lampPostMat = CreateSafeMaterial(new Color(0.3f, 0.3f, 0.3f)); Material lampGlowMat = CreateSafeMaterial(new Color(1f, 0.92f, 0.6f)); @@ -2547,6 +2720,8 @@ private void AddSceneryObjects(Material grassMat) for (int i = 0; i < treePositions.Length; i++) { + float s = Random.Range(1.8f, 2.8f); + if (WorldRouteLayout.IsOnRoute(sceneryRouteRegions, treePositions[i], s * .5f)) continue; GameObject trunk = GameObject.CreatePrimitive(PrimitiveType.Cylinder); trunk.name = $"Tree_{i}_Trunk"; trunk.transform.position = treePositions[i] + new Vector3(0f, 1.5f, 0f); @@ -2556,7 +2731,6 @@ private void AddSceneryObjects(Material grassMat) GameObject leaves = GameObject.CreatePrimitive(PrimitiveType.Sphere); leaves.name = $"Tree_{i}_Leaves"; leaves.transform.position = treePositions[i] + new Vector3(0f, 4f, 0f); - float s = Random.Range(1.8f, 2.8f); leaves.transform.localScale = new Vector3(s, s * 0.8f, s); leaves.GetComponent().material = treeLeafMat; } @@ -2574,9 +2748,10 @@ private void AddSceneryObjects(Material grassMat) for (int i = 0; i < rockPositions.Length; i++) { + float rs = Random.Range(0.5f, 1.4f); + if (WorldRouteLayout.IsOnRoute(sceneryRouteRegions, rockPositions[i], rs * .65f)) continue; GameObject rock = GameObject.CreatePrimitive(PrimitiveType.Sphere); rock.name = $"Rock_{i}"; - float rs = Random.Range(0.5f, 1.4f); rock.transform.position = rockPositions[i] + new Vector3(0f, rs * 0.3f, 0f); rock.transform.localScale = new Vector3(rs * 1.3f, rs * 0.6f, rs); rock.GetComponent().material = rockMat; @@ -2621,38 +2796,8 @@ private void AddSceneryObjects(Material grassMat) CreateMushroom(pos, Random.Range(0.3f, 0.6f), $"Mushroom_{i}"); } - // --- NEW: Distant mountains/hills (background, flattened Spheres far out) --- - Vector3[] mountainPositions = { - new Vector3(160f, 0f, 0f), new Vector3(-150f, 0f, 50f), - new Vector3(0f, 0f, 170f), new Vector3(100f, 0f, -140f) - }; - float[] mountainScales = { 40f, 35f, 45f, 30f }; - for (int i = 0; i < mountainPositions.Length; i++) - { - GameObject mountain = GameObject.CreatePrimitive(PrimitiveType.Sphere); - mountain.name = $"Scenery_Mountain_{i}"; - float ms = mountainScales[i]; - mountain.transform.position = mountainPositions[i] + new Vector3(0f, ms * 0.15f, 0f); - mountain.transform.localScale = new Vector3(ms * 2f, ms * 0.6f, ms * 2f); - mountain.GetComponent().material = mountainMat; - Object.Destroy(mountain.GetComponent()); - } - - // --- NEW: Clouds (high altitude, white translucent Spheres) --- - for (int i = 0; i < 5; i++) - { - float cx = Random.Range(-80f, 80f); - float cz = Random.Range(-80f, 80f); - float cy = Random.Range(50f, 60f); - - GameObject cloud = GameObject.CreatePrimitive(PrimitiveType.Sphere); - cloud.name = $"Scenery_Cloud_{i}"; - float cs = Random.Range(8f, 14f); - cloud.transform.position = new Vector3(cx, cy, cz); - cloud.transform.localScale = new Vector3(cs * 2f, cs * 0.4f, cs); - cloud.GetComponent().material = cloudMat; - Object.Destroy(cloud.GetComponent()); - } + // 원경(먼 산·구름)은 WorldBackdropBuilder가 짓는다 — 여기 박혀 있던 좌표는 월드를 1.5배로 + // 넓히기 전 값이라 「먼 산」이 꽃밭·유적·연못 옆 필드 안쪽을 올리브색 돔으로 덮었다. // --- NEW: Street lamps along paths (4) --- Vector3[] lampPositions = { @@ -2960,12 +3105,12 @@ private InsectSkill[] CreateTrainingSkills() foreach (InsectSkill skill in skills) { if (skill == null) continue; - // **요구 레벨을 비용에 태운다.** 옛 `power / 2`만 쓰면 위력을 낮춘 이번 재배치에서 - // 비용까지 함께 싸져(파멸의 독침 37 → 20) 상위기가 오히려 접근하기 쉬워진다. + // 회당 비용 = 가치/2 + 요구 레벨(TrainingPricing이 정본). **요구 레벨을 태우는 이유**: `power / 2`만 + // 쓰면 위력을 낮춘 재배치에서 비용까지 싸져(파멸의 독침 37 → 20) 상위기가 더 쉬워진다. + // 가치는 상태기도 효과 크기로 센다 — 위력 칸(1)으로 셌을 땐 광폭화가 26캔디 1회였다. // 누적 훈련이 회차마다 이 값을 받으므로 총비용은 여기에 필요 횟수를 곱한 만큼이다. // 기술 디스크 방식은 이 값을 **읽지 않는다** — GetTrainingCost가 method.candyCost만 돌려준다. - // 스킬 단가(여기)와 방식 단가(디스크) 두 출처가 공존하는 건 의도다. - skill.trainingCost = Mathf.Max(5, skill.power / 2 + skill.requiredLevel); + skill.trainingCost = TrainingPricing.SessionCost(skill, skill.requiredLevel); skill.description = "훈련을 통해 익힐 수 있는 범용 기술"; switch (skill.skillId) @@ -3423,8 +3568,15 @@ private InsectLearnableSkill[] BuildLevelLearnset(InsectData data) return learnset.ToArray(); } + /// + /// learnset 한 칸. 교체 훈련 단가를 여기서 굽는다 — 종족기의 해금 레벨은 learnset에만 있어서 + /// (범용기의 requiredLevel 자리) 기술을 만드는 시점엔 모른다. 같은 기술(속성×단계 캐시)은 + /// 어느 종에서나 같은 레벨에 열리므로 덮어써도 값이 같다. 옛 손 상수(연타 4·집중 8·특성 12· + /// 폭발/붕괴 14·폭풍 22·전용기 35/50)는 효과를 안 봐서 회복·기절 특성기가 연타보다 쌌다. + /// private InsectLearnableSkill CreateLearnableSkill(InsectSkill skill, int level) { + if (skill != null) skill.trainingCost = TrainingPricing.SessionCost(skill, level); return new InsectLearnableSkill { skillId = skill != null ? skill.skillId : string.Empty, @@ -3532,7 +3684,6 @@ private InsectSkill GetTraitSkill(InsectData data) InsectSkill skill = CreateTypedSkillInternal( cacheKey, displayName, element, effectType, power, 2, effectValue, 3); - skill.trainingCost = 12; skill.description = "곤충의 생태와 신체 특징을 살린 종족 기술"; generatedSkillCache[cacheKey] = skill; return skill; @@ -3614,7 +3765,6 @@ private InsectSkill CreateSignatureSkill(InsectData data) InsectSkill signature = CreateTypedSkillInternal( skillId, name, element, SkillEffectType.Damage, power, cooldown, 0.2f, 2); signature.isSignatureSkill = true; - signature.trainingCost = data != null && data.rarity == InsectRarity.Legendary ? 50 : 35; signature.description = "이 종만 사용할 수 있는 전용 필살기"; generatedSkillCache[skillId] = signature; return signature; @@ -3664,31 +3814,25 @@ private InsectSkill CreateTypedSkill(InsectElement element, string tier) { case "jab": skill = CreateTypedSkillInternal($"{element.ToString().ToLowerInvariant()}_jab", $"{label} 연타", element, SkillEffectType.Damage, 12, 0, 0.2f, 2); - skill.trainingCost = 4; return skill; case "boost": if (UsesBuffSkill(element)) skill = CreateTypedSkillInternal($"{element.ToString().ToLowerInvariant()}_boost", $"{label} 집중", element, SkillEffectType.BuffAttack, 1, 3, 0.3f, 3); else skill = CreateTypedSkillInternal($"{element.ToString().ToLowerInvariant()}_boost", $"{label} 압박", element, SkillEffectType.DebuffAttack, 1, 3, 0.25f, 3); - skill.trainingCost = 8; return skill; case "burst": skill = CreateTypedSkillInternal($"{element.ToString().ToLowerInvariant()}_burst", $"{label} 폭발", element, SkillEffectType.Damage, 26, 2, 0.2f, 2); - skill.trainingCost = 14; return skill; case "break": skill = CreateTypedSkillInternal($"{element.ToString().ToLowerInvariant()}_break", $"{label} 붕괴", element, SkillEffectType.DebuffAttack, 1, 3, 0.3f, 2); - skill.trainingCost = 14; return skill; case "storm": skill = CreateTypedSkillInternal($"{element.ToString().ToLowerInvariant()}_storm", $"{label} 폭풍", element, SkillEffectType.Damage, 42, 4, 0.2f, 2); - skill.trainingCost = 22; skill.accuracy = 0.9f; // 고위력 스킬은 명중 트레이드오프 return skill; default: skill = CreateTypedSkillInternal($"{element.ToString().ToLowerInvariant()}_nova", $"{label} 노바", element, SkillEffectType.Damage, 52, 5, 0.2f, 2); - skill.trainingCost = 28; skill.accuracy = 0.85f; // 최고위력 스킬은 더 낮은 명중 return skill; } @@ -4974,6 +5118,7 @@ public void ReloadFromDisk() private void OnDestroy() { + if (regionGroundMesh != null) Destroy(regionGroundMesh); if (guardianRegionMgr != null) guardianRegionMgr.GuardianDefeated -= OnGuardianSealBroken; } @@ -5065,6 +5210,9 @@ private void CreateSubAreaEntries(Data.RegionData[] regions) if (region.subAreas == null) continue; foreach (var sub in region.subAreas) { + // 테마 입구는 SubAreaGateBuilder가 짓는다(2막·안개·정상·신전·지하 등 표식이 없던 곳 + + // environmentType이 cave라 동굴 석문이 서던 개미귀신 구덩이). + if (SubAreaGateBuilder.Handles(sub.subAreaId)) continue; Vector3 c = sub.centerPosition; switch (sub.environmentType) { diff --git a/Assets/Scripts/Core/PlayerInsectData.cs b/Assets/Scripts/Core/PlayerInsectData.cs index e7875d9..7d7b764 100644 --- a/Assets/Scripts/Core/PlayerInsectData.cs +++ b/Assets/Scripts/Core/PlayerInsectData.cs @@ -48,22 +48,68 @@ public class PlayerInsectData public float IVPercent => (ivHp + ivAtk + ivDef) / (MaxIV * 3f); - public IVGrade Grade + public int IvSum => ivHp + ivAtk + ivDef; + + public IVGrade Grade => GradeForSum(IvSum); + + /// 개체값 합(0~45)의 등급. 경계(90/70/50/30%)의 단일 출처 — 훈련 화면의 "다음 등급까지"도 여기서 센다. + public static IVGrade GradeForSum(int ivSum) + { + float pct = ivSum / (MaxIV * 3f); + if (pct >= 0.9f) return IVGrade.S; + if (pct >= 0.7f) return IVGrade.A; + if (pct >= 0.5f) return IVGrade.B; + if (pct >= 0.3f) return IVGrade.C; + return IVGrade.D; + } + + /// 그 등급이 되는 최소 개체값 합(S 41 · A 32 · B 23 · C 14 · D 0). + public static int MinIvSumFor(IVGrade grade) { - get + for (int sum = 0; sum <= MaxIV * 3; sum++) + if (GradeForSum(sum) >= grade) return sum; + return MaxIV * 3; + } + + /// HP 개체값 1당 최대 HP. 능력치 훈련이 HP를 올릴 때 현재 HP도 이만큼 함께 올린다. + public const int HpPerIv = 2; + + public int GetTotalHp(int baseHp) + { + return baseHp + ivHp * HpPerIv + level * GameConstants.Battle.HpPerLevel; + } + + /// 훈련소 「성장 훈련」이 다루는 개체값 하나를 읽는다. + public int GetIv(GrowthStat stat) + { + switch (stat) { - float pct = IVPercent; - if (pct >= 0.9f) return IVGrade.S; - if (pct >= 0.7f) return IVGrade.A; - if (pct >= 0.5f) return IVGrade.B; - if (pct >= 0.3f) return IVGrade.C; - return IVGrade.D; + case GrowthStat.Attack: return ivAtk; + case GrowthStat.Defense: return ivDef; + default: return ivHp; } } - public int GetTotalHp(int baseHp) + /// + /// 개체값 하나를 1 올린다(최대 ). 올렸으면 true. 비용은 호출부 + /// (TrainingManager.TrainStat)가 받는다 — 여기는 값만 다룬다. + /// + /// HP를 올리면 최대 HP가 만큼 늘므로 현재 HP도 같이 올린다. 안 그러면 + /// 풀피 곤충이 훈련 직후 "부상"으로 떠 병원에 가야 한다. 기절(0)과 미초기화(-1)는 그대로 둔다. + /// + public bool RaiseIv(GrowthStat stat) { - return baseHp + ivHp * 2 + level * GameConstants.Battle.HpPerLevel; + if (GetIv(stat) >= MaxIV) return false; + switch (stat) + { + case GrowthStat.Attack: ivAtk++; break; + case GrowthStat.Defense: ivDef++; break; + default: + ivHp++; + if (currentHp > 0) currentHp += HpPerIv; + break; + } + return true; } /// 전투 시작 시 시드할 현재 HP. currentHp 미초기화(-1)면 풀피(maxHp). @@ -83,14 +129,18 @@ public void EnsureHp(int maxHp) else currentHp = UnityEngine.Mathf.Clamp(currentHp, 0, maxHp); } + /// 레벨당 공격·방어 증가분. 훈련 화면이 "레벨업하면 무엇이 오르나"를 이 값으로 적는다. + public const int AtkPerLevel = 2; + public const int DefPerLevel = 1; + public int GetTotalAtk(int baseAtk) { - return baseAtk + ivAtk + level * 2; + return baseAtk + ivAtk + level * AtkPerLevel; } public int GetTotalDef(int baseDef) { - return baseDef + ivDef + level; + return baseDef + ivDef + level * DefPerLevel; } public static PlayerInsectData CreateWithIV(string insectId, int level, Data.InsectRarity rarity = Data.InsectRarity.Common) diff --git a/Assets/Scripts/Core/PlayerMovement.cs b/Assets/Scripts/Core/PlayerMovement.cs index 5823662..f51da70 100644 --- a/Assets/Scripts/Core/PlayerMovement.cs +++ b/Assets/Scripts/Core/PlayerMovement.cs @@ -1,4 +1,4 @@ -using InsectGame.Spawning; +using InsectGame.Spawning; using UnityEngine; using UnityEngine.EventSystems; @@ -72,6 +72,9 @@ public class PlayerMovement : MonoBehaviour private Transform cachedArmL, cachedArmR, cachedLegPivotL, cachedLegPivotR, cachedBody, cachedHeadPivot; // 도구(NetHandle/NetRing)는 오른팔과 동기 회전 — 손과 분리되어 공중에 떠 있는 인상 차단. private Transform cachedNetHandle, cachedNetRing; + private Transform cachedHandL, cachedHandR; + private Vector3 lastHandlePos, lastRingPos; + private Quaternion lastHandleRot, lastRingRot; private Vector3 netHandleBasePos, netRingBasePos; private Quaternion netHandleBaseRot, netRingBaseRot; private bool toolBaseCached; @@ -84,6 +87,34 @@ public class PlayerMovement : MonoBehaviour private const float CatchSwingMaxDeg = 72f; private float stuckTimer; // 끼임(embedded) 자동탈출용 — 이동 시도 중 콜라이더 박힘 지속시간 + // ── 접지 ── + // 매 프레임 발밑 높이(GroundHeight — 밟을 수 있는 콜라이더 윗면과 둔덕 윗면 중 높은 쪽)를 따라간다. + // 옛 접지 Max(pos.y, hit.y)는 **올라가기만 하고 내려오지 않았다** — 레이가 2m라 필드에선 늘 접지로 판정돼 + // 중력도 안 걸려서, 물가 바위(Pond_ShoreRock_*, 콜라이더 꼭대기 최대 0.72m)·개구리 바위(0.85m)처럼 몸통 검사 + // (IsBlockedPosition, 발 위 1.0~1.8m)에 안 걸리는 낮고 둥근 콜라이더를 타 넘으면 그 높이에 뜬 채 남았다. + // 텔레포트의 "+0.5 여유"(서브에리어 입구 y 0.5, 이탈 지면 스냅 +0.5)도 같은 이유로 그대로 떠 있었다. + // 둔덕(사구·재 더미 …)은 콜라이더가 없어 FieldGround가 좌표로 답한다. + // + // 이 컴포넌트가 마지막으로 둔 자리 — 다르면 누가 옮긴 것(텔레포트)이라 보간 없이 곧장 발밑에 앉힌다. + private Vector3 lastOwnPosition; + private bool hasOwnPosition; + private bool snapToGround = true; // 첫 프레임·텔레포트·F9 복구 직후 + + /// + /// 한 걸음에 올라설 수 있는 높이(m) — 접지 레이를 발 위 이만큼에서 쏜다(옛 값 그대로). 이보다 높은 콜라이더는 + /// 레이가 그 안에서 출발해 못 보고 지나친다. 곤충 스폰(InsectSpawner)도 같은 높이까지만 밟는다. + /// + internal const float StepHeight = 0.5f; + + /// + /// 발밑 높이를 따라가는 속도(m/s). 재 더미·이끼 둔덕은 구 중심이 바닥 위라 가장자리에 0.1~0.33m 턱이 있고, 바위 + /// 가장자리·상자 콜라이더도 턱이 진다 — 그대로 따라가면 한 프레임에 몸과 카메라 시선(CameraFollower는 위치만 + /// 보간하고 LookAt은 즉시다)이 튄다. 6m/s면 턱 0.33m는 4프레임(60fps), 한 걸음 0.5m는 5프레임에 나눠 넘고, + /// 사구·언덕 경사는 가장 가파른 곳(사구 짧은 축 가장자리, 기울기 약 0.25)을 의상 배율 2배 최고 속도 16m/s로 + /// 넘어도 초당 4m 남짓이라 지연 없이 따라간다. 더 느리면 재 더미처럼 가파른 둔덕에서 발이 잠기는 시간이 길어진다. + /// + internal const float GroundFollowSpeed = 6f; + public bool IsFrozen => frozen; public PlayerStartPose MainWorldSafePose => mainWorldSafePose; @@ -141,7 +172,7 @@ private void Start() public void InvalidateVisualCache() { cachedArmL = cachedArmR = cachedLegPivotL = cachedLegPivotR = cachedBody = cachedHeadPivot = null; - cachedNetHandle = cachedNetRing = null; + cachedNetHandle = cachedNetRing = cachedHandL = cachedHandR = null; toolBaseCached = false; toolLookupDone = false; bodyBaseY = float.NaN; @@ -238,6 +269,24 @@ private void Update() } } + // A modal blocks every movement source, including a destination selected before it opened. + if (InsectGame.UI.ModalUIRegistry.IsAnyOpen()) + { + movingToClick = false; + EndAutoRun(); + joystickActive = false; + joystickInput = Vector2.zero; + guiClickRequest = false; + guiKeyW = guiKeyA = guiKeyS = guiKeyD = false; + guiKeyUp = guiKeyDown = guiKeyLeft = guiKeyRight = false; + walkAnimTimer = footstepTimer = 0f; + isWalking = false; + if (catchSwingTimer > 0f) catchSwingTimer -= Time.unscaledDeltaTime; + if (blockedMsgTimer > 0f) blockedMsgTimer -= Time.unscaledDeltaTime; + AnimateWalk(false); + return; + } + // OnGUI KeyDown 래치(guiKey*)는 KeyUp 이벤트를 놓치면 켜진 채 stuck된다 — 에디터 Game뷰가 // 포커스를 잃으면(다른 창 클릭 등) 키를 떼도 KeyUp이 안 와서 guiKeyW/guiKeyD 등이 true로 남아 // 입력 없이 캐릭터가 자동 이동한다(사용자 보고: 오른쪽 위로 계속 감). 실제 물리 키가 안 눌린 @@ -366,15 +415,25 @@ private void Update() float speedMul = Mathf.Clamp(outfitBonus != null ? outfitBonus.GetMoveSpeedMultiplier() : 1f, 0.5f, 2f); Vector3 move = direction * moveSpeed * speedMul * Time.deltaTime; + // 접지 — 이번 프레임 발밑 높이를 따라간다(오르막·내리막 모두). 누가 옮겼다면(텔레포트) 곧장 앉힌다. + Vector3 current = transform.position; + if (!hasOwnPosition || current != lastOwnPosition) snapToGround = true; + + // 트리거는 땅이 아니다 — 프로젝트 설정이 Queries Hit Triggers=1이라 명시하지 않으면 NPC 몸통·아이템 줍기 구 + // 같은 트리거 윗면에 올라선다(옛 Max 접지에선 그 높이에 붙어 남았다). isGrounded = Physics.Raycast( - transform.position + Vector3.up * 0.5f, - Vector3.down, out RaycastHit hit, groundCheckDistance); + current + Vector3.up * StepHeight, + Vector3.down, out RaycastHit hit, groundCheckDistance, + Physics.DefaultRaycastLayers, QueryTriggerInteraction.Ignore); if (isGrounded) { verticalVelocity = 0f; - Vector3 pos = transform.position; - pos.y = Mathf.Max(pos.y, hit.point.y); + bool inSubArea = regionManager != null && regionManager.CurrentSubArea != null; + float groundY = GroundHeight(hit.point.y, current.x, current.z, inSubArea); + Vector3 pos = current; + pos.y = snapToGround ? groundY : FollowGround(current.y, groundY, Time.deltaTime); + snapToGround = false; transform.position = pos; } else @@ -414,6 +473,8 @@ private void Update() } transform.position += move; + lastOwnPosition = transform.position; + hasOwnPosition = true; // 걷기 애니메이션 bool walking = direction.sqrMagnitude > 0.01f; @@ -538,21 +599,43 @@ private void AnimateWalk(bool walking) cachedNetHandle = transform.Find("NetHandle"); cachedNetRing = transform.Find("NetRing"); } - // 잡기 스윙 중엔 base 재캐싱 금지 — 안 그러면 스윙된 회전이 base로 누적돼 도구가 드리프트. - if (!walking && catchSwingTimer <= 0f && cachedNetHandle != null && cachedNetRing != null) + if (cachedHandL == null) cachedHandL = transform.Find("HandL"); + if (cachedHandR == null) cachedHandR = transform.Find("HandR"); + if (cachedHandL != null && cachedArmL != null) + { + cachedHandL.localPosition = PlayerVisualBuilder.RotateAttachment( + new Vector3(-0.29f, 0.52f, 0f), cachedArmL.localPosition, swingDeg + idleArm); + cachedHandL.localRotation = cachedArmL.localRotation; + } + if (cachedHandR != null && cachedArmR != null) { - netHandleBaseRot = cachedNetHandle.localRotation; - netRingBaseRot = cachedNetRing.localRotation; - toolBaseCached = true; + cachedHandR.localPosition = PlayerVisualBuilder.RotateAttachment( + new Vector3(0.29f, 0.52f, 0f), cachedArmR.localPosition, rightArmDeg); + cachedHandR.localRotation = cachedArmR.localRotation; } - if (toolBaseCached) + if (cachedNetHandle != null && cachedNetRing != null && cachedArmR != null) { - // idle 호흡이 빠진 toolDeg를 쓴다 — 위 재캐싱이 매 idle 프레임 도므로, - // 여기에 idleArm을 섞으면 그 회전이 base로 누적돼 도구가 드리프트한다. - if (cachedNetHandle != null) - cachedNetHandle.localRotation = Quaternion.Euler(toolDeg, 0f, 0f) * netHandleBaseRot; - if (cachedNetRing != null) - cachedNetRing.localRotation = Quaternion.Euler(toolDeg, 0f, 0f) * netRingBaseRot; + // Recipe writes are detected against the last displayed pose, never + // by recaching an animated idle pose (which accumulated drift). + if (!toolBaseCached || cachedNetHandle.localPosition != lastHandlePos + || cachedNetHandle.localRotation != lastHandleRot + || cachedNetRing.localPosition != lastRingPos || cachedNetRing.localRotation != lastRingRot) + { + netHandleBasePos = cachedNetHandle.localPosition; + netRingBasePos = cachedNetRing.localPosition; + netHandleBaseRot = cachedNetHandle.localRotation; + netRingBaseRot = cachedNetRing.localRotation; + toolBaseCached = true; + } + Quaternion attachmentRotation = Quaternion.Euler(rightArmDeg, 0f, 0f); + cachedNetHandle.localPosition = PlayerVisualBuilder.RotateAttachment(netHandleBasePos, cachedArmR.localPosition, rightArmDeg); + cachedNetRing.localPosition = PlayerVisualBuilder.RotateAttachment(netRingBasePos, cachedArmR.localPosition, rightArmDeg); + cachedNetHandle.localRotation = attachmentRotation * netHandleBaseRot; + cachedNetRing.localRotation = attachmentRotation * netRingBaseRot; + lastHandlePos = cachedNetHandle.localPosition; + lastRingPos = cachedNetRing.localPosition; + lastHandleRot = cachedNetHandle.localRotation; + lastRingRot = cachedNetRing.localRotation; } // 다리 흔들기 (팔과 반대) — LegPivot 회전 시 Leg+Boot 모두 함께 전파. @@ -650,14 +733,15 @@ private bool IsBlockedPosition(Vector3 pos) /// /// 조사는 "…에게"로 잇는다. 수문장 이름이 데이터에서 오는데 받침이 갈려서 /// ("사마귀" / "장수말벌") 을/를 어느 쪽으로 고정해도 절반은 어색해진다. + /// + /// 문구는 이 만든다 — 수문장을 이미 이겼는데 이야기 대결(명부회 간부)이 + /// 남은 리전(서릿길·잿불 골짜기)은 "집게에게 이겨야 열립니다"가 맞다. 여기서 수문장만 보면 이미 쓰러뜨린 수문장을 + /// 다시 가리킨다. 지도·HUD 목표가 같은 판정을 읽는다. /// private string BuildBlockedMessage(Data.RegionData region) { - Data.RegionData gate = regionManager.GetGatekeeperRegion(region.regionId); - if (gate != null && !string.IsNullOrEmpty(gate.guardianDisplayName)) - return $"{region.displayName} — {gate.guardianDisplayName}에게 이겨야 열립니다"; - - return $"{region.displayName} — 아직 갈 수 없습니다"; + string message = regionManager.DescribeLock(region); + return string.IsNullOrEmpty(message) ? $"{region.displayName} — 아직 갈 수 없습니다" : message; } // F9 unstick — 끼임 escape. SubArea 안이면 SubAreaWorldBuilder의 FindSafeSpawnPosition과 @@ -666,7 +750,14 @@ internal void RecoverToSafePosition() { bool inSubArea = regionManager != null && regionManager.CurrentSubArea != null; Vector3 target; - if (inSubArea) + if (inSubArea && regionManager.CurrentSubArea.detached) + { + // 분리 구역(나의 섬) — 방이 (2000,·,2000)에 없다. 그 구역이 정해 둔 도착 자리 부근으로 보낸다 + // (섬은 centerPosition에 도착 칸을 적어 두고, 그 칸은 물건을 놓을 수 없게 보호돼 있다). + Vector3 arrival = regionManager.CurrentSubArea.centerPosition; + target = FindClearSpot(arrival + new Vector3(0f, 0.5f, 0f), arrival); + } + else if (inSubArea) { // SubArea 안 — Origin 부근 spiral 탐색 Vector3 origin = new Vector3(2000f, 0f, 2000f); @@ -682,8 +773,29 @@ internal void RecoverToSafePosition() transform.rotation = mainWorldSafePose.Rotation; verticalVelocity = 0f; movingToClick = false; + // 복구 좌표로 곧장 앉힌다. 끼임 자동탈출은 이 뒤 같은 프레임에 lastOwnPosition을 새로 적어 + // 텔레포트로 안 잡히므로 여기서 직접 켠다. + snapToGround = true; } + // ================= 접지 — 순수 계산 ================= + + /// + /// 발 디딜 높이(월드 y) — 레이가 맞은 콜라이더 윗면(, 없으면 음의 무한대)과 + /// 둔덕 윗면() 중 높은 쪽. 둔덕 속에 묻힌 바위는 둔덕 윗면이 이기고, 둔덕 위로 + /// 삐져나온 바위는 바위가 이긴다. 평지에선 (0.1)라 옛 시작 높이와 같다 + /// (리전 평면 콜라이더는 0.08~0.093, 바깥 바닥은 0). + /// + /// 서브에리어에선 콜라이더만 본다. (2000,·,2000) 너머라 둔덕이 없고, 바닥도 FloorY가 아니라 y=0이다 — + /// SurfaceY를 섞으면 서브에리어에서만 0.1m 떠 있게 된다. + /// + internal static float GroundHeight(float colliderTopY, float x, float z, bool inSubArea) + => inSubArea ? colliderTopY : Mathf.Max(colliderTopY, FieldGround.SurfaceY(x, z)); + + /// 발밑 높이를 향해 만큼 한 프레임 옮긴다(오르막·내리막 같은 속도). + internal static float FollowGround(float currentY, float groundY, float deltaTime) + => Mathf.MoveTowards(currentY, groundY, GroundFollowSpeed * Mathf.Max(0f, deltaTime)); + private Vector3 FindClearSpot(Vector3 preferred, Vector3 fallbackCenter) { if (IsClearAt(preferred)) return preferred; @@ -750,6 +862,16 @@ public void CancelAutoRun() EndAutoRun(); } + /// 월드 이동 시 일반 클릭 목적지까지 폐기한다. 도착 이벤트는 발생시키지 않는다. + public void StopNavigation() + { + movingToClick = false; + clickTarget = transform.position; + joystickInput = Vector2.zero; + joystickActive = false; + EndAutoRun(); + } + // 주행 상태만 정리한다. movingToClick은 호출부 사정에 따라 다르므로 건드리지 않는다 // (도착 시엔 이미 꺼져 있고, 입력 취소 경로에선 그쪽이 먼저 껐다). private void EndAutoRun() diff --git a/Assets/Scripts/Core/PlayerProgressController.cs b/Assets/Scripts/Core/PlayerProgressController.cs index b530dd9..5d4b75f 100644 --- a/Assets/Scripts/Core/PlayerProgressController.cs +++ b/Assets/Scripts/Core/PlayerProgressController.cs @@ -20,6 +20,13 @@ public class PlayerProgressController : MonoBehaviour public event Action ProgressChanged; + /// + /// 경험치로 레벨이 오를 때마다 1씩 오른다. 화면이 이 값의 변화를 읽어 레벨업 연출을 띄운다. + /// 로는 그걸 가릴 수 없다 — 로그인·클라우드 적재도 같은 이벤트를 쏘는데, + /// 그때 레벨이 1에서 저장된 값으로 뛰는 것은 "레벨업"이 아니다. + /// + public int LevelUpSerial { get; private set; } + private void Awake() { data = PlayerProgressSaveService.Load(); @@ -78,6 +85,7 @@ public void GainXp(int amount) } PlayerProgressSaveService.Save(data); + if (data.level > levelBefore) LevelUpSerial++; ProgressChanged?.Invoke(data); // 레벨업은 중요한 진행이라 자동저장 120초 대기 안 하고 즉시 클라우드 동기화 diff --git a/Assets/Scripts/Core/PlayerVisualBuilder.cs b/Assets/Scripts/Core/PlayerVisualBuilder.cs index e644c15..d1ccfaa 100644 --- a/Assets/Scripts/Core/PlayerVisualBuilder.cs +++ b/Assets/Scripts/Core/PlayerVisualBuilder.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using UnityEngine; namespace InsectGame.Core @@ -14,7 +14,9 @@ namespace InsectGame.Core /// CharacterModelPreviewRenderer.FocusNodesFor. 틀리면 예외 없이 그냥 동작하지 않는다. /// Body/Shirt/Neck/Head/HeadPivot/ArmL/ArmR/HandL/HandR/ /// LegLPivot/LegRPivot/LegL/LegR/BootL/BootR/ - /// Backpack/BackpackStrap/NetHandle/NetRing/Cap/CapBrim/HatRoot. + /// Backpack/BackpackStrap/NetHandle/NetRing/Cap/CapBrim/HatRoot, + /// 그리고 2026-09-30에 더한 ShoulderL/ShoulderR(팔 자식, ApplyToCharacter가 칠함)·HairCrown(모자가 숨김)·Mouth(얼굴 애니메이터). + /// ArmL/ArmR의 localPosition은 어깨 관절이다 — 걷기가 그 점을 축으로 팔·손·도구를 돌린다. /// public class PlayerVisualBuilder : MonoBehaviour { @@ -278,6 +280,66 @@ private static Mesh UnitCapsule(float taper) return ProcMeshLibrary.TaperedCapsule(rTop, rBottom, 2f - rTop - rBottom, 8, 10); } + /// + /// 과 모양은 같고 위 반구의 중심이 원점인 캡슐 — 어깨에서 도는 팔용. + /// 은 옛 중심 원점에서 새 원점까지의 거리(단위 메시 기준)라, + /// 호출부가 옛 y + drop × scale.y에 놓으면 쉬는 자세가 예전과 한 치도 다르지 않다. + /// + private static Mesh UnitCapsuleHanging(float taper, out float shoulderDrop) + { + float rTop = 0.5f; + float rBottom = 0.5f * taper; + float height = 2f - rTop - rBottom; + shoulderDrop = height * 0.5f; + return ProcMeshLibrary.TaperedCapsule(rTop, rBottom, height, 8, 10, -shoulderDrop); + } + + /// + /// 몸통 폭 프로필(아래·가운데·위 배율). 위는 1 고정 — 팔이 ±0.29에 묶여 있어 어깨가 좁아지면 + /// 팔과 몸통 사이가 뜬다. 여자는 몸통 자체가 0.44로 좁아 이미 어깨선이 팔 안쪽에 맞닿는다. + /// + internal static void GetTorsoProfile(int gender, out float bottom, out float mid, out float top) + { + if (gender == 1) { bottom = 1.00f; mid = 0.88f; top = 1.00f; } // 허리 들어가고 골반 + else { bottom = 0.93f; mid = 0.96f; top = 1.00f; } // 어깨 → 허리로 살짝 좁게 + } + + /// + /// 열린 자켓의 라펠 둘(셔츠 판 위쪽 양옆, 위가 바깥으로 벌어진 V) + 목 뒤로 선 옷깃. + /// 색·표시는 CharacterOutfitManager.SetJacketTrim이 겉옷 형태에 따라 정한다(열린 자켓만 보인다). + /// 머티리얼은 인스턴스로 칠해지므로 여기선 자리만 잡는다. + /// + private void BuildJacketTrim(Transform body, float frontZ) + { + Material trimMat = MakeMaterial(new Color(0.14f, 0.28f, 0.6f), SurfaceKind.Cloth); + Mesh lapel = ProcMeshLibrary.RoundedBox(new Vector3(0.07f, 0.2f, 0.014f), 0.006f, 1); + ProcMeshLibrary.CreateNode("LapelL", body, lapel, trimMat, + new Vector3(-0.14f, 0.11f, frontZ + 0.024f), Quaternion.Euler(0f, 0f, 17f)); // 윗끝이 바깥(−X)으로 + ProcMeshLibrary.CreateNode("LapelR", body, lapel, trimMat, + new Vector3(0.14f, 0.11f, frontZ + 0.024f), Quaternion.Euler(0f, 0f, -17f)); // 윗끝이 바깥(+X)으로 + // 목 뒤를 감싸 선 깃 — 늘어진 천(원통 조각)을 짧게 세워 둔다. 원점이 윗변이라 몸통 위(0.23)보다 올려 단다. + Part("Collar", body, ProcMeshLibrary.DrapeShell(210f, 1.12f, 0, 0f, 2), trimMat, + new Vector3(0f, 0.285f, -0.01f), new Vector3(0.30f, 0.075f, 0.27f)); + } + + /// + /// 어깨 둥근 캡. 둥근 몸통 모서리와 팔 위 반구 사이에 V자 홈이 생겨 팔이 몸에 "꽂힌" 게 아니라 + /// 옆에 "세워 둔" 것처럼 보였다. 팔의 자식이라 흔들어도 관절에 붙어 있다. 팔의 비균일 스케일을 + /// 되갚아 구가 찌그러지지 않게 한다(자식에 회전이 없으면 축 배율만 곱해진다). + /// 색은 CharacterOutfitManager.ApplyToCharacter가 팔과 같이 칠한다(ShoulderL/R). + /// + private void BuildShoulderCaps(Vector3 armScale) + { + const float Diameter = 0.15f; + const float Inset = 0.03f; // 몸통 쪽으로 + Vector3 local = new Vector3(Diameter / armScale.x, Diameter / armScale.y, Diameter / armScale.z); + Mesh capMesh = UnitSphere(6, 10); + Part("ShoulderL", outerwearArmL.transform, capMesh, outerwearMat, + new Vector3(Inset / armScale.x, 0f, 0f), local); + Part("ShoulderR", outerwearArmR.transform, capMesh, outerwearMat, + new Vector3(-Inset / armScale.x, 0f, 0f), local); + } + /// /// 지름 1 원판(+Z를 향한다). 눈·동공·하이라이트·홍조가 쓰던 눌린 구체를 대신한다 — /// 그 8개가 캐릭터 정점의 40%였다. bulge는 z 스케일에 함께 눌리므로 넉넉히 잡아 둔다. @@ -328,14 +390,13 @@ private static GameObject BoxPart(string name, Transform parent, Material mat, /// private Material MakeMaterial(Color color, SurfaceKind kind) { - // 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) + // Unity 6 + Built-in Pipeline 환경 가정. 폴백 체인(Standard → URP Lit → Unlit/Color → + // Sprites/Default → 에러 셰이더)은 SceneryMaterials.LitShader가 단일 출처다 — 여기선 진단만 한다. + // 최종 fallback까지 떨어지면 캐릭터가 검정/마젠타 → 진단 로그로 알림. + // 광택은 공유하지 않는다: 부위별 재질은 아래 CharacterPalette.ApplySurface가 정한다(무광 마감 X). + Shader shader = SceneryMaterials.LitShader; + string usedName = shader != null ? shader.name : "(없음)"; + if (shader == null || usedName == "Hidden/InternalErrorShader") { if (!shaderDiagLogged) { @@ -344,7 +405,6 @@ private Material MakeMaterial(Color color, SurfaceKind kind) + "ProjectSettings → Graphics에서 Standard/URP shader가 Always Included Shaders에 포함되어 있는지 확인하세요."); shaderDiagLogged = true; } - shader = Shader.Find("Hidden/InternalErrorShader"); } else if (!shaderDiagLogged && usedName != "Standard") { @@ -363,6 +423,15 @@ private Material MakeMaterial(Color color, SurfaceKind kind) return mat; } + // Shared anatomical anchors; node names remain stable for outfit binding. + public static readonly Vector3 HeadAnchor = new Vector3(0f, 1.22f, 0.03f); + public static readonly Vector3 CapSize = new Vector3(0.76f, 0.34f, 0.73f); + public static readonly Vector3 CapBrimSize = new Vector3(0.68f, 0.045f, 0.43f); + public static Vector3 RotateAttachment(Vector3 restPosition, Vector3 pivot, float angle) + { + return pivot + Quaternion.Euler(angle, 0f, 0f) * (restPosition - pivot); + } + private void BuildAll() { // ── 기본 색상 (의상 미장착 시) ── @@ -395,20 +464,35 @@ private void BuildAll() Transform t = transform; - // ── 몸통 (자켓 외피) — Cube로 변경: 미리보기 직사각형 비례와 정합. ── - // Y 0.7 → 0.78 (조금 길게). 위치 1.40 유지 (Y range 1.01~1.79). - // 90° 모서리 Cube였다 — 벽돌을 얹은 것처럼 보이던 가장 큰 원인이다. - // 둥근 상자는 크기를 메시에 굽고 스케일을 걸지 않는다(모서리 반경 왜곡 방지). - BoxPart("Body", t, outerwearMat, new Vector3(0f, 0.77f, 0f), - new Vector3(bodyScaleX, 0.46f, bodyScaleZ), 0.085f, 3); + // ── 몸통 (자켓 외피) ── + // 90° 모서리 Cube였다 — 벽돌을 얹은 것처럼 보이던 가장 큰 원인이다. 둥근 상자로 바꾼 뒤에도 + // 위아래 폭이 같아 "빵 한 덩어리"였다. 폭을 높이별로 준다: 남자는 어깨에서 허리로 조금 좁아지고, + // 여자는 허리가 들어가고 골반이 나온다(성별 차이가 속눈썹뿐이던 걸 실루엣으로 옮긴다). + // 위쪽 배율은 1로 둔다 — 팔이 x ±0.29에 고정이라(손·도구 좌표가 거기 묶여 있다) 어깨가 + // 좁아지면 팔과 몸 사이가 뜬다. 크기는 메시에 굽고 스케일을 걸지 않는다(모서리 반경 왜곡 방지). + GetTorsoProfile(gender, out float torsoBottom, out float torsoMid, out float torsoTop); + ProcMeshLibrary.CreateNode("Body", t, + ProcMeshLibrary.ProfiledRoundedBox(new Vector3(bodyScaleX, 0.46f, bodyScaleZ), 0.085f, 4, + torsoBottom, torsoMid, torsoTop), + outerwearMat, new Vector3(0f, 0.77f, 0f)); // ── 셔츠 (Top) — Body 안쪽 면적으로 살짝 작게, 자켓 열린 사이로 보이는 영역 ── // 셔츠는 자켓 사이로 살짝 보이는 가슴 패널이다. 옛 값(z 0.15 / 폭 0.34)은 // 몸통 앞면(z 0.19)보다 0.06 앞으로 튀어나오고 폭도 몸통의 71%라, 흰 판이 앞을 통째로 // 덮고 자켓은 양옆에만 남았다 — 측면에서 보면 판때기를 붙인 것처럼 보였다. // 좁히고 몸통 안으로 넣어 자켓이 앞을 덮게 한다. - shirtRoot = BoxPart("Shirt", t, topMat, new Vector3(0f, 0.83f, 0.10f), - new Vector3(0.24f, 0.36f, 0.20f), 0.05f, 2); + // Keep the inset panel ahead of the jacket surface: intersecting rounded + // volumes produced a jagged white patch and depth fighting in the gallery. + // + // 2026-09-30: 자켓을 **앞이 열린** 형태로 바꿨다 — 판을 0.16 → 0.27로 넓히고 라펠·옷깃을 달아 상의가 + // 자켓 사이로 제대로 보이게 한다(예전엔 상의 14벌이 가슴의 좁은 색 조각이라 서로 구분되지 않았다). + // 판은 **몸통의 자식**이다 — 걷기에서 몸통만 위아래로 튀므로(PlayerMovement bob) 루트 자식이면 + // 걸을 때 판이 몸통 앞에서 미끄러졌다. 겉옷을 벗으면 ApplyToCharacter가 판을 숨긴다(몸통이 곧 상의). + Transform bodyT = t.Find("Body"); + float frontZ = bodyScaleZ * 0.5f; + shirtRoot = BoxPart("Shirt", bodyT, topMat, new Vector3(0f, 0.05f, frontZ + 0.006f), + new Vector3(0.27f, 0.30f, 0.026f), 0.012f, 3); + BuildJacketTrim(bodyT, frontZ); // ── 목 ── GameObject neck = GameObject.CreatePrimitive(PrimitiveType.Cylinder); @@ -422,7 +506,7 @@ private void BuildAll() // ── 머리 ── GameObject headPivot = new GameObject("HeadPivot"); headPivot.transform.SetParent(t, false); - headPivot.transform.localPosition = new Vector3(0f, 1.22f, 0.03f); + headPivot.transform.localPosition = HeadAnchor; headPivot.transform.localScale = Vector3.one * headPivotScale; // 치비 둥근 머리: 옛 (−0.10, +0.12, −0.04) 달걀형(세로로 긺) → X/Y를 거의 균등하게. @@ -436,19 +520,19 @@ private void BuildAll() hatRoot.transform.SetParent(headPivot.transform, false); hatRoot.transform.localPosition = Vector3.zero; - GameObject cap = GameObject.CreatePrimitive(PrimitiveType.Cylinder); + GameObject cap = GameObject.CreatePrimitive(PrimitiveType.Sphere); 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.transform.localPosition = new Vector3(0f, 0.24f, -0.02f); + cap.transform.localScale = CapSize; cap.GetComponent().material = hatMat; Object.Destroy(cap.GetComponent()); - GameObject brim = GameObject.CreatePrimitive(PrimitiveType.Cube); + GameObject brim = GameObject.CreatePrimitive(PrimitiveType.Sphere); 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.transform.localPosition = new Vector3(0f, 0.16f, 0.29f); + brim.transform.localScale = CapBrimSize; brim.GetComponent().material = hatMat; Object.Destroy(brim.GetComponent()); @@ -464,24 +548,31 @@ private void BuildAll() hairMat = MakeMaterial(CharacterPalette.Hair(hairColorIdx), SurfaceKind.Hair); 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만. - // 굵기가 일정한 캡슐이라 사지가 파이프처럼 보였다 — 어깨에서 손목으로 가늘어지게. - Mesh armMesh = UnitCapsule(0.72f); - outerwearArmL = Part("ArmL", t, armMesh, outerwearMat, - new Vector3(-0.29f, 0.78f, 0f), new Vector3(0.135f, 0.23f, 0.135f)); - outerwearArmR = Part("ArmR", t, armMesh, outerwearMat, - new Vector3(0.29f, 0.78f, 0f), new Vector3(0.135f, 0.23f, 0.135f)); - - // ── 손 (팔 끝점: y = 1.40 - 0.25 = 1.15. 손목/손바닥 자연 매달림 = 0.95) ── - // 구체 하나라 주먹이라기보다 공에 가까웠다. 손가락은 만들지 않는다 — - // 손 지름이 0.115m라 치비 스케일에서 손가락은 1~2픽셀이다. 대신 세로로 길고 - // 앞뒤로 납작한 미튼(벙어리장갑) 형태가 같은 비용에 확실히 손처럼 보인다. + // ── 팔 — 원점이 **어깨**다 ── + // 걷기(PlayerMovement)와 NPC 보행은 팔 Transform을 제자리에서 돌리고, 손·도구는 + // RotateAttachment(…, arm.localPosition, 각도)로 따라 돈다. 즉 **팔의 localPosition이 곧 관절**이다. + // 예전엔 캡슐 중심(y 0.78)이 원점이라 팔이 한가운데서 돌았다 — 흔들 때마다 윗부분이 몸통 + // 뒤로 빠져 어깨에서 떨어져 보였다. 캡슐을 아래로 밀어(위 반구 중심 = 원점) 원점을 어깨로 + // 옮겼다. 쉬는 자세의 팔 모양·길이는 예전과 같다(0.566~1.026). 손 쉬는 자리(±0.29, 0.52)도 그대로다. + Mesh armMesh = UnitCapsuleHanging(0.72f, out float shoulderDrop); + Vector3 armScale = new Vector3(0.135f, 0.23f, 0.135f); + float shoulderY = 0.78f + shoulderDrop * armScale.y; + outerwearArmL = Part("ArmL", t, armMesh, outerwearMat, new Vector3(-0.29f, shoulderY, 0f), armScale); + outerwearArmR = Part("ArmR", t, armMesh, outerwearMat, new Vector3(0.29f, shoulderY, 0f), armScale); + BuildShoulderCaps(armScale); + + // ── 손 ── + // 손가락은 만들지 않는다 — 손 지름이 0.1m라 치비 스케일에서 손가락은 1~2픽셀이다. 세로로 길고 + // 앞뒤로 납작한 미튼(벙어리장갑)에 **엄지 하나**를 붙이면 같은 비용으로 확실히 손이 된다 + // (엄지가 없을 땐 "흰 비누"처럼 보였다). 엄지는 손의 자식이라 걷기 회전을 그대로 따른다. Vector3 handSize = new Vector3(0.105f, 0.135f, 0.095f); - BoxPart("HandL", t, skinMat, new Vector3(-0.29f, 0.52f, 0f), handSize, 0.042f, 2); - BoxPart("HandR", t, skinMat, new Vector3(0.29f, 0.52f, 0f), handSize, 0.042f, 2); + GameObject handL = BoxPart("HandL", t, skinMat, new Vector3(-0.29f, 0.52f, 0f), handSize, 0.042f, 2); + GameObject handR = BoxPart("HandR", t, skinMat, new Vector3(0.29f, 0.52f, 0f), handSize, 0.042f, 2); + Mesh thumbMesh = UnitSphere(5, 7); + Part("ThumbL", handL.transform, thumbMesh, skinMat, new Vector3(0.045f, 0.012f, 0.03f), + new Vector3(0.05f, 0.075f, 0.05f), new Vector3(0f, 0f, -25f)); + Part("ThumbR", handR.transform, thumbMesh, skinMat, new Vector3(-0.045f, 0.012f, 0.03f), + new Vector3(0.05f, 0.075f, 0.05f), new Vector3(0f, 0f, 25f)); // ── 다리 + 부츠 (LegPivot으로 묶어 회전 시 발도 함께 움직이도록) ── // 옛은 BootL/R이 Player 직접 자식이라 PlayerMovement.AnimateWalk의 LegL/R 회전이 발에 전파 안 됨 @@ -512,13 +603,14 @@ private void BuildAll() new Vector3(0f, -0.36f, 0.07f), bootSize, bootRadius, 2); // ── 배낭 (Backpack) ── - backpackRoot = GameObject.CreatePrimitive(PrimitiveType.Cube); + // 몸통의 자식 — 걸을 때 몸통과 함께 튄다(루트 자식이던 때는 몸통이 가방 앞에서 위아래로 미끄러졌다). + // 가방 레시피도 OutfitAnchor.Body 좌표(몸통 중심 y 0.77 기준)다. + backpackRoot = Part("Backpack", bodyT, ProcMeshLibrary.RoundedBox(Vector3.one, 0.16f, 2), backpackMat, Vector3.zero, Vector3.one); backpackRoot.name = "Backpack"; - backpackRoot.transform.SetParent(t, false); - backpackRoot.transform.localPosition = new Vector3(0f, 0.80f, -0.22f); + backpackRoot.transform.localPosition = new Vector3(0f, 0.80f - 0.77f, -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"; @@ -536,20 +628,16 @@ private void BuildAll() toolHandle = GameObject.CreatePrimitive(PrimitiveType.Cylinder); toolHandle.name = "NetHandle"; toolHandle.transform.SetParent(t, false); - toolHandle.transform.localPosition = new Vector3(0.29f, 0.74f, 0.02f); + toolHandle.transform.localPosition = new Vector3(0.29f, 0.74f, 0f); toolHandle.transform.localScale = new Vector3(0.04f, 0.40f, 0.04f); - toolHandle.transform.localRotation = Quaternion.Euler(20f, 0f, -15f); + toolHandle.transform.localRotation = Quaternion.identity; 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.34f, 1.14f, 0.06f); - toolRing.transform.localScale = new Vector3(0.20f, 0.02f, 0.20f); - toolRing.transform.localRotation = Quaternion.Euler(-20f, 0f, 0f); - toolRing.GetComponent().material = toolRingMat; - Object.Destroy(toolRing.GetComponent()); + // 머리 = 세운 테 + 그물(원점 = 테 아래 끝 = 자루 끝). 기본 잠자리채 레시피와 같은 메시·좌표다. + toolRing = ProcMeshLibrary.CreateNode("NetRing", t, ProcMeshLibrary.NetHead(0.05f, 0.85f), toolRingMat, + new Vector3(0.29f, 1.14f, 0f), Quaternion.Euler(-25f, 0f, 0f)); + toolRing.transform.localScale = new Vector3(0.22f, 0.22f, 0.22f); // 악세서리는 미리 만들지 않는다 — OutfitShapeLibrary의 레시피가 장착 시점에 // 루트 아래 OP_Accessory 컨테이너로 필요한 파츠만 만든다. @@ -573,7 +661,9 @@ private void BuildAll() [System.Diagnostics.Conditional("UNITY_EDITOR")] private void LogVertexBudget() { - const int Budget = 3500; + // 3500 → 3800 → 4200(2026-09-30): 머리 스타일 4종(앞머리 껍질·옆머리·긴머리 가닥)과 입·눈꺼풀·어깨·엄지로 + // +300~500, 열린 자켓의 옷깃·라펠과 잠자리채 그물 머리로 +300. 그래도 프리미티브 시절(10,400)의 40% 수준이다. + const int Budget = 4200; int total = 0; MeshFilter[] filters = GetComponentsInChildren(true); @@ -595,101 +685,145 @@ private void LogVertexBudget() } } + // ── 얼굴 ──────────────────────────────────────────── + // + // 머리 구의 반지름(HeadPivot 공간). 얼굴 부품을 **머리 표면 위에** 놓는 데 쓴다 — 예전엔 z를 상수로 + // 박아서 머리가 큰 여자 얼굴에서는 입(0.32)이 표면(≈0.32) 안으로 반쯤 파묻혔다. + private Vector3 headRadii; + + /// 눈썹 높이. 앞머리 끝(y ≈ 0.105~0.115)보다 아래여야 눈썹이 가려지지 않는다. + private const float BrowY = 0.092f; + + /// 머리 표면의 z(앞면). (x, y)가 머리 밖이면 0. + private float FaceZ(float x, float y) + { + float u = x / headRadii.x; + float v = y / headRadii.y; + return headRadii.z * Mathf.Sqrt(Mathf.Max(0f, 1f - u * u - v * v)); + } + + /// 눈썹·속눈썹 색 — 머리색을 어둡게. 예전엔 고정 갈색이라 금발·파랑 머리에 갈색 눈썹이 붙었다(2D 초상은 이미 머리색을 따랐다). + internal static Color BrowTone(Color hair) + { + return new Color(hair.r * 0.55f + 0.04f, hair.g * 0.55f + 0.03f, hair.b * 0.55f + 0.03f); + } + private void BuildFace(GameObject headPivot, float headScale, int gender) { + Transform h = headPivot.transform; + headRadii = new Vector3((headScale - 0.02f) * 0.5f, (headScale - 0.04f) * 0.5f, (headScale - 0.04f) * 0.5f); + Material eyeMat = MakeMaterial(Color.white, SurfaceKind.Wet); - Material pupilMat = MakeMaterial(new Color(0.12f, 0.08f, 0.05f), SurfaceKind.Wet); + Material pupilMat = MakeMaterial(new Color(0.16f, 0.10f, 0.07f), SurfaceKind.Wet); + Material hlMat = MakeMaterial(new Color(1f, 1f, 1f, 0.9f), SurfaceKind.Wet); + Color browColor = BrowTone(CharacterPalette.Hair(look.hairColor)); + Material browMat = MakeMaterial(browColor, SurfaceKind.Hair); + Material lidMat = MakeMaterial(new Color(0.10f, 0.07f, 0.06f), SurfaceKind.Hair); - // 치비 큰 눈: 옛 0.11 → 0.15/0.17(세로로 큰 동그란 눈), 살짝 아래·바깥(귀여운 인상). - // 눌린 구체(515정점)에서 원판(17정점)으로 — 이 얼굴 8노드가 캐릭터 정점의 40%였다. + // 치비 큰 눈 — 노드 이름·좌표는 CharacterFaceAnimator가 깜빡임에 쓰므로 그대로 둔다. Mesh eyeMesh = UnitDisc(16); - Part("EyeL", headPivot.transform, eyeMesh, eyeMat, - new Vector3(-0.12f, -0.03f, 0.32f), new Vector3(0.15f, 0.17f, 0.06f)); - Part("EyeR", headPivot.transform, eyeMesh, eyeMat, - new Vector3(0.12f, -0.03f, 0.32f), new Vector3(0.15f, 0.17f, 0.06f)); + GameObject eyeL = Part("EyeL", h, eyeMesh, eyeMat, new Vector3(-0.12f, -0.03f, 0.32f), new Vector3(0.15f, 0.17f, 0.06f)); + GameObject eyeR = Part("EyeR", h, eyeMesh, eyeMat, new Vector3(0.12f, -0.03f, 0.32f), new Vector3(0.15f, 0.17f, 0.06f)); Mesh pupilMesh = UnitDisc(12); - Part("PupilL", headPivot.transform, pupilMesh, pupilMat, - new Vector3(-0.12f, -0.04f, 0.35f), new Vector3(0.09f, 0.11f, 0.02f)); - Part("PupilR", headPivot.transform, pupilMesh, pupilMat, - new Vector3(0.12f, -0.04f, 0.35f), new Vector3(0.09f, 0.11f, 0.02f)); + GameObject pupilL = Part("PupilL", h, pupilMesh, pupilMat, new Vector3(-0.12f, -0.04f, 0.35f), new Vector3(0.10f, 0.12f, 0.02f)); + GameObject pupilR = Part("PupilR", h, pupilMesh, pupilMat, new Vector3(0.12f, -0.04f, 0.35f), new Vector3(0.10f, 0.12f, 0.02f)); - Material hlMat = MakeMaterial(new Color(1f, 1f, 1f, 0.9f), SurfaceKind.Wet); Mesh hlMesh = UnitDisc(8); - Part("HighlightL", headPivot.transform, hlMesh, hlMat, - new Vector3(-0.10f, 0.01f, 0.36f), new Vector3(0.045f, 0.045f, 0.01f)); - Part("HighlightR", headPivot.transform, hlMesh, hlMat, - new Vector3(0.10f, 0.01f, 0.36f), new Vector3(0.045f, 0.045f, 0.01f)); - - int faceType = look.faceType; - Material browMat = MakeMaterial(new Color(0.2f, 0.15f, 0.1f), SurfaceKind.Hair); - 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()); - - // 코는 몸의 피부 머티리얼을 그대로 쓴다. 예전엔 여기서 같은 색으로 **하나 더** 만들면서 - // 이름까지 필드와 같아(지역 변수가 필드를 가림) 피부색을 한 곳에서 바꾸려 하면 - // 코만 옛 색으로 남는 함정이었다. BuildAll이 이 메서드보다 먼저 필드를 채운다. - // 치비: 코는 작은 점으로 (큰 눈 강조). 위치도 눈 아래로 내림. - Part("Nose", headPivot.transform, UnitSphere(4, 6), skinMat, - new Vector3(0f, -0.10f, 0.35f), new Vector3(0.024f, 0.022f, 0.02f)); - - Material mouthMat = MakeMaterial(new Color(0.8f, 0.4f, 0.35f), SurfaceKind.Skin); - 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()); + Part("HighlightL", h, hlMesh, hlMat, new Vector3(-0.10f, 0.01f, 0.36f), new Vector3(0.045f, 0.045f, 0.01f)); + Part("HighlightR", h, hlMesh, hlMat, new Vector3(0.10f, 0.01f, 0.36f), new Vector3(0.045f, 0.045f, 0.01f)); + // 아래쪽 작은 반사 — 동공의 자식이라 깜빡일 때 동공과 함께 눌린다(애니메이터 목록을 늘리지 않는다). + Part("GlintL", pupilL.transform, hlMesh, hlMat, new Vector3(-0.22f, -0.26f, 0.6f), new Vector3(0.18f, 0.15f, 0.5f)); + Part("GlintR", pupilR.transform, hlMesh, hlMat, new Vector3(-0.22f, -0.26f, 0.6f), new Vector3(0.18f, 0.15f, 0.5f)); + + // 윗눈꺼풀 선 — 눈의 **자식**이라 깜빡이면 눈과 함께 눌려 감은 눈의 선이 된다. + // 선 하나가 흰 원판을 "눈"으로 읽히게 하는 가장 싼 장치다(예전 남자 눈은 흰 원 + 점이라 멍해 보였다). + // 여자는 더 두껍고 바깥으로 길게 뻗는다(속눈썹). 옛 LashL/R 큐브는 이걸로 대신한다. + float lidThick = gender == 1 ? 0.20f : 0.13f; + float lidOuter = gender == 1 ? 8f : 22f; // 바깥 끝 각도(작을수록 길다) + Part("LidL", eyeL.transform, ProcMeshLibrary.Arc(0.5f, lidThick, 180f - lidOuter, 35f, 10), lidMat, + new Vector3(0f, 0f, 0.75f), Vector3.one); + Part("LidR", eyeR.transform, ProcMeshLibrary.Arc(0.5f, lidThick, lidOuter, 145f, 10), lidMat, + new Vector3(0f, 0f, 0.75f), Vector3.one); + + // 눈썹 — 둥근 막대. 머리색을 따른다. + Mesh browMesh = ProcMeshLibrary.RoundedBox(new Vector3(0.10f, gender == 1 ? 0.018f : 0.024f, 0.02f), 0.008f, 1); + ProcMeshLibrary.CreateNode("BrowL", h, browMesh, browMat, new Vector3(-0.12f, BrowY, FaceZ(-0.12f, BrowY) + 0.008f)); + ProcMeshLibrary.CreateNode("BrowR", h, browMesh, browMat, new Vector3(0.12f, BrowY, FaceZ(0.12f, BrowY) + 0.008f)); + + // 코는 몸의 피부 머티리얼을 그대로 쓴다(BuildAll이 먼저 채운다). 치비: 작은 점. + Part("Nose", h, UnitSphere(4, 6), skinMat, new Vector3(0f, -0.10f, FaceZ(0f, -0.10f)), new Vector3(0.024f, 0.022f, 0.02f)); + + BuildMouth(h, look.faceType); if (gender == 1) { Material blushMat = MakeMaterial(new Color(1f, 0.6f, 0.6f, 0.4f), SurfaceKind.Skin); Mesh blushMesh = UnitDisc(10); - Part("BlushL", headPivot.transform, blushMesh, blushMat, - new Vector3(-0.16f, -0.10f, 0.30f), new Vector3(0.08f, 0.05f, 0.02f)); - Part("BlushR", headPivot.transform, blushMesh, blushMat, - new Vector3(0.16f, -0.10f, 0.30f), new Vector3(0.08f, 0.05f, 0.02f)); - - Material lashMat = MakeMaterial(new Color(0.1f, 0.08f, 0.05f), SurfaceKind.Hair); - 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()); + Part("BlushL", h, blushMesh, blushMat, new Vector3(-0.17f, -0.11f, FaceZ(-0.17f, -0.11f) + 0.004f), new Vector3(0.08f, 0.05f, 0.02f)); + Part("BlushR", h, blushMesh, blushMat, new Vector3(0.17f, -0.11f, FaceZ(0.17f, -0.11f) + 0.004f), new Vector3(0.08f, 0.05f, 0.02f)); + } + } + + /// + /// 표정별 입 — 모양이 달라야 네 가지가 구분된다. 예전엔 폭 0.02~0.05의 납작한 상자 하나라 + /// 어떤 표정을 골라도 화면에선 점 하나였다(2D 초상화는 크기 순서까지 반대였다). + /// 노드는 "Mouth" 하나다 — CharacterFaceAnimator가 그 x 스케일을 표정에 따라 곱한다. + /// + private void BuildMouth(Transform h, int faceType) + { + MouthShape(faceType, out MouthKind kind, out Vector2 size, out float bottomY); + Material lineMat = MakeMaterial(new Color(0.42f, 0.17f, 0.15f), SurfaceKind.Skin); + + GameObject mouth; + switch (kind) + { + case MouthKind.Open: + { + // 윗변이 평평한 반원(D) + 혀. 원점 = 윗변 중앙. + Material openMat = MakeMaterial(new Color(0.55f, 0.16f, 0.17f), SurfaceKind.Skin); + float z = FaceZ(0f, bottomY + size.y) + 0.006f; + mouth = Part("Mouth", h, ProcMeshLibrary.Sector(0.5f, 1f, 180f, 360f, 12), openMat, + new Vector3(0f, bottomY + size.y, z), new Vector3(size.x, size.y, 1f)); + Material tongueMat = MakeMaterial(new Color(0.96f, 0.55f, 0.55f), SurfaceKind.Skin); + Part("MouthTongue", mouth.transform, ProcMeshLibrary.Sector(0.5f, 1f, 180f, 360f, 10), tongueMat, + new Vector3(0f, -0.55f, 0.002f), new Vector3(0.62f, 0.45f, 1f)); + break; + } + case MouthKind.Line: + { + float y = bottomY + size.y * 0.5f; + mouth = Part("Mouth", h, UnitDisc(10), lineMat, + new Vector3(0f, y, FaceZ(0f, y) + 0.006f), new Vector3(size.x, size.y, 0.01f)); + break; + } + default: + { + // 미소 곡선 — 원의 아래쪽 호. 원점 = 원 중심이라 곡선 바닥이 bottomY에 오게 올린다. + // z는 **원점 높이**의 표면에서 잡는다 — 곡선은 전부 그보다 아래(적도에서 먼 쪽)라 표면이 + // 그보다 뒤에 있다. 바닥 기준으로 잡으면 입꼬리가 머리 속으로 파묻힌다. + float y = bottomY + 0.5f * size.y; + mouth = Part("Mouth", h, ProcMeshLibrary.Arc(0.5f, 0.24f, 205f, 335f, 10), lineMat, + new Vector3(0f, y, FaceZ(0f, y) + 0.006f), new Vector3(size.x, size.y, 1f)); + break; + } + } + } + + internal enum MouthKind { Smile, Open, Line } + + /// + /// 표정(생성 화면 라벨: 미소·활짝·차분·무표정) → 입 모양. 순수 표라 테스트가 네 표정이 서로 다른지를 고정한다. + /// 는 노드 스케일(폭, 높이), 는 입 아래 끝의 HeadPivot y. + /// + internal static void MouthShape(int faceType, out MouthKind kind, out Vector2 size, out float bottomY) + { + switch (faceType) + { + case 1: kind = MouthKind.Open; size = new Vector2(0.115f, 0.075f); bottomY = -0.205f; break; // 활짝 + case 2: kind = MouthKind.Smile; size = new Vector2(0.075f, 0.045f); bottomY = -0.175f; break; // 차분 + case 3: kind = MouthKind.Line; size = new Vector2(0.06f, 0.014f); bottomY = -0.172f; break; // 무표정 + default: kind = MouthKind.Smile; size = new Vector2(0.11f, 0.075f); bottomY = -0.18f; break; // 미소 } } @@ -702,146 +836,145 @@ private void BuildEars(GameObject headPivot, Material skinMat) new Vector3(0.34f, -0.02f, 0f), new Vector3(0.05f, 0.08f, 0.06f)); } - private void BuildHair(GameObject headPivot, int style, int gender, Material hairMat) + // ── 머리카락 ──────────────────────────────────────── + // + // 예전 네 스타일은 전부 정수리를 덮는 "헬멧" 하나에 옆·뒤 덩어리만 달랐고, 긴머리조차 뒤로만 + // 늘어져 **정면에서는 넷이 같은 머리**였다. 정면에서 읽히는 요소로 스타일을 가른다: + // 짧은 = 삐친 앞머리 / 중간 = 턱까지 오는 옆머리 / 긴 = 어깨 앞으로 내려오는 머리 / 올림 = 정수리 위 형태. + // 정수리 덮개(HairTop)는 모든 스타일이 공유하고 **모자 속에 들어가는 크기**로 둔다 — + // 기본 캡(반지름 0.38×0.17×0.365, 중심 y 0.24)보다 위로 나오면 모든 모자가 머리를 뚫는다. + + private void BuildHair(GameObject headPivot, int style, int gender, Material mat) { + Transform h = headPivot.transform; + // 정수리 덮개 — 머리보다 조금 크고 위·뒤로 치우친 타원체. 얼굴(눈 높이)은 드러내고 + // 정수리·옆·뒤통수를 덮는다. 짧은 머리 남자는 폭을 줄여 귀가 보이게 한다. + float skullW = (style == 0 && gender == 0) ? headRadii.x * 2f + 0.02f : headRadii.x * 2f + 0.045f; + Part("HairTop", h, UnitSphere(10, 14), mat, new Vector3(0f, 0.075f, -0.045f), + new Vector3(skullW, 0.66f, headRadii.z * 2f + 0.05f)); // 높이는 고정 — 머리가 큰 여자도 캡(꼭대기 0.41) 밑 + + // 모자 선 위로 솟는 부분(올림머리 스파이크·번)은 한 컨테이너에 모은다. 정수리를 덮는 모자를 쓰면 + // CharacterOutfitManager가 이걸 통째로 숨긴다 — 안 그러면 스파이크가 캡·헬멧을 뚫고 나온다. + // 스타일과 무관하게 **항상 만든다**(빈 컨테이너여도) — 모자 레시피의 hideNodes가 이름으로 찾는다. + hairCrown = new GameObject(OutfitShapeLibrary.HairCrownNode).transform; + hairCrown.SetParent(h, false); + 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; + case 1: BuildMediumHair(h, gender, mat); break; + case 2: BuildLongHair(h, gender, mat); break; + case 3: BuildUpHair(h, gender, mat); break; + default: BuildShortHair(h, gender, mat); break; } } + private Transform hairCrown; + + /// 가닥용 저폴리 캡슐(6링×8) — 앞머리만 5~6개라 몸통 팔다리용(8×10)을 쓰면 정점이 두 배로 는다. + private static Mesh StrandMesh(float taper) + { + float rTop = 0.5f, rBottom = 0.5f * taper; + return ProcMeshLibrary.TaperedCapsule(rTop, rBottom, 2f - rTop - rBottom, 6, 8); + } + /// - /// 여성 앞머리. 스타일마다 두께가 다르다. + /// 앞머리 — 이마 곡면을 따라 붙인 **한 장**()이고 아래 끝만 모양이 다르다. + /// 남자는 한쪽으로 쓸린 톱니, 여자는 둥근 물결 + 관자놀이까지 내려오는 옆머리. /// - /// 예전엔 이 블록이 안에만 있었는데, 가 - /// 그 메서드를 부른 뒤 자기 앞머리를 또 만들었다 — 여성+긴머리에서 "HairBangs"라는 - /// 같은 이름의 Cube 둘(두께 0.08 / 0.10)이 같은 자리에 겹쳐 z-fighting이 났다. - /// 생성 지점을 하나로 모으고 호출을 한 번씩만 두어 구조적으로 막는다. + /// 시안 둘을 버렸다: 타원 한 덩어리는 모자 챙처럼 보였고, 캡슐 가닥을 늘어놓으면 가닥마다 음영이 + /// 따로 져서 "이빨"이나 "꼰 끈"이 됐다. 끝은 눈썹(y ≈ 0.10) 바로 위에서 멈춘다 — 눈썹이 가리면 표정이 죽는다. + /// 윗변은 정수리 덮개(HairTop) 밑으로 들어가게 높게 잡아 이음매가 안 보이게 한다. /// - private void BuildBangs(GameObject headPivot, Material mat, float thickness) + private void BuildBangs(Transform h, int gender, Material mat) { - 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, thickness, 0.1f); - bangs.GetComponent().material = mat; - Object.Destroy(bangs.GetComponent()); + const float Gap = 0.016f; // 머리 표면에서 띄우는 두께 — 붙이면 z-fighting, 크면 떠 보인다 + float r = Mathf.Max(headRadii.x, headRadii.z) + Gap; + float ry = headRadii.y + Gap; + Mesh shell = gender == 1 + ? ProcMeshLibrary.FringeShell(r, ry, 62f, 62f, 16f, 9f, 22f, 6, 0.5f, true) + : ProcMeshLibrary.FringeShell(r, ry, 52f, 62f, 17f, 19f, 4f, 4, 0.8f, false); + ProcMeshLibrary.CreateNode("HairBangs", h, shell, mat, Vector3.zero); } - /// - /// 긴머리는 자기 두께의 앞머리를 따로 만들므로 false로 부른다 — 앞머리 노드 중복 방지. - /// - private void BuildShortHair(GameObject headPivot, int gender, Material mat, bool withBangs = true) + private void BuildShortHair(Transform h, int gender, Material mat) { - Mesh cap = UnitSphere(8, 12); // 머리를 덮는 큰 덩어리 - Mesh tuft = UnitSphere(6, 8); // 옆·뒤 작은 덩어리 - - Part("HairTop", headPivot.transform, cap, mat, - new Vector3(0f, 0.22f, -0.02f), new Vector3(0.62f, 0.34f, 0.60f)); - Part("HairSideL", headPivot.transform, tuft, mat, - new Vector3(-0.2f, 0.05f, -0.02f), new Vector3(0.12f, 0.2f, 0.35f)); - Part("HairSideR", headPivot.transform, tuft, mat, - new Vector3(0.2f, 0.05f, -0.02f), new Vector3(0.12f, 0.2f, 0.35f)); - Part("HairBack", headPivot.transform, tuft, mat, - new Vector3(0f, 0.08f, -0.15f), new Vector3(0.45f, 0.28f, 0.2f)); + BuildBangs(h, gender, mat); + // 뒤통수를 둥글게 — 덮개만으로는 목덜미 위가 각져 보인다. + Part("HairBack", h, UnitSphere(6, 10), mat, new Vector3(0f, -0.06f, -0.2f), new Vector3(0.5f, 0.3f, 0.26f)); - if (gender == 1 && withBangs) BuildBangs(headPivot, mat, 0.08f); + if (gender == 1) + { + // 짧은 단발 — 광대까지 오는 옆머리. + Mesh side = UnitSphere(6, 10); + Part("HairSideL", h, side, mat, new Vector3(-0.29f, -0.05f, 0.02f), new Vector3(0.15f, 0.30f, 0.36f)); + Part("HairSideR", h, side, mat, new Vector3(0.29f, -0.05f, 0.02f), new Vector3(0.15f, 0.30f, 0.36f)); + } } - private void BuildMediumHair(GameObject headPivot, int gender, Material mat) + private void BuildMediumHair(Transform h, int gender, Material mat) { - BuildShortHair(headPivot, gender, mat); - - Mesh strandMesh = UnitCapsule(0.85f); // 끝으로 갈수록 살짝 가늘어지는 머리 다발 - - Part("HairExtL", headPivot.transform, strandMesh, mat, - new Vector3(-0.2f, -0.1f, -0.05f), new Vector3(0.1f, 0.18f, 0.12f)); - Part("HairExtR", headPivot.transform, strandMesh, mat, - new Vector3(0.2f, -0.1f, -0.05f), new Vector3(0.1f, 0.18f, 0.12f)); - Part("HairBackExt", headPivot.transform, strandMesh, mat, - new Vector3(0f, -0.08f, -0.18f), new Vector3(0.35f, 0.2f, 0.15f)); + BuildBangs(h, gender, mat); + // 턱선까지 내려오는 옆머리 — 정면에서 얼굴을 양옆으로 감싸는 것이 짧은 머리와의 차이다. + Mesh side = UnitSphere(6, 9); + Part("HairSideL", h, side, mat, new Vector3(-0.29f, -0.09f, 0.01f), new Vector3(0.17f, 0.44f, 0.42f)); + Part("HairSideR", h, side, mat, new Vector3(0.29f, -0.09f, 0.01f), new Vector3(0.17f, 0.44f, 0.42f)); + Part("HairBack", h, UnitSphere(6, 9), mat, new Vector3(0f, -0.11f, -0.2f), new Vector3(0.64f, 0.48f, 0.36f)); } - private void BuildLongHair(GameObject headPivot, int gender, Material mat) + private void BuildLongHair(Transform h, int gender, Material mat) { - // 앞머리는 아래에서 긴머리 두께(0.10)로 직접 만든다 — 여기서 받으면 둘이 겹친다. - BuildShortHair(headPivot, gender, mat, withBangs: false); - - Mesh longStrandMesh = UnitCapsule(0.8f); - - for (int i = 0; i < 3; i++) - { - float x = (i - 1) * 0.12f; - Part($"HairLong_{i}", headPivot.transform, longStrandMesh, mat, - new Vector3(x, -0.3f, -0.12f), new Vector3(0.12f, 0.35f, 0.1f)); - } - - Part("HairFrontL", headPivot.transform, longStrandMesh, mat, - new Vector3(-0.18f, -0.12f, 0.1f), new Vector3(0.06f, 0.22f, 0.06f)); - Part("HairFrontR", headPivot.transform, longStrandMesh, mat, - new Vector3(0.18f, -0.12f, 0.1f), new Vector3(0.06f, 0.22f, 0.06f)); - - if (gender == 1) - { - BuildBangs(headPivot, mat, 0.10f); - - for (int i = 0; i < 2; i++) - { - float x = (i == 0) ? -0.08f : 0.08f; - Part($"HairVeryLong_{i}", headPivot.transform, longStrandMesh, mat, - new Vector3(x, -0.55f, -0.12f), new Vector3(0.1f, 0.3f, 0.08f)); - } - } + BuildMediumHair(h, gender, mat); + + // 어깨 **앞으로** 늘어지는 머리 — 긴머리가 정면에서 읽히는 유일한 자리다(예전엔 전부 뒤로만 늘어졌다). + // 남자는 어깨선까지, 여자는 가슴까지. + // 턱 옆(z 0.08)에서 시작해 가슴 앞(몸통 앞면 월드 z 0.19 ≈ HeadPivot z 0.28)으로 내려오게 앞으로 기울인다 — + // 곧게 내리면 가슴 높이에서 몸통 속에 파묻힌다. + float len = gender == 1 ? 0.30f : 0.20f; + float topZ = 0.08f, bottomZ = gender == 1 ? 0.31f : 0.27f; + float lean = -Mathf.Asin(Mathf.Clamp((bottomZ - topZ) / (2f * len), -1f, 1f)) * Mathf.Rad2Deg; + Vector3 lockCenter = new Vector3(0.25f, -0.12f - len, (topZ + bottomZ) * 0.5f); + Mesh lockMesh = StrandMesh(0.7f); + Part("HairLongL", h, lockMesh, mat, new Vector3(-lockCenter.x, lockCenter.y, lockCenter.z), + new Vector3(0.15f, len, 0.12f), new Vector3(lean, 0f, -4f)); + Part("HairLongR", h, lockMesh, mat, lockCenter, new Vector3(0.15f, len, 0.12f), new Vector3(lean, 0f, 4f)); + + // 등으로 내려오는 뒷머리. 몸통 등판(월드 z −0.19)보다 뒤에 있어야 파묻히지 않는다. + float backLen = gender == 1 ? 0.34f : 0.22f; + Part("HairLongBack", h, StrandMesh(0.85f), mat, new Vector3(0f, -0.14f - backLen, -0.40f), + new Vector3(0.56f, backLen, 0.18f), new Vector3(-8f, 0f, 0f)); } - private void BuildUpHair(GameObject headPivot, int gender, Material mat) + private void BuildUpHair(Transform h, int gender, Material mat) { - Part("HairTop", headPivot.transform, UnitSphere(8, 12), mat, - new Vector3(0f, 0.26f, -0.02f), new Vector3(0.55f, 0.30f, 0.52f)); - if (gender == 0) { - // 스파이크는 끝이 뾰족할수록 좋다 — 테이퍼를 세게 준다. - Mesh spikeMesh = UnitCapsule(0.35f); - - Part("HairSpike", headPivot.transform, spikeMesh, mat, - new Vector3(0f, 0.35f, 0.05f), new Vector3(0.15f, 0.18f, 0.12f), - new Vector3(-20f, 0f, 0f)); - - for (int side = -1; side <= 1; side += 2) - { - Part($"HairSideSpike_{(side > 0 ? "R" : "L")}", headPivot.transform, spikeMesh, mat, - new Vector3(side * 0.15f, 0.28f, 0f), new Vector3(0.08f, 0.12f, 0.08f), - new Vector3(0f, 0f, -side * 30f)); - } - } - else - { - Part("HairBun", headPivot.transform, UnitSphere(7, 10), mat, - new Vector3(0f, 0.15f, -0.22f), new Vector3(0.22f, 0.22f, 0.22f)); - - Material ribbonMat = MakeMaterial(new Color(0.9f, 0.3f, 0.4f), SurfaceKind.Cloth); - 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()); - - BuildBangs(headPivot, mat, 0.08f); - - Mesh sideHairMesh = UnitCapsule(0.8f); - for (int side = -1; side <= 1; side += 2) - { - Part($"HairSide_{(side > 0 ? "R" : "L")}", headPivot.transform, sideHairMesh, mat, - new Vector3(side * 0.2f, -0.05f, 0.05f), new Vector3(0.06f, 0.15f, 0.06f)); - } + // 삐죽 올린 머리 — 이마를 드러내고(앞머리 없음) 정수리에서 방사형으로 솟는다. + // 앞머리가 없다는 것 자체가 정면에서 짧은 머리와 가르는 가장 큰 차이다. + // 첫 시안(지름 0.11~0.17)은 정수리 덮개에 묻혀 곰 귀처럼 보였다 — 굵고 길게. + // 전부 HairCrown 아래 — 정수리를 덮는 모자를 쓰면 통째로 숨는다. + Mesh spike = StrandMesh(0.25f); + Part("HairSpike", hairCrown, spike, mat, new Vector3(0f, 0.33f, 0.17f), new Vector3(0.22f, 0.16f, 0.17f), new Vector3(-50f, 0f, 0f)); + Part("HairSpike_L", hairCrown, spike, mat, new Vector3(-0.15f, 0.36f, 0.03f), new Vector3(0.17f, 0.17f, 0.15f), new Vector3(-12f, 0f, 30f)); + Part("HairSpike_R", hairCrown, spike, mat, new Vector3(0.15f, 0.36f, 0.03f), new Vector3(0.17f, 0.17f, 0.15f), new Vector3(-12f, 0f, -30f)); + Part("HairSpike_T", hairCrown, spike, mat, new Vector3(0f, 0.40f, -0.07f), new Vector3(0.18f, 0.18f, 0.16f), new Vector3(12f, 0f, 0f)); + Part("HairSpike_BL", hairCrown, spike, mat, new Vector3(-0.11f, 0.32f, -0.2f), new Vector3(0.15f, 0.15f, 0.13f), new Vector3(40f, 0f, 22f)); + Part("HairSpike_BR", hairCrown, spike, mat, new Vector3(0.11f, 0.32f, -0.2f), new Vector3(0.15f, 0.15f, 0.13f), new Vector3(40f, 0f, -22f)); + Part("HairBack", h, UnitSphere(6, 10), mat, new Vector3(0f, -0.06f, -0.2f), new Vector3(0.48f, 0.28f, 0.24f)); + return; } + + // 올림머리 — 정수리 뒤 높은 번(bun) + 리본. 번을 뒤통수에 붙이면(예전) 정면에서 안 보인다. + BuildBangs(h, gender, mat); + Part("HairBun", hairCrown, UnitSphere(7, 10), mat, new Vector3(0f, 0.33f, -0.2f), new Vector3(0.28f, 0.26f, 0.28f)); + Material ribbonMat = MakeMaterial(new Color(0.92f, 0.34f, 0.44f), SurfaceKind.Cloth); + Mesh bow = UnitSphere(5, 8); + Part("HairRibbonL", hairCrown, bow, ribbonMat, new Vector3(-0.09f, 0.25f, -0.09f), new Vector3(0.12f, 0.08f, 0.05f), new Vector3(0f, 20f, 25f)); + Part("HairRibbonR", hairCrown, bow, ribbonMat, new Vector3(0.09f, 0.25f, -0.09f), new Vector3(0.12f, 0.08f, 0.05f), new Vector3(0f, -20f, -25f)); + // 귀 앞으로 내린 잔머리 두 가닥. + Mesh tendril = StrandMesh(0.6f); + Part("HairSide_L", h, tendril, mat, new Vector3(-0.28f, -0.1f, 0.11f), new Vector3(0.06f, 0.15f, 0.06f), new Vector3(0f, 0f, -6f)); + Part("HairSide_R", h, tendril, mat, new Vector3(0.28f, -0.1f, 0.11f), new Vector3(0.06f, 0.15f, 0.06f), new Vector3(0f, 0f, 6f)); + Part("HairBack", h, UnitSphere(6, 10), mat, new Vector3(0f, -0.04f, -0.2f), new Vector3(0.48f, 0.28f, 0.24f)); } // ────────────────────────────────────────────── diff --git a/Assets/Scripts/Core/ProcMeshLibrary.cs b/Assets/Scripts/Core/ProcMeshLibrary.cs index be4c5cf..058b8b0 100644 --- a/Assets/Scripts/Core/ProcMeshLibrary.cs +++ b/Assets/Scripts/Core/ProcMeshLibrary.cs @@ -41,6 +41,13 @@ private enum Shape RoundedBox, TaperedCapsule, Diamond, + ProfiledRoundedBox, + Arc, + Sector, + FringeShell, + DrapeShell, + Torus, + NetHead, } /// @@ -56,23 +63,34 @@ private enum Shape private readonly float b; private readonly float c; private readonly float d; + private readonly float e; + private readonly float f; + private readonly float g; private readonly int i; private readonly int j; public MeshKey(Shape shape, float a, float b, float c, float d, int i, int j) + : this(shape, a, b, c, d, 0f, 0f, 0f, i, j) { } + + /// 인자가 넷을 넘는 생성기(프로필 상자)용. 기존 키는 e·f·g가 0이라 값이 바뀌지 않는다. + public MeshKey(Shape shape, float a, float b, float c, float d, float e, float f, float g, int i, int j) { this.shape = shape; this.a = a; this.b = b; this.c = c; this.d = d; + this.e = e; + this.f = f; + this.g = g; this.i = i; this.j = j; } public bool Equals(MeshKey o) { - return shape == o.shape && a == o.a && b == o.b && c == o.c && d == o.d && i == o.i && j == o.j; + return shape == o.shape && a == o.a && b == o.b && c == o.c && d == o.d + && e == o.e && f == o.f && g == o.g && i == o.i && j == o.j; } public override bool Equals(object obj) @@ -87,6 +105,9 @@ public override int GetHashCode() h = h * 397 ^ b.GetHashCode(); h = h * 397 ^ c.GetHashCode(); h = h * 397 ^ d.GetHashCode(); + h = h * 397 ^ e.GetHashCode(); + h = h * 397 ^ f.GetHashCode(); + h = h * 397 ^ g.GetHashCode(); h = h * 397 ^ i; h = h * 397 ^ j; return h; @@ -247,88 +268,191 @@ public static Mesh RoundedBox(Vector3 size, float radius, int subdiv) return Cached(new MeshKey(Shape.RoundedBox, size.x, size.y, size.z, radius, subdiv, 0), () => { - Vector3 inner = new Vector3(half.x - radius, half.y - radius, half.z - radius); + List verts = new List(); + List norms = new List(); + List uvs = new List(); + List tris = new List(); + BuildRoundedBox(half, radius, subdiv, verts, norms, uvs, tris); + Mesh mesh = new Mesh { name = "ProcRoundedBox" }; + mesh.SetVertices(verts); + mesh.SetNormals(norms); + mesh.SetUVs(0, uvs); + mesh.SetTriangles(tris, 0); + mesh.RecalculateBounds(); + return mesh; + }); + } + + /// + /// 폭(X)이 높이를 따라 변하는 둥근 상자 — 몸통 실루엣용. 를 짓고 + /// X만 높이별 배율로 누른다. 배율은 아래·가운데·위 세 값을 지나는 2차 곡선이다 + /// (남자는 어깨가 넓고 허리로 좁아지고, 여자는 허리가 들어가고 골반이 나온다). + /// + /// Z(두께)는 건드리지 않는다 — 셔츠 패널·가슴 배지 같은 덧붙임이 몸통 앞면 z에 맞춰 + /// 놓여 있어, 앞면이 기울면 아래쪽이 떠 보인다. 배율은 1 이하로 쓴다(겉옷 레시피가 어깨폭에 맞춰 있다). + /// + /// 노멀은 변형의 역전치 야코비안으로 해석적으로 옮긴다 — RecalculateNormals는 면 경계에서 + /// 복제된 정점마다 다른 값을 내 이음매가 보인다(가 해석적 노멀을 쓰는 이유와 같다). + /// + public static Mesh ProfiledRoundedBox(Vector3 size, float radius, int subdiv, + float bottomScale, float midScale, float topScale) + { + subdiv = Mathf.Max(1, subdiv); + Vector3 half = size * 0.5f; + radius = Mathf.Clamp(radius, 0f, Mathf.Min(half.x, Mathf.Min(half.y, half.z)) * 0.999f); + + return Cached(new MeshKey(Shape.ProfiledRoundedBox, size.x, size.y, size.z, radius, + bottomScale, midScale, topScale, subdiv, 0), () => + { List verts = new List(); List norms = new List(); + List uvs = new List(); List tris = new List(); + BuildRoundedBox(half, radius, subdiv, verts, norms, uvs, tris); + + float height = Mathf.Max(1e-4f, size.y); + for (int k = 0; k < verts.Count; k++) + { + Vector3 p = verts[k]; + Vector3 n = norms[k]; + float t = Mathf.Clamp01((p.y + half.y) / height); + float sc = ProfileScale(t, bottomScale, midScale, topScale); + float dsdy = ProfileSlope(t, bottomScale, midScale, topScale) / height; + + // x' = x·s(y). 노멀은 J^{-T}n = (nx/s, ny − x·s'·nx/s, nz). + Vector3 m = new Vector3(n.x / sc, n.y - p.x * dsdy * n.x / sc, n.z); + verts[k] = new Vector3(p.x * sc, p.y, p.z); + norms[k] = m.sqrMagnitude > 1e-10f ? m.normalized : n; + } + + Mesh mesh = new Mesh { name = "ProcProfiledRoundedBox" }; + mesh.SetVertices(verts); + mesh.SetNormals(norms); + mesh.SetUVs(0, uvs); + mesh.SetTriangles(tris, 0); + mesh.RecalculateBounds(); + return mesh; + }); + } - // 6면: (축, 부호) - Vector3[] normals = { Vector3.right, Vector3.left, Vector3.up, Vector3.down, Vector3.forward, Vector3.back }; - for (int f = 0; f < 6; f++) + /// 둥근 상자 한 면의 평면 UV — 의 remarks 참고. + internal static Vector2 FaceUv(int face, Vector3 p, Vector3 half) + { + float x = p.x / Mathf.Max(1e-5f, half.x); + float y = p.y / Mathf.Max(1e-5f, half.y); + float z = p.z / Mathf.Max(1e-5f, half.z); + // 아틀라스: u 0~0.5 = 앞면, 0.5~1 = 뒷면·옆면. 단추·주머니·거미줄 중심 같은 **앞면 전용 무늬**가 + // 등과 옆구리에 복사되지 않게 한다(OutfitPatternLibrary가 두 칸을 따로 그린다). + float fu, fv = (y + 1f) * 0.5f; + bool front = false; + switch (face) + { + case 0: fu = (z + 1f) * 0.5f; break; // +X + case 1: fu = (1f - z) * 0.5f; break; // −X + case 2: fu = (1f - x) * 0.5f; fv = 1f; front = true; break; // +Y(윗면) — 앞면 윗줄 색 + case 3: fu = (1f - x) * 0.5f; fv = 0f; front = true; break; // −Y(아랫면) — 앞면 밑줄 색 + case 4: fu = (1f - x) * 0.5f; front = true; break; // +Z(앞) — 정면에서 보면 +X가 왼쪽 + default: fu = (x + 1f) * 0.5f; break; // −Z(뒤) + } + // 칸 경계에서 이웃 칸이 번지지 않게(밉맵·쌍선형) 칸 안쪽으로 조금 당긴다. + fu = Mathf.Lerp(0.01f, 0.99f, fu); + return new Vector2(front ? fu * 0.5f : 0.5f + fu * 0.5f, fv); + } + + /// (0,b)·(0.5,m)·(1,top)을 지나는 2차 곡선. 0 가까이로 떨어지지 않게 막는다. + internal static float ProfileScale(float t, float b, float m, float top) + { + float l0 = 2f * (t - 0.5f) * (t - 1f); + float l1 = -4f * t * (t - 1f); + float l2 = 2f * t * (t - 0.5f); + return Mathf.Max(0.05f, b * l0 + m * l1 + top * l2); + } + + private static float ProfileSlope(float t, float b, float m, float top) + { + return b * (4f * t - 3f) + m * (4f - 8f * t) + top * (4f * t - 1f); + } + + /// + /// UV는 면마다 0..1 평면 투영이다(무늬 텍스처용). 앞·뒤·옆면은 그 면을 바라보는 사람 기준으로 + /// 왼→오른쪽이 u라 비대칭 무늬가 뒤집혀 보이지 않고, v는 전부 높이라 가로 줄무늬가 옆면까지 이어진다. + /// 윗면·아랫면은 v를 1·0에 고정해 맞닿은 줄의 색을 그대로 잇는다(어깨 위에 줄무늬가 가로로 눕지 않게). + /// + private static void BuildRoundedBox(Vector3 half, float radius, int subdiv, + List verts, List norms, List uvs, List tris) + { + Vector3 inner = new Vector3(half.x - radius, half.y - radius, half.z - radius); + + // 6면: (축, 부호) + Vector3[] normals = { Vector3.right, Vector3.left, Vector3.up, Vector3.down, Vector3.forward, Vector3.back }; + for (int f = 0; f < 6; f++) + { + Vector3 n = normals[f]; + // 면 평면의 두 접선축 — ±X면은 (up, forward), ±Y면은 (right, forward), ±Z면은 (right, up). + // 인접 면이 모서리에서 같은 좌표를 내야 틈이 안 생기므로 축은 항상 양의 방향으로 잡는다. + Vector3 tu = (f < 2) ? Vector3.up : Vector3.right; + Vector3 tv = (f < 4) ? Vector3.forward : Vector3.up; + + int start = verts.Count; + for (int iy = 0; iy <= subdiv; iy++) { - Vector3 n = normals[f]; - // 면 평면의 두 접선축 — ±X면은 (up, forward), ±Y면은 (right, forward), ±Z면은 (right, up). - // 인접 면이 모서리에서 같은 좌표를 내야 틈이 안 생기므로 축은 항상 양의 방향으로 잡는다. - Vector3 tu = (f < 2) ? Vector3.up : Vector3.right; - Vector3 tv = (f < 4) ? Vector3.forward : Vector3.up; - - int start = verts.Count; - for (int iy = 0; iy <= subdiv; iy++) + for (int ix = 0; ix <= subdiv; ix++) { - for (int ix = 0; ix <= subdiv; ix++) - { - float u = ix / (float)subdiv * 2f - 1f; // -1..1 - float v = iy / (float)subdiv * 2f - 1f; + float u = ix / (float)subdiv * 2f - 1f; // -1..1 + float v = iy / (float)subdiv * 2f - 1f; - // 면 위의 점(상자 표면 좌표) - Vector3 p = Vector3.Scale(n, half) - + tu * (u * Vector3.Dot(half, tu)) - + tv * (v * Vector3.Dot(half, tv)); + // 면 위의 점(상자 표면 좌표) + Vector3 p = Vector3.Scale(n, half) + + tu * (u * Vector3.Dot(half, tu)) + + tv * (v * Vector3.Dot(half, tv)); - // 안쪽 상자로 clamp → 그 점이 곡률 중심 - Vector3 c = new Vector3( - Mathf.Clamp(p.x, -inner.x, inner.x), - Mathf.Clamp(p.y, -inner.y, inner.y), - Mathf.Clamp(p.z, -inner.z, inner.z)); + // 안쪽 상자로 clamp → 그 점이 곡률 중심 + Vector3 c = new Vector3( + Mathf.Clamp(p.x, -inner.x, inner.x), + Mathf.Clamp(p.y, -inner.y, inner.y), + Mathf.Clamp(p.z, -inner.z, inner.z)); - Vector3 dir = p - c; - Vector3 normal = dir.sqrMagnitude > 1e-8f ? dir.normalized : n; + Vector3 dir = p - c; + Vector3 normal = dir.sqrMagnitude > 1e-8f ? dir.normalized : n; - verts.Add(c + normal * radius); - norms.Add(normal); - } + verts.Add(c + normal * radius); + norms.Add(normal); + uvs.Add(FaceUv(f, p, half)); } + } - // 격자 삼각형 (a, c, b)의 면 노멀은 cross(tv, tu) 방향이다. 그게 이 면의 바깥 - // 노멀과 반대면 순서를 뒤집는다. - // - // 면마다 하드코딩하면 틀린다 — 축 순환 때문에 ±X와 ±Z가 ±Y와 반대로 나온다. - // 처음에 `f == 1 || f == 3 || f == 5`로 적었다가 6면 중 4면이 뒤집혔고, - // 그건 백페이스 컬링에 걸려 예외 없이 안 보인다. - bool flip = Vector3.Dot(Vector3.Cross(tv, tu), n) < 0f; + // 격자 삼각형 (a, c, b)의 면 노멀은 cross(tv, tu) 방향이다. 그게 이 면의 바깥 + // 노멀과 반대면 순서를 뒤집는다. + // + // 면마다 하드코딩하면 틀린다 — 축 순환 때문에 ±X와 ±Z가 ±Y와 반대로 나온다. + // 처음에 `f == 1 || f == 3 || f == 5`로 적었다가 6면 중 4면이 뒤집혔고, + // 그건 백페이스 컬링에 걸려 예외 없이 안 보인다. + bool flip = Vector3.Dot(Vector3.Cross(tv, tu), n) < 0f; - int stride = subdiv + 1; - for (int iy = 0; iy < subdiv; iy++) + int stride = subdiv + 1; + for (int iy = 0; iy < subdiv; iy++) + { + for (int ix = 0; ix < subdiv; ix++) { - for (int ix = 0; ix < subdiv; ix++) + int a = start + iy * stride + ix; + int b = a + 1; + int c2 = a + stride; + int d = c2 + 1; + + if (flip) { - int a = start + iy * stride + ix; - int b = a + 1; - int c2 = a + stride; - int d = c2 + 1; - - if (flip) - { - tris.Add(a); tris.Add(b); tris.Add(c2); - tris.Add(b); tris.Add(d); tris.Add(c2); - } - else - { - tris.Add(a); tris.Add(c2); tris.Add(b); - tris.Add(b); tris.Add(c2); tris.Add(d); - } + tris.Add(a); tris.Add(b); tris.Add(c2); + tris.Add(b); tris.Add(d); tris.Add(c2); + } + else + { + tris.Add(a); tris.Add(c2); tris.Add(b); + tris.Add(b); tris.Add(c2); tris.Add(d); } } } - - Mesh mesh = new Mesh { name = "ProcRoundedBox" }; - mesh.SetVertices(verts); - mesh.SetNormals(norms); - mesh.SetTriangles(tris, 0); - mesh.RecalculateBounds(); - return mesh; - }); + } } // ── 테이퍼드 캡슐 ───────────────────────────────────── @@ -340,16 +464,23 @@ public static Mesh RoundedBox(Vector3 size, float radius, int subdiv) /// 중심은 원점, 축은 Y. 전체 높이는 + 양 끝 반구다 /// (내장 Capsule과 같은 규약이라 기존 좌표를 그대로 쓸 수 있다). /// - public static Mesh TaperedCapsule(float radiusTop, float radiusBottom, float height, int rings, int segments) + /// + /// 모든 정점을 Y로 민다. 0이면 기존과 같은 캐시 키·같은 메시다. 팔처럼 한쪽 끝을 축으로 돌리는 + /// 부위에 쓴다 — Transform은 원점을 축으로 도므로, 원점이 캡슐 가운데면 팔이 어깨가 아니라 + /// 팔 한가운데에서 돌아 윗부분이 몸통 뒤로 빠진다. + /// + public static Mesh TaperedCapsule(float radiusTop, float radiusBottom, float height, int rings, int segments, + float yOffset = 0f) { rings = Mathf.Max(3, rings); segments = Mathf.Max(3, segments); - return Cached(new MeshKey(Shape.TaperedCapsule, radiusTop, radiusBottom, height, 0f, rings, segments), () => + return Cached(new MeshKey(Shape.TaperedCapsule, radiusTop, radiusBottom, height, yOffset, rings, segments), () => { float halfH = height * 0.5f; int vCount = (rings + 1) * (segments + 1); Vector3[] verts = new Vector3[vCount]; Vector3[] norms = new Vector3[vCount]; + Vector2[] uvs = new Vector2[vCount]; List tris = new List(rings * segments * 6); for (int r = 0; r <= rings; r++) @@ -392,13 +523,16 @@ public static Mesh TaperedCapsule(float radiusTop, float radiusBottom, float hei for (int s = 0; s <= segments; s++) { - float u = s / (float)segments * Mathf.PI * 2f; + // 링은 **뒤(−Z)에서 시작**한다 — UV 이음매(u 0↔1)가 무늬 텍스처에서 뒤쪽에 숨는다. + // 형태는 예전(+X 시작)과 같고 정점 순서만 돈다(와인딩 방향 동일). + float u = (s / (float)segments + 0.75f) * Mathf.PI * 2f; float cx = Mathf.Cos(u); float cz = Mathf.Sin(u); int idx = r * (segments + 1) + s; - verts[idx] = new Vector3(cx * ringR, y, cz * ringR); + verts[idx] = new Vector3(cx * ringR, y + yOffset, cz * ringR); norms[idx] = new Vector3(cx * slopeXZ, slopeY, cz * slopeXZ).normalized; + uvs[idx] = new Vector2(s / (float)segments, 1f - t); // 원통 투영: u 둘레, v 위→아래 } } @@ -427,12 +561,435 @@ public static Mesh TaperedCapsule(float radiusTop, float radiusBottom, float hei Mesh mesh = new Mesh { name = "ProcTaperedCapsule" }; mesh.vertices = verts; mesh.normals = norms; + mesh.uv = uvs; mesh.triangles = tris.ToArray(); mesh.RecalculateBounds(); return mesh; }); } + // ── 평면 호·부채꼴 (+Z를 향한다) ───────────────────── + + /// + /// 두께 있는 호(띠). 입꼬리가 올라간 미소, 눈 위 속눈썹 선처럼 휜 선을 그린다 — + /// 예전 입은 폭 0.02~0.05의 납작한 상자라 표정 네 가지가 화면에서 구분되지 않았다. + /// 각도는 도 단위, +X에서 반시계로 잰다(270°가 아래). 원점은 원의 중심이다. + /// + public static Mesh Arc(float radius, float thickness, float startDeg, float endDeg, int segments) + { + segments = Mathf.Max(1, segments); + return Cached(new MeshKey(Shape.Arc, radius, thickness, startDeg, endDeg, segments, 0), () => + { + Vector3[] verts = new Vector3[(segments + 1) * 2]; + Vector3[] norms = new Vector3[verts.Length]; + int[] tris = new int[segments * 6]; + float rIn = Mathf.Max(0f, radius - thickness * 0.5f); + float rOut = radius + thickness * 0.5f; + + for (int k = 0; k <= segments; k++) + { + float a = Mathf.Lerp(startDeg, endDeg, k / (float)segments) * Mathf.Deg2Rad; + Vector3 dir = new Vector3(Mathf.Cos(a), Mathf.Sin(a), 0f); + verts[k * 2] = dir * rIn; + verts[k * 2 + 1] = dir * rOut; + norms[k * 2] = Vector3.forward; + norms[k * 2 + 1] = Vector3.forward; + } + + // 각도가 반시계로 늘면 (안k, 밖k, 밖k+1)의 면 노멀이 +Z다. 끝각이 더 작으면 뒤집는다. + bool ccw = endDeg >= startDeg; + for (int k = 0; k < segments; k++) + { + int i0 = k * 2, o0 = k * 2 + 1, i1 = k * 2 + 2, o1 = k * 2 + 3; + int t = k * 6; + if (ccw) + { + tris[t] = i0; tris[t + 1] = o0; tris[t + 2] = o1; + tris[t + 3] = i0; tris[t + 4] = o1; tris[t + 5] = i1; + } + else + { + tris[t] = i0; tris[t + 1] = o1; tris[t + 2] = o0; + tris[t + 3] = i0; tris[t + 4] = i1; tris[t + 5] = o1; + } + } + + Mesh mesh = new Mesh { name = "ProcArc" }; + mesh.vertices = verts; + mesh.normals = norms; + mesh.triangles = tris; + mesh.RecalculateBounds(); + return mesh; + }); + } + + /// + /// 부채꼴(중심에서 호까지 채운 면). 180°→360°면 윗변이 평평한 반원 — 활짝 웃는 입이다. + /// 원점은 원의 중심, 각도 규약은 와 같다. + /// + public static Mesh Sector(float radiusX, float radiusY, float startDeg, float endDeg, int segments) + { + segments = Mathf.Max(1, segments); + return Cached(new MeshKey(Shape.Sector, radiusX, radiusY, startDeg, endDeg, segments, 0), () => + { + Vector3[] verts = new Vector3[segments + 2]; + Vector3[] norms = new Vector3[verts.Length]; + int[] tris = new int[segments * 3]; + + verts[0] = Vector3.zero; + norms[0] = Vector3.forward; + for (int k = 0; k <= segments; k++) + { + float a = Mathf.Lerp(startDeg, endDeg, k / (float)segments) * Mathf.Deg2Rad; + verts[k + 1] = new Vector3(Mathf.Cos(a) * radiusX, Mathf.Sin(a) * radiusY, 0f); + norms[k + 1] = Vector3.forward; + } + + bool ccw = endDeg >= startDeg; + for (int k = 0; k < segments; k++) + { + tris[k * 3] = 0; + tris[k * 3 + 1] = ccw ? k + 1 : k + 2; + tris[k * 3 + 2] = ccw ? k + 2 : k + 1; + } + + Mesh mesh = new Mesh { name = "ProcSector" }; + mesh.vertices = verts; + mesh.normals = norms; + mesh.triangles = tris; + mesh.RecalculateBounds(); + return mesh; + }); + } + + // ── 앞머리 껍질 ─────────────────────────────────────── + + /// + /// 이마를 덮는 앞머리 한 장 — 머리 타원체(반지름 ·, + /// 중심 원점) 표면의 한 조각이고, **아래 끝만** 톱니(남자)나 물결(여자)로 잘린다. + /// + /// 왜 전용 생성기인가: 캡슐·구 조각을 이마에 늘어놓으면 조각마다 음영이 따로 져서 "이빨"이나 + /// "꼰 끈"으로 읽혔다(2026-09-30 시안 두 번). 곡면 한 장이면 음영이 이어지고 끝모양만 남는다. + /// + /// 각도는 도 단위. 방위각 θ는 +Z(정면)에서 잰다(±), + /// 고도 φ는 적도에서 위로 잰다. 윗변은 (정수리 덮개 밑으로 들어가게 높게), + /// 톱니 끝은 , 톱니 뿌리는 거기서 만큼 위다. + /// 만큼 양 끝(관자놀이)이 더 내려온다. + /// (0~1)는 톱니 꼭짓점의 위치 — 0.5면 대칭, 크면 한쪽으로 쓸린다. + /// 면 톱니 대신 둥근 물결이다(여자 앞머리). + /// 바깥(머리 반대쪽)만 그린다 — 안쪽은 머리에 붙어 보이지 않는다. + /// + public static Mesh FringeShell(float radiusXZ, float radiusY, float halfAngleDeg, float topDeg, float tipDeg, + float toothDeg, float sideDropDeg, int teeth, float skew, bool rounded) + { + teeth = Mathf.Clamp(teeth, 1, 40); + skew = Mathf.Clamp(skew, 0.05f, 0.95f); + int skewKey = Mathf.RoundToInt(skew * 100f); + return Cached(new MeshKey(Shape.FringeShell, radiusXZ, radiusY, halfAngleDeg, topDeg, tipDeg, toothDeg, sideDropDeg, + teeth * 1000 + skewKey * 2 + (rounded ? 1 : 0), 0), () => + { + int cols = teeth * 4; // 톱니당 4열이면 끝이 뾰족하게 선다(6열은 정점만 늘었다) + const int rows = 5; + Vector3[] verts = new Vector3[(cols + 1) * (rows + 1)]; + Vector3[] norms = new Vector3[verts.Length]; + int[] tris = new int[cols * rows * 6]; + + for (int c = 0; c <= cols; c++) + { + float u = c / (float)cols; // 0..1 (−θ → +θ) + float theta = Mathf.Lerp(-halfAngleDeg, halfAngleDeg, u) * Mathf.Deg2Rad; + float bottom = FringeEdgeDeg(u, tipDeg, toothDeg, sideDropDeg, teeth, skew, rounded); + + for (int r = 0; r <= rows; r++) + { + float phi = Mathf.Lerp(topDeg, bottom, r / (float)rows) * Mathf.Deg2Rad; + float cp = Mathf.Cos(phi); + Vector3 unit = new Vector3(Mathf.Sin(theta) * cp, Mathf.Sin(phi), Mathf.Cos(theta) * cp); + int idx = c * (rows + 1) + r; + verts[idx] = new Vector3(unit.x * radiusXZ, unit.y * radiusY, unit.z * radiusXZ); + norms[idx] = new Vector3(unit.x / radiusXZ, unit.y / radiusY, unit.z / radiusXZ).normalized; + } + } + + // 열은 +X로, 행은 아래로 간다 — (a, 아래, 오른쪽)이 정면에서 +Z를 향한다. + int t = 0; + for (int c = 0; c < cols; c++) + { + for (int r = 0; r < rows; r++) + { + int a = c * (rows + 1) + r; + int down = a + 1; + int right = a + rows + 1; + int diag = right + 1; + tris[t++] = a; tris[t++] = down; tris[t++] = right; + tris[t++] = right; tris[t++] = down; tris[t++] = diag; + } + } + + Mesh mesh = new Mesh { name = "ProcFringeShell" }; + mesh.vertices = verts; + mesh.normals = norms; + mesh.triangles = tris; + mesh.RecalculateBounds(); + return mesh; + }); + } + + /// 앞머리 아래 끝의 고도(도). u = 0..1 가로 위치. 순수 함수라 모양을 테스트로 고정한다. + internal static float FringeEdgeDeg(float u, float tipDeg, float toothDeg, float sideDropDeg, + int teeth, float skew, bool rounded) + { + float q = u * teeth; + q -= Mathf.Floor(q); + if (u >= 1f) q = 1f; // 마지막 열은 톱니 끝이 아니라 뿌리 + + float w; // 1 = 끝(가장 아래), 0 = 뿌리 + // sin(π)은 부동소수에서 −8.7e-8이라 그대로 Sqrt하면 **NaN**이다 — 오른쪽 끝 열이 통째로 사라졌다. + if (rounded) w = Mathf.Sqrt(Mathf.Max(0f, Mathf.Sin(q * Mathf.PI))); + else w = q < skew ? q / skew : (1f - q) / (1f - skew); + + float side = Mathf.Abs(u * 2f - 1f); + return tipDeg + toothDeg * (1f - w) - sideDropDeg * side * side; + } + + // ── 늘어진 천(망토·코트 자락·치맛단) ───────────────── + + /// + /// 위에서 아래로 늘어진 천 한 장 — Y축을 감싸는 원통 조각이고 **뒤(−Z)를 중심으로** 펼쳐진다. + /// 단위 크기: 윗변 반지름 0.5, 높이 1(원점 = 윗변 중앙, 아래로 −1). 파츠 스케일로 폭·길이·깊이를 준다. + /// 옛 망토·코트 자락은 두께 0.03의 상자라 "등에 붙인 판자"였다(2026-09-30 전수 캡처). + /// + /// 는 감싸는 각도(180이면 반원, 360이면 치마처럼 한 바퀴), + /// 는 밑단 반지름 배율(1보다 크면 퍼진다), + /// ·는 밑으로 갈수록 깊어지는 주름(반지름 방향 사인파). + /// 양면이다 — 망토 안쪽은 옆에서 보이므로 뒷면도 그린다(노멀을 뒤집은 두 번째 면). + /// UV는 u = 가로, v = 위(1)→아래(0). + /// + /// 세로 분할. 짧은 천(옷깃)은 2면 충분하다 — 정점 = (열+1)·(행+1)·2. + public static Mesh DrapeShell(float arcDeg, float flare, int folds, float foldDepth, int rows = 8) + { + arcDeg = Mathf.Clamp(arcDeg, 10f, 360f); + folds = Mathf.Clamp(folds, 0, 24); + rows = Mathf.Clamp(rows, 1, 16); + return Cached(new MeshKey(Shape.DrapeShell, arcDeg, flare, foldDepth, 0f, folds, rows), () => + { + int cols = Mathf.Max(8, Mathf.CeilToInt(arcDeg / 12f)); + int per = (cols + 1) * (rows + 1); + Vector3[] verts = new Vector3[per * 2]; + Vector3[] norms = new Vector3[per * 2]; + Vector2[] uvs = new Vector2[per * 2]; + int[] tris = new int[cols * rows * 12]; + + float half = arcDeg * 0.5f * Mathf.Deg2Rad; + float slope = 0.5f * (flare - 1f); + for (int c = 0; c <= cols; c++) + { + float u = c / (float)cols; + float th = Mathf.Lerp(-half, half, u); + for (int r = 0; r <= rows; r++) + { + float v = r / (float)rows; // 0 위 → 1 아래 + float fold = folds > 0 ? Mathf.Sin(u * folds * Mathf.PI * 2f) * foldDepth * v : 0f; + float rad = 0.5f * Mathf.Lerp(1f, flare, v) + fold; + // 뒤(−Z) 중심: th = 0이 (0, ·, −r). + Vector3 p = new Vector3(Mathf.Sin(th) * rad, -v, -Mathf.Cos(th) * rad); + int i = c * (rows + 1) + r; + verts[i] = p; + verts[i + per] = p; + // 바깥 노멀: 반지름 방향 + 퍼짐 기울기(밑단이 퍼지면 면이 아래를 조금 본다) + Vector3 n = new Vector3(Mathf.Sin(th), -slope, -Mathf.Cos(th)).normalized; + norms[i] = n; + norms[i + per] = -n; + uvs[i] = new Vector2(u, 1f - v); + uvs[i + per] = new Vector2(u, 1f - v); + } + } + + // 바깥면 와인딩은 바깥 노멀과 같은 쪽 — ProcMeshLibraryTests가 와인딩↔노멀 일치로 고정한다. + // 안쪽면은 같은 정점을 복제해 노멀을 뒤집고 순서도 뒤집는다. + int t = 0; + for (int c = 0; c < cols; c++) + { + for (int r = 0; r < rows; r++) + { + int a = c * (rows + 1) + r; + int down = a + 1; + int right = a + rows + 1; + int diag = right + 1; + tris[t++] = a; tris[t++] = right; tris[t++] = down; + tris[t++] = right; tris[t++] = diag; tris[t++] = down; + tris[t++] = a + per; tris[t++] = down + per; tris[t++] = right + per; + tris[t++] = right + per; tris[t++] = down + per; tris[t++] = diag + per; + } + } + + Mesh mesh = new Mesh { name = "ProcDrapeShell" }; + mesh.vertices = verts; + mesh.normals = norms; + mesh.uv = uvs; + mesh.triangles = tris; + mesh.RecalculateBounds(); + return mesh; + }); + } + + // ── 고리(토러스) ────────────────────────────────────── + + /// + /// Y축을 도는 고리. 단위 크기: 큰 반지름 0.5, 관 반지름 (바깥지름 ≈ 1 + 2·tube). + /// 잠자리채 테·모자 띠·팔찌처럼 **구멍이 있어야 하는** 자리에 쓴다 — 원기둥으로 흉내 내면 판이 막혀 + /// 잠자리채가 망치로 보였다. + /// + public static Mesh Torus(float tube, int segments, int sides) + { + tube = Mathf.Clamp(tube, 0.005f, 0.45f); + segments = Mathf.Clamp(segments, 6, 64); + sides = Mathf.Clamp(sides, 3, 16); + return Cached(new MeshKey(Shape.Torus, tube, 0f, 0f, 0f, segments, sides), () => + { + Vector3[] verts = new Vector3[(segments + 1) * (sides + 1)]; + Vector3[] norms = new Vector3[verts.Length]; + Vector2[] uvs = new Vector2[verts.Length]; + int[] tris = new int[segments * sides * 6]; + + for (int i = 0; i <= segments; i++) + { + float a = i / (float)segments * Mathf.PI * 2f; + Vector3 dir = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)); + for (int j = 0; j <= sides; j++) + { + float b = j / (float)sides * Mathf.PI * 2f; + Vector3 n = dir * Mathf.Cos(b) + Vector3.up * Mathf.Sin(b); + int k = i * (sides + 1) + j; + verts[k] = dir * 0.5f + n * tube; + norms[k] = n; + uvs[k] = new Vector2(i / (float)segments, j / (float)sides); + } + } + + int t = 0; + for (int i = 0; i < segments; i++) + { + for (int j = 0; j < sides; j++) + { + int a = i * (sides + 1) + j; + int b = a + sides + 1; + tris[t++] = a; tris[t++] = a + 1; tris[t++] = b; + tris[t++] = b; tris[t++] = a + 1; tris[t++] = b + 1; + } + } + + Mesh mesh = new Mesh { name = "ProcTorus" }; + mesh.vertices = verts; + mesh.normals = norms; + mesh.uv = uvs; + mesh.triangles = tris; + mesh.RecalculateBounds(); + return mesh; + }); + } + + // ── 잠자리채 머리(테 + 그물 주머니) ───────────────── + + /// + /// 잠자리채 머리 — **세운 고리(테) + 뒤(−Z)로 늘어진 그물 주머니**를 한 메시로 굽는다. + /// 원점은 테의 **아래 끝**(자루가 꽂히는 점)이라, 노드를 자루 끝에 두면 테가 자루 위에 선다. + /// 단위 크기: 테 지름 1(중심 (0, 0.5, 0), XY 평면), 관 반지름 , + /// 주머니 깊이 (끝은 조금 처진다). + /// + /// 한 메시인 이유: 잠자리채는 NetRing **bind** 노드 하나라(PlayerMovement가 스윙에 캐시한다) + /// 파츠를 따로 달 수 없다. 옛 모양은 납작한 원기둥(막힌 판)이라 자루와 합쳐 망치로 보였다. + /// 주머니는 양면이다 — 테 안으로 안쪽이 보인다. + /// + public static Mesh NetHead(float tube, float depth) + { + tube = Mathf.Clamp(tube, 0.01f, 0.2f); + depth = Mathf.Clamp(depth, 0.1f, 2f); + return Cached(new MeshKey(Shape.NetHead, tube, depth, 0f, 0f, 0, 0), () => + { + const int seg = 16, sides = 5, rows = 4; // 한 손에 드는 소품 — 테 102 + 그물 170정점 + List verts = new List(); + List norms = new List(); + List uvs = new List(); + List tris = new List(); + Vector3 center = new Vector3(0f, 0.5f, 0f); + + // 테: XY 평면의 토러스. 큰 원 방향 d = (sin a, −cos a, 0) — a = 0이 아래 끝(원점). + int ringStart = verts.Count; + for (int i = 0; i <= seg; i++) + { + float a = i / (float)seg * Mathf.PI * 2f; + Vector3 d = new Vector3(Mathf.Sin(a), -Mathf.Cos(a), 0f); + for (int j = 0; j <= sides; j++) + { + float b = j / (float)sides * Mathf.PI * 2f; + Vector3 n = d * Mathf.Cos(b) + Vector3.forward * Mathf.Sin(b); + verts.Add(center + d * 0.5f + n * tube); + norms.Add(n); + uvs.Add(new Vector2(i / (float)seg, j / (float)sides)); + } + } + for (int i = 0; i < seg; i++) + { + for (int j = 0; j < sides; j++) + { + int a0 = ringStart + i * (sides + 1) + j; + int b0 = a0 + sides + 1; + tris.Add(a0); tris.Add(b0); tris.Add(a0 + 1); + tris.Add(b0); tris.Add(b0 + 1); tris.Add(a0 + 1); + } + } + + // 주머니: 테(반지름 0.5)에서 뒤로 모이는 원뿔. 끝이 아래로 조금 처진다. + Vector3 tip = center + new Vector3(0f, -0.18f, -depth); + int bagStart = verts.Count; + for (int r = 0; r <= rows; r++) + { + float t = r / (float)rows; // 0 테 → 1 끝 + float rad = 0.5f * (1f - t) * (1f - 0.25f * t); + Vector3 c = Vector3.Lerp(center, tip, t); + c.y -= Mathf.Sin(t * Mathf.PI) * 0.06f; // 가운데가 살짝 처진 곡선 + for (int i = 0; i <= seg; i++) + { + float a = i / (float)seg * Mathf.PI * 2f; + Vector3 d = new Vector3(Mathf.Sin(a), -Mathf.Cos(a), 0f); + verts.Add(c + d * rad); + norms.Add((d + Vector3.back * 0.35f).normalized); + uvs.Add(new Vector2(i / (float)seg, 1f - t)); + } + } + int per = (rows + 1) * (seg + 1); + // 안쪽면 — 같은 정점을 복제해 노멀을 뒤집는다 + for (int k = 0; k < per; k++) + { + verts.Add(verts[bagStart + k]); + norms.Add(-norms[bagStart + k]); + uvs.Add(uvs[bagStart + k]); + } + for (int r = 0; r < rows; r++) + { + for (int i = 0; i < seg; i++) + { + int a0 = bagStart + r * (seg + 1) + i; + int b0 = a0 + seg + 1; + // 바깥(아래·뒤를 보는 면): (a, 다음 행, 옆) — 와인딩↔노멀 일치는 테스트가 고정한다. + tris.Add(a0); tris.Add(b0); tris.Add(a0 + 1); + tris.Add(a0 + 1); tris.Add(b0); tris.Add(b0 + 1); + tris.Add(a0 + per); tris.Add(a0 + 1 + per); tris.Add(b0 + per); + tris.Add(a0 + 1 + per); tris.Add(b0 + 1 + per); tris.Add(b0 + per); + } + } + + Mesh mesh = new Mesh { name = "ProcNetHead" }; + mesh.SetVertices(verts); + mesh.SetNormals(norms); + mesh.SetUVs(0, uvs); + mesh.SetTriangles(tris, 0); + mesh.RecalculateBounds(); + return mesh; + }); + } + // ── 팔면체 ─────────────────────────────────────────── /// diff --git a/Assets/Scripts/Core/ProceduralAudioGenerator.Battle.cs b/Assets/Scripts/Core/ProceduralAudioGenerator.Battle.cs new file mode 100644 index 0000000..e4a2a1e --- /dev/null +++ b/Assets/Scripts/Core/ProceduralAudioGenerator.Battle.cs @@ -0,0 +1,227 @@ +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 전투 목소리 — 곤충 계열별 울음(cry_*)·비명(hurt_*)과 층을 쌓은 타격음. + /// + /// 예전 타격음은 100Hz 사인 + 노이즈 0.1초 한 겹이라 "톡"에 가까웠고, 곤충은 기술을 쓰든 + /// 맞든 소리를 내지 않았다. 계열 분류는 전투 쪽(BattleShout.CryFor)이 하고 여기선 + /// 키 문자열만 받는다 — Core가 Battle을 참조하지 않게. + /// + public static partial class ProceduralAudioGenerator + { + /// 울음 한 소리의 설계값. 음높이는 시작→정점→끝 세 점을 잇는다. + private struct VoiceSpec + { + public float Seconds; + public float PitchStart, PitchPeak, PitchEnd; + public float PeakAt; // 0..1 — 정점 위치 + public float Saw; // 0=사인, 1=톱니(거친 배음) + public float VibratoHz, VibratoDepth; + public float RoughHz, RoughDepth; // 진폭 떨림 — 으르렁·붕붕의 "결" + public float Noise; // 노이즈 비율 + public float NoiseTone; // 0=어두운(저역) … 1=밝은(치찰음) + public float Brightness; // 톤 저역통과 — 낮을수록 둥글다 + public int Pulses; // >0이면 끊어 우는 소리(귀뚜라미·딸깍) + public float PulseDuty; + public float Attack, Release; // 초 + public float Drive; // 포화 — 클수록 거칠다 + public float Sub; // 한 옥타브 아래 사인(보스의 무게) + public int Seed; + } + + private static bool TryGenerateBattleVoice(string type, out AudioClip clip) + { + clip = null; + switch (type) + { + // 딱정벌레류 — 낮게 으르렁 "그르옷" + case "cry_beetle": clip = Voice("cry_beetle", new VoiceSpec { Seconds = 0.42f, PitchStart = 130f, PitchPeak = 210f, PitchEnd = 165f, PeakAt = 0.55f, Saw = 0.9f, VibratoHz = 7f, VibratoDepth = 0.03f, RoughHz = 28f, RoughDepth = 0.55f, Noise = 0.2f, NoiseTone = 0.4f, Brightness = 0.45f, Attack = 0.02f, Release = 0.12f, Drive = 2.6f, Sub = 0.12f, Seed = 101 }); break; + case "hurt_beetle": clip = Voice("hurt_beetle", new VoiceSpec { Seconds = 0.24f, PitchStart = 230f, PitchPeak = 240f, PitchEnd = 110f, PeakAt = 0.1f, Saw = 0.9f, VibratoHz = 9f, VibratoDepth = 0.04f, RoughHz = 34f, RoughDepth = 0.6f, Noise = 0.24f, NoiseTone = 0.45f, Brightness = 0.45f, Attack = 0.005f, Release = 0.1f, Drive = 3f, Sub = 0.1f, Seed = 102 }); break; + // 사마귀 — 쉭 하는 치찰음 위로 날카로운 끽 + case "cry_mantis": clip = Voice("cry_mantis", new VoiceSpec { Seconds = 0.36f, PitchStart = 1700f, PitchPeak = 2600f, PitchEnd = 2200f, PeakAt = 0.35f, Saw = 0.55f, VibratoHz = 22f, VibratoDepth = 0.05f, RoughHz = 0f, Noise = 0.62f, NoiseTone = 0.9f, Brightness = 0.55f, Attack = 0.006f, Release = 0.14f, Drive = 1.6f, Seed = 111 }); break; + case "hurt_mantis": clip = Voice("hurt_mantis", new VoiceSpec { Seconds = 0.2f, PitchStart = 2700f, PitchPeak = 2800f, PitchEnd = 1500f, PeakAt = 0.1f, Saw = 0.6f, VibratoHz = 30f, VibratoDepth = 0.06f, Noise = 0.5f, NoiseTone = 0.85f, Brightness = 0.5f, Attack = 0.003f, Release = 0.09f, Drive = 1.8f, Seed = 112 }); break; + // 잠자리·나비·나방 — 날갯짓 파닥임 + case "cry_flyer": clip = Voice("cry_flyer", new VoiceSpec { Seconds = 0.4f, PitchStart = 520f, PitchPeak = 800f, PitchEnd = 680f, PeakAt = 0.5f, Saw = 0.2f, VibratoHz = 5f, VibratoDepth = 0.04f, Noise = 0.62f, NoiseTone = 0.45f, Brightness = 0.4f, Pulses = 14, PulseDuty = 0.6f, Attack = 0.02f, Release = 0.14f, Drive = 1.2f, Seed = 121 }); break; + case "hurt_flyer": clip = Voice("hurt_flyer", new VoiceSpec { Seconds = 0.22f, PitchStart = 820f, PitchPeak = 840f, PitchEnd = 420f, PeakAt = 0.1f, Saw = 0.25f, VibratoHz = 8f, VibratoDepth = 0.05f, Noise = 0.55f, NoiseTone = 0.5f, Brightness = 0.4f, Pulses = 7, PulseDuty = 0.55f, Attack = 0.004f, Release = 0.1f, Drive = 1.4f, Seed = 122 }); break; + // 벌·말벌·파리 — 붕붕 + case "cry_buzzer": clip = Voice("cry_buzzer", new VoiceSpec { Seconds = 0.46f, PitchStart = 185f, PitchPeak = 255f, PitchEnd = 235f, PeakAt = 0.6f, Saw = 1f, VibratoHz = 11f, VibratoDepth = 0.05f, RoughHz = 90f, RoughDepth = 0.3f, Noise = 0.08f, NoiseTone = 0.5f, Brightness = 0.6f, Attack = 0.03f, Release = 0.12f, Drive = 1.8f, Seed = 131 }); break; + case "hurt_buzzer": clip = Voice("hurt_buzzer", new VoiceSpec { Seconds = 0.24f, PitchStart = 270f, PitchPeak = 280f, PitchEnd = 140f, PeakAt = 0.1f, Saw = 1f, VibratoHz = 16f, VibratoDepth = 0.08f, RoughHz = 70f, RoughDepth = 0.35f, Noise = 0.1f, NoiseTone = 0.5f, Brightness = 0.6f, Attack = 0.004f, Release = 0.1f, Drive = 2f, Seed = 132 }); break; + // 귀뚜라미·메뚜기·매미 — 끊어 우는 찌르르 + case "cry_chirper": clip = Voice("cry_chirper", new VoiceSpec { Seconds = 0.34f, PitchStart = 4000f, PitchPeak = 4400f, PitchEnd = 4300f, PeakAt = 0.4f, Saw = 0.1f, VibratoHz = 0f, Noise = 0.05f, NoiseTone = 0.9f, Brightness = 0.8f, Pulses = 5, PulseDuty = 0.55f, Attack = 0.01f, Release = 0.08f, Drive = 1.1f, Seed = 141 }); break; + case "hurt_chirper": clip = Voice("hurt_chirper", new VoiceSpec { Seconds = 0.18f, PitchStart = 4600f, PitchPeak = 4600f, PitchEnd = 3100f, PeakAt = 0.05f, Saw = 0.15f, Noise = 0.1f, NoiseTone = 0.9f, Brightness = 0.8f, Pulses = 3, PulseDuty = 0.6f, Attack = 0.003f, Release = 0.06f, Drive = 1.2f, Seed = 142 }); break; + // 거미·지네·개미 — 딸깍거리는 턱 소리 + case "cry_crawler": clip = Voice("cry_crawler", new VoiceSpec { Seconds = 0.32f, PitchStart = 1300f, PitchPeak = 1600f, PitchEnd = 1100f, PeakAt = 0.5f, Saw = 0.4f, Noise = 0.82f, NoiseTone = 0.75f, Brightness = 0.6f, Pulses = 9, PulseDuty = 0.22f, Attack = 0.004f, Release = 0.06f, Drive = 1.5f, Seed = 151 }); break; + case "hurt_crawler": clip = Voice("hurt_crawler", new VoiceSpec { Seconds = 0.18f, PitchStart = 1700f, PitchPeak = 1700f, PitchEnd = 900f, PeakAt = 0.05f, Saw = 0.45f, Noise = 0.75f, NoiseTone = 0.7f, Brightness = 0.6f, Pulses = 5, PulseDuty = 0.25f, Attack = 0.003f, Release = 0.05f, Drive = 1.6f, Seed = 152 }); break; + // 레이드 보스 — 깊고 긴 포효 + case "cry_boss": clip = Voice("cry_boss", new VoiceSpec { Seconds = 0.95f, PitchStart = 88f, PitchPeak = 132f, PitchEnd = 74f, PeakAt = 0.35f, Saw = 1f, VibratoHz = 5f, VibratoDepth = 0.04f, RoughHz = 32f, RoughDepth = 0.6f, Noise = 0.38f, NoiseTone = 0.35f, Brightness = 0.42f, Attack = 0.05f, Release = 0.35f, Drive = 3.4f, Sub = 0.3f, Seed = 161 }, true); break; + case "hurt_boss": clip = Voice("hurt_boss", new VoiceSpec { Seconds = 0.45f, PitchStart = 140f, PitchPeak = 146f, PitchEnd = 70f, PeakAt = 0.08f, Saw = 1f, VibratoHz = 7f, VibratoDepth = 0.05f, RoughHz = 36f, RoughDepth = 0.6f, Noise = 0.34f, NoiseTone = 0.4f, Brightness = 0.42f, Attack = 0.008f, Release = 0.2f, Drive = 3.4f, Sub = 0.25f, Seed = 162 }, true); break; + default: + return false; + } + return clip != null; + } + + /// + /// 울음 합성 — 음높이를 위상 누적으로 굴린다(SinWave(freq, sample)처럼 주파수×시각으로 계산하면 + /// 음높이가 움직일 때 위상이 튀어 딸깍 소리가 난다). + /// + private static AudioClip Voice(string name, VoiceSpec v, bool reverb = false) + { + int total = SecondsToSamples(v.Seconds); + float[] data = new float[total]; + System.Random rng = new System.Random(v.Seed); + float phase = 0f, subPhase = 0f; + float toneLp = 0f, noiseHi = 0f, noiseLo = 0f; + float toneCoef = Mathf.Lerp(0.04f, 0.6f, Mathf.Clamp01(v.Brightness)); + // 노이즈는 대역통과로 — 중심 300Hz(어두움)~5kHz(치찰음), 위아래 한 옥타브씩. + // 1차 미분으로 밝히면 에너지가 14~22kHz로 몰려 폰 스피커에선 안 들리고 귀만 찌른다(실측). + float noiseCenter = Mathf.Lerp(300f, 5000f, Mathf.Clamp01(v.NoiseTone)); + float noiseCoefHi = OnePoleCoef(noiseCenter * 2f); + float noiseCoefLo = OnePoleCoef(noiseCenter * 0.5f); + int attack = Mathf.Max(1, SecondsToSamples(v.Attack)); + int release = Mathf.Max(1, SecondsToSamples(v.Release)); + float drive = Mathf.Max(1f, v.Drive); + float driveNorm = 1f / (float)System.Math.Tanh(drive); + + for (int i = 0; i < total; i++) + { + float t = (float)i / SampleRate; + float u = (float)i / total; + float pitch = u < v.PeakAt + ? Mathf.Lerp(v.PitchStart, v.PitchPeak, Mathf.SmoothStep(0f, 1f, u / Mathf.Max(0.001f, v.PeakAt))) + : Mathf.Lerp(v.PitchPeak, v.PitchEnd, Mathf.SmoothStep(0f, 1f, (u - v.PeakAt) / Mathf.Max(0.001f, 1f - v.PeakAt))); + pitch *= 1f + v.VibratoDepth * Mathf.Sin(2f * Mathf.PI * v.VibratoHz * t); + phase += pitch / SampleRate; + phase -= Mathf.Floor(phase); + subPhase += pitch * 0.5f / SampleRate; + subPhase -= Mathf.Floor(subPhase); + + float saw = 2f * phase - 1f; + float sine = Mathf.Sin(2f * Mathf.PI * phase); + float tone = Mathf.Lerp(sine, saw, v.Saw); + toneLp += (tone - toneLp) * toneCoef; + + float white = Noise(rng); + noiseHi += (white - noiseHi) * noiseCoefHi; + noiseLo += (noiseHi - noiseLo) * noiseCoefLo; + float noise = (noiseHi - noiseLo) * 2.5f; + + float s = toneLp * (1f - v.Noise) * 1.6f + noise * v.Noise; + if (v.Sub > 0f) s += Mathf.Sin(2f * Mathf.PI * subPhase) * v.Sub; + if (v.RoughDepth > 0f) + s *= 1f - v.RoughDepth * (0.5f + 0.5f * Mathf.Sin(2f * Mathf.PI * v.RoughHz * t)); + if (v.Pulses > 0) + { + float cell = u * v.Pulses; + float local = cell - Mathf.Floor(cell); + float gate = local < v.PulseDuty + ? Mathf.Sin(Mathf.PI * local / Mathf.Max(0.01f, v.PulseDuty)) + : 0f; + s *= gate; + } + + float env = i < attack ? (float)i / attack + : i > total - release ? (float)(total - i) / release + : 1f; + data[i] = (float)System.Math.Tanh(s * env * drive) * driveNorm; + } + // 끝단 4단 저역통과(6.5kHz) — 1차 필터 한 겹으론 톱니 배음·치찰 노이즈의 40%가 10kHz 위에 + // 남았다(실측). 그 대역은 폰 스피커가 못 내고 이어폰에선 귀만 찌른다. + LowPass(data, 6500f, 4); + if (reverb) ApplySimpleReverb(data, 0.35f, 70); + Normalize(data, 0.95f); + return CreateClip("SFX_" + name, data, false); + } + + /// + /// 타격음 — 딸깍(고역 순간음) + 퍽(중역 노이즈 덩어리) + 쿵(내려가는 저음) + 부서짐을 겹친다. + /// 치명타는 꼬리가 길고 금속성 울림과 잔향이 붙는다. + /// + /// 중역 "퍽"이 주인공이다. 폰 스피커는 300Hz 아래를 거의 못 낸다. 저음 쿵이 주인공이던 + /// 첫 설계는 300Hz~6kHz 에너지가 2~3%뿐이라 기기에서 "틱"으로 들릴 판이었다(기존 타격음도 100Hz + /// 사인이라 같은 문제였다). 쿵은 이어폰·PC용 무게로만 남긴다. + /// + private static AudioClip GenerateImpactSFX(bool heavy) + { + float seconds = heavy ? 0.42f : 0.2f; + int total = SecondsToSamples(seconds); + float[] data = new float[total]; + System.Random rng = new System.Random(heavy ? 13 : 12); + float phase = 0f, crunchLp = 0f; + float smackHi = 0f, smackLo = 0f, clickHi = 0f, clickLo = 0f; + float smackCoefHi = OnePoleCoef(heavy ? 2600f : 3200f), smackCoefLo = OnePoleCoef(heavy ? 500f : 700f); + float clickCoefHi = OnePoleCoef(7000f), clickCoefLo = OnePoleCoef(2000f); + for (int i = 0; i < total; i++) + { + float t = (float)i / SampleRate; + float white = Noise(rng); + + // 딸깍 — 2~7kHz 대역 노이즈 6ms + clickHi += (white - clickHi) * clickCoefHi; + clickLo += (clickHi - clickLo) * clickCoefLo; + float click = (clickHi - clickLo) * Mathf.Exp(-t * 500f); + + // 퍽 — 0.5~3kHz 대역 노이즈 덩어리 + smackHi += (white - smackHi) * smackCoefHi; + smackLo += (smackHi - smackLo) * smackCoefLo; + float smack = (smackHi - smackLo) * Mathf.Exp(-t * (heavy ? 22f : 38f)); + + // 쿵 — 몸통이 눌리는 저음(220→70Hz, 치명타 180→48Hz) + float f = Mathf.Lerp(heavy ? 180f : 220f, heavy ? 48f : 70f, 1f - Mathf.Exp(-t * 26f)); + phase += f / SampleRate; + phase -= Mathf.Floor(phase); + float thump = Mathf.Sin(2f * Mathf.PI * phase) * Mathf.Exp(-t * (heavy ? 9f : 16f)); + + // 부서짐 — 어두운 노이즈 짧게 + crunchLp += (white - crunchLp) * 0.28f; + float crunch = crunchLp * Mathf.Exp(-t * (heavy ? 16f : 30f)); + + float s = click * 2.2f + smack * 3.2f + thump * 0.55f + crunch * 0.5f; + if (heavy) + { + // 금속성 울림 — 비정수배 배음 + float ring = (Mathf.Sin(2f * Mathf.PI * 1320f * t) * 0.5f + + Mathf.Sin(2f * Mathf.PI * 1873f * t) * 0.35f + + Mathf.Sin(2f * Mathf.PI * 2761f * t) * 0.25f) * Mathf.Exp(-t * 11f); + s += ring * 0.45f; + } + data[i] = (float)System.Math.Tanh(s * 1.6f); + } + if (heavy) ApplySimpleReverb(data, 0.3f, 45); + Normalize(data, 0.97f); + return CreateClip(heavy ? "SFX_CriticalHit" : "SFX_Hit", data, false); + } + + /// 1차 저역통과 계수 — 차단 주파수(Hz)에서. + private static float OnePoleCoef(float cutoffHz) + { + return 1f - Mathf.Exp(-2f * Mathf.PI * Mathf.Min(cutoffHz, SampleRate * 0.45f) / SampleRate); + } + + /// 1차 저역통과를 번 겹쳐 건다(단마다 -6dB/옥타브). + private static void LowPass(float[] data, float cutoffHz, int poles) + { + float coef = OnePoleCoef(cutoffHz); + for (int p = 0; p < poles; p++) + { + float y = 0f; + for (int i = 0; i < data.Length; i++) + { + y += (data[i] - y) * coef; + data[i] = y; + } + } + } + + private static void Normalize(float[] data, float peak) + { + float max = 0f; + for (int i = 0; i < data.Length; i++) max = Mathf.Max(max, Mathf.Abs(data[i])); + if (max < 0.0001f) return; + float gain = peak / max; + for (int i = 0; i < data.Length; i++) data[i] *= gain; + } + } +} diff --git a/Assets/Scripts/Core/ProceduralAudioGenerator.Battle.cs.meta b/Assets/Scripts/Core/ProceduralAudioGenerator.Battle.cs.meta new file mode 100644 index 0000000..5fe1af0 --- /dev/null +++ b/Assets/Scripts/Core/ProceduralAudioGenerator.Battle.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4a758deb97f0981448a474b115e3b951 \ No newline at end of file diff --git a/Assets/Scripts/Core/ProceduralAudioGenerator.Music.cs b/Assets/Scripts/Core/ProceduralAudioGenerator.Music.cs new file mode 100644 index 0000000..a402c14 --- /dev/null +++ b/Assets/Scripts/Core/ProceduralAudioGenerator.Music.cs @@ -0,0 +1,1870 @@ +using System; +using System.Collections.Generic; + +namespace InsectGame.Core +{ + /// + /// 전투 계열 곡(1대1·라온 대결·수문장·레이드·간부·최종전)의 작곡과 합성. + /// + /// 왜 따로 두나. 예전 전투곡은 12~16초짜리 한 마디 패턴을 "샘플마다 모든 악기를 다시 계산"하는 + /// 방식이었다. 그대로 50초로 늘리면 생성 시간이 네 배가 되고(기기에서 수백 ms 프리즈), 마디 수가 정수가 아니라 + /// 루프 이음매에서 딸깍 소리가 났다(12초 = 150bpm 7.5마디). 여기는 음표 사건을 먼저 적고(악보) 음마다 한 번만 + /// 합성해 섞는다 — 같은 음·길이는 한 번만 만든다. + /// + /// 루프 이음매. 곡 끝을 넘는 소리(마지막 음의 여운·잔향)는 곡 첫머리에 감아서 더한다. 그래서 끝 → + /// 처음이 곡 안의 아무 두 샘플 사이처럼 이어진다. 곡 길이는 언제나 정수 마디다. + /// + /// 폰 스피커. 300Hz 아래는 폰이 거의 못 낸다. 무게는 저음 북이 아니라 배음으로 낸다 — 베이스·금관은 톱니 + /// 배음, 북에는 때리는 소리(중역 노이즈)와 포화 배음을 붙였다. 음량은 300Hz 위 대역 RMS로 맞춘다() — + /// 저음이 많은 곡이 폰에서 작게 들리지 않게. 300Hz 위 에너지 비율은 새 곡 29~37%, 예전 보스 곡(리전 곡 생성기)은 약 8%였다. + /// + /// 메모리. 22.05kHz 모노다. 64초면 약 140만 샘플 — float로 5.6MB. 44.1kHz로 하면 곡마다 11MB다. + /// 나이퀴스트 11kHz는 폰 스피커 대역(~6kHz)을 넉넉히 덮는다. + /// + /// 스레드. 는 UnityEngine을 부르지 않는 순수 계산이라 작업 스레드에서 돌려도 된다 + /// (1대1 전투곡을 미리 굽는 이 그렇게 쓴다). + /// + public static partial class ProceduralAudioGenerator + { + /// 전투 계열 곡의 샘플레이트(모노). + internal const int MusicRate = 22050; + + /// + /// 300Hz 위 대역 RMS 목표(MasterGain을 곱하기 전, 의 어림으로). 이 값이면 최종 클립의 + /// 300Hz~6kHz 대역 RMS가 0.035~0.038(FFT 실측)로 탐험 곡(약 0.023)보다 4dB쯤 크다 — 전투가 한 단계 세게 들린다. + /// + internal const float MusicTargetBandRms = 0.075f; + + /// 리미터의 무릎 — 이 위의 봉우리만 tanh로 둥글게 누른다. 출력은 1을 넘지 않는다. + internal const float MusicLimiterKnee = 0.8f; + + /// 새 렌더러가 맡는 BGM 키(GetBGM의 case와 같은 문자열). + internal static readonly string[] CombatSongKeys = { "battle", "rival", "guardian", "raid", "boss_ledger", "boss_final" }; + + internal static bool IsCombatSong(string key) => Array.IndexOf(CombatSongKeys, key) >= 0; + + /// 곡의 꼴(템포·마디·샘플 수) — 합성하지 않고 악보만 적어서 낸다. + internal struct SongShape + { + public double Bpm; + public int Bars; + public int Samples; + public double Seconds => (double)Samples / MusicRate; + public double SamplesPerBar => MusicRate * 60.0 / Bpm * Score.BeatsPerBar; + } + + internal static SongShape GetCombatSongShape(string key) + { + Score s = ComposeCombatSong(key); + return s == null ? default : new SongShape { Bpm = s.Bpm, Bars = s.Bars, Samples = s.Length }; + } + + /// 곡을 합성한다(MasterGain 전, 피크 < 1). 모르는 키면 null. 스레드 안전. + internal static float[] RenderCombatSong(string key) + { + Score s = ComposeCombatSong(key); + return s?.Render(); + } + + private static Score ComposeCombatSong(string key) + { + switch (key) + { + case "battle": return ComposeBattle(); + case "rival": return ComposeRival(); + case "guardian": return ComposeGuardian(); + case "raid": return ComposeRaid(); + case "boss_ledger": return ComposeLedger(); + case "boss_final": return ComposeFinal(); + default: return null; + } + } + + // ──────────────────────────────────────────── + // 미리 굽기 — 작업 스레드에서 + // ──────────────────────────────────────────── + + // 곡 하나를 굽는 데 데스크톱에서 60~250ms 걸린다(수문장이 가장 무겁다). AudioManager는 BGM을 바꾸는 순간 한 프레임 뒤 + // 메인 스레드에서 GetBGM을 부르므로, 미리 굽지 않은 곡은 그만큼 프레임이 멈춘다. 미리 구운 곡은 AudioClip에 옮겨 담기만 한다. + // 사전과 Task 시작·회수는 메인 스레드에서만 한다(작업 스레드는 RenderCombatSong만 돈다). + private static readonly Dictionary> prewarms = + new Dictionary>(); + + /// + /// 전투 계열 곡을 작업 스레드에서 미리 굽는다 — 곧 그 곡을 틀 게 확실할 때(예: 라온과의 대결·수문장 레이드 직전 대사) + /// 부르면 전환 순간 프레임이 막히지 않는다. 구운 데이터는 GetBGM이 가져갈 때까지 곡당 4~5MB를 붙잡으므로 + /// "혹시 몰라서"는 부르지 말 것. 전투 계열이 아니거나 이미 만들었거나 굽는 중이면 아무것도 안 한다. 메인 스레드 전용. + /// + public static void PrewarmBGM(string key) + { +#if UNITY_WEBGL && !UNITY_EDITOR + return; // 스레드가 없다 — 필요할 때 그 자리에서 굽는다. +#else + if (!IsCombatSong(key) || cache.ContainsKey("bgm_" + key) || prewarms.ContainsKey(key)) return; + prewarms[key] = System.Threading.Tasks.Task.Run(() => RenderCombatSong(key)); +#endif + } + + /// + /// 1대1 전투곡은 거의 모든 세션에서 쓰여서 첫 탐험 곡을 만들 때 미리 굽는다. 다른 전투곡은 쓰일지 모르는 채 + /// 메모리를 붙잡지 않도록 저절로 굽지 않는다(를 부르는 쪽이 정한다). + /// + private static void StartCombatPrewarm() => PrewarmBGM("battle"); + + /// 미리 구운 데이터가 있으면 넘겨받는다(아직 굽는 중이면 끝날 때까지 기다린다). 없거나 실패했으면 null. + private static float[] TakePrewarmed(string key) + { + if (!prewarms.TryGetValue(key, out System.Threading.Tasks.Task task)) return null; + prewarms.Remove(key); + try { return task.Result; } + catch (Exception) { return null; } + } + + private static UnityEngine.AudioClip CreateMusicClip(string name, float[] data) + { + for (int i = 0; i < data.Length; i++) + { + float v = data[i] * MasterGain; + data[i] = v > 1f ? 1f : (v < -1f ? -1f : v); + } + UnityEngine.AudioClip clip = UnityEngine.AudioClip.Create(name, data.Length, 1, MusicRate, false); + clip.SetData(data, 0); + return clip; + } + + // ════════════════════════════════════════════ + // 곡 + // ════════════════════════════════════════════ + + /// + /// 1대1 전투 — A단조 150bpm, 36마디(57.6초). 예전 곡의 조성·템포를 잇는다. + /// A(주제) → A'(주제 변주·대선율 16분 아르페지오·하이햇 16분) → B(F장조 쪽으로 올라서는 영웅적 대목, 앞 4마디 하프타임) + /// → 브리지(D단조 저음 금관 리프·탐탐, 스네어 롤로 끌어올림) → A2(주제 + 한 옥타브 아래 금관 겹침, 절정) → 처음으로. + /// + private static Score ComposeBattle() + { + var s = new Score(150, 36, 77); + Inst lead = s.Add(new Inst { Name = "lead", Wave = Wave.Square, MaxPartials = 14, Attack = 0.005, Decay = 0.22, Sustain = 0.62, Release = 0.07, Gate = 0.92, Gain = 0.26, Send = 0.22, VibratoDepth = 0.16, VibratoDelay = 0.16, Cutoff = 4200 }); + Inst brass = s.Add(new Inst { Name = "brass", Wave = Wave.Brass, MaxPartials = 28, Attack = 0.03, Decay = 0.3, Sustain = 0.75, Release = 0.12, Gate = 0.95, Gain = 0.30, Send = 0.18, Cutoff = 3600, DarkCutoff = 700, BrightBase = 0.55, BrightDecay = 0.25, Unison = 2, DetuneCents = 7 }); + Inst bass = s.Add(new Inst { Name = "bass", Wave = Wave.Saw, MaxPartials = 30, Attack = 0.004, Decay = 0.16, Sustain = 0.5, Release = 0.05, Gate = 0.85, Gain = 0.45, Send = 0.0, Cutoff = 2600, DarkCutoff = 450, BrightBase = 0.3, BrightDecay = 0.07 }); + Inst pad = s.Add(new Inst { Name = "pad", Wave = Wave.Saw, MaxPartials = 24, Attack = 0.12, Decay = 0.5, Sustain = 0.8, Release = 0.3, Gate = 1.0, Gain = 0.09, Send = 0.35, Cutoff = 2200, Unison = 2, DetuneCents = 9 }); + Inst pluck = s.Add(new Inst { Name = "arp", Wave = Wave.Triangle, MaxPartials = 12, Attack = 0.002, Decay = 0.09, Sustain = 0.0, Release = 0.04, Gate = 1.0, Gain = 0.20, Send = 0.2, Cutoff = 5000 }); + + Chord[] a = Prog("Am F G Am Am F G E"); + Chord[] b = Prog("F G Em Am F G E E"); + Chord[] br = Prog("Dm Dm E E"); + + const string ThemeA = + "A4:.5 C5 E5 A5:1 G5:.5 E5 C5 | D5:.75 C5 A4:.5 C5:1 r:.5 A4 | B4:.5 D5 G5:1 F5:.5 E5 D5:1 | E5:1.5 C5:.5 A4:2 |" + + "A4:.5 C5 E5 A5:1 B5:.5 C6:1 | A5:.75 G5 F5:.5 E5:1 C5 | D5:.5 E5 F5 G5 B5:1 G5 | G#5:1.5 E5:.5 B4:1 G#4"; + const string ThemeA2 = + "A4:.5 C5 E5 A5:1 G5:.5 E5 C5 | D5:.75 C5 A4:.5 C5 D5 C5 A4 | B4:.5 D5 G5:1 F5:.5 E5 D5:1 | E5:.5 D5 C5 B4 C5:1 E5 |" + + "A4:.5 C5 E5 A5:1 B5:.5 C6:1 | A5:.75 G5 F5:.5 E5:1 C5 | D5:.5 E5 F5 G5 B5:1 G5 | G#5:.5 B5 G#5 E5 B4 D5 E5:1"; + const string ThemeB = + "C5:1.5 F5:.5 F5:1 E5:.5 F5 | G5:1.5 D5:.5 B4:1 D5 | E5:1.5 G5:.5 B5:1 A5:.5 G5 | A5:2 E5:1 C5 |" + + "F5:1.5 A5:.5 C6:1 B5:.5 A5 | B5:1.5 G5:.5 D5:1 G5 | G#5:1 B5 D6 B5 | B5:1.5 G#5:.5 E5:1 r"; + const string Riff = + "D4:.75 D4 F4:.5 A4:1 G4:.5 F4 | E4:.75 F4 E4:.5 D4:1 A3 | E4:.75 E4 G#4:.5 B4:1 A4:.5 G#4 | E4:.5 F4 G#4 A4 B4 C5 D5 E5"; + + // A (0~7) + s.Line(lead, 0, ThemeA); + s.Pad(pad, 0, a, 57, 0.8f); + s.Bass(bass, 0, a, 40, "R R O R R R O R"); + s.Drum(Kit.Kick, 0, 8, "X.......X.x....."); + s.Drum(Kit.Snare, 0, 8, "....X.......X..."); + s.Drum(Kit.HatClosed, 0, 8, "x.x.x.x.x.x.x.x."); + s.Drum(Kit.Crash, 0, 1, "X..............."); + s.Roll(Kit.Snare, s.BeatOf(7) + 3, 1, 0.25, 0.45f, 0.9f); + + // A' (8~15) + s.Line(lead, 8, ThemeA2); + s.Pad(pad, 8, a, 57, 0.8f); + s.Arp(pluck, 8, a, 64, "0123210123213210"); + s.Bass(bass, 8, a, 40, "R O R O R O R O"); + s.Drum(Kit.Kick, 8, 8, "X.....x.X.x...x."); + s.Drum(Kit.Snare, 8, 8, "....X.......X..."); + s.Drum(Kit.HatClosed, 8, 8, "xoxoxoxoxoxoxo.."); + s.Drum(Kit.HatOpen, 11, 1, "..............x."); + s.Drum(Kit.HatOpen, 15, 1, "..............x."); + s.Drum(Kit.Crash, 8, 1, "X..............."); + s.Roll(Kit.Snare, s.BeatOf(15) + 3, 1, 0.25, 0.5f, 1f); + + // B (16~23) — 앞 4마디 하프타임 + s.Line(lead, 16, ThemeB); + s.Line(brass, 16, ThemeB, 0.55f, -12); + s.Pad(pad, 16, b, 57, 1f); + s.Bass(bass, 16, Slice(b, 0, 4), 40, "R - - - R - O -"); + s.Bass(bass, 20, Slice(b, 4, 4), 40, "R R O R R R O R"); + s.Drum(Kit.Kick, 16, 4, "X.........x....."); + s.Drum(Kit.Snare, 16, 4, "........X......."); + s.Drum(Kit.Kick, 20, 3, "X.......X.x....."); + s.Drum(Kit.Snare, 20, 3, "....X.......X..."); + s.Drum(Kit.HatClosed, 16, 7, "x.x.x.x.x.x.x.x."); + s.Drum(Kit.Crash, 16, 1, "X..............."); + s.Drum(Kit.Kick, 23, 1, "X..............."); + s.Drum(Kit.Snare, 23, 1, "....X..........."); + s.Drum(Kit.TomHi, 23, 1, "........XxXx...."); + s.Drum(Kit.TomLo, 23, 1, "............XxXx"); + + // 브리지 (24~27) — 저음 금관 리프, 탐탐 3-3-2, 스네어 롤로 끌어올린다 + s.Line(brass, 24, Riff, 1f); + s.Bass(bass, 24, Slice(br, 0, 2), 40, "R - - R - - R -"); + s.Bass(bass, 26, Slice(br, 2, 2), 40, "R R R R R R R R"); + s.Pad(pad, 26, Slice(br, 2, 2), 57, 0.7f); + s.Drum(Kit.TomLo, 24, 2, "X.....X.....X..."); + s.Drum(Kit.TomHi, 24, 2, "...o.....o....o."); + s.Drum(Kit.Kick, 26, 2, "X...X...X...X..."); + s.Roll(Kit.Snare, s.BeatOf(26), 4, 0.5, 0.3f, 0.6f); + s.Roll(Kit.Snare, s.BeatOf(27), 4, 0.25, 0.6f, 1f); + s.Riser(s.BeatOf(26), 8, 0.5f); + + // A2 (28~35) — 절정: 주제 + 한 옥타브 아래 금관 + s.Line(lead, 28, ThemeA); + s.Line(brass, 28, ThemeA, 0.7f, -12); + s.Pad(pad, 28, a, 57, 1f); + s.Arp(pluck, 28, a, 64, "0123210123213210"); + s.Bass(bass, 28, a, 40, "R O R O R O R O"); + s.Drum(Kit.Kick, 28, 8, "X.....x.X.x...x."); + s.Drum(Kit.Snare, 28, 8, "....X.......X..."); + s.Drum(Kit.HatClosed, 28, 8, "xoxoxoxoxoxoxoxo"); + s.Drum(Kit.Crash, 28, 1, "X..............."); + s.Drum(Kit.Crash, 32, 1, "X..............."); + s.Roll(Kit.Snare, s.BeatOf(35) + 3, 1, 0.25, 0.5f, 1f); + return s; + } + + /// + /// 라온 대결 — G장조 160bpm, 가벼운 셔플, 36마디(54초). 주제는 아이들이 놀릴 때 부르는 "솔솔미라솔미" 꼴을 그대로 빌렸다 + /// ("라온 노래"로 기억되게). 25% 펄스(장난감 같은 리드) + 마림바 대답 + 뒷박 코드 찹 + 옥타브 튀는 베이스. + /// A(주제·마림바가 쉼표에서 낄낄) → A'(변주) → B(쫓아가는 마림바 8분, 나무토막 똑딱) → C(멈춤 박자 — 베이스가 놀림 주제를 받아친다, + /// 스네어로 끌어올림) → A2(주제를 3도 화음으로) → 처음으로. + /// + private static Score ComposeRival() + { + var s = new Score(160, 36, 211) { Swing = 0.07 }; + Inst lead = s.Add(new Inst { Name = "lead", Wave = Wave.Pulse25, MaxPartials = 16, Attack = 0.004, Decay = 0.14, Sustain = 0.55, Release = 0.05, Gate = 0.82, Gain = 0.30, Send = 0.18, VibratoDepth = 0.12, VibratoRate = 6.5, VibratoDelay = 0.12, Cutoff = 5200 }); + Inst harmony = s.Add(new Inst { Name = "harmony", Wave = Wave.Square, MaxPartials = 12, Attack = 0.004, Decay = 0.14, Sustain = 0.5, Release = 0.05, Gate = 0.82, Gain = 0.13, Send = 0.18, Cutoff = 4000 }); + Inst marimba = s.Add(new Inst { Name = "marimba", Mallet = true, Ratios = new[] { 1.0, 4.0, 10.0 }, Amps = new[] { 1.0, 0.35, 0.12 }, Decays = new[] { 0.45, 0.12, 0.04 }, Release = 0.06, Gate = 1.0, Gain = 0.26, Send = 0.22 }); + Inst organ = s.Add(new Inst { Name = "organ", Wave = Wave.Organ, MaxPartials = 8, Attack = 0.01, Decay = 0.2, Sustain = 0.8, Release = 0.06, Gate = 0.7, Gain = 0.11, Send = 0.15, Cutoff = 5000 }); + Inst chop = s.Add(new Inst { Name = "chop", Wave = Wave.Saw, MaxPartials = 18, Attack = 0.003, Decay = 0.07, Sustain = 0.0, Release = 0.03, Gate = 1.0, Gain = 0.20, Send = 0.12, Cutoff = 3800 }); + Inst bass = s.Add(new Inst { Name = "bass", Wave = Wave.Saw, MaxPartials = 30, Attack = 0.003, Decay = 0.12, Sustain = 0.35, Release = 0.04, Gate = 0.7, Gain = 0.36, Send = 0.0, Cutoff = 3000, DarkCutoff = 500, BrightBase = 0.25, BrightDecay = 0.05 }); + + Chord[] a = Prog("G C G D G C D G"); + Chord[] b = Prog("Em C Am D Em C Am D7"); + Chord[] c = Prog("G C D D"); + int[] gMajor = Scale("G A B C D E F#"); + + const string Hook = + "G5:.5 G5 E5 A5 G5 E5 r:1 | G5:.5 G5 E5 A5 G5 E5 C5:1 | D5:.5 E5 G5 B5:1 A5:.5 G5 E5 | F#5:1 A5 D5 r |" + + "G5:.5 G5 E5 A5 G5 E5 r:1 | G5:.5 G5 E5 A5 G5 E5 C6:1 | B5:.5 A5 G5 F#5 E5 F#5 A5:1 | G5:1.5 D5:.5 G4:1 r"; + const string Hook2 = + "G5:.5 G5 E5 A5 G5 E5 r:1 | G5:.5 G5 E5 A5 G5 E5 C5:1 | D5:.5 E5 G5 B5:1 A5:.5 G5 E5 | F#5:.5 G5 A5 F#5 D5:1 r |" + + "G5:.5 G5 E5 A5 G5 E5 r:1 | G5:.5 G5 E5 A5 G5 E5 C6:1 | B5:.5 A5 G5 F#5 E5 F#5 A5:1 | G5:.5 B5 D6 B5 G5:1 r"; + const string Chase = + "B4:.5 E5 G5 E5 B5 G5 E5 G5 | C5:.5 E5 G5 E5 C6:1 B5:.5 A5 | A5:.5 G5 E5 C5 A4 C5 E5 A5 | F#5:1.5 E5:.5 D5:1 A4 |" + + "B4:.5 E5 G5 E5 B5 G5 E5 G5 | C5:.5 E5 G5 E5 C6:1 D6:.5 E6 | C6:.5 B5 A5 G5 E5 G5 A5 C6 | D6:1 C6:.5 A5 F#5:1 D5"; + const string Taunt = + "G3:.5 G3 E3 A3 G3 E3 r:1 | G3:.5 G3 E3 A3 G3 E3 C3:1 | D3:.5 D3 F#3 A3 D4 A3 F#3 A3 | D4:.5 r D4 r D4:.25 D4 D4 D4 r:1"; + + // A (0~7) — 쉼표마다 마림바가 낄낄 + s.Line(lead, 0, Hook); + s.Phrase(marimba, s.BeatOf(0) + 3, "D6:.25 B5 G5:.5", 0.9f); + s.Phrase(marimba, s.BeatOf(4) + 3, "D6:.25 B5 G5:.5", 0.9f); + s.Stabs(chop, 0, a, 59, "..x...x...x...x."); + s.Bass(bass, 0, a, 40, "R O R O R O R O"); + s.Drum(Kit.Kick, 0, 8, "X.....x.X......."); + s.Drum(Kit.Clap, 0, 8, "....X.......X..."); + s.Drum(Kit.HatClosed, 0, 8, "x.x.x.x.x.x.x.x."); + s.Drum(Kit.Crash, 0, 1, "X..............."); + + // A' (8~15) + s.Line(lead, 8, Hook2); + s.Phrase(marimba, s.BeatOf(8) + 3, "B5:.25 C6 D6:.5", 0.9f); + s.Phrase(marimba, s.BeatOf(12) + 3, "G6:.25 E6 D6:.5", 0.9f); + s.Stabs(chop, 8, a, 59, "..x...x...x...x."); + s.Bass(bass, 8, a, 40, "R O F O R O F O"); + s.Drum(Kit.Kick, 8, 8, "X.....x.X.x....."); + s.Drum(Kit.Clap, 8, 8, "....X.......X..."); + s.Drum(Kit.Snare, 8, 8, "...............o"); + s.Drum(Kit.HatClosed, 8, 8, "xoxoxoxoxoxoxo.."); + s.Drum(Kit.HatOpen, 8, 8, "..............x."); + + // B (16~23) — 쫓아가기: 마림바 + 오르간 겹침, 나무토막 똑딱 + s.Line(marimba, 16, Chase); + s.Line(organ, 16, Chase, 0.9f); + s.Stabs(chop, 16, b, 59, "....x.......x..."); + s.Bass(bass, 16, b, 40, "R . O R . R O ."); + s.Drum(Kit.Kick, 16, 8, "X.....x.X.....x."); + s.Drum(Kit.Snare, 16, 8, "....X.......X..."); + s.Drum(Kit.HatClosed, 16, 8, "x.x.x.x.x.x.x.x."); + s.Drum(Kit.WoodHi, 16, 8, "..x.......x....."); + s.Drum(Kit.WoodLo, 16, 8, "......x.......x."); + s.Drum(Kit.Crash, 16, 1, "X..............."); + + // C (24~27) — 멈춤 박자: 다 같이 "빰 빰!" 하고 베이스가 놀림 주제를 받아친다 + s.Line(bass, 24, Taunt, 1.1f); + s.Stabs(chop, 24, Slice(c, 0, 2), 59, "X.....X.........", 1.3f); + s.Drum(Kit.Kick, 24, 2, "X.....X........."); + s.Drum(Kit.Clap, 24, 2, "X.....X........."); + s.Drum(Kit.Crash, 24, 1, "X..............."); + s.Drum(Kit.Kick, 26, 2, "X...X...X...X..."); + s.Roll(Kit.Snare, s.BeatOf(26), 4, 0.5, 0.35f, 0.65f); + s.Roll(Kit.Snare, s.BeatOf(27), 3, 0.25, 0.65f, 1f); + s.Riser(s.BeatOf(26), 7, 0.45f); + + // A2 (28~35) — 주제를 3도 아래 화음으로 + s.Line(lead, 28, Hook); + s.Line(harmony, 28, Hook, 1f, 0, gMajor, -2); + s.Arp(marimba, 28, a, 67, "0.1.2.3.2.1.2.3.", 0.55f); + s.Stabs(chop, 28, a, 59, "..x...x...x...x."); + s.Bass(bass, 28, a, 40, "R O F O R O F O"); + s.Drum(Kit.Kick, 28, 8, "X.....x.X.x....."); + s.Drum(Kit.Clap, 28, 8, "....X.......X..."); + s.Drum(Kit.HatClosed, 28, 8, "xoxoxoxoxoxoxoxo"); + s.Drum(Kit.Crash, 28, 1, "X..............."); + s.Drum(Kit.Crash, 32, 1, "X..............."); + return s; + } + + /// + /// 수문장 레이드 — C단조 96bpm, 24마디(60초). 레이드 곡(D단조 120bpm, 곧은 8분)과 일부러 다르게: + /// 느리고 무겁다. 큰북(타이코)이 3-3-2로 땅을 울리고, 낮은 트롬본이 동기를, 호른이 주제를 분다. 첫 박엔 징. + /// A(타이코 + 저음 현 8분 + 트롬본 동기, i–VI–iv–V) → B(호른 주제·합창, 행진 스네어) → C(D♭→G 나폴리 화음, 팀파니 롤) + /// → A2(주제 머리를 호른·트럼펫 겹침으로, 절정) → 처음으로. + /// + private static Score ComposeGuardian() + { + var s = new Score(96, 24, 401); + Inst trombone = s.Add(new Inst { Name = "trombone", Wave = Wave.Brass, MaxPartials = 36, Attack = 0.05, Decay = 0.4, Sustain = 0.8, Release = 0.18, Gate = 0.96, Gain = 0.30, Send = 0.22, Cutoff = 3200, DarkCutoff = 500, BrightBase = 0.5, BrightDecay = 0.35, Unison = 2, DetuneCents = 6 }); + Inst tuba = s.Add(new Inst { Name = "tuba", Wave = Wave.Brass, MaxPartials = 30, Attack = 0.06, Decay = 0.4, Sustain = 0.8, Release = 0.2, Gate = 0.96, Gain = 0.18, Send = 0.12, Cutoff = 1800, DarkCutoff = 350, BrightBase = 0.4, BrightDecay = 0.3 }); + Inst horn = s.Add(new Inst { Name = "horn", Wave = Wave.Brass, MaxPartials = 24, Attack = 0.06, Decay = 0.5, Sustain = 0.85, Release = 0.2, Gate = 0.97, Gain = 0.30, Send = 0.3, Cutoff = 2600, DarkCutoff = 650, BrightBase = 0.55, BrightDecay = 0.4, Unison = 2, DetuneCents = 5, VibratoDepth = 0.08, VibratoDelay = 0.3, VibratoRate = 5 }); + Inst trumpet = s.Add(new Inst { Name = "trumpet", Wave = Wave.Brass, MaxPartials = 20, Attack = 0.03, Decay = 0.4, Sustain = 0.8, Release = 0.15, Gate = 0.95, Gain = 0.20, Send = 0.28, Cutoff = 4200, DarkCutoff = 900, BrightBase = 0.6, BrightDecay = 0.3, VibratoDepth = 0.1, VibratoDelay = 0.25 }); + Inst brassPad = s.Add(new Inst { Name = "brassPad", Wave = Wave.Brass, MaxPartials = 30, Attack = 0.25, Decay = 0.8, Sustain = 0.9, Release = 0.4, Gate = 1.0, Gain = 0.10, Send = 0.3, Cutoff = 2400, DarkCutoff = 500, BrightBase = 0.5, BrightDecay = 0.8, Unison = 2, DetuneCents = 8 }); + Inst lowStrings = s.Add(new Inst { Name = "strings", Wave = Wave.Saw, MaxPartials = 40, Attack = 0.01, Decay = 0.12, Sustain = 0.3, Release = 0.06, Gate = 0.8, Gain = 0.38, Send = 0.1, Cutoff = 2400, Unison = 2, DetuneCents = 6 }); + Inst choir = s.Add(new Inst { Name = "choir", Wave = Wave.Choir, MaxPartials = 40, Attack = 0.35, Decay = 0.8, Sustain = 0.9, Release = 0.5, Gate = 1.0, Gain = 0.065, Send = 0.45, Cutoff = 3500, Unison = 2, DetuneCents = 10 }); + Inst stringPad = s.Add(new Inst { Name = "stringPad", Wave = Wave.Saw, MaxPartials = 24, Attack = 0.3, Decay = 0.8, Sustain = 0.85, Release = 0.4, Gate = 1.0, Gain = 0.045, Send = 0.4, Cutoff = 2000, Unison = 2, DetuneCents = 10 }); + + Chord[] a = Prog("Cm Cm Ab Ab Fm Fm G G"); + Chord[] b = Prog("Cm Ab Eb Bb Cm Ab Fm G"); + Chord[] c = Prog("Db Db G G"); + Chord[] a2 = Prog("Cm Ab Fm G"); + + const string Motif = + "C3:1.5 C3:.5 Eb3:1 G3 | F3:1.5 Eb3:.5 D3:2 | C3:1.5 C3:.5 Eb3:1 Ab3 | G3:1.5 F3:.5 Eb3:2 |" + + "F3:1.5 F3:.5 Ab3:1 C4 | Bb3:1.5 Ab3:.5 G3:2 | G3:.75 G3 G3:.5 B3:1 D4 | G3:.75 G3 G3:.5 r:2"; + const string Theme = + "G4:1.5 C5:.5 C5:1 D5:.5 Eb5 | Eb5:1.5 D5:.5 C5:1 Ab4 | G4:1.5 Bb4:.5 Eb5:1 D5:.5 C5 | D5:2.5 C5:.5 Bb4:1 |" + + "G4:1.5 C5:.5 C5:1 D5:.5 Eb5 | F5:1.5 Eb5:.5 D5:1 C5 | Ab5:1.5 G5:.5 F5:1 Eb5:.5 D5 | D5:2 B4"; + const string Bridge = "Ab4:1.5 F4:.5 Db5:2 | C5:1.5 Ab4:.5 F4:2 | G4:1.5 B4:.5 D5:2 | F5:2 D5:1 B4"; + const string Climax = "G4:1.5 C5:.5 C5:1 D5:.5 Eb5 | Eb5:1.5 D5:.5 C5:1 Ab4 | Ab5:1.5 G5:.5 F5:1 Eb5:.5 D5 | D5:2 B4"; + const string ClimaxLow = "C3:1.5 C3:.5 Eb3:1 G3 | Ab2:1.5 Ab2:.5 C3:1 Eb3 | F3:1.5 F3:.5 Ab3:1 C4 | G3:.75 G3 G3:.5 B3:1 D4"; + + // A (0~7) + s.Line(trombone, 0, Motif); + s.Line(tuba, 0, Motif, 1f, -12); + s.Pad(stringPad, 0, a, 55, 1f); + s.Bass(lowStrings, 0, a, 36, "R r r R r r R r", 12); + s.Drum(Kit.Taiko, 0, 6, "X.....x.....x..."); + s.Drum(Kit.Taiko, 6, 2, "X.....x.....x.x."); + s.Drum(Kit.TaikoHi, 0, 8, "...o.....o....o."); + s.Drum(Kit.Gong, 0, 1, "X..............."); + for (int bar = 0; bar < 6; bar++) s.Timpani(s.BeatOf(bar), a[bar], 0.8f); + s.TimpaniRoll(s.BeatOf(7), 4, a[7], 0.3f, 0.9f); + + // B (8~15) — 호른 주제 + s.Line(horn, 8, Theme); + s.Pad(brassPad, 8, b, 48, 1f); + s.Pad(choir, 8, b, 60, 1f); + s.Bass(lowStrings, 8, b, 36, "R r r R r r R r", 12); + s.Drum(Kit.Taiko, 8, 8, "X.....x.....x..."); + s.Drum(Kit.TaikoHi, 8, 8, "..o.o...o.o...xx"); + s.Drum(Kit.Snare, 8, 8, "o.o.X.o.o.o.X.o.", 0.55f); + s.Drum(Kit.Crash, 8, 1, "X..............."); + s.Drum(Kit.Crash, 12, 1, "X...............", 0.7f); + + // C (16~19) — D♭ → G, 팀파니 롤 + s.Line(horn, 16, Bridge); + s.Pad(brassPad, 16, c, 48, 1.2f); + s.Pad(choir, 16, c, 60, 1.1f); + s.Bass(lowStrings, 16, c, 36, "R - - - - - - -", 12); + s.Drum(Kit.Gong, 16, 1, "X..............."); + s.Drum(Kit.Taiko, 16, 2, "X.......X......."); + s.Drum(Kit.Taiko, 18, 1, "X.....x.....x..."); + for (int i = 0; i < 8; i++) s.Timpani(s.BeatOf(18) + i * 0.5, c[2], 0.5f + i * 0.04f); + s.TimpaniRoll(s.BeatOf(19), 4, c[3], 0.4f, 1f); + s.Roll(Kit.Snare, s.BeatOf(19), 4, 0.125, 0.15f, 0.7f); + s.Riser(s.BeatOf(18), 8, 0.5f); + + // A2 (20~23) — 절정 + s.Line(horn, 20, Climax); + s.Line(trumpet, 20, Climax, 0.9f); + s.Line(trombone, 20, ClimaxLow); + s.Line(tuba, 20, ClimaxLow, 1f, -12); + s.Pad(choir, 20, a2, 60, 1.1f); + s.Bass(lowStrings, 20, a2, 36, "R r r R r r R r", 12); + s.Drum(Kit.Taiko, 20, 4, "X.....x.....x..."); + s.Drum(Kit.TaikoHi, 20, 4, "..o.o...o.o...xx"); + s.Drum(Kit.Snare, 20, 4, "o.o.X.o.o.o.X.o.", 0.6f); + s.Drum(Kit.Crash, 20, 1, "X..............."); + for (int bar = 20; bar < 24; bar++) s.Timpani(s.BeatOf(bar), a2[bar - 20], 0.9f); + return s; + } + + /// + /// 레이드 — D단조 120bpm, 24마디(48초). 예전 곡의 조성·템포·"웅장하고 위압적"을 잇되, 다섯이 함께 덤비는 전투라 + /// 16분 현 오스티나토가 끝없이 몰아친다. A(하프타임·금관 리드 호출) → A'(주제·합창) → B(F–C로 열렸다가 E♭–A로 조여 드는 대목) → 처음으로. + /// + private static Score ComposeRaid() + { + var s = new Score(120, 24, 99); + Inst lead = s.Add(new Inst { Name = "lead", Wave = Wave.Brass, MaxPartials = 24, Attack = 0.025, Decay = 0.35, Sustain = 0.8, Release = 0.12, Gate = 0.95, Gain = 0.26, Send = 0.28, Cutoff = 4200, DarkCutoff = 900, BrightBase = 0.6, BrightDecay = 0.3, Unison = 2, DetuneCents = 6, VibratoDepth = 0.12, VibratoDelay = 0.22 }); + Inst ostinato = s.Add(new Inst { Name = "ostinato", Wave = Wave.Saw, MaxPartials = 20, Attack = 0.003, Decay = 0.08, Sustain = 0.15, Release = 0.03, Gate = 0.7, Gain = 0.30, Send = 0.18, Cutoff = 3400, Unison = 2, DetuneCents = 5 }); + Inst bass = s.Add(new Inst { Name = "bass", Wave = Wave.Saw, MaxPartials = 30, Attack = 0.004, Decay = 0.2, Sustain = 0.55, Release = 0.06, Gate = 0.85, Gain = 0.45, Send = 0.0, Cutoff = 2400, DarkCutoff = 400, BrightBase = 0.3, BrightDecay = 0.09 }); + Inst strings = s.Add(new Inst { Name = "strings", Wave = Wave.Saw, MaxPartials = 24, Attack = 0.25, Decay = 0.8, Sustain = 0.85, Release = 0.35, Gate = 1.0, Gain = 0.065, Send = 0.4, Cutoff = 2200, Unison = 2, DetuneCents = 10 }); + Inst choir = s.Add(new Inst { Name = "choir", Wave = Wave.Choir, MaxPartials = 40, Attack = 0.3, Decay = 0.8, Sustain = 0.9, Release = 0.45, Gate = 1.0, Gain = 0.085, Send = 0.45, Cutoff = 3500, Unison = 2, DetuneCents = 10 }); + + Chord[] a = Prog("Dm Dm Bb Bb Gm Gm A A"); + Chord[] a2 = Prog("Dm Bb Gm A Dm Bb Gm A"); + Chord[] b = Prog("F C Dm Bb Gm Dm Eb A"); + + const string Call = + "r:4 | r:2 A4:.5 D5 F5 A5 | Bb5:3 A5:.5 G5 | F5:2 D5 |" + + "G5:1.5 Bb5:.5 D6:1 C6:.5 Bb5 | A5:2 G5:1 F5 | E5:1.5 F5:.5 G5:1 A5 | C#6:2 A5:1 E5"; + const string Theme = + "D5:1 F5:.5 A5 D6:1 C6:.5 A5 | Bb5:1.5 A5:.5 G5:1 F5 | G5:1 Bb5:.5 D6 G5:1 F5:.5 E5 | E5:1.5 C#5:.5 A4:2 |" + + "D5:1 F5:.5 A5 D6:1 C6:.5 D6 | Bb5:1.5 C6:.5 D6:1 Bb5 | A5:1 G5:.5 F5 G5:1 A5 | A5:1.5 G5:.5 F5:1 E5"; + const string Lift = + "A5:2 C6:1 A5 | G5:2 E5:1 C5 | F5:1.5 E5:.5 D5:1 F5 | D5:2 F5:1 Bb5 |" + + "Bb5:2 A5:1 G5 | A5:1.5 F5:.5 D5:2 | G5:1.5 Bb5:.5 Eb6:1 D6:.5 C6 | C#6:1 A5 E5 C#5"; + const string Spiccato = "0020102000201032"; + const string Accent = "X..X..X.X..X..X."; + + // A (0~7) — 하프타임 + s.Line(lead, 0, Call); + s.Arp(ostinato, 0, a, 62, Spiccato, 1f, Accent); + s.Pad(strings, 0, a, 57, 1f); + s.Bass(bass, 0, a, 38, "R r r R r r R r"); + s.Drum(Kit.Kick, 0, 8, "X.......X.x....."); + s.Drum(Kit.Snare, 0, 8, "........X......."); + s.Drum(Kit.HatClosed, 0, 8, "x.x.x.x.x.x.x.x."); + s.Drum(Kit.Crash, 0, 1, "X..............."); + s.Drum(Kit.Crash, 4, 1, "X...............", 0.7f); + s.Drum(Kit.TomLo, 7, 1, "........X.X.XxXx"); + + // A' (8~15) — 주제 + s.Line(lead, 8, Theme); + s.Arp(ostinato, 8, a2, 62, Spiccato, 1f, Accent); + s.Pad(choir, 8, a2, 57, 1f); + s.Pad(strings, 8, a2, 57, 0.8f); + s.Bass(bass, 8, a2, 38, "R r O R r O R r"); + s.Drum(Kit.Kick, 8, 8, "X.....x.X.x...x."); + s.Drum(Kit.Snare, 8, 8, "....X.......X..."); + s.Drum(Kit.HatClosed, 8, 8, "xoxoxoxoxoxoxoxo"); + s.Drum(Kit.Crash, 8, 1, "X..............."); + s.Drum(Kit.Crash, 12, 1, "X...............", 0.7f); + s.Drum(Kit.TomHi, 15, 1, "........XxXx...."); + s.Drum(Kit.TomLo, 15, 1, "............XxXx"); + + // B (16~23) + s.Line(lead, 16, Lift); + s.Arp(ostinato, 16, b, 62, Spiccato, 0.9f, Accent); + s.Pad(choir, 16, b, 57, 1.1f); + s.Pad(strings, 16, b, 57, 1f); + s.Bass(bass, 16, b, 38, "R - - R - - R -"); + s.Drum(Kit.Kick, 16, 8, "X.......X......."); + s.Drum(Kit.Snare, 16, 7, "....X.......X..."); + s.Drum(Kit.TomLo, 16, 7, "X.....X.....X...", 0.7f); + s.Drum(Kit.HatClosed, 16, 8, "x.x.x.x.x.x.x..."); + s.Drum(Kit.HatOpen, 16, 8, "..............x."); + s.Drum(Kit.Crash, 16, 1, "X..............."); + s.Drum(Kit.Crash, 20, 1, "X...............", 0.7f); + s.Drum(Kit.Snare, 23, 1, "....X..........."); + s.Roll(Kit.Snare, s.BeatOf(23) + 2, 2, 0.25, 0.5f, 1f); + return s; + } + + /// + /// 명부회 간부전 — D단조 138bpm, 28마디(약 48.7초). "사무적인 냉정함": 시계 똑딱(나무토막), 타자기 같은 16분 하이햇, + /// 차가운 종(글로켄)과 비브라토 없는 사각파, D 페달 위의 내려가는 아르페지오. 감정이 없는 정확함. + /// A(D 페달) → B(D–C–B♭–A 하행 라멘토, 금관 도장 찍기) → A'(완전4도 평행 — 기계적) → C(E♭→A, 3-3-2 도장) → 처음으로. + /// + private static Score ComposeLedger() + { + var s = new Score(138, 28, 353); + Inst bell = s.Add(new Inst { Name = "bell", Mallet = true, Ratios = new[] { 1.0, 2.76, 5.40, 8.93 }, Amps = new[] { 1.0, 0.45, 0.22, 0.1 }, Decays = new[] { 0.9, 0.35, 0.15, 0.06 }, Release = 0.1, Gate = 1.0, Gain = 0.28, Send = 0.3 }); + Inst lead = s.Add(new Inst { Name = "lead", Wave = Wave.Square, MaxPartials = 12, Attack = 0.004, Decay = 0.25, Sustain = 0.6, Release = 0.06, Gate = 0.88, Gain = 0.17, Send = 0.2, Cutoff = 3600 }); + Inst fourth = s.Add(new Inst { Name = "fourth", Wave = Wave.Square, MaxPartials = 10, Attack = 0.004, Decay = 0.25, Sustain = 0.6, Release = 0.06, Gate = 0.88, Gain = 0.11, Send = 0.2, Cutoff = 3000 }); + Inst stamp = s.Add(new Inst { Name = "stamp", Wave = Wave.Brass, MaxPartials = 28, Attack = 0.012, Decay = 0.12, Sustain = 0.3, Release = 0.06, Gate = 0.6, Gain = 0.2, Send = 0.2, Cutoff = 3600, DarkCutoff = 800, BrightBase = 0.4, BrightDecay = 0.08, Unison = 2, DetuneCents = 6 }); + Inst pluck = s.Add(new Inst { Name = "pluck", Wave = Wave.Triangle, MaxPartials = 12, Attack = 0.002, Decay = 0.08, Sustain = 0.0, Release = 0.03, Gate = 1.0, Gain = 0.26, Send = 0.15, Cutoff = 5000 }); + Inst bass = s.Add(new Inst { Name = "bass", Wave = Wave.Square, MaxPartials = 25, Attack = 0.003, Decay = 0.1, Sustain = 0.4, Release = 0.04, Gate = 0.6, Gain = 0.36, Send = 0.0, Cutoff = 2400, DarkCutoff = 500, BrightBase = 0.35, BrightDecay = 0.06 }); + Inst pad = s.Add(new Inst { Name = "pad", Wave = Wave.Saw, MaxPartials = 20, Attack = 0.2, Decay = 0.6, Sustain = 0.8, Release = 0.3, Gate = 1.0, Gain = 0.05, Send = 0.35, Cutoff = 1800, Unison = 2, DetuneCents = 7 }); + + Chord[] a = Prog("Dm Gm/D Dm Gm/D Bb C A A"); + Chord[] b = Prog("Dm C Bb A Dm C Bb A"); + Chord[] c = Prog("Eb Eb A A"); + + const string Ledger = + "D5:1.5 E5:.5 F5:1 E5 | D5:1.5 C5:.5 Bb4:2 | D5:1.5 E5:.5 F5:1 G5:.5 A5 | G5:1.5 F5:.5 E5:2 |" + + "D6:1.5 C6:.5 Bb5:1 F5 | E5:1.5 G5:.5 C6:2 | C#6:1 E5 A5 G5 | F5:1 E5 C#5 A4"; + const string Ledger2 = + "D5:1.5 E5:.5 F5:1 E5 | D5:1.5 C5:.5 Bb4:2 | D5:1.5 E5:.5 F5:1 G5:.5 A5 | G5:1.5 F5:.5 E5:2 |" + + "D6:1.5 C6:.5 Bb5:1 F5 | E5:1.5 G5:.5 C6:2 | C#6:1 E5 A5 G5 | A5:.5 G5 F5 E5 D5 C#5 Bb4 A4"; + const string Lament = + "A5:1 A5 A5:.5 G5 F5:1 | G5:1 G5 G5:.5 F5 E5:1 | F5:1 F5 F5:.5 E5 D5:1 | E5:2 C#5 |" + + "A5:1 A5 A5:.5 Bb5 C6:1 | G5:1 G5 G5:.5 A5 Bb5:1 | F5:1 D5 Bb4 D5 | C#5:1 E5 A5:2"; + const string Seal = "Bb4:1.5 Eb5:.5 G5:2 | F5:1.5 Eb5:.5 D5:2 | C#5:1.5 E5:.5 A5:2 | G5:1 F5 E5 C#5"; + + // 시계는 처음부터 끝까지 — 장부를 넘기는 소리 + s.Drum(Kit.WoodHi, 0, 28, "X.......X.......", 0.75f); + s.Drum(Kit.WoodLo, 0, 28, "....x.......x...", 0.75f); + + // A (0~7) — D 페달 + s.Line(bell, 0, Ledger); + s.Line(lead, 0, Ledger, 0.9f); + s.Arp(pluck, 0, a, 62, "3212321232123212", 1f, "X...X...X...X..."); + s.Pad(pad, 0, a, 57, 1f); + s.Bass(bass, 0, a, 38, "R O R O R O R O"); + s.Drum(Kit.Kick, 0, 8, "X.......X......."); + s.Drum(Kit.Snare, 0, 8, "....X.......X..."); + s.Drum(Kit.HatClosed, 0, 8, "xoxoxxoxoxoxxoxo"); + s.Drum(Kit.Crash, 0, 1, "X..............."); + + // B (8~15) — 하행 라멘토, 금관 도장 + s.Line(bell, 8, Lament, 0.8f); + s.Line(stamp, 8, Lament, 1f); + s.Arp(pluck, 8, b, 62, "3212321232123212", 0.8f, "X...X...X...X..."); + s.Pad(pad, 8, b, 57, 1f); + s.Bass(bass, 8, b, 38, "R R R R R R R R"); + s.Drum(Kit.Kick, 8, 8, "X...X...X...X..."); + s.Drum(Kit.Snare, 8, 8, "....X.......X..."); + s.Drum(Kit.Clap, 8, 8, "....X.......X...", 0.6f); + s.Drum(Kit.HatClosed, 8, 8, "x.x.x.x.x.x.x.x."); + s.Drum(Kit.Crash, 8, 1, "X..............."); + + // A' (16~23) — 완전4도 평행 + s.Line(bell, 16, Ledger2); + s.Line(lead, 16, Ledger2, 0.9f); + s.Line(fourth, 16, Ledger2, 1f, -5); + s.Arp(pluck, 16, a, 62, "3212321232123212", 1f, "X...X...X...X..."); + s.Pad(pad, 16, a, 57, 1f); + s.Bass(bass, 16, a, 38, "R O R O R O R O"); + s.Drum(Kit.Kick, 16, 8, "X...X...X...X..."); + s.Drum(Kit.Snare, 16, 8, "....X.......X..x"); + s.Drum(Kit.HatClosed, 16, 8, "xoxoxxoxoxoxxoxo"); + s.Drum(Kit.Crash, 16, 1, "X..............."); + s.Drum(Kit.Crash, 20, 1, "X...............", 0.7f); + + // C (24~27) — 도장 3-3-2 + s.Line(bell, 24, Seal); + s.Stabs(stamp, 24, c, 51, "X.....X.....X...", 1.2f); + s.Pad(pad, 24, c, 57, 1.2f); + s.Bass(bass, 24, c, 38, "R - - R - - R -"); + s.Drum(Kit.Kick, 24, 4, "X.....X.....X..."); + s.Drum(Kit.Snare, 24, 3, "X.....X.....X...", 0.85f); + s.Drum(Kit.Crash, 24, 1, "X..............."); + s.Drum(Kit.Snare, 27, 1, "X.....X........."); + s.Roll(Kit.Snare, s.BeatOf(27) + 2, 2, 0.25, 0.45f, 1f); + return s; + } + + /// + /// 최종전(관장 하월·무명) — C단조 152bpm, 32마디(약 50.5초). 반음 충돌(C–D♭–C–B)을 섞은 16분 오스티나토가 끝없이 흔들고, + /// 그 위로 어두운 찬가가 선다. A(i–♭II 흔들림) → A'(합창 겹침·킥 8분) → B(찬가) → C(심장 박동과 종소리만 — 숨 고르기) + /// → D(G 페달 위 롤·금관 8분으로 끌어올림) → 처음으로. + /// + private static Score ComposeFinal() + { + var s = new Score(152, 32, 379); + Inst lead = s.Add(new Inst { Name = "lead", Wave = Wave.Brass, MaxPartials = 24, Attack = 0.02, Decay = 0.3, Sustain = 0.8, Release = 0.1, Gate = 0.94, Gain = 0.26, Send = 0.28, Cutoff = 4400, DarkCutoff = 900, BrightBase = 0.6, BrightDecay = 0.25, Unison = 2, DetuneCents = 8, VibratoDepth = 0.14, VibratoDelay = 0.2 }); + Inst ostinato = s.Add(new Inst { Name = "ostinato", Wave = Wave.Saw, MaxPartials = 22, Attack = 0.002, Decay = 0.07, Sustain = 0.1, Release = 0.03, Gate = 0.75, Gain = 0.26, Send = 0.15, Cutoff = 3600, Drive = 1.8 }); + Inst bass = s.Add(new Inst { Name = "bass", Wave = Wave.Saw, MaxPartials = 30, Attack = 0.003, Decay = 0.18, Sustain = 0.6, Release = 0.05, Gate = 0.85, Gain = 0.38, Send = 0.0, Cutoff = 2200, DarkCutoff = 500, BrightBase = 0.35, BrightDecay = 0.08, Drive = 2.2 }); + Inst choir = s.Add(new Inst { Name = "choir", Wave = Wave.Choir, MaxPartials = 40, Attack = 0.3, Decay = 0.8, Sustain = 0.9, Release = 0.45, Gate = 1.0, Gain = 0.10, Send = 0.45, Cutoff = 3500, Unison = 2, DetuneCents = 12 }); + Inst strings = s.Add(new Inst { Name = "strings", Wave = Wave.Saw, MaxPartials = 24, Attack = 0.2, Decay = 0.8, Sustain = 0.85, Release = 0.3, Gate = 1.0, Gain = 0.06, Send = 0.35, Cutoff = 2200, Unison = 2, DetuneCents = 10 }); + Inst stab = s.Add(new Inst { Name = "stab", Wave = Wave.Brass, MaxPartials = 28, Attack = 0.01, Decay = 0.12, Sustain = 0.3, Release = 0.06, Gate = 0.7, Gain = 0.24, Send = 0.2, Cutoff = 3800, DarkCutoff = 800, BrightBase = 0.4, BrightDecay = 0.08, Unison = 2, DetuneCents = 8 }); + Inst toll = s.Add(new Inst { Name = "toll", Mallet = true, Ratios = new[] { 1.0, 2.0, 3.01, 4.17, 5.43 }, Amps = new[] { 1.0, 0.6, 0.35, 0.25, 0.12 }, Decays = new[] { 2.2, 1.6, 0.9, 0.6, 0.35 }, Release = 0.3, Gate = 1.0, Gain = 0.18, Send = 0.4 }); + + Chord[] a = Prog("Cm Db Cm Db Ab Fm G G"); + Chord[] b = Prog("Ab Eb Fm Db Ab Eb G G"); + Chord[] c = Prog("Db Db Cm Cm"); + Chord[] d = Prog("G G G G"); + int[] shake = { 0, 1, 0, -1, 0, 1, 0, -5 }; + + const string Hymn = + "C5:1.5 Eb5:.5 G5:1 F#5 | F5:1.5 Ab5:.5 Db6:1 C6 | G5:1.5 Eb5:.5 C5:1 B4 | Db5:2 C5 |" + + "Eb5:1.5 Ab5:.5 C6:1 Bb5:.5 Ab5 | F5:1.5 Ab5:.5 C6:1 Db6 | B5:1.5 Ab5:.5 G5:1 F5 | D5:1 F5 Ab5 B5"; + const string Hymn2 = + "C5:1.5 Eb5:.5 G5:1 F#5 | F5:1.5 Ab5:.5 Db6:1 C6 | G5:1.5 Eb5:.5 C5:1 B4 | Db5:2 C5 |" + + "Eb5:1.5 Ab5:.5 C6:1 Bb5:.5 Ab5 | F5:1.5 Ab5:.5 C6:1 Db6 | B5:1.5 Ab5:.5 G5:1 F5 | B5:1 Ab5 F5 D5"; + const string Anthem = + "C6:2 Bb5:1 Ab5 | G5:2 Bb5:1 Eb6 | Db6:1.5 C6:.5 Ab5:1 F5 | Ab5:3 F5:1 |" + + "C6:2 Bb5:1 Ab5 | G5:2 Bb5:1 Eb6 | D6:1.5 C6:.5 B5:1 Ab5 | G5:2 r"; + + // A (0~7) + s.Line(lead, 0, Hymn); + s.Figure(ostinato, 0, a, 60, shake); + s.Pad(strings, 0, a, 55, 1f); + s.Bass(bass, 0, a, 36, "R r R r R r R r"); + s.Drum(Kit.Kick, 0, 8, "X.....x.X.x...x."); + s.Drum(Kit.Snare, 0, 8, "....X.......X..."); + s.Drum(Kit.HatClosed, 0, 8, "xoxoxoxoxoxoxoxo"); + s.Drum(Kit.Crash, 0, 1, "X..............."); + s.Drum(Kit.Crash, 4, 1, "X...............", 0.7f); + + // A' (8~15) — 합창 겹침, 킥 8분 + s.Line(lead, 8, Hymn2); + s.Line(choir, 8, Hymn2, 0.9f, -12); + s.Figure(ostinato, 8, a, 60, shake); + s.Pad(strings, 8, a, 55, 1f); + s.Bass(bass, 8, a, 36, "R r O r R r O r"); + s.Drum(Kit.Kick, 8, 8, "X.x.X.x.X.x.X.x."); + s.Drum(Kit.Snare, 8, 8, "....X.......X..."); + s.Drum(Kit.HatClosed, 8, 8, "x.x.x.x.x.x.x.x."); + s.Drum(Kit.Crash, 8, 1, "X..............."); + s.Drum(Kit.Crash, 12, 1, "X...............", 0.7f); + s.Drum(Kit.TomHi, 15, 1, "........XxXx...."); + s.Drum(Kit.TomLo, 15, 1, "............XxXx"); + + // B (16~23) — 찬가 + s.Line(lead, 16, Anthem); + s.Pad(choir, 16, b, 57, 1.1f); + s.Pad(strings, 16, b, 55, 1f); + s.Figure(ostinato, 16, b, 60, shake, 0.8f); + s.Bass(bass, 16, b, 36, "R - R O R - R O"); + s.Drum(Kit.Kick, 16, 8, "X.......X.x....."); + s.Drum(Kit.Snare, 16, 8, "....X.......X..."); + s.Drum(Kit.TomLo, 16, 8, "X.....X.....X...", 0.6f); + s.Drum(Kit.HatClosed, 16, 8, "x.x.x.x.x.x.x.x."); + s.Drum(Kit.Crash, 16, 1, "X..............."); + s.Drum(Kit.Crash, 20, 1, "X...............", 0.8f); + + // C (24~27) — 심장 박동과 종 + s.Pad(choir, 24, c, 57, 1.1f); + s.Bass(bass, 24, c, 36, "R - - - - - - -", 0, 0.7f); + s.Line(toll, 24, "Db5:4 | Db5:4 | C5:4 | C5:4", 1f); + s.Drum(Kit.Kick, 24, 4, "X..x............"); + s.Drum(Kit.Kick, 26, 2, "........X..x....", 0.7f); + + // D (28~31) — G 페달, 끌어올리기 + s.Figure(ostinato, 28, d, 60, shake, 1.1f); + s.Pad(strings, 28, d, 55, 1.2f); + s.Bass(bass, 28, d, 36, "R R R R R R R R"); + s.Stabs(stab, 30, Slice(d, 2, 2), 55, "x.x.x.x.x.x.x.x.", 1f); + s.Drum(Kit.Kick, 28, 4, "X...X...X...X..."); + s.Roll(Kit.Snare, s.BeatOf(28), 8, 0.5, 0.3f, 0.55f); + s.Roll(Kit.Snare, s.BeatOf(30), 4, 0.25, 0.55f, 0.8f); + s.Roll(Kit.Snare, s.BeatOf(31), 4, 0.125, 0.8f, 1f); + s.Drum(Kit.TomLo, 31, 1, "X...X...X.X.XXXX", 0.8f); + s.Riser(s.BeatOf(29), 12, 0.55f); + return s; + } + + // ════════════════════════════════════════════ + // 악보 — 음표 사건을 모아 두었다가 한 번에 합성한다 + // ════════════════════════════════════════════ + + internal enum Wave { Saw, Square, Pulse25, Triangle, Brass, Choir, Organ } + + internal enum Kit { Kick, Snare, Clap, HatClosed, HatOpen, Crash, TomHi, TomLo, Taiko, TaikoHi, WoodHi, WoodLo, Gong, Timpani } + + /// 악기 한 대. 음색표(배음 진폭) + 엔벨로프 + 비브라토 + 밝기 엔벨로프(금관의 "빠앙"). + internal sealed class Inst + { + public string Name; + public int Id; + public Wave Wave = Wave.Saw; + public int MaxPartials = 20; + public double Attack = 0.005, Decay = 0.2, Sustain = 0.7, Release = 0.08; + /// 음 길이 중 실제로 누르는 비율 — 1 미만이면 스타카토. + public double Gate = 1.0; + public double Gain = 0.25; + /// 잔향으로 보내는 비율. + public double Send = 0.15; + public double VibratoDepth, VibratoRate = 5.5, VibratoDelay = 0.15; // 깊이: 반음 + public int Unison = 1; + public double DetuneCents; + /// 밝은 음색표의 저역통과(Hz). + public double Cutoff = 6000; + /// 0보다 크면 어두운 음색표를 하나 더 만들어 소리 크기를 따라 섞는다(Hz). + public double DarkCutoff; + public double BrightBase = 0.5, BrightDecay = 0.2; + /// 포화 — 1보다 크면 tanh로 배음을 더한다. + public double Drive; + /// 말렛·종 — 비정수배 배음()을 각자 다른 빠르기로 감쇠. + public bool Mallet; + public double[] Ratios, Amps, Decays; + } + + internal struct Chord + { + public int RootPc; + public int BassPc; + public int[] Intervals; + } + + private struct NoteEvent { public Inst Inst; public int Start; public int Hold; public int Midi; public float Vel; } + private struct HitEvent { public Kit Kit; public int Start; public float Vel; public int Midi; } + private struct RiserEvent { public int Start; public int Length; public float Vel; } + + internal sealed class Score + { + public const int BeatsPerBar = 4; + /// 음색표·사인표 한 주기의 칸 수 — 가장 높은 배음(40번째)도 한 주기에 25칸이 넘는다. + internal const int TableSize = 1024; + + public readonly double Bpm; + public readonly int Bars; + public readonly int Length; + public double Swing; + private readonly double samplesPerBeat; + private readonly int seed; + private readonly List insts = new List(); + private readonly List notes = new List(); + private readonly List hits = new List(); + private readonly List risers = new List(); + + public Score(double bpm, int bars, int seed) + { + Bpm = bpm; + Bars = bars; + this.seed = seed; + samplesPerBeat = MusicRate * 60.0 / bpm; + Length = (int)Math.Round(bars * BeatsPerBar * samplesPerBeat); + } + + public Inst Add(Inst inst) + { + inst.Id = insts.Count; + insts.Add(inst); + return inst; + } + + public double BeatOf(int bar) => bar * (double)BeatsPerBar; + + /// 박 → 샘플. 셔플이면 뒷박(반 박)을 늦춘다 — 박 안을 두 구간으로 나눠 늘이고 줄인다. + public int At(double beat) + { + if (Swing > 0) + { + double whole = Math.Floor(beat); + double f = beat - whole; + double split = 0.5 + Swing; + f = f <= 0.5 ? f * split / 0.5 : split + (f - 0.5) * (1.0 - split) / 0.5; + beat = whole + f; + } + return (int)Math.Round(beat * samplesPerBeat); + } + + public void Note(Inst inst, double beat, double beats, int midi, float vel) + { + int start = At(beat); + int hold = (int)Math.Round((At(beat + beats) - start) * inst.Gate); + if (hold < 32) hold = 32; + // 작은 셈여림 차이 — 같은 음이 기계처럼 똑같지 않게(결정적) + uint h = (uint)(start * 2654435761u) ^ (uint)(midi * 40503) ^ (uint)seed; + float jitter = 0.95f + (h % 1000u) / 10000f; + notes.Add(new NoteEvent { Inst = inst, Start = start, Hold = hold, Midi = midi, Vel = vel * jitter }); + } + + /// + /// 선율 한 줄. "A4:.5 C5 E5 A5:1 | …" — 음이름:박(생략하면 앞 음과 같은 길이), r은 쉼표, |는 마디 줄. + /// 마디마다 4박이 맞는지 검사한다 — 틀리면 예외(테스트가 곡을 전부 합성하므로 저작 실수가 바로 잡힌다). + /// + public void Line(Inst inst, int bar, string text, float vel = 1f, int transpose = 0, int[] scale = null, int diatonicShift = 0) + { + double beat = BeatOf(bar); + double barStart = beat; + double dur = 1; + string[] tokens = text.Replace("|", " | ").Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + foreach (string token in tokens) + { + if (token == "|") + { + CheckBar(beat - barStart, text); + barStart = beat; + continue; + } + ParseToken(token, ref dur, out int midi); + if (midi >= 0) + { + int m = midi + transpose; + if (scale != null && diatonicShift != 0) m = DiatonicShift(m, scale, diatonicShift); + double frac = beat - Math.Floor(beat); + float accent = frac < 1e-6 ? 1f : 0.9f; + Note(inst, beat, dur, m, vel * accent); + } + beat += dur; + } + CheckBar(beat - barStart, text); + } + + /// 마디 검사 없는 짧은 악구(대답·장식). + public void Phrase(Inst inst, double beat, string text, float vel = 1f) + { + double dur = 1; + foreach (string token in text.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)) + { + ParseToken(token, ref dur, out int midi); + if (midi >= 0) Note(inst, beat, dur, midi, vel); + beat += dur; + } + } + + private static void CheckBar(double beats, string text) + { + if (Math.Abs(beats - BeatsPerBar) > 1e-6) + throw new InvalidOperationException($"마디 길이가 {beats}박이다(4박이어야 한다): {text}"); + } + + /// 코드를 한 마디씩 길게(패드). 음은 lowMidi부터 한 옥타브 안에 모아 성부 진행을 부드럽게. + public void Pad(Inst inst, int bar, Chord[] chords, int lowMidi, float vel) + { + for (int i = 0; i < chords.Length; i++) + foreach (int m in Voicing(chords[i], lowMidi)) + Note(inst, BeatOf(bar + i), BeatsPerBar, m, vel); + } + + /// 코드 찹 — 16분 칸 패턴(x/X)마다 짧게. + public void Stabs(Inst inst, int bar, Chord[] chords, int lowMidi, string pattern, float vel = 1f) + { + pattern = pattern.Replace(" ", ""); + for (int i = 0; i < chords.Length; i++) + { + int[] tones = Voicing(chords[i], lowMidi); + for (int step = 0; step < 16; step++) + { + float v = StepVel(pattern[step % pattern.Length]); + if (v <= 0f) continue; + foreach (int m in tones) Note(inst, BeatOf(bar + i) + step * 0.25, 0.25, m, vel * v); + } + } + } + + /// + /// 베이스 — 8분 칸마다 한 글자: R 근음, O 옥타브 위, F 5도, T 3도(대문자 = 강세, 소문자 = 여리게), + /// '-' 앞 음 늘이기, '.' 쉼. 이 0이 아니면 그만큼 위 음을 함께 낸다(폰에서 들리게). + /// + public void Bass(Inst inst, int bar, Chord[] chords, int lowMidi, string pattern, int doubleUp = 0, float vel = 1f) + { + string[] cells = pattern.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (cells.Length != 8) throw new InvalidOperationException("베이스 패턴은 8칸이어야 한다: " + pattern); + for (int i = 0; i < chords.Length; i++) + { + int root = PlaceAbove(chords[i].BassPc, lowMidi); + int third = root + chords[i].Intervals[1]; + for (int c = 0; c < 8; c++) + { + char ch = cells[c][0]; + if (ch == '-' || ch == '.') continue; + int len = 1; + while (c + len < 8 && cells[c + len][0] == '-') len++; + int m; + switch (char.ToUpperInvariant(ch)) + { + case 'O': m = root + 12; break; + case 'F': m = root + 7; break; + case 'T': m = third; break; + default: m = root; break; + } + float v = char.IsUpper(ch) ? 1f : 0.62f; + double beat = BeatOf(bar + i) + c * 0.5; + Note(inst, beat, len * 0.5, m, vel * v); + if (doubleUp != 0) Note(inst, beat, len * 0.5, m + doubleUp, vel * v * 0.6f); + } + } + } + + /// + /// 아르페지오 — 16칸(16분), 숫자는 코드음 번호(0 = 가장 낮은 음, 화음 수 = 맨 아래 음의 옥타브 위), '.' 쉼. + /// 가 있으면 그 칸 'X'만 세게, 나머지는 0.7. + /// + public void Arp(Inst inst, int bar, Chord[] chords, int lowMidi, string pattern, float vel = 1f, string accent = null) + { + for (int i = 0; i < chords.Length; i++) + { + int[] v = Voicing(chords[i], lowMidi); + int[] tones = new int[v.Length + 1]; + Array.Copy(v, tones, v.Length); + tones[v.Length] = v[0] + 12; + for (int step = 0; step < 16; step++) + { + char ch = pattern[step % pattern.Length]; + if (ch < '0' || ch > '9') continue; + int idx = Math.Min(ch - '0', tones.Length - 1); + float a = accent == null ? 1f : (accent[step % accent.Length] == 'X' ? 1f : 0.7f); + Note(inst, BeatOf(bar + i) + step * 0.25, 0.25, tones[idx], vel * a); + } + } + } + + /// 근음 기준 반음 수 무늬를 16분으로 두 번씩(최종전의 흔들림 C–D♭–C–B). + public void Figure(Inst inst, int bar, Chord[] chords, int lowMidi, int[] semis, float vel = 1f) + { + for (int i = 0; i < chords.Length; i++) + { + int root = PlaceAbove(chords[i].RootPc, lowMidi); + for (int step = 0; step < 16; step++) + { + float a = (step % 8 == 0) ? 1f : 0.72f; + Note(inst, BeatOf(bar + i) + step * 0.25, 0.25, root + semis[step % semis.Length], vel * a); + } + } + } + + /// 북 — 16칸(16분) 패턴: X 세게, x 보통, o 여리게, '.' 없음. + public void Drum(Kit kit, int bar, int bars, string pattern, float vel = 1f, int midi = 0) + { + pattern = pattern.Replace(" ", ""); + if (pattern.Length != 16) throw new InvalidOperationException("북 패턴은 16칸이어야 한다: " + pattern); + for (int b = 0; b < bars; b++) + for (int step = 0; step < 16; step++) + { + float v = StepVel(pattern[step]); + if (v > 0f) Hit(kit, BeatOf(bar + b) + step * 0.25, vel * v, midi); + } + } + + public void Hit(Kit kit, double beat, float vel, int midi = 0) + { + hits.Add(new HitEvent { Kit = kit, Start = At(beat), Vel = vel, Midi = midi }); + } + + /// 롤 — 일정 간격으로 세기를 키우며. + public void Roll(Kit kit, double beat, double beats, double step, float from, float to, int midi = 0) + { + int n = Math.Max(1, (int)Math.Round(beats / step)); + for (int i = 0; i < n; i++) + Hit(kit, beat + i * step, from + (to - from) * i / Math.Max(1, n - 1), midi); + } + + public void Timpani(double beat, Chord chord, float vel) => Hit(Kit.Timpani, beat, vel, PlaceAbove(chord.RootPc, 36)); + + public void TimpaniRoll(double beat, double beats, Chord chord, float from, float to) => + Roll(Kit.Timpani, beat, beats, 0.125, from, to, PlaceAbove(chord.RootPc, 36)); + + /// 솟구치는 노이즈 — 대목 앞에서 끌어올린다. + public void Riser(double beat, double beats, float vel) + { + int start = At(beat); + risers.Add(new RiserEvent { Start = start, Length = At(beat + beats) - start, Vel = vel }); + } + + // ── 합성 ── + + public float[] Render() + { + float[] dry = new float[Length]; + float[] wet = new float[Length]; + var tables = new Dictionary(); + + // 같은 (악기, 음, 길이)는 한 번만 합성해 그 자리들에 모두 섞고 버린다 — 합성한 음을 쌓아 두면 + // 수문장 곡에서 수십 MB가 한꺼번에 살아 있었다(큰 객체 힙 할당이 GC를 부른다). + var noteGroups = new Dictionary>(); + for (int e = 0; e < notes.Count; e++) + { + NoteEvent n = notes[e]; + long key = ((long)n.Inst.Id << 48) | ((long)(n.Midi & 0xFF) << 32) | (uint)n.Hold; + if (!noteGroups.TryGetValue(key, out List list)) noteGroups[key] = list = new List(); + list.Add(e); + } + foreach (List group in noteGroups.Values) + { + NoteEvent first = notes[group[0]]; + int len = RenderNote(first.Inst, first.Midi, first.Hold, tables); + foreach (int e in group) + { + NoteEvent n = notes[e]; + MixWrapped(dry, wet, scratchNote, len, n.Start, (float)(n.Vel * n.Inst.Gain), (float)n.Inst.Send); + } + } + + // 북도 같은 방식 — 노이즈 북은 변형 몇 벌을 돌려 써서 기관총처럼 똑같이 들리지 않게 한다. + var hitGroups = new Dictionary>(); + int hitIndex = 0; + for (int e = 0; e < hits.Count; e++) + { + HitEvent h = hits[e]; + int variants = NoiseVariants(h.Kit); + int variant = variants > 1 ? hitIndex++ % variants : 0; + long key = ((long)h.Kit << 40) | ((long)variant << 32) | (uint)h.Midi; + if (!hitGroups.TryGetValue(key, out List list)) hitGroups[key] = list = new List(); + list.Add(e); + } + foreach (KeyValuePair> group in hitGroups) + { + HitEvent first = hits[group.Value[0]]; + int variant = (int)((group.Key >> 32) & 0xFF); + float[] buf = DrumSynth.Render(first.Kit, first.Midi, seed * 31 + variant * 7 + (int)first.Kit); + DrumSynth.Levels(first.Kit, out float gain, out float send); + foreach (int e in group.Value) + MixWrapped(dry, wet, buf, buf.Length, hits[e].Start, gain * hits[e].Vel, send); + } + + foreach (RiserEvent r in risers) + { + float[] buf = DrumSynth.Riser(r.Length, seed + r.Start); + MixWrapped(dry, wet, buf, buf.Length, r.Start, 0.22f * r.Vel, 0.6f); + } + + AddReverb(wet, dry); + Master(dry); + return dry; + } + + private static int NoiseVariants(Kit kit) + { + switch (kit) + { + case Kit.Snare: case Kit.HatClosed: case Kit.Clap: return 3; + default: return 1; + } + } + + /// 곡 끝을 넘는 부분은 첫머리에 감아서 더한다 — 루프 이음매가 곡 안의 아무 두 샘플 사이처럼 이어진다. + private static void MixWrapped(float[] dry, float[] wet, float[] src, int srcLength, int start, float gain, float send) + { + int len = dry.Length; + int idx = start % len; + if (idx < 0) idx += len; + int n = Math.Min(srcLength, len); + int first = Math.Min(n, len - idx); + for (int j = 0; j < first; j++) dry[idx + j] += src[j] * gain; + for (int j = first; j < n; j++) dry[j - first] += src[j] * gain; + float sendGain = gain * send; + if (sendGain == 0f) return; + for (int j = 0; j < first; j++) wet[idx + j] += src[j] * sendGain; + for (int j = first; j < n; j++) wet[j - first] += src[j] * sendGain; + } + + // 음 하나를 합성할 때 쓰는 작업 버퍼 — 음마다 새로 잡지 않는다(악보 하나가 스레드 하나라 공유해도 안전). + private float[] scratchEnv = new float[0], scratchMix = new float[0], scratchNote = new float[0]; + + private static float[] Grow(float[] a, int n) => a.Length >= n ? a : new float[n]; + + /// + /// 음 하나를 에 합성하고 길이를 돌려준다 — 엔벨로프·밝기를 먼저 한 줄로 깔고, + /// 유니즌 목소리마다 표를 훑어 더한다(목소리 단위 반복이 빠르다). + /// + private int RenderNote(Inst inst, int midi, int hold, Dictionary tables) + { + double f0 = 440.0 * Math.Pow(2.0, (midi - 69) / 12.0); + int release = Math.Max(16, (int)(inst.Release * MusicRate)); + int total = hold + release; + scratchNote = Grow(scratchNote, total); + float[] buf = scratchNote; + Array.Clear(buf, 0, total); + if (inst.Mallet) + { + RenderMallet(inst, f0, hold, total, buf); + return total; + } + + float[] bright = Table(inst, midi, f0, inst.Cutoff, 0, tables); + float[] dark = inst.DarkCutoff > 0 ? Table(inst, midi, f0, inst.DarkCutoff, 1, tables) : null; + + // 엔벨로프(선형 어택 → 지수 감쇠 → 서스테인, 놓으면 제곱 꼴로 0) + 소리 크기를 따라가는 밝기 + scratchEnv = Grow(scratchEnv, total); + float[] env = scratchEnv; + float[] mix = null; + if (dark != null) { scratchMix = Grow(scratchMix, total); mix = scratchMix; } + int attack = Math.Max(8, (int)(inst.Attack * MusicRate)); + double decayMul = Math.Exp(-1.0 / Math.Max(1.0, inst.Decay * MusicRate)); + double brightMul = Math.Exp(-1.0 / Math.Max(1.0, inst.BrightDecay * MusicRate)); + double decayPart = 1.0, brightPart = 1.0, level = 0, holdLevel = 0; + for (int j = 0; j < total; j++) + { + if (j < hold) + { + if (j < attack) level = (double)j / attack; + else + { + decayPart *= decayMul; + level = inst.Sustain + (1.0 - inst.Sustain) * decayPart; + } + holdLevel = level; + } + else + { + double r = 1.0 - (double)(j - hold) / release; + level = holdLevel * r * r; + } + env[j] = (float)level; + if (mix != null) + { + brightPart *= brightMul; + double m = level * (inst.BrightBase + (1.0 - inst.BrightBase) * brightPart); + mix[j] = (float)(m > 1 ? 1 : m); + } + } + + // 비브라토는 목소리 고리 안에서 바로 굴린다 — 샘플별 배율을 큰 배열에 깔아 두고 읽으면 이 고리가 4배 느려졌다(실측). + double vibAmt = inst.VibratoDepth * 0.05776; // 반음 → 주파수 비(2^(1/12) - 1) + double vibInc = inst.VibratoRate / MusicRate; + int vibDelay = vibAmt > 0 ? (int)(inst.VibratoDelay * MusicRate) : int.MaxValue; + double rampLen = 0.25 * MusicRate; + float[] sine = SineTable; // 정적 필드는 고리 밖에서 한 번만 읽는다 + int voices = Math.Max(1, inst.Unison); + for (int v = 0; v < voices; v++) + { + double cents = voices == 1 ? 0 : inst.DetuneCents * (2.0 * v / (voices - 1) - 1.0); + double inc = f0 / MusicRate * Math.Pow(2.0, cents / 1200.0); + double p = (double)v / voices, vibPhase = 0; + for (int j = 0; j < total; j++) + { + if (j > vibDelay) + { + vibPhase += vibInc; + if (vibPhase >= 1) vibPhase -= 1; + int k = j - vibDelay; + double ramp = k < rampLen ? k / rampLen : 1.0; + p += inc * (1.0 + vibAmt * ramp * Lookup(sine, vibPhase)); + } + else p += inc; + if (p >= 1) p -= 1; + double x = p * TableSize; + int i = (int)x; + float fr = (float)(x - i); + float sb = bright[i] + (bright[i + 1] - bright[i]) * fr; + if (mix != null) + { + float sd = dark[i] + (dark[i + 1] - dark[i]) * fr; + sb = sd + (sb - sd) * mix[j]; + } + buf[j] += sb; + } + } + + float voiceNorm = (float)(1.0 / Math.Sqrt(voices)); + if (inst.Drive > 1.0) + { + float drive = (float)inst.Drive, driveNorm = 1f / SoftClip(drive); + for (int j = 0; j < total; j++) buf[j] = SoftClip(buf[j] * voiceNorm * drive) * driveNorm * env[j]; + } + else + { + for (int j = 0; j < total; j++) buf[j] *= voiceNorm * env[j]; + } + return total; + } + + /// tanh 근사(±3 밖은 ±1) — 포화에 쓴다. Math.Tanh를 샘플마다 부르지 않는다. + internal static float SoftClip(float x) + { + if (x > 3f) return 1f; + if (x < -3f) return -1f; + float x2 = x * x; + return x * (27f + x2) / (27f + 9f * x2); + } + + /// 말렛·종 — 비정수배 배음을 각자 감쇠. 나이퀴스트 근처 배음은 뺀다. + private static void RenderMallet(Inst inst, double f0, int hold, int total, float[] buf) + { + int release = total - hold; + float[] sine = SineTable; + for (int p = 0; p < inst.Ratios.Length; p++) + { + double f = f0 * inst.Ratios[p]; + if (f > MusicRate * 0.42) continue; + double inc = f / MusicRate; + double phase = 0; + // 높은 음일수록 빨리 사그라진다 + double decaySec = inst.Decays[p] * Math.Min(1.6, Math.Max(0.45, Math.Sqrt(523.25 / f0))); + double mul = Math.Exp(-1.0 / (decaySec * MusicRate)); + double env = inst.Amps[p]; + for (int j = 0; j < total; j++) + { + phase += inc; + if (phase >= 1) phase -= 1; + double g = env; + if (j < 24) g *= j / 24.0; + else if (j >= hold) { double u = 1.0 - (double)(j - hold) / release; g *= u * u; } + buf[j] += (float)(Lookup(sine, phase) * g); + env *= mul; + } + } + } + + internal static double Lookup(float[] table, double phase) + { + double x = phase * TableSize; + int i = (int)x; + if (i >= TableSize) i = TableSize - 1; + double fr = x - i; + return table[i] + (table[i + 1] - table[i]) * fr; + } + + /// + /// 음색표 한 주기 — 배음 진폭 × 저역통과, 나이퀴스트(0.45 × 레이트) 위 배음은 뺀다(에일리어싱 방지). + /// sin(kθ)는 체비쇼프 점화식으로 굴린다(배음마다 sin을 부르지 않는다). + /// + private static float[] Table(Inst inst, int midi, double f0, double cutoff, int variant, Dictionary cacheByNote) + { + long key = ((long)inst.Id << 16) | ((long)(midi & 0xFF) << 4) | (uint)variant; + if (cacheByNote.TryGetValue(key, out float[] cached)) return cached; + + int k = Math.Max(1, Math.Min(inst.MaxPartials, (int)(MusicRate * 0.45 / f0))); + double[] amp = new double[k + 1]; + for (int p = 1; p <= k; p++) + { + double fp = p * f0; + double lp = 1.0 / Math.Sqrt(1.0 + Math.Pow(fp / cutoff, 4)); + amp[p] = SpectrumAmp(inst.Wave, p, fp) * lp; + } + float[] table = new float[TableSize + 1]; + double peak = 0; + for (int i = 0; i < TableSize; i++) + { + double theta = 2.0 * Math.PI * i / TableSize; + double c2 = 2.0 * Math.Cos(theta); + double prev = 0, cur = Math.Sin(theta), sum = 0; + for (int p = 1; p <= k; p++) + { + sum += amp[p] * cur; + double next = c2 * cur - prev; + prev = cur; + cur = next; + } + table[i] = (float)sum; + peak = Math.Max(peak, Math.Abs(sum)); + } + if (peak > 1e-9) + for (int i = 0; i < TableSize; i++) table[i] = (float)(table[i] / peak); + table[TableSize] = table[0]; + cacheByNote[key] = table; + return table; + } + + private static double SpectrumAmp(Wave wave, int k, double fk) + { + switch (wave) + { + case Wave.Square: return (k & 1) == 1 ? 1.0 / k : 0; + case Wave.Pulse25: return Math.Sin(Math.PI * k * 0.25) / k; + case Wave.Triangle: return (k & 1) == 1 ? ((((k - 1) / 2) & 1) == 0 ? 1.0 : -1.0) / ((double)k * k) : 0; + case Wave.Brass: + { + // 금관 — 1~1.3kHz 부근이 솟는 포먼트(폰 스피커의 단 대역에 그대로 얹힌다) + double l = Math.Log(fk / 1150.0); + return Math.Pow(k, -0.85) * (0.55 + 1.6 * Math.Exp(-l * l / 0.5)); + } + case Wave.Choir: + { + // "아" 모음 포먼트 650 / 1080 / 2650Hz + double f1 = (fk - 650) / 110, f2 = (fk - 1080) / 160, f3 = (fk - 2650) / 250; + double formant = 1.0 / (1 + f1 * f1) + 0.5 / (1 + f2 * f2) + 0.25 / (1 + f3 * f3) + 0.03; + return Math.Pow(k, -0.5) * formant; + } + case Wave.Organ: + switch (k) + { + case 1: return 1; + case 2: return 0.55; + case 3: return 0.32; + case 4: return 0.18; + case 6: return 0.1; + case 8: return 0.06; + default: return 0; + } + default: return 1.0 / k; // Saw + } + } + + // ── 잔향·마스터 ── + + /// + /// Schroeder 잔향(콤 4 + 올패스 2)을 에 더한다. 곡이 고리이므로 끝 3초를 먼저 흘려 상태를 데운 뒤 처음부터 쓴다 — + /// 루프 첫머리에도 앞(=곡 끝)의 잔향이 그대로 남는다. + /// + private static void AddReverb(float[] input, float[] output) + { + int len = input.Length; + float[] c0 = new float[557], c1 = new float[593], c2 = new float[641], c3 = new float[677]; + float[] a0 = new float[225], a1 = new float[341]; + int i0 = 0, i1 = 0, i2 = 0, i3 = 0, j0 = 0, j1 = 0; + float s0 = 0, s1 = 0, s2 = 0, s3 = 0; + const float feedback = 0.8f, damp = 0.3f, keep = 1f - damp, apFeedback = 0.5f; + + int warm = Math.Min(len, MusicRate * 3); + for (int pass = 0; pass < 2; pass++) + { + int from = pass == 0 ? len - warm : 0; + for (int i = from; i < len; i++) + { + float x = input[i]; + float o0 = c0[i0], o1 = c1[i1], o2 = c2[i2], o3 = c3[i3]; + s0 = o0 * keep + s0 * damp; c0[i0] = x + s0 * feedback; if (++i0 == c0.Length) i0 = 0; + s1 = o1 * keep + s1 * damp; c1[i1] = x + s1 * feedback; if (++i1 == c1.Length) i1 = 0; + s2 = o2 * keep + s2 * damp; c2[i2] = x + s2 * feedback; if (++i2 == c2.Length) i2 = 0; + s3 = o3 * keep + s3 * damp; c3[i3] = x + s3 * feedback; if (++i3 == c3.Length) i3 = 0; + float y = o0 + o1 + o2 + o3; + float b0 = a0[j0]; a0[j0] = y + b0 * apFeedback; if (++j0 == a0.Length) j0 = 0; y = b0 - y; + float b1 = a1[j1]; a1[j1] = y + b1 * apFeedback; if (++j1 == a1.Length) j1 = 0; y = b1 - y; + if (pass == 1) output[i] += y * 0.22f; + } + } + } + + /// + /// 마스터 — ①30Hz 아래를 걷고(헤드룸만 먹는다) ②300Hz~6kHz 대역 RMS를 목표에 맞추고 + /// ③무릎 위 봉우리만 tanh로 눌러 1을 넘지 않게 한다. + /// + private static void Master(float[] d) + { + HighPassLoop(d, 30.0); + double band = BandRms(d, MusicRate); + if (band > 1e-6) + { + float gain = (float)(MusicTargetBandRms / band); + for (int i = 0; i < d.Length; i++) d[i] *= gain; + } + const float knee = MusicLimiterKnee; + for (int i = 0; i < d.Length; i++) + { + float x = d[i]; + float ax = x < 0 ? -x : x; + if (ax <= knee) continue; + float y = knee + (1f - knee) * (float)Math.Tanh((ax - knee) / (1f - knee)); + d[i] = x < 0 ? -y : y; + } + } + + private static void HighPassLoop(float[] d, double cutoff) + { + double a = Math.Exp(-2.0 * Math.PI * cutoff / MusicRate); + int len = d.Length; + int warm = Math.Min(len, MusicRate); + double y = 0, xPrev = d[len - warm]; + for (int i = len - warm + 1; i < len; i++) { y = a * (y + d[i] - xPrev); xPrev = d[i]; } + for (int i = 0; i < len; i++) + { + double x = d[i]; + y = a * (y + x - xPrev); + xPrev = x; + d[i] = (float)y; + } + } + + /// + /// 폰 스피커 대역 RMS — 300Hz 1차 고역통과 두 번으로 어림한다. 6kHz 위 에너지는 이 곡들에서 1% 남짓이라 + /// 저역통과는 생략한다(필터 사슬이 짧을수록 빠르다). + /// + internal static double BandRms(float[] d, int rate) + { + float hp = (float)Math.Exp(-2.0 * Math.PI * 300.0 / rate); + float h1 = 0, h1x = 0, h2 = 0, h2x = 0; + double sum = 0; + for (int i = 0; i < d.Length; i++) + { + float x = d[i]; + h1 = hp * (h1 + x - h1x); h1x = x; + h2 = hp * (h2 + h1 - h2x); h2x = h1; + sum += h2 * h2; + } + return Math.Sqrt(sum / Math.Max(1, d.Length)); + } + } + + // ── 악보 도우미 ── + + private static readonly float[] SineTable = BuildSineTable(); + + private static float[] BuildSineTable() + { + const int size = Score.TableSize; + var t = new float[size + 1]; + for (int i = 0; i <= size; i++) t[i] = (float)Math.Sin(2.0 * Math.PI * i / size); + return t; + } + + private static float StepVel(char c) + { + switch (c) + { + case 'X': return 1f; + case 'x': return 0.75f; + case 'o': return 0.45f; + default: return 0f; + } + } + + private static Chord[] Slice(Chord[] chords, int from, int count) + { + var r = new Chord[count]; + Array.Copy(chords, from, r, 0, count); + return r; + } + + /// "Am F G7 Gm/D" — 마디마다 코드 하나. + internal static Chord[] Prog(string text) + { + string[] parts = text.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + var chords = new Chord[parts.Length]; + for (int i = 0; i < parts.Length; i++) chords[i] = ParseChord(parts[i]); + return chords; + } + + private static Chord ParseChord(string sym) + { + string body = sym; + int bassPc = -1; + int slash = sym.IndexOf('/'); + if (slash > 0) + { + body = sym.Substring(0, slash); + bassPc = ParsePitchClass(sym.Substring(slash + 1), out _); + } + int root = ParsePitchClass(body, out int used); + string q = body.Substring(used); + int[] iv; + switch (q) + { + case "": iv = new[] { 0, 4, 7 }; break; + case "m": iv = new[] { 0, 3, 7 }; break; + case "7": iv = new[] { 0, 4, 7, 10 }; break; + case "m7": iv = new[] { 0, 3, 7, 10 }; break; + case "dim": iv = new[] { 0, 3, 6 }; break; + case "sus4": iv = new[] { 0, 5, 7 }; break; + default: throw new InvalidOperationException("모르는 코드: " + sym); + } + return new Chord { RootPc = root, BassPc = bassPc >= 0 ? bassPc : root, Intervals = iv }; + } + + private static int ParsePitchClass(string s, out int used) + { + int pc; + switch (s[0]) + { + case 'C': pc = 0; break; + case 'D': pc = 2; break; + case 'E': pc = 4; break; + case 'F': pc = 5; break; + case 'G': pc = 7; break; + case 'A': pc = 9; break; + case 'B': pc = 11; break; + default: throw new InvalidOperationException("음이름이 아니다: " + s); + } + used = 1; + if (s.Length > 1 && s[1] == '#') { pc++; used = 2; } + else if (s.Length > 1 && s[1] == 'b') { pc--; used = 2; } + return (pc + 12) % 12; + } + + /// "C#5:.75" → 미디 번호와 길이. 길이를 생략하면 앞 음 길이. 쉼표면 midi = -1. + private static void ParseToken(string token, ref double dur, out int midi) + { + int colon = token.IndexOf(':'); + string name = colon >= 0 ? token.Substring(0, colon) : token; + if (colon >= 0) + dur = double.Parse(token.Substring(colon + 1), System.Globalization.CultureInfo.InvariantCulture); + if (name == "r") { midi = -1; return; } + int pc = ParsePitchClass(name, out int used); + int octave = int.Parse(name.Substring(used), System.Globalization.CultureInfo.InvariantCulture); + // B#·Cb 같은 옥타브 경계는 쓰지 않는다 — 음이름 그대로 옥타브를 붙인다 + int natural = name[0] == 'C' && pc == 11 ? -1 : (name[0] == 'B' && pc == 0 ? 1 : 0); + midi = (octave + 1 + natural) * 12 + pc; + } + + private static int[] Voicing(Chord chord, int lowMidi) + { + var tones = new int[chord.Intervals.Length]; + for (int i = 0; i < tones.Length; i++) + tones[i] = PlaceAbove((chord.RootPc + chord.Intervals[i]) % 12, lowMidi); + Array.Sort(tones); + return tones; + } + + /// 음높이 종류 pc를 lowMidi 이상 한 옥타브 안에 놓는다. + private static int PlaceAbove(int pc, int lowMidi) => lowMidi + ((pc - lowMidi % 12) % 12 + 12) % 12; + + private static int[] Scale(string text) + { + string[] parts = text.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + var pcs = new int[parts.Length]; + for (int i = 0; i < parts.Length; i++) pcs[i] = ParsePitchClass(parts[i], out _); + return pcs; + } + + /// 음계 안에서 steps도 옮긴다(-2 = 3도 아래). 음계 밖 음은 바로 아래 음계음 기준. + private static int DiatonicShift(int midi, int[] scale, int steps) + { + int m = midi; + while (Array.IndexOf(scale, ((m % 12) + 12) % 12) < 0) m--; + int dir = Math.Sign(steps); + for (int i = 0; i < Math.Abs(steps); i++) + { + do { m += dir; } while (Array.IndexOf(scale, ((m % 12) + 12) % 12) < 0); + } + return m; + } + + // ════════════════════════════════════════════ + // 북 합성 — 한 번 만들어 두고 여러 번 섞는다 + // ════════════════════════════════════════════ + + private static class DrumSynth + { + /// + /// 북 음량·잔향. 폰 대역(300Hz 위)에서 들리는 건 킥 몸통이 아니라 스네어·하이햇·박수·가죽 소리라 + /// 그쪽을 넉넉히 둔다 — 처음 값으로는 곡마다 북이 대역 기준 13~15dB 아래 묻혔다(마디별 실측). + /// + public static void Levels(Kit kit, out float gain, out float send) + { + switch (kit) + { + case Kit.Kick: gain = 0.6f; send = 0.02f; break; + case Kit.Snare: gain = 0.62f; send = 0.18f; break; + case Kit.Clap: gain = 0.55f; send = 0.22f; break; + case Kit.HatClosed: gain = 0.16f; send = 0.05f; break; + case Kit.HatOpen: gain = 0.14f; send = 0.1f; break; + case Kit.Crash: gain = 0.24f; send = 0.25f; break; + case Kit.TomHi: case Kit.TomLo: gain = 0.55f; send = 0.18f; break; + case Kit.Taiko: gain = 0.8f; send = 0.25f; break; + case Kit.TaikoHi: gain = 0.45f; send = 0.2f; break; + case Kit.WoodHi: case Kit.WoodLo: gain = 0.24f; send = 0.12f; break; + case Kit.Gong: gain = 0.45f; send = 0.35f; break; + case Kit.Timpani: gain = 0.55f; send = 0.25f; break; + default: gain = 0.3f; send = 0.1f; break; + } + } + + public static float[] Render(Kit kit, int midi, int seed) + { + var rng = new Noise((uint)seed * 2654435761u + 12345u); + switch (kit) + { + case Kit.Kick: return Kick(ref rng); + case Kit.Snare: return Snare(ref rng); + case Kit.Clap: return Clap(ref rng); + case Kit.HatClosed: return Hat(ref rng, 0.07, 0.016); + case Kit.HatOpen: return Hat(ref rng, 0.38, 0.12); + case Kit.Crash: return Crash(ref rng); + case Kit.TomHi: return Tom(ref rng, 196.0); + case Kit.TomLo: return Tom(ref rng, 123.5); + case Kit.Taiko: return Taiko(ref rng, 68.0, 0.42, 1.4); + case Kit.TaikoHi: return Taiko(ref rng, 190.0, 0.12, 1.6); + case Kit.WoodHi: return Wood(ref rng, 1318.5); + case Kit.WoodLo: return Wood(ref rng, 880.0); + case Kit.Gong: return Gong(ref rng); + case Kit.Timpani: return Timpani(ref rng, 440.0 * Math.Pow(2.0, ((midi > 0 ? midi : 36) - 69) / 12.0)); + default: return new float[1]; + } + } + + /// xorshift32 — System.Random보다 몇 배 빠르고 결정적이다(같은 시드 = 같은 북). + private struct Noise + { + private uint state; + public Noise(uint seed) { state = seed == 0 ? 0x9E3779B9u : seed; } + public float Next() + { + uint x = state; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + state = x; + return (int)x * (1f / 2147483648f); + } + } + + /// 1차 저역통과 계수. + private static double Coef(double hz) => 1.0 - Math.Exp(-2.0 * Math.PI * Math.Min(hz, MusicRate * 0.45) / MusicRate); + /// 시간 상수(초)만큼 지나면 1/e가 되는 샘플당 곱 — 샘플마다 Math.Exp를 부르지 않는다. + private static double Mul(double seconds) => Math.Exp(-1.0 / (seconds * MusicRate)); + private static double Sin(float[] sine, double phase) => Score.Lookup(sine, phase); + private static double Step(double phase, double hz) + { + phase += hz / MusicRate; + return phase >= 1 ? phase - Math.Floor(phase) : phase; + } + + /// 킥 — 196→46Hz 내려가는 몸통 + 포화 배음(폰에서 "퉁"이 들리게) + 2~5kHz 딸깍. + private static float[] Kick(ref Noise rng) + { + int n = (int)(0.38 * MusicRate); + var d = new float[n]; + float[] sine = SineTable; + double phase = 0, phase2 = 0, bend = 1, body = 1, knock = 0.3, click = 1.6, hiA = 0, hiB = 0; + double mBend = Mul(0.03), mBody = Mul(0.17), mKnock = Mul(0.045), mClick = Mul(0.004); + double ca = Coef(5000), cb = Coef(1800); + for (int j = 0; j < n; j++) + { + double f = 46 + 150 * bend; + phase = Step(phase, f); + phase2 = Step(phase2, 2 * f); + double w = rng.Next(); + hiA += (w - hiA) * ca; + hiB += (hiA - hiB) * cb; + float tone = (float)(1.8 * (Sin(sine, phase) * body + Sin(sine, phase2) * knock)); + d[j] = Score.SoftClip(tone) + (float)((hiA - hiB) * click); + bend *= mBend; body *= mBody; knock *= mKnock; click *= mClick; + } + return Normalize(d); + } + + /// 스네어 — 190/330Hz 몸통 + 1.5~8kHz 노이즈. + private static float[] Snare(ref Noise rng) + { + int n = (int)(0.28 * MusicRate); + var d = new float[n]; + float[] sine = SineTable; + double pA = 0, pB = 0, bend = 0.3, tone = 0.55, noise = 2.2, hA = 0, hB = 0; + double mBend = Mul(0.01), mTone = Mul(0.045), mNoise = Mul(0.085); + double ca = Coef(8000), cb = Coef(1500); + for (int j = 0; j < n; j++) + { + double k = 1 + bend; + pA = Step(pA, 190 * k); + pB = Step(pB, 330 * k); + double w = rng.Next(); + hA += (w - hA) * ca; + hB += (hA - hB) * cb; + d[j] = (float)((Sin(sine, pA) * 0.6 + Sin(sine, pB) * 0.35) * tone + (hA - hB) * noise); + bend *= mBend; tone *= mTone; noise *= mNoise; + } + return Normalize(d); + } + + /// 박수 — 11ms 간격 세 번 + 꼬리, 0.9~3.5kHz. + private static float[] Clap(ref Noise rng) + { + int n = (int)(0.25 * MusicRate); + var d = new float[n]; + double hA = 0, hB = 0, ca = Coef(3500), cb = Coef(900); + for (int j = 0; j < n; j++) + { + double t = (double)j / MusicRate; + double env = 0; + for (int k = 0; k < 3; k++) + { + double dt = t - k * 0.011; + if (dt >= 0 && dt < 0.04) env = Math.Max(env, Math.Exp(-dt / 0.006)); + } + if (t > 0.03) env = Math.Max(env, 0.7 * Math.Exp(-(t - 0.03) / 0.07)); + double w = rng.Next(); + hA += (w - hA) * ca; + hB += (hA - hB) * cb; + d[j] = (float)((hA - hB) * env); + } + return Normalize(d); + } + + private static float[] Hat(ref Noise rng, double seconds, double decay) + { + int n = (int)(seconds * MusicRate); + var d = new float[n]; + double l1 = 0, l2 = 0, c = Coef(5500), env = 1, m = Mul(decay); + for (int j = 0; j < n; j++) + { + double w = rng.Next(); + l1 += (w - l1) * c; + l2 += (l1 - l2) * c; + d[j] = (float)((w - l2) * env); + env *= m; + } + return Normalize(d); + } + + private static float[] Crash(ref Noise rng) + { + int n = (int)(1.8 * MusicRate); + var d = new float[n]; + float[] sine = SineTable; + double l1 = 0, l2 = 0, c = Coef(2600), env = 1, m = Mul(0.55); + double[] f = { 3150, 4720, 6080, 7390 }; + double[] ph = { 0, 0.16, 0.32, 0.48 }; + for (int j = 0; j < n; j++) + { + double w = rng.Next(); + l1 += (w - l1) * c; + l2 += (l1 - l2) * c; + double ring = 0; + for (int p = 0; p < f.Length; p++) + { + ph[p] = Step(ph[p], f[p]); + ring += Sin(sine, ph[p]) * 0.15; + } + double attack = j < 40 ? j / 40.0 : 1.0; + d[j] = (float)(((w - l2) + ring) * env * attack); + env *= m; + } + return Normalize(d); + } + + /// 탐탐 — 내려앉는 음높이 + 1.5배 모드 + 두드림. + private static float[] Tom(ref Noise rng, double f0) + { + int n = (int)(0.45 * MusicRate); + var d = new float[n]; + float[] sine = SineTable; + double p1 = 0, p2 = 0, l1 = 0, c = Coef(3000); + double bend = 0.6, e1 = 1, e2 = 0.3, slap = 1.2; + double mBend = Mul(0.02), m1 = Mul(0.22), m2 = Mul(0.12), mSlap = Mul(0.01); + for (int j = 0; j < n; j++) + { + double f = f0 * (1 + bend); + p1 = Step(p1, f); + p2 = Step(p2, 1.5 * f); + double body = Sin(sine, p1) * e1 + Sin(sine, p2) * e2; + double w = rng.Next(); + l1 += (w - l1) * c; + d[j] = Score.SoftClip((float)(1.4 * body)) + (float)(l1 * slap); + bend *= mBend; e1 *= m1; e2 *= m2; slap *= mSlap; + } + return Normalize(d); + } + + /// + /// 타이코 — 몸통(낮은 사인 + 1.6·2.3배 모드)을 세게 포화해 배음을 만들고, 200Hz~1.5kHz "딱" 하는 가죽 소리를 얹는다. + /// 폰에서 북이 들리는 건 몸통이 아니라 이 둘이다. + /// + private static float[] Taiko(ref Noise rng, double f0, double decay, double slapAmt) + { + int n = (int)((decay * 2.4 + 0.1) * MusicRate); + var d = new float[n]; + float[] sine = SineTable; + double p1 = 0, p2 = 0, p3 = 0, hA = 0, hB = 0, ca = Coef(1500), cb = Coef(220); + double bend = 0.9, e1 = 1, e2 = 0.35, e3 = 0.2, slap = 2.5 * slapAmt; + double mBend = Mul(0.025), m1 = Mul(decay), m2 = Mul(decay * 0.45), m3 = Mul(decay * 0.3), mSlap = Mul(0.022); + for (int j = 0; j < n; j++) + { + double f = f0 * (1 + bend); + p1 = Step(p1, f); + p2 = Step(p2, 1.6 * f); + p3 = Step(p3, 2.3 * f); + double body = Sin(sine, p1) * e1 + Sin(sine, p2) * e2 + Sin(sine, p3) * e3; + double w = rng.Next(); + hA += (w - hA) * ca; + hB += (hA - hB) * cb; + d[j] = Score.SoftClip((float)(2.2 * body)) + (float)((hA - hB) * slap); + bend *= mBend; e1 *= m1; e2 *= m2; e3 *= m3; slap *= mSlap; + } + return Normalize(d); + } + + private static float[] Wood(ref Noise rng, double f0) + { + int n = (int)(0.12 * MusicRate); + var d = new float[n]; + float[] sine = SineTable; + double pA = 0, pB = 0, eA = 1, eB = 0.35, mA = Mul(0.03), mB = Mul(0.012); + for (int j = 0; j < n; j++) + { + pA = Step(pA, f0); + pB = Step(pB, f0 * 2.7); + double s = Sin(sine, pA) * eA + Sin(sine, pB) * eB; + if (j < 40) s += rng.Next() * 0.3 * (1 - j / 40.0); + d[j] = (float)s; + eA *= mA; eB *= mB; + } + return Normalize(d); + } + + /// 징 — 비정수배 부분음이 천천히 피어올랐다 오래 남는다(높은 부분음 300~600Hz가 폰에서 들린다). + private static float[] Gong(ref Noise rng) + { + int n = (int)(3.2 * MusicRate); + var d = new float[n]; + float[] sine = SineTable; + double[] f = { 72, 113, 158, 203, 271, 349, 455, 612, 790 }; + double[] env = { 1.0, 0.7, 0.6, 0.55, 0.5, 0.45, 0.35, 0.25, 0.15 }; + double[] dec = { 2.6, 2.2, 2.0, 1.8, 1.6, 1.4, 1.1, 0.8, 0.6 }; + int count = f.Length; + var ph = new double[count]; + var m = new double[count]; + var bloom = new double[count]; + var mBloom = new double[count]; + for (int p = 0; p < count; p++) + { + ph[p] = p * 0.7 / (2 * Math.PI); + m[p] = Mul(dec[p]); + bloom[p] = p < 3 ? 0 : 1; + mBloom[p] = Mul(0.05 + 0.02 * p); + } + double l1 = 0, c = Coef(900), hit = 1.5, mHit = Mul(0.04); + for (int j = 0; j < n; j++) + { + double s = 0; + for (int p = 0; p < count; p++) + { + ph[p] = Step(ph[p], f[p]); + s += Sin(sine, ph[p]) * env[p] * (1 - bloom[p]); + env[p] *= m[p]; + bloom[p] *= mBloom[p]; + } + double w = rng.Next(); + l1 += (w - l1) * c; + s += l1 * hit; + hit *= mHit; + d[j] = (float)s; + } + return Normalize(d); + } + + /// 팀파니 — 원형 막의 모드비(1, 1.5, 1.74, 2, 2.24, 2.49) + 말렛 두드림. + private static float[] Timpani(ref Noise rng, double f0) + { + int n = (int)(1.5 * MusicRate); + var d = new float[n]; + float[] sine = SineTable; + double[] r = { 1.0, 1.504, 1.742, 2.0, 2.245, 2.494 }; + double[] env = { 1.0, 0.6, 0.35, 0.3, 0.2, 0.15 }; + double[] dec = { 0.9, 0.6, 0.5, 0.45, 0.35, 0.3 }; + var ph = new double[r.Length]; + var m = new double[r.Length]; + for (int p = 0; p < r.Length; p++) m[p] = Mul(dec[p]); + double l1 = 0, c = Coef(1200), bend = 0.015, mBend = Mul(0.05), thump = 0.9, mThump = Mul(0.012); + for (int j = 0; j < n; j++) + { + double f = f0 * (1 + bend); + double s = 0; + for (int p = 0; p < r.Length; p++) + { + ph[p] = Step(ph[p], f * r[p]); + s += Sin(sine, ph[p]) * env[p]; + env[p] *= m[p]; + } + double w = rng.Next(); + l1 += (w - l1) * c; + s += l1 * thump; + d[j] = Score.SoftClip((float)(1.3 * s)); + bend *= mBend; thump *= mThump; + } + return Normalize(d); + } + + /// 솟구침 — 대역통과 중심이 400Hz→6kHz로 올라가며 커지는 노이즈. 끝에서 짧게 닫는다. + public static float[] Riser(int length, int seed) + { + var rng = new Noise((uint)seed * 2246822519u + 7u); + var d = new float[Math.Max(1, length)]; + double lA = 0, lB = 0, ca = 0, cb = 0; + for (int j = 0; j < d.Length; j++) + { + double u = (double)j / d.Length; + if ((j & 31) == 0) + { + double center = 400 * Math.Pow(15, u); + ca = Coef(center * 1.6); + cb = Coef(center * 0.6); + } + double w = rng.Next(); + lA += (w - lA) * ca; + lB += (lA - lB) * cb; + double env = u * u * Math.Min(1.0, (1 - u) * 40); + d[j] = (float)((lA - lB) * 3.0 * env); + } + return d; + } + + private static float[] Normalize(float[] d) + { + float peak = 0f; + for (int i = 0; i < d.Length; i++) peak = Math.Max(peak, Math.Abs(d[i])); + if (peak > 1e-6f) for (int i = 0; i < d.Length; i++) d[i] /= peak; + // 끝을 4ms에 걸쳐 닫는다 — 잘린 꼬리가 딸깍거리지 않게 + int fade = Math.Min(d.Length, 88); + for (int i = 0; i < fade; i++) d[d.Length - 1 - i] *= (float)i / fade; + return d; + } + } + } +} diff --git a/Assets/Scripts/Core/ProceduralAudioGenerator.Music.cs.meta b/Assets/Scripts/Core/ProceduralAudioGenerator.Music.cs.meta new file mode 100644 index 0000000..98898ad --- /dev/null +++ b/Assets/Scripts/Core/ProceduralAudioGenerator.Music.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0468359c9d7b25946a26c419e8345b9d \ No newline at end of file diff --git a/Assets/Scripts/Core/ProceduralAudioGenerator.cs b/Assets/Scripts/Core/ProceduralAudioGenerator.cs index 6a1d846..9f2fce5 100644 --- a/Assets/Scripts/Core/ProceduralAudioGenerator.cs +++ b/Assets/Scripts/Core/ProceduralAudioGenerator.cs @@ -7,7 +7,7 @@ namespace InsectGame.Core /// PCM 데이터를 직접 생성하여 AudioClip을 만드는 절차적 오디오 생성기. /// MonoBehaviour 없이 static으로 동작하며 캐시를 통해 중복 생성을 방지합니다. /// - public static class ProceduralAudioGenerator + public static partial class ProceduralAudioGenerator { private const int SampleRate = 44100; private const float MasterGain = 0.4f; @@ -28,11 +28,30 @@ public static AudioClip GetBGM(string type) return clip; } + // ── 전투 계열 ── 48~60초 정수 마디 곡, 22.05kHz 모노(ProceduralAudioGenerator.Music.cs). + // 여기 키를 늘리면 Music.cs의 CombatSongKeys·ComposeCombatSong에도 같은 키를 둔다. + switch (type) + { + case "battle": + case "rival": + case "guardian": + case "raid": + case "boss_ledger": + case "boss_final": + float[] song = TakePrewarmed(type) ?? RenderCombatSong(type); + if (song == null) + { + Debug.LogWarning($"[ProceduralAudio] Combat song failed: {type}"); + return null; + } + clip = CreateMusicClip("BGM_" + type, song); + cache[key] = clip; + return clip; + } + switch (type) { case "explore": clip = GenerateExploreBGM(); break; - case "battle": clip = GenerateBattleBGM(); break; - case "raid": clip = GenerateRaidBattleBGM(); break; case "victory": clip = GenerateVictoryBGM(); break; case "defeat": clip = GenerateDefeatBGM(); break; case "menu": clip = GenerateMenuBGM(); break; @@ -51,16 +70,16 @@ public static AudioClip GetBGM(string type) case "explore_emberfall": clip = GenerateRegionBGM("Emberfall", 88, new[] { C4, D4, F4, G4, C5, D5 }, new[] { D1 * 2f, C3, G3, C3 }, 277); break; case "explore_canopy": clip = GenerateRegionBGM("Canopy", 112, new[] { G4, A4, B4, D5, E5, G5 }, new[] { G3, D4, E4, C3 }, 307); break; case "explore_nameless": clip = GenerateRegionBGM("Nameless", 58, new[] { C4, D4, F4, G4, B4, C5 }, new[] { D1, D1, C3, D1 }, 331); break; - // ── 2막 보스 테마 ── 리전 곡보다 빠르고 베이스가 한 음에 눌러앉는다(압박). - // 간부전은 명부회의 사무적인 냉정함, 최종전은 반음 충돌을 섞어 불안정하게. - case "boss_ledger": clip = GenerateRegionBGM("BossLedger", 138, new[] { D4, F4, G4, A4, C5, D5 }, new[] { D2, D2, D2, A2 }, 353); break; - case "boss_final": clip = GenerateRegionBGM("BossFinal", 152, new[] { C4, D4, E4, G4, B4, C5 }, new[] { D1, D1, C3, D1 }, 379); break; + // 2막 보스 테마(boss_ledger·boss_final)는 위 전투 계열 switch로 옮겼다 — 예전엔 리전 곡 생성기를 + // 16초로 돌려 에너지의 93%가 300Hz 아래(폰에서 거의 안 들림)였다. default: Debug.LogWarning($"[ProceduralAudio] Unknown BGM type: {type}"); return null; } cache[key] = clip; + // 첫 탐험 곡을 만든 김에 1대1 전투곡을 작업 스레드에서 미리 굽는다 — 첫 전투가 열릴 때 프레임이 막히지 않게. + StartCombatPrewarm(); return clip; } @@ -77,8 +96,8 @@ public static AudioClip GetSFX(string type) { case "attack": clip = GenerateAttackSFX(); break; case "skill_use": clip = GenerateSkillUseSFX(); break; - case "hit": clip = GenerateHitSFX(); break; - case "critical": clip = GenerateCriticalHitSFX(); break; + case "hit": clip = GenerateImpactSFX(false); break; + case "critical": clip = GenerateImpactSFX(true); break; case "capture": clip = GenerateCaptureSFX(); break; case "capture_success": clip = GenerateCaptureSuccessSFX(); break; case "capture_fail": clip = GenerateCaptureFailSFX(); break; @@ -112,8 +131,13 @@ public static AudioClip GetSFX(string type) case "skill_dark": clip = GenerateElementSkillSFX(8); break; case "skill_metal": clip = GenerateElementSkillSFX(9); break; default: - Debug.LogWarning($"[ProceduralAudio] Unknown SFX type: {type}"); - return null; + // 전투 울음·비명(cry_*/hurt_*)은 종 계열 표에서 만든다 — ProceduralAudioGenerator.Battle.cs. + if (!TryGenerateBattleVoice(type, out clip)) + { + Debug.LogWarning($"[ProceduralAudio] Unknown SFX type: {type}"); + return null; + } + break; } cache[key] = clip; @@ -430,154 +454,8 @@ private static AudioClip GenerateExploreBGM() return CreateClip("BGM_Explore", data, true); } - /// 전투 BGM: A마이너 기반, 긴장감 있고 빠른 12초 루프. - private static AudioClip GenerateBattleBGM() - { - const float bpm = 150f; - const float durationSec = 12f; - int totalSamples = SecondsToSamples(durationSec); - float[] data = new float[totalSamples]; - System.Random rng = new System.Random(77); - - float secPerBeat = 60f / bpm; - int samplesPerBeat = SecondsToSamples(secPerBeat); - int samplesPerEighth = samplesPerBeat / 2; - - // 코드 진행: Am(A2) -> F(F4*0.5) -> G(G3) -> Am(A2) — 각 2비트 - float[] chordRoots = { A2, F4 * 0.5f, G3, A2 }; - - // 멜로디 패턴 - float[] melodyNotes = { A4, C5, D5, E5, G5 }; - int totalEighths = (int)(durationSec / (secPerBeat * 0.5f)); - int[] melodySeq = new int[totalEighths]; - for (int i = 0; i < totalEighths; i++) - { - melodySeq[i] = rng.Next(melodyNotes.Length); - } - - // 드럼 패턴 (8비트 단위 1마디) - // K=킥, S=스네어, H=하이햇 - // 비트: 1 & 2 & 3 & 4 & - bool[] kick = { true, false, false, false, true, false, false, false }; - bool[] snare = { false, false, true, false, false, false, true, false }; - bool[] hihat = { true, true, true, true, true, true, true, true }; - - for (int i = 0; i < totalSamples; i++) - { - float t = (float)i / SampleRate; - float beatPos = t / secPerBeat; - - // 베이스: 사각파 스타카토 (8분음표) - int chordIdx = ((int)(beatPos / 2f)) % chordRoots.Length; - int posInEighth = i % samplesPerEighth; - float bassEnv = Decay(posInEighth, samplesPerEighth, 5f); - float bass = SquareWave(chordRoots[chordIdx], i) * 0.2f * bassEnv; - - // 드럼 - int eighthInBar = ((int)(beatPos * 2f)) % 8; - float drum = 0f; - - if (kick[eighthInBar]) - { - int posInDrum = posInEighth; - float kickFreq = 80f * Mathf.Exp(-6f * (float)posInDrum / samplesPerEighth); - drum += SinWave(kickFreq, posInDrum) * Decay(posInDrum, samplesPerEighth, 8f) * 0.35f; - } - if (snare[eighthInBar]) - { - int posInDrum = posInEighth; - drum += Noise(rng) * Decay(posInDrum, samplesPerEighth, 12f) * 0.2f; - } - if (hihat[eighthInBar]) - { - int posInDrum = posInEighth; - drum += Noise(rng) * Decay(posInDrum, samplesPerEighth / 4, 20f) * 0.08f; - } - - // 멜로디: 삼각파 - int melIdx = ((int)(beatPos * 2f)) % melodySeq.Length; - float melFreq = melodyNotes[melodySeq[melIdx]]; - float melEnv = Envelope(posInEighth, samplesPerEighth / 12, samplesPerEighth / 6, - 0.5f, samplesPerEighth / 4, samplesPerEighth); - float melody = TriWave(melFreq, i) * 0.2f * melEnv; - - data[i] = bass + drum + melody; - } - - return CreateClip("BGM_Battle", data, true); - } - - /// 레이드 전투 BGM: D마이너 기반, 웅장하고 위압적 16초 루프. - private static AudioClip GenerateRaidBattleBGM() - { - const float bpm = 120f; - const float durationSec = 16f; - int totalSamples = SecondsToSamples(durationSec); - float[] data = new float[totalSamples]; - System.Random rng = new System.Random(99); - - float secPerBeat = 60f / bpm; - int samplesPerBeat = SecondsToSamples(secPerBeat); - int samplesPerEighth = samplesPerBeat / 2; - - // 멜로디: D4, F4, A4, D5 레가토 - float[] melodyNotes = { D4, F4, A4, D5 }; - - // 드럼 패턴 (4비트 단위) - bool[] kick = { true, false, false, false }; - bool[] snare = { false, false, true, false }; - - for (int i = 0; i < totalSamples; i++) - { - float t = (float)i / SampleRate; - float beatPos = t / secPerBeat; - - // 베이스: D2 + A2 옥타브 사각파 - int posInBeat = i % samplesPerBeat; - float bassEnv = Decay(posInBeat, samplesPerBeat, 3f); - float bass = (SquareWave(D2, i) * 0.15f + SquareWave(A2, i) * 0.1f) * bassEnv; - - // 서브베이스: D1 사인파 존재감 - float subBass = SinWave(D1, i) * 0.12f; - - // 드럼: 느린 킥 + 무거운 스네어 + 크래시 - int beatInBar = ((int)beatPos) % kick.Length; - float drum = 0f; - int posInEighth = i % samplesPerEighth; - - if (kick[beatInBar] && posInBeat < samplesPerBeat / 2) - { - float kickFreq = 60f * Mathf.Exp(-4f * (float)posInBeat / samplesPerBeat); - drum += SinWave(kickFreq, posInBeat) * Decay(posInBeat, samplesPerBeat, 5f) * 0.4f; - } - if (snare[beatInBar] && posInBeat < samplesPerBeat / 2) - { - drum += Noise(rng) * Decay(posInBeat, samplesPerBeat / 2, 8f) * 0.25f; - } - - // 크래시: 매 4마디 시작 - int barIndex = (int)(beatPos / 4f); - float barStart = barIndex * 4f * secPerBeat; - float timeSinceBar = t - barStart; - if (timeSinceBar < 0.5f) - { - drum += Noise(rng) * Decay(SecondsToSamples(timeSinceBar), - SecondsToSamples(0.5f), 6f) * 0.1f; - } - - // 멜로디: 사인파+삼각파 혼합, 느린 레가토 (1비트당 1음) - int melIdx = ((int)beatPos) % melodyNotes.Length; - float melFreq = melodyNotes[melIdx]; - float melEnv = Envelope(posInBeat, samplesPerBeat / 6, samplesPerBeat / 4, - 0.7f, samplesPerBeat / 3, samplesPerBeat); - float melody = (SinWave(melFreq, i) * 0.5f + TriWave(melFreq, i) * 0.5f) - * 0.2f * melEnv; - - data[i] = bass + subBass + drum + melody; - } - - return CreateClip("BGM_Raid", data, true); - } + // 1대1 전투(battle)·레이드(raid) 곡은 ProceduralAudioGenerator.Music.cs로 옮겼다 — 예전 12초·16초 반복은 + // 마디 수가 정수가 아니라(12초 = 150bpm 7.5마디) 루프 이음매에서 딸깍 소리가 났고 금방 질렸다. /// 승리 BGM: C메이저 팡파레 6초 (루프 아님). private static AudioClip GenerateVictoryBGM() @@ -758,43 +636,6 @@ private static AudioClip GenerateSkillUseSFX() return CreateClip("SFX_SkillUse", data, false); } - /// 타격 SFX: "탁" (0.1초). - private static AudioClip GenerateHitSFX() - { - int totalSamples = SecondsToSamples(0.1f); - float[] data = new float[totalSamples]; - System.Random rng = new System.Random(12); - - for (int i = 0; i < totalSamples; i++) - { - float env = Decay(i, totalSamples, 15f); - float lowPulse = SinWave(100f, i) * 0.5f; - float noise = Noise(rng) * 0.3f; - data[i] = (lowPulse + noise) * env * 0.7f; - } - - return CreateClip("SFX_Hit", data, false); - } - - /// 크리티컬 타격 SFX: "퍽!" (0.2초). - private static AudioClip GenerateCriticalHitSFX() - { - int totalSamples = SecondsToSamples(0.2f); - float[] data = new float[totalSamples]; - System.Random rng = new System.Random(13); - - for (int i = 0; i < totalSamples; i++) - { - float env = Decay(i, totalSamples, 10f); - float lowPulse = SinWave(60f, i) * 0.5f; - float noise = Noise(rng) * 0.4f; - float highAccent = SinWave(1200f, i) * 0.15f * Decay(i, totalSamples, 20f); - data[i] = (lowPulse + noise + highAccent) * env * 0.8f; - } - - return CreateClip("SFX_CriticalHit", data, false); - } - /// 포획 시도 SFX: "삐리리" 떨림 (0.5초). private static AudioClip GenerateCaptureSFX() { diff --git a/Assets/Scripts/Core/QuestRewardFormatter.cs b/Assets/Scripts/Core/QuestRewardFormatter.cs index a3df7e4..341604b 100644 --- a/Assets/Scripts/Core/QuestRewardFormatter.cs +++ b/Assets/Scripts/Core/QuestRewardFormatter.cs @@ -8,6 +8,8 @@ public enum QuestRewardKind Exp, Item, Insect, + Coin, + IslandObject, } public struct QuestRewardEntry @@ -101,6 +103,30 @@ public static void Collect( Amount = 1, }); } + + if (HasCoins(quest)) + { + into.Add(new QuestRewardEntry + { + Kind = QuestRewardKind.Coin, + Label = "코인 " + quest.rewardCoins, + Amount = quest.rewardCoins, + }); + } + + // 섬 물건은 카탈로그에 있는 것만 — GrantRewards도 모르는 id는 지급하지 않는다(IslandManager.GrantObject). + if (HasIslandObject(quest)) + { + string name = IslandCatalog.Get(quest.rewardIslandObjectId).displayName; + into.Add(new QuestRewardEntry + { + Kind = QuestRewardKind.IslandObject, + Label = quest.rewardIslandObjectCount > 1 + ? "섬 물건 " + name + " ×" + quest.rewardIslandObjectCount + : "섬 물건 " + name, + Amount = quest.rewardIslandObjectCount, + }); + } } // ── 포함 조건 (GrantRewards와 1:1) ── @@ -117,6 +143,11 @@ private static bool HasItem(TutorialQuest quest) => private static bool HasInsect(TutorialQuest quest) => !string.IsNullOrEmpty(quest.rewardInsectId); + private static bool HasCoins(TutorialQuest quest) => quest.rewardCoins > 0; + + internal static bool HasIslandObject(TutorialQuest quest) => + quest.rewardIslandObjectCount > 0 && IslandCatalog.Get(quest.rewardIslandObjectId) != null; + /// 보상 한 줄 요약 — "캔디 5 + 경험치 10 + 황금 채집망 ×1". 없으면 빈 문자열. public static string Format(TutorialQuest quest, System.Func itemNameResolver) { @@ -135,7 +166,8 @@ public static string Format(TutorialQuest quest, System.Func ite public static bool HasAny(TutorialQuest quest) { if (quest == null) return false; - return HasCandy(quest) || HasExp(quest) || HasItem(quest) || HasInsect(quest); + return HasCandy(quest) || HasExp(quest) || HasItem(quest) || HasInsect(quest) + || HasCoins(quest) || HasIslandObject(quest); } } } diff --git a/Assets/Scripts/Core/QuestTraitRules.cs b/Assets/Scripts/Core/QuestTraitRules.cs new file mode 100644 index 0000000..85ac7ba --- /dev/null +++ b/Assets/Scripts/Core/QuestTraitRules.cs @@ -0,0 +1,149 @@ +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 방금 잡은 개체의 성질 — 판정의 입력. + /// 포획 지점(CaptureController·전투 포획)이 으로 만들어 넘긴다. + /// + public struct CaptureFacts + { + public InsectRarity rarity; + public InsectElement primary; + public InsectElement secondary; + /// 이 개체의 몸길이(mm) — . + public float sizeMm; + /// 종 기준 대비 몸길이 배율(0.75~1.25) — . + public float sizeRatio; + public bool shiny; + /// + /// 크기를 아는가. 저장된 개체()가 없으면 몸길이는 중간값으로 채워질 뿐 + /// 실제 크기가 아니다 — 그런 포획이 크기 조건을 채우면 안 되므로 판정이 이 값을 본다. + /// + public bool sizeKnown; + + /// + /// 가 null이면(개체 저장에 실패한 포획) 크기는 모르는 것으로 두고 + /// 이로치는 을 쓴다. 등급·속성 조건은 종 데이터만으로 판정되므로 그대로 센다. + /// + public static CaptureFacts From(InsectData data, PlayerInsectData captured, bool shinyFallback = false) + { + var facts = new CaptureFacts(); + if (data == null) return facts; + facts.rarity = data.rarity; + facts.primary = data.primaryType; + facts.secondary = data.secondaryType; + facts.sizeKnown = captured != null; + facts.sizeMm = InsectSizeCalculator.SizeMm(data, captured); + facts.sizeRatio = InsectSizeCalculator.SizeRatio(data, captured); + facts.shiny = captured != null ? captured.isShiny : shinyFallback; + return facts; + } + } + + /// + /// 방금 이긴 전투의 모습 — 판정의 입력. + /// 전투 컨트롤러가 이긴 순간 만들어 넘긴다(적이 풀로 돌아가기 전의 시작 시점 스냅샷으로). + /// + public struct BattleFacts + { + public InsectRarity enemyRarity; + public InsectElement enemyPrimary; + public InsectElement enemySecondary; + public int enemyLevel; + /// 끝났을 때 싸우고 있던 내 곤충의 레벨. + public int playerLevel; + /// 이 전투에서 내가 행동한 횟수(기술·기본 공격·스턴으로 건너뛴 차례 포함). + public int playerActions; + /// 끝났을 때 내 곤충의 남은 HP 비율(0~1). + public float playerHpRatio; + + public static BattleFacts From(InsectData enemy, int enemyLevel, int playerLevel, + int playerActions, int playerHp, int playerMaxHp) + { + var facts = new BattleFacts + { + enemyLevel = enemyLevel, + playerLevel = playerLevel, + playerActions = playerActions, + playerHpRatio = playerMaxHp > 0 ? Mathf.Clamp01((float)playerHp / playerMaxHp) : 0f, + }; + if (enemy != null) + { + facts.enemyRarity = enemy.rarity; + facts.enemyPrimary = enemy.primaryType; + facts.enemySecondary = enemy.secondaryType; + } + return facts; + } + } + + /// + /// 조건부 퀘스트(·)의 순수 판정. + /// MonoBehaviour와 떼어 놓아 테스트로 고정한다(와 같은 성격). + /// + /// 조건 필드의 0·None·false는 "조건 없음"이다. 채운 조건은 전부 맞아야 한다(AND). + /// 그래서 조건을 하나도 안 채운 조건부 퀘스트는 무엇이든 센다 — 의도한 게 아니라 저작 실수이므로 + /// quest_lint 검사 13이 막는다. + /// + public static class QuestTraitRules + { + // 화면에 찍히는 몸길이는 소수 첫째 자리다(InsectSizeCalculator.SizeLabel). "40.0mm"로 보이는 개체가 + // 실제로는 39.96이라 40 이상 퀘스트에 안 세어지면 플레이어에게는 버그다 — 보이는 값 기준으로 잰다. + private const float SizeRoundStep = 10f; + + // 배율은 정수 롤(0~100)에서 나온 Lerp라 1.2가 1.1999999로 떨어질 수 있다. + private const float RatioEpsilon = 1e-4f; + + public static bool HasCondition(TutorialQuest q) + { + if (q == null) return false; + return q.minSizeMm > 0f || q.maxSizeMm > 0f + || q.minSizeRatio > 0f || q.maxSizeRatio > 0f + || q.requiredElement != InsectElement.None + || q.requireShiny + || q.minRarity > InsectRarity.Common + || q.minLevelEdge > 0 || q.maxTurns > 0 || q.minHpPercent > 0 + || q.resetOnLoss; + } + + public static bool Matches(TutorialQuest q, CaptureFacts f) + { + if (q == null || q.type != QuestType.CaptureTrait) return false; + if (f.rarity < q.minRarity) return false; + + bool wantsSize = q.minSizeMm > 0f || q.maxSizeMm > 0f || q.minSizeRatio > 0f || q.maxSizeRatio > 0f; + if (wantsSize && !f.sizeKnown) return false; + + float mm = Mathf.Round(f.sizeMm * SizeRoundStep) / SizeRoundStep; + if (q.minSizeMm > 0f && mm < q.minSizeMm) return false; + if (q.maxSizeMm > 0f && mm > q.maxSizeMm) return false; + if (q.minSizeRatio > 0f && f.sizeRatio + RatioEpsilon < q.minSizeRatio) return false; + if (q.maxSizeRatio > 0f && f.sizeRatio - RatioEpsilon > q.maxSizeRatio) return false; + + if (!HasElement(q.requiredElement, f.primary, f.secondary)) return false; + if (q.requireShiny && !f.shiny) return false; + return true; + } + + public static bool Matches(TutorialQuest q, BattleFacts f) + { + if (q == null || q.type != QuestType.BattleFeat) return false; + if (f.enemyRarity < q.minRarity) return false; + if (!HasElement(q.requiredElement, f.enemyPrimary, f.enemySecondary)) return false; + + if (q.minLevelEdge > 0 && f.enemyLevel - f.playerLevel < q.minLevelEdge) return false; + if (q.maxTurns > 0 && f.playerActions > q.maxTurns) return false; + // 퍼센트는 정수 비교로 — 70%를 "정확히 70.0"으로 끝낸 전투가 부동소수점에 떨어지면 안 된다. + if (q.minHpPercent > 0 && Mathf.RoundToInt(f.playerHpRatio * 100f) < q.minHpPercent) return false; + return true; + } + + private static bool HasElement(InsectElement wanted, InsectElement primary, InsectElement secondary) + { + if (wanted == InsectElement.None) return true; + return primary == wanted || secondary == wanted; + } + } +} diff --git a/Assets/Scripts/Core/QuestTraitRules.cs.meta b/Assets/Scripts/Core/QuestTraitRules.cs.meta new file mode 100644 index 0000000..a53f05c --- /dev/null +++ b/Assets/Scripts/Core/QuestTraitRules.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5ddbaa19cd2c9764799f19980cabf360 \ No newline at end of file diff --git a/Assets/Scripts/Core/RegionAtmosphere.cs b/Assets/Scripts/Core/RegionAtmosphere.cs new file mode 100644 index 0000000..3980bdc --- /dev/null +++ b/Assets/Scripts/Core/RegionAtmosphere.cs @@ -0,0 +1,118 @@ +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 리전별 대기 — 햇빛 색·세기, 환경광(하늘 쪽) 색, 원경 연무. 가 + /// 서브에리어 밖에서 현재 리전의 값으로 부드럽게 옮겨 간다. + /// + /// 옛날엔 메인 필드 전체가 같은 한낮 빛이라 서릿길과 잿불 골짜기가 바닥색만 다른 같은 들판이었다. + /// + /// 연무는 지수제곱(Exp2)이고 옅다. 목적은 원경 산맥을 하늘로 풀어 주는 공기원근이지 시야 차단이 아니다. + /// 게임 카메라는 플레이어에서 약 11m 떨어져 있으므로 그 거리의 투과율이 98%를 넘어야 한다 — + /// 가 그 상한이고 FieldThemeTests.RegionAtmosphere_FogNeverHidesThePlayer가 고정한다. + /// 내장 안개는 스카이박스에 안 걸리므로 연무색을 지평선 하늘색 근처로 잡는다(잿불·이름 없는 자리만 예외). + /// + /// 안개 모드는 상수 하나다. 런타임()과 가시거리 검사가 둘 다 + /// 그 상수를 에 넣는다 — 예전 검사는 Exp2 식을 테스트 안에 직접 적어서, 런타임이 Exp로 + /// 바뀌면(밀도 상한 0.009에서 11m 투과율이 99.0% → 90.6%로 떨어진다) 캐릭터가 뿌옇게 묻혀도 통과했다. + /// + public static class RegionAtmosphere + { + /// 메인 필드 리전 연무의 안개 모드 — 원경만 흐리고 가까운 곳은 거의 안 건드리는 지수제곱. + public const FogMode FieldFogMode = FogMode.ExponentialSquared; + + /// 서브에리어 방의 안개 모드 — 벽이 가까운 좁은 방이라 거리에 고르게 먹는 지수. + public const FogMode SubAreaFogMode = FogMode.Exponential; + + /// 게임 카메라에서 플레이어까지의 거리(m, 고각 (0,9,−6) 구도). + public const float PlayerCameraDistance = 11f; + + /// 그 거리에서 지켜야 할 안개 투과율 하한 — 아래로 내려가면 캐릭터가 뿌옇다. + public const float MinPlayerTransmittance = 0.98f; + + /// (Exp2)에서 11m 투과율이 98%가 되는 밀도(≈0.0129)에 여유를 둔 상한. + public const float MaxFogDensity = 0.009f; + + // ── 안개 날씨(가 리전 연무 위에 얹는 가산) ── + // 리전 연무는 "원경을 하늘로 푸는 공기원근"이라 98%였지만, 안개 낀 날은 안개가 **보여야** 한다. 대신 캐릭터가 묻히면 안 되므로 + // 같은 11m 거리에서 아래 하한을 지킨다. 한도는 투과율 식에서 거꾸로 푼 값에 여유를 둔 것이고 + // WorldSkyRulesTests가 모든 리전·시각·날씨 조합으로 같은 식()을 돌려 고정한다. + + /// 메인 필드()의 안개 날씨에서 11m 투과율 하한 — 캐릭터가 읽히는 선. + public const float MinWeatherTransmittance = 0.85f; + + /// (Exp2)에서 11m 투과율 85%가 되는 밀도(≈0.0366)에 여유를 둔 상한. + public const float MaxWeatherFogDensity = 0.034f; + + /// 섬 같은 분리 서브에리어(, Exp)의 안개 날씨 11m 투과율 하한. + public const float MinWeatherTransmittanceSubArea = 0.80f; + + /// (Exp)에서 11m 투과율 80%가 되는 밀도(≈0.0203)에 여유를 둔 상한. + public const float MaxWeatherFogDensitySubArea = 0.019f; + + /// + /// Unity 내장 안개의 투과율(1 = 안개 없음) — 셰이더의 UNITY_CALC_FOG_FACTOR와 같은 식이다. + /// Linear e = (end − z)/(end − start), Exp e = exp(−density·z), Exp2 e = exp(−(density·z)²). + /// Linear의 시작·끝 기본값은 RenderSettings의 기본값(0, 300)이다. + /// + public static float FogTransmittance(FogMode mode, float density, float distance, float linearStart = 0f, float linearEnd = 300f) + { + switch (mode) + { + case FogMode.Exponential: + return Mathf.Exp(-density * distance); + case FogMode.ExponentialSquared: + float x = density * distance; + return Mathf.Exp(-x * x); + default: + return linearEnd > linearStart ? Mathf.Clamp01((linearEnd - distance) / (linearEnd - linearStart)) : 1f; + } + } + + public struct Profile + { + public Color light; + public float intensity; // 기본 햇빛 세기에 곱한다 + public Color ambientSky; + public Color fog; + public float fogDensity; + } + + public static bool TryGet(string regionId, out Profile p) + { + switch (regionId) + { + case "meadow": p = P(1.00f, 0.96f, 0.86f, 1.00f, 0.50f, 0.56f, 0.64f, 0.76f, 0.83f, 0.90f, 0.0026f); return true; + case "pond": p = P(0.97f, 0.97f, 0.92f, 1.00f, 0.49f, 0.57f, 0.66f, 0.74f, 0.84f, 0.91f, 0.0030f); return true; + case "forest": p = P(0.95f, 0.98f, 0.84f, 0.95f, 0.44f, 0.52f, 0.47f, 0.66f, 0.76f, 0.68f, 0.0042f); return true; + case "swamp": p = P(0.90f, 0.94f, 0.80f, 0.90f, 0.42f, 0.47f, 0.42f, 0.58f, 0.64f, 0.54f, 0.0060f); return true; + case "mountain": p = P(0.98f, 0.98f, 1.00f, 1.05f, 0.50f, 0.55f, 0.64f, 0.78f, 0.83f, 0.90f, 0.0034f); return true; + case "garden": p = P(1.00f, 0.95f, 0.88f, 1.02f, 0.55f, 0.54f, 0.60f, 0.86f, 0.83f, 0.88f, 0.0028f); return true; + case "ruins": p = P(1.00f, 0.93f, 0.80f, 1.00f, 0.52f, 0.50f, 0.52f, 0.80f, 0.78f, 0.74f, 0.0036f); return true; + case "hollow": p = P(0.94f, 0.93f, 0.88f, 0.95f, 0.50f, 0.51f, 0.52f, 0.74f, 0.74f, 0.71f, 0.0052f); return true; + case "dunes": p = P(1.00f, 0.93f, 0.78f, 1.10f, 0.59f, 0.55f, 0.52f, 0.88f, 0.82f, 0.72f, 0.0034f); return true; + case "frostline": p = P(0.88f, 0.94f, 1.00f, 1.00f, 0.54f, 0.60f, 0.71f, 0.82f, 0.88f, 0.95f, 0.0046f); return true; + case "emberfall": p = P(1.00f, 0.80f, 0.62f, 0.92f, 0.55f, 0.44f, 0.40f, 0.60f, 0.46f, 0.42f, 0.0058f); return true; + case "canopy": p = P(0.94f, 1.00f, 0.84f, 0.96f, 0.45f, 0.55f, 0.47f, 0.64f, 0.78f, 0.66f, 0.0040f); return true; + case "nameless": p = P(0.84f, 0.84f, 0.92f, 0.86f, 0.45f, 0.44f, 0.50f, 0.52f, 0.50f, 0.58f, 0.0068f); return true; + default: + p = default; + return false; + } + } + + private static Profile P(float lr, float lg, float lb, float intensity, + float ar, float ag, float ab, float fr, float fg, float fb, float density) + { + return new Profile + { + light = new Color(lr, lg, lb), + intensity = intensity, + ambientSky = new Color(ar, ag, ab), + fog = new Color(fr, fg, fb), + fogDensity = density, + }; + } + } +} diff --git a/Assets/Scripts/Core/RegionAtmosphere.cs.meta b/Assets/Scripts/Core/RegionAtmosphere.cs.meta new file mode 100644 index 0000000..5951740 --- /dev/null +++ b/Assets/Scripts/Core/RegionAtmosphere.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d87ab091a97b7404ca4d23dc9d1e363c \ No newline at end of file diff --git a/Assets/Scripts/Core/RegionDefinitions.cs b/Assets/Scripts/Core/RegionDefinitions.cs index e168b37..677fb11 100644 --- a/Assets/Scripts/Core/RegionDefinitions.cs +++ b/Assets/Scripts/Core/RegionDefinitions.cs @@ -32,9 +32,20 @@ public static bool IsAct2Region(RegionData region) public static RegionData[] CreateAll() { + // 리전 풀(insectIds)은 등급을 정하지 않는다 — 필드 스폰이 등급을 전역 표로 먼저 굴리고 + // (FieldSpawnRules), 종만 이 풀에서 그 등급 안의 spawnWeight로 고른다. 리전 사이의 차이는 + // 레벨 대역뿐이다(사용자 결정 2026-09-29). + // 그래서 **한 등급 안의 종 수가 곧 그 리전의 다양성**이다. 숲 일반이 2종이면 숲 스폰의 60%가 + // 그 둘이고, 등급이 통째로 비면 FieldSpawnRules.Fallback이 이웃 등급으로 대신 굴린다(초원이 + // 전설을 영영 못 낸다). 전 리전이 일반 4 · 고급 3 · 희귀 2 · 영웅 1 · 전설 1종 이상을 갖도록 + // 기존 필드 종을 테마에 맞춰 나눠 넣었다(각 풀 끝의 「등급 보충」 줄). 새 종을 만들지 않는다. + // - 서브에리어 전용종은 그 서브에리어가 속한 리전 필드에 넣지 않는다 — 들어가는 순간 전용이 아니다. + // - 1막 리전에는 2막 종을 넣지 않는다. 2막은 1막 종을 빌려 오되 그 땅의 분위기에 맞는 것만. + // - 스토리 포획 목표(ch2_emperor 왕잠자리 · ch4_firefly 파란반딧불이)가 있는 영웅 칸은 넓히지 + // 않는다. 같은 등급 종이 늘면 목표종 확률이 그만큼 줄어든다. RegionData[] regions = new RegionData[] { - // ── 초원: Lv.1~10, 입문 지역, Common/Uncommon 위주 ── + // ── 초원: Lv.1~10, 입문 지역 ── new RegionData { regionId = "meadow", @@ -50,7 +61,10 @@ public static RegionData[] CreateAll() "grasshopper_green", "ladybug_seven", "caterpillar_green", "aphid_colony", "moth_brown", "beetle_dung", // 확장 64종 — 초원 신규 4종 (C/U) - "bee_bumble", "grasshopper_brown", "ladybug_harlequin", "antlion_pit" + "bee_bumble", "grasshopper_brown", "ladybug_harlequin", "antlion_pit", + // 등급 보충 — 희귀 2(방아벌레·배추흰나비는 원래 Meadow 태그다)·영웅 1(꿀벌 무리의 여왕)· + // 전설 1(마을 고목의 태고매미 — 소리의 들판. 소리가 멎은 텅 빈 들과 짝이다) + "butterfly_cabbage", "beetle_click", "bee_queen", "cicada_ancient" }, guardianInsectId = "mantis_green", guardianDisplayName = "초원의 수호자 사마귀", @@ -100,7 +114,10 @@ public static RegionData[] CreateAll() // 확장 64종 — 연못 신규 9종 (C4/U3/R1/E1) + 습지와 공유 1종(fly_crane) "mosquito_tiger", "fly_hover", "water_strider_stream", "diving_beetle_striped", "damselfly_red", "dragonfly_scarlet", "diving_beetle_great", - "dragonfly_jade", "firefly_marsh", "fly_crane" + "dragonfly_jade", "firefly_marsh", "fly_crane", + // 등급 보충 — 희귀 1(물웅덩이 사냥꾼 습지사마귀)·전설 1(고대잠자리, 원래 Pond 태그). + // 영웅 칸은 왕잠자리(ch2_emperor) 확률을 지키려 그대로 둔다 + "mantis_swamp", "dragonfly_ancient" }, guardianInsectId = "dragonfly_emperor", guardianDisplayName = "연못의 파수꾼 왕잠자리", @@ -150,7 +167,10 @@ public static RegionData[] CreateAll() "hornet_asian", // 확장 64종 — 숲 신규 5종 (U2/R2/E1) "beetle_longhorn_oak", "cricket_tree", "stag_beetle_saw", - "mantis_bark", "rhinoceros_beetle_titan" + "mantis_bark", "rhinoceros_beetle_titan", + // 등급 보충 — 일반 2(썩은 나무 둘레 왕개미·가지 사이 무당거미)· + // 전설 1(고목 수액의 황금사슴벌레 — 꽃밭 온실 전용종이지만 온실은 꽃밭 소속이다) + "ant_soldier", "spider_garden", "beetle_golden_stag" }, guardianInsectId = "beetle_hercules", guardianDisplayName = "숲의 문지기 헤라클레스", @@ -209,7 +229,10 @@ public static RegionData[] CreateAll() "mosquito_swamp", "centipede_red", "earwig_swamp", "pill_bug_mud", "fly_crane", "dragonfly_swamp_hawker", "firefly_swamp", "spider_marsh", "mantis_swamp", "centipede_venom", "wasp_night", "spider_bog_widow", - "mosquito_tiger" + "mosquito_tiger", + // 등급 보충 — 전설 1(안개 위의 고대잠자리, 연못과 공유). 영웅 칸은 파란반딧불이 + // (ch4_firefly) 확률을 지키려 그대로 둔다 + "dragonfly_ancient" }, guardianInsectId = "mantis_ghost", guardianDisplayName = "습지의 유령 사마귀", @@ -316,7 +339,10 @@ public static RegionData[] CreateAll() "wasp_paper", "butterfly_cabbage", // 확장 64종 — 꽃밭 신규 6종 (C1/U2/R2/E1) "aphid_rose", "bee_carpenter", "butterfly_peacock", - "butterfly_glasswing", "moth_hummingbird", "bee_queen" + "butterfly_glasswing", "moth_hummingbird", "bee_queen", + // 등급 보충 — 일반 2(꽃을 찾는 일꿀벌·꽃등에)·전설 1(한밤 정원의 그믐황후나비). + // 알렉산드라비단나비·황금사슴벌레는 이 리전 서브에리어(미로·온실)의 전용종이라 쓰지 않는다 + "bee_worker", "fly_hover", "butterfly_midnight" }, guardianInsectId = "butterfly_swallowtail", guardianDisplayName = "꽃밭의 문지기 호랑나비", @@ -351,12 +377,14 @@ public static RegionData[] CreateAll() } } }, - // ── 고대 유적: Lv.36~50, 전설급 (신규) ── + // ── 고대 유적: Lv.36~50, 1막의 끝 ── new RegionData { regionId = "ruins", displayName = "고대 유적", - description = "잊혀진 문명의 유적 — 전설급 곤충만이 서식하는 최종 지역입니다.", + // 옛 문구 「전설급 곤충만이 서식하는 최종 지역」은 둘 다 어긋났다 — 등급은 이제 전역 표를 + // 따르고(여기서도 60%가 일반), 2막이 붙어 최종 지역도 아니다. + description = "잊혀진 문명의 유적 — 고대의 곤충들이 무너진 돌 틈에 잠들어 있습니다.", themeColor = new Color(0.4f, 0.35f, 0.3f), centerPosition = new Vector3(0f, 0f, 140f), radius = 45f, @@ -368,7 +396,11 @@ public static RegionData[] CreateAll() // 확장 64종 — 고대 유적 신규 11종 (U1/R4/E3/L3) "cricket_tomb", "scarab_relic", "mantis_obsidian", "spider_tomb", "centipede_ruin", "jewel_beetle_azure", "moth_shadow", "wasp_gold", - "scarab_pharaoh", "butterfly_midnight", "hornet_emperor" + "scarab_pharaoh", "butterfly_midnight", "hornet_emperor", + // 등급 보충 — 일반 4 · 고급 2. 유적 종이 전부 밤·돌 틈이라 거기 맞췄다: + // 돌 밑 집게벌레·공벌레, 돌무더기 귀뚜라미, 울타리 그늘 밤나방 / 왕지네, 처마 밑 모래의 개미귀신 + "earwig_common", "moth_brown", "cricket_stone", "pill_bug_rock", + "centipede_common", "antlion_pit" }, // 1막에는 수문장이 없어 유적이 종착지였다. 2막을 열려면 여기 고리가 필요하다 — // RegionManager.DefeatGuardian이 유일한 리전 해금 경로이기 때문이다. @@ -399,7 +431,7 @@ public static RegionData[] CreateAll() { subAreaId = "ruins_underground", displayName = "유적 지하", - description = "유적 아래 봉인된 지하 — 고대 곤충이 잠들어 있습니다.", + description = "신전 아래 지하 — 이름 벽이 이어지는 곳입니다.", centerPosition = new Vector3(-10f, 0f, 135f), radius = 10f, exclusiveInsectIds = new[] { "beetle_hercules", "leaf_insect_phantom" }, @@ -437,7 +469,10 @@ public static RegionData[] CreateAll() "cricket_hush", "moth_ashen", "beetle_husk", "spider_threadbare", "mantis_hollow", "moth_forgotten", "moth_brown", "aphid_colony", "beetle_dung", "earwig_common", - "pill_bug_garden", "stick_insect_long", "mantis_dead_leaf", "moth_shadow" + "pill_bug_garden", "stick_insect_long", "mantis_dead_leaf", "moth_shadow", + // 등급 보충도 재활용 원칙 그대로 — 초원에서 마른 땅의 개미귀신(고급)·죽은 척하는 + // 방아벌레(희귀), 이름 없는 자리와 함께 쓰는 그믐황후나비(전설) + "antlion_pit", "beetle_click", "butterfly_midnight" }, guardianInsectId = "mantis_hollow", guardianDisplayName = "텅 빈 들의 사마귀", @@ -486,7 +521,9 @@ public static RegionData[] CreateAll() "bee_digger", "antlion_dune", "grasshopper_locust", "spider_camel", "scarab_sand", "wasp_hawk", "centipede_sand", "hornet_dune", - "grasshopper_rock", "pill_bug_rock" + "grasshopper_rock", "pill_bug_rock", + // 등급 보충 — 전설 1(모래에 묻힌 기록 곁의 파라오풍뎅이, 모래풍뎅이의 윗대) + "scarab_pharaoh" }, guardianInsectId = "hornet_dune", guardianDisplayName = "모래언덕의 장수말벌", @@ -535,7 +572,9 @@ public static RegionData[] CreateAll() "spider_frost", "stag_beetle_glacier", "butterfly_snowveil", "mantis_icicle", "moth_aurora", "beetle_hoarfrost", "katydid_snowfield", "bee_glacier", - "centipede_frost", "butterfly_apollo" + "centipede_frost", "butterfly_apollo", + // 등급 보충 — 전설 1(얼음이 시간을 붙든 땅의 태고매미. 아폴로모시나비처럼 산에서 빌려 왔다) + "cicada_ancient" }, guardianInsectId = "moth_aurora", guardianDisplayName = "서릿길의 오로라나방", @@ -573,7 +612,7 @@ public static RegionData[] CreateAll() { regionId = "emberfall", displayName = "잿불 골짜기", - description = "재가 식지 않는 골짜기 — 기록이 통째로 불타 빈칸이 가장 두껍게 겹친 곳입니다.", + description = "재가 식지 않는 골짜기 — 이름이 불타 빈칸이 가장 많은 곳입니다.", themeColor = new Color(0.62f, 0.28f, 0.22f), // (120,255)에서 (128,262)로 옮겼다 — 옛 위치는 사슬상 이웃도 아닌 hollow와 // 0.8m 겹쳤다(거리 92.2 < 반경합 93). 겹치면 RegionManager.ContainsPoint가 @@ -588,7 +627,9 @@ public static RegionData[] CreateAll() "wasp_ash", "cicada_ember", "beetle_longhorn_char", "mantis_ember", "hornet_magma", "pill_bug_cinder", "cricket_slag", "moth_shadow", - "beetle_scorch", "moth_smoulder" + "beetle_scorch", "moth_smoulder", + // 등급 보충 — 전설 1(용암말벌·잿말벌 위의 황제말벌. 그림자나방처럼 유적에서 빌려 왔다) + "hornet_emperor" }, guardianInsectId = "hornet_magma", guardianDisplayName = "잿불 골짜기의 용암말벌", @@ -687,7 +728,10 @@ public static RegionData[] CreateAll() "centipede_pale", "mantis_blank", "butterfly_erased", "moth_effaced", "mantis_unnamed", "mantis_hollow", "moth_forgotten", "spider_threadbare", - "moth_shadow", "butterfly_midnight" + "moth_shadow", "butterfly_midnight", + // 등급 보충 — 텅 빈 들에서 넘어온 잿빛나방(일반)·껍질만 남은 딱정벌레(고급). + // 전설 칸은 무명(fin_seal의 상대) 확률을 지키려 그대로 둔다 + "moth_ashen", "beetle_husk" }, guardianInsectId = "mantis_unnamed", guardianDisplayName = "이름 없는 사마귀", @@ -710,7 +754,7 @@ public static RegionData[] CreateAll() { subAreaId = "nameless_core", displayName = "빈칸", - description = "아무것도 새겨지지 않은 자리 — 그것이 서려던 곳입니다.", + description = "울타리 끝의 빈칸 — 그림자가 숨은 곳입니다.", centerPosition = new Vector3(-68f, 0f, 214f), radius = 10f, exclusiveInsectIds = new[] { "mantis_unnamed", "mantis_blank" }, diff --git a/Assets/Scripts/Core/RegionDressingBuilder.cs b/Assets/Scripts/Core/RegionDressingBuilder.cs new file mode 100644 index 0000000..fe879e6 --- /dev/null +++ b/Assets/Scripts/Core/RegionDressingBuilder.cs @@ -0,0 +1,1591 @@ +using System.Collections.Generic; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 리전 표면 장식 — 바닥 얼룩, 발밑 디테일(풀·꽃·조약돌·낙엽·눈덩이·재…), 물(호수·웅덩이), + /// 그리고 울타리 바깥 테두리(사구·설원 둑·숲 가장자리·암벽). + /// + /// 왜 필요한가: 게임 카메라는 (0,9,-6) 고각이라 화면의 대부분이 발밑 15×10m다. 그런데 그 자리가 + /// 단색 바닥 + 길 하나뿐이었다(배치 캡처). 큰 소품을 더 세우면 캐릭터를 가리므로(숲 나무를 관목 키로 + /// 줄인 이력이 있다) 여기선 무릎 아래 디테일과 밖에 두르는 테두리로 채운다. + /// + /// 규칙: + /// + /// 콜라이더 없음. 전부 장식이다. 플레이어 접지는 올라가기만 하고 내려오지 않으므로 + /// (PlayerMovement의 Max) 밟히는 콜라이더를 두면 공중에 뜬다. + /// 길·마을·전초기지·물·제단을 피한다 — 필드 도로(), 리전 안 길 + /// (), 본 마을, 전초기지, 연못 강, 수문장 제단. + /// 남쪽 테두리는 낮게. 카메라가 늘 플레이어 남쪽 위에 있어서, 남쪽 울타리 밖의 큰 나무는 + /// 가장자리에 선 플레이어를 가린다(카메라 차폐 검사는 콜라이더만 본다). 북쪽이 카메라가 보는 배경이다. + /// 칸 × 색으로 합친다() — 풀포기 수천 개가 드로우콜 수십 번. + /// 배치는 시드 고정(리전마다 따로 — ), 전역 난수 상태는 끝나면 되돌린다. + /// + /// + /// 마을·전초기지·수문장 제단이 BuildSystems에서 지어지므로(지형보다 뒤) 실제 빌드는 Start에서 한다. + /// 리전 루트 이름이 Scenery_로 시작하는 건 서브에리어 진입 때 HideMainWorld가 접두어로 끄기 때문이다. + /// 그 아래 칸 오브젝트에는 접두어를 달지 않는다() — 루트만 꺼도 따라 꺼진다. + /// + public class RegionDressingBuilder : MonoBehaviour + { + private const int LayoutSeed = 20260929; + private const float CellSize = 32f; + private const float FloorY = FieldGround.FloorY; + + private RegionData[] regions; + private RegionTerrainBuilder terrain; + private bool built; + + private readonly List ownedMeshes = new List(); + private readonly List ownedMaterials = new List(); + private readonly List ownedTextures = new List(); + // 색 + 표면(발광·광택) 키 — 리전 13곳의 flat·rim 배처가 전부 이 사전 하나를 넘겨 같은 표면끼리 머티리얼을 나눠 쓴다 + private readonly Dictionary materialCache = new Dictionary(); + private readonly SceneryPalette palette = new SceneryPalette(); + + /// + /// 방향 있는 사각형 회피 — 물길(연못 강)·부두 널판용. 원으로 덮으면 26×5m 띠의 옆이 크게 부풀고, 도로처럼 + /// 선분()으로 두면 avoidPaths: false 소품(조약돌·얼룩)이 물 위에 앉는다. + /// + private struct KeepStrip + { + public Vector3 center; + public Vector3 axis; // 길이 방향(XZ 단위 벡터) + public float halfLength; + public float halfWidth; + } + + // 회피 — XZ 선분(반폭 포함), 원, 방향 있는 사각형, 불규칙 원반(호수 윤곽 × 배율) + private readonly List laneA = new List(); + private readonly List laneB = new List(); + private readonly List laneHalf = new List(); + private readonly List keepOut = new List(); // xz + 반경(w) + private readonly List strips = new List(); + private readonly List<(DiscPlacement disc, float fraction)> discKeepOut = new List<(DiscPlacement disc, float fraction)>(); + // 지금 짓는 리전 근처의 것만 — 소품 수만 개 × 전 월드 도로 선분을 매번 훑지 않게 + private readonly List nearLanes = new List(); + private readonly List nearKeep = new List(); + + // 자체 메시(파괴 대상) — ProcMeshLibrary 것은 프로세스 캐시라 파괴하지 않는다 + private Mesh tuftMesh, fernMesh, patchMesh, lakeMesh, coneMesh, bladeMesh, stalkMesh, bloomMesh; + private Mesh sphereMesh, pebbleMesh, blockMesh, stemMesh, leafMesh; + + public void Configure(RegionData[] regionDefs, RegionTerrainBuilder regionTerrain) + { + regions = regionDefs; + terrain = regionTerrain; + } + + // 물 기록은 정적이라 씬을 넘어 남는다 — 새 씬의 곤충 스포너가 옛 씬의 기록을 "다 됐다"로 읽지 않게, 어느 Start보다 + // 먼저 도는 여기서 비운다(부트스트랩이 Awake에서 이 컴포넌트를 붙인다). 물 판정 절 참조. + private void Awake() + { + ClearWater(); + } + + private void Start() + { + Build(); + } + + public void Build() + { + if (built || regions == null) return; + built = true; + + Random.State prev = Random.state; + Random.InitState(LayoutSeed); + try + { + ClearWater(); + CreateMeshes(); + CollectAvoidance(); + foreach (RegionData r in regions) + { + if (r == null) continue; + BuildRegion(r); + } + // 난수를 쓰지 않는다 — 지어진 물을 읽기만 한다. 끝까지 지었을 때만 "기록 완료"로 둔다: 도중에 예외가 나면 + // 호수를 못 적었을 수 있어, 기록 전 판정(순수 윤곽의 호수)이 반쪽 기록보다 낫다. + RecordSceneWater(); + waterRecorded = true; + } + finally + { + Random.state = prev; + // 빌드 도중 예외가 나도 캐시는 비운다 — 원본 배열 캐시가 이 빌더의 메시(파괴 대상)를 키로 쥔 채 + // 남으면 다음 씬에서 인스턴스 ID가 재사용될 때 엉뚱한 배열이 나온다(ClearSourceCache 주석). + palette.Apply(); // 마지막 리전까지 모인 색을 텍스처에 올린다 + SceneryBatcher.ClearSourceCache(); + } + + // 모바일 예산 확인용 — 합친 메시 수와 정점 총량(정점당 32바이트, CPU 사본은 버렸다) + long verts = 0; + foreach (Mesh m in ownedMeshes) if (m != null) verts += m.vertexCount; + Debug.Log($"[RegionDressingBuilder] 메시 {ownedMeshes.Count}개 · 정점 {verts:N0} · 머티리얼 {ownedMaterials.Count}개"); + // 2026-09-28 실측: 색별 머티리얼·캡슐 줄기 시절엔 메시 3,407개 · 정점 101만 · 머티리얼 201개였다 → 팔레트 텍스처로 교체 + } + + // ======= 리전 ======= + + /// + /// 리전마다 난수 줄기를 새로 시작한다. 한 줄기로 이어 뽑으면 앞 리전에서 호출 수가 하나만 바뀌어도 + /// (회피 원을 하나 더해 가 한 번 더 구르기만 해도) 뒤 리전의 배치가 통째로 밀려, + /// 연못 하나를 고쳤는데 모래언덕 캡처까지 달라진다. string.GetHashCode는 런타임마다 달라질 수 있어 직접 섞는다. + /// + private static int RegionSeed(string regionId) + { + unchecked + { + int h = LayoutSeed; + for (int i = 0; i < regionId.Length; i++) h = h * 31 + regionId[i]; + return h; + } + } + + private void BuildRegion(RegionData r) + { + Random.InitState(RegionSeed(r.regionId)); // Build의 finally가 전역 상태를 되돌린다 + SelectNearby(r); + var root = new GameObject($"Scenery_Dressing_{r.regionId}").transform; + root.SetParent(transform, false); + + // 울타리 안(바닥 얼룩 + 발밑 소품)은 배처 하나다 — 둘 다 그림자를 안 던진다(모바일). 울타리 밖만 그림자를 던진다. + // 예전엔 같은 배처를 ground·detail 두 이름으로 넘겨서 발광·광택 표시(MarkGlow/MarkWet)가 한쪽에만 걸리는 것처럼 + // 읽혔다 — 실제로는 한 객체라 어느 이름으로 걸어도 안쪽 전부에 걸린다. 이름을 하나(b)로 합쳤다. + var flat = new SceneryBatcher(CellSize, palette); + var rim = new SceneryBatcher(CellSize, palette); + + RegionPalette.PatchTones(r.regionId, r.themeColor, out Color light, out Color dark); + Patches(r, flat, light, dark); + + switch (r.regionId) + { + case "meadow": Meadow(r, flat, rim); break; + case "pond": Pond(r, flat, rim); break; + case "forest": Forest(r, flat, rim); break; + case "swamp": Swamp(r, flat, rim); break; + case "mountain": Mountain(r, flat, rim); break; + case "garden": Garden(r, flat, rim); break; + case "ruins": Ruins(r, flat, rim); break; + case "hollow": Hollow(r, flat, rim); break; + case "dunes": Dunes(r, flat, rim); break; + case "frostline": Frostline(r, flat, rim); break; + case "emberfall": Emberfall(r, flat, rim); break; + case "canopy": Canopy(r, flat, rim); break; + case "nameless": Nameless(r, flat, rim); break; + } + + // 칸 이름엔 Scenery_를 안 단다 — HideMainWorld가 위 root 하나만 끄면 된다(SceneryBatcher.Build 주석) + flat.Build(root, $"Detail_{r.regionId}", false, ownedMeshes, ownedMaterials, ownedTextures, materialCache); + rim.Build(root, $"Rim_{r.regionId}", true, ownedMeshes, ownedMaterials, ownedTextures, materialCache); + } + + /// 바닥 얼룩 — 밝은·짙은 두 톤의 불규칙 원반. 단색 판 느낌을 깬다. + private void Patches(RegionData r, SceneryBatcher b, Color light, Color dark) + { + int n = Scale(34, r); + for (int i = 0; i < n; i++) + { + if (!TryInside(r, 0.92f, 1.5f, out Vector3 p, avoidPaths: false)) continue; + float s = Random.Range(3f, 8.5f); + b.Add(patchMesh, p + Vector3.up * (0.004f + (i % 4) * 0.002f), Yaw(), + new Vector3(s * Random.Range(0.8f, 1.5f), 1f, s), i % 2 == 0 ? light : dark); + } + } + + // ---------------- 1막 ---------------- + + private void Meadow(RegionData r, SceneryBatcher b, SceneryBatcher rim) + { + Tufts(r, b, Scale(420, r), 0.34f, 0.6f, 0.9f, C(0.34f, 0.58f, 0.24f), C(0.44f, 0.66f, 0.28f), C(0.52f, 0.64f, 0.26f)); + Flowers(r, b, Scale(150, r), C(0.98f, 0.86f, 0.30f), C(0.98f, 0.98f, 0.94f), C(0.95f, 0.50f, 0.62f), C(0.62f, 0.52f, 0.92f)); + Pebbles(r, b, Scale(60, r), 0.12f, 0.3f, C(0.62f, 0.60f, 0.55f), C(0.52f, 0.50f, 0.46f)); + Clover(r, b, Scale(40, r), C(0.30f, 0.52f, 0.22f)); + RimBushes(r, rim, 0.9f, C(0.30f, 0.50f, 0.22f), C(0.36f, 0.56f, 0.26f)); + RimTrees(r, rim, 0.35f, false, C(0.40f, 0.28f, 0.17f), C(0.28f, 0.50f, 0.22f), C(0.34f, 0.56f, 0.25f)); + } + + private void Pond(RegionData r, SceneryBatcher b, SceneryBatcher rim) + { + // 호수 — 옛날엔 리전 바닥 전체가 파란색이라 "물"을 흉내 냈다. 바닥을 물가 풀밭으로 바꾸고 진흙 + 여울 + 깊은 물 + // 세 겹 원반을 깐다. 윤곽·자리는 PondLake가 단일 출처다(부두·NPC·입구와의 여유가 거기 적혀 있다). + PondLakeLayout lake = PondLake(r.centerPosition, r.radius); + Color shallow = C(0.42f, 0.64f, 0.66f), deep = C(0.22f, 0.46f, 0.62f), mud = C(0.42f, 0.38f, 0.28f); + b.MarkWet(shallow); + b.MarkWet(deep); + b.Add(lakeMesh, lake.mud.center + Vector3.up * 0.014f, lake.mud.Rotation, lake.mud.Scale, mud); + b.Add(lakeMesh, lake.water.center + Vector3.up * 0.02f, lake.water.Rotation, lake.water.Scale, shallow); + b.Add(lakeMesh, lake.deep.center + Vector3.up * 0.026f, lake.deep.Rotation, lake.deep.Scale, deep); + AddWaterDisc(lake.water); // 깊은 물은 여울 안쪽 2m 이상에 머문다(PondLake 주석) — 여울 윤곽이 곧 물가다 + + // 나루터 오두막(VillageBuilder.BuildPierHut) 밑의 물가 — 옛날엔 리전 전체가 파란 바닥이라 말뚝 오두막과 + // 부두 널판이 물 위에 선 것처럼 보였다. 바닥이 풀밭이 되자 부두가 맨땅으로 뻗었다. 오두막(로컬 z -2.5)과 + // 널판(x -2.6) 쪽에 작은 물가를 깐다 — 모닥불(0,2.5)·주민 자리(1.5,3.2)는 비껴간다. + // 큰 호수와는 진흙끼리 6.4m 떨어져 있어 두 물이 이어 붙은 이상한 모양이 되지 않는다(PondLake 주석). + GameObject pier = GameObject.Find("Outpost_pond"); + if (pier != null) + { + Vector3 cove = Floor(pier.transform.TransformPoint(new Vector3(-3.2f, 0f, -2.5f))); + Quaternion yaw = pier.transform.rotation; + b.Add(patchMesh, cove + Vector3.up * 0.014f, yaw, new Vector3(10.8f, 1f, 9.6f), mud); + b.Add(patchMesh, cove + Vector3.up * 0.02f, yaw * Quaternion.Euler(0f, 30f, 0f), new Vector3(9.4f, 1f, 8.4f), shallow); + b.Add(patchMesh, cove + Vector3.up * 0.026f, yaw * Quaternion.Euler(0f, 75f, 0f), new Vector3(7.6f, 1f, 6.8f), deep); + // 물은 여울·깊은 물 두 장 — 요가 달라(30°·75°) 깊은 물이 여울 밖으로 삐져나오는 방향이 있다. 진흙은 뭍이다. + // 인자는 위 b.Add와 글자 그대로 같아야 한다(FieldGroundPlacementTests가 소스에서 대조한다). + RecordWaterPatch(cove, yaw * Quaternion.Euler(0f, 30f, 0f), new Vector3(9.4f, 1f, 8.4f)); + RecordWaterPatch(cove, yaw * Quaternion.Euler(0f, 75f, 0f), new Vector3(7.6f, 1f, 6.8f)); + for (int i = 0; i < 18; i++) + { + Vector3 q = cove + Polar(i * 20f + 10f, Random.Range(4.3f, 5.2f)); + if (Vector3.Distance(q, Floor(pier.transform.TransformPoint(new Vector3(0f, 0f, 2.5f)))) < 3f) continue; + b.Add(bladeMesh, q, Yaw(), new Vector3(0.5f, Random.Range(0.9f, 1.5f), 0.5f), C(0.40f, 0.54f, 0.24f)); + } + } + + // 부두 널판(RegionTerrainBuilder)은 콜라이더가 없어 레이캐스트로 못 가린다 — 자리를 직접 받아 갈대·풀이 + // 널판을 뚫고 솟지 않게 막는다(널판 윗면 0.28m, 풀포기는 0.9m까지 솟는다). + RegionTerrainBuilder.PondDockFootprint(r.centerPosition, r.radius, out Vector3 dock, out Vector2 dockSize); + strips.Add(new KeepStrip + { + center = dock, + axis = Vector3.forward, + halfLength = dockSize.y * 0.5f + 0.2f, + halfWidth = dockSize.x * 0.5f + 0.2f, + }); + + // 수련잎 — 수면 위. 물 윤곽을 각도마다 정확히 재서(PatchEdgeRadius) 가장자리의 88% 안쪽에만 띄운다 — + // 가장 좁은 쪽(짧은 축 × 가장 들어간 요철, 물가까지 약 13.5m)에서도 잎 가장자리가 물가에서 1m 넘게 안쪽이다. + for (int i = 0; i < Scale(26, r); i++) + { + Vector3 p = lake.water.EdgePoint(Random.Range(0f, Mathf.PI * 2f), 0.88f * Mathf.Sqrt(Random.Range(0.03f, 1f))); + float s = Random.Range(0.5f, 1.1f); + b.Add(patchMesh, Floor(p) + Vector3.up * 0.04f, Yaw(), new Vector3(s, 1f, s), i % 3 == 0 ? C(0.34f, 0.60f, 0.26f) : C(0.26f, 0.52f, 0.22f)); + if (i % 4 == 0) + b.Add(bloomMesh, Floor(p) + Vector3.up * 0.06f, Quaternion.identity, new Vector3(0.3f, 1f, 0.3f), C(0.98f, 0.78f, 0.86f)); + } + // 물가 갈대 고리 — 물 가장자리의 94%(여울 안쪽 약 1m)부터 109%(진흙 띠 안, 진흙은 110%)까지. 옛 식은 원형 평균 반경을 + // 기준으로 삼아 요철이 들어간 쪽에선 풀밭에, 나온 쪽에선 물 한가운데에 섰다. 호수 회피는 이 고리를 놓은 뒤에 + // 건다(먼저 걸면 갈대가 제 호수에 걸려 버려진다). 도로·입구·부두 회피는 그대로 본다. + for (int i = 0; i < Scale(90, r); i++) + { + Vector3 p = lake.water.EdgePoint(Random.Range(0f, Mathf.PI * 2f), Random.Range(0.94f, 1.09f)); + if (Blocked(p, 0.4f)) continue; + float h = Random.Range(1.0f, 1.8f); + b.Add(bladeMesh, Floor(p), Yaw(), new Vector3(0.5f, h, 0.5f), i % 2 == 0 ? C(0.40f, 0.54f, 0.24f) : C(0.50f, 0.58f, 0.28f)); + if (i % 5 == 0) + b.Add(pebbleMesh, Floor(p) + Vector3.up * h, Quaternion.identity, new Vector3(0.09f, 0.3f, 0.09f), C(0.42f, 0.27f, 0.15f)); + } + // 풀·꽃·조약돌은 물에 안 들어가게 — 물 윤곽의 105%(진흙 띠의 안쪽 절반, 약 1m)까지 막는다. 진흙 바깥 절반엔 풀이 난다. + // 옛 원형 회피(여울 요철의 최대 반경 17.7m)는 요철이 들어간 쪽에서 진흙 끝과 풀 사이에 최대 3.7m 맨땅을 남겼다. + discKeepOut.Add((lake.water, LakeClearFraction)); + + Tufts(r, b, Scale(300, r), 0.34f, 0.6f, 1f, C(0.36f, 0.58f, 0.30f), C(0.44f, 0.64f, 0.32f)); + Flowers(r, b, Scale(50, r), C(0.64f, 0.70f, 0.98f), C(0.98f, 0.98f, 0.94f)); + Pebbles(r, b, Scale(50, r), 0.12f, 0.35f, C(0.56f, 0.56f, 0.52f), C(0.46f, 0.47f, 0.45f)); + RimReeds(r, rim, C(0.40f, 0.54f, 0.24f), C(0.50f, 0.58f, 0.30f)); + RimBushes(r, rim, 0.5f, C(0.28f, 0.48f, 0.24f), C(0.34f, 0.54f, 0.28f)); + RimTrees(r, rim, 0.25f, false, C(0.40f, 0.30f, 0.18f), C(0.30f, 0.52f, 0.30f), C(0.36f, 0.58f, 0.32f)); + } + + private void Forest(RegionData r, SceneryBatcher b, SceneryBatcher rim) + { + Leaves(r, b, Scale(700, r), C(0.52f, 0.36f, 0.18f), C(0.62f, 0.46f, 0.20f), C(0.40f, 0.44f, 0.18f), C(0.70f, 0.38f, 0.16f)); + Ferns(r, b, Scale(130, r), C(0.22f, 0.46f, 0.18f), C(0.28f, 0.52f, 0.20f)); + Tufts(r, b, Scale(220, r), 0.3f, 0.5f, 0.8f, C(0.26f, 0.44f, 0.18f), C(0.32f, 0.50f, 0.20f)); + Mushrooms(r, b, Scale(45, r), C(0.80f, 0.24f, 0.18f), C(0.72f, 0.56f, 0.36f), C(0.92f, 0.86f, 0.74f)); + Twigs(r, b, Scale(80, r), C(0.36f, 0.26f, 0.16f)); + Clover(r, b, Scale(45, r), C(0.24f, 0.44f, 0.20f)); // 이끼 + RimTrees(r, rim, 1.6f, true, C(0.34f, 0.24f, 0.15f), C(0.16f, 0.36f, 0.18f), C(0.20f, 0.42f, 0.20f)); + RimTrees(r, rim, 0.8f, false, C(0.38f, 0.27f, 0.16f), C(0.22f, 0.44f, 0.18f), C(0.28f, 0.50f, 0.20f)); + RimBushes(r, rim, 0.7f, C(0.20f, 0.40f, 0.18f), C(0.26f, 0.46f, 0.20f)); + } + + private void Swamp(RegionData r, SceneryBatcher b, SceneryBatcher rim) + { + // 탁한 웅덩이 — 광택으로 젖은 느낌 + Color pool = C(0.20f, 0.25f, 0.19f), poolEdge = C(0.27f, 0.25f, 0.18f); + b.MarkWet(pool); + for (int i = 0; i < Scale(12, r); i++) + { + if (!TryInside(r, 0.85f, 5f, out Vector3 p, avoidPaths: true)) continue; + float s = Random.Range(3f, 6.5f); + Quaternion y = Yaw(); + b.Add(patchMesh, p + Vector3.up * 0.014f, y, new Vector3(s * 1.25f, 1f, s * 1.1f), poolEdge); + b.Add(patchMesh, p + Vector3.up * 0.02f, y, new Vector3(s, 1f, s * 0.85f), pool); + RecordWaterPatch(p, y, new Vector3(s, 1f, s * 0.85f)); // 위 b.Add와 같은 인자 — 가장자리(poolEdge)는 진흙이라 뭍 + for (int k = 0; k < 6; k++) + { + Vector3 q = p + Polar(Random.Range(0f, 360f), s * 0.55f * Random.Range(0.8f, 1.1f)); + b.Add(bladeMesh, Floor(q), Yaw(), new Vector3(0.45f, Random.Range(0.8f, 1.4f), 0.45f), C(0.34f, 0.42f, 0.20f)); + } + } + Tufts(r, b, Scale(320, r), 0.32f, 0.55f, 1.1f, C(0.30f, 0.40f, 0.20f), C(0.36f, 0.44f, 0.22f), C(0.40f, 0.42f, 0.24f)); + Mushrooms(r, b, Scale(40, r), C(0.52f, 0.30f, 0.62f), C(0.44f, 0.52f, 0.30f), C(0.80f, 0.78f, 0.66f)); + Clover(r, b, Scale(50, r), C(0.26f, 0.34f, 0.18f)); + Twigs(r, b, Scale(60, r), C(0.28f, 0.24f, 0.18f)); + RimReeds(r, rim, C(0.36f, 0.44f, 0.22f), C(0.44f, 0.46f, 0.26f)); + RimDeadTrees(r, rim, 0.6f, C(0.26f, 0.23f, 0.19f)); + RimBushes(r, rim, 0.6f, C(0.22f, 0.30f, 0.17f), C(0.28f, 0.34f, 0.19f)); + } + + private void Mountain(RegionData r, SceneryBatcher b, SceneryBatcher rim) + { + Pebbles(r, b, Scale(260, r), 0.12f, 0.45f, C(0.58f, 0.57f, 0.54f), C(0.48f, 0.47f, 0.45f), C(0.66f, 0.64f, 0.60f)); + Tufts(r, b, Scale(180, r), 0.3f, 0.5f, 0.7f, C(0.52f, 0.56f, 0.28f), C(0.46f, 0.52f, 0.26f)); + Flowers(r, b, Scale(40, r), C(0.98f, 0.98f, 0.94f), C(0.70f, 0.60f, 0.95f)); // 에델바이스·용담 + SnowPatches(r, b, Scale(10, r), C(0.93f, 0.95f, 0.97f)); + RimRocks(r, rim, 1.2f, C(0.50f, 0.49f, 0.46f), C(0.42f, 0.41f, 0.39f), 3.2f); + RimTrees(r, rim, 0.5f, true, C(0.32f, 0.24f, 0.16f), C(0.18f, 0.34f, 0.22f), C(0.22f, 0.40f, 0.24f)); + } + + private void Garden(RegionData r, SceneryBatcher b, SceneryBatcher rim) + { + // 꽃밭 — 분홍 바닥 대신 꽃 자체가 색을 낸다. 무리 지어 심어 화단처럼 + Color[] beds = { C(0.98f, 0.52f, 0.66f), C(0.98f, 0.84f, 0.30f), C(0.96f, 0.96f, 0.96f), C(0.72f, 0.52f, 0.96f), C(0.98f, 0.40f, 0.34f) }; + for (int i = 0; i < Scale(26, r); i++) + { + if (!TryInside(r, 0.88f, 3f, out Vector3 center, avoidPaths: true)) continue; + Color col = beds[i % beds.Length]; + float spread = Random.Range(1.6f, 3.2f); + for (int k = 0; k < 26; k++) + { + Vector3 p = center + Polar(Random.Range(0f, 360f), spread * Mathf.Sqrt(Random.value)); + if (Blocked(p, 0.2f)) continue; + Flower(b, p, col, Random.Range(0.28f, 0.5f)); + } + } + Flowers(r, b, Scale(200, r), beds); + Tufts(r, b, Scale(300, r), 0.3f, 0.5f, 0.8f, C(0.40f, 0.64f, 0.30f), C(0.48f, 0.70f, 0.32f)); + Petals(r, b, Scale(120, r), C(0.98f, 0.70f, 0.80f), C(0.98f, 0.92f, 0.94f)); + RimHedges(r, rim, C(0.26f, 0.50f, 0.24f), beds); + RimTrees(r, rim, 0.45f, false, C(0.44f, 0.32f, 0.22f), C(0.96f, 0.72f, 0.80f), C(0.98f, 0.82f, 0.86f)); // 꽃나무 + } + + private void Ruins(RegionData r, SceneryBatcher b, SceneryBatcher rim) + { + Rubble(r, b, Scale(150, r), C(0.64f, 0.60f, 0.52f), C(0.56f, 0.52f, 0.45f)); + Tufts(r, b, Scale(240, r), 0.3f, 0.5f, 0.8f, C(0.44f, 0.50f, 0.28f), C(0.50f, 0.54f, 0.30f)); + Clover(r, b, Scale(40, r), C(0.40f, 0.48f, 0.28f)); // 이끼 낀 판석 틈 + Tiles(r, b, Scale(40, r), C(0.62f, 0.58f, 0.50f)); + RimWalls(r, rim, C(0.60f, 0.56f, 0.48f), C(0.52f, 0.48f, 0.41f)); + RimBushes(r, rim, 0.5f, C(0.34f, 0.44f, 0.24f), C(0.40f, 0.48f, 0.26f)); + } + + // ---------------- 2막 ---------------- + + private void Hollow(RegionData r, SceneryBatcher b, SceneryBatcher rim) + { + // 곤충이 떠난 들 — 색이 빠진 풀, 갈라진 흙. 꽃은 없다 + Tufts(r, b, Scale(300, r), 0.32f, 0.55f, 1.1f, C(0.70f, 0.66f, 0.50f), C(0.62f, 0.58f, 0.44f), C(0.76f, 0.72f, 0.58f)); + Pebbles(r, b, Scale(70, r), 0.12f, 0.3f, C(0.72f, 0.70f, 0.64f), C(0.62f, 0.60f, 0.55f)); + Cracks(r, b, Scale(60, r), C(0.46f, 0.44f, 0.36f)); + Twigs(r, b, Scale(50, r), C(0.56f, 0.52f, 0.44f)); + RimMounds(r, rim, 0.9f, C(0.64f, 0.62f, 0.52f), C(0.58f, 0.56f, 0.47f), 1.6f); + RimDeadTrees(r, rim, 0.45f, C(0.56f, 0.53f, 0.46f)); + } + + private void Dunes(RegionData r, SceneryBatcher b, SceneryBatcher rim) + { + // 모래 물결 — 같은 방향으로 눕힌 가늘고 긴 둔덕. 사구 능선(RegionTerrainBuilder)과 같은 바람 결(35°) + Color crest = C(0.93f, 0.82f, 0.60f), trough = C(0.80f, 0.66f, 0.45f); + for (int i = 0; i < Scale(160, r); i++) + { + if (!TryInside(r, 0.92f, 1.5f, out Vector3 p, avoidPaths: true)) continue; + float len = Random.Range(2.5f, 6f); + // 납작한 줄무늬 — 저정점 구를 길게 늘이면 끝이 뾰족하게 반짝이는 바늘이 된다(실제로 그랬다) + b.Add(patchMesh, p + Vector3.up * 0.011f, Quaternion.Euler(0f, 35f + Random.Range(-8f, 8f), 0f), + new Vector3(len, 1f, 0.5f), i % 3 == 0 ? trough : crest); + } + Shrubs(r, b, Scale(70, r), C(0.52f, 0.44f, 0.28f), C(0.44f, 0.46f, 0.30f)); + Pebbles(r, b, Scale(60, r), 0.15f, 0.5f, C(0.72f, 0.56f, 0.40f), C(0.62f, 0.48f, 0.34f)); + RimMounds(r, rim, 1.2f, C(0.90f, 0.77f, 0.54f), C(0.84f, 0.70f, 0.48f), 3.4f); + RimRocks(r, rim, 0.4f, C(0.74f, 0.56f, 0.38f), C(0.66f, 0.50f, 0.34f), 2.6f); + } + + private void Frostline(RegionData r, SceneryBatcher b, SceneryBatcher rim) + { + Color snow = C(0.95f, 0.97f, 0.99f), snowShade = C(0.84f, 0.89f, 0.95f), ice = C(0.66f, 0.84f, 0.95f); + // 얼음은 속에서 빛나는 듯 — 그늘에서도 푸르게. 발광 표시는 배처마다 따로라 울타리 밖 얼음 가시(RimIce)에도 + // 건다 — 안쪽에만 걸면 같은 ice 색이 테두리에선 팔레트(무광)로 떨어져 울타리 안팎 얼음이 다른 재질로 보인다. + Color iceGlow = C(0.10f, 0.16f, 0.22f); + b.MarkGlow(ice, iceGlow); + rim.MarkGlow(ice, iceGlow); + for (int i = 0; i < Scale(200, r); i++) + { + if (!TryInside(r, 0.92f, 1f, out Vector3 p, avoidPaths: true)) continue; + float s = Random.Range(0.25f, 0.8f); + b.Add(pebbleMesh, Grounded(p), Yaw(), new Vector3(s * 1.4f, s * 0.45f, s), i % 3 == 0 ? snowShade : snow); + } + for (int i = 0; i < Scale(55, r); i++) + { + if (!TryInside(r, 0.9f, 2f, out Vector3 p, avoidPaths: true)) continue; + int shards = Random.Range(2, 5); + for (int k = 0; k < shards; k++) + { + float h = Random.Range(0.35f, 0.9f); + b.Add(coneMesh, Grounded(p + Polar(Random.Range(0f, 360f), Random.Range(0f, 0.35f))), + Quaternion.Euler(Random.Range(-18f, 18f), Random.Range(0f, 360f), Random.Range(-18f, 18f)), + new Vector3(h * 0.35f, h, h * 0.35f), ice); + } + } + Tufts(r, b, Scale(120, r), 0.3f, 0.5f, 0.8f, C(0.72f, 0.80f, 0.84f), C(0.62f, 0.72f, 0.78f)); // 서리 앉은 풀 + Pebbles(r, b, Scale(50, r), 0.12f, 0.35f, C(0.56f, 0.62f, 0.68f), C(0.48f, 0.53f, 0.60f)); + RimMounds(r, rim, 1.1f, snow, snowShade, 2.8f); + RimIce(r, rim, ice); + RimTrees(r, rim, 0.55f, true, C(0.30f, 0.26f, 0.22f), C(0.26f, 0.40f, 0.36f), C(0.90f, 0.94f, 0.98f)); // 눈 얹힌 침엽수 + } + + private void Emberfall(RegionData r, SceneryBatcher b, SceneryBatcher rim) + { + Color ember = C(1f, 0.42f, 0.12f); + b.MarkGlow(ember, C(1.6f, 0.55f, 0.12f)); + Color crack = C(0.95f, 0.36f, 0.10f); + b.MarkGlow(crack, C(1.2f, 0.36f, 0.08f)); + + // 재 더미 얼룩 + 굳은 용암 틈(발광 선) + for (int i = 0; i < Scale(50, r); i++) + { + if (!TryInside(r, 0.9f, 2f, out Vector3 p, avoidPaths: true)) continue; + float s = Random.Range(1.5f, 4f); + b.Add(patchMesh, p + Vector3.up * 0.012f, Yaw(), new Vector3(s, 1f, s * 0.7f), i % 2 == 0 ? C(0.46f, 0.44f, 0.42f) : C(0.38f, 0.36f, 0.35f)); + } + for (int i = 0; i < Scale(40, r); i++) + { + if (!TryInside(r, 0.88f, 2f, out Vector3 p, avoidPaths: true)) continue; + Quaternion y = Yaw(); + int segs = Random.Range(2, 4); + for (int k = 0; k < segs; k++) + { + Vector3 q = p + y * new Vector3(0f, 0f, k * 0.9f); + b.Add(blockMesh, q + Vector3.up * 0.01f, y * Quaternion.Euler(0f, Random.Range(-25f, 25f), 0f), + new Vector3(0.1f, 0.03f, 1.1f), crack); + } + } + Pebbles(r, b, Scale(180, r), 0.12f, 0.5f, C(0.16f, 0.14f, 0.14f), C(0.24f, 0.21f, 0.20f)); + for (int i = 0; i < Scale(60, r); i++) + { + if (!TryInside(r, 0.9f, 1f, out Vector3 p, avoidPaths: true)) continue; + float s = Random.Range(0.08f, 0.16f); + b.Add(pebbleMesh, Grounded(p) + Vector3.up * s * 0.3f, Quaternion.identity, Vector3.one * s, ember); + } + Shrubs(r, b, Scale(40, r), C(0.20f, 0.17f, 0.15f), C(0.28f, 0.24f, 0.20f)); // 타 버린 덤불 + RimRocks(r, rim, 1.2f, C(0.20f, 0.18f, 0.18f), C(0.27f, 0.24f, 0.23f), 3f); + RimMounds(r, rim, 0.5f, C(0.44f, 0.42f, 0.40f), C(0.36f, 0.34f, 0.33f), 1.8f); // 재 둔덕 + RimDeadTrees(r, rim, 0.5f, C(0.12f, 0.11f, 0.11f)); + } + + private void Canopy(RegionData r, SceneryBatcher b, SceneryBatcher rim) + { + // 2막에서 유일하게 살아 있는 리전 — 밀도와 채도를 가장 높게 + Leaves(r, b, Scale(600, r), C(0.36f, 0.56f, 0.22f), C(0.56f, 0.62f, 0.24f), C(0.70f, 0.64f, 0.26f)); + Ferns(r, b, Scale(200, r), C(0.26f, 0.56f, 0.22f), C(0.34f, 0.62f, 0.26f)); + Tufts(r, b, Scale(260, r), 0.32f, 0.55f, 0.9f, C(0.32f, 0.56f, 0.26f), C(0.40f, 0.62f, 0.30f)); + Mushrooms(r, b, Scale(50, r), C(0.96f, 0.62f, 0.20f), C(0.86f, 0.30f, 0.30f), C(0.94f, 0.92f, 0.84f)); + Flowers(r, b, Scale(70, r), C(0.98f, 0.60f, 0.24f), C(0.96f, 0.36f, 0.52f), C(0.98f, 0.94f, 0.50f)); + Clover(r, b, Scale(60, r), C(0.26f, 0.48f, 0.24f)); + RimTrees(r, rim, 1.2f, false, C(0.36f, 0.26f, 0.16f), C(0.22f, 0.48f, 0.22f), C(0.30f, 0.56f, 0.26f)); + RimBushes(r, rim, 1.1f, C(0.24f, 0.50f, 0.22f), C(0.32f, 0.58f, 0.26f)); + } + + private void Nameless(RegionData r, SceneryBatcher b, SceneryBatcher rim) + { + // 채워지지 않은 자리 — 소품을 일부러 적게. 대신 빛바랜 반딧불 같은 점이 떠 있다 + Color mote = C(0.78f, 0.74f, 0.92f); + b.MarkGlow(mote, C(0.55f, 0.50f, 0.72f)); + Pebbles(r, b, Scale(80, r), 0.12f, 0.35f, C(0.60f, 0.59f, 0.62f), C(0.50f, 0.49f, 0.53f)); + Tufts(r, b, Scale(90, r), 0.28f, 0.45f, 0.8f, C(0.54f, 0.54f, 0.54f), C(0.60f, 0.59f, 0.60f)); + for (int i = 0; i < Scale(50, r); i++) + { + if (!TryInside(r, 0.9f, 1f, out Vector3 p, avoidPaths: false)) continue; + b.Add(pebbleMesh, Grounded(p) + Vector3.up * Random.Range(0.6f, 2.2f), Quaternion.identity, Vector3.one * Random.Range(0.06f, 0.11f), mote); + } + RimMonoliths(r, rim, C(0.26f, 0.25f, 0.30f), C(0.34f, 0.33f, 0.38f)); + } + + // ======= 발밑 소품 ======= + + /// + /// 밀도 배율. 처음 값(초원 945포기/15,300㎡ ≈ 0.06/㎡)으로는 게임 카메라가 보는 15×10m에 풀포기가 + /// 9개뿐이라 화면이 여전히 맨바닥이었다. 호출부의 개수는 상대 비율로 두고 여기서 한 번에 올린다. + /// + private const float TuftDensity = 2f, FlowerDensity = 2f, LitterDensity = 1.5f; + + private void Tufts(RegionData r, SceneryBatcher b, int count, float minS, float maxS, float height, params Color[] colors) + { + count = Mathf.RoundToInt(count * TuftDensity); + for (int i = 0; i < count; i++) + { + if (!TryInside(r, 0.93f, 0.5f, out Vector3 p, avoidPaths: true)) continue; + float s = Random.Range(minS, maxS) * 1.5f; + b.Add(tuftMesh, Grounded(p), Yaw(), new Vector3(s, s * 0.8f * height * Random.Range(0.8f, 1.25f), s), colors[i % colors.Length]); + } + } + + private void Ferns(RegionData r, SceneryBatcher b, int count, params Color[] colors) + { + for (int i = 0; i < count; i++) + { + if (!TryInside(r, 0.92f, 0.8f, out Vector3 p, avoidPaths: true)) continue; + float s = Random.Range(0.55f, 1.0f); + b.Add(fernMesh, Grounded(p), Yaw(), new Vector3(s, s * 0.7f, s), colors[i % colors.Length]); + } + } + + private void Flowers(RegionData r, SceneryBatcher b, int count, params Color[] heads) + { + count = Mathf.RoundToInt(count * FlowerDensity); + for (int i = 0; i < count; i++) + { + if (!TryInside(r, 0.92f, 0.4f, out Vector3 p, avoidPaths: true)) continue; + Flower(b, p, heads[i % heads.Length], Random.Range(0.25f, 0.45f)); + } + } + + private void Flower(SceneryBatcher b, Vector3 p, Color head, float h) + { + p = Grounded(p); // 꽃밭 화단(Garden)도 이 경로라 한 번에 둔덕 위로 오른다 + b.Add(stalkMesh, p, Quaternion.Euler(Random.Range(-8f, 8f), Random.Range(0f, 360f), Random.Range(-8f, 8f)), + new Vector3(0.6f, h, 0.6f), C(0.24f, 0.42f, 0.18f)); + // 꽃송이는 고각 카메라에서 점 하나라도 읽혀야 한다 — 13cm는 안 보였다 + b.Add(bloomMesh, p + Vector3.up * h, Quaternion.Euler(Random.Range(-15f, 15f), Random.Range(0f, 360f), 0f), new Vector3(0.24f, 1f, 0.24f), head); + } + + private void Pebbles(RegionData r, SceneryBatcher b, int count, float minS, float maxS, params Color[] colors) + { + count = Mathf.RoundToInt(count * LitterDensity); + for (int i = 0; i < count; i++) + { + if (!TryInside(r, 0.93f, 0.4f, out Vector3 p, avoidPaths: false)) continue; + float s = Random.Range(minS, maxS); + b.Add(pebbleMesh, Grounded(p) + Vector3.up * s * 0.12f, Yaw(), new Vector3(s * 1.3f, s * 0.55f, s), colors[i % colors.Length]); + } + } + + private void Leaves(RegionData r, SceneryBatcher b, int count, params Color[] colors) + { + count = Mathf.RoundToInt(count * LitterDensity); + for (int i = 0; i < count; i++) + { + if (!TryInside(r, 0.93f, 0.2f, out Vector3 p, avoidPaths: false)) continue; + float s = Random.Range(0.14f, 0.26f); + b.Add(leafMesh, p + Vector3.up * (0.006f + (i % 4) * 0.002f), Yaw(), new Vector3(s, 1f, s * 0.6f), colors[i % colors.Length]); + } + } + + private void Petals(RegionData r, SceneryBatcher b, int count, params Color[] colors) => Leaves(r, b, count, colors); + + private void Clover(RegionData r, SceneryBatcher b, int count, Color color) + { + for (int i = 0; i < count; i++) + { + if (!TryInside(r, 0.9f, 0.8f, out Vector3 p, avoidPaths: true)) continue; + float s = Random.Range(0.8f, 1.8f); + b.Add(patchMesh, p + Vector3.up * 0.009f, Yaw(), new Vector3(s, 1f, s * 0.8f), color); + } + } + + private void Cracks(RegionData r, SceneryBatcher b, int count, Color color) + { + for (int i = 0; i < count; i++) + { + if (!TryInside(r, 0.9f, 1f, out Vector3 p, avoidPaths: true)) continue; + Quaternion y = Yaw(); + for (int k = 0; k < 3; k++) + b.Add(blockMesh, p + y * new Vector3(0f, 0.01f, k * 0.7f), y * Quaternion.Euler(0f, Random.Range(-35f, 35f), 0f), + new Vector3(0.05f, 0.02f, 0.8f), color); + } + } + + private void Tiles(RegionData r, SceneryBatcher b, int count, Color color) + { + for (int i = 0; i < count; i++) + { + if (!TryInside(r, 0.88f, 1f, out Vector3 p, avoidPaths: false)) continue; + float s = Random.Range(0.6f, 1.2f); + b.Add(blockMesh, p + Vector3.up * 0.01f, Yaw(), new Vector3(s, 0.04f, s * Random.Range(0.6f, 1f)), color); + } + } + + private void SnowPatches(RegionData r, SceneryBatcher b, int count, Color color) + { + for (int i = 0; i < count; i++) + { + if (!TryInside(r, 0.9f, 2f, out Vector3 p, avoidPaths: true)) continue; + float s = Random.Range(1.2f, 3f); + b.Add(patchMesh, p + Vector3.up * 0.013f, Yaw(), new Vector3(s * 1.3f, 1f, s), color); + } + } + + private void Mushrooms(RegionData r, SceneryBatcher b, int count, params Color[] caps) + { + for (int i = 0; i < count; i++) + { + if (!TryInside(r, 0.9f, 0.6f, out Vector3 center, avoidPaths: true)) continue; + int cluster = Random.Range(1, 4); + for (int k = 0; k < cluster; k++) + { + Vector3 p = Grounded(center + Polar(Random.Range(0f, 360f), Random.Range(0f, 0.3f))); + float h = Random.Range(0.12f, 0.28f); + b.Add(stalkMesh, p, Yaw(), new Vector3(1.2f, h, 1.2f), C(0.92f, 0.88f, 0.78f)); + b.Add(pebbleMesh, p + Vector3.up * h, Quaternion.identity, new Vector3(h * 0.9f, h * 0.45f, h * 0.9f), caps[i % caps.Length]); + } + } + } + + private void Twigs(RegionData r, SceneryBatcher b, int count, Color color) + { + for (int i = 0; i < count; i++) + { + if (!TryInside(r, 0.9f, 0.4f, out Vector3 p, avoidPaths: false)) continue; + b.Add(blockMesh, Grounded(p) + Vector3.up * 0.03f, Quaternion.Euler(0f, Random.Range(0f, 180f), Random.Range(-6f, 6f)), + new Vector3(Random.Range(0.4f, 0.9f), 0.04f, 0.04f), color); + } + } + + private void Shrubs(RegionData r, SceneryBatcher b, int count, params Color[] colors) + { + for (int i = 0; i < count; i++) + { + if (!TryInside(r, 0.9f, 0.8f, out Vector3 p, avoidPaths: true)) continue; + p = Grounded(p); + int twigs = Random.Range(4, 7); + for (int k = 0; k < twigs; k++) + { + float h = Random.Range(0.3f, 0.7f); + b.Add(stalkMesh, p, + Quaternion.Euler(Random.Range(-30f, 30f), Random.Range(0f, 360f), Random.Range(-30f, 30f)), + new Vector3(0.8f, h, 0.8f), colors[k % colors.Length]); + } + } + } + + private void Rubble(RegionData r, SceneryBatcher b, int count, params Color[] colors) + { + for (int i = 0; i < count; i++) + { + if (!TryInside(r, 0.9f, 0.6f, out Vector3 p, avoidPaths: true)) continue; + float s = Random.Range(0.18f, 0.55f); + b.Add(blockMesh, Grounded(p) + Vector3.up * s * 0.3f, + Quaternion.Euler(Random.Range(-15f, 15f), Random.Range(0f, 360f), Random.Range(-15f, 15f)), + new Vector3(s * 1.3f, s * 0.7f, s), colors[i % colors.Length]); + } + } + + // ======= 울타리 밖 테두리 ======= + + /// + /// 울타리(R-1) 바깥 고리에서 자리를 뽑는다. 도로·통로는 피하고, 남쪽 반원에서는 로 + /// 키를 제한한다 — 카메라가 플레이어 남쪽 위에 있어서 남쪽의 큰 것은 가장자리의 플레이어를 가린다. + /// + private bool TryRim(RegionData r, float minOut, float maxOut, float footprint, out Vector3 p, out float heightCap) + { + for (int attempt = 0; attempt < 6; attempt++) + { + float a = Random.Range(0f, 360f); + float dist = r.radius + Random.Range(minOut, maxOut); + p = Floor(r.centerPosition + Polar(a, dist)); + if (Blocked(p, footprint + 1.5f)) continue; + if (InsideOtherRegion(r, p, footprint)) continue; + float south = -Mathf.Sin(a * Mathf.Deg2Rad); // 1 = 정남 + heightCap = south > 0.25f ? 2.2f : float.MaxValue; + return true; + } + p = Vector3.zero; + heightCap = 0f; + return false; + } + + private void RimTrees(RegionData r, SceneryBatcher b, float density, bool conifer, Color trunk, Color leaf, Color leafLight) + { + int n = RimCount(r, 40f * density); + for (int i = 0; i < n; i++) + { + if (!TryRim(r, 2.5f, 13f, 2.5f, out Vector3 p, out float cap)) continue; + float h = Random.Range(4.5f, 8.5f); + if (h > cap) continue; // 남쪽엔 큰 나무를 두지 않는다 + if (conifer) + { + b.Add(stemMesh, p + Vector3.up * h * 0.2f, Quaternion.identity, new Vector3(0.28f, h * 0.2f, 0.28f), trunk); + for (int k = 0; k < 3; k++) + { + float t = k / 3f; + float w = (1.9f - t * 1.1f) * h / 7f; + b.Add(coneMesh, p + Vector3.up * (h * (0.22f + t * 0.24f)), Quaternion.Euler(0f, Random.Range(0f, 360f), 0f), + new Vector3(w * 2f, h * 0.42f, w * 2f), k == 2 ? leafLight : leaf); + } + } + else + { + b.Add(stemMesh, p + Vector3.up * h * 0.3f, Quaternion.Euler(Random.Range(-4f, 4f), 0f, Random.Range(-4f, 4f)), + new Vector3(0.32f, h * 0.3f, 0.32f), trunk); + int puffs = Random.Range(3, 5); + for (int k = 0; k < puffs; k++) + { + float s = h * Random.Range(0.28f, 0.4f); + Vector3 o = new Vector3(Random.Range(-0.8f, 0.8f), h * Random.Range(0.62f, 0.85f), Random.Range(-0.8f, 0.8f)); + b.Add(sphereMesh, p + o, Yaw(), new Vector3(s * 1.2f, s, s * 1.2f), k % 2 == 0 ? leaf : leafLight); + } + } + } + } + + private void RimBushes(RegionData r, SceneryBatcher b, float density, Color a, Color c) + { + int n = RimCount(r, 60f * density); + for (int i = 0; i < n; i++) + { + if (!TryRim(r, 1f, 7f, 1.2f, out Vector3 p, out _)) continue; + int puffs = Random.Range(2, 4); + for (int k = 0; k < puffs; k++) + { + float s = Random.Range(1.0f, 2.1f); + b.Add(sphereMesh, p + new Vector3(Random.Range(-0.7f, 0.7f), s * 0.3f, Random.Range(-0.7f, 0.7f)), Yaw(), + new Vector3(s * 1.2f, s * 0.8f, s), k % 2 == 0 ? a : c); + } + } + } + + private void RimHedges(RegionData r, SceneryBatcher b, Color hedge, Color[] blossoms) + { + int n = RimCount(r, 70f); + for (int i = 0; i < n; i++) + { + if (!TryRim(r, 1.2f, 6f, 1.5f, out Vector3 p, out _)) continue; + Vector3 outward = (p - r.centerPosition); + outward.y = 0f; + Quaternion along = Quaternion.LookRotation(Vector3.Cross(Vector3.up, outward.normalized), Vector3.up); + float len = Random.Range(2.5f, 4.5f); + b.Add(blockMesh, p + Vector3.up * 0.7f, along, new Vector3(1.1f, 1.4f, len), hedge); + for (int k = 0; k < 7; k++) + { + Vector3 o = along * new Vector3(Random.Range(-0.56f, 0.56f), Random.Range(0.4f, 1.42f), Random.Range(-len * 0.45f, len * 0.45f)); + b.Add(pebbleMesh, p + o, Quaternion.identity, Vector3.one * Random.Range(0.18f, 0.28f), blossoms[(i + k) % blossoms.Length]); + } + } + } + + private void RimReeds(RegionData r, SceneryBatcher b, Color a, Color c) + { + int n = RimCount(r, 90f); + for (int i = 0; i < n; i++) + { + if (!TryRim(r, 0.8f, 6f, 0.8f, out Vector3 p, out _)) continue; + for (int k = 0; k < 4; k++) + { + float h = Random.Range(1.2f, 2.1f); + b.Add(bladeMesh, p + Polar(Random.Range(0f, 360f), Random.Range(0f, 0.5f)), Yaw(), new Vector3(0.6f, h, 0.6f), k % 2 == 0 ? a : c); + } + } + } + + private void RimDeadTrees(RegionData r, SceneryBatcher b, float density, Color wood) + { + int n = RimCount(r, 30f * density); + for (int i = 0; i < n; i++) + { + if (!TryRim(r, 2f, 11f, 1.5f, out Vector3 p, out float cap)) continue; + float h = Random.Range(3f, 6f); + if (h > cap) h = cap; + b.Add(stemMesh, p + Vector3.up * h * 0.5f, Quaternion.Euler(Random.Range(-8f, 8f), 0f, Random.Range(-8f, 8f)), + new Vector3(0.24f, h * 0.5f, 0.24f), wood); + int branches = Random.Range(2, 4); + for (int k = 0; k < branches; k++) + { + float bl = Random.Range(0.8f, 1.6f); + Quaternion rot = Quaternion.Euler(0f, Random.Range(0f, 360f), 0f) * Quaternion.Euler(Random.Range(35f, 60f), 0f, 0f); + b.Add(stemMesh, p + Vector3.up * h * Random.Range(0.55f, 0.85f) + rot * new Vector3(0f, bl * 0.5f, 0f), rot, + new Vector3(0.1f, bl * 0.5f, 0.1f), wood); + } + } + } + + private void RimRocks(RegionData r, SceneryBatcher b, float density, Color a, Color c, float maxSize) + { + int n = RimCount(r, 45f * density); + for (int i = 0; i < n; i++) + { + if (!TryRim(r, 1.5f, 11f, 2f, out Vector3 p, out float cap)) continue; + float s = Random.Range(1.2f, maxSize); + float h = Mathf.Min(s * Random.Range(0.7f, 1.4f), cap); + b.Add(blockMesh, p + Vector3.up * h * 0.35f, + Quaternion.Euler(Random.Range(-10f, 10f), Random.Range(0f, 360f), Random.Range(-10f, 10f)), + new Vector3(s * Random.Range(1f, 1.6f), h, s), i % 2 == 0 ? a : c); + } + } + + private void RimMounds(RegionData r, SceneryBatcher b, float density, Color a, Color c, float maxRise) + { + int n = RimCount(r, 40f * density); + for (int i = 0; i < n; i++) + { + if (!TryRim(r, 3f, 14f, 4f, out Vector3 p, out float cap)) continue; + float rise = Mathf.Min(Random.Range(maxRise * 0.45f, maxRise), cap); + float w = Random.Range(6f, 13f); + Vector3 outward = p - r.centerPosition; + outward.y = 0f; + Quaternion along = Quaternion.LookRotation(Vector3.Cross(Vector3.up, outward.normalized), Vector3.up); + // PlaceMound와 같은 식 — 구의 위 40%만 드러낸다 + float bAxis = rise / 0.4f; + b.Add(sphereMesh, new Vector3(p.x, FloorY + rise - bAxis, p.z), along * Quaternion.Euler(0f, Random.Range(-20f, 20f), 0f), + new Vector3(w * 0.55f / 0.8f, bAxis * 2f, w / 0.8f), i % 3 == 0 ? c : a); + } + } + + private void RimIce(RegionData r, SceneryBatcher b, Color ice) + { + int n = RimCount(r, 35f); + for (int i = 0; i < n; i++) + { + if (!TryRim(r, 1.5f, 10f, 1.5f, out Vector3 p, out float cap)) continue; + int spikes = Random.Range(2, 5); + for (int k = 0; k < spikes; k++) + { + float h = Mathf.Min(Random.Range(1.2f, 3.4f), cap); + b.Add(coneMesh, p + Polar(Random.Range(0f, 360f), Random.Range(0f, 0.8f)), + Quaternion.Euler(Random.Range(-14f, 14f), Random.Range(0f, 360f), Random.Range(-14f, 14f)), + new Vector3(h * 0.4f, h, h * 0.4f), ice); + } + } + } + + private void RimWalls(RegionData r, SceneryBatcher b, Color a, Color c) + { + int n = RimCount(r, 30f); + for (int i = 0; i < n; i++) + { + if (!TryRim(r, 1.5f, 9f, 2.5f, out Vector3 p, out float cap)) continue; + Vector3 outward = p - r.centerPosition; + outward.y = 0f; + Quaternion along = Quaternion.LookRotation(Vector3.Cross(Vector3.up, outward.normalized), Vector3.up); + float h = Mathf.Min(Random.Range(1.2f, 3.4f), cap); + float len = Random.Range(2.5f, 5f); + b.Add(blockMesh, p + Vector3.up * h * 0.5f, along * Quaternion.Euler(0f, 0f, Random.Range(-4f, 4f)), + new Vector3(0.6f, h, len), i % 2 == 0 ? a : c); + if (i % 3 == 0) // 쓰러진 기둥 토막 + b.Add(stemMesh, p + outward.normalized * 1.4f + Vector3.up * 0.35f, along * Quaternion.Euler(90f, 0f, 0f), + new Vector3(0.7f, 1.1f, 0.7f), c); + } + } + + private void RimMonoliths(RegionData r, SceneryBatcher b, Color a, Color c) + { + int n = RimCount(r, 16f); + for (int i = 0; i < n; i++) + { + if (!TryRim(r, 2f, 12f, 1.5f, out Vector3 p, out float cap)) continue; + float h = Mathf.Min(Random.Range(1.8f, 4.2f), cap); + b.Add(blockMesh, p + Vector3.up * h * 0.45f, Quaternion.Euler(Random.Range(-5f, 5f), Random.Range(0f, 360f), Random.Range(-6f, 6f)), + new Vector3(Random.Range(0.8f, 1.4f), h, 0.35f), i % 2 == 0 ? a : c); + } + } + + // ======= 자리 판정 ======= + + /// 리전 원 안(반경 비율 maxFrac)에서 회피 규칙을 통과하는 자리를 뽑는다. 6회 실패하면 포기. + private bool TryInside(RegionData r, float maxFrac, float footprint, out Vector3 p, bool avoidPaths) + { + for (int attempt = 0; attempt < 6; attempt++) + { + float a = Random.Range(0f, 360f); + float d = r.radius * maxFrac * Mathf.Sqrt(Random.value); + p = Floor(r.centerPosition + Polar(a, d)); + if (InKeepOut(p, footprint)) continue; + if (avoidPaths && OnLane(p, footprint)) continue; + return true; + } + p = Vector3.zero; + return false; + } + + private bool Blocked(Vector3 p, float footprint) => InKeepOut(p, footprint) || OnLane(p, footprint); + + private bool InKeepOut(Vector3 p, float footprint) + { + for (int n = 0; n < nearKeep.Count; n++) + { + Vector4 k = keepOut[nearKeep[n]]; + float dx = p.x - k.x, dz = p.z - k.z; + float rr = k.w + footprint; + if (dx * dx + dz * dz < rr * rr) return true; + } + // 물길·부두·호수는 월드에 몇 개뿐이라 근처 추리기 없이 전부 본다(원반은 외접원으로 먼저 거른다) + for (int n = 0; n < strips.Count; n++) + { + KeepStrip s = strips[n]; + float dx = p.x - s.center.x, dz = p.z - s.center.z; + float along = dx * s.axis.x + dz * s.axis.z; + float side = dx * s.axis.z - dz * s.axis.x; + if (Mathf.Abs(along) < s.halfLength + footprint && Mathf.Abs(side) < s.halfWidth + footprint) return true; + } + for (int n = 0; n < discKeepOut.Count; n++) + if (discKeepOut[n].disc.Contains(p, discKeepOut[n].fraction, footprint)) return true; + return false; + } + + private bool OnLane(Vector3 p, float footprint) + { + for (int n = 0; n < nearLanes.Count; n++) + { + int i = nearLanes[n]; + if (WorldRouteLayout.DistanceToSegment(p, laneA[i], laneB[i]) < laneHalf[i] + footprint * 0.5f) return true; + } + return false; + } + + /// 리전 원 + 테두리 폭(20m) 안에 걸치는 도로·회피원만 추린다. + private void SelectNearby(RegionData r) + { + nearLanes.Clear(); + nearKeep.Clear(); + float reach = r.radius + 20f; + for (int i = 0; i < laneA.Count; i++) + if (WorldRouteLayout.DistanceToSegment(r.centerPosition, laneA[i], laneB[i]) < reach + laneHalf[i]) nearLanes.Add(i); + for (int i = 0; i < keepOut.Count; i++) + { + Vector4 k = keepOut[i]; + float dx = r.centerPosition.x - k.x, dz = r.centerPosition.z - k.z; + float rr = reach + k.w; + if (dx * dx + dz * dz < rr * rr) nearKeep.Add(i); + } + } + + /// 다른 리전 원 안으로 파고드는 테두리 소품은 버린다(리전끼리 가까운 곳). + private bool InsideOtherRegion(RegionData self, Vector3 p, float footprint) + { + foreach (RegionData o in regions) + { + if (o == null || o == self) continue; + float dx = p.x - o.centerPosition.x, dz = p.z - o.centerPosition.z; + float rr = o.radius + footprint + 1f; + if (dx * dx + dz * dz < rr * rr) return true; + } + return false; + } + + private void CollectAvoidance() + { + laneA.Clear(); laneB.Clear(); laneHalf.Clear(); keepOut.Clear(); strips.Clear(); discKeepOut.Clear(); + + foreach (WorldRouteEdge edge in WorldRouteLayout.FieldConnections) + { + Vector3[] route = WorldRouteLayout.BuildRoute(regions, edge.FromRegionId, edge.ToRegionId); + for (int i = 1; i < route.Length; i++) Lane(route[i - 1], route[i], WorldRouteLayout.RoadWidth * 0.5f + 0.8f); + } + + RegionData meadow = WorldRouteLayout.Find(regions, "meadow"); + if (meadow != null) + { + Vector3 main = VillageBuilder.GetMainVillageCenter(meadow.centerPosition, meadow.radius); + keepOut.Add(new Vector4(main.x, 0f, main.z, VillageBuilder.MainVillageFootprintRadius + 2f)); + Lane(meadow.centerPosition, PlayerStartPlacement.ResolveMainVillageEntrance(regions).Position, + WorldRouteLayout.RoadWidth * 0.5f + 0.8f); + } + + if (terrain != null) + foreach (RegionTerrainBuilder.PathSegment s in terrain.InternalPaths) + Lane(s.from, s.to, s.width * 0.5f + 0.5f); + + // 전초기지(VillageBuilder가 BuildSystems에서 짓는다 — 그래서 Start에서 모은다) + GameObject village = GameObject.Find("Village"); + if (village != null) + { + foreach (Transform child in village.transform) + if (child.name.StartsWith("Outpost_")) + keepOut.Add(new Vector4(child.position.x, 0f, child.position.z, 9.5f)); + } + + // 서브에리어 입구 자리 — 입구 구조물과 겹치지 않게 + foreach (RegionData r in regions) + { + if (r?.subAreas == null) continue; + foreach (SubAreaData sub in r.subAreas) + if (sub != null) keepOut.Add(new Vector4(sub.centerPosition.x, 0f, sub.centerPosition.z, 3.5f)); + } + + // 수문장 제단(PlaySceneBootstrap.CreateGuardians — 지름 4m, 윗면 0.3m) — 안 피하면 풀포기(최고 ~0.9m)가 + // 판을 뚫고 솟는다. 자리는 RegionManager.GetGuardianPosition이 단일 출처라 사본을 두지 않고 지어진 판을 읽는다. + // 격파 뒤에도 판은 남으므로 늘 피한다. 기둥(±3m)은 밑동에 풀이 나도 자연스러워 판만 덮는다. + foreach (RegionData r in regions) + { + if (r == null) continue; + GameObject platform = GameObject.Find($"Guardian_{r.regionId}_Platform"); + if (platform == null) continue; + Vector3 c = platform.transform.position; + keepOut.Add(new Vector4(c.x, 0f, c.z, platform.transform.lossyScale.x * 0.5f + 0.3f)); + } + + // 연못 강(WorldTerrainBuilder.BuildRiver) — 안 피하면 반투명 수면 위로 풀·꽃이 솟는다. 강 자리는 연못 입구 방향에서 + // 파생되므로 좌표를 여기 다시 적지 않고 지어진 수면을 읽는다. 내장 Plane은 10×10이라 반폭·반길이는 + // 5 × 스케일이고, 둑 돌(River_Bank — 중심선에서 2.8m, 지름 0.7)까지 폭을 0.7m 더 막는다. + for (int i = 0; ; i++) + { + GameObject water = GameObject.Find($"River_Water_{i}"); + if (water == null) break; + Transform t = water.transform; + Vector3 axis = new Vector3(t.forward.x, 0f, t.forward.z); + if (axis.sqrMagnitude < 0.0001f) continue; + strips.Add(new KeepStrip + { + center = t.position, + axis = axis.normalized, + halfLength = 5f * t.lossyScale.z, + halfWidth = 5f * t.lossyScale.x + 0.7f, + }); + } + } + + private void Lane(Vector3 a, Vector3 b, float half) + { + laneA.Add(a); + laneB.Add(b); + laneHalf.Add(half); + } + + // ======= 메시 ======= + + /// patchMesh()의 반지름 — 배율 s의 원반은 평균 반경 PatchRadius·s다. + internal const float PatchRadius = 0.5f; + + /// 얼룩·잎·꽃송이 원반의 분할 수. + internal const int PatchSegments = 14; + + /// + /// 호수 원반의 분할 수. 얼룩(14분할)을 지름 40m로 늘리면 테두리 한 변이 9m라 각진 다각형으로 보였다 — + /// 요철식은 같고 분할만 두 배다(요철 범위도 14분할의 0.79~1.21에서 연속식의 0.76~1.24에 가까워진다). + /// + internal const int LakeSegments = 28; + + /// 풀·꽃·조약돌이 호수에서 비키는 범위 — 물 윤곽의 이 배율(진흙 띠 110%의 안쪽 절반)까지. + private const float LakeClearFraction = 1.05f; + + /// + /// 원반 테두리의 요철(평균 반경 대비 배율). 메시()와 윤곽 계산()이 + /// 같은 식을 써야 소품이 실제 물가를 따라 선다 — 옛날엔 물 반경을 원으로 어림해 갈대가 물 한가운데·풀밭에 흩어졌다. + /// + internal static float PatchWobble(float angle) => + 1f + 0.16f * Mathf.Sin(angle * 3f + 0.7f) + 0.09f * Mathf.Sin(angle * 5f + 2.1f); + + /// + /// 배율 1의 원반(반지름 )에서 로컬 각 (라디안, x축에서 z축으로) 방향으로 + /// 테두리까지의 거리 — 꼭짓점 사이는 메시와 똑같이 직선(현)이다. + /// + internal static float PatchEdgeRadius(float angle, int segments) + { + float step = Mathf.PI * 2f / segments; + float a = Mathf.Repeat(angle, Mathf.PI * 2f); + int i = Mathf.Min(segments - 1, Mathf.FloorToInt(a / step)); + float a0 = i * Mathf.PI * 2f / segments, a1 = (i + 1) * Mathf.PI * 2f / segments; + Vector2 p0 = new Vector2(Mathf.Cos(a0), Mathf.Sin(a0)) * (PatchRadius * PatchWobble(a0)); + Vector2 p1 = new Vector2(Mathf.Cos(a1), Mathf.Sin(a1)) * (PatchRadius * PatchWobble(a1)); + Vector2 dir = new Vector2(Mathf.Cos(a), Mathf.Sin(a)); + Vector2 e = p1 - p0; + float cross = dir.x * e.y - dir.y * e.x; + if (Mathf.Abs(cross) < 1e-6f) return p0.magnitude; + return (p0.x * e.y - p0.y * e.x) / cross; // 반직선 t·dir이 현 p0→p1과 만나는 t + } + + /// + /// 불규칙 원반 한 장의 월드 배치(배처에 넘기는 TRS와 같은 값). 소품 배치·회피·테스트가 메시와 같은 윤곽을 본다. + /// 원반 중심에서 보면 테두리가 한 번만 나오는 별 모양이라, 반직선 하나로 안팎을 가를 수 있다. + /// + internal readonly struct DiscPlacement + { + public readonly Vector3 center; + public readonly float scaleX, scaleZ, yaw; + public readonly int segments; + + public DiscPlacement(Vector3 center, float scaleX, float scaleZ, float yaw, int segments) + { + this.center = center; + this.scaleX = scaleX; + this.scaleZ = scaleZ; + this.yaw = yaw; + this.segments = segments; + } + + public Quaternion Rotation => Quaternion.Euler(0f, yaw, 0f); + public Vector3 Scale => new Vector3(scaleX, 1f, scaleZ); + + /// 로컬 각 방향 테두리의 배 자리(월드, y는 중심과 같다). + public Vector3 EdgePoint(float localAngle, float fraction) + { + float r = PatchEdgeRadius(localAngle, segments) * fraction; + return center + Rotation * new Vector3(Mathf.Cos(localAngle) * r * scaleX, 0f, Mathf.Sin(localAngle) * r * scaleZ); + } + + /// + /// p의 로컬 각 — 에 넘기면 중심에서 p로 가는 같은 반직선 위의 테두리가 나온다 + /// (배율로 늘어난 타원이라 월드 방위각을 그대로 넘기면 반직선이 어긋난다). + /// + public float LocalAngleOf(Vector3 p) + { + Vector3 local = Quaternion.Inverse(Rotation) * new Vector3(p.x - center.x, 0f, p.z - center.z); + return Mathf.Atan2(local.z / scaleZ, local.x / scaleX); + } + + /// + /// p가 윤곽을 배 한 도형 안인가 — m만큼 바깥까지 안으로 친다 + /// (중심에서 p를 잇는 반직선을 따라 잰 거리라 테두리 법선 거리보다 약간 후하다). + /// + public bool Contains(Vector3 p, float fraction, float grow) + { + float dx = p.x - center.x, dz = p.z - center.z; + float dist2 = dx * dx + dz * dz; + float reach = PatchRadius * 1.25f * Mathf.Max(scaleX, scaleZ) * fraction + grow; // 요철 연속식 최대 1.2498 + if (dist2 > reach * reach) return false; + Vector3 local = Quaternion.Inverse(Rotation) * new Vector3(dx, 0f, dz); + float u = local.x / scaleX, v = local.z / scaleZ; + float rho = Mathf.Sqrt(u * u + v * v); + if (rho < 1e-5f) return true; + float edge = PatchEdgeRadius(Mathf.Atan2(v, u), segments) * fraction; + float dist = Mathf.Sqrt(dist2); + return dist < dist * (edge / rho) + grow; + } + } + + internal struct PondLakeLayout + { + public DiscPlacement mud, water, deep; + } + + /// + /// 연못 호수의 윤곽 — 진흙·여울·깊은 물 세 장. 리전 중심·반경의 배율로만 적어 월드 배율(WorldScale)이 바뀌어도 + /// 부두·NPC·입구(전부 반경 배율로 놓인다)와의 관계가 그대로다. + /// + /// 왜 이 자리·이 모양인가(반경 67.5m 리전, 2026-09-29 검산). 옛 호수는 평균 물 반경 14.6m 원이었고 + /// 부두(, 옛 호수 중심에서 18~23m)는 물 밖 풀밭에 섰다. + /// 그런데 호수를 제자리에서 고르게 키울 수는 없다 — 옛 중심에서 20~24m 고리에 NPC가 둘러서 있다 + /// (북동 64° 전초기지 모닥불 21.3m·주민 20.8m, 동 18° 라온 22.2m, 북서 140° 갈대 밀림 입구 20.4m, + /// 서남서 202° 검은 옷 끈 22.4m·잡기 아이 23.8m). 비어 있는 쪽은 남동(연못 깊은 곳 입구 방향)뿐이라 + /// 중심을 남동으로 (0.11R, −0.08R) 옮기고, 평균 물 반경 0.29R(19.6m)·가로세로 1.1배 타원을 40° 돌려 앉혔다. + /// 물 면적 655 → 1,203㎡(1.84배), 진흙까지 830 → 1,456㎡. + /// + /// 결과: 부두 중심선의 북쪽 3.1m(길이 6m 중)가 여울 위, 나머지가 진흙이고 남쪽 끝 너머 1m까지 진흙이다. + /// 진흙 가장자리까지의 여유(발자리 반경을 뺀 값) — 모닥불 돌 3.8m · 의자 3.7m · 주민 3.7m · 마을 이야기 주민 5.9m · + /// 라온 4.3m · 검은 옷 3.2m · 잡기 아이 4.5m · 갈대 밀림 아치 7.0m · 버드나무 8.5m · 전초기지 물가 진흙과 6.4m. + /// 연못 깊은 곳 입구(돌 고리 2.45m)는 물가에서 10.7m 안쪽이다. 필드 길은 리전 중심에서 시작하므로 여전히 물을 + /// 건너 나온다(물 위 11.3 → 13m). 수문장 제단 33m·강 32m·울타리 29m 밖. + /// 진흙·여울은 요를 맞춰 진흙 띠가 고른 폭(물 반경의 10%)이고, 깊은 물은 요를 225° 돌려(요철이 다르게) 0.7배로 + /// 두어 어느 방향에서도 여울 안쪽 2m 이상에 머문다. 위 숫자는 FieldThemeTests.PondLake_*가 고정한다. + /// + internal static PondLakeLayout PondLake(Vector3 regionCenter, float regionRadius) + { + var center = new Vector3(regionCenter.x + regionRadius * 0.11f, FloorY, regionCenter.z - regionRadius * 0.08f); + float s = regionRadius * 0.29f / PatchRadius; // 평균 물 반경 0.29R + const float aspect = 1.1f, yaw = 40f; + return new PondLakeLayout + { + water = new DiscPlacement(center, s * aspect, s / aspect, yaw, LakeSegments), + mud = new DiscPlacement(center, s * aspect * 1.1f, s / aspect * 1.1f, yaw, LakeSegments), + deep = new DiscPlacement(center, s * aspect * 0.7f, s / aspect * 0.7f, yaw + 225f, LakeSegments), + }; + } + + /// + /// 연못 물가 소품의 자리 — 호수 물(여울 윤곽)에 몸이 닿으면 같은 방위의 진흙 띠 바깥 가장자리로 내민다. + /// 물에 안 닿으면(진흙 위·풀밭) 그대로 돌려준다. y는 건드리지 않는다. 난수를 쓰지 않는다. + /// + /// 쓰는 곳: 부트스트랩 물가 바위(PlaySceneBootstrap.AddPondScenery의 Pond_ShoreRock_). 옛 호수 중심에서 + /// 0.35R 고리에 60° 간격으로 서는데, 호수가 남동으로 옮겨 커지자() 동쪽(0°)·남동쪽(300°) 두 개가 + /// 난수에 따라 물 한가운데에 섰다(물가에서 최대 약 12m 안쪽). 호출부는 이 결과만 바꾸고 난수는 그대로 뽑는다. + /// + /// 진흙 띠는 물 윤곽의 110%라 같은 반직선에서 물 가장자리보다 물 반경의 10%(가장 좁은 쪽 약 1.5m) 바깥이다 — + /// 바위 반경(최대 0.54m)을 넉넉히 넘는다. 진흙 가장자리는 연못 NPC·모닥불·입구가 2m 넘게 비켜 있는 선이라 + /// (FieldThemeTests.PondLake_*) 옮긴 바위가 그들 발자리에 들지 않는다. + /// + internal static Vector3 PondShoreSpot(Vector3 regionCenter, float regionRadius, Vector3 p, float footprint) + { + PondLakeLayout lake = PondLake(regionCenter, regionRadius); + if (!lake.water.Contains(p, 1f, footprint)) return p; + Vector3 shore = lake.mud.EdgePoint(lake.mud.LocalAngleOf(p), 1f); + return new Vector3(shore.x, p.y, shore.z); + } + + // ======= 물 — 공개 판정 ======= + // + // 필드에서 물로 칠해진 곳을 한 곳에서 묻는다: 연못 호수, 나루터 오두막 밑 물가(Pond의 원반 두 장), 습지 웅덩이(Swamp), + // 그리고 다른 빌더가 깐 물 — 연못 강(WorldTerrainBuilder.BuildRiver), 부트스트랩의 원기둥 물(Pond_Water·Meadow_Puddle· + // Swamp_Puddle_*), 수렁 웅덩이(RegionTerrainBuilder, Scenery_SwampPool_*). 곤충 스폰(InsectSpawner)과 필드 아이템 + // (CaptureItemSpawner)이 이걸로 물 위를 피한다. + // + // 습지 웅덩이 자리는 리전 난수(RegionSeed)로 정해진다. 밖에서 같은 난수를 재현하면 앞선 호출이 하나만 늘어도 조용히 + // 다른 자리를 가리키므로, 빌드가 실제로 놓은 원반을 그 자리에서 기록한다(RecordWaterPatch). 다른 빌더의 물은 + // 지어진 오브젝트를 읽는다 — 강 회피(CollectAvoidance)가 이미 그렇게 한다. + // + // 기록 전(이 빌더의 Start 전, 또는 장식 빌더가 없는 씬)에는 호수만 판정한다. 호수 윤곽은 리전 데이터의 순수 함수 + // ()라 빌드 없이도 같은 답이 나온다. 나머지는 놓이기 전엔 없으므로 물로 치지 않는다 — 대신 + // 곤충 초기 채우기가 기록을 기다린다(InsectSpawner.Start). 기록 뒤에는 기록만 본다(호수도 Pond가 적는다). + // 월드는 씬마다 새로 지어지므로 기록은 이 빌더의 Awake·빌드 시작·파괴 때 비운다(FieldGround와 같은 수명). + + private const string PondRegionId = "pond"; + + private static readonly List waterDiscs = new List(); + private static readonly List waterCircles = new List(); // xz + 반경(w) + private static readonly List waterStrips = new List(); + private static bool waterRecorded; + + // 기록 전 판정용 호수 — 리전 데이터가 씬 안에서 안 바뀌므로 한 번만 구한다 + private static bool fallbackLakeResolved; + private static bool hasFallbackLake; + private static DiscPlacement fallbackLake; + + /// 빌드가 물을 다 기록했는가. 기록 전에는 가 호수만 본다. + internal static bool WaterRecorded => waterRecorded; + + /// 기록된 물 도형 수(테스트·진단용). + internal static int WaterShapeCount => waterDiscs.Count + waterCircles.Count + waterStrips.Count; + + /// 기록을 비우고 "기록 전"으로 되돌린다. + internal static void ClearWater() + { + waterDiscs.Clear(); + waterCircles.Clear(); + waterStrips.Clear(); + waterRecorded = false; + } + + /// 불규칙 원반 물 한 장(호수·물가·웅덩이). + internal static void AddWaterDisc(DiscPlacement disc) + { + if (disc.scaleX <= 0f || disc.scaleZ <= 0f) return; + waterDiscs.Add(disc); + } + + /// 원형 물(내장 원기둥을 납작하게 깐 것) — 중심과 반경(m). + internal static void AddWaterCircle(Vector3 center, float radius) + { + if (radius <= 0f) return; + waterCircles.Add(new Vector4(center.x, 0f, center.z, radius)); + } + + /// 방향 있는 사각형 물(강) — 는 길이 방향 XZ 단위 벡터. + internal static void AddWaterStrip(Vector3 center, Vector3 axis, float halfLength, float halfWidth) + { + axis.y = 0f; + if (axis.sqrMagnitude < 1e-6f || halfLength <= 0f || halfWidth <= 0f) return; + waterStrips.Add(new KeepStrip { center = center, axis = axis.normalized, halfLength = halfLength, halfWidth = halfWidth }); + } + + /// 기록을 끝낸 것으로 둔다 — 빌드는 스스로 부른다. 테스트가 기록 뒤 동작을 볼 때 쓴다. + internal static void MarkWaterRecorded() => waterRecorded = true; + + /// + /// (x, z)가 물 위인가 — 물가에서 (m) 바깥까지 물로 친다. y는 보지 않는다. + /// 기록 전에는 호수만 판정한다(이 절 머리 주석). + /// + internal static bool IsOnWater(Vector3 p, float margin) => TryFindWater(p, margin, out _); + + /// + /// 물 밖(여유 포함)까지 한 방향으로 민다. 방향은 처음 p를 덮은 물 도형에서 멀어지는 쪽 — 원반·원은 중심에서 p로 가는 + /// 반직선(원반은 중심에서 보면 테두리가 한 번만 나오는 별 모양이라 곧게 나가면 물가를 한 번만 넘는다), 강은 가까운 둑 쪽 + /// 수직이다. 도중에 다른 물을 만나도 방향을 바꾸지 않는다 — 겹친 두 웅덩이 사이에서 번갈아 밀리면 제자리를 돈다. + /// 물이 아니면 그대로 돌려준다. y는 건드리지 않는다. + /// + internal static Vector3 PushOutOfWater(Vector3 p, float margin, float step, int maxSteps) + { + if (!TryFindWater(p, margin, out Vector3 away)) return p; + for (int i = 0; i < maxSteps && TryFindWater(p, margin, out _); i++) + p += away * step; + return p; + } + + /// p를 덮는 첫 물 도형과 거기서 멀어지는 XZ 단위 방향. + private static bool TryFindWater(Vector3 p, float margin, out Vector3 away) + { + if (!waterRecorded) + { + if (TryFallbackLake(out DiscPlacement lake) && lake.Contains(p, 1f, margin)) + { + away = AwayFrom(p, lake.center); + return true; + } + away = Vector3.zero; + return false; + } + + for (int i = 0; i < waterDiscs.Count; i++) + { + if (!waterDiscs[i].Contains(p, 1f, margin)) continue; + away = AwayFrom(p, waterDiscs[i].center); + return true; + } + for (int i = 0; i < waterCircles.Count; i++) + { + Vector4 c = waterCircles[i]; + float dx = p.x - c.x, dz = p.z - c.z, rr = c.w + margin; + if (dx * dx + dz * dz >= rr * rr) continue; + away = AwayFrom(p, new Vector3(c.x, 0f, c.z)); + return true; + } + for (int i = 0; i < waterStrips.Count; i++) + { + KeepStrip s = waterStrips[i]; + float dx = p.x - s.center.x, dz = p.z - s.center.z; + float along = dx * s.axis.x + dz * s.axis.z; + float side = dx * s.axis.z - dz * s.axis.x; // InKeepOut과 같은 부호 — 옆 방향 (axis.z, 0, -axis.x) + if (Mathf.Abs(along) >= s.halfLength + margin || Mathf.Abs(side) >= s.halfWidth + margin) continue; + away = new Vector3(s.axis.z, 0f, -s.axis.x) * (side < 0f ? -1f : 1f); + return true; + } + away = Vector3.zero; + return false; + } + + private static Vector3 AwayFrom(Vector3 p, Vector3 center) + { + var d = new Vector3(p.x - center.x, 0f, p.z - center.z); + return d.sqrMagnitude > 1e-6f ? d.normalized : Vector3.forward; + } + + private static bool TryFallbackLake(out DiscPlacement lake) + { + if (!fallbackLakeResolved) + { + fallbackLakeResolved = true; + foreach (RegionData r in RegionDefinitions.CreateAll()) + { + if (r == null || r.regionId != PondRegionId) continue; + fallbackLake = PondLake(r.centerPosition, r.radius).water; + hasFallbackLake = true; + break; + } + } + lake = fallbackLake; + return hasFallbackLake; + } + + /// + /// 패치 원반 물 한 장을 적는다 — 인자는 그 원반을 그린 b.Add(patchMesh, center + 높이, rotation, scale, …)와 같다 + /// (배처의 TRS와 는 같은 뜻이다: 요 = rotation의 y, 배율 = scale의 x·z). + /// + private static void RecordWaterPatch(Vector3 center, Quaternion rotation, Vector3 scale) + { + AddWaterDisc(new DiscPlacement(center, scale.x, scale.z, rotation.eulerAngles.y, PatchSegments)); + } + + /// + /// 다른 빌더가 깐 물을 지어진 오브젝트에서 읽는다(좌표를 여기 다시 적지 않는다). 강은 내장 Plane(10×10)이라 반폭·반길이가 + /// 5 × 스케일(의 강과 같은 식, 둑 돌 여유는 뺀다), 원기둥은 지름 1이라 반경이 스케일의 절반이다. + /// 번호 붙은 것은 0부터 빈 번호가 나올 때까지 — 짓는 쪽이 전부 0부터 빠짐없이 매긴다. + /// + private static void RecordSceneWater() + { + for (int i = 0; ; i++) + { + GameObject water = GameObject.Find($"River_Water_{i}"); + if (water == null) break; + Transform t = water.transform; + AddWaterStrip(t.position, new Vector3(t.forward.x, 0f, t.forward.z), 5f * t.lossyScale.z, 5f * t.lossyScale.x); + } + RecordCylinderWater(GameObject.Find("Pond_Water")); + RecordCylinderWater(GameObject.Find("Meadow_Puddle")); + RecordCylinderSeries("Swamp_Puddle_"); + RecordCylinderSeries("Scenery_SwampPool_"); + } + + private static void RecordCylinderSeries(string prefix) + { + for (int i = 0; ; i++) + { + GameObject g = GameObject.Find(prefix + i); + if (g == null) break; + RecordCylinderWater(g); + } + } + + private static void RecordCylinderWater(GameObject g) + { + if (g == null) return; + Vector3 s = g.transform.lossyScale; + AddWaterCircle(g.transform.position, 0.5f * Mathf.Max(Mathf.Abs(s.x), Mathf.Abs(s.z))); + } + + private void CreateMeshes() + { + // 잎 폭은 고각 카메라(약 11m)에서 한 픽셀 넘게 잡히도록 — 처음의 9cm×크기(3~5cm)는 안 보였다 + tuftMesh = Own(BladeCluster("DressTuft", 5, 0.2f, 1f, 30f)); + fernMesh = Own(BladeCluster("DressFern", 6, 0.24f, 1f, 55f)); + bladeMesh = Own(BladeCluster("DressReed", 3, 0.05f, 1f, 8f)); + stalkMesh = Own(BladeCluster("DressStalk", 1, 0.05f, 1f, 0f)); // 꽃·버섯 줄기 — 캡슐(수십 정점) 대신 잎 한 장(3정점) + bloomMesh = Own(IrregularDisc("DressBloom", 6, 0.5f)); // 꽃송이 — 고각 카메라엔 위에서 본 원반이 곧 꽃이다 + patchMesh = Own(IrregularDisc("DressPatch", PatchSegments, PatchRadius)); + lakeMesh = Own(IrregularDisc("DressLake", LakeSegments, PatchRadius)); + leafMesh = Own(IrregularDisc("DressLeaf", 6, 0.5f)); + coneMesh = Own(Cone("DressCone", 6)); + // 정점 예산: 합친 장식 전체가 한 번에 메모리에 올라간다. 구 하나 54 → 40, 조약돌 28 → 18정점. + sphereMesh = ProcMeshLibrary.LowSphere(0.5f, 0.5f, 0.5f, 4, 7); + pebbleMesh = ProcMeshLibrary.LowSphere(0.5f, 0.5f, 0.5f, 2, 5); + blockMesh = ProcMeshLibrary.RoundedBox(Vector3.one, 0.12f, 1); + stemMesh = Own(Prism("DressTrunk", 6)); // 줄기·기둥 — 내장 Capsule처럼 높이 2·중심 원점, 옆면만(14정점) + } + + private Mesh Own(Mesh m) + { + ownedMeshes.Add(m); + return m; + } + + /// + /// 풀잎 다발 — 가는 삼각형 잎을 방사형으로 기울여 세운다(양면). 법선을 전부 위로 둬서 + /// 잎이 바닥과 같은 밝기로 비친다(얇은 잎을 면 법선으로 비추면 반은 검게 죽는다). + /// + private static Mesh BladeCluster(string name, int blades, float width, float height, float lean) + { + var v = new List(); + var t = new List(); + for (int i = 0; i < blades; i++) + { + float yaw = i * 360f / blades + (i % 2) * 17f; + Quaternion q = Quaternion.Euler(0f, yaw, 0f) * Quaternion.Euler(lean * (0.6f + 0.4f * ((i * 7) % 5) / 4f), 0f, 0f); + float h = height * (0.7f + 0.3f * ((i * 3) % 4) / 3f); + Vector3 l = q * new Vector3(-width, 0f, 0f); + Vector3 r = q * new Vector3(width, 0f, 0f); + Vector3 tip = q * new Vector3(0f, h, 0f); + int b = v.Count; + v.Add(l); v.Add(r); v.Add(tip); + t.Add(b); t.Add(b + 2); t.Add(b + 1); // 앞 + t.Add(b); t.Add(b + 1); t.Add(b + 2); // 뒤 + } + var mesh = new Mesh { name = name }; + mesh.SetVertices(v); + mesh.SetTriangles(t, 0); + var n = new Vector3[v.Count]; + for (int i = 0; i < n.Length; i++) n[i] = Vector3.up; + mesh.normals = n; + mesh.RecalculateBounds(); + return mesh; + } + + /// 윗면만 있는 불규칙한 원반(지름 1). 테두리 반경을 정해진 패턴()으로 흔들어 유기적인 윤곽을 낸다. + internal static Mesh IrregularDisc(string name, int segments, float radius) + { + var v = new Vector3[segments + 1]; + var tri = new int[segments * 3]; + v[0] = Vector3.zero; + for (int i = 0; i < segments; i++) + { + float a = i * Mathf.PI * 2f / segments; + float k = PatchWobble(a); + v[i + 1] = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * radius * k; + tri[i * 3] = 0; + tri[i * 3 + 1] = (i + 1) % segments + 1; + tri[i * 3 + 2] = i + 1; + } + var mesh = new Mesh { name = name, vertices = v, triangles = tri }; + var n = new Vector3[v.Length]; + for (int i = 0; i < n.Length; i++) n[i] = Vector3.up; + mesh.normals = n; + mesh.RecalculateBounds(); + return mesh; + } + + /// + /// 옆면만 있는 각기둥(높이 2, 지름 1, 중심 원점 — 내장 Capsule과 같은 크기 규약). 나무줄기·쓰러진 기둥. + /// 위아래 뚜껑은 고각 카메라에서 거의 안 보이므로 뺀다. + /// + private static Mesh Prism(string name, int segments) + { + var v = new Vector3[(segments + 1) * 2]; + var n = new Vector3[v.Length]; + var t = new int[segments * 6]; + for (int i = 0; i <= segments; i++) + { + float a = i * Mathf.PI * 2f / segments; + Vector3 dir = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)); + v[i * 2] = dir * 0.5f + Vector3.down; + v[i * 2 + 1] = dir * 0.44f + Vector3.up; // 위로 살짝 가늘게 + n[i * 2] = dir; + n[i * 2 + 1] = dir; + } + for (int i = 0; i < segments; i++) + { + int b = i * 2; + t[i * 6] = b; t[i * 6 + 1] = b + 1; t[i * 6 + 2] = b + 2; + t[i * 6 + 3] = b + 1; t[i * 6 + 4] = b + 3; t[i * 6 + 5] = b + 2; + } + var mesh = new Mesh { name = name, vertices = v, normals = n, triangles = t }; + mesh.RecalculateBounds(); + return mesh; + } + + /// 밑면 없는 각뿔(높이 1, 밑지름 1, 밑면이 y=0). 얼음 조각·침엽수 층·가시. + private static Mesh Cone(string name, int segments) + { + var v = new List(); + var t = new List(); + Vector3 tip = new Vector3(0f, 1f, 0f); + for (int i = 0; i < segments; i++) + { + float a0 = i * Mathf.PI * 2f / segments, a1 = (i + 1) * Mathf.PI * 2f / segments; + Vector3 p0 = new Vector3(Mathf.Cos(a0), 0f, Mathf.Sin(a0)) * 0.5f; + Vector3 p1 = new Vector3(Mathf.Cos(a1), 0f, Mathf.Sin(a1)) * 0.5f; + int b = v.Count; + v.Add(p0); v.Add(tip); v.Add(p1); + t.Add(b); t.Add(b + 1); t.Add(b + 2); + } + var mesh = new Mesh { name = name }; + mesh.SetVertices(v); + mesh.SetTriangles(t, 0); + mesh.RecalculateNormals(); + mesh.RecalculateBounds(); + return mesh; + } + + // ======= 유틸 ======= + + /// + /// 이 파일의 색은 옛 노출(환경광 0) 기준으로 골랐다. Trilight 환경광을 켠 뒤 화면이 하얗게 날아가서 + /// 와 함께 한 번에 누른다 — 발광색은 Emission이 따로 받으므로 영향이 없다. + /// + private const float Tone = 0.84f; + + private static Color C(float r, float g, float b) => new Color(r * Tone, g * Tone, b * Tone, 1f); + + private static Quaternion Yaw() => Quaternion.Euler(0f, Random.Range(0f, 360f), 0f); + + private static Vector3 Polar(float deg, float dist) + { + float a = deg * Mathf.Deg2Rad; + return new Vector3(Mathf.Cos(a) * dist, 0f, Mathf.Sin(a) * dist); + } + + private static Vector3 Floor(Vector3 p) => new Vector3(p.x, FloorY, p.z); + + /// + /// 선 소품(풀포기·꽃·조약돌·버섯·잔가지·관목·돌조각·눈덩이·얼음 조각·불씨)을 둔덕 윗면에 올린다. 둔덕은 콜라이더가 + /// 없어() 바닥 높이에 두면 그 속에 묻힌다 — 초원 언덕·사구·눈 언덕 위가 + /// 소품 없는 민둥 무늬로 보였다. 모양은 지형 빌더가 에 올려 두었다(지형이 먼저, 장식은 Start). + /// 납작한 무늬(얼룩·잎·금·모래 물결·웅덩이)는 올리지 않는다 — 둥근 윗면에 평판을 띄우면 한쪽 끝이 공중에 뜨고 + /// 반대쪽이 묻힌다. 둔덕 속에 그대로 두면 둔덕이 가려 줄 뿐이다. 난수를 쓰지 않는다. + /// + private static Vector3 Grounded(Vector3 p) => new Vector3(p.x, p.y + FieldGround.LiftAt(p.x, p.z), p.z); + + /// 개수를 면적 비례로(설계 반경 50m 기준). + private static int Scale(int baseCount, RegionData r) + { + float k = (r.radius / 50f) * (r.radius / 50f); + return Mathf.Max(1, Mathf.RoundToInt(baseCount * k)); + } + + /// 테두리 개수를 둘레 비례로(설계 반경 50m 기준). + private static int RimCount(RegionData r, float baseCount) + { + return Mathf.Max(1, Mathf.RoundToInt(baseCount * r.radius / 50f)); + } + + private void OnDestroy() + { + foreach (Mesh m in ownedMeshes) if (m != null) Destroy(m); + foreach (Material m in ownedMaterials) if (m != null) Destroy(m); + foreach (Texture2D t in ownedTextures) if (t != null) Destroy(t); + ownedMeshes.Clear(); + ownedMaterials.Clear(); + ownedTextures.Clear(); + materialCache.Clear(); + ClearWater(); // 물 기록도 이 월드의 것이다 — 다음 씬이 옛 물을 보지 않게 + } + } +} diff --git a/Assets/Scripts/Core/RegionDressingBuilder.cs.meta b/Assets/Scripts/Core/RegionDressingBuilder.cs.meta new file mode 100644 index 0000000..33586de --- /dev/null +++ b/Assets/Scripts/Core/RegionDressingBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 488bedd120870d442897bd8de351a486 \ No newline at end of file diff --git a/Assets/Scripts/Core/RegionManager.cs b/Assets/Scripts/Core/RegionManager.cs index 647209a..2001a33 100644 --- a/Assets/Scripts/Core/RegionManager.cs +++ b/Assets/Scripts/Core/RegionManager.cs @@ -32,6 +32,21 @@ public class RegionManager : MonoBehaviour, ICloudReloadable public SubAreaData CurrentSubArea => currentSubArea; public bool SubAreaSticky => subAreaSticky; + /// + /// 행동(포획·전투·대화)이 어느 리전에서 일어났는가 — 지역 의뢰(QuestRegionGate)와 스토리 리전 게이트가 읽는다. + /// 분리 구역(나의 섬) 안이면 null이다. 섬은 어느 리전도 아닌데 은 섬에 있는 동안에도 떠나기 전 + /// 리전으로 남아서(sticky — ), 섬의 손님 곤충을 잡으면 그 리전의 의뢰·스토리 포획으로 셌다. + /// 동굴 같은 보통 서브에리어는 그 리전 안이다(지역 의뢰는 서브에리어 안의 행동도 센다 — rules/quest-system.md). + /// + public string ActionRegionId => ActionRegionIdOf(currentRegion, currentSubArea); + + /// 의 순수 판정 — 테스트가 매니저 없이 본다. + public static string ActionRegionIdOf(RegionData region, SubAreaData subArea) + { + if (subArea != null && subArea.detached) return null; + return region != null ? region.regionId : null; + } + // 사용자가 영역 안에 있지만 아직 진입 안 한 상태. SubAreaProximityChanged로 UI 표시. // 옛은 ContainsPoint 시 SubAreaChanged 자동 발화 → 자동 진입. 사용자 명시 요청: [E] 키 선택. private SubAreaData nearbySubArea; @@ -52,6 +67,13 @@ public class RegionManager : MonoBehaviour, ICloudReloadable /// public event System.Action GuardianDefeated; + /// + /// 수문장 배지를 새로 얻었다 — 의 첫 격파에서만, 리전당 일생 한 번. + /// 와 나눈 이유: 그쪽은 이미 깬 수문장이 필드에 남아 있을 때 봉인을 걷으려고 + /// 이 다시 울린다. 배지 연출이 그걸 들으면 같은 배지가 두 번 뜬다. + /// + public event System.Action GuardianBadgeEarned; + public void SetSubAreaSticky(bool sticky, string exitedId = null) { subAreaSticky = sticky; @@ -69,6 +91,8 @@ public void ForceExitSubArea() string exitedId = currentSubArea.subAreaId; currentSubArea = null; subAreaSticky = false; + nearbySubArea = null; + SubAreaProximityChanged?.Invoke(null); lastExitedSubAreaId = exitedId; lastExitedAtTime = Time.time; SubAreaChanged?.Invoke(null); @@ -167,8 +191,116 @@ public void RequestEnterSubArea() SubAreaChanged?.Invoke(currentSubArea); } + /// + /// 위치와 무관하게 분리 구역(나의 섬)으로 들어간다 — 인 구역 전용. + /// + /// 섬을 서브에리어 상태로 태우는 이유: "분리된 공간에 있다"는 판정이 전부 + /// != null에 걸려 있다(플레이어 접지·끼임 복구, 스포너 필드 틱, 미니맵·지도, 환경광). + /// 섬을 별개 상태로 두면 그 전부를 따로 고쳐야 하고, 리전 판정이 멈추지 않아 섬을 오갈 때마다 + /// 가 다시 울린다(방문 퀘스트·스토리 트리거·BGM이 왕복마다 재발화한다). + /// + /// 들어가는 순간 sticky를 켠다 — 섬 좌표에는 리전이 없어 Update의 위치 판정이 리전을 null로 바꾸기 때문이다. + /// 나올 때는 . + /// + public bool EnterDetachedSubArea(SubAreaData area) + { + if (area == null || !area.detached || currentSubArea != null) return false; + subAreaSticky = true; + // 근접 진입 대상이 남아 있으면 섬 위에서도 "○○ 들어가기" 버튼과 [E] 진입이 살아 있다. + if (nearbySubArea != null) + { + nearbySubArea = null; + SubAreaProximityChanged?.Invoke(null); + } + currentSubArea = area; + SubAreaChanged?.Invoke(currentSubArea); + return true; + } + // --- 지역 잠금 시스템 --- + /// + /// 이야기 잠금 — 앞 리전 수문장에 더해 이 사람을 이겨야 열리는 리전(2026-10-04 사용자 결정: + /// 명부회 간부전은 본편 필수, 이겨야 다음 지역이 열린다). 하월은 같은 리전 안의 최종장이라 리전 잠금이 아니라 + /// 스토리 선행(fin_unnamed ← duel_chief_win)으로 막는다. + /// + /// 인물 ID를 문자열로 둔다 — Core가 NPC 모듈(NpcBossDuels)을 참조하지 않게 하려는 것이고, 이긴 기록은 + /// 부트스트랩이 넘기는 조회 함수()로만 읽는다. 표시명이 대결 표·대사창과 같은지는 + /// RegionStoryLockTests가 본다(어긋나면 "집게에게 이겨야"가 다른 이름으로 뜬다). + /// + public struct StoryLock + { + public string regionId; + /// 이겨야 하는 상대 — 간부 대결 표의 storyNpcId(격파 기록 ID와 같다). + public string storyNpcId; + /// 안내 문구에 쓰는 이름 — 대사창 이름표와 같다. + public string displayName; + } + + private static readonly StoryLock[] StoryLocks = + { + // 서릿길 — 모래언덕 창고의 집게. 창고(ch8_confront) 직후 대결이 열리고, 이기면 서릿길 도착(ch9_arrive)이 이어진다. + new StoryLock { regionId = "frostline", storyNpcId = "ledger_grip", displayName = "집게" }, + // 잿불 골짜기 — 얼음 서고의 저울. 서고(ch9_confront)의 대답 뒤 대결, 이기면 잿불 도착(ch10_arrive). + new StoryLock { regionId = "emberfall", storyNpcId = "ledger_scale", displayName = "저울" }, + }; + + /// 이야기 잠금 표 전체 — 테스트·검사용(값 복사). + public static StoryLock[] AllStoryLocks() + { + StoryLock[] copy = new StoryLock[StoryLocks.Length]; + System.Array.Copy(StoryLocks, copy, StoryLocks.Length); + return copy; + } + + public static bool TryGetStoryLock(string regionId, out StoryLock storyLock) + { + for (int i = 0; i < StoryLocks.Length; i++) + { + if (StoryLocks[i].regionId == regionId) + { + storyLock = StoryLocks[i]; + return true; + } + } + storyLock = default; + return false; + } + + /// + /// 간부를 이겼는가 — NpcDuelController.IsBossDefeated. 부트스트랩이 넘긴다. + /// null이면 이야기 잠금이 없는 옛 동작이다(수문장만으로 열린다) — 배선이 빠져 영영 못 여는 것보다 낫다. + /// + private System.Func storyDuelWon; + + /// 이야기 잠금의 판정 함수 — 간부 격파 기록 조회. 클라우드 재적재는 그쪽 컨트롤러가 다시 읽는다. + public void AutoWireDuelGate(System.Func duelWon) + { + if (storyDuelWon == null) storyDuelWon = duelWon; + } + + /// + /// 리전이 열려 있는가 — 순수 판정(테스트가 매니저 없이 본다). 마스터·시작 리전 우회는 호출부가 먼저 한다. + /// + /// + /// 저장된 해금 기록은 닫지 않는다. 이야기 잠금이 생기기 전에 열린 서릿길·잿불 골짜기는 그대로 열려 있다 — + /// 그 안을 돌던 플레이어를 가두거나 밖으로 밀어내지 않는다. 진행은 스토리 선행(ch9_arrive ← 집게 승리)이 + /// 따로 막고, HUD가 간부에게 안내한다. + /// 그 밖에는 앞 리전 수문장 격파가 열쇠이고, 이야기 잠금이 있으면 그 간부 승리까지 함께 본다. + /// 수문장을 먼저 이기면 해금 기록을 미뤄 두고(), 간부를 이기는 순간 여기서 열린다 — + /// 두 기록이 모두 저장·클라우드 동기라 기기를 바꿔도 같다. + /// + /// + /// null이면 이야기 잠금을 보지 않는다(옛 동작). + public static bool IsOpenByProgress(string regionId, bool savedUnlock, bool gateGuardianDefeated, + System.Func duelWon) + { + if (savedUnlock) return true; + if (!gateGuardianDefeated) return false; + if (duelWon == null || !TryGetStoryLock(regionId, out StoryLock storyLock)) return true; + return duelWon(storyLock.storyNpcId); + } + public bool IsRegionAccessible(RegionData region) { if (region == null) return false; @@ -177,7 +309,78 @@ public bool IsRegionAccessible(RegionData region) // **"특권 없이" 모드면 우회하지 않는다** — 그 모드의 요점이 지역 게이트를 살리는 것이다. if (AuthManager.Instance != null && AuthManager.Instance.MasterPrivilegesActive) return true; if (region.regionId == "meadow") return true; - return unlockedRegions.Contains(region.regionId); + return IsOpenByProgress(region.regionId, unlockedRegions.Contains(region.regionId), + IsGatekeeperGuardianDefeated(region.regionId), storyDuelWon); + } + + // 앞 리전(열쇠를 쥔 리전)의 수문장을 쓰러뜨렸는가. 앞이 없는 리전(시작 리전)은 false — 저장 기록으로만 열린다. + private bool IsGatekeeperGuardianDefeated(string regionId) + { + string prevId = GetPreviousRegionId(regionId); + return !string.IsNullOrEmpty(prevId) && defeatedGuardians.Contains(prevId); + } + + /// 잠긴 리전의 열쇠 종류. + public enum LockKind + { + /// 열려 있다. + Open, + /// 앞 리전 수문장을 아직 못 이겼다. + Guardian, + /// 수문장은 이겼고 이야기 대결(간부)이 남았다. + StoryDuel, + /// 열쇠를 모른다(앞 리전 없음·데이터 누락). + Unknown, + } + + /// + /// 잠긴 이유 — 안내 문구(필드 차단·지도·HUD 목표)가 같은 답을 내도록 여기 한 곳에서 판정한다. + /// 는 열쇠를 쥔 앞 리전, 은 이야기 잠금(있을 때). + /// + public LockKind GetLockKind(RegionData region, out RegionData gate, out StoryLock storyLock) + { + gate = null; + storyLock = default; + if (region == null) return LockKind.Unknown; + if (IsRegionAccessible(region)) return LockKind.Open; + + gate = GetGatekeeperRegion(region.regionId); + bool hasStoryLock = TryGetStoryLock(region.regionId, out storyLock); + if (gate != null && !IsGuardianDefeated(gate.regionId)) return LockKind.Guardian; + if (hasStoryLock && storyDuelWon != null && !storyDuelWon(storyLock.storyNpcId)) return LockKind.StoryDuel; + return LockKind.Unknown; + } + + /// + /// 잠긴 리전 앞에서 띄울 한 줄 — "서릿길 — 집게에게 이겨야 열립니다". 필드 차단 배너(PlayerMovement)가 쓰고, + /// 지도(RegionMapUI)의 잠김 안내도 이걸 쓰면 두 화면이 같은 이유를 댄다. + /// 열려 있으면 빈 문자열. + /// + public string DescribeLock(RegionData region) + { + if (region == null) return string.Empty; + LockKind kind = GetLockKind(region, out RegionData gate, out StoryLock storyLock); + return DescribeLockText(region.displayName, kind, + gate != null ? gate.guardianDisplayName : null, storyLock.displayName); + } + + /// + /// 의 순수 문구부. 조사는 "…에게"로 잇는다 — 수문장 이름이 데이터에서 오는데 받침이 + /// 갈려서("사마귀" / "장수말벌") 을/를을 어느 쪽으로 고정해도 절반은 어색해진다. 원인을 모르면 지어내지 않는다. + /// + public static string DescribeLockText(string regionName, LockKind kind, string gateGuardianName, string duelOpponentName) + { + switch (kind) + { + case LockKind.Open: return string.Empty; + case LockKind.Guardian: + if (!string.IsNullOrEmpty(gateGuardianName)) return $"{regionName} — {gateGuardianName}에게 이겨야 열립니다"; + break; + case LockKind.StoryDuel: + if (!string.IsNullOrEmpty(duelOpponentName)) return $"{regionName} — {duelOpponentName}에게 이겨야 열립니다"; + break; + } + return $"{regionName} — 아직 갈 수 없습니다"; } public RegionData[] GetAccessibleRegions() @@ -226,10 +429,19 @@ public bool TryDefeatGuardian(string regionId, string via) } DefeatGuardian(regionId); RegionData region = GetRegionById(regionId); - Debug.Log($"[Guardian] {(region != null ? region.displayName : regionId)} 수문장 격파({via})! 다음 지역 해금됨"); + string next = GetNextRegionId(regionId); + Debug.Log($"[Guardian] {(region != null ? region.displayName : regionId)} 수문장 격파({via})! " + + (next != null && IsHeldByStoryLock(next) ? "다음 지역은 이야기 대결이 남았다" : "다음 지역 해금됨")); return true; } + // 이 리전이 아직 이야기 대결 때문에 닫혀 있어야 하는가. 판정 함수가 없으면(미배선) 잠그지 않는다 — 옛 동작. + private bool IsHeldByStoryLock(string regionId) + { + return storyDuelWon != null && TryGetStoryLock(regionId, out StoryLock storyLock) + && !storyDuelWon(storyLock.storyNpcId); + } + public void DefeatGuardian(string regionId) { // 중복 격파 가드 — BattleScreenUI.CheckGuardianDefeat가 IsGuardianDefeated 가드 후 호출하지만 @@ -239,7 +451,9 @@ public void DefeatGuardian(string regionId) defeatedGuardians.Add(regionId); string nextRegion = GetNextRegionId(regionId); - if (!string.IsNullOrEmpty(nextRegion)) + // **이야기 잠금이 남았으면 해금 기록을 미룬다.** 저장된 해금은 "닫지 않는다"는 약속이라(IsOpenByProgress), + // 여기서 적어 버리면 간부를 이기기 전에 영영 열린다. 간부를 이기는 순간 IsRegionAccessible이 두 기록으로 연다. + if (!string.IsNullOrEmpty(nextRegion) && !IsHeldByStoryLock(nextRegion)) { unlockedRegions.Add(nextRegion); } @@ -255,14 +469,16 @@ public void DefeatGuardian(string regionId) // 해금·저장이 끝난 뒤에 알린다 — 구독자(StoryDirector)가 발화 시점에 // IsRegionAccessible 같은 상태를 읽어도 이미 갱신된 값을 보게 한다. // 위 idempotent 가드 덕에 리전당 정확히 1회만 울린다. + // 배지가 먼저다 — 배지 서비스가 이정표 보상까지 지급해 둔 뒤에 스토리·봉인이 움직인다. + GuardianBadgeEarned?.Invoke(regionId); GuardianDefeated?.Invoke(regionId); } // GetRegionWithGuardianNear(위치, 반경)는 제거했다 — **좌표로는 수문장을 판별할 수 없다.** - // 야생 스폰 링이 플레이어를 따라오므로(InsectSpawner.RelocateSpawnPoints: 10~43m 나선 + - // SpawnPoint.radius 5m) 수문장 앞에 선 순간 야생이 반경 5m까지 들어온다. 어떤 반경을 골라도 - // 야생 조우가 격파로 잡히는 구조라, 격파 판정은 개체 표식(InsectEntity.GuardianRegionId)으로 - // 옮겼다. 이름이 그럴듯해 다시 불려 나가지 않도록 함수째 지운다. + // 당시 야생 스폰 링이 플레이어를 따라왔으므로(10~43m 나선 + 5m 산포) 수문장 앞에 선 순간 야생이 + // 반경 5m까지 들어왔다. 지금은 야생이 리전 원판 전체에 기록돼 흩어지지만(InsectSpawner) 수문장 앞을 + // 비켜 두지 않으니 같다 — 어떤 반경을 골라도 야생 조우가 격파로 잡히는 구조라, 격파 판정은 개체 표식 + // (InsectEntity.GuardianRegionId)으로 옮겼다. 이름이 그럴듯해 다시 불려 나가지 않도록 함수째 지운다. /// /// 수문장이 서는 자리 — 이전 리전에서 오는 길목, 리전 경계 안쪽이다. diff --git a/Assets/Scripts/Core/RegionPalette.cs b/Assets/Scripts/Core/RegionPalette.cs new file mode 100644 index 0000000..134aa93 --- /dev/null +++ b/Assets/Scripts/Core/RegionPalette.cs @@ -0,0 +1,69 @@ +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 리전 바닥 색의 단일 출처 — 바닥 평면(PlaySceneBootstrap)과 그 위의 얼룩·디테일 + /// ()이 같은 표를 읽는다. + /// + /// 옛 바닥색은 공식이었다(themeColor × 0.5 + 0.1). 테마색은 지도·표지판용 강조색이라 + /// 그걸 어둡게 눌러 바닥에 깔면 서릿길은 회녹색(눈이 아니다), 모래언덕은 탁한 겨자색, 꽃밭은 적갈색, + /// 연못은 리전 전체가 파란 "물"이 되어 플레이어가 물 위를 걸었다. 그래서 리전마다 손으로 고른다. + /// + /// 값은 감마 공간 반사율이다. 햇빛(1.2 × NdotL≈0.77) + Trilight 환경광을 합친 조명이 약 1.3배라 + /// 화면에 나오는 색은 여기 적은 값의 1.3배쯤이다 — 눈을 0.9 넘게 적으면 하얗게 날아간다(실제로 그랬다). + /// + /// 여기 없는 리전은 옛 공식으로 떨어진다() — 리전을 늘리면 이 표도 함께 늘린다 + /// (FieldThemeTests.RegionPalette_CoversEveryRegion이 전 리전 등록을 고정한다). + /// + public static class RegionPalette + { + public static bool Has(string regionId) + { + return TryGet(regionId, out _, out _, out _); + } + + /// 바닥 평면 색. 미등록 리전은 테마색 공식. + public static Color Ground(string regionId, Color themeColor) + { + if (TryGet(regionId, out Color ground, out _, out _)) return ground; + return new Color(themeColor.r * 0.5f + 0.1f, themeColor.g * 0.5f + 0.1f, themeColor.b * 0.4f + 0.08f); + } + + /// 바닥 위 얼룩 두 톤(밝은·짙은). 단색 판 느낌을 깨는 용도라 바닥색에서 크게 벗어나지 않는다. + public static void PatchTones(string regionId, Color themeColor, out Color light, out Color dark) + { + if (TryGet(regionId, out Color ground, out light, out dark)) return; + ground = Ground(regionId, themeColor); + light = Color.Lerp(ground, Color.white, 0.12f); + dark = Color.Lerp(ground, Color.black, 0.14f); + } + + private static bool TryGet(string regionId, out Color ground, out Color light, out Color dark) + { + switch (regionId) + { + case "meadow": ground = C(0.29f, 0.45f, 0.21f); light = C(0.35f, 0.51f, 0.24f); dark = C(0.25f, 0.39f, 0.18f); return true; + // 연못 — 물가 풀밭. 물은 호수·여울 원반이 따로 칠한다 + case "pond": ground = C(0.28f, 0.44f, 0.24f); light = C(0.33f, 0.49f, 0.26f); dark = C(0.33f, 0.34f, 0.24f); return true; + case "forest": ground = C(0.21f, 0.33f, 0.16f); light = C(0.27f, 0.39f, 0.18f); dark = C(0.24f, 0.24f, 0.15f); return true; + case "swamp": ground = C(0.24f, 0.28f, 0.17f); light = C(0.29f, 0.33f, 0.19f); dark = C(0.18f, 0.20f, 0.14f); return true; + case "mountain": ground = C(0.40f, 0.38f, 0.32f); light = C(0.46f, 0.44f, 0.38f); dark = C(0.33f, 0.37f, 0.25f); return true; + // 꽃밭 — 분홍은 꽃이 낸다. 바닥은 잘 가꾼 밝은 풀 + case "garden": ground = C(0.30f, 0.47f, 0.22f); light = C(0.36f, 0.53f, 0.25f); dark = C(0.26f, 0.40f, 0.19f); return true; + case "ruins": ground = C(0.44f, 0.40f, 0.31f); light = C(0.50f, 0.46f, 0.37f); dark = C(0.34f, 0.37f, 0.24f); return true; + case "hollow": ground = C(0.50f, 0.48f, 0.39f); light = C(0.56f, 0.54f, 0.45f); dark = C(0.43f, 0.41f, 0.33f); return true; + case "dunes": ground = C(0.66f, 0.57f, 0.39f); light = C(0.72f, 0.63f, 0.44f); dark = C(0.59f, 0.50f, 0.34f); return true; + case "frostline": ground = C(0.66f, 0.70f, 0.76f); light = C(0.74f, 0.77f, 0.82f); dark = C(0.56f, 0.62f, 0.70f); return true; + case "emberfall": ground = C(0.26f, 0.22f, 0.21f); light = C(0.36f, 0.33f, 0.31f); dark = C(0.16f, 0.14f, 0.14f); return true; + case "canopy": ground = C(0.24f, 0.41f, 0.21f); light = C(0.30f, 0.47f, 0.24f); dark = C(0.29f, 0.28f, 0.18f); return true; + case "nameless": ground = C(0.38f, 0.37f, 0.40f); light = C(0.45f, 0.44f, 0.47f); dark = C(0.31f, 0.30f, 0.34f); return true; + default: + ground = light = dark = Color.clear; + return false; + } + } + + private static Color C(float r, float g, float b) => new Color(r, g, b, 1f); + } +} diff --git a/Assets/Scripts/Core/RegionPalette.cs.meta b/Assets/Scripts/Core/RegionPalette.cs.meta new file mode 100644 index 0000000..01b5c46 --- /dev/null +++ b/Assets/Scripts/Core/RegionPalette.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8d8bc122a56a30042899b664a387f3cd \ No newline at end of file diff --git a/Assets/Scripts/Core/RegionTerrainBuilder.cs b/Assets/Scripts/Core/RegionTerrainBuilder.cs index 5fa758c..99fddb8 100644 --- a/Assets/Scripts/Core/RegionTerrainBuilder.cs +++ b/Assets/Scripts/Core/RegionTerrainBuilder.cs @@ -1,4 +1,4 @@ -using InsectGame.Data; +using InsectGame.Data; using UnityEngine; namespace InsectGame.Core @@ -22,11 +22,33 @@ public class RegionTerrainBuilder : MonoBehaviour /// private const int TerrainLayoutSeed = 20260807; + private RegionData[] routeRegions; + + /// 리전 안 길 한 구간(XZ). 폭은 m. + public struct PathSegment + { + public Vector3 from; + public Vector3 to; + public float width; + } + + private readonly System.Collections.Generic.List internalPaths = + new System.Collections.Generic.List(); + + /// + /// 이 빌더가 깐 리전 안 길. 가 풀포기·조약돌을 길 위에 + /// 뿌리지 않으려고 읽는다 — 길은 콜라이더가 없어 레이캐스트로는 못 가린다. 읽기 전용으로만 내준다. + /// + public System.Collections.Generic.IReadOnlyList InternalPaths => internalPaths; + public void BuildAllRegions(RegionData[] regions) { // 배치 난수만 시드로 가두고 끝나면 되돌린다. **전역 상태를 복원하지 않으면** // 스폰·IV·포획 판정까지 결정론이 되어 훨씬 나쁜 문제가 된다. // 빌드 도중 예외가 나도 반드시 되돌아가도록 finally에 둔다. + routeRegions = regions; + // 둔덕 높이 표는 이 빌더가 채운다 — 씬 재로드(로그아웃·계정삭제) 뒤 옛 월드의 둔덕이 남지 않게 비우고 시작한다 + FieldGround.Clear(); Random.State prevRandomState = Random.state; Random.InitState(TerrainLayoutSeed); try @@ -69,52 +91,21 @@ public void BuildAllRegions(RegionData[] regions) public void BuildBoundaries(RegionData[] regions) { if (regions == null || regions.Length == 0) return; - const float NeighborThresholdExtra = 30f; - const float DefaultGatewayWidthDeg = 22f; // 약 5m gateway (r=50 기준 호 길이) + routeRegions = regions; + BeginFenceBatch(); 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)); - } - } - + Vector3 direction = WorldRouteLayout.GetGateway(r, regions) - r.centerPosition; + float angle = Mathf.Atan2(direction.z, direction.x) * Mathf.Rad2Deg; + // 폭 6m 통로 + 가장자리 장애물 여유. 단일 입구와 실제 필드 길이 같은 좌표를 쓴다. + float widthDeg = 8f / Mathf.Max(1f, r.radius - 1f) * Mathf.Rad2Deg; + var gateways = new System.Collections.Generic.List<(float angleDeg, float widthDeg)> { + (NormalizeAngle(angle), widthDeg) }; BuildFenceArc(r, gateways); } + EndFenceBatch(); } private static float NormalizeAngle(float deg) @@ -135,46 +126,249 @@ private static bool IsInGateway(float angleDeg, return false; } + /// + /// 기둥 자리가 다른 리전의 울타리 줄(반경 − 1m) 안쪽인가. 겹친 리전은 서로의 원 안으로 울타리가 파고든다 — + /// 초원·습지는 약 42m 겹쳐 습지 둔덕 기둥 16개가 초원 안에 섰고, 그중 4개가 본 마을(중심에서 12~17m)에, + /// 하나는 가챠 오두막 한가운데(1.0m)에 박혔다. 초원 기둥 14개도 반대로 습지 바닥 위를 가로질렀다. + /// 양쪽을 다 빼면 두 링이 교차점에서 만나 합집합의 바깥 테두리 하나가 된다. + /// + /// 기둥은 통행을 막지 않는다(6° 간격이라 기둥 사이가 리전에 따라 4.7~8m 빈다. 난간이 막는 초원은 예외다 — + /// , 겹친 쪽 끝은 가 이 원까지 잇는다) — 잠긴 리전을 막는 것은 + /// PlayerMovement.IsBlockedPosition의 원 판정(RegionData.ContainsPoint)이라 기둥을 빼도 진입 차단은 그대로다. + /// 겹친 자리의 경계는 위에 깔리는 리전 바닥(평면 높이가 순번 × 1mm라 습지가 초원 위)의 색 경계로 읽힌다 — + /// 그 색 경계가 곧 습지 잠금 원이다. + /// 기둥 배치엔 난수가 없어() 개수가 줄어도 뒤따르는 배치가 밀리지 않는다. + /// + /// 부트스트랩의 Barrier_ 고리(0.85R, 8개)도 이 판정을 쓴다(PlaySceneBootstrap.EnsureGround) — + /// 같은 겹침에서 초원 바위 하나(콜라이더 있음)가 습지 안에, 습지 말뚝 둘이 초원 안에 섰다. 규칙을 한 곳에 둬야 + /// 울타리는 비었는데 그 안쪽 장식만 남의 리전에 박히는 식으로 둘이 갈라지지 않는다. + /// + internal static bool IsInsideOtherRegionFence(RegionData[] regions, RegionData self, Vector3 pos) + { + if (regions == null) return false; + foreach (RegionData o in regions) + { + if (o == null || o == self) continue; + float dx = pos.x - o.centerPosition.x, dz = pos.z - o.centerPosition.z; + float inner = o.radius - 1f; + if (dx * dx + dz * dz < inner * inner) 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°) + const int segments = 60; // 6° 간격 (60 × 6 = 360°) + + // 빈 자리를 먼저 정한다 — 난간은 양 끝 기둥이 다 설 때만 잇는다(통로 쪽·다른 리전 쪽 모두) + const float stepDeg = 360f / segments; + var open = new bool[segments]; + var gatewayOpen = new bool[segments]; + var posts = new Vector3[segments]; 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); + float angDeg = stepDeg * s; + posts[s] = FenceRingPoint(r, fenceR, angDeg); + gatewayOpen[s] = IsInGateway(angDeg, gateways); + open[s] = gatewayOpen[s] || IsInsideOtherRegionFence(routeRegions, r, posts[s]); } - // gateway 위치에 노란 표지등(시각 anchor) — 통과 가능 시각 신호 + + bool solid = HasSolidRail(r.regionId); + for (int s = 0; s < segments; s++) + { + if (open[s]) continue; + float angDeg = stepDeg * s; + BuildFencePost(posts[s], angDeg, fenceMat, r.regionId); + + // 다음 기둥까지 잇는 난간 — 통로(gateway)나 다른 리전 쪽으로 빈 자리로는 잇지 않는다 + int next = (s + 1) % segments; + int prev = (s + segments - 1) % segments; + if (!open[next]) BuildFenceRail(posts[s], posts[next], r.regionId, s); + + // 막힌 울타리는 겹친 리전 쪽 끝을 그 리전의 원까지 잇는다(통로 쪽은 비워 둔다) + if (!solid) continue; + if (open[next] && !gatewayOpen[next]) CloseFenceEnd(r, fenceR, angDeg, stepDeg, fenceMat, s); + if (open[prev] && !gatewayOpen[prev]) CloseFenceEnd(r, fenceR, angDeg, -stepDeg, fenceMat, s); + } + // 기존 marker를 도로 옆 양면 이정표로 재사용한다. 지역당 하나만 만든다. 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()); + Vector3 outward = new Vector3(Mathf.Cos(rad), 0f, Mathf.Sin(rad)); + Vector3 gateway = r.centerPosition + outward * fenceR; + BuildGatewaySign(r, gateway, outward, g); } } + private static Vector3 FenceRingPoint(RegionData r, float fenceR, float angDeg) + { + float rad = angDeg * Mathf.Deg2Rad; + return r.centerPosition + new Vector3(Mathf.Cos(rad) * fenceR, 0f, Mathf.Sin(rad) * fenceR); + } + + /// + /// 난간이 통행을 막는 리전인가. 초원은 목장 울타리(기둥 + 가로대 두 줄)로 빈틈없이 둘러 그려지므로 그림대로 막는다 — + /// 나가는 길은 통로(길·이정표) 하나다. 다른 리전의 경계는 듬성듬성 선 바위·둔덕·말뚝이라 사이로 다니고, + /// 빈 골짜기는 끊긴 철사 울타리(버려진 목장)라 그대로 둔다. + /// + internal static bool HasSolidRail(string regionId) => regionId == "meadow"; + + /// 다른 리전의 원 안인가(RegionData.ContainsPoint — 잠긴 리전을 막는 판정과 같은 원). + private static bool IsInsideOtherRegion(RegionData[] regions, RegionData self, Vector3 pos) + { + if (regions == null) return false; + foreach (RegionData o in regions) + if (o != null && o != self && o.ContainsPoint(pos)) return true; + return false; + } + + /// + /// 울타리가 겹친 리전 앞에서 끊기는 자리를 그 리전의 원까지 잇는다(끝 기둥 + 난간). + /// + /// 겹친 리전 쪽 기둥은 그 리전의 울타리 줄(반경 − 1m) 안에 들면 빠진다(). + /// 그런데 기둥이 6° 간격(초원에서 7.7m)이라 마지막 기둥과 그 리전의 원 사이가 최대 한 칸 빈다 — 초원·습지에서 + /// 3.6m였고, 습지가 잠긴 동안에도 그 틈으로 울타리 밖에 나갈 수 있었다. 원 안으로 조금 더 들어가서 끝낸다: + /// 원 안쪽은 잠겨 있으면 원 판정이 막고, 열려 있으면 그 리전으로 넘어가는 자리다. + /// + private void CloseFenceEnd(RegionData r, float fenceR, float fromDeg, float stepDeg, Material mat, int index) + { + Vector3 from = FenceRingPoint(r, fenceR, fromDeg); + if (IsInsideOtherRegion(routeRegions, r, from)) return; // 기둥이 이미 그 리전 원 안이다 + + // fromDeg(원 밖) ~ fromDeg + stepDeg(그 리전의 울타리 줄 안 = 원 안) 사이에서 원에 들어서는 각도를 찾는다 + float lo = 0f, hi = 1f; + for (int i = 0; i < 12; i++) + { + float mid = (lo + hi) * 0.5f; + if (IsInsideOtherRegion(routeRegions, r, FenceRingPoint(r, fenceR, fromDeg + stepDeg * mid))) hi = mid; + else lo = mid; + } + float marginDeg = FenceEndOverlap / fenceR * Mathf.Rad2Deg; + float endDeg = fromDeg + stepDeg * Mathf.Min(1f, hi + marginDeg / Mathf.Abs(stepDeg)); + Vector3 to = FenceRingPoint(r, fenceR, endDeg); + if ((to - from).sqrMagnitude < 0.25f) return; + + BuildFencePost(to, endDeg, mat, r.regionId); + BuildFenceRail(from, to, r.regionId, index); + } + + /// 울타리 끝이 겹친 리전의 원 안으로 들어가는 길이(m). 플레이어 검사 구(반경 0.4m)보다 길어야 틈이 안 남는다. + private const float FenceEndOverlap = 0.8f; + + private void BuildGatewaySign(RegionData region, Vector3 gateway, Vector3 outward, int index) + { + Vector3 right = Vector3.Cross(Vector3.up, outward).normalized; + Vector3 basePosition = gateway + right * 3.4f; + GameObject marker = Prim(PrimitiveType.Cylinder, $"GatewayMarker_{region.regionId}_{index}"); + marker.transform.position = basePosition + Vector3.up * 0.57f; + marker.transform.localScale = new Vector3(0.16f, 0.57f, 0.16f); + Apply(marker, Mat(new Color(0.28f, 0.23f, 0.18f))); + RemoveSignCollider(marker); + Quaternion rotation = Quaternion.LookRotation(outward); + Vector3 boardPosition = basePosition + Vector3.up * 2.05f; + GameObject board = Prim(PrimitiveType.Cube, "WayfindingBoard"); + board.transform.SetPositionAndRotation(boardPosition, rotation); + board.transform.localScale = new Vector3(3.2f, 1.75f, 0.12f); + Apply(board, Mat(new Color(0.09f, 0.14f, 0.16f))); + RemoveSignCollider(board); + board.transform.SetParent(marker.transform, true); + GameObject band = Prim(PrimitiveType.Cube, "RegionColorBand"); + band.transform.SetPositionAndRotation(boardPosition + Vector3.up * 0.81f, rotation); + band.transform.localScale = new Vector3(3.22f, 0.13f, 0.15f); + Apply(band, Mat(Color.Lerp(region.themeColor, Color.white, 0.2f))); + RemoveSignCollider(band); + band.transform.SetParent(marker.transform, true); + string front = region.displayName; + string back = region.displayName; + foreach (WorldRouteEdge edge in WorldRouteLayout.FieldConnections) + { + string targetId = edge.FromRegionId == region.regionId ? edge.ToRegionId : + edge.ToRegionId == region.regionId ? edge.FromRegionId : null; + if (targetId == null) continue; + RegionData target = WorldRouteLayout.Find(routeRegions, targetId); + Vector3[] route = WorldRouteLayout.BuildRoute(routeRegions, region.regionId, targetId); + if (target == null || route.Length < 4) continue; + Vector3 direction = route[3] - route[2]; + float side = Vector3.Dot(direction.normalized, right); + string frontArrow = side > 0.2f ? "→" : side < -0.2f ? "←" : "↑"; + string backArrow = side > 0.2f ? "←" : side < -0.2f ? "→" : "↓"; + front += "\n" + frontArrow + " " + target.displayName; + back += "\n" + backArrow + " " + target.displayName; + } + BuildGatewayText(marker.transform, "DestinationsInside", front, + boardPosition - outward * 0.07f, rotation); + BuildGatewayText(marker.transform, "DestinationsOutside", back, + boardPosition + outward * 0.07f, rotation * Quaternion.Euler(0f, 180f, 0f)); + } + + private Font gatewayFont; + + private void BuildGatewayText(Transform parent, string name, string label, + Vector3 position, Quaternion rotation) + { + GameObject obj = new GameObject(name); + obj.transform.SetPositionAndRotation(position, rotation); + TextMesh text = obj.AddComponent(); + // Bootstrap의 Malgun Gothic 경로를 재사용하고 모바일/다른 OS의 한글 글꼴도 지정한다. + if (gatewayFont == null) + { + gatewayFont = Font.CreateDynamicFontFromOSFont(new[] { + "Malgun Gothic", "Apple SD Gothic Neo", "Noto Sans CJK KR", "Noto Sans KR", "Droid Sans Fallback" + }, 48); + // 이 빌더가 만든 폰트의 원본 머티리얼을 유지해 동적 atlas 재생성도 그대로 반영한다. + // 기본 GUI/Text shader는 깊이를 무시하여 반대편 글자가 판을 투과한다. + Shader textShader = Resources.Load("GatewayText"); + if (textShader != null) gatewayFont.material.shader = textShader; + } + text.font = gatewayFont; + text.fontSize = 48; + gatewayFont.RequestCharactersInTexture(label, text.fontSize, FontStyle.Normal); + obj.GetComponent().sharedMaterial = gatewayFont.material; + text.text = label; + text.characterSize = 1f; + text.anchor = TextAnchor.MiddleCenter; + text.alignment = TextAlignment.Center; + text.lineSpacing = 1.05f; + text.color = new Color(0.96f, 0.96f, 0.87f); + // 실제 글리프 경계로 3.2 x 1.75m 판 안에 맞춘다. 긴 한국어 이름/최대 네 줄도 동일 여백. + Vector3 measured = obj.GetComponent().localBounds.size; + if (measured.x > 0.001f && measured.y > 0.001f) + text.characterSize = Mathf.Min(2.85f / measured.x, 1.42f / measured.y); + else + text.characterSize = 0.12f; + obj.transform.SetParent(parent, true); + } + + private static void RemoveSignCollider(GameObject obj) + { + Collider collider = obj.GetComponent(); + if (collider == null) return; + collider.enabled = false; + Destroy(collider); + } + private Material GetFenceMaterial(string regionId) { switch (regionId) { - case "meadow": return Mat(new Color(0.55f, 0.45f, 0.3f)); // 돌담 + case "meadow": return Mat(new Color(0.52f, 0.38f, 0.22f)); // 나무 울타리 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)); // 폐허 벽 + // ── 2막 ── 옛날엔 case가 없어 여섯 곳 모두 같은 갈색 판자(default)가 섰다 + case "hollow": return Mat(new Color(0.74f, 0.72f, 0.66f)); // 표백된 말뚝 + case "dunes": return Mat(new Color(0.76f, 0.60f, 0.40f)); // 사암 덩이 + case "frostline": return Mat(new Color(0.72f, 0.86f, 0.95f)); // 얼음 덩이 + case "emberfall": return Mat(new Color(0.17f, 0.15f, 0.15f)); // 현무암 기둥 + case "canopy": return Mat(new Color(0.36f, 0.27f, 0.17f)); // 굵은 뿌리 + case "nameless": return Mat(new Color(0.24f, 0.23f, 0.28f)); // 이름 없는 석판 default: return Mat(new Color(0.5f, 0.45f, 0.35f)); } } @@ -211,16 +405,262 @@ private void BuildFencePost(Vector3 pos, float facingDeg, Material mat, string r post = Prim(PrimitiveType.Cube, "FenceMound"); scale = new Vector3(1.4f, 1.2f, 1.4f); break; + case "meadow": + // 목장 울타리 기둥 — 난간(BuildFenceRail)이 사이를 잇는다 + post = Prim(PrimitiveType.Cube, "FencePost"); + scale = new Vector3(0.24f, 1.15f, 0.24f); + break; + case "hollow": + post = Prim(PrimitiveType.Cylinder, "FenceStake"); + scale = new Vector3(0.16f, 0.7f, 0.16f); + break; + case "dunes": + post = Prim(PrimitiveType.Cube, "FenceSandstone"); + scale = new Vector3(1.3f, 1.0f, 0.9f); + break; + case "frostline": + post = Prim(PrimitiveType.Cube, "FenceIce"); + scale = new Vector3(1.1f, 1.25f, 0.8f); + break; + case "emberfall": + post = Prim(PrimitiveType.Cylinder, "FenceBasalt"); + scale = new Vector3(0.7f, 0.8f, 0.7f); + break; + case "canopy": + post = Prim(PrimitiveType.Cylinder, "FenceRoot"); + scale = new Vector3(0.55f, 0.9f, 0.55f); + break; + case "nameless": + post = Prim(PrimitiveType.Cube, "FenceSlab"); + scale = new Vector3(0.9f, 1.7f, 0.28f); + 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); + // 새로 둔 원기둥 기둥은 바닥에 선다(Cylinder 프리미티브는 높이 2단위라 반 높이가 scale.y다). + // 숲·연못의 옛 원기둥은 scale.y * 0.5 높이에 떠 있던 그대로 둔다 — 콜라이더 위치가 바뀌지 않게. + float halfHeight = IsGroundedCylinderPost(post.name) ? scale.y : scale.y * 0.5f; + post.transform.position = pos + new Vector3(0f, halfHeight, 0f); post.transform.localScale = scale; post.transform.rotation = Quaternion.Euler(0f, -facingDeg + 90f, 0f); // 원 접선 방향 Apply(post, mat); // collider 보존 — PlayerMovement.IsBlockedPosition OverlapSphere가 통과 차단 + DecorateFencePost(post.transform, pos, facingDeg, regionId); + RaiseColliderToBlockHeight(post); // 장식이 기울이고 묻은 뒤의 최종 자세로 잰다 + } + + private static bool IsGroundedCylinderPost(string name) => + name == "FenceStake" || name == "FenceBasalt" || name == "FenceRoot"; + + /// + /// 울타리 기둥 콜라이더 꼭대기의 최저 높이(월드 y). PlayerMovement.IsBlockedPosition은 발 위 1.4m에 반경 + /// 0.4m 구를 띄워 겹침을 보는데, 발은 리전 바닥 평면(0.08 + 순번 × 0.001) 위라 구 중심이 최고 1.49m다. + /// 콜라이더가 그 높이에 닿아야 구가 옆으로 0.4m를 온전히 잡는다 — 기울어진 기둥 몫으로 0.1m를 더 얹는다. + /// + private const float FenceColliderTopY = 1.6f; + + /// + /// 기둥의 보이는 모양은 그대로 두고 콜라이더만 위로 늘려 에 닿게 한다(아래는 그대로). + /// + /// 검사 구의 바닥이 발 위 1.0m라, 그보다 낮은 기둥은 아예 안 걸려 플레이어가 기둥을 뚫고 지나갔다 — 모래언덕 + /// 사암 덩이(0.2m 묻어 꼭대기 0.8m)와 산·유적 돌무더기(0.8m)가 그랬다. 1.49m보다 낮으면 걸리긴 해도 옆으로 닿는 + /// 거리가 짧아진다(초원 기둥 1.15m는 0.23m — 몸이 기둥에 반쯤 박힌다). 이미 높은 기둥은 건드리지 않는다. + /// 카메라 차폐(CameraFollower.ResolveObstruction)도 콜라이더를 보지만 꽃밭 생울타리(1.5m)·잿불 현무암(1.6m)· + /// 이름 없는 석판(1.7m)과 같은 높이라 새로운 종류의 가림은 아니다. + /// + private static void RaiseColliderToBlockHeight(GameObject post) + { + Transform t = post.transform; + // 기둥 로컬 y축이 기울면 같은 길이로 덜 올라간다 — 월드 높이를 로컬 길이로 바꿀 때 up.y로 나눈다 + float perLocalY = t.lossyScale.y * Mathf.Max(0.5f, t.up.y); + if (perLocalY < 0.0001f) return; + float neededTop = (FenceColliderTopY - t.position.y) / perLocalY; + + if (post.TryGetComponent(out BoxCollider box)) + { + float bottom = box.center.y - box.size.y * 0.5f; + if (bottom + box.size.y >= neededTop) return; + box.size = new Vector3(box.size.x, neededTop - bottom, box.size.z); + box.center = new Vector3(box.center.x, (bottom + neededTop) * 0.5f, box.center.z); + } + else if (post.TryGetComponent(out CapsuleCollider capsule) && capsule.direction == 1) + { + float bottom = capsule.center.y - capsule.height * 0.5f; + if (bottom + capsule.height >= neededTop) return; + capsule.height = neededTop - bottom; + capsule.center = new Vector3(capsule.center.x, (bottom + neededTop) * 0.5f, capsule.center.z); + } + } + + /// + /// 기둥 하나에 붙는 장식(콜라이더 없음). 기둥은 콜라이더를 보존해야 해서 모양을 크게 못 바꾸므로 + /// 테마의 인상은 여기서 준다 — 얼음 위 눈, 현무암 기둥 다발, 뿌리의 이끼, 말뚝의 기울기. + /// 난수를 쓰지 않는다: 시드 고정 배치라 한 번만 더 뽑아도 뒤따르는 소품 자리가 통째로 밀린다. + /// 변주는 각도에서 파생한다. + /// + /// 장식은 GameObject가 아니라 에 모아 칸 단위 메시로 합친다 — 리전 13곳 × 기둥 60개에 + /// 장식 두세 개씩이면 오브젝트가 800개를 넘는다(드로우콜도 그만큼). + /// + private void DecorateFencePost(Transform post, Vector3 pos, float facingDeg, string regionId) + { + float v = Mathf.Repeat(facingDeg * 0.37f, 1f); // 기둥마다 다른 0~1 + Quaternion tangent = Quaternion.Euler(0f, -facingDeg + 90f, 0f); + switch (regionId) + { + case "frostline": + Deco(PrimitiveType.Sphere, pos + new Vector3(0f, 1.27f, 0f), tangent, new Vector3(1.25f, 0.28f, 0.95f), new Color(0.95f, 0.97f, 1f)); + Deco(PrimitiveType.Sphere, pos + tangent * new Vector3(0.3f, 0.12f, 0.25f), tangent, new Vector3(1.6f, 0.5f, 1.2f), new Color(0.93f, 0.95f, 0.98f)); + break; + case "dunes": + post.rotation = tangent * Quaternion.Euler(v * 10f - 5f, 0f, v * 12f - 6f); + post.position += Vector3.down * 0.2f; // 반쯤 묻힌 덩이 + Deco(PrimitiveType.Sphere, pos + new Vector3(0f, 0.05f, 0f), tangent, new Vector3(2.2f, 0.45f, 1.5f), new Color(0.74f, 0.63f, 0.44f)); + break; + case "emberfall": + { + // 높이가 다른 현무암 기둥 다발 + Color basalt = new Color(0.21f, 0.18f, 0.18f); + float hb = 0.55f + v * 0.3f; + Deco(PrimitiveType.Cylinder, pos + tangent * new Vector3(0.5f, hb, 0.15f), Quaternion.identity, new Vector3(0.5f, hb, 0.5f), basalt); + Deco(PrimitiveType.Cylinder, pos + tangent * new Vector3(-0.45f, 0.35f, -0.1f), Quaternion.identity, new Vector3(0.45f, 0.35f, 0.45f), basalt); + if (v > 0.6f) + Deco(PrimitiveType.Sphere, pos + new Vector3(0f, 1.62f, 0f), Quaternion.identity, new Vector3(0.32f, 0.12f, 0.32f), EmberGlow); + break; + } + case "canopy": + post.rotation = tangent * Quaternion.Euler(0f, 0f, v * 16f - 8f); + Deco(PrimitiveType.Sphere, pos + new Vector3(0f, 1.75f, 0f), tangent, new Vector3(0.75f, 0.35f, 0.75f), new Color(0.30f, 0.50f, 0.24f)); + Deco(PrimitiveType.Sphere, pos + tangent * new Vector3(0.55f, 0.3f, 0.3f), tangent, new Vector3(1.1f, 0.55f, 0.9f), new Color(0.26f, 0.52f, 0.22f)); + break; + case "hollow": + post.rotation = tangent * Quaternion.Euler(v * 22f - 11f, 0f, v * 18f - 9f); + break; + case "nameless": + post.rotation = tangent * Quaternion.Euler(0f, 0f, v * 8f - 4f); + break; + case "pond": + { + // 갈대 한 줄기 → 세 줄기 + 부들 이삭 + Color reed = new Color(0.38f, 0.50f, 0.24f); + Deco(PrimitiveType.Cylinder, pos + tangent * new Vector3(0.35f, 1.0f, 0.1f), Quaternion.Euler(v * 10f - 5f, 0f, 6f), new Vector3(0.08f, 1.0f, 0.08f), reed); + Deco(PrimitiveType.Cylinder, pos + tangent * new Vector3(-0.3f, 0.8f, -0.1f), Quaternion.Euler(0f, 0f, -7f), new Vector3(0.08f, 0.8f, 0.08f), reed); + // 이삭은 가운데 기둥(FenceReed) 꼭대기에 얹는다. 원기둥 메시는 높이 2단위라 꼭대기는 중심 + scale.y다 + // (FenceReed는 1.25 + 2.5 = 3.75m). 옛 y 2.35는 기둥 속(2.13~2.57)이었고 반경 0.07이 기둥 0.075보다 + // 가늘어 통째로 묻혔다 — 기둥 60개 × 원기둥 88정점이 안 보이는 채 합쳐졌다. 기둥보다 굵게(반경 0.1) 둔다. + const float headHalf = 0.22f; + float reedTop = post.position.y + post.localScale.y; + Deco(PrimitiveType.Cylinder, new Vector3(pos.x, reedTop + headHalf - 0.06f, pos.z), Quaternion.identity, + new Vector3(0.2f, headHalf, 0.2f), new Color(0.42f, 0.28f, 0.16f)); + break; + } + } + } + + /// + /// 기둥 사이 난간 — 초원은 두 줄 목책, 텅 빈 들은 군데군데 끊긴 밧줄. 콜라이더 없음(기둥이 막는다). + /// + private void BuildFenceRail(Vector3 from, Vector3 to, string regionId, int index) + { + float height; + float thick; + Color color; + int rails; + switch (regionId) + { + case "meadow": height = 0.95f; thick = 0.11f; color = new Color(0.58f, 0.43f, 0.26f); rails = 2; break; + case "hollow": height = 0.95f; thick = 0.035f; color = new Color(0.60f, 0.55f, 0.44f); rails = 1; break; + default: return; + } + if (regionId == "hollow" && index % 4 == 1) return; // 버려진 목장 — 끊긴 칸 + + Vector3 d = to - from; + Vector3 flat = new Vector3(d.x, 0f, d.z); + float len = flat.magnitude; + if (len < 0.1f) return; + Quaternion rot = Quaternion.LookRotation(flat / len, Vector3.up); + for (int k = 0; k < rails; k++) + { + float y = rails == 1 ? height - 0.12f : height * (0.5f + 0.5f * k); + Deco(PrimitiveType.Cube, (from + to) * 0.5f + Vector3.up * y, rot, new Vector3(thick, thick, len), color); + } + if (HasSolidRail(regionId)) AddRailBlocker((from + to) * 0.5f, rot, len); + } + + private Transform railBlockerRoot; + + /// 난간 차단 콜라이더의 두께(m). 보이는 가로대(0.11m)보다 조금 두껍게 — 기둥(0.24m)보다는 얇다. + private const float RailBlockerThickness = 0.2f; + + /// + /// 난간 한 칸의 통행 차단. 보이는 가로대는 합친 메시()라 콜라이더가 없어서, 눈에는 막힌 울타리인데 + /// 기둥 사이(초원 약 7.7m)를 그대로 걸어 나갔다. 바닥부터 까지 얇은 벽을 세운다 — + /// 기둥 콜라이더와 같은 높이라 PlayerMovement.IsBlockedPosition의 검사 구(발 위 1.0~1.8m)에 온전히 걸린다. + /// + /// 레이어는 Ignore Raycast(2)다. 겹침 검사(OverlapSphere)는 기본이 전 레이어라 이 벽을 보지만, + /// 레이·구 캐스트는 기본이 이 레이어를 건너뛴다 — 보이지 않는 벽이 카메라 차폐(CameraFollower.ResolveObstruction, + /// 남쪽 울타리 앞에 서면 카메라가 3.5m로 당겨졌을 것이다)·탭 이동·곤충 탭·접지 레이에 끼어들지 않는다. + /// + private void AddRailBlocker(Vector3 middle, Quaternion rotation, float length) + { + if (railBlockerRoot == null) + { + railBlockerRoot = new GameObject("FenceRailBlockers").transform; + railBlockerRoot.SetParent(transform, false); + } + var blocker = new GameObject("FenceRailBlocker") { layer = 2 }; + blocker.transform.SetParent(railBlockerRoot, false); + blocker.transform.SetPositionAndRotation(middle + Vector3.up * (FenceColliderTopY * 0.5f), rotation); + blocker.AddComponent().size = new Vector3(RailBlockerThickness, FenceColliderTopY, length); + } + + // ── 울타리 장식 배치(합친 메시) ── + private static readonly Color EmberGlow = new Color(1f, 0.45f, 0.15f); + private SceneryBatcher fenceBatch; + private readonly SceneryPalette fencePalette = new SceneryPalette(); + private readonly System.Collections.Generic.List fenceMeshes = new System.Collections.Generic.List(); + private readonly System.Collections.Generic.List fenceTextures = new System.Collections.Generic.List(); + // 색 + 표면(발광·광택) 키 — 울타리 장식 배처 하나만 쓴다(리전 장식 RegionDressingBuilder의 캐시와는 따로) + private readonly System.Collections.Generic.Dictionary fenceColorMats = + new System.Collections.Generic.Dictionary(); + + private void Deco(PrimitiveType type, Vector3 position, Quaternion rotation, Vector3 scale, Color color) + { + if (fenceBatch == null) return; + fenceBatch.Add(DecoMesh(type), position, rotation, scale, color); + } + + /// + /// 장식 메시 — 구는 저정점(내장 구는 515정점이라 수백 개를 합치면 정점만 수만 개다), 원기둥은 내장 것, + /// 상자는 모서리가 둥근 것. 전부 프로세스 캐시라 파괴하지 않는다. + /// + private static Mesh DecoMesh(PrimitiveType type) + { + switch (type) + { + case PrimitiveType.Sphere: return ProcMeshLibrary.LowSphere(0.5f, 0.5f, 0.5f, 6, 10); + case PrimitiveType.Cube: return ProcMeshLibrary.RoundedBox(Vector3.one, 0.04f, 1); + default: return OutfitShapeLibrary.GetPrimMesh(type); + } + } + + private void BeginFenceBatch() + { + fenceBatch = new SceneryBatcher(40f, fencePalette); + fenceBatch.MarkGlow(EmberGlow, new Color(1.4f, 0.5f, 0.12f)); + } + + private void EndFenceBatch() + { + if (fenceBatch == null) return; + var root = new GameObject("Scenery_FenceDeco").transform; + root.SetParent(transform, false); + // 칸 이름엔 Scenery_를 안 단다 — HideMainWorld가 위 root 하나만 끄면 된다(SceneryBatcher.Build 주석) + fenceBatch.Build(root, "FenceDeco", true, fenceMeshes, runtimeMaterials, fenceTextures, fenceColorMats); + fencePalette.Apply(); + SceneryBatcher.ClearSourceCache(); + fenceBatch = null; } @@ -241,19 +681,18 @@ private void BuildMeadowTerrain(Vector3 c, float rad) 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); + PlaceMound(hill, pos, hs * 2f, hs * 2f, Mathf.Lerp(0.28f, 0.42f, Mathf.InverseLerp(4f, 8f, hs))); Apply(hill, hillMat); Destroy(hill.GetComponent()); } // 개울 (얇은 물길) Material waterMat = Mat(new Color(0.3f, 0.5f, 0.7f, 0.5f)); - SetTransparent(waterMat); + SceneryMaterials.MakeFade(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); + Vector3 from = c + new Vector3(-rad * 0.4f, 0.13f, -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); @@ -335,45 +774,64 @@ private static Vector3 RandomSpotAvoiding(Vector3 c, float rad, Vector3 avoidCen return avoidCenter + away.normalized * (Mathf.Sqrt(avoidSq) + 2f); } + /// + /// 연못 부두(나루터 널판)의 바닥 자리 — XZ 중심과 크기(x 폭 2m · z 길이 6m, 남북으로 놓인다). + /// 호수 윤곽()이 이 자리를 기준으로 잡혀 있어 검사(FieldThemeTests)와 + /// 장식 회피가 같은 값을 읽는다. 부두를 옮기면 호수 쪽 끝이 물 밖으로 나간다 — 함께 다시 잰다. + /// + internal static void PondDockFootprint(Vector3 regionCenter, float rad, out Vector3 center, out Vector2 size) + { + center = regionCenter + new Vector3(-rad * 0.15f, 0f, -rad * 0.2f); + size = new Vector2(2f, 6f); + } + + private static bool OnDock(Vector3 p, Vector3 dockCenter, Vector2 dockSize, float margin) => + Mathf.Abs(p.x - dockCenter.x) < dockSize.x * 0.5f + margin && Mathf.Abs(p.z - dockCenter.z) < dockSize.y * 0.5f + margin; + // ======= 연못: 큰 호수 + 데크/부두 + 갈대 ======= + // 물 자체는 RegionDressingBuilder가 깐다(진흙·여울·깊은 물 세 겹 원반). 여기엔 물 위·물가 소품만 있다. 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()); + // 옛 "큰 호수"(반경 0.05R = 3.4m 반투명 원기둥)는 뺐다 — 장식 호수(평균 물 반경 0.29R)가 들어선 뒤로는 + // 깊은 물 한쪽에 떠 있는 어두운 얼룩일 뿐이었다. 난수를 쓰지 않던 자리라 뒤따르는 배치는 그대로다. - // 부두/데크 + // 부두/데크 — 호수 서쪽 물가에 남북으로 놓인다. 북쪽(호수 쪽) 끝 약 3m가 여울 위, 나머지는 진흙 물가 위다 + // (호수 윤곽을 이 자리에 맞췄다 — RegionDressingBuilder.PondLake). 옛 주석은 "부두가 호수 안에 든다"였지만 + // 실제로는 옛 호수의 진흙 끝에서도 1.2m 떨어진 풀밭이었다. + PondDockFootprint(c, rad, out Vector3 dockCenter, out Vector2 dockSize); 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); + deck.transform.position = dockCenter + new Vector3(0f, 0.2f, 0f); + deck.transform.localScale = new Vector3(dockSize.x, 0.15f, dockSize.y); 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); + float z = -2f + i * 1.8f; + post.transform.position = dockCenter + new Vector3(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()); } - // 갈대 군락 — 면적 비례 개수 (설계 반경 45) + // 갈대 군락 — 면적 비례 개수 (설계 반경 45). 옛 자리(옛 호수 중심에서 0.2~0.35R 고리)는 호수가 커지자 44%가 + // 물 안(34%는 물가에서 2m 넘게 안쪽)에 섰다. 같은 난수 두 개(각·거리)를 호수 윤곽 기준 물가 고리(물 가장자리의 + // 96~108%)로 옮긴다 — 난수 호출 수가 같아 뒤 리전(콜라이더 있는 나무·바위)의 배치가 밀리지 않는다. + RegionDressingBuilder.PondLakeLayout lake = RegionDressingBuilder.PondLake(c, rad); int reedCount = ScaleCount(20, rad, 45f); for (int i = 0; i < reedCount; 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); + float shore = Random.Range(0.96f, 1.08f); + Vector3 pos = lake.water.EdgePoint(a, shore); + // 부두 널판을 뚫고 솟지 않게 — 난수를 더 뽑지 않고 각을 정해진 만큼 돌려 비킨다 + for (int k = 0; k < 8 && OnDock(pos, dockCenter, dockSize, 0.4f); k++) + pos = lake.water.EdgePoint(a += 0.3f, shore); + pos.y = 0f; GameObject reed = Prim(PrimitiveType.Cylinder, $"Scenery_PondReed_{i}"); reed.transform.position = pos + new Vector3(0f, 1f, 0f); @@ -398,15 +856,16 @@ private void BuildPondTerrain(Vector3 c, float rad) Destroy(step.GetComponent()); } - // 수련잎 — 호수 수면 위 납작 원반 (신규 장식, 호수가 리전 중심이라 예외적으로 중심부 배치) + // 수련잎 — 옛 호수 중심(c + (3,2)) 둘레 3m 안의 한 무리. 장식 호수의 깊은 물 안쪽(호수 중심에서 약 8.6m)이라 + // 그대로 둔다 — 호수 전체에 흩어진 수련은 RegionDressingBuilder가 따로 띄운다. Material lilyMat = Mat(new Color(0.2f, 0.55f, 0.25f)); int lilyCount = ScaleCount(5, rad, 45f); - float lakeR = rad * 0.05f; // Scenery_Lake 반경 (scale x = rad*0.1 = 지름) + float lakeR = rad * 0.05f; // 무리 반경의 기준(옛 원기둥 호수 반경이었다) for (int i = 0; i < lilyCount; i++) { float a = Random.Range(0f, Mathf.PI * 2f); float d = Random.Range(lakeR * 0.2f, lakeR * 0.85f); - Vector3 pos = c + new Vector3(3f + Mathf.Cos(a) * d, 0.07f, 2f + Mathf.Sin(a) * d); + Vector3 pos = c + new Vector3(3f + Mathf.Cos(a) * d, 0.15f, 2f + Mathf.Sin(a) * d); GameObject pad = Prim(PrimitiveType.Cylinder, $"Scenery_LilyPad_{i}"); pad.transform.position = pos; float ls = Random.Range(0.5f, 0.9f); @@ -460,6 +919,7 @@ private void BuildForestTerrain(Vector3 c, float rad) 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); + pos = ClearSceneryPosition(pos, c, rad, 4f); float treeH = Random.Range(0.8f, 1.5f); GameObject trunk = Prim(PrimitiveType.Cylinder, $"Scenery_ForestTree_{i}"); @@ -484,6 +944,7 @@ private void BuildForestTerrain(Vector3 c, float rad) 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); + pos = ClearSceneryPosition(pos, c, rad, 4f); GameObject log = Prim(PrimitiveType.Cylinder, $"Scenery_Log_{i}"); log.transform.position = pos; @@ -499,6 +960,7 @@ private void BuildForestTerrain(Vector3 c, float rad) 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); + pos = ClearSceneryPosition(pos, c, rad, 4f); float rs = Random.Range(0.6f, 1.5f); GameObject rock = Prim(PrimitiveType.Sphere, $"Scenery_ForestRock_{i}"); @@ -548,6 +1010,7 @@ private void BuildForestTerrain(Vector3 c, float rad) float a = Random.Range(0f, Mathf.PI * 2f); float d = rad * Random.Range(0.2f, 0.75f); Vector3 pos = c + new Vector3(Mathf.Cos(a) * d, 0f, Mathf.Sin(a) * d); + pos = ClearSceneryPosition(pos, c, rad, 4f); GameObject stump = Prim(PrimitiveType.Cylinder, $"Scenery_Stump_{i}"); float sh = Random.Range(0.25f, 0.45f); @@ -563,7 +1026,7 @@ 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); + SceneryMaterials.MakeFade(waterMat); Material deadWoodMat = Mat(new Color(0.3f, 0.22f, 0.15f)); // 수렁 (진흙 + 물 웅덩이) — 면적 비례 개수 (설계 반경 45) @@ -572,7 +1035,8 @@ private void BuildSwampTerrain(Vector3 c, float rad) { 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); + Vector3 pos = c + new Vector3(Mathf.Cos(a) * d,0.12f, Mathf.Sin(a) * d); + pos = ClearSceneryPosition(pos, c, rad, 4f); float ps = Random.Range(2f, 4f); GameObject pool = Prim(PrimitiveType.Cylinder, $"Scenery_SwampPool_{i}"); @@ -589,6 +1053,7 @@ private void BuildSwampTerrain(Vector3 c, float rad) 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); + pos = ClearSceneryPosition(pos, c, rad, 4f); GameObject trunk = Prim(PrimitiveType.Cylinder, $"Scenery_DeadTree_{i}"); float h = Random.Range(2f, 4f); @@ -649,6 +1114,7 @@ private void BuildSwampTerrain(Vector3 c, float rad) float a = Random.Range(0f, Mathf.PI * 2f); float d = rad * Random.Range(0.2f, 0.75f); Vector3 pos = c + new Vector3(Mathf.Cos(a) * d, Random.Range(0.8f, 1.6f), Mathf.Sin(a) * d); + pos = ClearSceneryPosition(pos, c, rad, 4f); GameObject wisp = Prim(PrimitiveType.Sphere, $"Scenery_Wisp_{i}"); wisp.transform.position = pos; float ws = Random.Range(0.15f, 0.25f); @@ -672,6 +1138,7 @@ private void BuildMountainTerrain(Vector3 c, float rad) 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); + pos = ClearSceneryPosition(pos, c, rad, 4f); float rs = Random.Range(1.5f, 4f); GameObject rock = Prim(PrimitiveType.Sphere, $"Scenery_MountainRock_{i}"); @@ -686,12 +1153,14 @@ private void BuildMountainTerrain(Vector3 c, float rad) { 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); + Vector3 pos = c + new Vector3(Mathf.Cos(a) * d,0.13f, Mathf.Sin(a) * d); + pos = ClearSceneryPosition(pos, c, rad, 4f); - GameObject snow = Prim(PrimitiveType.Plane, $"Scenery_Snow_{i}"); - snow.transform.position = pos; + // Plane(10m 정사각형)이면 종이를 깐 듯 모서리가 각져 보였다 — 낮은 눈 더미로 둔다. + // 크기는 옛 면적(한 변 ss×10m)을 그대로 따르고 난수는 새로 뽑지 않는다(시드 배치 보존). + GameObject snow = Prim(PrimitiveType.Sphere, $"Scenery_Snow_{i}"); float ss = Random.Range(0.3f, 0.6f); - snow.transform.localScale = new Vector3(ss, 1f, ss); + PlaceMound(snow, pos, ss * 10f, ss * 8.5f, 0.12f); Apply(snow, snowMat); Destroy(snow.GetComponent()); } @@ -709,6 +1178,7 @@ private void BuildMountainTerrain(Vector3 c, float rad) float a = Random.Range(0f, Mathf.PI * 2f); float d = rad * Random.Range(0.2f, 0.75f); Vector3 pos = c + new Vector3(Mathf.Cos(a) * d, 0f, Mathf.Sin(a) * d); + pos = ClearSceneryPosition(pos, c, rad, 4f); GameObject pineTrunk = Prim(PrimitiveType.Cylinder, $"Scenery_Pine_{i}"); pineTrunk.transform.position = pos + new Vector3(0f, 0.25f, 0f); @@ -764,7 +1234,9 @@ private void BuildGardenTerrain(Vector3 c, float rad) 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); + pos = ClearSceneryPosition(pos, c, rad, 4f); + if (WorldRouteLayout.IsOnRoute(routeRegions, pos, rad * 0.18f)) continue; GameObject hedge = Prim(PrimitiveType.Cube, $"Scenery_GardenHedge_{i}"); hedge.transform.position = pos; hedge.transform.localScale = new Vector3(rad * 0.35f, 1.2f, 0.5f); @@ -841,6 +1313,7 @@ private void BuildGardenTerrain(Vector3 c, float rad) float a = Random.Range(0f, Mathf.PI * 2f); float d = rad * Random.Range(0.2f, 0.75f); Vector3 pos = c + new Vector3(Mathf.Cos(a) * d, 0f, Mathf.Sin(a) * d); + pos = ClearSceneryPosition(pos, c, rad, 4f); Quaternion bedRot = Quaternion.Euler(0f, Random.Range(0f, 360f), 0f); GameObject soil = Prim(PrimitiveType.Cube, $"Scenery_FlowerBed_{i}"); @@ -895,6 +1368,7 @@ private void BuildRuinsTerrain(Vector3 c, float rad) 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); + pos = ClearSceneryPosition(pos, c, rad, 4f); float wh = Random.Range(0.3f, 0.8f); float ww = Random.Range(3f, 6f); @@ -913,6 +1387,7 @@ private void BuildRuinsTerrain(Vector3 c, float rad) float a = i * Mathf.PI * 2f / pillarCount; float d = rad * 0.3f; Vector3 pos = c + new Vector3(Mathf.Cos(a) * d, 0f,Mathf.Sin(a) * d); + pos = ClearSceneryPosition(pos, c, rad, 4f); bool fallen = Random.value > 0.6f; GameObject pillar = Prim(PrimitiveType.Cylinder, $"Scenery_RuinPillar_{i}"); @@ -941,6 +1416,7 @@ private void BuildRuinsTerrain(Vector3 c, float rad) 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); + pos = ClearSceneryPosition(pos, c, rad, 4f); float rs = Random.Range(0.5f, 1.2f); GameObject moss = Prim(PrimitiveType.Sphere, $"Scenery_MossRock_{i}"); @@ -958,6 +1434,7 @@ private void BuildRuinsTerrain(Vector3 c, float rad) float a = Random.Range(0f, Mathf.PI * 2f); float d = rad * Random.Range(0.2f, 0.75f); Vector3 pos = c + new Vector3(Mathf.Cos(a) * d, 0f, Mathf.Sin(a) * d); + pos = ClearSceneryPosition(pos, c, rad, 4f); float lean = Random.Range(4f, 12f); float leanDir = Random.Range(0f, 360f); for (int j = 0; j < 3; j++) @@ -985,6 +1462,7 @@ private void BuildRuinsTerrain(Vector3 c, float rad) float a = Random.Range(0f, Mathf.PI * 2f); float d = rad * Random.Range(0.25f, 0.7f); Vector3 pos = c + new Vector3(Mathf.Cos(a) * d, 0f, Mathf.Sin(a) * d); + pos = ClearSceneryPosition(pos, c, rad, 4f); GameObject pedestal = Prim(PrimitiveType.Cube, $"Scenery_Statue_{i}"); pedestal.transform.position = pos + new Vector3(0f, 0.25f, 0f); @@ -1026,8 +1504,7 @@ private void BuildHollowTerrain(Vector3 c, float rad) float hs = Random.Range(4f, 7f); GameObject hill = Prim(PrimitiveType.Sphere, $"Scenery_HollowRidge_{i}"); - hill.transform.position = pos + new Vector3(0f, -hs * 0.3f, 0f); - hill.transform.localScale = new Vector3(hs * 2.2f, hs * 0.28f, hs * 2.2f); + PlaceMound(hill, pos, hs * 2.2f, hs * 2.2f, Mathf.Lerp(0.2f, 0.3f, Mathf.InverseLerp(4f, 7f, hs))); Apply(hill, dirtMat); Destroy(hill.GetComponent()); } @@ -1109,8 +1586,9 @@ private void BuildHollowTerrain(Vector3 c, float rad) // ======= 모래언덕: 사구 능선 + 바위 노두 + 마른 관목 + 명부회 화물 ======= private void BuildDunesTerrain(Vector3 c, float rad) { - Material sandMat = Mat(new Color(0.86f, 0.74f, 0.46f)); - Material sandShadeMat = Mat(new Color(0.72f, 0.60f, 0.36f)); + // 바닥(RegionPalette)보다 한 톤만 밝게 — 더 밝으면 둔덕이 흰 원판처럼 떠 보인다 + Material sandMat = Mat(new Color(0.72f, 0.62f, 0.43f)); + Material sandShadeMat = Mat(new Color(0.62f, 0.52f, 0.36f)); Material rockMat = Mat(new Color(0.60f, 0.50f, 0.40f)); Material shrubMat = Mat(new Color(0.48f, 0.46f, 0.30f)); Material crateMat = Mat(new Color(0.55f, 0.42f, 0.26f)); @@ -1125,9 +1603,7 @@ private void BuildDunesTerrain(Vector3 c, float rad) float ds = Random.Range(6f, 11f); GameObject dune = Prim(PrimitiveType.Sphere, $"Scenery_Dune_{i}"); - dune.transform.position = pos + new Vector3(0f, -ds * 0.32f, 0f); - dune.transform.localScale = new Vector3(ds * 3f, ds * 0.42f, ds * 1.4f); - dune.transform.rotation = Quaternion.Euler(0f, 35f, 0f); + PlaceMound(dune, pos, ds * 3f, ds * 1.4f, Mathf.Lerp(0.4f, 0.62f, Mathf.InverseLerp(6f, 11f, ds)), 35f); Apply(dune, i % 3 == 0 ? sandShadeMat : sandMat); Destroy(dune.GetComponent()); } @@ -1195,9 +1671,9 @@ private void BuildDunesTerrain(Vector3 c, float rad) // ======= 서릿길: 눈 언덕 + 얼음 기둥 + 언 나무 ======= private void BuildFrostlineTerrain(Vector3 c, float rad) { - Material snowMat = Mat(new Color(0.90f, 0.93f, 0.96f)); + Material snowMat = Mat(new Color(0.77f, 0.81f, 0.86f)); // 바닥(RegionPalette)보다 한 톤 밝게 Material iceMat = Mat(new Color(0.66f, 0.84f, 0.92f, 0.72f)); - SetTransparent(iceMat); + SceneryMaterials.MakeFade(iceMat); Material rockMat = Mat(new Color(0.52f, 0.56f, 0.60f)); Material frozenWoodMat = Mat(new Color(0.44f, 0.48f, 0.52f)); @@ -1211,8 +1687,7 @@ private void BuildFrostlineTerrain(Vector3 c, float rad) float ds = Random.Range(5f, 9f); GameObject drift = Prim(PrimitiveType.Sphere, $"Scenery_SnowDrift_{i}"); - drift.transform.position = pos + new Vector3(0f, -ds * 0.28f, 0f); - drift.transform.localScale = new Vector3(ds * 2.4f, ds * 0.45f, ds * 2f); + PlaceMound(drift, pos, ds * 2.4f, ds * 2f, Mathf.Lerp(0.3f, 0.48f, Mathf.InverseLerp(5f, 9f, ds))); Apply(drift, snowMat); Destroy(drift.GetComponent()); } @@ -1310,9 +1785,8 @@ private void BuildEmberfallTerrain(Vector3 c, float rad) float fs = Random.Range(6f, 10f); GameObject flow = Prim(PrimitiveType.Sphere, $"Scenery_LavaFlow_{i}"); - flow.transform.position = pos + new Vector3(0f, -fs * 0.34f, 0f); - flow.transform.localScale = new Vector3(fs * 2.6f, fs * 0.38f, fs * 1.8f); - flow.transform.rotation = Quaternion.Euler(0f, Random.Range(0f, 360f), 0f); + // 요 난수는 예전(놓은 뒤 rotation 대입)과 같은 차례에 한 번 뽑힌다 — 뒤따르는 배치가 밀리지 않는다 + PlaceMound(flow, pos, fs * 2.6f, fs * 1.8f, 0.08f, Random.Range(0f, 360f)); Apply(flow, basaltMat); Destroy(flow.GetComponent()); } @@ -1345,6 +1819,7 @@ private void BuildEmberfallTerrain(Vector3 c, float rad) GameObject mound = Prim(PrimitiveType.Sphere, $"Scenery_AshMound_{i}"); mound.transform.position = pos + new Vector3(0f, ms * 0.18f, 0f); mound.transform.localScale = new Vector3(ms * 1.8f, ms * 0.6f, ms * 1.8f); + RegisterDome(mound); // 꼭대기가 바닥 위 0.48~1.05m — 기어다니는 곤충이 통째로 묻히는 높이다 Apply(mound, ashMat); Destroy(mound.GetComponent()); } @@ -1432,6 +1907,7 @@ private void BuildCanopyTerrain(Vector3 c, float rad) GameObject mound = Prim(PrimitiveType.Sphere, $"Scenery_MossMound_{i}"); mound.transform.position = pos + new Vector3(0f, ms * 0.12f, 0f); mound.transform.localScale = new Vector3(ms * 2f, ms * 0.42f, ms * 2f); + RegisterDome(mound); // 꼭대기가 바닥 위 0.36~0.9m Apply(mound, mossMat); Destroy(mound.GetComponent()); } @@ -1455,8 +1931,7 @@ private void BuildNamelessTerrain(Vector3 c, float rad) float ps = Random.Range(7f, 11f); GameObject plate = Prim(PrimitiveType.Sphere, $"Scenery_VoidPlate_{i}"); - plate.transform.position = pos + new Vector3(0f, -ps * 0.36f, 0f); - plate.transform.localScale = new Vector3(ps * 2.6f, ps * 0.4f, ps * 2.4f); + PlaceMound(plate, pos, ps * 2.6f, ps * 2.4f, 0.05f); Apply(plate, voidGroundMat); Destroy(plate.GetComponent()); } @@ -1500,6 +1975,46 @@ private void BuildNamelessTerrain(Vector3 c, float rad) // ======= 공통 유틸 ======= + /// 리전 바닥 평면의 높이(PlaySceneBootstrap이 0.08 + 리전 순번 × 0.001에 깐다). 둔덕 높이 표와 같은 기준이어야 한다. + private const float RegionFloorY = FieldGround.FloorY; + + /// + /// 꼭대기가 리전 바닥 위로 m 드러나는 낮은 둔덕으로 구를 놓는다. + /// width·depth는 **지면에 드러난 자국의 지름**이다. + /// + /// 옛 식(y = -0.3hs, scaleY = 0.28hs 같은 것)은 꼭대기가 지면 **아래**였다 — 모래언덕의 사구, + /// 서릿길의 눈 언덕, 텅 빈 들의 능선, 잿불의 용암 판, 초원의 언덕이 전부 묻혀 안 보였고 + /// 그 리전들이 평평한 단색 판으로 찍혔다. + /// + /// 높이는 호출부의 기존 크기 난수에서 파생한다 — 난수를 새로 뽑지 않는다. 시드 고정 배치라 + /// 한 번만 더 뽑아도 뒤따르는 나무·바위(콜라이더가 있다) 자리가 통째로 밀려 길이 바뀐다. + /// + /// 구의 위 40%(수직 반축 b 중 0.4b)만 드러내 가장자리 경사를 완만하게 둔다. **콜라이더는 두지 않는다** — + /// PlayerMovement의 접지는 Max(pos.y, hit.y)라 올라가기만 하고 내려오지 않아서, + /// 둔덕을 밟고 지나가면 그 높이에 뜬 채 남는다. 그래서 rise를 발목~무릎 아래로 낮게 잡는다. + /// + /// 대신 모양을 에 올린다 — 평면 y에 서는 곤충·플레이어가 둔덕 속에 묻히지 않게 + /// 스폰·이동 쪽이 좌표로 높이를 묻는다. 회전()까지 여기서 정해야 등록이 최종 자세를 본다 + /// (예전엔 사구·용암 판이 놓은 뒤에 따로 돌렸다 — 난수를 쓰는 용암 판은 인자 자리에서 뽑아 순서가 같다). + /// + internal static void PlaceMound(GameObject mound, Vector3 pos, float width, float depth, float rise, float yawDeg = 0f) + { + float b = Mathf.Max(0.01f, rise) / 0.4f; + mound.transform.SetPositionAndRotation(new Vector3(pos.x, RegionFloorY + rise - b, pos.z), Quaternion.Euler(0f, yawDeg, 0f)); + mound.transform.localScale = new Vector3(width / 0.8f, b * 2f, depth / 0.8f); + RegisterDome(mound); + } + + /// + /// 콜라이더 없는 구 둔덕(내장 Sphere, 부모 없음)을 지면 높이 표에 올린다 — 를 거치지 않는 + /// 재 더미·이끼 둔덕도 같은 타원체라 여기로 부른다. 자세가 다 정해진 뒤에 불러야 한다. + /// + internal static void RegisterDome(GameObject mound) + { + Transform t = mound.transform; + FieldGround.AddDome(t.position, t.lossyScale * 0.5f, t.eulerAngles.y); + } + // 소품 개수를 면적 비례로 유지 — designRadius는 소품 수치를 설계했던 스케일 1.0 기준 반경. // WorldScale 확장으로 radius가 커져도 밀도(개수/면적)가 희석되지 않도록 보정. private static int ScaleCount(int baseCount, float radius, float designRadius) @@ -1509,6 +2024,7 @@ private static int ScaleCount(int baseCount, float radius, float designRadius) private void CreateInternalPath(Vector3 from, Vector3 to, float width, Material pathMat, Material edgeMat) { + internalPaths.Add(new PathSegment { from = from, to = to, width = width }); Vector3 dir = to - from; float dist = new Vector3(dir.x, 0f, dir.z).magnitude; if (dist < 1f) return; @@ -1522,7 +2038,7 @@ private void CreateInternalPath(Vector3 from, Vector3 to, float width, Material float segLen = dist / segCount; GameObject path = Prim(PrimitiveType.Plane, $"Scenery_InPath_{i}"); - path.transform.position = mid + new Vector3(0f, 0.06f, 0f); + path.transform.position = mid + new Vector3(0f, 0.17f, 0f); path.transform.rotation = Quaternion.Euler(0f, angle, 0f); path.transform.localScale = new Vector3(width / 10f, 1f, segLen / 10f); Apply(path, pathMat); @@ -1539,7 +2055,7 @@ private void CreateInternalPath(Vector3 from, Vector3 to, float width, Material { 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.position = pos + perp * (width * 0.55f) * side + new Vector3(0f, 0.12f + ss * 0.15f, 0f); stone.transform.localScale = new Vector3(ss * 1.3f, ss * 0.3f, ss); Apply(stone, edgeMat); Destroy(stone.GetComponent()); @@ -1549,6 +2065,7 @@ private void CreateInternalPath(Vector3 from, Vector3 to, float width, Material private void CreateStairPath(Vector3 from, Vector3 to, int stepCount, Material mat) { + internalPaths.Add(new PathSegment { from = from, to = to, width = 3f }); Vector3 dir = to - from; float yDiff = dir.y; for (int i = 0; i < stepCount; i++) @@ -1573,6 +2090,26 @@ private GameObject Prim(PrimitiveType type, string name) return obj; } + private Vector3 ClearSceneryPosition(Vector3 position, Vector3 center, float radius, float footprint) + { + if (routeRegions == null || !WorldRouteLayout.IsOnRoute(routeRegions, position, footprint)) return position; + Vector3 delta = position - center; + for (int i = 1; i <= 36; i++) + { + Vector3 candidate = center + Quaternion.Euler(0f, i * 10f, 0f) * delta; + if (!WorldRouteLayout.IsOnRoute(routeRegions, candidate, footprint)) return candidate; + } + // 중심에 너무 가까운 장식도 통로 바깥 가장자리에서 유한 횟수로 찾는다. + for (int i = 0; i < 36; i++) + { + float angle = i * 10f * Mathf.Deg2Rad; + Vector3 candidate = center + new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * radius * 0.7f; + candidate.y = position.y; + if (!WorldRouteLayout.IsOnRoute(routeRegions, candidate, footprint)) return candidate; + } + return position; + } + private void Apply(GameObject obj, Material mat) { MeshRenderer mr = obj.GetComponent(); @@ -1584,7 +2121,7 @@ private void Apply(GameObject obj, Material mat) /// 로그아웃·계정삭제가 씬을 재로드하므로 회수하지 않으면 재로드마다 지형 한 벌이 샌다. /// /// 색상 캐시를 쓰지 않는 이유: 여기서 나온 머티리얼은 뒤에서 변형된다 - /// (SetTransparent가 물·얼음에, 발광 설정이 따로). 색으로 공유하면 같은 색의 + /// (가 물·얼음에, 발광 설정이 따로). 색으로 공유하면 같은 색의 /// 불투명 지형까지 함께 투명해진다. VillageBuilder는 생성 뒤 아무도 안 건드려서 /// 캐시가 성립한다 — 그쪽과 방식이 갈리는 건 그 차이 때문이다. /// @@ -1593,32 +2130,33 @@ private void Apply(GameObject obj, Material mat) private void OnDestroy() { + // 둔덕 높이 표는 정적이다 — 월드가 사라지면 함께 비워 다음 씬(메인 메뉴 등)이 옛 둔덕 높이를 보지 않게 한다 + FieldGround.Clear(); for (int i = 0; i < runtimeMaterials.Count; i++) if (runtimeMaterials[i] != null) Destroy(runtimeMaterials[i]); runtimeMaterials.Clear(); + foreach (Mesh m in fenceMeshes) if (m != null) Destroy(m); + foreach (Texture2D t in fenceTextures) if (t != null) Destroy(t); + fenceMeshes.Clear(); + fenceTextures.Clear(); + if (gatewayFont != null) Destroy(gatewayFont); + gatewayFont = null; } + /// + /// 셰이더 폴백·색·무광 마감은 가 단일 출처다(체인 사본이 빌더마다 있었다). + /// 여기선 회수 목록에만 올린다 — 반투명·발광은 호출부가 뒤에서 따로 입힌다(색 캐시를 안 쓰는 이유, 위 주석). + /// 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; + Material mat = SceneryMaterials.Create(color); runtimeMaterials.Add(mat); 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; - } + // 반투명(물·얼음)은 SceneryMaterials.MakeFade가 단일 출처다. 옛 사본(SetTransparent)은 _Mode를 3으로 적고 + // 키워드 둘(_ALPHATEST_ON·_ALPHAPREMULTIPLY_ON)을 끄지 않았지만, Create가 그 둘을 켜지 않고 _Mode는 셰이더 패스가 + // 안 읽는 인스펙터 값이라 그리는 결과(블렌드·ZWrite·_ALPHABLEND_ON·큐 3000)는 같다. // 에미시브 느낌 — 실시간 Light 컴포넌트 없이 자체 발광 색상만 부여 (빌드 타임 1회). private void SetEmissive(Material mat, Color emission) diff --git a/Assets/Scripts/Core/SaveScope.cs b/Assets/Scripts/Core/SaveScope.cs index 2436e15..ad3411e 100644 --- a/Assets/Scripts/Core/SaveScope.cs +++ b/Assets/Scripts/Core/SaveScope.cs @@ -33,6 +33,12 @@ public static class SaveScope GameConstants.PrefsKeys.TutorialHidden, GameConstants.PrefsKeys.WeeklyContestClaimed, GameConstants.PrefsKeys.BlightCleansed, + GameConstants.PrefsKeys.BadgeMilestonesClaimed, + // 따라가는 마을 이야기. 계정 스코프 키가 생긴 뒤에 도입돼 옮겨 올 옛 전역 값이 없으므로 + // MigrationVersion은 그대로 두고 **삭제 목록**으로서만 여기 있다 — 빠지면 계정 삭제 후 + // 같은 uid로 재로그인했을 때 옛 계정의 따라가기가 새 게임에 되살아난다. + // (로그인 전 순간엔 ScopedKey가 전역 키를 돌려주지만 로컬 편의 상태라 진행과 무관하다.) + GameConstants.PrefsKeys.TrackedTale, "InsectGame.UnlockedRegions", "InsectGame.DefeatedGuardians", // 위 5개와 정확히 같은 결함이었다 — `SaveScope.PrefsKey`를 거치면서도 이 목록에 @@ -72,7 +78,8 @@ public static class SaveScope // WeeklyContestClaimed). 올려야 기존 기기의 전역 키가 계정 스코프로 이전된다. // 6: BlightCleansed(신규) + DefeatedLedgerBosses(누락분) 추가. // 8: 가챠 천장 카운터 3키(int) 추가. - private const int MigrationVersion = 8; + // 9: 배지 이정표 보상 수령 상태(BadgeMilestonesClaimed) 추가. + private const int MigrationVersion = 9; private static readonly string[] ScopedFiles = { @@ -84,6 +91,9 @@ public static class SaveScope GameConstants.SaveFiles.BattleTeam, GameConstants.SaveFiles.DexSave, GameConstants.SaveFiles.StoryProgress, + // 섬은 계정 스코프 경로가 생긴 뒤에 도입돼 옮겨 올 전역 파일이 없다 — MigrationVersion은 그대로 두고 + // **삭제 목록**으로서 여기 있다. 빠지면 계정을 지우고 같은 uid로 다시 로그인했을 때 옛 섬이 되살아난다. + GameConstants.SaveFiles.Island, }; private const string LocalOwnerKey = "InsectGame.LocalOwnerUid"; diff --git a/Assets/Scripts/Core/SceneryBatcher.cs b/Assets/Scripts/Core/SceneryBatcher.cs new file mode 100644 index 0000000..af25d72 --- /dev/null +++ b/Assets/Scripts/Core/SceneryBatcher.cs @@ -0,0 +1,302 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 장식 소품의 색표 — 256×1 텍스처 한 장. 소품마다 UV로 제 색 칸을 가리키므로 색이 몇 개든 머티리얼이 하나다. + /// + /// 처음엔 색마다 머티리얼을 나눴더니(리전 장식 전체에서) 머티리얼 201개·렌더러 3,407개가 나왔다 — 칸마다 + /// 색 수만큼 드로우콜이 늘었다. Standard 셰이더는 정점 색을 안 읽으므로 색을 텍스처로 옮긴다. + /// 감마 색공간 프로젝트라 텍스처 값이 그대로 알베도다. 점 필터 + 칸 중앙 UV라 이웃 색이 번지지 않는다. + /// + public sealed class SceneryPalette + { + public const int Size = 256; + + private readonly Dictionary index = new Dictionary(); + private readonly Color32[] pixels = new Color32[Size]; + private Texture2D texture; + private Material material; + private bool overflowWarned; + + /// + /// 색 칸을 얻는다. 256칸이 차면 false — 호출부는 색별 머티리얼로 떨어진다. + /// + /// 넘치면 경고를 한 번 남긴다. 폴백 자체는 멀쩡히 그려지므로 아무도 눈치채지 못한 채 칸마다 + /// 드로우콜이 색 수만큼 다시 늘어난다(팔레트를 도입한 이유가 통째로 사라진다). 리전 장식은 2026-09-28에 + /// 약 205색이라 여유가 50칸뿐이다 — 리전·색을 늘리다 넘기면 이 경고가 먼저 알려 준다. + /// + public bool TryIndex(Color c, out int i) + { + if (index.TryGetValue(c, out i)) return true; + if (index.Count >= Size) + { + if (!overflowWarned) + { + overflowWarned = true; + Debug.LogWarning($"[SceneryPalette] 색 {Size}칸이 찼다 — 넘친 색은 색별 머티리얼로 떨어져 칸마다 드로우콜이 는다. " + + "장식 색 수를 줄이거나(가까운 색 합치기) 팔레트를 늘릴 것."); + } + i = -1; + return false; + } + i = index.Count; + index[c] = i; + pixels[i] = c; + return true; + } + + public static Vector2 UV(int i) => new Vector2((i + 0.5f) / Size, 0.5f); + + /// 팔레트 머티리얼(무광). 처음 부를 때 만들고, 가 색을 올린다. + public Material GetMaterial(List ownedMaterials, List ownedTextures) + { + if (material != null) return material; + texture = new Texture2D(Size, 1, TextureFormat.RGBA32, false) + { + name = "SceneryPalette", + filterMode = FilterMode.Point, + wrapMode = TextureWrapMode.Clamp, + }; + ownedTextures.Add(texture); + material = SceneryMaterials.Create(Color.white); + material.mainTexture = texture; + ownedMaterials.Add(material); + Apply(); + return material; + } + + /// 지금까지 모인 색을 텍스처에 올린다. 빌드가 끝난 뒤 한 번 더 부르면 된다. + public void Apply() + { + if (texture == null) return; + texture.SetPixels32(pixels); + texture.Apply(false, false); + } + } + + /// + /// 콜라이더 없는 장식 소품 수백~수천 개를 칸(cell) 단위 메시로 합친다. + /// + /// 소품 하나를 GameObject 하나로 두면(이 저장소의 옛 방식) 풀포기 천 개가 드로우콜 천 번이다. + /// 전부 한 메시로 합치면 리전 하나가 늘 통째로 그려져 프러스텀 컬링이 안 먹는다. 그래서 한 변 + /// m 칸으로 나눈다 — 카메라가 보는 칸만 그려진다. + /// + /// 색은 의 UV로 준다(칸당 메시·머티리얼 1개). 발광·광택이 필요한 색만 + /// 색별 머티리얼로 따로 합친다(/). 합치기는 직접 한다 — + /// Mesh.CombineMeshes는 인스턴스마다 UV를 바꿀 수 없다. 원본 메시는 읽기만 한다. + /// + public sealed class SceneryBatcher + { + private struct Item + { + public Mesh mesh; + public Matrix4x4 matrix; + public Color color; + } + + private struct Source + { + public Vector3[] vertices; + public Vector3[] normals; + public int[] triangles; + } + + private readonly float cellSize; + private readonly SceneryPalette palette; + private readonly Dictionary> cells = new Dictionary>(); + private readonly Dictionary glow = new Dictionary(); + private readonly HashSet wet = new HashSet(); + + /// 원본 메시 배열 캐시 — mesh.vertices는 부를 때마다 새 배열을 만든다(소품 수천 번). + private static readonly Dictionary sources = new Dictionary(); + + public SceneryBatcher(float cellSize, SceneryPalette palette) + { + this.cellSize = Mathf.Max(4f, cellSize); + this.palette = palette; + } + + public void MarkGlow(Color color, Color emission) => glow[color] = emission; + public void MarkWet(Color color) => wet.Add(color); + + public void Add(Mesh mesh, Vector3 position, Quaternion rotation, Vector3 scale, Color color) + { + if (mesh == null) return; + long key = CellKey(position); + if (!cells.TryGetValue(key, out List list)) + { + list = new List(); + cells[key] = list; + } + list.Add(new Item { mesh = mesh, matrix = Matrix4x4.TRS(position, rotation, scale), color = color }); + } + + /// + /// 합친 메시를 parent 아래에 만든다. 머티리얼·메시·텍스처는 넘긴 목록에 넣으므로 소유자(빌더)의 + /// OnDestroy가 회수한다. surfaceMaterials는 빌더 전체가 공유해 같은 색·같은 표면(발광·광택)이 + /// 머티리얼 하나로 모인다 — 키가 표면까지 보므로 같은 색이라도 표면이 다르면 따로 만든다(). + /// 공유 범위는 같은 사전 인스턴스를 넘긴 배처끼리다(리전 장식 전체가 하나, 울타리 장식이 하나). + /// 옛날엔 색만 키로 쓰는 Dictionary<Color, Material>를 받아 같은 색의 발광·광택이 먼저 만든 쪽으로 합쳐졌다. + /// + /// 칸 오브젝트 이름은 {namePrefix}_{n}이다. 에 Scenery_를 + /// 달지 말 것 — 서브에리어 진입 때 SubAreaWorldBuilder.HideMainWorld가 씬의 오브젝트를 전부 + /// 훑어 그 접두어를 가진 것을 하나하나 끄고 목록에 담는다. 접두어는 호출부가 만드는 parent에만 달면 + /// 루트 하나가 꺼지고 칸들은 부모를 따라 안 보인다(옛날엔 칸 수백 개가 따로 걸려 진입마다 SetActive 수백 번). + /// + public void Build(Transform parent, string namePrefix, bool castShadows, + List ownedMeshes, List ownedMaterials, List ownedTextures, + Dictionary surfaceMaterials) + { + int n = 0; + var paletteItems = new List(); + var byColor = new Dictionary>(); + foreach (var cell in cells) + { + paletteItems.Clear(); + byColor.Clear(); + foreach (Item item in cell.Value) + { + bool special = glow.ContainsKey(item.color) || wet.Contains(item.color); + if (!special && palette != null && palette.TryIndex(item.color, out _)) + { + paletteItems.Add(item); + continue; + } + if (!byColor.TryGetValue(item.color, out List list)) + { + list = new List(); + byColor[item.color] = list; + } + list.Add(item); + } + + if (paletteItems.Count > 0) + { + Mesh mesh = Combine(paletteItems, namePrefix, true); + ownedMeshes.Add(mesh); + Spawn(parent, $"{namePrefix}_{n++}", mesh, palette.GetMaterial(ownedMaterials, ownedTextures), castShadows); + } + foreach (var kv in byColor) + { + Mesh mesh = Combine(kv.Value, namePrefix, false); + ownedMeshes.Add(mesh); + Spawn(parent, $"{namePrefix}_{n++}", mesh, ColorMaterial(kv.Key, ownedMaterials, surfaceMaterials), castShadows); + } + } + cells.Clear(); + } + + /// 이 배처에서 가 받을 표면 — /가 정한다. + internal SceneryMaterialKey KeyFor(Color color) + { + bool isGlow = glow.TryGetValue(color, out Color emission); + return new SceneryMaterialKey(color, isGlow, emission, wet.Contains(color)); + } + + /// + /// 합친 메시의 인덱스 폭. 16비트 인덱스는 정점 65,535개까지라 넘으면 삼각형이 엉뚱한 정점을 가리켜 + /// 조각이 사방으로 튄다. 경계에 여유를 두고 65,000에서 32비트로 넘긴다(기본값이 16비트라 넘기지 않으면 그대로다). + /// + internal static UnityEngine.Rendering.IndexFormat IndexFormatFor(int vertexCount) => + vertexCount > 65000 ? UnityEngine.Rendering.IndexFormat.UInt32 : UnityEngine.Rendering.IndexFormat.UInt16; + + private Mesh Combine(List items, string name, bool usePalette) + { + int vCount = 0, tCount = 0; + foreach (Item it in items) + { + Source s = SourceOf(it.mesh); + vCount += s.vertices.Length; + tCount += s.triangles.Length; + } + var v = new Vector3[vCount]; + var nrm = new Vector3[vCount]; + var uv = new Vector2[vCount]; + var tri = new int[tCount]; + int vi = 0, ti = 0; + foreach (Item it in items) + { + Source s = SourceOf(it.mesh); + Matrix4x4 m = it.matrix; + Matrix4x4 nm = m.inverse.transpose; + Vector2 u = Vector2.zero; + if (usePalette && palette.TryIndex(it.color, out int idx)) u = SceneryPalette.UV(idx); + int baseIndex = vi; + for (int k = 0; k < s.vertices.Length; k++) + { + v[vi] = m.MultiplyPoint3x4(s.vertices[k]); + Vector3 sn = k < s.normals.Length ? s.normals[k] : Vector3.up; + nrm[vi] = nm.MultiplyVector(sn).normalized; + uv[vi] = u; + vi++; + } + for (int k = 0; k < s.triangles.Length; k++) tri[ti++] = baseIndex + s.triangles[k]; + } + var mesh = new Mesh { name = name }; + mesh.indexFormat = IndexFormatFor(vCount); // 삼각형을 넣기 전에 정해야 한다 + mesh.vertices = v; + mesh.normals = nrm; + mesh.uv = uv; + mesh.triangles = tri; + mesh.RecalculateBounds(); + // CPU 사본을 버린다 — 다시 읽을 일이 없는 정적 장식이라 GPU 쪽만 있으면 된다(메모리 절반) + mesh.UploadMeshData(true); + return mesh; + } + + private static Source SourceOf(Mesh mesh) + { + if (sources.TryGetValue(mesh, out Source s) && s.vertices != null) return s; + s = new Source { vertices = mesh.vertices, normals = mesh.normals, triangles = mesh.triangles }; + sources[mesh] = s; + return s; + } + + /// + /// 원본 배열 캐시를 비운다. 빌더가 자기 메시를 파괴하기 전에 불러야 한다 — 파괴된 메시를 키로 쥔 채 + /// 남으면 다음 씬에서 같은 인스턴스 ID가 재사용될 때 엉뚱한 배열이 나올 수 있다. + /// + public static void ClearSourceCache() => sources.Clear(); + + private static void Spawn(Transform parent, string name, Mesh mesh, Material mat, bool castShadows) + { + var go = new GameObject(name); + go.transform.SetParent(parent, false); + go.AddComponent().sharedMesh = mesh; + var mr = go.AddComponent(); + mr.sharedMaterial = mat; + mr.shadowCastingMode = castShadows + ? UnityEngine.Rendering.ShadowCastingMode.On + : UnityEngine.Rendering.ShadowCastingMode.Off; + } + + /// + /// 색별 머티리얼(발광·광택·팔레트 넘침). 캐시 키는 색 + 이 배처가 정한 표면이다 — 색만 키로 쓰면 같은 색이 + /// 다른 배처에서 다른 표면으로 지정될 때 먼저 만든 쪽으로 합쳐진다. 파괴된 항목(== null)은 새로 만든다. + /// + internal Material ColorMaterial(Color color, List owned, Dictionary cache) + { + SceneryMaterialKey key = KeyFor(color); + if (cache.TryGetValue(key, out Material cached) && cached != null) return cached; + Material mat = SceneryMaterials.Create(color); + if (key.Glow) SceneryMaterials.SetEmission(mat, key.Emission); + if (key.Wet) + { + if (mat.HasProperty("_Glossiness")) mat.SetFloat("_Glossiness", 0.82f); + if (mat.HasProperty("_Smoothness")) mat.SetFloat("_Smoothness", 0.82f); + } + owned.Add(mat); + cache[key] = mat; + return mat; + } + + private long CellKey(Vector3 p) + { + long cx = Mathf.FloorToInt(p.x / cellSize); + long cz = Mathf.FloorToInt(p.z / cellSize); + return (cx << 32) ^ (cz & 0xffffffffL); + } + } +} diff --git a/Assets/Scripts/Core/SceneryBatcher.cs.meta b/Assets/Scripts/Core/SceneryBatcher.cs.meta new file mode 100644 index 0000000..0522a74 --- /dev/null +++ b/Assets/Scripts/Core/SceneryBatcher.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d82014848eec91741ab4b513cad45980 \ No newline at end of file diff --git a/Assets/Scripts/Core/SceneryMaterialVisualCapture.cs b/Assets/Scripts/Core/SceneryMaterialVisualCapture.cs new file mode 100644 index 0000000..269960e --- /dev/null +++ b/Assets/Scripts/Core/SceneryMaterialVisualCapture.cs @@ -0,0 +1,157 @@ +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System.Collections; +using System.IO; +using System.Text; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 월드 머티리얼의 반투명·발광이 플레이어 빌드에서 실제로 그려지는지 재는 촬영 fixture(-battleScenario materials). + /// + /// 에디터에선 모든 셰이더 변형이 있어 늘 멀쩡하다 — 결함은 빌드가 Standard의 shader_feature 변형을 걸러낼 때만 + /// 난다(ShaderVariantKeepers 주석). 그래서 눈대중 대신 수치로 판정한다: 빨강·파랑 세로 줄무늬 벽 앞에 구를 세우고 + /// 구 가로줄의 (r−b) 편차를 잰다. 반투명이면 줄무늬가 비쳐 편차가 크고, 불투명 변형으로 떨어지면 거의 0이다. + /// 발광은 역광 쪽 어두운 구 두 개(발광/무발광)의 밝기 차로 본다. 필드 안개(Exp2)를 켠 판도 함께 찍는다 — + /// 안개 변형과 곱해진 조합이 따로 빠질 수 있다. + /// + public static class SceneryMaterialVisualCapture + { + private const float Radius = 0.6f; + + private struct Probe + { + public string name; + public Vector3 center; + } + + public static IEnumerator Run(string output, Camera camera) + { + camera.transform.position = new Vector3(0f, 1.2f, -6.5f); + camera.transform.LookAt(new Vector3(0f, 1.2f, 0f)); + camera.backgroundColor = new Color(0.2f, 0.2f, 0.2f); + + Light key = new GameObject("MaterialQAKey").AddComponent(); + key.type = LightType.Directional; + key.intensity = 1.1f; + // 구 앞면에 빛을 준다 — 발광 비교 구는 뒤쪽 면이 아니라 카메라를 향한 면을 재므로 조명 차가 없다. + key.transform.rotation = Quaternion.Euler(30f, 20f, 0f); + + // 줄무늬 벽: 0.5m 폭 빨강·파랑 번갈아. 불투명 무광(월드와 같은 생성 경로). + Material red = SceneryMaterials.Create(new Color(0.85f, 0.12f, 0.1f)); + Material blue = SceneryMaterials.Create(new Color(0.1f, 0.2f, 0.9f)); + for (int i = 0; i < 20; i++) + { + GameObject col = GameObject.CreatePrimitive(PrimitiveType.Cube); + col.transform.position = new Vector3(-5f + i * 0.5f + 0.25f, 1.5f, 3f); + col.transform.localScale = new Vector3(0.5f, 4f, 0.2f); + col.GetComponent().sharedMaterial = i % 2 == 0 ? red : blue; + } + + Color glow = new Color(1f, 0.8f, 0.3f); + Probe fade = Sphere("fade", -2.4f, M(new Color(0.95f, 0.95f, 0.95f, 0.35f), false, glow)); + Probe fadeGlow = Sphere("fade+emission", -0.8f, M(new Color(0.95f, 0.95f, 0.95f, 0.35f), true, glow)); + Probe glowing = Sphere("emission", 0.8f, M(new Color(0.12f, 0.12f, 0.12f), true, glow)); + Probe plain = Sphere("plain", 2.4f, M(new Color(0.12f, 0.12f, 0.12f), false, glow)); + + var report = new StringBuilder(); + report.AppendLine("Actual standalone render. Stripe signal = stddev of (r-b) across the sphere's middle row;"); + report.AppendLine("translucent spheres must show the stripe wall behind them. Emission = luminance(emission) - luminance(plain)."); + bool allPass = true; + foreach (bool fog in new[] { false, true }) + { + RenderSettings.fog = fog; + RenderSettings.fogMode = RegionAtmosphere.FieldFogMode; + RenderSettings.fogDensity = 0.02f; + RenderSettings.fogColor = new Color(0.6f, 0.65f, 0.7f); + yield return null; + yield return new WaitForEndOfFrame(); + Texture2D shot = ScreenCapture.CaptureScreenshotAsTexture(); + string tag = fog ? "fog-exp2" : "no-fog"; + File.WriteAllBytes(Path.Combine(output, "materials-" + tag + ".png"), shot.EncodeToPNG()); + + float wall = StripeSignal(shot, camera, new Vector3(0f, 2.9f, 3f), 1.2f); + float sFade = StripeSignal(shot, camera, fade.center, Radius); + float sFadeGlow = StripeSignal(shot, camera, fadeGlow.center, Radius); + float emission = Luminance(shot, camera, glowing.center) - Luminance(shot, camera, plain.center); + // 기준: 벽 자체 편차의 15% 이상이 구를 통해 보이면 비친다. 불투명 변형이면 1~2% 수준이다. + bool fadeOk = sFade > wall * 0.15f; + bool fadeGlowOk = sFadeGlow > wall * 0.15f; + bool emissionOk = emission > 0.12f; + allPass &= fadeOk && fadeGlowOk && emissionOk; + report.AppendLine($"[{tag}] wall={wall:0.000} fade={sFade:0.000} ({Verdict(fadeOk)}) " + + $"fade+emission={sFadeGlow:0.000} ({Verdict(fadeGlowOk)}) " + + $"emission-delta={emission:0.000} ({Verdict(emissionOk)})"); + Object.Destroy(shot); + } + report.AppendLine("RESULT " + (allPass ? "PASS" : "FAIL")); + File.WriteAllText(Path.Combine(output, "README.txt"), report.ToString()); + Debug.Log("[MaterialQA]\n" + report); + Application.Quit(allPass ? 0 : 5); // 4는 BattleVisualCapture 감시견(시간 초과)이 쓴다 + } + + private static string Verdict(bool ok) => ok ? "PASS" : "FAIL"; + + /// 월드 빌더와 같은 경로 — 생성 → 발광 → 반투명 순(SubAreaGateBuilder 연기 덩이가 이 순서다). + private static Material M(Color color, bool emission, Color glow) + { + Material m = SceneryMaterials.Create(color); + if (emission) SceneryMaterials.SetEmission(m, glow); + if (SceneryMaterials.IsTranslucent(color)) SceneryMaterials.MakeFade(m); + return m; + } + + private static Probe Sphere(string name, float x, Material mat) + { + GameObject s = GameObject.CreatePrimitive(PrimitiveType.Sphere); + s.name = "MaterialQA_" + name; + s.transform.position = new Vector3(x, 1.2f, 0f); + s.transform.localScale = Vector3.one * Radius * 2f; + s.GetComponent().sharedMaterial = mat; + return new Probe { name = name, center = s.transform.position }; + } + + /// 중심 가로줄의 가운데 60%에서 (r−b) 표준편차. + private static float StripeSignal(Texture2D shot, Camera camera, Vector3 center, float worldHalfWidth) + { + Vector3 c = camera.WorldToScreenPoint(center); + Vector3 edge = camera.WorldToScreenPoint(center + camera.transform.right * worldHalfWidth); + float half = Mathf.Abs(edge.x - c.x) * 0.6f; + int y = Mathf.Clamp(Mathf.RoundToInt(c.y), 0, shot.height - 1); + int x0 = Mathf.Clamp(Mathf.RoundToInt(c.x - half), 0, shot.width - 1); + int x1 = Mathf.Clamp(Mathf.RoundToInt(c.x + half), 0, shot.width - 1); + int n = 0; + float sum = 0f, sumSq = 0f; + for (int x = x0; x <= x1; x++) + { + Color p = shot.GetPixel(x, y); + float v = p.r - p.b; + sum += v; + sumSq += v * v; + n++; + } + if (n < 2) return 0f; + float mean = sum / n; + return Mathf.Sqrt(Mathf.Max(0f, sumSq / n - mean * mean)); + } + + /// 중심 둘레 5×5 픽셀 평균 휘도. + private static float Luminance(Texture2D shot, Camera camera, Vector3 center) + { + Vector3 c = camera.WorldToScreenPoint(center); + int cx = Mathf.RoundToInt(c.x), cy = Mathf.RoundToInt(c.y); + float sum = 0f; + int n = 0; + for (int dy = -2; dy <= 2; dy++) + for (int dx = -2; dx <= 2; dx++) + { + int x = Mathf.Clamp(cx + dx, 0, shot.width - 1), y = Mathf.Clamp(cy + dy, 0, shot.height - 1); + Color p = shot.GetPixel(x, y); + sum += 0.2126f * p.r + 0.7152f * p.g + 0.0722f * p.b; + n++; + } + return sum / n; + } + } +} +#endif diff --git a/Assets/Scripts/Core/SceneryMaterialVisualCapture.cs.meta b/Assets/Scripts/Core/SceneryMaterialVisualCapture.cs.meta new file mode 100644 index 0000000..7c90616 --- /dev/null +++ b/Assets/Scripts/Core/SceneryMaterialVisualCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8b4101e1ab87ef744a6a1ef222d16fe9 \ No newline at end of file diff --git a/Assets/Scripts/Core/SceneryMaterials.cs b/Assets/Scripts/Core/SceneryMaterials.cs new file mode 100644 index 0000000..a608f0b --- /dev/null +++ b/Assets/Scripts/Core/SceneryMaterials.cs @@ -0,0 +1,181 @@ +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 월드 소품(지형·건물·식생) 머티리얼의 생성·표면 마감·반투명 전환 — 단일 출처. + /// + /// 빌더 다섯 곳(PlaySceneBootstrap·RegionTerrainBuilder·WorldTerrainBuilder· + /// VillageBuilder·SubAreaWorldBuilder)이 각자 new Material(Standard)를 만들고 + /// 색만 넣었다. Standard의 기본 광택이 0.5라 **바위·흙·나무가 전부 플라스틱처럼 반짝였다** + /// (배치 캡처에서 산의 바위가 크롬 달걀로 찍혔다). 캐릭터는 로 + /// 이미 부위별 재질을 받으므로 여기서 다루지 않는다. + /// + /// 셰이더 폴백 체인과 Fade 전환도 빌더마다 사본이 있었다(체인 5벌, 반투명 4벌). 사본끼리 한 줄씩 어긋나기 + /// 시작하면 같은 색이 빌더마다 다르게 그려지므로 여기로 모았다. + /// + /// **반투명(알파 < 1)은 마감을 건드리지 않는다** — 물·얼음·유리는 반사가 제 질감이다. + /// + public static class SceneryMaterials + { + /// 흙·돌·나무·천의 광택. 0이면 완전 무광이라 형태 음영이 죽는다. + public const float MatteGloss = 0.12f; + + /// + /// 폴백 체인이 고른 셰이더. 셰이더는 에셋이라 씬 재로드에 파괴되지 않는다 — 정적 참조는 + /// Resources.UnloadUnusedAssets의 도달성 검사에도 잡혀 내려가지 않는다. 그래도 Unity의 == null + /// (파괴 검사)로 다시 찾게 해 두어 어떤 경로로든 파괴된 객체를 쥔 채 쓰지 않는다. + /// + private static Shader litShader; + + /// + /// Standard → URP Lit → Unlit/Color → Sprites/Default → Hidden/InternalErrorShader. + /// 처음 한 번만 찾는다 — 빌더들이 머티리얼마다 Shader.Find를 네 번씩 불렀다(서브에리어는 진입마다 수십 번). + /// + internal static Shader LitShader + { + get + { + if (litShader != null) return litShader; + 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"); + if (shader == null) shader = Shader.Find("Hidden/InternalErrorShader"); + litShader = shader; + return shader; + } + } + + /// + /// 플레이어 빌드에 변형이 남아 있어야 하는 Standard 키워드 조합 — 런타임에 켜는 조합 전부다 + /// ( · · 둘 다). 둘 다 Standard의 shader_feature라 빌드에 든 + /// 머티리얼이 쓰는 조합만 컴파일되는데, 이 저장소엔 .mat이 없어 반투명·발광 변형이 빌드에서 통째로 빠졌다 — + /// 없는 변형은 불투명·무발광 변형으로 그려진다. 에디터 도구 ShaderVariantKeepers가 이 표대로 + /// Resources/에 머티리얼을 두어 빌드가 그 조합을 남기게 한다. + /// 새 조합을 런타임에 켜면 여기에 한 줄 — ShaderVariantKeeperTests가 빠진 조합을 잡는다. + /// + /// Standard를 Always Included Shaders에 다시 넣지 말 것. 거기 든 셰이더는 머티리얼의 키워드 사용을 보지 않고 + /// shader_feature를 전부 끈 변형만 남긴다 — 이 머티리얼들을 Resources에 두고도 빌드 변형 수가 그대로였다(2026-09-29 + /// QA 빌드 실측: FORWARD 16 → 목록에서 빼자 64, 반투명 32·발광 32). Standard를 빌드에 싣는 일(의 + /// Shader.Find가 찾게 하는 일)은 키워드 없는 StandardOpaque가 대신한다. + /// + public static readonly (string name, string[] keywords)[] BuildKeepers = + { + ("StandardOpaque", new string[0]), // 키워드 없는 기본 — Standard 자체를 빌드에 싣는 것도 이 머티리얼이다(아래) + ("StandardFade", new[] { "_ALPHABLEND_ON" }), + ("StandardEmission", new[] { "_EMISSION" }), + ("StandardFadeEmission", new[] { "_ALPHABLEND_ON", "_EMISSION" }), + }; + + /// 머티리얼이 놓이는 Resources 하위 폴더. + public const string BuildKeeperFolder = "ShaderVariantKeepers"; + + /// 반투명으로 다룰 색인가 — 가 건너뛰고 가 필요한 쪽. + public static bool IsTranslucent(Color color) => color.a < 0.999f; + + public static void ApplyFinish(Material mat, Color color) + { + if (mat == null || IsTranslucent(color)) return; + if (mat.HasProperty("_Glossiness")) mat.SetFloat("_Glossiness", MatteGloss); + if (mat.HasProperty("_Smoothness")) mat.SetFloat("_Smoothness", MatteGloss); + if (mat.HasProperty("_Metallic")) mat.SetFloat("_Metallic", 0f); + } + + /// + /// 스스로 은은히 빛나게 한다(Standard의 _EMISSION). 구름처럼 아랫면이 환경광만 받아 + /// 칙칙해지는 것을 들어 올릴 때 쓴다. 폴백 셰이더에는 조용히 무시된다. + /// + public static void SetEmission(Material mat, Color emission) + { + if (mat == null || !mat.HasProperty("_EmissionColor")) return; + mat.EnableKeyword("_EMISSION"); + mat.SetColor("_EmissionColor", emission); + mat.globalIlluminationFlags = MaterialGlobalIlluminationFlags.None; + } + + /// + /// Standard를 Fade 모드로 돌린다 — Unity 표준 머티리얼 인스펙터가 하는 것과 같은 설정이다. + /// 빌드에서 이 변형이 살아 있는 건 덕이다. + /// + /// Standard는 기본이 Opaque라 mat.color에 알파를 넣어도 무시된다 — 블렌드·ZWrite·렌더 큐· + /// 키워드를 함께 세워야 실제로 비친다. 프로퍼티가 없는 폴백 셰이더(Unlit/Color 등)에서는 HasProperty + /// 가드가 조용히 넘어간다. + /// + /// 옛 사본 넷 중 지형 빌더 둘(WorldTerrainBuilder·SubAreaWorldBuilder의 SetTransparent)은 + /// _Mode를 3(Transparent)으로 적었지만 블렌드는 이것과 같은 SrcAlpha/OneMinusSrcAlpha(=Fade)였다. + /// _Mode는 셰이더 패스가 읽지 않는 인스펙터 전용 값이라 그리는 결과는 같다 — 실제 블렌드와 맞는 2로 통일했다. + /// + public static void MakeFade(Material mat) + { + if (mat == null) return; + if (mat.HasProperty("_Mode")) mat.SetFloat("_Mode", 2f); // 2 = Fade + if (mat.HasProperty("_SrcBlend")) mat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha); + if (mat.HasProperty("_DstBlend")) mat.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); + if (mat.HasProperty("_ZWrite")) mat.SetInt("_ZWrite", 0); + mat.DisableKeyword("_ALPHATEST_ON"); + mat.EnableKeyword("_ALPHABLEND_ON"); + mat.DisableKeyword("_ALPHAPREMULTIPLY_ON"); + mat.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent; + } + + /// Standard → URP Lit → Unlit 폴백으로 머티리얼을 만들고 마감까지 입힌다. + public static Material Create(Color color) + { + Material mat = new Material(LitShader); + mat.color = color; + if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color); + ApplyFinish(mat, color); + return mat; + } + } + + /// + /// 색별 머티리얼 캐시의 키 — 색 + 표면(발광색·젖은 광택). + /// + /// 옛 캐시는 색만 키로 썼다. 같은 색이 한 배처에선 발광, 다른 배처에선 광택(또는 팔레트가 넘친 무광)으로 + /// 지정되면 먼저 만든 쪽 머티리얼로 합쳐져 한쪽이 엉뚱하게 빛나거나 빛을 잃는다 — 예외도 경고도 없다. + /// 2026-09-29 기준 겹치는 색이 없어 무증상이었다. 같은 표면끼리는 여전히 하나로 모인다(드로우콜·머티리얼 수 그대로). + /// + public readonly struct SceneryMaterialKey : System.IEquatable + { + public Color Albedo { get; } + public bool Glow { get; } + /// 가 아니면 항상 default — 발광 없는 키끼리 쓰레기 값으로 갈라지지 않게. + public Color Emission { get; } + public bool Wet { get; } + + public SceneryMaterialKey(Color albedo, bool glow, Color emission, bool wet) + { + Albedo = albedo; + Glow = glow; + Emission = glow ? emission : default; + Wet = wet; + } + + /// 발광·광택 없는 기본 마감(무광). + public static SceneryMaterialKey Matte(Color albedo) => new SceneryMaterialKey(albedo, false, default, false); + + public static SceneryMaterialKey Glowing(Color albedo, Color emission) => new SceneryMaterialKey(albedo, true, emission, false); + + // Color.Equals는 성분별 정확 비교다(== 연산자는 근사 비교) — 옛 Dictionary와 같은 판정을 유지한다. + public bool Equals(SceneryMaterialKey other) => + Albedo.Equals(other.Albedo) && Glow == other.Glow && Emission.Equals(other.Emission) && Wet == other.Wet; + + public override bool Equals(object obj) => obj is SceneryMaterialKey other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + int h = Albedo.GetHashCode(); + h = h * 397 ^ Emission.GetHashCode(); + h = h * 397 ^ (Glow ? 1 : 0); + h = h * 397 ^ (Wet ? 2 : 0); + return h; + } + } + + public override string ToString() => $"{Albedo} glow={Glow} {Emission} wet={Wet}"; + } +} diff --git a/Assets/Scripts/Core/SceneryMaterials.cs.meta b/Assets/Scripts/Core/SceneryMaterials.cs.meta new file mode 100644 index 0000000..842a089 --- /dev/null +++ b/Assets/Scripts/Core/SceneryMaterials.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 41d2b8a0d41f38144a99249783f3c69b \ No newline at end of file diff --git a/Assets/Scripts/Core/SubAreaEnvironment.cs b/Assets/Scripts/Core/SubAreaEnvironment.cs index ddd0570..cdea43b 100644 --- a/Assets/Scripts/Core/SubAreaEnvironment.cs +++ b/Assets/Scripts/Core/SubAreaEnvironment.cs @@ -3,9 +3,22 @@ namespace InsectGame.Core { + /// + /// 리전·서브에리어의 조명 환경을 전환하고, 그 위에 낮·밤·날씨 하늘()을 얹는다. + /// + /// 이중 적용을 막는 구조. 전환 보간()은 "기본 상태"(, 하늘 보정 전)만 만든다. + /// 렌더 설정(RenderSettings·라이트·카메라 배경·스카이박스)에 쓰는 건 이 기본 상태에 하늘을 얹어 한 번에 한다. + /// 스냅샷()은 렌더 설정이 아니라 기본 상태에서 뜬다 — 렌더 설정에서 읽으면 밤색이 기본으로 굳어 + /// 리전을 옮길 때마다 하늘이 한 겹씩 더 얹힌다. + /// + /// 보정이 걸리는 곳은 메인 필드와 나의 섬뿐이다(). 동굴·방은 실내라 없고 + /// 「챔피언의 꿈」은 늘 맑은 한낮이다. 걸리고 풀리는 경계는 가 0.5초에 걸쳐 오가므로 동굴 입구에서 + /// 밤 하늘이 뚝 끊기지 않는다. + /// public class SubAreaEnvironment : MonoBehaviour { [SerializeField] private RegionManager regionManager; + [SerializeField] private WorldStateProvider worldState; private Light directionalLight; private Camera mainCamera; @@ -18,14 +31,22 @@ public class SubAreaEnvironment : MonoBehaviour private Color defaultFogColor = new Color(0.75f, 0.82f, 0.88f); private bool defaultFogEnabled; private float defaultFogDensity; + private FogMode defaultFogMode; // 하늘색 폴백 — Camera.main이 없어 캡처를 못 했을 때만 쓰인다(형제 필드와 동일 패턴). private Color defaultCameraBg = new Color(0.5f, 0.8f, 1f); // 메인 필드의 원래 카메라 클리어 플래그(보통 Skybox). 서브지역에선 SolidColor로 바꿔야 // backgroundColor가 실제로 렌더된다 — Skybox 모드에서 Unity는 backgroundColor를 무시한다. private CameraClearFlags defaultClearFlags = CameraClearFlags.Skybox; - // 메인 필드의 원래 환경광 모드(보통 Skybox). 서브지역에선 Flat로 바꿔 ambientColor가 실제 적용되게 하고, - // 빠져나올 때 이 값으로 복원한다. (Skybox 모드에선 ambientColor가 무시돼 서브지역을 밝힐 수 없었음) - private UnityEngine.Rendering.AmbientMode defaultAmbientMode = UnityEngine.Rendering.AmbientMode.Skybox; + // 메인 필드의 원래 환경광 모드 — 지금은 Trilight다(PlaySceneBootstrap.EnsureLight: 이 씬엔 구운 환경광 프로브가 + // 없어 Skybox 모드면 그늘이 새까맸다). CaptureDefaults가 실제 값을 읽어 덮으므로 이 초기값은 캡처 전 폴백일 뿐이다. + // 서브지역에선 Flat으로 바꿔 ambientColor 한 색이 사방을 비추게 하고(Trilight면 적도·바닥 색이 메인 필드 것으로 남는다. + // 예전 Skybox 모드에선 ambientColor가 아예 무시돼 서브지역을 밝힐 수 없었다), 빠져나올 때 이 값으로 복원한다. + private UnityEngine.Rendering.AmbientMode defaultAmbientMode = UnityEngine.Rendering.AmbientMode.Trilight; + // Trilight 환경광의 옆·아래 색과 그림자 세기 — 리전 대기는 위쪽(ambientLight = ambientSkyColor) 색만 바꾸므로 나머지는 + // 부트스트랩이 정한 값 그대로이고, 하늘 보정(밤·흐림)이 이 값 위에 얹힌다. CaptureDefaults가 실제 값을 읽어 덮는다. + private Color defaultAmbientEquator = new Color(0.42f, 0.43f, 0.41f); + private Color defaultAmbientGround = new Color(0.26f, 0.24f, 0.21f); + private float defaultShadowStrength = 0.5f; // 전환 상태 private EnvironmentProfile targetProfile; @@ -33,21 +54,89 @@ public class SubAreaEnvironment : MonoBehaviour private float transitionProgress = 1f; private float transitionSpeed = 2f; + // 기본 상태 — 전환 보간의 현재 값(하늘 보정 전). 스냅샷·전환의 출발점은 항상 이것이다. + private EnvironmentProfile baseState; + // 직전 보간에서 출발·도착 중 안개가 있었는가 — 필드 안개 모드(지수제곱)를 계속 쓸지 정한다. + private bool baseFogUsed; + + // ── 낮·밤·날씨 하늘 ── + /// 하늘을 갱신하는 간격(초). 전환 중에는 매 프레임이다 — 게임 1시간이 30초라 0.1초 단위면 눈에 안 띈다. + private const float SkyTickSeconds = 0.1f; + /// 하늘 보정이 걸리고 풀리는 데 드는 시간(초) — 전환 보간(transitionSpeed 2)과 같다. + private const float SkyFadeSeconds = 0.5f; + /// 를 못 찾았을 때 다시 찾기까지의 간격(초). + private const float WorldLookupSeconds = 2f; + + private WorldSkyRules.SkyFrame skyFrame; + private float skyBlend; // 0이면 기본 상태 그대로, 1이면 하늘 보정 전부 + private bool skyBlendReady; + private float skyClock; + private float skyLastTime; + private float lastWrittenBlend = -1f; + private float worldLookupClock; + private WorldSkyVisuals skyVisuals; + private bool initialized; + // 메인 필드에서 따라갈 리전 대기(RegionAtmosphere). 서브에리어 안에선 기록만 하고 적용하지 않는다. + private RegionData currentRegion; + public void AutoWire(RegionManager rm) { if (regionManager != null) + { regionManager.SubAreaChanged -= OnSubAreaChanged; + regionManager.RegionChanged -= OnRegionChanged; + } regionManager = rm; if (regionManager != null) + { regionManager.SubAreaChanged += OnSubAreaChanged; + regionManager.RegionChanged += OnRegionChanged; + } + } + + /// + /// 시계·날씨 공급자를 주입한다. 안 주입해도 FindFirstObjectByType로 찾지만(2초 간격 재시도), + /// 못 찾으면 하늘은 맑은 정오로 남는다 — 기존 한낮 모습 그대로다. + /// + public void AutoWire(WorldStateProvider provider) + { + if (provider != null) worldState = provider; + } + + private void OnEnable() + { + if (regionManager == null) return; + regionManager.SubAreaChanged -= OnSubAreaChanged; + regionManager.SubAreaChanged += OnSubAreaChanged; + regionManager.RegionChanged -= OnRegionChanged; + regionManager.RegionChanged += OnRegionChanged; + currentRegion = regionManager.CurrentRegion; + OnSubAreaChanged(regionManager.CurrentSubArea); } private void OnDisable() { if (regionManager != null) + { regionManager.SubAreaChanged -= OnSubAreaChanged; + regionManager.RegionChanged -= OnRegionChanged; + } + } + + /// + /// 리전이 바뀌면 그 리전의 대기로 옮겨 간다. 서브에리어 안(좌표가 2000대라 리전 판정이 멈춘다)에서는 + /// 기록만 해 두고, 나올 때 (null)가 그 리전 값으로 복귀한다. + /// + private void OnRegionChanged(RegionData region) + { + currentRegion = region; + if (!initialized) CaptureDefaults(); + if (regionManager != null && regionManager.CurrentSubArea != null) return; + currentState = SnapshotCurrent(); + targetProfile = BuildRegionProfile(currentRegion); + transitionProgress = 0f; } private void Start() @@ -59,7 +148,16 @@ private void CaptureDefaults() { if (initialized) return; - directionalLight = FindFirstObjectByType(); + directionalLight = RenderSettings.sun; + if (directionalLight == null) + { + foreach (Light candidate in FindObjectsByType(FindObjectsSortMode.None)) + { + if (candidate.type != LightType.Directional) continue; + directionalLight = candidate; + break; + } + } mainCamera = Camera.main; if (directionalLight != null) @@ -67,13 +165,17 @@ private void CaptureDefaults() defaultLightColor = directionalLight.color; defaultLightIntensity = directionalLight.intensity; defaultLightRotation = directionalLight.transform.rotation; + defaultShadowStrength = directionalLight.shadowStrength; } defaultAmbient = RenderSettings.ambientLight; + defaultAmbientEquator = RenderSettings.ambientEquatorColor; + defaultAmbientGround = RenderSettings.ambientGroundColor; defaultAmbientMode = RenderSettings.ambientMode; defaultFogEnabled = RenderSettings.fog; defaultFogColor = RenderSettings.fogColor; defaultFogDensity = RenderSettings.fogDensity; + defaultFogMode = RenderSettings.fogMode; if (mainCamera != null) { @@ -82,18 +184,190 @@ private void CaptureDefaults() } currentState = BuildDefaultProfile(); + baseState = currentState; targetProfile = currentState; + + // 스카이박스 복제본 — 낮밤·날씨가 노출을 바꾼다. 원본 에셋은 건드리지 않고 OnDestroy가 되돌린다. + skyVisuals = new WorldSkyVisuals(); + skyVisuals.CreateSkybox(); initialized = true; } + private void OnDestroy() + { + if (skyVisuals != null) + { + skyVisuals.Dispose(); + skyVisuals = null; + } + } + 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); + bool transitioning = transitionProgress < 1f; + if (transitioning) + { + transitionProgress = Mathf.Clamp01(transitionProgress + Time.deltaTime * transitionSpeed); + float t = Mathf.SmoothStep(0f, 1f, transitionProgress); + BlendBase(currentState, targetProfile, t); + } + + // 하늘 — 전환 중에는 매 프레임(기본 상태가 움직인다), 아니면 0.1초마다 + skyClock -= Time.unscaledDeltaTime; + if (!transitioning && skyClock > 0f) return; + skyClock = SkyTickSeconds; + RefreshSky(transitioning); + } + + private void LateUpdate() + { + // 별·연무 돔이 카메라를 따라다닌다(켜져 있을 때만 일한다) + if (skyVisuals != null) skyVisuals.Follow(mainCamera); + } + + // ── 낮·밤·날씨 하늘 ── + + /// 하늘 보정이 걸리는 곳인가 — 메인 필드·나의 섬이고 꿈이 아닐 때. + private bool SkyApplies() + { + SubAreaData sub = regionManager != null ? regionManager.CurrentSubArea : null; + return WorldSkyRules.Applies(sub != null, sub != null ? sub.subAreaId : null, DreamPrologueState.Active); + } + + /// + /// 날씨를 읽을 리전 — 메인 필드의 현재 리전. 섬·서브에리어에는 지역이 없으니 null(세계 날씨 그대로)이다. + /// CurrentRegion은 서브에리어 안에서도 부모 리전을 가리키므로 서브에리어를 먼저 거른다. + /// + private string WeatherRegionId() + { + if (regionManager == null || regionManager.CurrentSubArea != null) return null; + return regionManager.CurrentRegion != null ? regionManager.CurrentRegion.regionId : null; + } + + private WorldSkyRules.SkyFrame ComputeSkyFrame() + { + float hour = WorldSkyRules.NoonHour; + WorldSkyRules.WeatherMix mix = WorldSkyRules.WeatherMix.Of(WeatherType.Clear); + + if (worldState == null && Time.unscaledTime >= worldLookupClock) + { + worldState = FindFirstObjectByType(); + worldLookupClock = Time.unscaledTime + WorldLookupSeconds; + } + if (worldState != null) + { + GameClock clock = worldState.Clock; + if (clock != null) hour = clock.GetHourFloat(); + mix = WorldSkyRules.WeatherMix.From(worldState.Weather, WeatherRegionId()); + } + return WorldSkyRules.Evaluate(hour, mix); + } + + /// + /// 하늘 한 틱 — 걸림 비율을 목표로 옮기고 하늘 프레임을 새로 평가해 렌더 설정에 쓴다. + /// 걸릴 일이 없는 곳(동굴·방)에서는 비율이 0에 닿은 뒤 아무것도 쓰지 않는다. + /// + private void RefreshSky(bool force) + { + float now = Time.unscaledTime; + float dt = skyLastTime > 0f ? Mathf.Clamp(now - skyLastTime, 0f, 0.5f) : 0f; + skyLastTime = now; + + bool applies = SkyApplies(); + float target = applies ? 1f : 0f; + if (!skyBlendReady) + { + // 첫 틱은 서서히 걸지 않는다 — 켜자마자 기본 한낮에서 밤으로 페이드되는 걸 막는다 + skyBlend = target; + skyBlendReady = true; + } + else + { + skyBlend = Mathf.MoveTowards(skyBlend, target, dt / SkyFadeSeconds); + } + + if (applies || skyBlend > 0f) skyFrame = ComputeSkyFrame(); + + if (!force && !applies && skyBlend <= 0f && lastWrittenBlend <= 0f) return; + lastWrittenBlend = skyBlend; + WriteFinal(); + } + + private WorldSkyRules.LightingState ToState(EnvironmentProfile p, bool flatAmbient) + { + return new WorldSkyRules.LightingState + { + lightColor = p.lightColor, + lightIntensity = p.lightIntensity, + lightRotation = p.lightRotation, + shadowStrength = defaultShadowStrength, + ambientSky = p.ambientColor, + // 평면 환경광(서브에리어)은 한 색이 사방을 비춘다. 필드(Trilight)는 옆·아래가 부트스트랩이 정한 색이다. + ambientEquator = flatAmbient ? p.ambientColor : defaultAmbientEquator, + ambientGround = flatAmbient ? p.ambientColor : defaultAmbientGround, + fogEnabled = p.fogEnabled, + fogColor = p.fogColor, + fogDensity = p.fogDensity, + background = p.cameraBg, + }; + } + + /// + /// 기본 상태에 하늘을 얹은 최종 값을 렌더 설정에 쓴다 — 이 파일에서 RenderSettings·라이트·카메라 배경에 쓰는 유일한 곳이다. + /// 걸림 비율이 1 미만이면 기본 상태와 보정 결과를 섞는다(걸리고 풀리는 경계의 페이드). + /// + private void WriteFinal() + { + if (mainCamera == null) mainCamera = Camera.main; + bool inSubArea = regionManager != null && regionManager.CurrentSubArea != null; + + WorldSkyRules.LightingState b = ToState(baseState, inSubArea); + WorldSkyRules.LightingState f = b; + if (skyBlend > 0.001f) + { + WorldSkyRules.LightingState m = WorldSkyRules.Apply(b, skyFrame, !inSubArea); + f = skyBlend >= 0.999f ? m : WorldSkyRules.LightingState.Lerp(b, m, skyBlend); + } + + if (directionalLight != null) + { + directionalLight.color = f.lightColor; + directionalLight.intensity = f.lightIntensity; + directionalLight.transform.rotation = f.lightRotation; + directionalLight.shadowStrength = f.shadowStrength; + } + + // 평면(Flat) 환경광은 ambientLight, Trilight의 위쪽 색은 ambientSkyColor다 — 둘 다 같은 값을 적어 모드가 어느 쪽이든 맞는다. + // 옆·아래 색은 Trilight(메인 필드)에서만 쓰이고 서브에리어(평면)에선 건드리지 않는다. + RenderSettings.ambientLight = f.ambientSky; + RenderSettings.ambientSkyColor = f.ambientSky; + if (RenderSettings.ambientMode != UnityEngine.Rendering.AmbientMode.Flat) + { + RenderSettings.ambientEquatorColor = f.ambientEquator; + RenderSettings.ambientGroundColor = f.ambientGround; + } + + // **안개는 런타임에만 켠다** — 빌드 씬은 둘 다 m_Fog: 0이라, GraphicsSettings의 Fog Modes가 + // Automatic이면 기기 빌드에서 FOG_EXP/EXP2 셰이더 변형이 빠져 에디터에서만 보인다. + // 그래서 Custom(Exp·Exp2 유지)으로 둔다 — Automatic으로 되돌리지 말 것. + RenderSettings.fog = f.fogEnabled; + RenderSettings.fogColor = f.fogColor; + RenderSettings.fogDensity = f.fogEnabled ? f.fogDensity : 0f; + // 서브에리어는 좁은 방이라 지수(Exp), 메인 필드의 리전 연무는 원경만 흐리는 지수제곱(Exp2). + // 모드는 RegionAtmosphere의 상수로만 적는다 — 가시거리 검사(FieldThemeTests)가 같은 상수로 투과율을 계산하므로 + // 여기서 모드를 바꾸면 검사 계산도 함께 바뀐다(리터럴을 쓰면 FieldFog_RuntimeUsesTheSharedModeConstant가 잡는다). + // 안개 날씨의 밀도 상한(RegionAtmosphere.MaxWeatherFogDensity*)도 같은 상수·같은 식의 검사가 지킨다. + RenderSettings.fogMode = inSubArea ? RegionAtmosphere.SubAreaFogMode + : (f.fogEnabled || baseFogUsed) ? RegionAtmosphere.FieldFogMode : defaultFogMode; + + if (mainCamera != null) + mainCamera.backgroundColor = f.background; + + // 흐린 날의 연무 막은 이번에 쓴 안개색을 그대로 칠한다 — 원경 산을 덮는 안개와 같은 색이어야 지평선이 녹는다. + // 섬(SolidColor 배경)도 같다. 동굴·꿈은 skyBlend가 0이라 막이 꺼진다. + if (skyVisuals != null) skyVisuals.Apply(skyFrame, skyBlend, mainCamera, f.fogColor); } private void OnSubAreaChanged(SubAreaData subArea) @@ -102,9 +376,9 @@ private void OnSubAreaChanged(SubAreaData subArea) EnvironmentProfile profile; if (subArea == null) - profile = BuildDefaultProfile(); + profile = BuildRegionProfile(currentRegion); else - profile = GetProfileForType(subArea.environmentType); + profile = GetProfileForSubArea(subArea); // 서브지역에선 Flat 환경광으로 전환(밝힌 ambientColor가 실제 적용됨), 메인 복귀 시 원래 모드로. RenderSettings.ambientMode = subArea != null @@ -128,48 +402,40 @@ private void OnSubAreaChanged(SubAreaData subArea) transitionProgress = 0f; } - private void ApplyLerp(EnvironmentProfile from, EnvironmentProfile to, float t) + /// + /// 출발→도착 보간으로 기본 상태()를 갱신한다 — 렌더 설정엔 쓰지 않는다(이 쓴다). + /// + private void BlendBase(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); + EnvironmentProfile p = baseState; + p.lightColor = Color.Lerp(from.lightColor, to.lightColor, t); + p.lightIntensity = Mathf.Lerp(from.lightIntensity, to.lightIntensity, t); + p.lightRotation = Quaternion.Slerp(from.lightRotation, to.lightRotation, t); + p.ambientColor = 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); + p.fogEnabled = (from.fogEnabled && t < 1f) || to.fogEnabled; + p.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); + p.fogDensity = Mathf.Lerp(sourceDensity, targetDensity, t); + p.cameraBg = Color.Lerp(from.cameraBg, to.cameraBg, t); // 안개 해제: 전환 완료 + 대상이 안개 없음이면 끔 if (t >= 1f && !to.fogEnabled) - RenderSettings.fog = false; + p.fogEnabled = false; + + baseFogUsed = to.fogEnabled || from.fogEnabled; + baseState = p; } + /// + /// 전환의 출발점. 렌더 설정이 아니라 기본 상태에서 뜬다 — 렌더 설정에는 하늘 보정이 이미 얹혀 있어서 + /// 거기서 읽으면 밤색·날씨색이 기본으로 굳는다(이 매번 기본 상태 위에 다시 얹는다). + /// 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; + return baseState; } private EnvironmentProfile BuildDefaultProfile() @@ -187,6 +453,94 @@ private EnvironmentProfile BuildDefaultProfile() }; } + /// + /// 메인 필드 기본값 위에 리전 대기()를 얹는다. 리전 밖(길 위)이거나 + /// 표에 없는 리전이면 기본값 그대로다. 햇빛 방향은 바꾸지 않는다 — 그림자 방향이 리전 경계에서 + /// 돌아가면 어색하다. + /// + private EnvironmentProfile BuildRegionProfile(RegionData region) + { + EnvironmentProfile p = BuildDefaultProfile(); + if (region == null || !RegionAtmosphere.TryGet(region.regionId, out RegionAtmosphere.Profile a)) return p; + p.lightColor = a.light; + p.lightIntensity = defaultLightIntensity * a.intensity; + p.ambientColor = a.ambientSky; + p.fogEnabled = true; + p.fogColor = a.fog; + p.fogDensity = Mathf.Min(a.fogDensity, RegionAtmosphere.MaxFogDensity); + return p; + } + + /// + /// 같은 environmentType을 쓰는 방이라도 빛이 달라야 하는 곳 — 지오메트리를 subAreaId로 가르는 + /// SubAreaWorldBuilder.Themes와 짝이다. 개미귀신 구덩이는 하늘이 열린 사막(동굴 빛이면 안 된다), + /// 가장 높은 가지는 나무 꼭대기의 햇빛, 빈칸은 창백한 여백. 없으면 타입 프로필로 떨어진다. + /// + private EnvironmentProfile GetProfileForSubArea(SubAreaData subArea) + { + switch (subArea.subAreaId) + { + // 나의 섬 — 탁 트인 바다 위의 한낮. 카메라 배경이 곧 하늘이고(서브에리어는 SolidColor로 그린다) + // 안개색을 그 하늘과 맞춰 먼바다가 수평선으로 녹아들게 한다. + case GameConstants.Island.SubAreaId: + // 환경광을 낮게 둔다 — 서브에리어는 평면(Flat) 환경광이라 그 값이 그대로 색에 더해진다. + // 0.6대로 두면 햇빛(1.1 × 0.79)과 합쳐 물건 색의 1.6배가 나와 잔디·모래가 파스텔로 날아간다. + return Profile(new Color(1f, 0.97f, 0.88f), 1.1f, Quaternion.Euler(52f, 35f, 0f), new Color(0.40f, 0.44f, 0.50f), + new Color(0.66f, 0.84f, 0.95f), 0.006f, new Color(0.56f, 0.80f, 0.96f)); + case "dunes_pit": + return Profile(new Color(1f, 0.93f, 0.78f), 1.25f, Quaternion.Euler(55f, 30f, 0f), new Color(0.55f, 0.50f, 0.42f), + new Color(0.86f, 0.78f, 0.62f), 0.012f, new Color(0.72f, 0.64f, 0.50f)); + case "canopy_crown": + return Profile(new Color(1f, 0.98f, 0.86f), 1.25f, Quaternion.Euler(50f, 40f, 0f), new Color(0.46f, 0.56f, 0.46f), + new Color(0.70f, 0.84f, 0.78f), 0.012f, new Color(0.58f, 0.76f, 0.86f)); + case "nameless_core": + return Profile(new Color(0.92f, 0.90f, 1f), 0.9f, Quaternion.Euler(85f, 0f, 0f), new Color(0.52f, 0.50f, 0.58f), + new Color(0.80f, 0.78f, 0.86f), 0.03f, new Color(0.86f, 0.84f, 0.90f)); + case "frostline_ridge": + return Profile(new Color(0.88f, 0.94f, 1f), 1.3f, Quaternion.Euler(35f, 30f, 0f), new Color(0.52f, 0.58f, 0.68f), + new Color(0.84f, 0.90f, 0.96f), 0.02f, new Color(0.72f, 0.80f, 0.90f)); + case "hollow_silence": + return Profile(new Color(0.9f, 0.9f, 0.88f), 0.9f, Quaternion.Euler(60f, 30f, 0f), new Color(0.50f, 0.50f, 0.48f), + new Color(0.74f, 0.74f, 0.72f), 0.035f, new Color(0.60f, 0.60f, 0.58f)); + case "emberfall_vent": + return Profile(new Color(1f, 0.55f, 0.3f), 0.8f, Quaternion.Euler(80f, 0f, 0f), new Color(0.40f, 0.24f, 0.18f), + new Color(0.22f, 0.11f, 0.07f), 0.035f, new Color(0.08f, 0.04f, 0.03f)); + case "mountain_cave": + return Profile(new Color(0.75f, 0.8f, 0.95f), 0.9f, Quaternion.Euler(80f, 30f, 0f), new Color(0.42f, 0.44f, 0.52f), + new Color(0.16f, 0.18f, 0.24f), 0.02f, new Color(0.06f, 0.07f, 0.10f)); + case "forest_cave": + return Profile(new Color(0.7f, 0.85f, 0.6f), 0.9f, Quaternion.Euler(80f, 30f, 0f), new Color(0.36f, 0.44f, 0.32f), + new Color(0.14f, 0.20f, 0.12f), 0.022f, new Color(0.05f, 0.08f, 0.04f)); + case "swamp_cave": + return Profile(new Color(0.62f, 0.75f, 0.6f), 0.85f, Quaternion.Euler(80f, 30f, 0f), new Color(0.34f, 0.40f, 0.32f), + new Color(0.12f, 0.16f, 0.12f), 0.025f, new Color(0.04f, 0.06f, 0.04f)); + case "hollow_burrow": + return Profile(new Color(0.8f, 0.76f, 0.66f), 0.9f, Quaternion.Euler(80f, 30f, 0f), new Color(0.46f, 0.43f, 0.38f), + new Color(0.28f, 0.25f, 0.21f), 0.022f, new Color(0.10f, 0.09f, 0.07f)); + case "canopy_bough": + return Profile(new Color(0.85f, 1f, 0.7f), 1.0f, Quaternion.Euler(70f, 45f, 0f), new Color(0.40f, 0.50f, 0.34f), + new Color(0.30f, 0.44f, 0.26f), 0.02f, new Color(0.16f, 0.26f, 0.12f)); + default: + return GetProfileForType(subArea.environmentType); + } + } + + private static EnvironmentProfile Profile(Color light, float intensity, Quaternion rotation, Color ambient, + Color fog, float fogDensity, Color cameraBg) + { + return new EnvironmentProfile + { + lightColor = light, + lightIntensity = intensity, + lightRotation = rotation, + ambientColor = ambient, + fogEnabled = true, + fogColor = fog, + fogDensity = fogDensity, + cameraBg = cameraBg, + }; + } + private EnvironmentProfile GetProfileForType(string envType) { switch (envType) diff --git a/Assets/Scripts/Core/SubAreaGateBuilder.cs b/Assets/Scripts/Core/SubAreaGateBuilder.cs new file mode 100644 index 0000000..0b003e1 --- /dev/null +++ b/Assets/Scripts/Core/SubAreaGateBuilder.cs @@ -0,0 +1,377 @@ +using System.Collections.Generic; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 필드 쪽 서브에리어 입구 표식 — "여기로 들어간다"를 테마로 알려 주는 작은 구조물. + /// + /// PlaySceneBootstrap.CreateSubAreaEntries는 environmentType 7종(동굴·깊은 숲·수중·갈대·꽃 미로·온실· + /// 웅덩이)에만 입구를 세우고 나머지는 default: break였다. 배치 캡처로 26곳을 훑어 보니 15곳이 + /// 필드에 아무 표식도 없었다(2막 서브에리어 대부분 + 안개 늪·산 정상·고대 신전·유적 지하) — 근접 프롬프트가 + /// 뜨기 전까지는 거기가 입구인 줄 알 방법이 없었다. 그리고 모래언덕의 개미귀신 구덩이는 environmentType이 + /// cave라 동굴 석문이 섰다. + /// + /// 여기 등록한 서브에리어는 부트스트랩이 입구를 짓지 않는다(). 이름은 + /// SubArea_{id}_Gate… — 서브에리어 진입 때 HideMainWorld가 이 접두어로 메인 월드를 끈다. + /// 콜라이더는 없다: 입구 한가운데서 [E]를 누르는 자리이고, 밟히는 콜라이더는 플레이어를 띄운다. + /// 높이는 3m 안팎 — 카메라(플레이어 남쪽 위)가 입구 앞에 선 플레이어를 가리지 않게. + /// + public class SubAreaGateBuilder : MonoBehaviour + { + /// + /// 서브에리어 id → 입구 설계. 이 표가 유일한 목록이다 — 부트스트랩이 건너뛸지()도 + /// 여기서 파생한다. 예전엔 id 집합(HashSet)과 짓는 switch가 따로 있었고 switch엔 default가 없었다. + /// 집합에만 id를 넣고 case를 빠뜨리면 부트스트랩은 기본 입구를 건너뛰고 이 빌더도 아무것도 안 지어 + /// 입구가 조용히 사라진다. 한 표에서 둘 다 읽으면 그 어긋남 자체가 성립하지 않는다. + /// + private static readonly Dictionary> Designs = + new Dictionary> + { + { "swamp_fog", b => b.SwampFog() }, + { "mountain_peak", b => b.MountainPeak() }, + { "ruins_temple", b => b.RuinsTemple() }, + { "ruins_underground", b => b.RuinsUnderground() }, + { "hollow_silence", b => b.HollowSilence() }, + { "hollow_burrow", b => b.HollowBurrow() }, + { "dunes_vault", b => b.DunesVault() }, + { "dunes_pit", b => b.DunesPit() }, + { "frostline_archive", b => b.FrostlineArchive() }, + { "frostline_ridge", b => b.FrostlineRidge() }, + { "emberfall_kiln", b => b.EmberfallKiln() }, + { "emberfall_vent", b => b.EmberfallVent() }, + { "canopy_crown", b => b.CanopyCrown() }, + { "canopy_bough", b => b.CanopyBough() }, + { "nameless_ledger", b => b.NamelessLedger() }, + { "nameless_core", b => b.NamelessCore() }, + }; + + /// 이 빌더가 입구를 짓는 서브에리어인가 — 부트스트랩은 여기 해당하면 건너뛴다. + public static bool Handles(string subAreaId) => !string.IsNullOrEmpty(subAreaId) && Designs.ContainsKey(subAreaId); + + /// 설계가 있는 서브에리어 id 전부 — 테스트가 실재 서브에리어인지·실제로 뭔가 짓는지 대조한다. + internal static IEnumerable DesignedIds => Designs.Keys; + + private readonly Dictionary mats = new Dictionary(); + /// 발광 복제 캐시 — (원본 머티리얼, 발광색). 원본은 의 한 색이다. + private readonly Dictionary<(Material source, Color emission), Material> glowMats = + new Dictionary<(Material source, Color emission), Material>(); + private readonly List owned = new List(); + + private Vector3 origin; + private Quaternion facing; + private string prefix; + + public void Build(RegionData[] regions) + { + if (regions == null) return; + foreach (RegionData r in regions) + { + if (r?.subAreas == null) continue; + foreach (SubAreaData sub in r.subAreas) + { + if (sub == null || !Handles(sub.subAreaId)) continue; + origin = new Vector3(sub.centerPosition.x, 0.1f, sub.centerPosition.z); + // 정면(+Z)이 리전 중심을 본다 — 플레이어는 대개 리전 안쪽에서 다가온다 + Vector3 toCenter = r.centerPosition - sub.centerPosition; + toCenter.y = 0f; + facing = toCenter.sqrMagnitude > 0.01f ? Quaternion.LookRotation(toCenter.normalized) : Quaternion.identity; + prefix = $"SubArea_{sub.subAreaId}_Gate"; + BuildGate(sub.subAreaId); + } + } + } + + /// + /// 한 서브에리어의 입구를 짓는다. 설계 표()가 id 목록의 유일한 출처라 여기서 빠질 일은 + /// 구조적으로 없지만, 설계가 비었거나(부품 0개) 표 밖 id가 들어오면 에러로 드러낸다 — 이 빌더가 맡았다고 + /// 답한 순간 부트스트랩은 기본 입구를 건너뛰었으므로, 여기서 조용히 넘어가면 필드에 아무 표식도 안 선다. + /// + private void BuildGate(string id) + { + int before = transform.childCount; + if (Designs.TryGetValue(id, out System.Action design) && design != null) design(this); + if (transform.childCount == before) + Debug.LogError($"[SubAreaGateBuilder] '{id}' 입구를 하나도 못 지었다 — Handles가 참이라 부트스트랩도 기본 입구를 건너뛰어 필드에 표식이 없다"); + } + + // ---- 입구 설계 (좌표는 입구 로컬: +Z가 리전 중심 쪽) ---- + + private void SwampFog() + { + // 안개가 고인 자리 — 낮은 안개 덩이, 마른 갈대, 초록빛 등불 + for (int i = 0; i < 5; i++) + Smoke(Quaternion.Euler(0f, i * 72f, 0f) * new Vector3(0f, 0.5f, 1.6f), 1.6f, C(0.62f, 0.66f, 0.58f)); + for (int i = 0; i < 9; i++) + Part(PrimitiveType.Cylinder, "DeadReed", Quaternion.Euler(0f, i * 40f, 0f) * new Vector3(0f, 0.6f, 2.8f), new Vector3((i % 3 - 1) * 8f, 0f, 6f), + new Vector3(0.05f, 0.6f, 0.05f), C(0.42f, 0.40f, 0.28f)); + Part(PrimitiveType.Cylinder, "LanternPole", new Vector3(1.2f, 0.9f, 0.6f), Vector3.zero, new Vector3(0.07f, 0.9f, 0.07f), C(0.26f, 0.22f, 0.18f)); + Glow(Part(PrimitiveType.Sphere, "Lantern", new Vector3(1.2f, 1.9f, 0.6f), Vector3.zero, new Vector3(0.28f, 0.34f, 0.28f), C(0.6f, 1f, 0.5f)), new Color(0.5f, 1.1f, 0.35f)); + } + + private void MountainPeak() + { + Cairn(C(0.56f, 0.54f, 0.50f), C(0.88f, 0.90f, 0.94f)); + Pennant(C(0.85f, 0.22f, 0.18f)); + Part(PrimitiveType.Cube, "SignPost", new Vector3(1.8f, 0.8f, 0.6f), new Vector3(0f, 0f, 0f), new Vector3(0.12f, 1.6f, 0.12f), C(0.40f, 0.30f, 0.20f)); + Part(PrimitiveType.Cube, "SignArrow", new Vector3(1.8f, 1.45f, 0.7f), new Vector3(0f, 0f, 18f), new Vector3(0.9f, 0.26f, 0.06f), C(0.58f, 0.44f, 0.28f)); + } + + private void RuinsTemple() + { + // 신전 문 — 새김 기둥 두 개와 보랏빛 문양 + Color stone = C(0.62f, 0.58f, 0.50f); + Part(PrimitiveType.Cube, "TemplePillarL", new Vector3(-1.2f, 1.3f, 0f), Vector3.zero, new Vector3(0.55f, 2.6f, 0.55f), stone); + Part(PrimitiveType.Cube, "TemplePillarR", new Vector3(1.2f, 1.3f, 0f), Vector3.zero, new Vector3(0.55f, 2.6f, 0.55f), stone); + Part(PrimitiveType.Cube, "TempleLintel", new Vector3(0f, 2.75f, 0f), Vector3.zero, new Vector3(3.2f, 0.4f, 0.7f), C(0.56f, 0.52f, 0.45f)); + Glow(Part(PrimitiveType.Cube, "TempleGlyph", new Vector3(0f, 2.75f, 0.36f), new Vector3(0f, 0f, 45f), new Vector3(0.36f, 0.36f, 0.03f), C(0.72f, 0.52f, 0.96f)), + new Color(0.5f, 0.3f, 0.9f)); + for (int side = -1; side <= 1; side += 2) + Glow(Part(PrimitiveType.Cube, "PillarBand", new Vector3(side * 1.2f, 1.6f, 0.28f), Vector3.zero, new Vector3(0.45f, 0.08f, 0.02f), C(0.72f, 0.52f, 0.96f)), + new Color(0.4f, 0.25f, 0.7f)); + Part(PrimitiveType.Cube, "TempleStep", new Vector3(0f, 0.08f, 0.6f), Vector3.zero, new Vector3(2.6f, 0.16f, 0.7f), C(0.58f, 0.54f, 0.46f)); + } + + private void RuinsUnderground() + { + // 땅 아래로 내려가는 계단 — 어두운 구멍과 돌 틀 + Part(PrimitiveType.Cube, "StairFrameL", new Vector3(-1.1f, 0.25f, 0f), Vector3.zero, new Vector3(0.35f, 0.5f, 2.6f), C(0.56f, 0.52f, 0.44f)); + Part(PrimitiveType.Cube, "StairFrameR", new Vector3(1.1f, 0.25f, 0f), Vector3.zero, new Vector3(0.35f, 0.5f, 2.6f), C(0.56f, 0.52f, 0.44f)); + Part(PrimitiveType.Cube, "StairFrameBack", new Vector3(0f, 0.35f, -1.3f), Vector3.zero, new Vector3(2.55f, 0.7f, 0.35f), C(0.52f, 0.48f, 0.41f)); + Part(PrimitiveType.Cube, "StairHole", new Vector3(0f, 0.012f, -0.2f), Vector3.zero, new Vector3(1.85f, 0.01f, 2.2f), C(0.07f, 0.06f, 0.05f)); + for (int k = 0; k < 3; k++) + Part(PrimitiveType.Cube, "StairStep", new Vector3(0f, 0.03f, 0.75f - k * 0.45f), Vector3.zero, new Vector3(1.85f, 0.03f, 0.4f), + k == 0 ? C(0.50f, 0.46f, 0.38f) : k == 1 ? C(0.34f, 0.31f, 0.26f) : C(0.20f, 0.18f, 0.15f)); + } + + private void HollowSilence() + { + // 울리지 않는 풍경 세 대가 빈 판석을 둘러선다 + Part(PrimitiveType.Cylinder, "PaleStone", new Vector3(0f, 0.06f, 0f), Vector3.zero, new Vector3(2f, 0.06f, 1.6f), C(0.74f, 0.73f, 0.68f)); + for (int i = 0; i < 3; i++) + { + Vector3 at = Quaternion.Euler(0f, i * 120f + 30f, 0f) * new Vector3(0f, 0f, 2.2f); + Part(PrimitiveType.Cylinder, "ChimePost", at + Vector3.up * 1.1f, Vector3.zero, new Vector3(0.09f, 1.1f, 0.09f), C(0.62f, 0.58f, 0.50f)); + Part(PrimitiveType.Cube, "ChimeBar", at + Vector3.up * 2.15f, new Vector3(0f, i * 120f, 0f), new Vector3(0.7f, 0.04f, 0.04f), C(0.62f, 0.58f, 0.50f)); + for (int k = 0; k < 3; k++) + Part(PrimitiveType.Cylinder, "ChimeTube", at + Quaternion.Euler(0f, i * 120f, 0f) * new Vector3((k - 1) * 0.25f, 1.85f, 0f), Vector3.zero, + new Vector3(0.04f, 0.2f, 0.04f), C(0.80f, 0.82f, 0.86f)); + } + } + + private void HollowBurrow() + { + // 마른 굴 입구 — 흙 둔덕 고리와 어두운 구멍, 늘어진 마른 뿌리 + Part(PrimitiveType.Sphere, "BurrowMound", new Vector3(0f, -0.2f, -0.6f), Vector3.zero, new Vector3(3.2f, 1.6f, 2.6f), C(0.54f, 0.50f, 0.40f)); + Part(PrimitiveType.Cube, "BurrowHole", new Vector3(0f, 0.42f, 0.62f), new Vector3(-12f, 0f, 0f), new Vector3(1.2f, 0.75f, 0.05f), C(0.10f, 0.09f, 0.07f)); + for (int k = 0; k < 4; k++) + Part(PrimitiveType.Cylinder, "DryRoot", new Vector3(-0.45f + k * 0.3f, 0.7f, 0.66f), new Vector3((k % 2) * 10f - 5f, 0f, 0f), new Vector3(0.04f, 0.22f, 0.04f), C(0.46f, 0.40f, 0.30f)); + } + + private void DunesVault() + { + // 반쯤 묻힌 창고 뚜껑 + 명부회 화물 + 아이보리 깃발(명부회 제복색) + Part(PrimitiveType.Cube, "HatchFrame", new Vector3(0f, 0.08f, 0f), Vector3.zero, new Vector3(2.2f, 0.16f, 1.8f), C(0.36f, 0.27f, 0.17f)); + Part(PrimitiveType.Cube, "HatchDoor", new Vector3(0f, 0.17f, 0f), new Vector3(0f, 0f, 0f), new Vector3(1.8f, 0.05f, 1.4f), C(0.46f, 0.35f, 0.22f)); + Part(PrimitiveType.Cube, "HatchBand", new Vector3(0f, 0.2f, 0.35f), Vector3.zero, new Vector3(1.82f, 0.03f, 0.1f), C(0.30f, 0.30f, 0.32f)); + Part(PrimitiveType.Cube, "HatchBand", new Vector3(0f, 0.2f, -0.35f), Vector3.zero, new Vector3(1.82f, 0.03f, 0.1f), C(0.30f, 0.30f, 0.32f)); + Part(PrimitiveType.Cube, "Crate", new Vector3(1.9f, 0.4f, -0.6f), new Vector3(0f, 18f, 0f), new Vector3(0.9f, 0.8f, 0.9f), C(0.52f, 0.40f, 0.25f)); + Part(PrimitiveType.Cube, "Crate", new Vector3(1.8f, 1.1f, -0.5f), new Vector3(0f, -12f, 0f), new Vector3(0.75f, 0.6f, 0.75f), C(0.52f, 0.40f, 0.25f)); + Part(PrimitiveType.Cylinder, "BannerPole", new Vector3(-1.6f, 1.3f, -0.4f), Vector3.zero, new Vector3(0.07f, 1.3f, 0.07f), C(0.30f, 0.24f, 0.18f)); + Part(PrimitiveType.Cube, "Banner", new Vector3(-1.25f, 2.2f, -0.4f), Vector3.zero, new Vector3(0.65f, 0.9f, 0.02f), C(0.90f, 0.88f, 0.82f)); + Part(PrimitiveType.Cube, "BannerMark", new Vector3(-1.25f, 2.25f, -0.38f), Vector3.zero, new Vector3(0.3f, 0.06f, 0.02f), C(0.18f, 0.22f, 0.28f)); + } + + private void DunesPit() + { + // 모래 깔때기 — 가운데로 갈수록 짙은 동심원, 둘레 둔덕, 가운데 개미귀신 턱 + Color[] rings = { C(0.66f, 0.55f, 0.38f), C(0.56f, 0.45f, 0.30f), C(0.44f, 0.34f, 0.22f), C(0.28f, 0.21f, 0.13f) }; + float[] radii = { 3.6f, 2.6f, 1.6f, 0.7f }; + for (int i = 0; i < rings.Length; i++) + Part(PrimitiveType.Cylinder, "Ring", new Vector3(0f, 0.01f + i * 0.006f, 0f), Vector3.zero, new Vector3(radii[i] * 2f, 0.004f, radii[i] * 2f), rings[i]); + for (int i = 0; i < 8; i++) + { + float a = i * 45f; + Part(PrimitiveType.Sphere, "Rim", Quaternion.Euler(0f, a, 0f) * new Vector3(0f, -0.1f, 3.9f), new Vector3(0f, a, 0f), + new Vector3(1.6f, 0.7f, 1.0f), C(0.76f, 0.64f, 0.44f)); + } + Part(PrimitiveType.Capsule, "JawL", new Vector3(-0.18f, 0.12f, 0f), new Vector3(20f, 0f, 35f), new Vector3(0.06f, 0.18f, 0.06f), C(0.20f, 0.14f, 0.10f)); + Part(PrimitiveType.Capsule, "JawR", new Vector3(0.18f, 0.12f, 0f), new Vector3(20f, 0f, -35f), new Vector3(0.06f, 0.18f, 0.06f), C(0.20f, 0.14f, 0.10f)); + } + + private void FrostlineArchive() + { + // 얼음 아치 문 — 안쪽이 푸르게 빛난다 + Color ice = C(0.70f, 0.85f, 0.95f); + Part(PrimitiveType.Cube, "IcePillarL", new Vector3(-1.1f, 1.2f, 0f), new Vector3(0f, 0f, 4f), new Vector3(0.6f, 2.4f, 0.6f), ice); + Part(PrimitiveType.Cube, "IcePillarR", new Vector3(1.1f, 1.2f, 0f), new Vector3(0f, 0f, -4f), new Vector3(0.6f, 2.4f, 0.6f), ice); + Part(PrimitiveType.Cube, "IceLintel", new Vector3(0f, 2.55f, 0f), Vector3.zero, new Vector3(3f, 0.45f, 0.7f), ice); + Glow(Part(PrimitiveType.Cube, "ArchiveDoor", new Vector3(0f, 1.1f, -0.1f), Vector3.zero, new Vector3(1.6f, 2.2f, 0.05f), C(0.40f, 0.62f, 0.86f)), + new Color(0.18f, 0.34f, 0.55f)); + Part(PrimitiveType.Sphere, "SnowCap", new Vector3(0f, 2.82f, 0f), Vector3.zero, new Vector3(3.1f, 0.35f, 0.9f), C(0.93f, 0.95f, 0.98f)); + Part(PrimitiveType.Sphere, "Drift", new Vector3(-1.6f, 0.05f, 0.4f), Vector3.zero, new Vector3(1.6f, 0.6f, 1.2f), C(0.90f, 0.93f, 0.97f)); + Part(PrimitiveType.Sphere, "Drift", new Vector3(1.7f, 0.05f, 0.3f), Vector3.zero, new Vector3(1.4f, 0.5f, 1.1f), C(0.90f, 0.93f, 0.97f)); + } + + private void FrostlineRidge() + { + Cairn(C(0.56f, 0.62f, 0.70f), C(0.92f, 0.95f, 0.98f)); + Pennant(C(0.40f, 0.62f, 0.90f)); + for (int i = 0; i < 5; i++) + { + float a = i * 72f + 20f; + Vector3 at = Quaternion.Euler(0f, a, 0f) * new Vector3(0f, 0f, 2.6f); + Glow(Part(PrimitiveType.Cube, "IceSpike", at + Vector3.up * 0.5f, new Vector3(12f, a, 10f), new Vector3(0.28f, 1.1f, 0.28f), C(0.66f, 0.84f, 0.96f)), + new Color(0.10f, 0.17f, 0.24f)); + } + } + + private void EmberfallKiln() + { + // 벽돌 가마 — 둥근 몸통, 달아오른 아궁이, 굴뚝 + Part(PrimitiveType.Sphere, "KilnDome", new Vector3(0f, 0.3f, -0.4f), Vector3.zero, new Vector3(2.6f, 2.2f, 2.4f), C(0.55f, 0.30f, 0.20f)); + Glow(Part(PrimitiveType.Cube, "KilnMouth", new Vector3(0f, 0.55f, 0.72f), Vector3.zero, new Vector3(0.8f, 0.6f, 0.1f), C(1f, 0.48f, 0.15f)), + new Color(1.6f, 0.55f, 0.12f)); + Part(PrimitiveType.Cylinder, "KilnChimney", new Vector3(0.6f, 1.9f, -0.9f), Vector3.zero, new Vector3(0.36f, 0.6f, 0.36f), C(0.44f, 0.24f, 0.16f)); + Part(PrimitiveType.Cube, "BrickStack", new Vector3(-1.7f, 0.25f, 0.3f), new Vector3(0f, 14f, 0f), new Vector3(0.8f, 0.5f, 0.5f), C(0.60f, 0.34f, 0.22f)); + } + + private void EmberfallVent() + { + // 김이 새는 분출구 — 검은 둔덕 고리, 빛나는 가운데, 피어오르는 연기 + Part(PrimitiveType.Cylinder, "VentMound", new Vector3(0f, 0.2f, 0f), Vector3.zero, new Vector3(3.2f, 0.2f, 3.2f), C(0.16f, 0.14f, 0.14f)); + Glow(Part(PrimitiveType.Cylinder, "VentGlow", new Vector3(0f, 0.42f, 0f), Vector3.zero, new Vector3(1.4f, 0.02f, 1.4f), C(1f, 0.42f, 0.12f)), + new Color(1.8f, 0.5f, 0.1f)); + for (int i = 0; i < 4; i++) + Smoke(new Vector3(0.2f * (i % 2), 1.0f + i * 0.7f, 0.15f * (i - 1)), 0.9f + i * 0.35f, C(0.42f, 0.40f, 0.40f)); + } + + private void CanopyCrown() + { + // 거대수 밑동에 걸친 줄사다리 — 위로 올라간다 + Part(PrimitiveType.Cylinder, "CrownTrunk", new Vector3(0f, 1.3f, -0.9f), Vector3.zero, new Vector3(1.7f, 1.3f, 1.7f), C(0.40f, 0.30f, 0.19f)); + Part(PrimitiveType.Sphere, "CrownLeafTop", new Vector3(0f, 3.0f, -0.9f), Vector3.zero, new Vector3(2.8f, 1.2f, 2.6f), C(0.26f, 0.52f, 0.22f)); + for (int side = -1; side <= 1; side += 2) + Part(PrimitiveType.Cylinder, "LadderRope", new Vector3(side * 0.32f, 1.25f, 0f), Vector3.zero, new Vector3(0.04f, 1.25f, 0.04f), C(0.70f, 0.62f, 0.44f)); + for (int k = 0; k < 6; k++) + Part(PrimitiveType.Cube, "LadderRung", new Vector3(0f, 0.3f + k * 0.38f, 0f), Vector3.zero, new Vector3(0.7f, 0.05f, 0.08f), C(0.56f, 0.42f, 0.26f)); + } + + private void CanopyBough() + { + // 두 가지가 엇갈려 만든 아치 + 잎 + Part(PrimitiveType.Cylinder, "BoughL", new Vector3(-0.7f, 1.2f, 0f), new Vector3(0f, 0f, -22f), new Vector3(0.35f, 1.35f, 0.35f), C(0.40f, 0.30f, 0.19f)); + Part(PrimitiveType.Cylinder, "BoughR", new Vector3(0.7f, 1.2f, 0f), new Vector3(0f, 0f, 22f), new Vector3(0.35f, 1.35f, 0.35f), C(0.40f, 0.30f, 0.19f)); + Part(PrimitiveType.Sphere, "BoughLeaves", new Vector3(0f, 2.55f, 0f), Vector3.zero, new Vector3(2.8f, 1.1f, 1.4f), C(0.28f, 0.54f, 0.24f)); + Glow(Part(PrimitiveType.Sphere, "BoughFlower", new Vector3(0.6f, 2.9f, 0.5f), Vector3.zero, Vector3.one * 0.35f, C(0.98f, 0.70f, 0.40f)), new Color(0.3f, 0.14f, 0.04f)); + } + + private void NamelessLedger() + { + // 검은 문 석판 — 희미한 괘선이 빛난다 + Part(PrimitiveType.Cube, "LedgerDoor", new Vector3(0f, 1.4f, -0.2f), Vector3.zero, new Vector3(1.9f, 2.8f, 0.35f), C(0.14f, 0.13f, 0.18f)); + for (int k = 0; k < 5; k++) + Glow(Part(PrimitiveType.Cube, "LedgerLine", new Vector3(0f, 0.7f + k * 0.42f, -0.01f), Vector3.zero, new Vector3(1.4f, 0.03f, 0.02f), C(0.62f, 0.58f, 0.80f)), + new Color(0.35f, 0.32f, 0.55f)); + Part(PrimitiveType.Cube, "LedgerStep", new Vector3(0f, 0.07f, 0.45f), Vector3.zero, new Vector3(2.4f, 0.14f, 0.7f), C(0.22f, 0.21f, 0.26f)); + } + + private void NamelessCore() + { + // 창백한 원반을 둘러선 빈 석판 다섯 + Glow(Part(PrimitiveType.Cylinder, "BlankDisc", new Vector3(0f, 0.02f, 0f), Vector3.zero, new Vector3(3.4f, 0.02f, 3.4f), C(0.84f, 0.83f, 0.90f)), + new Color(0.18f, 0.17f, 0.24f)); + for (int i = 0; i < 5; i++) + { + float a = i * 72f + 36f; + Vector3 at = Quaternion.Euler(0f, a, 0f) * new Vector3(0f, 0f, 2.6f); + Part(PrimitiveType.Cube, "BlankSlab", at + Vector3.up * 0.9f, new Vector3(0f, a, (i % 2) * 4f - 2f), new Vector3(0.8f, 1.8f, 0.2f), C(0.88f, 0.87f, 0.92f)); + } + } + + // ---- 부품 ---- + + private void Cairn(Color stone, Color snow) + { + for (int k = 0; k < 5; k++) + { + float w = 1f - k * 0.16f; + Part(PrimitiveType.Sphere, "Cairn", new Vector3(0f, 0.16f + k * 0.27f, 0f), new Vector3(0f, k * 47f, 0f), new Vector3(w, 0.3f, w * 0.85f), stone); + } + Part(PrimitiveType.Sphere, "CairnSnow", new Vector3(0.1f, 0.05f, 0.5f), Vector3.zero, new Vector3(1.4f, 0.3f, 0.9f), snow); + } + + private void Pennant(Color cloth) + { + Part(PrimitiveType.Cylinder, "PennantPole", new Vector3(-0.9f, 1.3f, -0.2f), Vector3.zero, new Vector3(0.06f, 1.3f, 0.06f), C(0.40f, 0.30f, 0.20f)); + Part(PrimitiveType.Cube, "PennantCloth", new Vector3(-0.55f, 2.3f, -0.2f), new Vector3(0f, 0f, -5f), new Vector3(0.7f, 0.42f, 0.02f), cloth); + } + + private void Smoke(Vector3 local, float size, Color color) + { + Material m = MatFor(new Color(color.r, color.g, color.b, 0.35f)); + Part(PrimitiveType.Sphere, "Mist", local, Vector3.zero, new Vector3(size, size * 0.7f, size), m); + } + + private GameObject Part(PrimitiveType type, string name, Vector3 local, Vector3 euler, Vector3 scale, Color color) + { + return Part(type, name, local, euler, scale, MatFor(color)); + } + + private GameObject Part(PrimitiveType type, string name, Vector3 local, Vector3 euler, Vector3 scale, Material mat) + { + GameObject go = GameObject.CreatePrimitive(type); + go.name = $"{prefix}_{name}"; + go.transform.SetParent(transform, false); + go.transform.SetPositionAndRotation(origin + facing * local, facing * Quaternion.Euler(euler)); + go.transform.localScale = scale; + go.GetComponent().sharedMaterial = mat; + Collider col = go.GetComponent(); + if (col != null) { col.enabled = false; Destroy(col); } + return go; + } + + /// + /// 발광 부품. 발광은 머티리얼 단위라 같은 색 캐시()에 걸면 같은 색을 쓰는 다른 부품까지 + /// 빛난다 — 그래서 원본을 복제해 발광을 건다. 복제는 (원본 = 색, 발광색)마다 한 벌이다: 얼음 가시 5개· + /// 괘선 5개처럼 같은 색·같은 발광이 반복되면 부품마다 복제하던 것을 하나로 모은다. + /// + private void Glow(GameObject go, Color emission) + { + if (go == null) return; + MeshRenderer mr = go.GetComponent(); + if (mr == null) return; + Material source = mr.sharedMaterial; + var key = (source, emission); + if (!glowMats.TryGetValue(key, out Material m)) + { + m = new Material(source); + SceneryMaterials.SetEmission(m, emission); + glowMats[key] = m; + owned.Add(m); + } + mr.sharedMaterial = m; + } + + private Material MatFor(Color color) + { + if (mats.TryGetValue(color, out Material m)) return m; + m = SceneryMaterials.Create(color); + if (SceneryMaterials.IsTranslucent(color)) SceneryMaterials.MakeFade(m); // 연기·안개 덩이 + mats[color] = m; + owned.Add(m); + return m; + } + + private static Color C(float r, float g, float b) => new Color(r, g, b, 1f); + + private void OnDestroy() + { + foreach (Material m in owned) if (m != null) Destroy(m); + owned.Clear(); + mats.Clear(); + glowMats.Clear(); + } + } +} diff --git a/Assets/Scripts/Core/SubAreaGateBuilder.cs.meta b/Assets/Scripts/Core/SubAreaGateBuilder.cs.meta new file mode 100644 index 0000000..fbeaa0c --- /dev/null +++ b/Assets/Scripts/Core/SubAreaGateBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3bb795dd52c867c49bfb77d525609834 \ No newline at end of file diff --git a/Assets/Scripts/Core/SubAreaWorldBuilder.Themes.cs b/Assets/Scripts/Core/SubAreaWorldBuilder.Themes.cs new file mode 100644 index 0000000..0d34594 --- /dev/null +++ b/Assets/Scripts/Core/SubAreaWorldBuilder.Themes.cs @@ -0,0 +1,629 @@ +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 서브에리어 테마 변주 — 같은 environmentType을 공유하는 방들이 같은 방으로 보이지 않게 한다. + /// + /// 배치 캡처로 확인한 것: 동굴형 9곳(초원·숲·습지·산 동굴, 유적 지하, 마른 굴, 개미귀신 구덩이, 잿불 굴뚝, + /// 빈칸)이 한 개의 갈색 상자 미로였고, 산 정상형 3곳(산 정상·서리 능선·우듬지 "가장 높은 가지")이 같은 + /// 회색 상자였다. 나무 꼭대기가 바위산으로, 개미귀신 구덩이가 동굴로 나왔다. + /// + /// environmentType 데이터는 바꾸지 않는다. 그 값은 조명 프로필(), + /// 환경음(AudioManager.PlayAmbient), 지도 색(RegionMapUI), 스토리 연출 좌표 검사 + /// (story_lint 21 — 빌더별 방 크기를 이 파일이 아니라 본 파일의 switch로 읽는다)에 묶여 있다. + /// 그래서 빌더 안에서 subAreaId로 갈라지고, 방 크기(CreateBoundaryWalls의 halfSize)는 + /// 원래 빌더와 같게 둔다 — 그래야 story_lint가 읽는 방 크기가 거짓이 되지 않는다. + /// + /// 장식은 전부 콜라이더를 뺀다. 연출 배우(Scripted 이동)는 콜라이더에 막히면 화면에 안 나오고, + /// 입구(0,0.5,-8) 주변은 FindSafeSpawnPosition이 비어 있어야 한다. + /// + public partial class SubAreaWorldBuilder + { + // ======================= 동굴 ======================= + + private enum CaveStyle { Earth, Moss, Wet, Crystal, Ancient, Dry, Ember } + + private struct CaveTheme + { + public CaveStyle style; + public Color wall, floor, flame, light; + public bool torches; + } + + private static CaveTheme CaveThemeFor(string subAreaId) + { + switch (subAreaId) + { + case "forest_cave": + return new CaveTheme { style = CaveStyle.Moss, wall = new Color(0.30f, 0.33f, 0.24f), floor = new Color(0.24f, 0.28f, 0.18f), + flame = new Color(1f, 0.72f, 0.3f), light = new Color(0.85f, 0.9f, 0.6f), torches = true }; + case "swamp_cave": + return new CaveTheme { style = CaveStyle.Wet, wall = new Color(0.26f, 0.30f, 0.27f), floor = new Color(0.20f, 0.24f, 0.20f), + flame = new Color(0.7f, 1f, 0.5f), light = new Color(0.6f, 0.9f, 0.55f), torches = false }; + case "mountain_cave": + return new CaveTheme { style = CaveStyle.Crystal, wall = new Color(0.36f, 0.37f, 0.42f), floor = new Color(0.28f, 0.29f, 0.33f), + flame = new Color(0.6f, 0.85f, 1f), light = new Color(0.62f, 0.72f, 1f), torches = false }; + case "ruins_underground": + return new CaveTheme { style = CaveStyle.Ancient, wall = new Color(0.56f, 0.50f, 0.40f), floor = new Color(0.42f, 0.38f, 0.30f), + flame = new Color(1f, 0.78f, 0.35f), light = new Color(1f, 0.8f, 0.45f), torches = true }; + case "hollow_burrow": + return new CaveTheme { style = CaveStyle.Dry, wall = new Color(0.56f, 0.52f, 0.44f), floor = new Color(0.46f, 0.42f, 0.35f), + flame = new Color(0.95f, 0.85f, 0.6f), light = new Color(0.9f, 0.85f, 0.72f), torches = true }; + case "emberfall_vent": + return new CaveTheme { style = CaveStyle.Ember, wall = new Color(0.18f, 0.16f, 0.16f), floor = new Color(0.13f, 0.11f, 0.11f), + flame = new Color(1f, 0.5f, 0.15f), light = new Color(1f, 0.5f, 0.2f), torches = false }; + default: // meadow_cave 등 — 흙 동굴(옛 색 그대로) + return new CaveTheme { style = CaveStyle.Earth, wall = new Color(0.4f, 0.35f, 0.29f), floor = new Color(0.32f, 0.28f, 0.22f), + flame = new Color(1f, 0.7f, 0.2f), light = new Color(1f, 0.7f, 0.3f), torches = true }; + } + } + + /// + /// 미로 동굴의 테마 소품. 벽 칸(maze=1) 위·옆과 빈 칸(maze=0) 바닥에 스타일별 소품을 놓는다. + /// 입구 칸(3~4, 1~2)은 비워 둔다. + /// + private void DecorateCave(CaveTheme t, int[,] maze, float cell, float offX, float offZ) + { + int nx = maze.GetLength(0), nz = maze.GetLength(1); + // 머티리얼은 여기서 스타일당 한 번만 만든다. 칸·소품마다 Mat()을 부르면 한 번 들어갈 때마다 수십 개가 새로 + // 생기고 같은 색 소품끼리도 배칭이 깨진다. extra는 스타일마다 뜻이 다르다(오른쪽 주석). + // **여기 넣는 건 개체마다 바꾸지 않는 것만이다** — 공유하므로 루프 안에서 발광·광택을 고치면 전부 같이 바뀐다. + Material accent = null, accent2 = null, extra = null, stem = null; + switch (t.style) + { + case CaveStyle.Earth: + accent = Mat(new Color(0.36f, 0.26f, 0.16f)); // 뿌리 + accent2 = GlowMat(new Color(0.75f, 1f, 0.45f), new Color(0.5f, 0.9f, 0.3f)); // 반딧불이 + extra = Mat(new Color(0.72f, 0.36f, 0.24f)); // 바닥 버섯 갓 + stem = Mat(new Color(0.88f, 0.84f, 0.74f)); // 버섯 줄기 + break; + case CaveStyle.Moss: + accent = Mat(new Color(0.26f, 0.46f, 0.20f)); // 이끼 + accent2 = GlowMat(new Color(0.45f, 0.85f, 1f), new Color(0.25f, 0.65f, 0.9f)); // 푸른 버섯 + stem = Mat(new Color(0.88f, 0.84f, 0.74f)); // 버섯 줄기 + break; + case CaveStyle.Wet: + accent = Mat(new Color(0.16f, 0.20f, 0.18f)); // 물웅덩이 + Wet(accent); + accent2 = GlowMat(new Color(0.7f, 1f, 0.55f), new Color(0.4f, 0.8f, 0.3f)); // 포자 + extra = Mat(new Color(0.32f, 0.36f, 0.33f)); // 석순 + break; + case CaveStyle.Crystal: + accent = GlowMat(new Color(0.72f, 0.55f, 1f), new Color(0.55f, 0.32f, 0.95f)); // 자수정 + accent2 = GlowMat(new Color(0.55f, 0.95f, 1f), new Color(0.25f, 0.7f, 0.9f)); // 청수정 + break; + case CaveStyle.Ancient: + accent = GlowMat(new Color(1f, 0.82f, 0.38f), new Color(0.75f, 0.5f, 0.12f)); // 새김 띠 + accent2 = Mat(new Color(0.62f, 0.56f, 0.45f)); // 기둥·돌 토막 + extra = Mat(new Color(0.48f, 0.44f, 0.35f)); // 바닥 타일 + break; + case CaveStyle.Dry: + accent = Mat(new Color(0.50f, 0.44f, 0.34f)); // 마른 뿌리 + accent2 = Mat(new Color(0.78f, 0.76f, 0.70f)); // 흰 돌·거미줄 + extra = Mat(new Color(0.52f, 0.48f, 0.40f)); // 흙더미 + break; + case CaveStyle.Ember: + accent = GlowMat(new Color(1f, 0.45f, 0.12f), new Color(1.5f, 0.45f, 0.08f)); // 용암 틈 + accent2 = Mat(new Color(0.24f, 0.21f, 0.21f)); // 현무암 돌기 + break; + } + + for (int x = 0; x < nx; x++) + { + for (int z = 0; z < nz; z++) + { + Vector3 c = new Vector3(offX + x * cell, 0f, offZ + z * cell); + bool entry = (x == 3 || x == 4) && (z == 1 || z == 2); + if (maze[x, z] == 1) + DecorateCaveWall(t.style, c, cell, x * 31 + z * 17, accent, accent2, extra, stem); + else if (!entry) + DecorateCaveFloor(t.style, c, cell, x * 13 + z * 7, accent, accent2, extra, stem); + } + } + + if (t.style == CaveStyle.Crystal) + { + CreatePointLight(new Vector3(-6f, 1.5f, 6f), new Color(0.7f, 0.5f, 1f), 10f, 1.4f); + CreatePointLight(new Vector3(7f, 1.5f, -2f), new Color(0.4f, 0.85f, 1f), 10f, 1.4f); + } + else if (t.style == CaveStyle.Ember) + { + CreatePointLight(new Vector3(-5f, 0.6f, 4f), new Color(1f, 0.45f, 0.15f), 9f, 1.8f); + CreatePointLight(new Vector3(6f, 0.6f, 7f), new Color(1f, 0.45f, 0.15f), 9f, 1.8f); + } + } + + private void DecorateCaveWall(CaveStyle style, Vector3 c, float cell, int seed, Material a, Material b, Material extra, Material stem) + { + float h = 5f, half = (cell - 0.2f) * 0.5f; + // 벽의 네 면 중 하나 — 결정적으로 고른다(미로가 매번 달라도 모양 규칙은 같게) + int face = seed % 4; + Vector3 n = face == 0 ? Vector3.forward : face == 1 ? Vector3.back : face == 2 ? Vector3.right : Vector3.left; + Vector3 onFace = c + n * (half + 0.05f); + switch (style) + { + case CaveStyle.Earth: + case CaveStyle.Dry: + for (int k = 0; k < 3; k++) // 늘어진 뿌리 + { + Vector3 side = Vector3.Cross(Vector3.up, n) * ((k - 1) * 0.9f); + float len = 0.8f + ((seed + k) % 3) * 0.45f; + Deco(PrimitiveType.Cylinder, "CaveRoot", onFace + side + Vector3.up * (h - len * 0.5f), + Quaternion.Euler((k - 1) * 8f, 0f, (k % 2) * 10f - 5f), new Vector3(0.07f, len * 0.5f, 0.07f), a); + } + if (style == CaveStyle.Earth && seed % 2 == 0) + for (int k = 0; k < 4; k++) + Deco(PrimitiveType.Sphere, "Glowworm", onFace + Vector3.up * (2.6f + (k % 2) * 1.3f) + + Vector3.Cross(Vector3.up, n) * ((k - 1.5f) * 0.7f), Quaternion.identity, Vector3.one * 0.08f, b); + if (style == CaveStyle.Dry && seed % 3 == 0) // 거미줄 — 흰 얇은 판 + Deco(PrimitiveType.Cube, "Cobweb", onFace + Vector3.up * 3.6f, Quaternion.LookRotation(n) * Quaternion.Euler(0f, 0f, 30f), + new Vector3(1.1f, 0.9f, 0.01f), b); + break; + case CaveStyle.Moss: + Deco(PrimitiveType.Cube, "MossCap", c + Vector3.up * (h + 0.08f), Quaternion.identity, + new Vector3(cell - 0.1f, 0.22f, cell - 0.1f), a); + for (int k = 0; k < 3; k++) // 벽을 타고 내려오는 이끼 + Deco(PrimitiveType.Cube, "MossDrape", onFace + Vector3.up * (h - 0.9f) + Vector3.Cross(Vector3.up, n) * ((k - 1) * 1.1f), + Quaternion.LookRotation(n), new Vector3(0.7f, 1.6f + (k % 2) * 0.6f, 0.04f), a); + if (seed % 2 == 0) + for (int k = 0; k < 3; k++) + Mushroom(onFace + n * 0.35f + Vector3.Cross(Vector3.up, n) * ((k - 1) * 0.5f), 0.22f + k * 0.06f, stem, b); + break; + case CaveStyle.Wet: + for (int k = 0; k < 2; k++) // 석순 + { + float sh = 0.8f + ((seed + k) % 3) * 0.4f; + Deco(PrimitiveType.Capsule, "Stalagmite", onFace + n * 0.4f + Vector3.Cross(Vector3.up, n) * ((k - 0.5f) * 1.4f) + Vector3.up * sh * 0.5f, + Quaternion.identity, new Vector3(0.35f, sh * 0.5f, 0.35f), extra); + } + Deco(PrimitiveType.Cube, "WetSheen", onFace + Vector3.up * 2.5f, Quaternion.LookRotation(n), + new Vector3(cell - 0.6f, 4.4f, 0.02f), a); + if (seed % 3 == 0) + for (int k = 0; k < 5; k++) + Deco(PrimitiveType.Sphere, "Spore", onFace + n * 0.6f + Vector3.up * (1.2f + k * 0.55f) + + Vector3.Cross(Vector3.up, n) * (((k * 7) % 5 - 2) * 0.4f), Quaternion.identity, Vector3.one * 0.07f, b); + break; + case CaveStyle.Crystal: + for (int k = 0; k < 3; k++) + { + float ch = 0.7f + ((seed + k) % 3) * 0.45f; + Deco(PrimitiveType.Cube, "Crystal", onFace + n * 0.3f + Vector3.Cross(Vector3.up, n) * ((k - 1) * 0.6f) + Vector3.up * ch * 0.45f, + Quaternion.Euler((k - 1) * 18f, seed * 13f + k * 40f, (k % 2) * 20f - 10f), new Vector3(0.26f, ch, 0.26f), (seed + k) % 2 == 0 ? a : b); + } + break; + case CaveStyle.Ancient: + Deco(PrimitiveType.Cube, "CarvedBand", onFace + Vector3.up * 2.3f, Quaternion.LookRotation(n), + new Vector3(cell - 0.4f, 0.14f, 0.03f), a); + Deco(PrimitiveType.Cube, "CarvedBandLow", onFace + Vector3.up * 1.2f, Quaternion.LookRotation(n), + new Vector3(cell - 0.4f, 0.06f, 0.03f), a); + if (seed % 3 == 0) + Deco(PrimitiveType.Cube, "Glyph", onFace + Vector3.up * 3.3f, Quaternion.LookRotation(n) * Quaternion.Euler(0f, 0f, 45f), + new Vector3(0.5f, 0.5f, 0.03f), a); + break; + case CaveStyle.Ember: + Deco(PrimitiveType.Cube, "EmberVein", onFace + Vector3.up * (1.2f + (seed % 3) * 0.9f), Quaternion.LookRotation(n) * Quaternion.Euler(0f, 0f, (seed % 5) * 14f - 28f), + new Vector3(cell * 0.7f, 0.07f, 0.03f), a); + for (int k = 0; k < 2; k++) // 현무암 돌기 + Deco(PrimitiveType.Cylinder, "BasaltSpur", onFace + n * 0.35f + Vector3.Cross(Vector3.up, n) * ((k - 0.5f) * 1.5f) + Vector3.up * 0.5f, + Quaternion.identity, new Vector3(0.45f, 0.5f + k * 0.25f, 0.45f), b); + break; + } + } + + private void DecorateCaveFloor(CaveStyle style, Vector3 c, float cell, int seed, Material a, Material b, Material extra, Material stem) + { + Vector3 jitter = new Vector3(((seed * 7) % 5 - 2) * 0.35f, 0f, ((seed * 3) % 5 - 2) * 0.35f); + Vector3 p = c + jitter; + switch (style) + { + case CaveStyle.Earth: + if (seed % 3 == 0) + for (int k = 0; k < 3; k++) + Mushroom(p + new Vector3(k * 0.3f, 0f, (k % 2) * 0.25f), 0.2f + k * 0.07f, stem, extra); + break; + case CaveStyle.Moss: + Deco(PrimitiveType.Cylinder, "MossCarpet", p + Vector3.up * 0.02f, Quaternion.identity, new Vector3(1.8f, 0.01f, 1.5f), a); + break; + case CaveStyle.Wet: + if (seed % 2 == 0) + Deco(PrimitiveType.Cylinder, "Puddle", p + Vector3.up * 0.015f, Quaternion.identity, new Vector3(2.2f, 0.01f, 1.7f), a); + break; + case CaveStyle.Crystal: + if (seed % 4 == 0) + Deco(PrimitiveType.Cube, "CrystalShard", p + Vector3.up * 0.3f, Quaternion.Euler(20f, seed * 23f, 15f), new Vector3(0.2f, 0.6f, 0.2f), b); + break; + case CaveStyle.Ancient: + if (seed % 3 == 0) + Deco(PrimitiveType.Cube, "FallenBlock", p + Vector3.up * 0.25f, Quaternion.Euler(0f, seed * 29f, 6f), new Vector3(0.9f, 0.5f, 0.6f), b); + else + Deco(PrimitiveType.Cube, "FloorTile", p + Vector3.up * 0.015f, Quaternion.Euler(0f, 45f, 0f), new Vector3(1.6f, 0.02f, 1.6f), extra); + break; + case CaveStyle.Dry: + if (seed % 2 == 0) + Deco(PrimitiveType.Sphere, "DustMound", p + Vector3.up * 0.04f, Quaternion.identity, new Vector3(1.4f, 0.16f, 1.1f), extra); + else + Deco(PrimitiveType.Sphere, "PaleStone", p + Vector3.up * 0.1f, Quaternion.Euler(0f, seed * 17f, 0f), new Vector3(0.45f, 0.22f, 0.35f), b); + break; + case CaveStyle.Ember: + Deco(PrimitiveType.Cube, "Fissure", p + Vector3.up * 0.02f, Quaternion.Euler(0f, seed * 37f, 0f), new Vector3(0.14f, 0.02f, cell * 0.8f), a); + if (seed % 3 == 0) + Deco(PrimitiveType.Cylinder, "LavaPool", p + new Vector3(0.6f, 0.02f, 0.4f), Quaternion.identity, new Vector3(1.2f, 0.01f, 1f), a); + break; + } + } + + /// 버섯 한 송이. 줄기·갓 머티리얼은 호출부가 한 번 만들어 넘긴다( 주석). + private void Mushroom(Vector3 at, float h, Material stem, Material cap) + { + Deco(PrimitiveType.Cylinder, "MushStem", at + Vector3.up * h * 0.5f, Quaternion.identity, new Vector3(0.06f, h * 0.5f, 0.06f), stem); + Deco(PrimitiveType.Sphere, "MushCap", at + Vector3.up * h, Quaternion.identity, new Vector3(h * 0.9f, h * 0.4f, h * 0.9f), cap); + } + + // ======================= 개미귀신 구덩이 ======================= + + /// + /// 모래언덕의 개미귀신 구덩이 — 동굴이 아니라 하늘이 열린 모래 깔때기다. 가운데로 갈수록 짙어지는 + /// 동심원이 파인 경사를, 둘레의 모래 둔덕이 가장자리를 만든다. 방 크기는 동굴과 같은 14(story_lint 21). + /// + private void BuildAntlionPit(SubAreaData sub) + { + Material sand = Mat(new Color(0.76f, 0.64f, 0.44f)); + Material wallMat = Mat(new Color(0.70f, 0.58f, 0.40f)); + CreateFloor(sand, 30f); + + Vector3 pit = new Vector3(0f, 0f, 3f); // 입구(0,-8)에서 11m 앞 + Color[] rings = { new Color(0.71f, 0.59f, 0.40f), new Color(0.64f, 0.52f, 0.35f), new Color(0.56f, 0.45f, 0.30f), + new Color(0.46f, 0.36f, 0.23f), new Color(0.32f, 0.24f, 0.15f) }; + float[] radii = { 9.5f, 7.2f, 5f, 3f, 1.3f }; + for (int i = 0; i < rings.Length; i++) + Deco(PrimitiveType.Cylinder, $"PitRing_{i}", pit + Vector3.up * (0.012f + i * 0.006f), Quaternion.identity, + new Vector3(radii[i] * 2f, 0.005f, radii[i] * 2f), Mat(rings[i])); + // 흘러내린 모래 자국 — 가장자리에서 중심으로 향하는 줄 + Material streak = Mat(new Color(0.80f, 0.69f, 0.49f)); + for (int i = 0; i < 14; i++) + { + float a = i * 360f / 14f; + Vector3 dir = Quaternion.Euler(0f, a, 0f) * Vector3.forward; + Deco(PrimitiveType.Cube, "SandStreak", pit + dir * 6f + Vector3.up * 0.05f, Quaternion.LookRotation(dir), + new Vector3(0.18f, 0.02f, 4.2f), streak); + } + // 구덩이 둘레의 모래 둔덕 + 벽 밑을 덮는 사구(상자 모서리를 숨긴다) + Material dune = Mat(new Color(0.80f, 0.68f, 0.47f)); + for (int i = 0; i < 12; i++) + { + float a = (i + 0.5f) * 30f * Mathf.Deg2Rad; + Vector3 at = pit + new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * 10.6f; + if (at.z < -6f && Mathf.Abs(at.x) < 4f) continue; // 입구 쪽은 트인다 + Deco(PrimitiveType.Sphere, "PitRim", at + Vector3.down * 0.35f, Quaternion.Euler(0f, -a * Mathf.Rad2Deg, 0f), + new Vector3(2.4f, 1.3f, 4.2f), dune); + } + for (int i = 0; i < 16; i++) + { + float t = i / 16f * 4f; + int side = Mathf.FloorToInt(t); + float u = (t - side) * 26f - 13f; + Vector3 at = side == 0 ? new Vector3(u, 0f, 13f) : side == 1 ? new Vector3(13f, 0f, u) + : side == 2 ? new Vector3(-u, 0f, -13f) : new Vector3(-13f, 0f, -u); + if (at.z < -12f && Mathf.Abs(at.x) < 3f) continue; + Deco(PrimitiveType.Sphere, "WallDune", at + Vector3.down * 0.4f, Quaternion.Euler(0f, side * 90f, 0f), + new Vector3(5.5f, 3.2f, 3.2f), i % 3 == 0 ? wallMat : dune); + } + // 반쯤 묻힌 명부회 화물 상자와 마른 덤불 + Material crate = Mat(new Color(0.50f, 0.38f, 0.24f)); + Deco(PrimitiveType.Cube, "BuriedCrate", new Vector3(8.5f, 0.2f, 9f), Quaternion.Euler(8f, 25f, 12f), new Vector3(1.2f, 0.9f, 1.1f), crate); + Deco(PrimitiveType.Cube, "BuriedCrate", new Vector3(-9f, 0.1f, 7.5f), Quaternion.Euler(-10f, -30f, 6f), new Vector3(1.1f, 0.8f, 1.1f), crate); + Material twig = Mat(new Color(0.46f, 0.38f, 0.25f)); + for (int i = 0; i < 18; i++) + { + Vector3 at = new Vector3(Mathf.Sin(i * 2.4f) * 11f, 0.3f, Mathf.Cos(i * 1.7f) * 11f); + if (at.z < -5f && Mathf.Abs(at.x) < 4f) continue; + Deco(PrimitiveType.Cylinder, "DryTwig", at, Quaternion.Euler(i * 23f % 50f - 25f, i * 40f, i * 17f % 40f - 20f), new Vector3(0.05f, 0.35f, 0.05f), twig); + } + + CreatePointLight(new Vector3(0f, 8f, 0f), new Color(1f, 0.92f, 0.75f), 26f, 0.9f); + CreateBoundaryWalls(wallMat, 14f, 3.5f); + } + + // ======================= 빈칸 ======================= + + /// + /// 이름 없는 자리의 끝(nameless_core) — 아무것도 적히지 않은 장부의 한 페이지. 창백한 바닥에 줄이 그어져 + /// 있고, 빈 석판이 둘러서 있고, 가운데엔 틀만 남은 문이 있다. 최종 대면 연출(fin_unnamed)의 무대라 입구에서 + /// 가운데까지는 아무것도 막지 않는다(전부 콜라이더 없음). 방 크기는 동굴과 같은 14. + /// + private void BuildBlankCore(SubAreaData sub) + { + Material paper = Mat(new Color(0.80f, 0.79f, 0.84f)); + Material wallMat = Mat(new Color(0.70f, 0.69f, 0.76f)); + Material rule = Mat(new Color(0.62f, 0.60f, 0.74f)); + Material slab = Mat(new Color(0.88f, 0.87f, 0.92f)); + Material frame = Mat(new Color(0.46f, 0.44f, 0.56f)); + Material mote = GlowMat(new Color(0.92f, 0.90f, 1f), new Color(0.6f, 0.58f, 0.8f)); + CreateFloor(paper, 30f); + + // 괘선 — 장부의 줄. 가로줄만 긋고 왼쪽에 세로 여백선 하나 + for (int i = -4; i <= 4; i++) + Deco(PrimitiveType.Cube, "RuledLine", new Vector3(0f, 0.012f, i * 3f), Quaternion.identity, new Vector3(26f, 0.01f, 0.06f), rule); + Deco(PrimitiveType.Cube, "MarginLine", new Vector3(-9.5f, 0.013f, 0f), Quaternion.identity, new Vector3(0.08f, 0.01f, 26f), GlowMat(new Color(0.85f, 0.55f, 0.6f), new Color(0.35f, 0.12f, 0.15f))); + + // 빈 석판 원 — 입구 쪽(남쪽 90°)은 비운다 + for (int i = 0; i < 12; i++) + { + float deg = i * 30f + 15f; + if (deg > 225f && deg < 315f) continue; + float a = deg * Mathf.Deg2Rad; + Vector3 at = new Vector3(Mathf.Cos(a) * 9.5f, 0f, Mathf.Sin(a) * 9.5f + 1f); + float h = 2.2f + (i % 3) * 0.5f; + Quaternion face = Quaternion.Euler(0f, -(deg + 90f), 0f); + Deco(PrimitiveType.Cube, "BlankSlab", at + Vector3.up * h * 0.5f, face, new Vector3(1.3f, h, 0.24f), slab); + Deco(PrimitiveType.Cube, "BlankSlabFrame", at + Vector3.up * h * 0.55f, face, + new Vector3(0.95f, h * 0.62f, 0.26f), frame); + Deco(PrimitiveType.Cube, "BlankSlabFace", at + Vector3.up * h * 0.55f, face, new Vector3(0.82f, h * 0.54f, 0.28f), slab); + } + + // 틀만 남은 문 — 이름을 적을 자리 + Vector3 door = new Vector3(0f, 0f, 5f); + Deco(PrimitiveType.Cube, "EmptyDoorL", door + new Vector3(-1.1f, 1.5f, 0f), Quaternion.identity, new Vector3(0.22f, 3f, 0.22f), frame); + Deco(PrimitiveType.Cube, "EmptyDoorR", door + new Vector3(1.1f, 1.5f, 0f), Quaternion.identity, new Vector3(0.22f, 3f, 0.22f), frame); + Deco(PrimitiveType.Cube, "EmptyDoorTop", door + new Vector3(0f, 3.05f, 0f), Quaternion.identity, new Vector3(2.44f, 0.22f, 0.22f), frame); + Deco(PrimitiveType.Cube, "EmptyDoorGlow", door + new Vector3(0f, 1.5f, 0f), Quaternion.identity, new Vector3(1.9f, 2.9f, 0.02f), mote); + + // 떠 있는 빈 종이 + for (int i = 0; i < 22; i++) + { + Vector3 at = new Vector3(Mathf.Sin(i * 1.9f) * 10f, 1.4f + (i % 5) * 0.6f, Mathf.Cos(i * 2.7f) * 10f); + if (at.z < -5f && Mathf.Abs(at.x) < 4f) continue; + Deco(PrimitiveType.Cube, "BlankPage", at, Quaternion.Euler(i * 37f % 60f - 30f, i * 53f, i * 29f % 40f - 20f), new Vector3(0.36f, 0.5f, 0.01f), + i % 3 == 0 ? mote : slab); + } + + CreatePointLight(door + Vector3.up * 1.5f, new Color(0.85f, 0.82f, 1f), 12f, 1.2f); + CreatePointLight(new Vector3(0f, 7f, -2f), new Color(0.92f, 0.9f, 1f), 24f, 0.9f); + CreateBoundaryWalls(wallMat, 14f, 4f); + } + + // ======================= 가장 높은 가지 ======================= + + /// + /// 우듬지의 가장 높은 가지(canopy_crown) — 산 정상이 아니라 나무 꼭대기다. 바닥은 잎이 깔린 넓은 가지, + /// 굵은 가지가 가로지르고, 가장자리는 잎 덩이가 둘러싼다. 뒤로 하늘이 보인다. 방 크기는 산 정상과 같은 11. + /// + private void BuildCanopyCrown(SubAreaData sub) + { + Material leafFloor = Mat(new Color(0.30f, 0.46f, 0.22f)); + Material bark = Mat(new Color(0.42f, 0.31f, 0.19f)); + Material barkDark = Mat(new Color(0.33f, 0.24f, 0.15f)); + Material leaf = Mat(new Color(0.26f, 0.52f, 0.22f)); + Material leafLight = Mat(new Color(0.40f, 0.64f, 0.28f)); + Material blossom = Mat(new Color(0.98f, 0.72f, 0.36f)); + Material wallMat = Mat(new Color(0.24f, 0.44f, 0.20f)); + CreateFloor(leafFloor, 25f); + + // 굵은 가지 — 바닥을 가로지르는 나무껍질 길. 입구(0,-8)에서 북쪽으로 이어진다. + // 콜라이더가 없어 플레이어가 그 위를 "걷는" 게 아니라 통과하므로 윗면을 발목 아래(≈0.15m)로 둔다 + Deco(PrimitiveType.Capsule, "MainBranch", new Vector3(0f, -0.1f, 0f), Quaternion.Euler(90f, 0f, 0f), new Vector3(3.2f, 11f, 0.5f), bark); + Deco(PrimitiveType.Capsule, "SideBranchE", new Vector3(5f, -0.08f, 3f), Quaternion.Euler(90f, 55f, 0f), new Vector3(1.8f, 5f, 0.4f), barkDark); + Deco(PrimitiveType.Capsule, "SideBranchW", new Vector3(-5f, -0.08f, -1f), Quaternion.Euler(90f, -60f, 0f), new Vector3(1.8f, 5f, 0.4f), barkDark); + // 잎 조각 — 바닥 결 + for (int i = 0; i < 40; i++) + { + Vector3 at = new Vector3(Mathf.Sin(i * 2.1f) * 9.5f, 0.03f, Mathf.Cos(i * 1.3f) * 9.5f); + Deco(PrimitiveType.Cylinder, "LeafMat", at, Quaternion.Euler(0f, i * 31f, 0f), new Vector3(1.1f, 0.01f, 0.6f), i % 2 == 0 ? leaf : leafLight); + } + // 가장자리 잎 덩이 — 벽을 덮는다(남쪽 가운데는 낮게) + for (int i = 0; i < 24; i++) + { + float t = i / 24f * 4f; + int side = Mathf.FloorToInt(t); + float u = (t - side) * 22f - 11f; + Vector3 at = side == 0 ? new Vector3(u, 0f, 10.3f) : side == 1 ? new Vector3(10.3f, 0f, u) + : side == 2 ? new Vector3(-u, 0f, -10.3f) : new Vector3(-10.3f, 0f, -u); + bool south = side == 2; + float s = south ? 1.8f : 3.2f + (i % 3) * 0.6f; + Deco(PrimitiveType.Sphere, "CrownLeaves", at + Vector3.up * s * 0.35f, Quaternion.Euler(0f, i * 40f, 0f), + new Vector3(s * 1.3f, s, s * 1.1f), i % 2 == 0 ? leaf : leafLight); + if (i % 4 == 1 && !south) + Deco(PrimitiveType.Sphere, "Blossom", at + Vector3.up * (s * 0.8f), Quaternion.identity, Vector3.one * 0.45f, blossom); + } + // 가지 끝의 새순과 열매 + Material fruit = GlowMat(new Color(1f, 0.55f, 0.28f), new Color(0.35f, 0.12f, 0.04f)); + for (int i = 0; i < 8; i++) + Deco(PrimitiveType.Sphere, "CrownFruit", new Vector3(Mathf.Sin(i * 1.4f) * 6f, 0.25f, Mathf.Cos(i * 1.4f) * 6f + 2f), Quaternion.identity, Vector3.one * 0.3f, fruit); + + CreatePointLight(Vector3.up * 10f, new Color(1f, 0.98f, 0.85f), 25f, 1.3f); + CreateBoundaryWalls(wallMat, 11f, 3f); + } + + // ======================= 산 정상 · 서리 능선 ======================= + + /// 산 정상의 돌탑·깃발, 서리 능선의 얼음 가시·눈 덮인 바위. 눈 패치는 둔덕(각진 사각형 금지). + private void DecoratePeak(SubAreaData sub) + { + bool frost = sub.subAreaId == "frostline_ridge"; + Material snow = Mat(frost ? new Color(0.90f, 0.93f, 0.97f) : new Color(0.88f, 0.90f, 0.94f)); + for (int i = 0; i < (frost ? 14 : 7); i++) + { + Vector3 at = new Vector3(Mathf.Sin(i * 2.3f) * 8f, 0.02f, Mathf.Cos(i * 1.9f) * 8f); + Deco(PrimitiveType.Sphere, "SnowMound", at, Quaternion.Euler(0f, i * 47f, 0f), new Vector3(2.8f, 0.4f, 2.1f), snow); + } + if (frost) + { + Material ice = GlowMat(new Color(0.66f, 0.84f, 0.96f), new Color(0.12f, 0.2f, 0.28f)); + for (int i = 0; i < 12; i++) + { + Vector3 at = new Vector3(Mathf.Sin(i * 1.7f + 0.4f) * 8.5f, 0f, Mathf.Cos(i * 2.9f) * 8.5f); + if (at.z < -5f && Mathf.Abs(at.x) < 3.5f) continue; + for (int k = 0; k < 3; k++) + { + float h = 0.8f + ((i + k) % 3) * 0.6f; + Deco(PrimitiveType.Cube, "IceSpike", at + new Vector3(k * 0.35f, h * 0.45f, (k % 2) * 0.3f), + Quaternion.Euler((k - 1) * 16f, i * 29f + k * 50f, (k % 2) * 14f - 7f), new Vector3(0.28f, h, 0.28f), ice); + } + } + // 서릿발 바람에 깎인 능선 바위 + Material rock = Mat(new Color(0.52f, 0.58f, 0.66f)); + for (int i = 0; i < 5; i++) + { + Vector3 at = new Vector3(-7f + i * 3.5f, 0.5f, 7.5f + (i % 2) * 1.2f); + Deco(PrimitiveType.Cube, "RidgeRock", at, Quaternion.Euler(0f, i * 33f, 8f), new Vector3(2f, 1.3f, 1.4f), rock); + Deco(PrimitiveType.Sphere, "RidgeSnowCap", at + Vector3.up * 0.72f, Quaternion.Euler(0f, i * 33f, 0f), new Vector3(2.1f, 0.35f, 1.5f), snow); + } + return; + } + // 산 정상 — 돌탑(소원 돌)과 깃발 + Material stone = Mat(new Color(0.56f, 0.54f, 0.50f)); + Vector3 cairn = new Vector3(0f, 0f, 4.5f); + for (int k = 0; k < 6; k++) + { + float w = 1.2f - k * 0.16f; + Deco(PrimitiveType.Sphere, "CairnStone", cairn + Vector3.up * (0.18f + k * 0.3f), Quaternion.Euler(0f, k * 51f, 0f), + new Vector3(w, 0.32f, w * 0.85f), stone); + } + Deco(PrimitiveType.Cylinder, "SummitPole", new Vector3(2.2f, 1.4f, 5f), Quaternion.identity, new Vector3(0.08f, 1.4f, 0.08f), Mat(new Color(0.40f, 0.30f, 0.20f))); + Deco(PrimitiveType.Cube, "SummitFlag", new Vector3(2.75f, 2.45f, 5f), Quaternion.Euler(0f, 0f, -4f), new Vector3(1f, 0.6f, 0.02f), Mat(new Color(0.85f, 0.22f, 0.18f))); + Material alpine = Mat(new Color(0.46f, 0.52f, 0.28f)); + for (int i = 0; i < 16; i++) + Deco(PrimitiveType.Sphere, "AlpineTuft", new Vector3(Mathf.Sin(i * 2.7f) * 9f, 0.08f, Mathf.Cos(i * 1.1f) * 9f), Quaternion.identity, + new Vector3(0.6f, 0.22f, 0.5f), alpine); + } + + // ======================= 침묵의 자리 ======================= + + /// + /// 텅 빈 들의 침묵의 자리(hollow_silence) — 늪이 아니다. 빛바랜 풀밭에 풍경(風磬)을 단 장대가 서 있는데 + /// 아무것도 울리지 않는다. 늪 안개 빌더를 쓰되 물웅덩이·고목 대신 이걸 둔다. + /// + private void DecorateSilence() + { + Material post = Mat(new Color(0.62f, 0.58f, 0.50f)); + Material chime = Mat(new Color(0.78f, 0.80f, 0.84f)); + Material grass = Mat(new Color(0.66f, 0.63f, 0.50f)); + for (int i = 0; i < 7; i++) + { + float a = (i * 51f + 20f) * Mathf.Deg2Rad; + Vector3 at = new Vector3(Mathf.Cos(a) * 7.5f, 0f, Mathf.Sin(a) * 7.5f + 1f); + if (at.z < -5f && Mathf.Abs(at.x) < 3.5f) continue; + Deco(PrimitiveType.Cylinder, "ChimePost", at + Vector3.up * 1.2f, Quaternion.Euler(0f, 0f, (i % 3 - 1) * 3f), new Vector3(0.1f, 1.2f, 0.1f), post); + Deco(PrimitiveType.Cube, "ChimeBar", at + Vector3.up * 2.35f, Quaternion.Euler(0f, i * 40f, 0f), new Vector3(0.9f, 0.05f, 0.05f), post); + for (int k = 0; k < 3; k++) + { + Vector3 off = Quaternion.Euler(0f, i * 40f, 0f) * new Vector3((k - 1) * 0.35f, 0f, 0f); + Deco(PrimitiveType.Cylinder, "ChimeTube", at + off + Vector3.up * (2.0f - k * 0.08f), Quaternion.identity, new Vector3(0.05f, 0.22f + k * 0.05f, 0.05f), chime); + } + } + for (int i = 0; i < 40; i++) + Deco(PrimitiveType.Sphere, "PaleGrass", new Vector3(Mathf.Sin(i * 2.2f) * 11f, 0.12f, Mathf.Cos(i * 1.6f) * 11f), Quaternion.Euler(0f, i * 17f, 0f), + new Vector3(0.7f, 0.35f, 0.5f), grass); + } + + // ======================= 겹친 가지 속 ======================= + + /// 우듬지의 겹친 가지 속(canopy_bough) — 깊은 숲 빌더 위에 밝은 잎·꽃·이끼 낀 가지를 얹는다. + private void DecorateBough() + { + Material moss = Mat(new Color(0.34f, 0.56f, 0.26f)); + Material flower = GlowMat(new Color(0.98f, 0.70f, 0.40f), new Color(0.25f, 0.12f, 0.04f)); + Material vine = Mat(new Color(0.24f, 0.46f, 0.20f)); + for (int i = 0; i < 30; i++) + { + Vector3 at = new Vector3(Mathf.Sin(i * 2.9f) * 13f, 0.04f, Mathf.Cos(i * 1.3f) * 13f); + Deco(PrimitiveType.Cylinder, "MossPatch", at, Quaternion.identity, new Vector3(1.6f, 0.01f, 1.2f), moss); + if (i % 2 == 0) + Deco(PrimitiveType.Sphere, "BoughFlower", at + new Vector3(0.3f, 0.2f, 0f), Quaternion.identity, Vector3.one * 0.28f, flower); + } + for (int i = 0; i < 16; i++) // 늘어진 덩굴(잎 덩이 아래) + { + Vector3 at = new Vector3(Mathf.Sin(i * 1.9f) * 12f, 3.2f, Mathf.Cos(i * 2.3f) * 12f); + Deco(PrimitiveType.Cylinder, "Vine", at, Quaternion.Euler((i % 3 - 1) * 6f, 0f, 0f), new Vector3(0.05f, 0.9f, 0.05f), vine); + } + } + + // ======================= 숨겨진 웅덩이 ======================= + + /// + /// 초원의 숨겨진 웅덩이(meadow_pond) — 수중이 아니다. 풀밭 한가운데 맑은 웅덩이와 수련, 갈대. + /// 옛날엔 연못 깊은 곳과 같은 수중 빌더라 분홍 산호 기둥이 초원 물웅덩이에 서 있었다. 방 크기는 14. + /// + private void BuildHiddenPuddle(SubAreaData sub) + { + Material grass = Mat(new Color(0.30f, 0.46f, 0.23f)); + Material grassLight = Mat(new Color(0.38f, 0.54f, 0.27f)); + Material mud = Mat(new Color(0.36f, 0.32f, 0.22f)); + Material water = Mat(new Color(0.30f, 0.52f, 0.62f)); + Wet(water); + Material shallow = Mat(new Color(0.42f, 0.62f, 0.60f)); + Wet(shallow); + Material pad = Mat(new Color(0.28f, 0.52f, 0.22f)); + Material reed = Mat(new Color(0.40f, 0.52f, 0.24f)); + Material hedge = Mat(new Color(0.24f, 0.40f, 0.20f)); + Material lily = Mat(new Color(0.98f, 0.80f, 0.88f)); // 수련 꽃 — 루프 안에서 만들면 송이마다 인스턴스가 는다 + CreateFloor(grass, 30f); + + Vector3 pool = new Vector3(0f, 0f, 3f); + Deco(PrimitiveType.Cylinder, "PuddleMud", pool + Vector3.up * 0.012f, Quaternion.identity, new Vector3(15f, 0.005f, 13f), mud); + Deco(PrimitiveType.Cylinder, "PuddleShallow", pool + Vector3.up * 0.02f, Quaternion.identity, new Vector3(13.4f, 0.005f, 11.4f), shallow); + Deco(PrimitiveType.Cylinder, "PuddleWater", pool + Vector3.up * 0.028f, Quaternion.identity, new Vector3(10.5f, 0.005f, 8.6f), water); + for (int i = 0; i < 14; i++) + { + Vector3 at = pool + new Vector3(Mathf.Sin(i * 2.4f) * 4f, 0.04f, Mathf.Cos(i * 1.7f) * 3.2f); + Deco(PrimitiveType.Cylinder, "LilyPad", at, Quaternion.Euler(0f, i * 40f, 0f), new Vector3(0.8f, 0.01f, 0.8f), pad); + if (i % 4 == 0) + Deco(PrimitiveType.Sphere, "LilyFlower", at + Vector3.up * 0.08f, Quaternion.identity, new Vector3(0.25f, 0.14f, 0.25f), lily); + } + for (int i = 0; i < 26; i++) + { + float a = i * 360f / 26f * Mathf.Deg2Rad; + Vector3 at = pool + new Vector3(Mathf.Cos(a) * 7.2f, 0f, Mathf.Sin(a) * 6.2f); + if (at.z < -3f && Mathf.Abs(at.x) < 2.5f) continue; // 입구에서 물가로 들어오는 틈 + float h = 1.0f + (i % 3) * 0.35f; + Deco(PrimitiveType.Cylinder, "Reed", at + Vector3.up * h * 0.5f, Quaternion.Euler((i % 3 - 1) * 5f, 0f, (i % 2) * 6f - 3f), new Vector3(0.07f, h * 0.5f, 0.07f), reed); + } + for (int i = 0; i < 40; i++) + Deco(PrimitiveType.Sphere, "GrassClump", new Vector3(Mathf.Sin(i * 1.9f) * 12f, 0.1f, Mathf.Cos(i * 2.6f) * 12f), Quaternion.identity, + new Vector3(0.9f, 0.35f, 0.7f), i % 2 == 0 ? grass : grassLight); + // 둘레 수풀 — 상자 벽을 가린다 + for (int i = 0; i < 20; i++) + { + float t = i / 20f * 4f; + int side = Mathf.FloorToInt(t); + float u = (t - side) * 28f - 14f; + Vector3 at = side == 0 ? new Vector3(u, 0f, 13.2f) : side == 1 ? new Vector3(13.2f, 0f, u) + : side == 2 ? new Vector3(-u, 0f, -13.2f) : new Vector3(-13.2f, 0f, -u); + Deco(PrimitiveType.Sphere, "HedgeBush", at + Vector3.up * 0.8f, Quaternion.identity, new Vector3(3.4f, 2.2f, 2.4f), hedge); + } + + CreatePointLight(new Vector3(0f, 6f, 0f), new Color(1f, 0.97f, 0.88f), 24f, 1.1f); + CreateBoundaryWalls(hedge, 14f, 3f); + } + + // ======================= 공용 ======================= + + /// 장식용 프리미티브 — 콜라이더 없음(연출 배우·입구 안전 좌표를 막지 않는다). + private GameObject Deco(PrimitiveType type, string name, Vector3 localPos, Quaternion localRot, Vector3 scale, Material mat) + { + GameObject go = Prim(type, name); + go.transform.localPosition = localPos; + go.transform.localRotation = localRot; + go.transform.localScale = scale; + Apply(go, mat); + NoCollider(go); + return go; + } + + private Material GlowMat(Color color, Color emission) + { + Material m = Mat(color); + SceneryMaterials.SetEmission(m, emission); + return m; + } + + private static void Wet(Material m) + { + if (m == null) return; + if (m.HasProperty("_Glossiness")) m.SetFloat("_Glossiness", 0.85f); + if (m.HasProperty("_Smoothness")) m.SetFloat("_Smoothness", 0.85f); + } + } +} diff --git a/Assets/Scripts/Core/SubAreaWorldBuilder.Themes.cs.meta b/Assets/Scripts/Core/SubAreaWorldBuilder.Themes.cs.meta new file mode 100644 index 0000000..b56fabf --- /dev/null +++ b/Assets/Scripts/Core/SubAreaWorldBuilder.Themes.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ca8123fdc0212294ba2fcd29ff7154b9 \ No newline at end of file diff --git a/Assets/Scripts/Core/SubAreaWorldBuilder.cs b/Assets/Scripts/Core/SubAreaWorldBuilder.cs index 2ab46d1..4e2b9e0 100644 --- a/Assets/Scripts/Core/SubAreaWorldBuilder.cs +++ b/Assets/Scripts/Core/SubAreaWorldBuilder.cs @@ -9,7 +9,7 @@ namespace InsectGame.Core /// 서브에리어 진입 시 완전히 다른 공간을 프로시저럴 생성합니다. /// 메인 월드를 숨기고, 별도 위치(2000,0,2000)에 미니 던전/환경을 구축합니다. /// - public class SubAreaWorldBuilder : MonoBehaviour + public partial class SubAreaWorldBuilder : MonoBehaviour { [SerializeField] private RegionManager regionManager; [SerializeField] private CameraFollower cameraFollower; @@ -30,6 +30,9 @@ public class SubAreaWorldBuilder : MonoBehaviour private bool notifyIsEnter; private GUIStyle notifyStyleCache; private GUIStyle entryExitButtonStyleCache; + // 진입 버튼 문구 캐시 — 표시 이름이 바뀔 때만 다시 만든다(GetEnterLabel) + private string enterLabelName; + private string enterLabel; 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); @@ -66,7 +69,7 @@ private static void SetLayerRecursively(GameObject root, int layer) /// /// 이번 서브에리어 빌드가 만든 런타임 머티리얼. subAreaRoot를 Destroy해도 이건 안 지워진다 — /// new Material(...)은 GameObject가 아니라 별개 오브젝트라 명시적으로 파괴해야 한다. - /// 한 번 지을 때마다 Mat()이 41번 불리고, 서브에리어는 25m 이탈·[E] 재진입으로 + /// 한 번 지을 때마다 Mat()이 수십 번 불리고(테마마다 다르다), 서브에리어는 25m 이탈·[E] 재진입으로 /// 세션 내내 반복해서 들락거리므로 방치하면 계속 쌓인다. /// (같은 이유로 NpcVisualBuilder.CleanupMaterials·PlayerVisualBuilder.OnDestroy가 있다.) /// @@ -176,7 +179,12 @@ private void DestroySubAreaWorld() private void Update() { - if (notifyTimer > 0f) notifyTimer -= Time.deltaTime; + if (notifyTimer > 0f) + { + notifyTimer -= Time.deltaTime; + // 출입 토스트는 가운데 무대(HudStage)의 고정 칸에 선다 — 서 있는 동안 차례 카드들이 기다린다. + HudStage.Request(HudStageItem.PlaceToast); + } // 모달/배틀/미니게임/포획 모달(frozen) 중에는 수동 진입·퇴장 입력을 막는다. // CaptureInputController와 동일 신호 — 같은 [E]가 포획과 SubArea 진입에 동시 발화하던 충돌 차단. @@ -254,17 +262,17 @@ private void OnGUI() { fontSize = 28, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; } - // 진입/퇴장 토스트 (3초 페이드) + // 토스트와 버튼은 다른 HUD와 같은 가상 캔버스(1920×1080 / 1080×1920)에 그린다 — 자리를 HudStage·GateRect가 + // 가상 좌표로 정하고, 겹침 전수 검사(HudOverlapSweepTests)가 그 좌표를 본다. + UIScale.Begin(); + HudFrame frame = HudFrame.Current; + + // 진입/퇴장 토스트 (3초 페이드) — 가운데 무대의 고정 칸(ToastRect). if (notifyTimer > 0f && !string.IsNullOrEmpty(notifyText)) { float alpha = Mathf.Clamp01(notifyTimer / 3f); - float w = 560f; - float h = 56f; - // 픽셀 좌표계다(`UIScale.Begin()`을 쓰지 않는다). 고정 90px은 노치·상단 인셋이 - // 있는 기기에서 토스트를 그 아래로 밀어 넣는다 — 인셋이 0인 데스크톱에서는 - // 90이 그대로 이기고, 인셋이 있으면 그만큼 내려간다(rules/ui-layout.md의 Px 파사드). - float toastY = Mathf.Max(90f, UISafeLayout.Px.ContentTop); - Rect r = new Rect((Screen.width - w) * 0.5f, toastY, w, h); + Rect r = ToastRect(frame); + HudStage.Request(HudStageItem.PlaceToast, r); Color bg = NotifyBgCol; bg.a = 0.78f * alpha; GUI.color = bg; @@ -273,7 +281,7 @@ private void OnGUI() textCol.a = alpha; notifyStyleCache.normal.textColor = textCol; GUI.color = Color.white; - GUI.Label(r, notifyText, notifyStyleCache); + UIHelper.LabelFit(r, notifyText, notifyStyleCache); } // 진입/퇴장 버튼은 모달/배틀/미니게임/포획 모달(frozen) 중에는 그리지도 입력받지도 않는다. @@ -283,8 +291,9 @@ private void OnGUI() // 진입과 퇴장은 같은 고정 위치의 큰 버튼으로 제공한다. if (isInSubArea) { - Rect r = GetEntryExitButtonRect(); + Rect r = GateRect(frame); BlockFieldClicks(r); + HudPresence.Mark(HudPresenceItem.Gate); // 겹치는 안내 배너가 비켜 준다 GUI.backgroundColor = NotifyExitCol; if (GUI.Button(r, "메인 월드로 나가기", GetEntryExitButtonStyle())) RequestExit(); @@ -294,10 +303,11 @@ private void OnGUI() else if (regionManager != null && regionManager.NearbySubArea != null) { SubAreaData sub = regionManager.NearbySubArea; - Rect r = GetEntryExitButtonRect(); + Rect r = GateRect(frame); BlockFieldClicks(r); + HudPresence.Mark(HudPresenceItem.Gate); GUI.backgroundColor = NotifyEnterCol; - if (GUI.Button(r, $"{GetSubAreaDisplayName(sub)} 들어가기", GetEntryExitButtonStyle())) + if (GUI.Button(r, GetEnterLabel(sub), GetEntryExitButtonStyle())) { regionManager.RequestEnterSubArea(); } @@ -305,6 +315,7 @@ private void OnGUI() } } GUI.color = Color.white; + UIScale.End(); } /// @@ -316,25 +327,42 @@ private void OnGUI() /// (rules/ui-layout.md — 같은 결함을 QuickAccessBarUI·WorldFieldMultiplayerUI· /// TutorialQuestUI에서 이미 고쳤다. 이 화면의 버튼이 그중 가장 크다: 620×100). /// - /// 이 화면은 픽셀 좌표로 그린다(`UIScale.Begin()`을 쓰지 않는다) — 등록은 - /// 가상 좌표를 받으므로 `UIScale.Scale`로 나눠 넘긴다. + /// 이 화면은 가상 캔버스(`UIScale.Begin()`)에 그리므로 Rect를 그대로 넘긴다. + /// + private static void BlockFieldClicks(Rect virtualRect) + { + FieldHudInput.RegisterBlockingRect(virtualRect); + } + + public const float GateMaxWidth = 620f; + public const float GateHeight = 100f; + public const float ToastWidth = 560f; + public const float ToastHeight = 56f; + + /// + /// 동굴 입구·나가기 버튼의 자리(가상 좌표) — 순수 계산. 겹침 전수 검사와 가운데 무대()가 읽는다. + /// 데스크톱: 아래 단축 바 바로 위 가운데(예전엔 화면 맨 아래라 단축 바를 덮었다). + /// 가로 모바일: 아래 가운데 — 단축 바가 오른쪽 위라 비어 있고, 왼쪽 조이스틱 안내 원보다 오른쪽이다. + /// 세로 모바일: 잡기 버튼 위 습격 경고 글자 바로 위 가운데 — 맨 아래 가운데는 720×1280에서 조이스틱 안내 원을 덮었다. /// - private static void BlockFieldClicks(Rect pixelRect) + public static Rect GateRect(HudFrame f) { - float s = UIScale.Scale; - if (s <= 0f) return; - FieldHudInput.RegisterBlockingRect( - new Rect(pixelRect.x / s, pixelRect.y / s, pixelRect.width / s, pixelRect.height / s)); + float w = Mathf.Min(GateMaxWidth, f.ContentWidth); + float x = f.ContentLeft + (f.ContentWidth - w) * 0.5f; + float y; + if (!f.Mobile) + y = QuickAccessBarUI.ShortcutBarRectFor(f).y - UITheme.Space.S - GateHeight; + else if (!f.Portrait) + y = f.ContentBottom - GateHeight; + else + y = InsectGame.Capture.CatchButtonLayout.WarnRect(f).y - UITheme.Space.S - GateHeight; + return new Rect(x, y, w, GateHeight); } - private Rect GetEntryExitButtonRect() + /// 출입 토스트의 자리 — 가운데 무대의 고정 칸(). + public static Rect ToastRect(HudFrame f) { - float availableWidth = Screen.width - SafeArea.Left - SafeArea.Right - 40f; - float w = Mathf.Min(620f, availableWidth); - float h = 100f; - float x = (Screen.width - w) * 0.5f; - float y = UISafeLayout.Px.BottomY(h); - return new Rect(x, y, w, h); + return HudStage.Place(f, HudStageItem.PlaceToast, ToastWidth, ToastHeight); } private GUIStyle GetEntryExitButtonStyle() @@ -357,6 +385,10 @@ private GUIStyle GetEntryExitButtonStyle() private void OnSubAreaChanged(SubAreaData subArea) { + // 분리 구역(나의 섬)은 여기서 짓지 않는다 — 그 구역의 주인(IslandWorldBuilder)이 짓고 드나듦도 맡는다. + // isInSubArea가 false로 남으므로 25m 자동 이탈·퇴장 버튼·F2도 섬에서는 돌지 않는다. + if (subArea != null && subArea.detached) return; + if (subArea != null && !isInSubArea) { EnterSubArea(subArea); @@ -378,6 +410,21 @@ private void OnSubAreaChanged(SubAreaData subArea) } } + /// + /// "○○ 들어가기" 버튼 문구. OnGUI는 이벤트마다(프레임당 Layout·Repaint 최소 2회) 불리므로 매번 보간하면 + /// 근처에 서 있는 내내 문자열이 쌓인다 — 표시 이름이 바뀔 때만 다시 만든다(서브에리어가 바뀌어도 이름으로 걸린다). + /// + private string GetEnterLabel(SubAreaData sub) + { + string name = GetSubAreaDisplayName(sub); + if (enterLabel == null || !string.Equals(name, enterLabelName, System.StringComparison.Ordinal)) + { + enterLabelName = name; + enterLabel = $"{name} 들어가기"; + } + return enterLabel; + } + private static string GetSubAreaDisplayName(SubAreaData sub) { if (sub == null) return "서브지역"; @@ -477,6 +524,7 @@ private void EnterSubArea(SubAreaData subArea) // 환경 전체에 SubArea layer 일괄 설정 — CameraFollower 차폐 제외용. // 8개 환경 빌드에 개별 추가하지 않고 subAreaRoot 자식 트리 전체 재귀 처리. SetLayerRecursively(subAreaRoot, GetSubAreaEnvLayer()); + Physics.SyncTransforms(); // 플레이어를 서브에리어 입구로 텔레포트 — 벽 겹침 회피. // BuildCave의 무작위 미로(7×7), BuildTemple의 z=-8 pillar 등으로 옛 고정 좌표는 @@ -484,6 +532,7 @@ private void EnterSubArea(SubAreaData subArea) if (player != null) { player.transform.position = FindSafeSpawnPosition(SubAreaOrigin); + player.GetComponent()?.StopNavigation(); // 좌표 점프 후 카메라 baseline 리셋 — 옛 메인 월드 좌표에서 SubArea(2000m)로 // 슬슬 들어오는 시각적 끊김 차단. SetSubAreaMode가 내부적으로 ResetBaseline 호출. if (cameraFollower != null) cameraFollower.SetSubAreaMode(true); @@ -507,6 +556,10 @@ private static Vector3 FindSafeSpawnPosition(Vector3 origin) Mathf.Sin(angle) * radii[r], 0.5f, -8f + Mathf.Cos(angle) * radii[r]); + // The smallest interior has walls at +/-9. Never select an empty + // point on the far side of a wall just because no collider overlaps it. + if (Mathf.Abs(candidate.x - origin.x) > 8f + || Mathf.Abs(candidate.z - origin.z) > 8f) continue; if (IsSpawnPositionClear(candidate)) return candidate; } } @@ -567,7 +620,6 @@ private void ExitSubArea() { isInSubArea = false; string exitedId = currentSubArea != null ? currentSubArea.subAreaId : null; - SubAreaData exited = currentSubArea; currentSubArea = null; // 서브에리어 파괴 @@ -575,27 +627,21 @@ private void ExitSubArea() // 메인 월드 복원 ShowMainWorld(); + Physics.SyncTransforms(); - // 플레이어를 원래 위치로. 단 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; - } + // Entry is explicit and RegionManager already applies a cooldown. + // Keep the approach location instead of pushing across a region edge. // 진입(FindSafeSpawnPosition)과 대칭으로 지면 스냅 + 충돌 빈자리 보정. ShowMainWorld로 // 메인 콜라이더(산 바위 Scenery_MountainRock/경사 등)가 복원된 뒤라, dest가 그 안에 박히면 // PlayerMovement.IsBlockedPosition이 모든 이동을 막아 영구 갇힘 → 산에서 못 움직이던 원인. dest = FindClearGroundPositionNear(dest); player.transform.position = dest; + player.GetComponent()?.StopNavigation(); // 좌표 점프 후 카메라 baseline 리셋 + 일반 모드로 offset 복귀. // SetSubAreaMode(false)가 내부적으로 ResetBaseline 호출하여 한 번에 처리. if (cameraFollower != null) cameraFollower.SetSubAreaMode(false); @@ -659,11 +705,17 @@ private void ShowMainWorld() // ========== 동굴 ========== private void BuildCave(SubAreaData sub) { + // 같은 cave/underground라도 방마다 다르다(SubAreaWorldBuilder.Themes.cs) — 개미귀신 구덩이와 + // 빈칸은 동굴이 아니라 따로 짓는다. 방 크기는 이 빌더와 같은 14로 둔다(story_lint 21). + if (sub.subAreaId == "dunes_pit") { BuildAntlionPit(sub); return; } + if (sub.subAreaId == "nameless_core") { BuildBlankCore(sub); return; } + CaveTheme theme = CaveThemeFor(sub.subAreaId); + // 어두운 머티리얼이 동굴이 새까매 보이던 핵심 원인 — 바닥/벽 밝기를 올려 환경광이 반사되게 한다. - Material wallMat = Mat(new Color(0.4f, 0.35f, 0.29f)); - Material floorMat = Mat(new Color(0.32f, 0.28f, 0.22f)); + Material wallMat = Mat(theme.wall); + Material floorMat = Mat(theme.floor); Material ceilingMat = Mat(new Color(0.1f, 0.08f, 0.06f)); - Material torchMat = Mat(new Color(1f, 0.7f, 0.2f)); + Material torchMat = GlowMat(theme.flame, theme.flame * 0.9f); Material torchHandleMat = Mat(new Color(0.3f, 0.2f, 0.1f)); // 바닥 @@ -708,7 +760,7 @@ private void BuildCave(SubAreaData sub) // 횃불 (통로에 배치) int torchCount = 0; - for (int x = 0; x < 7 && torchCount < 12; x++) + for (int x = 0; x < 7 && torchCount < 12 && theme.torches; x++) { for (int z = 0; z < 7 && torchCount < 12; z++) { @@ -722,11 +774,13 @@ private void BuildCave(SubAreaData sub) } // 포인트 라이트 (횃불에서) — 범위/세기 상향 + 중앙 상단 따뜻한 채움광으로 전체를 밝힌다. - CreatePointLight(new Vector3(0f, 3f, 0f), new Color(1f, 0.7f, 0.3f), 16f, 2.4f); - CreatePointLight(new Vector3(10f, 3f, 5f), new Color(1f, 0.7f, 0.3f), 13f, 1.8f); - CreatePointLight(new Vector3(-8f, 3f, -4f), new Color(1f, 0.7f, 0.3f), 13f, 1.8f); + CreatePointLight(new Vector3(0f, 3f, 0f), theme.light, 16f, 2.4f); + CreatePointLight(new Vector3(10f, 3f, 5f), theme.light, 13f, 1.8f); + CreatePointLight(new Vector3(-8f, 3f, -4f), theme.light, 13f, 1.8f); CreatePointLight(new Vector3(0f, 9f, 0f), new Color(0.95f, 0.88f, 0.72f), 26f, 1.3f); + DecorateCave(theme, maze, cellSize, offsetX, offsetZ); + // 외곽 벽 — 바닥(30 = ±15) 안쪽에 배치하여 벽-바닥 사이 빠짐 방지 CreateBoundaryWalls(wallMat, 14f, 5f); } @@ -734,10 +788,12 @@ private void BuildCave(SubAreaData sub) // ========== 깊은 숲 ========== 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)); + // 우듬지의 겹친 가지 속은 1막 깊은 숲보다 밝고 살아 있다 — 같은 미로에 색과 소품만 바꾼다 + bool bough = sub.subAreaId == "canopy_bough"; + Material groundMat = Mat(bough ? new Color(0.22f, 0.34f, 0.16f) : new Color(0.1f, 0.18f, 0.06f)); + Material trunkMat = Mat(bough ? new Color(0.36f, 0.26f, 0.16f) : new Color(0.2f, 0.12f, 0.06f)); + Material leafMat = Mat(bough ? new Color(0.22f, 0.46f, 0.20f) : new Color(0.05f, 0.25f, 0.03f)); + Material pathMat = Mat(bough ? new Color(0.40f, 0.32f, 0.20f) : new Color(0.3f, 0.25f, 0.15f)); CreateFloor(groundMat, 35f); @@ -782,7 +838,7 @@ private void BuildDeepForest(SubAreaData sub) // 안개 구체 Material fogMat = Mat(new Color(0.15f, 0.25f, 0.1f, 0.15f)); - SetTransparent(fogMat); + SceneryMaterials.MakeFade(fogMat); for (int i = 0; i < 6; i++) { GameObject fog = Prim(PrimitiveType.Sphere, $"Fog_{i}"); @@ -793,12 +849,19 @@ private void BuildDeepForest(SubAreaData sub) } CreatePointLight(Vector3.up * 8f, new Color(0.3f, 0.6f, 0.2f), 20f, 0.6f); + if (bough) + { + DecorateBough(); + CreatePointLight(new Vector3(0f, 6f, 0f), new Color(1f, 0.95f, 0.75f), 22f, 1.0f); // 잎 틈 햇살 + } CreateBoundaryWalls(trunkMat, 16f, 7f); } // ========== 수중 ========== private void BuildUnderwater(SubAreaData sub) { + // 초원의 숨겨진 웅덩이는 물속이 아니라 풀밭의 맑은 웅덩이다 — 분홍 산호가 서면 안 된다 + if (sub.subAreaId == "meadow_pond") { BuildHiddenPuddle(sub); return; } // 바닥을 조금 밝게 — 옛 (0.1,0.15,0.25)는 파란 fog와 겹쳐 캐릭터 발밑이 새까매 대비 상실. Material floorMat = Mat(new Color(0.16f, 0.22f, 0.33f)); Material coralMat = Mat(new Color(0.8f, 0.3f, 0.4f)); @@ -806,7 +869,7 @@ private void BuildUnderwater(SubAreaData sub) // 물 표면 alpha 0.3 → 0.16: 옛은 카메라-캐릭터 시선을 가로지르는 반투명 파란 막이 캐릭터를 덮어 // "연못/수중에서 캐릭터가 안 보임"의 직접 원인이었음(사용자 보고). 위치도 함께 올려 이중 방어. Material waterMat = Mat(new Color(0.1f, 0.25f, 0.5f, 0.16f)); - SetTransparent(waterMat); + SceneryMaterials.MakeFade(waterMat); CreateFloor(floorMat, 30f); @@ -851,7 +914,7 @@ private void BuildUnderwater(SubAreaData sub) // 기포 Material bubbleMat = Mat(new Color(0.6f, 0.8f, 1f, 0.3f)); - SetTransparent(bubbleMat); + SceneryMaterials.MakeFade(bubbleMat); for (int i = 0; i < 20; i++) { GameObject bubble = Prim(PrimitiveType.Sphere, $"Bubble_{i}"); @@ -872,14 +935,16 @@ private void BuildUnderwater(SubAreaData sub) // ========== 안개 늪 ========== private void BuildFogSwamp(SubAreaData sub) { - Material mudMat = Mat(new Color(0.18f, 0.2f, 0.1f)); + // 텅 빈 들의 침묵의 자리는 늪이 아니다 — 빛바랜 풀밭. 물웅덩이·고목 대신 풍경 장대를 둔다 + bool silence = sub.subAreaId == "hollow_silence"; + Material mudMat = Mat(silence ? new Color(0.52f, 0.50f, 0.42f) : new Color(0.18f, 0.2f, 0.1f)); Material waterMat = Mat(new Color(0.12f, 0.2f, 0.15f, 0.5f)); - SetTransparent(waterMat); + SceneryMaterials.MakeFade(waterMat); CreateFloor(mudMat, 30f); // 물웅덩이 - for (int i = 0; i < 8; i++) + for (int i = 0; i < (silence ? 0 : 8); i++) { GameObject pool = Prim(PrimitiveType.Cylinder, $"Pool_{i}"); pool.transform.localPosition = new Vector3(Random.Range(-10f, 10f), 0.02f, Random.Range(-10f, 10f)); @@ -890,8 +955,8 @@ private void BuildFogSwamp(SubAreaData sub) } // 안개 구체 (밀집) - Material fogMat = Mat(new Color(0.5f, 0.5f, 0.45f, 0.12f)); - SetTransparent(fogMat); + Material fogMat = Mat(silence ? new Color(0.78f, 0.78f, 0.76f, 0.1f) : new Color(0.5f, 0.5f, 0.45f, 0.12f)); + SceneryMaterials.MakeFade(fogMat); for (int i = 0; i < 15; i++) { GameObject fog = Prim(PrimitiveType.Sphere, $"Fog_{i}"); @@ -903,7 +968,8 @@ private void BuildFogSwamp(SubAreaData sub) // 고목 Material deadWood = Mat(new Color(0.25f, 0.2f, 0.15f)); - for (int i = 0; i < 6; i++) + if (silence) DecorateSilence(); + for (int i = 0; i < (silence ? 0 : 6); i++) { GameObject tree = Prim(PrimitiveType.Cylinder, $"DeadTree_{i}"); Vector3 pos = new Vector3(Random.Range(-10f, 10f), 0f, Random.Range(-10f, 10f)); @@ -923,22 +989,16 @@ private void BuildFogSwamp(SubAreaData sub) // ========== 산 정상 ========== 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)); + // 우듬지의 "가장 높은 가지"는 나무 꼭대기다 — 바위산이 아니다. 방 크기는 같은 11. + if (sub.subAreaId == "canopy_crown") { BuildCanopyCrown(sub); return; } + bool frost = sub.subAreaId == "frostline_ridge"; + Material rockMat = Mat(frost ? new Color(0.72f, 0.77f, 0.84f) : new Color(0.5f, 0.48f, 0.44f)); 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); - } + // 눈 — 옛날엔 Plane(정사각형)이라 종이를 깐 듯 각졌다. 둔덕·돌탑·얼음 가시는 테마 파일에서. + DecoratePeak(sub); + if (frost) rockMat = Mat(new Color(0.55f, 0.61f, 0.69f)); // 능선 바위는 바닥보다 짙게 // 바위 for (int i = 0; i < 12; i++) @@ -968,7 +1028,7 @@ private void BuildLedgerVault(SubAreaData sub) Material shelfMat = Mat(new Color(0.38f, 0.28f, 0.17f)); Material crateMat = Mat(new Color(0.52f, 0.42f, 0.26f)); Material glassMat = Mat(new Color(0.62f, 0.74f, 0.72f, 0.35f)); - SetTransparent(glassMat); // 알파 재질은 Mat만으론 불투명하게 나온다 + SceneryMaterials.MakeFade(glassMat); // 알파 재질은 Mat만으론 불투명하게 나온다 Material lampMat = Mat(new Color(0.95f, 0.80f, 0.45f)); CreateFloor(floorMat, 30f); @@ -1027,7 +1087,7 @@ private void BuildIceArchive(SubAreaData sub) { Material floorMat = Mat(new Color(0.62f, 0.72f, 0.80f)); Material iceMat = Mat(new Color(0.70f, 0.84f, 0.92f, 0.55f)); - SetTransparent(iceMat); + SceneryMaterials.MakeFade(iceMat); Material deepMat = Mat(new Color(0.42f, 0.58f, 0.72f)); Material paperMat = Mat(new Color(0.86f, 0.82f, 0.68f)); Material glowMat = Mat(new Color(0.55f, 0.85f, 1f)); @@ -1305,7 +1365,7 @@ 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); + SceneryMaterials.MakeFade(glassMat); Material soilMat = Mat(new Color(0.3f, 0.22f, 0.12f)); CreateFloor(soilMat, 20f); @@ -1339,7 +1399,7 @@ private void BuildGreenhouse(SubAreaData sub) private void BuildReeds(SubAreaData sub) { Material waterMat = Mat(new Color(0.15f, 0.25f, 0.35f, 0.5f)); - SetTransparent(waterMat); + SceneryMaterials.MakeFade(waterMat); Material reedMat = Mat(new Color(0.4f, 0.5f, 0.2f)); Material mudMat = Mat(new Color(0.25f, 0.22f, 0.15f)); @@ -1550,29 +1610,22 @@ private void Apply(GameObject obj, Material mat) private void NoCollider(GameObject obj) { Collider c = obj.GetComponent(); - if (c != null) Destroy(c); + if (c != null) + { + c.enabled = false; // Spawn checks run before deferred Destroy completes. + 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; - runtimeMaterials.Add(mat); // 빌드 파기 때 함께 정리 — 안 하면 진입할 때마다 41개씩 샌다 + Material mat = SceneryMaterials.Create(color); + runtimeMaterials.Add(mat); // 빌드 파기 때 함께 정리 — 안 하면 진입할 때마다 수십 개씩 샌다 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/TrainerLevelGap.cs b/Assets/Scripts/Core/TrainerLevelGap.cs new file mode 100644 index 0000000..021e225 --- /dev/null +++ b/Assets/Scripts/Core/TrainerLevelGap.cs @@ -0,0 +1,72 @@ +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 캐릭터(트레이너) 레벨과 곤충 레벨의 차이가 포획·EXP에 주는 효과의 단일 출처. + /// 계수는 , 실측 근거는 rules/balance.md. + /// + /// 포획은 미니게임(CaptureChanceCalculator)과 전투 승리 후(BattleCaptureChanceCalculator) + /// 두 경로가 같은 배율을 곱하고, EXP는 포획·전투·NPC 대결·레이드가 전부 + /// InsectRewardCalculator.GetExpReward(data, insectLevel, trainerLevel)로 이 배율을 받는다. + /// 경로마다 따로 적으면 한쪽만 고쳐져 어긋난다. + /// + public static class TrainerLevelGap + { + /// 곤충이 캐릭터보다 몇 레벨 높은가(음수면 낮다). 1 미만 레벨은 1로 본다. + public static int Gap(int trainerLevel, int insectLevel) + { + return Mathf.Max(1, insectLevel) - Mathf.Max(1, trainerLevel); + } + + /// + /// 유예()를 넘긴 레벨 차 — + /// 포획 선택 화면의 경고도 이 값으로 판단한다. + /// + public static bool IsCaptureRestricted(int trainerLevel, int insectLevel) + { + return Gap(trainerLevel, insectLevel) > GameConstants.TrainerLevel.CaptureGraceLevels; + } + + /// + /// 포획 확률에 마지막으로 곱하는 배율(유예 안이면 1). 아이템·의상·미니게임 보너스와 + /// 등급별 최저 보장까지 다 더한 뒤에 곱한다 — 보너스로 레벨 격차를 메우지 못하게. + /// + public static float CaptureMultiplier(int trainerLevel, int insectLevel) + { + int over = Gap(trainerLevel, insectLevel) - GameConstants.TrainerLevel.CaptureGraceLevels; + if (over <= 0) return 1f; + return Mathf.Max( + GameConstants.TrainerLevel.MinCaptureMultiplier, + 1f - over * GameConstants.TrainerLevel.CaptureDropPerLevel); + } + + /// 곤충 레벨 자체의 EXP 배율 — 1 + (Lv − 1) × ExpPerInsectLevel. + public static float ExpLevelFactor(int insectLevel) + { + return 1f + (Mathf.Max(1, insectLevel) - 1) * GameConstants.TrainerLevel.ExpPerInsectLevel; + } + + /// + /// 캐릭터와의 레벨 차 EXP 배율 — 높은 곤충은 +10%/Lv(최대 ×2), 낮은 곤충은 −10%/Lv(최저 ×0.2). + /// + public static float ExpGapFactor(int trainerLevel, int insectLevel) + { + int gap = Gap(trainerLevel, insectLevel); + if (gap >= 0) + { + return 1f + Mathf.Min(gap, GameConstants.TrainerLevel.ExpHigherCapLevels) + * GameConstants.TrainerLevel.ExpHigherBonusPerLevel; + } + return Mathf.Max( + GameConstants.TrainerLevel.ExpLowerMinFactor, + 1f + gap * GameConstants.TrainerLevel.ExpLowerPenaltyPerLevel); + } + + /// EXP 최종 레벨 배율 = 곤충 레벨 배율 × 레벨 차 배율. + public static float ExpMultiplier(int trainerLevel, int insectLevel) + { + return ExpLevelFactor(insectLevel) * ExpGapFactor(trainerLevel, insectLevel); + } + } +} diff --git a/Assets/Scripts/Core/TrainerLevelGap.cs.meta b/Assets/Scripts/Core/TrainerLevelGap.cs.meta new file mode 100644 index 0000000..8642a8d --- /dev/null +++ b/Assets/Scripts/Core/TrainerLevelGap.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 776429d98a966ec4f86cfb17aed80998 \ No newline at end of file diff --git a/Assets/Scripts/Core/TrainingManager.cs b/Assets/Scripts/Core/TrainingManager.cs index 0047593..f98ff01 100644 --- a/Assets/Scripts/Core/TrainingManager.cs +++ b/Assets/Scripts/Core/TrainingManager.cs @@ -184,7 +184,8 @@ public int GetTrainingCost(TrainingMethod method, PlayerInsectData insect, strin } /// - /// 이 기술을 익히는 데 필요한 훈련 횟수. 위력이 셀수록 오래 걸린다. + /// 이 기술을 익히는 데 필요한 훈련 횟수. 기술의 가치()가 + /// 클수록 오래 걸린다 — 위력 칸이 1인 상태기도 효과 크기로 센다. /// /// 기술 디스크는 예외로 1회다 — 디스크는 사서 얻는 물건이라 "즉시 습득"이 /// 그 값어치이고, 누적 훈련과 대비돼야 둘 다 존재 이유가 생긴다. @@ -193,7 +194,7 @@ public int GetRequiredSessions(TrainingMethod method, InsectSkill skill) { if (skill == null) return 1; if (method != null && method.methodId == DiscMethodId) return 1; - return Mathf.Clamp(1 + skill.power / 12, 1, 5); + return TrainingPricing.RequiredSessions(skill); } public int GetRequiredSessions(TrainingMethod method, string skillId) @@ -287,6 +288,101 @@ public bool TrainSkill(TrainingMethod method, PlayerInsectData insect, string sk return true; } + // ── 성장 훈련: 레벨 · 능력치(개체값) ───────────────────────────────────── + // + // 기술만 가르치던 훈련소에 레벨업과 개체값 훈련을 더한다. 등급(S~D)은 개체값 합에서 파생되므로 + // (PlayerInsectData.Grade) 능력치를 올리면 등급이 저절로 오른다 — 따로 올리는 경로를 두지 않는다. + + /// + /// 레벨업 1회 비용. 보유 곤충 창과 같은 정본(GetCandyCostForLevel)을 읽는다 — + /// 두 곳 값이 갈리면 싼 쪽만 쓰인다. 만렙이거나 배선이 없으면 0. + /// + public int GetLevelUpCost(PlayerInsectData insect) + { + if (insect == null || collection == null || IsMaxLevel(insect)) return 0; + return collection.GetCandyCostForLevel(insect.insectId, insect.level); + } + + public bool IsMaxLevel(PlayerInsectData insect) + { + if (insect == null || collection == null) return true; + return insect.level >= collection.GetMaxLevel(insect.insectId); + } + + public bool CanTrainLevel(PlayerInsectData insect) + { + if (insect == null || candyInventory == null || IsMaxLevel(insect)) return false; + return candyInventory.Candies >= GetLevelUpCost(insect); + } + + /// + /// 레벨업. 결제·레벨·기술 자동 습득·세이브는 TryLevelUpWithCandy가 전부 한다(보유 곤충 창과 같은 함수). + /// 여기는 그 뒤의 통지만 더한다 — 레벨업 퀘스트와 훈련 퀘스트 둘 다 이 행동으로 진행된다. + /// + public bool TrainLevel(PlayerInsectData insect) + { + if (!CanTrainLevel(insect)) return false; + if (!collection.TryLevelUpWithCandy(insect)) return false; + + TrainingCompleted?.Invoke(); + TutorialQuestManager.Instance?.NotifyLevelUp(); + TutorialQuestManager.Instance?.NotifyTraining(); + return true; + } + + /// + /// 능력치 +1 비용. 이미 15면 0. 등급 배율은 종 데이터에서 읽고, 데이터를 못 찾으면 일반으로 본다 + /// (테스트처럼 컬렉션이 없는 경우 포함). + /// + public int GetStatTrainingCost(PlayerInsectData insect, GrowthStat stat) + { + if (insect == null) return 0; + return TrainingPricing.StatCost(insect.GetIv(stat), RarityOf(insect)); + } + + public bool CanTrainStat(PlayerInsectData insect, GrowthStat stat) + { + if (insect == null || candyInventory == null) return false; + if (insect.GetIv(stat) >= PlayerInsectData.MaxIV) return false; + return candyInventory.Candies >= GetStatTrainingCost(insect, stat); + } + + /// + /// 능력치 훈련 1회 — 캔디를 내고 개체값을 1 올린다. 누적 훈련이 아니라 한 번에 +1이다: + /// 비용이 이미 가파르게 오르므로 횟수까지 나누면 같은 값을 두 번 받는 셈이 된다. + /// + public bool TrainStat(PlayerInsectData insect, GrowthStat stat) + { + LastStatGradeBefore = insect != null ? insect.Grade : IVGrade.D; + if (!CanTrainStat(insect, stat)) return false; + + int cost = GetStatTrainingCost(insect, stat); + if (!candyInventory.SpendCandy(cost)) return false; + if (!insect.RaiseIv(stat)) + { + candyInventory.AddCandy(cost); // CanTrainStat 뒤라 도달하지 않지만, 캔디만 사라지는 길은 막는다 + return false; + } + + if (collection != null) + { + collection.NotifyInsectChanged(insect); + collection.ForceSave(); + } + TrainingCompleted?.Invoke(); + TutorialQuestManager.Instance?.NotifyTraining(); + return true; + } + + /// 직전 전의 등급 — UI가 "등급 상승!"을 가르는 데 쓴다. + public IVGrade LastStatGradeBefore { get; private set; } + + private InsectRarity RarityOf(PlayerInsectData insect) + { + InsectData data = collection != null && insect != null ? collection.GetInsectData(insect.insectId) : null; + return data != null ? data.rarity : InsectRarity.Common; + } + /// 직전 이 실제 습득까지 갔는가. UI 문구가 쓴다. public bool LastTrainingLearned { get; private set; } diff --git a/Assets/Scripts/Core/TrainingPricing.cs b/Assets/Scripts/Core/TrainingPricing.cs new file mode 100644 index 0000000..adc5c3f --- /dev/null +++ b/Assets/Scripts/Core/TrainingPricing.cs @@ -0,0 +1,106 @@ +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Core +{ + /// 훈련소 「성장 훈련」이 올리는 능력치. 개체값(0~15) 하나에 대응한다. + public enum GrowthStat { Hp, Attack, Defense } + + /// + /// 훈련 비용의 단일 출처 — 기술 습득(회당 캔디·필요 횟수)과 능력치 훈련(개체값 +1). + /// 계수는 , 실측 근거는 rules/balance.md 「훈련 기준점」. + /// + /// 예전엔 기술 가격이 power에서만 나왔다. 버프·약화·회복·기절·방어 기술은 위력 칸이 1이라 + /// 공식이 "가장 약한 기술"로 읽었고, 광폭화(공격 +60%, Lv26)가 26캔디 1회로 끝날 때 파멸의 독침은 + /// 216캔디였다. 종족기·전용기는 아예 손으로 적은 상수(4·8·12·14·22·35·50)였다. 지금은 효과를 위력 + /// 눈금으로 환산한 하나에서 모든 가격이 나온다 — 피해기는 옛 값과 같다. + /// + public static class TrainingPricing + { + /// + /// 기술의 가치를 위력 눈금으로. 피해기는 기대 피해(위력 × 명중), 상태기는 효과 크기 × 지속을 + /// 기준 위력으로 환산한다(). + /// + public static float SkillValue(InsectSkill skill) + { + if (skill == null) return 0f; + float accuracy = Mathf.Clamp01(skill.accuracy); + int turns = Mathf.Max(1, skill.effectDurationTurns); + + switch (skill.effectType) + { + case SkillEffectType.BuffAttack: + case SkillEffectType.DebuffAttack: + case SkillEffectType.DefenseBuff: + return Mathf.Abs(skill.effectValue) * turns * GameConstants.Training.StatusRefPower; + case SkillEffectType.Heal: + return Mathf.Clamp01(skill.effectValue) * GameConstants.Training.HealRefPower; + case SkillEffectType.PoisonDot: + // 턴당 피해(power) × 지속 — 방어를 무시하지만 한 번에 들어가지 않는다. 둘이 상쇄한다고 본다. + return skill.power * turns * accuracy; + case SkillEffectType.Stun: + return GameConstants.Training.StunRefPower * accuracy; + default: + return skill.power * accuracy; + } + } + + /// + /// 누적 훈련 필요 횟수 — 1 + 가치 / 12, 1~5. 피해기(명중 1)는 옛 1 + power / 12와 같다. + /// 기술 디스크의 "1회"는 방식의 규칙이라 호출부(TrainingManager.GetRequiredSessions)가 가른다. + /// + public static int RequiredSessions(InsectSkill skill) + { + if (skill == null) return 1; + return Mathf.Clamp(1 + ValuePoints(skill) / GameConstants.Training.SessionValueStep, 1, GameConstants.Training.MaxSessions); + } + + /// + /// 훈련 1회분 캔디 — 가치 / 2 + 해금 레벨, 최소 5. 해금 레벨은 범용기면 requiredLevel, + /// 종족기면 learnset의 learnLevel이다(같은 가치라도 늦게 열리는 기술이 비싸다). + /// 부트스트랩이 기술을 만들 때 이 값을 InsectSkill.trainingCost에 굽고, 결제는 그 값을 읽는다. + /// + public static int SessionCost(InsectSkill skill, int unlockLevel) + { + return Mathf.Max(GameConstants.Training.MinSessionCost, ValuePoints(skill) / 2 + Mathf.Max(1, unlockLevel)); + } + + /// + /// 가치의 정수 눈금. 작은 여유(0.001)를 더해 내린다 — 0.35f × 2 × 30이 부동소수로 20.9999…가 되어 + /// 21이 아니라 20으로 떨어지는 식의 한 칸 오차를 막는다. 피해기(정수 위력 × 명중 1)는 그대로다. + /// + private static int ValuePoints(InsectSkill skill) + { + return Mathf.FloorToInt(SkillValue(skill) + 0.001f); + } + + /// 습득까지 드는 총 캔디(회당 × 횟수). 표시·밸런스 검산용. + public static int TotalSkillCost(InsectSkill skill, int unlockLevel) + { + return SessionCost(skill, unlockLevel) * RequiredSessions(skill); + } + + /// + /// 능력치(개체값) → +1 비용. 이미 최대(15)면 0. + /// 20 × 1.2^현재값 × 등급 배율 — 올릴수록 가파르다(0→1은 20, 14→15는 257, 전설은 ×2.8). + /// + public static int StatCost(int currentIv, InsectRarity rarity) + { + if (currentIv >= PlayerInsectData.MaxIV) return 0; + float cost = GameConstants.Training.StatBaseCost + * Mathf.Pow(GameConstants.Training.StatCostGrowth, Mathf.Max(0, currentIv)) + * InsectRewardCalculator.GetRarityMultiplier(rarity); + return Mathf.Max(1, Mathf.RoundToInt(cost)); + } + + /// 한 능력치를 에서 까지 올리는 총 캔디. + public static int StatCostRange(int fromIv, int toIv, InsectRarity rarity) + { + int total = 0; + int end = Mathf.Min(toIv, PlayerInsectData.MaxIV); + for (int v = Mathf.Max(0, fromIv); v < end; v++) + total += StatCost(v, rarity); + return total; + } + } +} diff --git a/Assets/Scripts/Core/TrainingPricing.cs.meta b/Assets/Scripts/Core/TrainingPricing.cs.meta new file mode 100644 index 0000000..790e886 --- /dev/null +++ b/Assets/Scripts/Core/TrainingPricing.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7973b264cf9c09d4b8aab2f410b5bbcc \ No newline at end of file diff --git a/Assets/Scripts/Core/TutorialQuestData.cs b/Assets/Scripts/Core/TutorialQuestData.cs index c99ff3e..4865272 100644 --- a/Assets/Scripts/Core/TutorialQuestData.cs +++ b/Assets/Scripts/Core/TutorialQuestData.cs @@ -33,6 +33,25 @@ public enum QuestType // 명부회 오염 거점 정화 — RegionBlightManager.RegionCleansed가 알린다. // 이벤트 기반이라 구독 등록이 급소다(q_team 전례). CleanseBlight, + // ── 나의 섬 ── 전부 IslandManager가 행동이 성립한 지점에서 Notify___로 직접 알린다(이벤트 구독이 아니다). + // 내 섬에 들어감. + VisitIsland, + // 보유 곤충을 섬에 풀어놓음. + ReleaseOnIsland, + // 보관함의 물건을 섬에 놓음(옮기기·돌리기는 세지 않는다). + PlaceIslandObject, + // 쌓인 수확물을 받음. + HarvestIsland, + // 섬 상점에서 물건을 삼. + IslandPurchase, + // 다른 사람의 섬을 구경함. + VisitFriendIsland, + // 조건부 포획 — 몸길이·속성·이로치처럼 잡은 개체의 성질이 TutorialQuest의 조건 필드와 맞아야 센다. + // 판정은 QuestTraitRules(순수). 포획 지점이 CaptureFacts를 실어 NotifyCapture(CaptureFacts)로 알린다. + CaptureTrait, + // 조건부 전투 승리 — 상대 등급·속성·레벨 차·내 행동 수·남은 HP가 조건과 맞아야 센다. + // 전투 컨트롤러가 승리 지점에서 BattleFacts를 실어 NotifyBattleFeat로 알린다. resetOnLoss면 연승. + BattleFeat, } // 퀘스트 분류 — Story(선형 메인 체인) vs Side(다중 활성, 일부 반복 상승). @@ -58,6 +77,12 @@ public class TutorialQuest public string rewardInsectId; public string rewardInsectDisplayName; public int rewardInsectLevel = 1; + // 코인 보상 — 섬 퀘스트가 쓴다(가구 값이 코인이다). 다이아는 보상으로 줄 수 없다: + // 서버 규칙이 클라이언트의 다이아 증가를 거부해 세이브 업로드 전체가 막힌다. + public int rewardCoins = 0; + // 섬 물건 보상(IslandCatalog의 id) — 섬 보관함으로 들어간다. + public string rewardIslandObjectId; + public int rewardIslandObjectCount = 0; public string prerequisiteQuestId; // 분류: 기본 Story(기존 선형 체인 그대로). Side는 다중 활성 + 반복 상승 지원. public QuestCategory category = QuestCategory.Story; @@ -68,6 +93,63 @@ public class TutorialQuest // QuestType.CaptureRarity 전용: 이 등급을 포획해야 진행된다. 다른 타입에서는 무시. // 기본값 Common은 enum의 0이라, 이 필드를 안 쓰는 기존 퀘스트에 영향이 없다. public InsectGame.Data.InsectRarity requiredRarity = InsectGame.Data.InsectRarity.Common; + // Side 전용 "지역 의뢰": 채우면 ①그 리전이 열리기 전에는 활성이 아니고 ②그 리전 안에서 + // 한 행동만 센다(서브에리어 포함 — 진입 중에도 RegionManager.CurrentRegion은 부모 리전에 머문다). + // 비우면(기본 null) 기존 서브 퀘스트 그대로 어디서든 센다. 마을 이야기(Story.json의 town + // 챕터)의 매듭 비트가 requiredQuestId로 이 퀘스트의 완료를 관찰한다 — 퀘스트는 스토리를 모른다. + public string requiredRegionId; + + // ── 조건부 퀘스트(QuestType.CaptureTrait / BattleFeat) 전용 조건 — Side 전용 ── + // 0·None·false·Common이 "조건 없음"이다(기본값이라 이 필드를 안 쓰는 기존 퀘스트는 영향이 없다). + // 여러 개를 채우면 **전부** 맞아야 센다 — 판정은 QuestTraitRules가 한다. + // 몸길이(mm): 개체의 몸길이가 이 범위 안일 때만. 소수 첫째 자리로 맞춰 본다(화면에 보이는 값과 같게). + public float minSizeMm = 0f; + public float maxSizeMm = 0f; + // 종 기준 대비 크기 배율(0.75~1.25): 같은 종 평균보다 얼마나 큰가·작은가. 종마다 평균이 달라 몸길이와 따로 둔다. + public float minSizeRatio = 0f; + public float maxSizeRatio = 0f; + // 잡은(CaptureTrait) 또는 맞선(BattleFeat) 곤충의 속성 — 주속성이나 부속성 어느 한쪽이면 된다. + public InsectGame.Data.InsectElement requiredElement = InsectGame.Data.InsectElement.None; + // CaptureTrait: 이로치(색다른 곤충)만. + public bool requireShiny = false; + // 잡은·맞선 곤충의 등급이 이 이상일 때만. 기본 Common은 모든 등급이라 "조건 없음"이다. + public InsectGame.Data.InsectRarity minRarity = InsectGame.Data.InsectRarity.Common; + // BattleFeat: (상대 레벨 − 내 곤충 레벨)이 이 이상. 0이면 조건 없음. + public int minLevelEdge = 0; + // BattleFeat: 이긴 전투에서 내가 행동한 횟수가 이 이하. 0이면 조건 없음. + public int maxTurns = 0; + // BattleFeat: 전투가 끝났을 때 내 곤충의 남은 HP가 이 퍼센트 이상. 0이면 조건 없음. + public int minHpPercent = 0; + // BattleFeat: 지거나 도망치면 진행을 0으로 되돌린다(연승). 조건이 안 맞는 승리는 건드리지 않는다. + public bool resetOnLoss = false; + } + + /// + /// 지역 의뢰()의 순수 판정. + /// MonoBehaviour와 떼어 놓아 테스트로 고정한다(와 같은 성격). + /// + public static class QuestRegionGate + { + /// + /// 이번 행동이 이 퀘스트의 진행으로 세어지는가. 리전 한정이 없으면 늘 true. + /// 리전 밖(리전 사이 빈 땅 — CurrentRegion이 null)에서 한 행동은 세지 않는다. + /// + public static bool Counts(string requiredRegionId, string currentRegionId) + { + if (string.IsNullOrEmpty(requiredRegionId)) return true; + return requiredRegionId == currentRegionId; + } + + /// + /// 리전 게이트가 열렸는가. 판정 자체(RegionManager.IsRegionAccessible)는 호출부가 넘긴다 — + /// 해금 규칙의 단일 출처를 여기 복제하지 않는다. 판정기가 없으면 닫힌 쪽으로 둔다 + /// (잠긴 리전의 의뢰가 먼저 뜨는 것보다 조금 늦게 뜨는 편이 낫다). + /// + public static bool IsOpen(string requiredRegionId, System.Func isRegionAccessible) + { + if (string.IsNullOrEmpty(requiredRegionId)) return true; + return isRegionAccessible != null && isRegionAccessible(requiredRegionId); + } } /// @@ -150,5 +232,72 @@ public static List CollectBackfillTargets( } return targets; } + + /// + /// 미리 세는 목표인가. 포획·전투·레이드·레벨업처럼 "몇 번 했는가"를 세는 것만이다. + /// + /// 넣으면 안 되는 것이 있다: + /// + /// CleanseBlight — 「하나 무너뜨리기」와 「하나 더」가 이어져 있다. 미리 세면 첫 정화 하나가 + /// 둘을 한꺼번에 깬다. + /// VisitRegion·VisitSubArea·Movement — 그 퀘스트가 가리키는 순간의 행동이어야 한다 + /// (초원에 들어간 것이 "연못에 가 보세요"를 깨면 안 된다). + /// TalkToElder·DefeatGuardian — 스토리 비트와 맞물려 따로 정합한다. + /// + /// + public static bool IsBankable(QuestType type) + { + switch (type) + { + case QuestType.Capture: + case QuestType.CaptureRare: + case QuestType.CaptureRarity: + case QuestType.Battle: + case QuestType.RaidBattle: + case QuestType.LevelUp: + return true; + default: + return false; + } + } + + /// + /// 이번 행동이 이 퀘스트의 진행으로 세어지는가. 포획은 을 + /// Capture로 넘기고 등급으로 가른다(NotifyCapture의 활성 퀘스트 판정과 같은 규칙). + /// + public static bool CountsToward(TutorialQuest quest, QuestType action, InsectGame.Data.InsectRarity rarity) + { + if (quest == null || !IsBankable(quest.type)) return false; + if (action != QuestType.Capture) return quest.type == action; + + switch (quest.type) + { + case QuestType.Capture: return true; + case QuestType.CaptureRare: return rarity >= InsectGame.Data.InsectRarity.Uncommon; + case QuestType.CaptureRarity: return rarity == quest.requiredRarity; + default: return false; + } + } + + /// + /// 이번 행동을 미리 세어 둘 스토리 퀘스트 — 아직 안 끝났고 지금 활성도 아닌 것. + /// 활성 퀘스트는 원래 경로(IncrementProgress)가 올리므로 여기서 빼야 두 번 세지 않는다. + /// 서브 퀘스트는 대상이 아니다(다중 활성이라 자기 경로로 센다). + /// + public static void CollectBankTargets(TutorialQuest[] quests, System.Func isCompleted, + string activeQuestId, QuestType action, InsectGame.Data.InsectRarity rarity, List into) + { + if (into == null) return; + into.Clear(); + if (quests == null || isCompleted == null) return; + + for (int i = 0; i < quests.Length; i++) + { + TutorialQuest q = quests[i]; + if (q == null || q.category != QuestCategory.Story) continue; + if (q.questId == activeQuestId || isCompleted(q.questId)) continue; + if (CountsToward(q, action, rarity)) into.Add(q); + } + } } } diff --git a/Assets/Scripts/Core/TutorialQuestManager.cs b/Assets/Scripts/Core/TutorialQuestManager.cs index 221065e..f7d55c4 100644 --- a/Assets/Scripts/Core/TutorialQuestManager.cs +++ b/Assets/Scripts/Core/TutorialQuestManager.cs @@ -19,6 +19,9 @@ public class TutorialQuestManager : MonoBehaviour, ICloudReloadable private BattleTeamManager battleTeamManager; private RegionManager regionManager; private WeeklyContestManager weeklyContest; + // 섬 퀘스트 보상(코인·섬 물건) 지급처. 없으면 그 보상만 경고 후 건너뛴다(다른 보상은 그대로 나간다). + private PlayerCurrencyWallet currencyWallet; + private IslandManager islandManager; private TutorialQuest[] allQuests; private Dictionary questProgress = new Dictionary(); @@ -137,6 +140,13 @@ public void AutoWire(RegionBlightManager blightManager) if (blight != null) blight.RegionCleansed += OnRegionCleansed; } + /// 섬 퀘스트 보상(코인·섬 물건) 지급처. 진행 통지는 IslandManager가 Notify___로 직접 한다. + public void AutoWire(PlayerCurrencyWallet wallet, IslandManager island) + { + if (currencyWallet == null) currencyWallet = wallet; + if (islandManager == null) islandManager = island; + } + /// 이번 주 대결 대상 종 — TutorialQuestUI가 퀘스트 문구를 덮어쓸 때 쓴다. public Data.InsectData WeeklyContestTarget => weeklyContest != null ? weeklyContest.TargetInsect : null; @@ -175,9 +185,16 @@ private void OnDestroy() private void Initialize() { - // **배열 순서가 곧 첫 퀘스트다.** ActivateNextQuest가 배열을 위에서부터 훑어 - // 첫 미완료·prereq충족 스토리 퀘스트를 고르는데, q_collection/q_dex는 prereq가 - // 아예 없어서 순서만이 그 둘보다 먼저 오게 하는 유일한 장치다. + // **배열 순서가 곧 스토리 순서다.** ActivateNextQuest가 배열을 위에서부터 훑어 + // 첫 미완료·prereq충족 스토리 퀘스트를 고른다. + // + // **수문장까지의 스토리 체인은 "하는 일"만 남겼다**(2026-10-02): 이동 → 어르신 → 포획 1 → + // 포획 3 → 레벨업 → 전투 1 → 전투 3 → 희귀 포획 → 수문장. 창을 한 번 열면 끝나는 과제 + // (컬렉션·도감·스킬 장착·아이템·훈련·팀 편성)는 **자리는 그대로 두고 category만 Side로** 바꿨다 — + // 예전엔 수문장 전 14개 중 7개가 그런 과제였고 첫 전투가 9번째였다. 이제 순서 없이 아무 때나 + // 하면 보상을 받고, 안 해도 진행이 막히지 않는다. 배열 자리를 안 옮긴 것은 소급 완료 + // (BackfillSkippedStoryQuests)가 "배열 순서 = 완료 순서"에 기대기 때문이다 — 남은 스토리 + // 퀘스트의 순서는 예전 그대로라 기존 세이브가 아무것도 건너뛰지 않는다. // // q_move가 맨 앞인 이유: 예전엔 q_approach(첫 포획)가 첫 퀘스트라, 처음 켠 사람이 // **움직이는 법을 배우기 전에** 곤충을 잡으라는 지시를 받았다. @@ -216,18 +233,20 @@ private void Initialize() }, new TutorialQuest { - questId = "q_collection", title = "컬렉션 확인", + questId = "q_collection", title = "[둘러보기] 컬렉션 확인", description = "C키로 보유 곤충을 확인해보세요", hint = "C키를 눌러 컬렉션 화면을 열어보세요", type = QuestType.ViewCollection, targetCount = 1, + category = QuestCategory.Side, prerequisiteQuestId = "q_approach", rewardExp = 5 }, new TutorialQuest { - questId = "q_dex", title = "도감 열기", + questId = "q_dex", title = "[둘러보기] 도감 열기", description = "D키로 도감을 열어 발견한 곤충을 확인하세요", hint = "D키를 눌러 도감 화면을 열어보세요", type = QuestType.OpenDex, targetCount = 1, + category = QuestCategory.Side, prerequisiteQuestId = "q_approach", rewardExp = 5 }, new TutorialQuest @@ -242,7 +261,7 @@ private void Initialize() new TutorialQuest { questId = "q_levelup", title = "첫 레벨업!", - description = "컬렉션에서 캔디로 곤충을 레벨업하세요", + description = "컬렉션에서 캔디를 먹여 곤충을 키워 보세요 — 강해진 친구는 더 험한 곳도 함께 갑니다", hint = "컬렉션에서 곤충을 선택하고 레벨업 버튼을 누르세요", type = QuestType.LevelUp, targetCount = 1, prerequisiteQuestId = "q_capture3", @@ -250,11 +269,11 @@ private void Initialize() }, new TutorialQuest { - questId = "q_equip", title = "스킬 장착", + questId = "q_equip", title = "[둘러보기] 스킬 장착", description = "훈련 메뉴에서 곤충에게 스킬을 장착하세요", hint = "훈련 메뉴를 열고 스킬 장착 탭을 확인하세요", type = QuestType.EquipSkill, targetCount = 1, - prerequisiteQuestId = "q_levelup", + category = QuestCategory.Side, prerequisiteQuestId = "q_levelup", rewardExp = 10 }, new TutorialQuest @@ -263,36 +282,36 @@ private void Initialize() description = "야생 곤충과 전투해서 승리하세요", hint = "야생 곤충에게 다가가 전투를 시작하세요", type = QuestType.Battle, targetCount = 1, - prerequisiteQuestId = "q_equip", + prerequisiteQuestId = "q_levelup", rewardCandy = 10, rewardExp = 20, rewardItemId = "exp_boost", rewardItemCount = 1 }, new TutorialQuest { - questId = "q_item", title = "아이템 활용", + questId = "q_item", title = "[둘러보기] 아이템 활용", description = "아이템을 사용해보세요 (채집망 등)", hint = "[I] 가방에서 아이템 사용 (채집망은 곤충 앞에서)", type = QuestType.UseItem, targetCount = 1, - prerequisiteQuestId = "q_battle", + category = QuestCategory.Side, prerequisiteQuestId = "q_battle", rewardCandy = 5, rewardItemId = "net_silver", rewardItemCount = 2 }, new TutorialQuest { - questId = "q_training", title = "훈련 시작!", + questId = "q_training", title = "[둘러보기] 훈련 시작!", description = "훈련 메뉴에서 곤충을 훈련시키세요", hint = "훈련 메뉴를 열고 훈련 방법을 선택하세요", type = QuestType.Training, targetCount = 1, - prerequisiteQuestId = "q_battle", + category = QuestCategory.Side, prerequisiteQuestId = "q_battle", rewardExp = 15 }, new TutorialQuest { - questId = "q_team", title = "팀 편성", + questId = "q_team", title = "[둘러보기] 팀 편성", description = "전투 팀에 곤충을 배치하세요", hint = "팀 편성 화면에서 슬롯에 곤충을 배치하세요", type = QuestType.SetTeam, targetCount = 1, - prerequisiteQuestId = "q_training", + category = QuestCategory.Side, prerequisiteQuestId = "q_battle", rewardCandy = 10 }, new TutorialQuest @@ -301,13 +320,13 @@ private void Initialize() description = "전투에서 3번 승리하세요", hint = "야생 곤충들과 전투를 반복하세요", type = QuestType.Battle, targetCount = 3, - prerequisiteQuestId = "q_team", + prerequisiteQuestId = "q_battle", rewardCandy = 15, rewardExp = 30 }, new TutorialQuest { questId = "q_capture_rare", title = "희귀종 발견!", - description = "Uncommon 이상 등급 곤충을 포획하세요", + description = "고급(Uncommon) 이상 등급 곤충을 포획하세요", hint = "특별한 색상이나 효과를 가진 곤충을 찾아보세요", type = QuestType.CaptureRare, targetCount = 1, prerequisiteQuestId = "q_battle3", @@ -335,7 +354,7 @@ private void Initialize() new TutorialQuest { questId = "q_subarea", title = "숨겨진 장소", - description = "서브구역(동굴, 갈대밭 등)을 탐험하세요", + description = "동굴이나 갈대밭 같은 숨은 장소에 들어가 보세요 — 아직 못 만난 곤충은 대개 그런 곳에 숨어 있습니다", hint = "연못 주변의 특별한 장소를 찾아보세요", type = QuestType.VisitSubArea, targetCount = 1, prerequisiteQuestId = "q_visit_pond", @@ -344,7 +363,7 @@ private void Initialize() new TutorialQuest { questId = "q_raid", title = "레이드 도전!", - description = "Epic 이상 곤충에게 레이드 전투를 시도하세요", + description = "영웅(Epic) 이상 곤충에게 레이드를 걸어 보세요 — 팀 다섯이 함께 싸웁니다", hint = "강력한 보스 곤충을 찾아 레이드를 시작하세요", type = QuestType.RaidBattle, targetCount = 1, prerequisiteQuestId = "q_subarea", @@ -353,7 +372,7 @@ private void Initialize() new TutorialQuest { questId = "q_capture10", title = "곤충 박사", - description = "총 10마리의 곤충을 포획하세요", + description = "곤충을 총 10마리 포획하세요 — 기록이 늘수록 세상이 덜 조용해집니다", hint = "다양한 지역을 탐험하며 곤충을 모으세요", type = QuestType.Capture, targetCount = 10, prerequisiteQuestId = "q_raid", @@ -373,7 +392,7 @@ private void Initialize() new TutorialQuest { questId = "q_complete", title = "모험의 시작", - description = "축하합니다! 이제 자유롭게 모험하세요!", + description = "어르신께 배울 건 다 배웠습니다. 이제 사라짐의 뿌리를 찾아 자유롭게 떠나세요", hint = "월드를 자유롭게 탐험하세요", type = QuestType.Movement, targetCount = 1, prerequisiteQuestId = "q_battle10", @@ -394,8 +413,8 @@ private void Initialize() rewardCandy = 60, rewardExp = 120, rewardItemId = "net_gold", rewardItemCount = 1 }, - // **목표는 1이지 2가 아니다.** NotifyAction은 **활성 퀘스트 하나만** 올리는데 - // (`:699`), 첫 정화는 그 앞의 q_blight_first가 이미 소비한다. + // **목표는 1이지 2가 아니다.** 정화는 **활성 퀘스트 하나만** 올리는데(미리 세기 대상이 + // 아니다 — `TutorialQuestOrder.IsBankable`), 첫 정화는 그 앞의 q_blight_first가 이미 소비한다. // **체인 합계가 거점 수를 넘으면 뒤 퀘스트가 영영 완료되지 않는다** — 거점이 // 둘이던 시절 1 + 2를 적어 실제로 죽어 있었다. 지금은 셋이지만 합계는 그대로 // 1 + 1로 둔다: 거점이 다시 줄어도 안전하고, 어차피 "하나 더"가 이 퀘스트의 @@ -425,8 +444,8 @@ private void Initialize() new TutorialQuest { questId = "s_battle_win", title = "전투 단련", - description = "배틀에서 승리하세요. 반복할수록 목표가 상승합니다.", - hint = "야생 곤충에게 배틀을 걸어 이기세요", + description = "전투에서 승리하세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "야생 곤충에게 전투를 걸어 이기세요", type = QuestType.Battle, targetCount = 3, targetIncrement = 3, category = QuestCategory.Side, repeatable = true, prerequisiteQuestId = "q_battle", @@ -435,8 +454,8 @@ private void Initialize() new TutorialQuest { questId = "s_raid_win", title = "레이드 도전자", - description = "레이드에서 승리하세요. 반복할수록 목표가 상승합니다.", - hint = "Epic/Legendary 곤충에게 레이드를 도전하세요", + description = "레이드에서 승리하세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "영웅·전설 곤충에게 레이드를 걸어 보세요", type = QuestType.RaidBattle, targetCount = 1, targetIncrement = 1, category = QuestCategory.Side, repeatable = true, prerequisiteQuestId = "q_raid", @@ -446,7 +465,7 @@ private void Initialize() new TutorialQuest { questId = "s_npc_duel", title = "동네 최강자", - description = "곤충잡이 아이와의 대결에서 승리하세요. 반복할수록 목표가 상승합니다.", + description = "곤충잡이 아이와의 대결에서 승리하세요. 달성할수록 다음 목표가 늘어납니다.", hint = "필드를 돌아다니는 아이에게 [E]로 대결을 신청하세요", type = QuestType.NpcDuel, targetCount = 3, targetIncrement = 2, category = QuestCategory.Side, repeatable = true, @@ -471,7 +490,7 @@ private void Initialize() new TutorialQuest { questId = "s_pack_common", title = "일반 곤충 채집단", - description = "일반(Common) 등급 곤충을 포획하세요. 반복할수록 목표가 상승합니다.", + description = "일반(Common) 등급 곤충을 포획하세요. 달성할수록 다음 목표가 늘어납니다.", hint = "어느 리전에서나 흔하게 만날 수 있습니다", type = QuestType.CaptureRarity, requiredRarity = InsectRarity.Common, targetCount = 8, targetIncrement = 6, @@ -483,7 +502,7 @@ private void Initialize() new TutorialQuest { questId = "s_pack_uncommon", title = "고급 곤충 채집단", - description = "고급(Uncommon) 등급 곤충을 포획하세요. 반복할수록 목표가 상승합니다.", + description = "고급(Uncommon) 등급 곤충을 포획하세요. 달성할수록 다음 목표가 늘어납니다.", hint = "은빛 채집망을 쓰면 성공률이 오릅니다", type = QuestType.CaptureRarity, requiredRarity = InsectRarity.Uncommon, targetCount = 5, targetIncrement = 4, @@ -495,7 +514,7 @@ private void Initialize() new TutorialQuest { questId = "s_pack_rare", title = "희귀 곤충 채집단", - description = "희귀(Rare) 등급 곤충을 포획하세요. 반복할수록 목표가 상승합니다.", + description = "희귀(Rare) 등급 곤충을 포획하세요. 달성할수록 다음 목표가 늘어납니다.", hint = "황금 채집망과 포박의 그물을 함께 쓰세요", type = QuestType.CaptureRarity, requiredRarity = InsectRarity.Rare, targetCount = 3, targetIncrement = 2, @@ -507,7 +526,7 @@ private void Initialize() new TutorialQuest { questId = "s_pack_epic", title = "영웅 곤충 채집단", - description = "영웅(Epic) 등급 곤충을 포획하세요. 반복할수록 목표가 상승합니다.", + description = "영웅(Epic) 등급 곤충을 포획하세요. 달성할수록 다음 목표가 늘어납니다.", hint = "레이드로 약화시킨 뒤 포획하면 수월합니다", type = QuestType.CaptureRarity, requiredRarity = InsectRarity.Epic, targetCount = 2, targetIncrement = 1, @@ -519,7 +538,7 @@ private void Initialize() new TutorialQuest { questId = "s_pack_legendary", title = "전설 곤충 채집단", - description = "전설(Legendary) 등급 곤충을 포획하세요. 반복할수록 목표가 상승합니다.", + description = "전설(Legendary) 등급 곤충을 포획하세요. 달성할수록 다음 목표가 늘어납니다.", hint = "최고 난도입니다 — 포획 보정 아이템을 모두 준비하세요", type = QuestType.CaptureRarity, requiredRarity = InsectRarity.Legendary, targetCount = 1, targetIncrement = 1, @@ -528,6 +547,611 @@ private void Initialize() rewardCandy = 120, rewardExp = 100, rewardItemId = "spirit_blessing", rewardItemCount = 1 }, + + // --- 조건부 포획 — QuestType.CaptureTrait (크기·속성·이로치) --- + // 전부 서브라 동시에 진행되고, **한 번의 포획이 맞는 조건 전부를 함께 올린다** + // (큰 물 곤충 한 마리가 "듬직한 이웃"·"물가의 손님"·"영웅 곤충 채집단"을 같이 채운다). + // 몸길이 기준은 실제 분포에서 잡았다. 종 기준(ApplySizeProfile: 등급 기준 × 종 해시 0.7~1.3)에 개체 편차 + // 0.75~1.25가 곱해진다. 필드 포획 한 번당 40mm 이상 약 11% · 60mm 이상 약 2.6% · 80mm 이상 약 0.4% · + // 20mm 이하 약 25% · 16mm 이하 약 7.5%(등급표 60/25/11/3.5/0.5%, 종 균등 가정). 기준을 바꾸면 + // QuestTraitTests의 도달 가능성 검사가 실제 곤충 DB로 다시 잰다. + new TutorialQuest + { + questId = "s_size_big40", title = "듬직한 이웃", + description = "몸길이 40mm 이상인 곤충을 포획하세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "보유 곤충 창에서 몸길이를 볼 수 있습니다 — 희귀 이상에 큰 개체가 많습니다", + type = QuestType.CaptureTrait, minSizeMm = 40f, + targetCount = 3, targetIncrement = 2, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_capture3", + rewardCandy = 30, rewardExp = 24, + rewardItemId = "net_silver", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_size_big60", title = "거대 곤충 사냥꾼", + description = "몸길이 60mm 이상인 거대한 곤충을 포획하세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "영웅·전설 곤충이나, 큰 개체로 태어난 희귀 곤충을 노리세요", + type = QuestType.CaptureTrait, minSizeMm = 60f, + targetCount = 1, targetIncrement = 1, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_capture10", + rewardCandy = 40, rewardExp = 32, + rewardItemId = "net_gold", rewardItemCount = 1 + }, + new TutorialQuest + { + questId = "s_size_big80", title = "초대형 곤충", + description = "몸길이 80mm 이상인 초대형 곤충을 포획하세요. 영웅·전설 곤충의 큰 개체만 닿습니다.", + hint = "오래 걸리는 목표입니다 — 다른 퀘스트를 하며 천천히 노리세요", + type = QuestType.CaptureTrait, minSizeMm = 80f, targetCount = 1, + category = QuestCategory.Side, + prerequisiteQuestId = "q_capture10", + rewardCandy = 120, rewardExp = 100, + rewardItemId = "spirit_blessing", rewardItemCount = 1 + }, + new TutorialQuest + { + questId = "s_size_small20", title = "작은 손님", + description = "몸길이 20mm 이하인 작은 곤충을 포획하세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "일반·고급 곤충 중에 작은 개체가 많습니다", + type = QuestType.CaptureTrait, maxSizeMm = 20f, + targetCount = 4, targetIncrement = 3, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_capture3", + rewardCandy = 15, rewardExp = 10, + rewardItemId = "net_basic", rewardItemCount = 3 + }, + new TutorialQuest + { + questId = "s_size_small16", title = "콩알 곤충", + description = "몸길이 16mm 이하인 콩알만 한 곤충을 포획하세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "일반 곤충 중에서도 아주 작게 태어난 개체만 해당됩니다", + type = QuestType.CaptureTrait, maxSizeMm = 16f, + targetCount = 2, targetIncrement = 1, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_capture3", + rewardCandy = 25, rewardExp = 20, + rewardItemId = "net_silver", rewardItemCount = 1 + }, + new TutorialQuest + { + questId = "s_size_ratio_big", title = "대물", + description = "같은 종 평균보다 20% 이상 큰 개체를 포획하세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "종마다 평균 크기가 다릅니다 — 같은 종을 여러 마리 잡아 견주어 보세요", + type = QuestType.CaptureTrait, minSizeRatio = 1.2f, + targetCount = 3, targetIncrement = 2, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_capture3", + rewardCandy = 35, rewardExp = 28, + rewardItemId = "net_silver", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_size_ratio_small", title = "꼬마 개체", + description = "같은 종 평균보다 20% 이상 작은 개체를 포획하세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "종마다 평균 크기가 다릅니다 — 같은 종을 여러 마리 잡아 견주어 보세요", + type = QuestType.CaptureTrait, maxSizeRatio = 0.8f, + targetCount = 3, targetIncrement = 2, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_capture3", + rewardCandy = 30, rewardExp = 24, + rewardItemId = "net_basic", rewardItemCount = 4 + }, + new TutorialQuest + { + questId = "s_size_band", title = "알맞은 크기", + description = "몸길이가 25~30mm인 곤충을 포획하세요. 너무 크거나 작으면 안 됩니다. 달성할수록 다음 목표가 늘어납니다.", + hint = "고급 곤충에 가장 많이 해당됩니다", + type = QuestType.CaptureTrait, minSizeMm = 25f, maxSizeMm = 30f, + targetCount = 5, targetIncrement = 3, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_capture3", + rewardCandy = 20, rewardExp = 15, + rewardItemId = "wound_salve", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_trait_shiny", title = "색다른 손님", + description = "빛깔이 다른 색다른 곤충을 포획하세요. 필드에서 반짝이는 개체를 찾아보세요.", + hint = "근처에 나타나면 알림이 뜹니다 — 놓치지 마세요", + type = QuestType.CaptureTrait, requireShiny = true, targetCount = 1, + category = QuestCategory.Side, + prerequisiteQuestId = "q_capture3", + rewardCandy = 80, rewardExp = 60, + rewardItemId = "net_gold", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_trait_water", title = "물가의 손님", + description = "물 속성 곤충을 포획하세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "연못에 물 속성 곤충이 많습니다", + type = QuestType.CaptureTrait, requiredElement = InsectElement.Water, + targetCount = 4, targetIncrement = 3, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_capture3", + rewardCandy = 20, rewardExp = 16, + rewardItemId = "net_silver", rewardItemCount = 1 + }, + new TutorialQuest + { + questId = "s_trait_light", title = "빛을 품은 곤충", + description = "빛 속성 곤충을 포획하세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "반딧불이처럼 빛을 내는 곤충이 대표적입니다", + type = QuestType.CaptureTrait, requiredElement = InsectElement.Light, + targetCount = 3, targetIncrement = 2, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_capture10", + rewardCandy = 30, rewardExp = 24, + rewardItemId = "net_silver", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_trait_dark", title = "어둠에 사는 곤충", + description = "어둠 속성 곤충을 포획하세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "밤에 활동하는 곤충과 고대 유적의 곤충이 어둠 속성입니다", + type = QuestType.CaptureTrait, requiredElement = InsectElement.Dark, + targetCount = 3, targetIncrement = 2, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_capture10", + rewardCandy = 30, rewardExp = 24, + rewardItemId = "net_silver", rewardItemCount = 2 + }, + + // --- 조건부 전투 — QuestType.BattleFeat (상대 등급·속성·레벨 차·내 행동 수·남은 HP·연승) --- + // 1v1 전투 승리만 센다(레이드는 별개 컨트롤러라 안 센다). 야생·수문장·곤충잡이 아이 대결을 모두 센다. + // 한 번의 승리가 맞는 조건 전부를 함께 올린다 — "속전속결"과 "깔끔한 승리"는 같은 전투로 같이 찬다. + new TutorialQuest + { + questId = "s_feat_elite", title = "강적 사냥꾼", + description = "희귀(Rare) 이상 곤충과 싸워 이기세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "희귀 이상 곤충에게 [B]로 전투를 거세요", + type = QuestType.BattleFeat, minRarity = InsectRarity.Rare, + targetCount = 3, targetIncrement = 2, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_battle", + rewardCandy = 40, rewardExp = 30, + rewardItemId = "wound_salve_great", rewardItemCount = 1 + }, + new TutorialQuest + { + questId = "s_feat_epic", title = "영웅 사냥꾼", + description = "영웅(Epic) 이상 곤충과 싸워 이기세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "[B] 전투만 셉니다 — 레이드 승리는 해당되지 않습니다", + type = QuestType.BattleFeat, minRarity = InsectRarity.Epic, + targetCount = 1, targetIncrement = 1, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_battle10", + rewardCandy = 60, rewardExp = 50, + rewardItemId = "full_restore", rewardItemCount = 1 + }, + new TutorialQuest + { + questId = "s_feat_upset", title = "하극상", + description = "내 곤충보다 레벨이 3 이상 높은 곤충과 싸워 이기세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "상대 레벨에서 싸우는 내 곤충의 레벨을 뺀 값으로 셉니다 — 낮은 레벨 곤충으로 도전하세요", + type = QuestType.BattleFeat, minLevelEdge = 3, + targetCount = 2, targetIncrement = 1, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_battle", + rewardCandy = 30, rewardExp = 25, + rewardItemId = "antidote", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_feat_swift", title = "속전속결", + description = "내 행동 3번 이내에 전투를 이기세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "강한 기술로 몰아치세요 — 기본 공격과 기절로 건너뛴 차례도 행동으로 셉니다", + type = QuestType.BattleFeat, maxTurns = 3, + targetCount = 3, targetIncrement = 2, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_battle", + rewardCandy = 25, rewardExp = 20, + rewardItemId = "wound_salve", rewardItemCount = 3 + }, + new TutorialQuest + { + questId = "s_feat_clean", title = "깔끔한 승리", + description = "내 곤충의 HP를 70% 이상 남기고 전투를 이기세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "상성이 유리하면 피해를 덜 입습니다", + type = QuestType.BattleFeat, minHpPercent = 70, + targetCount = 3, targetIncrement = 2, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_battle", + rewardCandy = 25, rewardExp = 20, + rewardItemId = "antidote", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_feat_streak", title = "연승 가도", + description = "전투를 연달아 5번 이기세요. 지거나 도망치면 처음부터 다시 셉니다. 달성할수록 다음 목표가 늘어납니다.", + hint = "컨디션이 좋을 때 도전하세요 — 남은 HP는 병원이나 치료 아이템으로 채울 수 있습니다", + type = QuestType.BattleFeat, resetOnLoss = true, + targetCount = 5, targetIncrement = 2, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_battle3", + rewardCandy = 40, rewardExp = 30, + rewardItemId = "full_restore", rewardItemCount = 1 + }, + new TutorialQuest + { + questId = "s_feat_ace", title = "일격필살", + description = "희귀(Rare) 이상 곤충을 내 행동 3번 이내에 쓰러뜨리세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "유리한 상성과 가장 센 기술로 마무리하세요", + type = QuestType.BattleFeat, minRarity = InsectRarity.Rare, maxTurns = 3, + targetCount = 1, targetIncrement = 1, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_battle10", + rewardCandy = 50, rewardExp = 40, + rewardItemId = "wound_salve_great", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_feat_wind", title = "바람 사냥꾼", + description = "바람 속성 곤충과 싸워 이기세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "바람은 전기·강철 속성 기술에 약합니다", + type = QuestType.BattleFeat, requiredElement = InsectElement.Wind, + targetCount = 3, targetIncrement = 2, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "q_battle", + rewardCandy = 25, rewardExp = 20, + rewardItemId = "wound_salve", rewardItemCount = 2 + }, + + // --- 외전 — 본편 옆을 나란히 가는 연작(한 편씩 차례로 열린다) --- + // 서브·1회·선행 사슬이다. 본편이나 다른 외전, 위의 반복 서브와 **동시에** 진행된다. 같은 행동이 여러 칸에 + // 함께 차지 않도록 맞는 퀘스트를 먼저 다 모은 뒤 올린다(NotifyCapture/NotifyBattleFeat) — 한 번의 포획이 + // 앞 편을 끝내고 뒤 편까지 채우지 않는다. + // 글은 짧은 쪽지다. **라온의 말은 직접 하는 말이 아니라 남겨 둔 도전장이다** — 라온이 전력에서 빠지는 + // 구간(ch10~ch12)에 이 목록을 여는 사람에게 그의 목소리가 새로 들리지 않게 한다. 세라의 연작은 + // 숲에서 합류한 뒤에야 열리도록 q_blight_first 뒤에 둔다. + // 어휘: 주인공 쪽은 「거둬들이다」를 쓰지 않는다 — 만나다·맞이하다·기록하다. + + // 외전 ① 어르신의 낡은 일기 — 크기와 맞섬. 어르신은 처음부터 곁에 있는 사람이라 q_capture3 뒤에 연다. + new TutorialQuest + { + questId = "s_tale_diary1", title = "[외전] 일기 1쪽 · 작은 손님", + description = "어르신의 낡은 일기 1쪽 — '처음엔 제일 작은 손님부터 맞았다.' 몸길이 20mm 이하 곤충을 3마리 포획하세요.", + hint = "일반·고급 곤충 중에 작은 개체가 많습니다", + type = QuestType.CaptureTrait, maxSizeMm = 20f, targetCount = 3, + category = QuestCategory.Side, + prerequisiteQuestId = "q_capture3", + rewardCandy = 15, rewardExp = 12, + rewardItemId = "net_basic", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_tale_diary2", title = "[외전] 일기 2쪽 · 덩치 큰 이웃", + description = "일기 2쪽 — '작은 손님 다음은 덩치 큰 이웃이었다.' 몸길이 40mm 이상 곤충을 2마리 포획하세요.", + hint = "희귀 이상에 큰 개체가 많습니다", + type = QuestType.CaptureTrait, minSizeMm = 40f, targetCount = 2, + category = QuestCategory.Side, + prerequisiteQuestId = "s_tale_diary1", + rewardCandy = 25, rewardExp = 20, + rewardItemId = "net_silver", rewardItemCount = 1 + }, + new TutorialQuest + { + questId = "s_tale_diary3", title = "[외전] 일기 3쪽 · 맞서 본 날", + description = "일기 3쪽 — '나보다 센 녀석 앞에서 다리가 떨렸다.' 내 곤충보다 레벨이 2 이상 높은 곤충과 싸워 이기세요.", + hint = "상대 레벨에서 싸우는 내 곤충의 레벨을 뺀 값으로 셉니다", + type = QuestType.BattleFeat, minLevelEdge = 2, targetCount = 1, + category = QuestCategory.Side, + prerequisiteQuestId = "s_tale_diary2", + rewardCandy = 30, rewardExp = 24, + rewardItemId = "wound_salve", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_tale_diary4", title = "[외전] 일기 마지막 쪽 · 가장 큰 놈", + description = "일기 마지막 쪽 — '가장 큰 놈 앞에서 손이 멈췄다.' 몸길이 60mm 이상 곤충을 포획하세요.", + hint = "영웅·전설 곤충이나, 큰 개체로 태어난 희귀 곤충을 노리세요", + type = QuestType.CaptureTrait, minSizeMm = 60f, targetCount = 1, + category = QuestCategory.Side, + prerequisiteQuestId = "s_tale_diary3", + rewardCandy = 60, rewardExp = 50, + rewardItemId = "net_gold", rewardItemCount = 2 + }, + + // 외전 ② 라온의 도전장 — 전투의 내용. 앞 편을 깨야 다음 도전장이 열린다. + new TutorialQuest + { + questId = "s_tale_raon1", title = "[외전] 라온의 도전장 · 한 판", + description = "초원 게시판에 붙은 라온의 도전장 — '느려 터졌네! 3번 안에 끝내 봐.' 내 행동 3번 이내에 전투를 이기세요.", + hint = "강한 기술로 몰아치세요 — 기본 공격과 기절로 건너뛴 차례도 행동으로 셉니다", + type = QuestType.BattleFeat, maxTurns = 3, targetCount = 1, + category = QuestCategory.Side, + prerequisiteQuestId = "q_battle", + rewardCandy = 20, rewardExp = 16, + rewardItemId = "wound_salve", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_tale_raon2", title = "[외전] 라온의 도전장 · 흠집 없이", + description = "도전장 두 번째 — '이기는 건 누구나 해. 흠집 없이 이겨야 채집가지.' HP를 70% 이상 남기고 2번 이기세요.", + hint = "상성이 유리하면 피해를 덜 입습니다", + type = QuestType.BattleFeat, minHpPercent = 70, targetCount = 2, + category = QuestCategory.Side, + prerequisiteQuestId = "s_tale_raon1", + rewardCandy = 25, rewardExp = 20, + rewardItemId = "antidote", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_tale_raon3", title = "[외전] 라온의 도전장 · 연승 증명", + description = "도전장 세 번째 — '한 번 이긴 건 운이야. 3연승을 보여 줘.' 전투를 연달아 3번 이기세요. 지거나 도망치면 처음부터 다시 셉니다.", + hint = "컨디션이 좋을 때 도전하세요 — 남은 HP는 병원이나 치료 아이템으로 채울 수 있습니다", + type = QuestType.BattleFeat, resetOnLoss = true, targetCount = 3, + category = QuestCategory.Side, + prerequisiteQuestId = "s_tale_raon2", + rewardCandy = 30, rewardExp = 24, + rewardItemId = "full_restore", rewardItemCount = 1 + }, + new TutorialQuest + { + questId = "s_tale_raon4", title = "[외전] 라온의 도전장 · 마지막 한 수", + description = "도전장 마지막 장 — '센 녀석을 한 방에 눕혀 봐!' 희귀(Rare) 이상 곤충을 내 행동 3번 이내에 쓰러뜨리세요.", + hint = "유리한 상성과 가장 센 기술로 마무리하세요", + type = QuestType.BattleFeat, minRarity = InsectRarity.Rare, maxTurns = 3, targetCount = 1, + category = QuestCategory.Side, + prerequisiteQuestId = "s_tale_raon3", + rewardCandy = 60, rewardExp = 50, + rewardItemId = "full_restore", rewardItemCount = 2 + }, + + // 외전 ③ 세라의 표본 조사 — 속성과 크기. 세라가 숲에서 합류한 뒤라야 이름이 어울리므로 첫 거점 정화 뒤에 연다. + new TutorialQuest + { + questId = "s_tale_sera1", title = "[외전] 세라의 표본 조사 · 물가", + description = "세라의 표본 조사 1 — '물가의 곤충부터 기록해요.' 물 속성 곤충을 2마리 포획하세요.", + hint = "연못에 물 속성 곤충이 많습니다", + type = QuestType.CaptureTrait, requiredElement = InsectElement.Water, targetCount = 2, + category = QuestCategory.Side, + prerequisiteQuestId = "q_blight_first", + rewardCandy = 30, rewardExp = 24, + rewardItemId = "net_silver", rewardItemCount = 1 + }, + new TutorialQuest + { + questId = "s_tale_sera2", title = "[외전] 세라의 표본 조사 · 불빛", + description = "표본 조사 2 — '다음은 빛을 품은 곤충이에요.' 빛 속성 곤충을 2마리 포획하세요.", + hint = "반딧불이처럼 빛을 내는 곤충이 대표적입니다", + type = QuestType.CaptureTrait, requiredElement = InsectElement.Light, targetCount = 2, + category = QuestCategory.Side, + prerequisiteQuestId = "s_tale_sera1", + rewardCandy = 30, rewardExp = 24, + rewardItemId = "net_silver", rewardItemCount = 1 + }, + new TutorialQuest + { + questId = "s_tale_sera3", title = "[외전] 세라의 표본 조사 · 마지막 칸", + description = "표본 조사 마지막 칸 — '같은 종 평균보다 20% 이상 큰 개체예요.' 그런 개체를 1마리 포획하세요.", + hint = "종마다 평균 크기가 다릅니다 — 같은 종을 여러 마리 잡아 견주어 보세요", + type = QuestType.CaptureTrait, minSizeRatio = 1.2f, targetCount = 1, + category = QuestCategory.Side, + prerequisiteQuestId = "s_tale_sera2", + rewardCandy = 50, rewardExp = 40, + rewardItemId = "net_gold", rewardItemCount = 2 + }, + + // --- 마을 의뢰(지역 한정·1회) — category=Side + requiredRegionId --- + // 마을 이야기(Story.json town 챕터)의 짝이다. 부탁은 주민이 대사로 하고(만남 비트), + // 진행은 여기서 세고, 매듭 비트가 requiredQuestId로 완료를 관찰한다. + // **반복 금지** — 반복 서브는 completedQuests에 들어가지 않아 매듭 비트의 + // 퀘스트 게이트가 영영 안 열린다(story_lint가 잡는다). + // 보상은 퀘스트가 캔디·XP·소모품을, 매듭 비트가 다음 수문장 대비용 아이템을 준다. + new TutorialQuest + { + questId = "s_town_meadow", title = "[초원] 그림책의 빈 페이지", + description = "꼬마 화가 달래가 그림책에 그릴 초원 곤충을 보고 싶어 합니다. 초원에서 곤충 6마리를 포획한 뒤 달래에게 알려 주세요.", + hint = "본 마을 광장의 달래를 찾아가 보세요", // 만나기 전에 다 잡아도 된다(13장) — "먼저"라 쓰지 않는다 + type = QuestType.Capture, targetCount = 6, + category = QuestCategory.Side, requiredRegionId = "meadow", + prerequisiteQuestId = "q_capture3", + rewardCandy = 20, rewardExp = 25, + rewardItemId = "wound_salve", rewardItemCount = 3 + }, + new TutorialQuest + { + questId = "s_town_pond", title = "[연못] 나루터 기둥의 금", + description = "물결 할머니가 연못에 곤충이 얼마나 남았는지 궁금해합니다. 연못에서 곤충 5마리를 포획한 뒤 할머니에게 알려 주세요.", + hint = "연못 나루터(모닥불 옆)의 물결 할머니를 찾아가세요", + type = QuestType.Capture, targetCount = 5, + category = QuestCategory.Side, requiredRegionId = "pond", + prerequisiteQuestId = "q_capture3", + rewardCandy = 25, rewardExp = 30, + rewardItemId = "net_silver", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_town_forest", title = "[숲] 그물 아래의 소리", + description = "그물에서 풀려나 성이 난 곤충들이 숲을 어지럽힙니다. 숲에서 전투 3번을 이긴 뒤 나무꾼 솔에게 알려 주세요.", + hint = "숲 통나무집(모닥불 옆)의 나무꾼 솔을 찾아가세요", + type = QuestType.Battle, targetCount = 3, + category = QuestCategory.Side, requiredRegionId = "forest", + prerequisiteQuestId = "q_battle", + rewardCandy = 30, rewardExp = 40, + rewardItemId = "antidote", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_town_swamp", title = "[습지] 안개 속 약초 바구니", + description = "약초꾼 이끼가 늪에 누가 남아 있는지 알고 싶어 합니다. 습지에서 곤충 5마리를 포획한 뒤 이끼에게 알려 주세요.", + hint = "습지 원두막(모닥불 옆)의 약초꾼 이끼를 찾아가세요", + type = QuestType.Capture, targetCount = 5, + category = QuestCategory.Side, requiredRegionId = "swamp", + prerequisiteQuestId = "q_capture3", + rewardCandy = 35, rewardExp = 50, + rewardItemId = "antidote", rewardItemCount = 3 + }, + new TutorialQuest + { + questId = "s_town_mountain", title = "[산] 봉수대의 곤충 달력", + description = "무릎이 아픈 봉수지기 너울 대신 산 곤충들의 기운을 살펴 주세요. 산에서 전투 4번을 이긴 뒤 너울에게 알려 주세요.", + hint = "산 돌집(모닥불 옆)의 봉수지기 너울을 찾아가세요", + type = QuestType.Battle, targetCount = 4, + category = QuestCategory.Side, requiredRegionId = "mountain", + prerequisiteQuestId = "q_battle", + rewardCandy = 40, rewardExp = 60, + rewardItemId = "wound_salve_great", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_town_garden", title = "[꽃밭] 모양을 바꾸지 마라", + description = "정원사 누리가 가훈대로라면 나비가 여전히 오는지 알고 싶어 합니다. 꽃밭에서 곤충 5마리를 포획한 뒤 누리에게 알려 주세요.", + hint = "꽃밭 정자(모닥불 옆)의 정원사 누리를 찾아가세요", + type = QuestType.Capture, targetCount = 5, + category = QuestCategory.Side, requiredRegionId = "garden", + prerequisiteQuestId = "q_capture3", + rewardCandy = 35, rewardExp = 50, + rewardItemId = "disc_nature_force", rewardItemCount = 1 + }, + new TutorialQuest + { + questId = "s_town_ruins", title = "[유적] 탁본의 빈칸", + description = "탁본꾼 결이 벽에서 빠진 문양의 주인을 찾고 있습니다. 고대 유적에서 희귀 곤충(고급 이상) 3마리를 포획한 뒤 결에게 알려 주세요.", + hint = "유적 천막(모닥불 옆)의 탁본꾼 결을 찾아가세요", + type = QuestType.CaptureRare, targetCount = 3, + category = QuestCategory.Side, requiredRegionId = "ruins", + prerequisiteQuestId = "q_capture3", + rewardCandy = 50, rewardExp = 80, + rewardItemId = "full_restore", rewardItemCount = 2 + }, + // 2막 — 이름 없는 자리는 두지 않는다(결말 톤. 잡기 아이를 안 두는 것과 같은 이유). + new TutorialQuest + { + questId = "s_town_hollow", title = "[텅 빈 들] 울지 않는 풍경", + description = "풍경지기 메아리가 들판에 소리가 돌아오기를 기다립니다. 텅 빈 들에서 곤충 5마리를 포획한 뒤 메아리에게 알려 주세요.", + hint = "텅 빈 들 표석 야영지(모닥불 옆)의 풍경지기 메아리를 찾아가세요", + type = QuestType.Capture, targetCount = 5, + category = QuestCategory.Side, requiredRegionId = "hollow", + prerequisiteQuestId = "q_capture3", + rewardCandy = 45, rewardExp = 90, + rewardItemId = "net_gold", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_town_dunes", title = "[모래언덕] 열어 둔 상자", + description = "떠돌이 상인 모래가 상자에서 풀어 준 곤충들이 잘 버티는지 궁금해합니다. 모래언덕에서 곤충 5마리를 포획한 뒤 모래에게 알려 주세요.", + hint = "모래언덕 천막(모닥불 옆)의 떠돌이 상인 모래를 찾아가세요", + type = QuestType.Capture, targetCount = 5, + category = QuestCategory.Side, requiredRegionId = "dunes", + prerequisiteQuestId = "q_capture3", + rewardCandy = 50, rewardExp = 100, + rewardItemId = "wound_salve_great", rewardItemCount = 3 + }, + new TutorialQuest + { + questId = "s_town_frostline", title = "[서릿길] 얼음 벽의 글씨", + description = "누군가 얼음 서고 앞의 곤충들을 들쑤셔 필사생 서리가 벽에 다가가지 못합니다. 서릿길에서 전투 4번을 이긴 뒤 서리에게 알려 주세요.", + hint = "서릿길 얼음 움막(모닥불 옆)의 필사생 서리를 찾아가세요", + type = QuestType.Battle, targetCount = 4, + category = QuestCategory.Side, requiredRegionId = "frostline", + prerequisiteQuestId = "q_battle", + rewardCandy = 55, rewardExp = 110, + rewardItemId = "full_restore", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_town_emberfall", title = "[잿불 골짜기] 타지 않은 기억", + description = "광부 숯이 사람들의 기억이 맞는지 눈으로 확인하고 싶어 합니다. 잿불 골짜기에서 곤충 5마리를 포획한 뒤 숯에게 알려 주세요.", + hint = "잿불 골짜기 현무암 움막(모닥불 옆)의 광부 숯을 찾아가세요", + type = QuestType.Capture, targetCount = 5, + category = QuestCategory.Side, requiredRegionId = "emberfall", + prerequisiteQuestId = "q_capture3", + rewardCandy = 60, rewardExp = 120, + rewardItemId = "net_gold", rewardItemCount = 2 + }, + new TutorialQuest + { + questId = "s_town_canopy", title = "[우듬지] 없어진 누에", + description = "나무타기 잎새가 우듬지 곤충들이 다 무사한지 걱정합니다. 우듬지에서 희귀 곤충(고급 이상) 3마리를 포획한 뒤 잎새에게 알려 주세요.", + hint = "우듬지 나무 위 오두막(모닥불 옆)의 나무타기 잎새를 찾아가세요", + type = QuestType.CaptureRare, targetCount = 3, + category = QuestCategory.Side, requiredRegionId = "canopy", + prerequisiteQuestId = "q_capture3", + rewardCandy = 65, rewardExp = 130, + rewardItemId = "full_restore", rewardItemCount = 3 + }, + + // --- 나의 섬 — 하나씩 따라 하면 섬의 기본 조작을 전부 한 번씩 해 보게 된다(Docs/IslandDesign.md) --- + // 선행이 q_capture3인 이유: 섬 자체가 그 퀘스트로 열린다(GameConstants.Island.UnlockQuestId). + new TutorialQuest + { + questId = "s_island_arrive", title = "나의 섬", + description = "나만의 섬이 생겼습니다. 탐험 메뉴의 [내 섬]이나 본 마을 나루터로 섬에 들어가 보세요.", + hint = "탐험 메뉴에서 [내 섬]을 누르세요", + type = QuestType.VisitIsland, targetCount = 1, + category = QuestCategory.Side, + prerequisiteQuestId = "q_capture3", + rewardExp = 20, rewardCoins = 100, + rewardIslandObjectId = "f_lantern", rewardIslandObjectCount = 1 + }, + new TutorialQuest + { + questId = "s_island_place", title = "꾸미기 시작", + description = "섬에서 [꾸미기]를 눌러 보관함의 물건 3개를 섬에 놓아 보세요.", + hint = "섬의 [꾸미기] — 물건을 고르고 칸을 누른 뒤 [놓기]", + type = QuestType.PlaceIslandObject, targetCount = 3, + category = QuestCategory.Side, + prerequisiteQuestId = "s_island_arrive", + rewardCoins = 80, + rewardIslandObjectId = "t_flowerbed", rewardIslandObjectCount = 1 + }, + new TutorialQuest + { + questId = "s_island_release", title = "첫 손님", + description = "섬에서 [곤충]을 눌러 보유 곤충 2마리를 풀어놓으세요. 풀어놓아도 전투에는 그대로 쓸 수 있습니다.", + hint = "섬의 [곤충] — 풀어놓을 곤충을 고르세요", + type = QuestType.ReleaseOnIsland, targetCount = 2, + category = QuestCategory.Side, + prerequisiteQuestId = "s_island_arrive", + rewardCandy = 20, + rewardIslandObjectId = "o_feeder", rewardIslandObjectCount = 1 + }, + new TutorialQuest + { + questId = "s_island_harvest", title = "첫 수확", + description = "곤충이 섬에 머무는 동안 캔디와 코인이 쌓입니다. 섬에서 [수확]을 눌러 받아 보세요.", + hint = "섬의 [수확] — 쌓인 양이 1 이상이면 받을 수 있습니다", + type = QuestType.HarvestIsland, targetCount = 1, + category = QuestCategory.Side, + prerequisiteQuestId = "s_island_release", + rewardCandy = 25, rewardExp = 30 + }, + new TutorialQuest + { + questId = "s_island_shop", title = "섬 상점", + description = "섬 [상점]에서 마음에 드는 물건을 하나 사 보세요.", + hint = "섬의 [상점] — 건물·가구·지형지물·도구", + type = QuestType.IslandPurchase, targetCount = 1, + category = QuestCategory.Side, + prerequisiteQuestId = "s_island_harvest", + rewardCoins = 120 + }, + new TutorialQuest + { + questId = "s_island_visit", title = "이웃 섬 구경", + description = "섬 [방문]에서 친구나 섬 코드로 다른 사람의 섬을 구경해 보세요.", + hint = "섬의 [방문] — 친구 목록 또는 섬 코드 8자리", + type = QuestType.VisitFriendIsland, targetCount = 1, + category = QuestCategory.Side, + prerequisiteQuestId = "s_island_arrive", + rewardCandy = 30, + rewardIslandObjectId = "f_sign", rewardIslandObjectCount = 1 + }, + new TutorialQuest + { + // 보상을 낮게 둔다 — 수확은 한두 시간마다 할 수 있어, 후하게 주면 섬이 만드는 코인보다 + // 이 퀘스트가 주는 코인이 커진다(목표가 3씩 늘어 회당 몫은 점점 줄어든다). + questId = "s_island_keeper", title = "섬지기", + description = "섬 수확물을 꾸준히 받으세요. 달성할수록 다음 목표가 늘어납니다.", + hint = "섬의 [수확]을 틈틈이 눌러 주세요", + type = QuestType.HarvestIsland, targetCount = 4, targetIncrement = 3, + category = QuestCategory.Side, repeatable = true, + prerequisiteQuestId = "s_island_harvest", + rewardCandy = 10, rewardCoins = 15 + }, }; } @@ -642,6 +1266,11 @@ private void OnBattleEnded(bool playerWon) { NotifyAction(QuestType.Battle); } + else + { + // 졌거나 도망쳤다 — 연승 퀘스트의 진행이 끊긴다. + ResetBattleStreaks(); + } } private void OnRaidEnded(bool playerWon) @@ -662,7 +1291,8 @@ private void OnRegionChanged(RegionData region) private void OnSubAreaChanged(SubAreaData subArea) { - if (subArea != null) + // 섬은 서브에리어 상태를 빌려 탈 뿐 "숨겨진 장소"가 아니다 — 세면 q_subarea가 섬 방문으로 깨진다. + if (subArea != null && !subArea.detached) { NotifyAction(QuestType.VisitSubArea); } @@ -674,6 +1304,8 @@ private void Update() { if (!tutorialSessionStarted) return; if (ActiveQuest == null) return; + // 섬을 걸어 다니는 것은 "첫 걸음"이 아니다 — 꿈이 끝나 걸을 수 있게 된 뒤부터 센다. + if (DreamPrologueState.Active) { movementQuestId = null; return; } // 이동 퀘스트가 아닐 땐 위치 추적 자체가 불필요 — Find/거리계산 스킵. if (ActiveQuest.type != QuestType.Movement) return; @@ -705,6 +1337,11 @@ private void Update() public void NotifyAction(QuestType type, int count = 1) { if (!tutorialSessionStarted) return; + // 꿈속의 걸음·전투·곤충은 퀘스트에 들어가지 않는다 — 챔피언전 승리가 "첫 전투!"를 깨면 안 된다. + if (DreamPrologueState.Active) return; + // 활성 퀘스트보다 **먼저** 미리 센다 — 아래에서 활성 퀘스트가 완료되면 곧바로 다음 퀘스트가 + // 활성화되는데, 그때 이번 행동이 이미 들어 있어야 한다(전투 1 → 전투 3이 1/3에서 시작한다). + BankUpcomingStoryProgress(type, InsectRarity.Common, count); if (ActiveQuest != null && ActiveQuest.type == type) IncrementProgress(activeQuestId, count); ProgressSideQuests(type, count); // 서브 퀘스트(다중 활성)도 함께 진행 @@ -725,6 +1362,9 @@ public void NotifyTalkToElder() public void NotifyCapture(InsectRarity rarity) { if (!tutorialSessionStarted) return; + if (DreamPrologueState.Active) return; // NotifyAction과 같은 이유 + + BankUpcomingStoryProgress(QuestType.Capture, rarity, 1); // NotifyAction과 같은 이유로 먼저 if (ActiveQuest != null) { @@ -740,6 +1380,86 @@ public void NotifyCapture(InsectRarity rarity) ProgressSideCapture(rarity); } + /// + /// 포획 + 잡은 개체의 성질. 등급 기반 퀘스트는 를 그대로 거치고, + /// 이어서 조건부 포획( — 몸길이·속성·이로치)을 센다. + /// 한 번의 포획이 맞는 조건 전부를 함께 올린다 — 서브가 다중 활성이라 병렬이다. + /// + public void NotifyCapture(CaptureFacts facts) + { + NotifyCapture(facts.rarity); + if (!tutorialSessionStarted) return; + if (DreamPrologueState.Active) return; + if (allQuests == null) return; + + // 맞는 퀘스트를 **먼저 다 모은 뒤** 올린다. 올리는 도중 앞 에피소드가 끝나 다음 에피소드가 열려도 + // 같은 포획이 그쪽까지 세지 않아야 외전이 "한 걸음씩"이다(배열을 훑으며 바로 올리면 뒤 칸이 따라 오른다). + traitTargets.Clear(); + foreach (TutorialQuest q in allQuests) + { + if (q.type != QuestType.CaptureTrait || !IsSideActive(q) || !CountsHere(q)) continue; + if (QuestTraitRules.Matches(q, facts)) traitTargets.Add(q); + } + IncrementTraitTargets(); + } + + /// + /// 이긴 전투의 모습으로 조건부 전투()를 센다. + /// InsectBattleController가 **이긴 순간** 직접 부른다(이벤트 구독자 예외에 진행이 삼켜지지 않게 — + /// 와 같은 이유). 지거나 도망친 전투는 부르지 않고, + /// 연승()을 끊는 것은 의 몫이다. + /// + public void NotifyBattleFeat(BattleFacts facts) + { + if (!tutorialSessionStarted) return; + if (DreamPrologueState.Active) return; // 꿈속 전투는 어느 기록에도 남지 않는다 + if (allQuests == null) return; + + traitTargets.Clear(); + foreach (TutorialQuest q in allQuests) + { + if (q.type != QuestType.BattleFeat || !IsSideActive(q) || !CountsHere(q)) continue; + if (QuestTraitRules.Matches(q, facts)) traitTargets.Add(q); + } + IncrementTraitTargets(); + } + + // 조건부 서브 퀘스트 수집 버퍼 — 포획·전투마다 불리므로 리스트를 새로 만들지 않는다. + private readonly List traitTargets = new List(); + + private void IncrementTraitTargets() + { + if (traitTargets.Count == 0) return; + // 완료 이벤트 구독자가 되짚어 들어와 버퍼를 비워도 이 루프가 흔들리지 않게 복사해서 돈다. + // 맞는 퀘스트가 있을 때만 할당한다(대부분의 포획·전투는 여기 오기 전에 끝난다). + TutorialQuest[] hits = traitTargets.ToArray(); + traitTargets.Clear(); + for (int i = 0; i < hits.Length; i++) + IncrementSideProgress(hits[i], 1); + } + + /// + /// 지거나 도망쳐서 연승이 끊겼다. 진행만 0으로 되돌린다 — 목표·반복 횟수는 그대로다. + /// 진행 이벤트는 쏘지 않는다(되돌림마다 "0/5" 알림이 뜨면 시끄럽다. 목록은 매번 진행값을 직접 읽는다). + /// + private void ResetBattleStreaks() + { + if (!tutorialSessionStarted) return; + if (DreamPrologueState.Active) return; + if (allQuests == null) return; + + bool changed = false; + foreach (TutorialQuest q in allQuests) + { + if (q.type != QuestType.BattleFeat || !q.resetOnLoss) continue; + if (!IsSideActive(q) || !CountsHere(q)) continue; + if (GetSideProgress(q.questId) <= 0) continue; + sideProgress[q.questId] = 0; + changed = true; + } + if (changed) SaveProgress(); + } + public void NotifyBattleWon() { NotifyAction(QuestType.Battle); @@ -802,8 +1522,94 @@ public void NotifySizeContestTier() NotifyAction(QuestType.SizeContest); } + // ── 나의 섬 ── IslandManager가 행동이 성립한 지점에서 부른다. + + /// 내 섬에 들어갔다. + public void NotifyIslandVisited() + { + NotifyAction(QuestType.VisitIsland); + } + + /// 곤충을 섬에 풀어놓았다. + public void NotifyIslandInsectReleased() + { + NotifyAction(QuestType.ReleaseOnIsland); + } + + /// 보관함의 물건을 섬에 놓았다. + public void NotifyIslandObjectPlaced() + { + NotifyAction(QuestType.PlaceIslandObject); + } + + /// 섬 수확물을 받았다. + public void NotifyIslandHarvested() + { + NotifyAction(QuestType.HarvestIsland); + } + + /// 섬 상점에서 물건을 샀다. + public void NotifyIslandPurchase() + { + NotifyAction(QuestType.IslandPurchase); + } + + /// 다른 사람의 섬을 구경했다. + public void NotifyFriendIslandVisited() + { + NotifyAction(QuestType.VisitFriendIsland); + } + // --- 진행 추적 --- + // 미리 세기 대상 수집 버퍼 — 포획·전투마다 불리므로 호출마다 리스트를 새로 만들지 않는다. + private readonly List bankTargets = new List(); + + /// + /// 아직 차례가 안 온 스토리 퀘스트의 진행을 미리 센다. 예전엔 활성 퀘스트만 세어서, 다른 + /// 퀘스트를 하는 동안 잡은 곤충·이긴 전투는 버려지고 차례가 오면 처음부터 다시 해야 했다 + /// ("3마리 포획" 직전에 잡은 세 마리가 0으로 돌아갔다). 지금은 "총 N번"으로 센다. + /// + /// 조용히 올린다 — 진행 이벤트를 쏘면 아직 안 뜬 퀘스트의 진행 알림이 뜬다. 차례가 왔을 때 + /// 이미 목표를 채웠으면 가 그 자리에서 완료한다. + /// + private void BankUpcomingStoryProgress(QuestType action, InsectRarity rarity, int count) + { + if (count <= 0) return; + TutorialQuestOrder.CollectBankTargets(allQuests, completedQuests.Contains, activeQuestId, + action, rarity, bankTargets); + if (bankTargets.Count == 0) return; + + for (int i = 0; i < bankTargets.Count; i++) + { + string id = bankTargets[i].questId; + questProgress.TryGetValue(id, out int current); + questProgress[id] = current + count; + } + SaveProgress(); + } + + // 활성 퀘스트가 이미 목표를 채웠으면(미리 세기·클라우드 병합) 그 자리에서 완료한다. + // 반환: 완료했으면 true(호출부가 QuestActivated 중복 발화를 피하게 — 수문장 정합과 같은 약속). + private bool ReconcileBankedProgress() + { + if (ActiveQuest == null || completedQuests.Contains(ActiveQuest.questId)) return false; + if (!questProgress.TryGetValue(ActiveQuest.questId, out int progress) + || progress < ActiveQuest.targetCount) return false; + CompleteQuest(ActiveQuest.questId); + return true; + } + + // 활성화 직후의 정합 둘 — 이미 끝난 수문장, 미리 채워 둔 진행. + private bool ReconcileActiveQuest() => ReconcileActiveGuardianQuest() || ReconcileBankedProgress(); + + // 저장된 활성 퀘스트를 그대로 쓸 수 없는가 — 없거나, 끝났거나, **스토리가 아니다**. + // 마지막은 「둘러보기」 과제를 Side로 옮기면서 생겼다: 그 과제가 활성인 채 저장된 세이브는 + // 다음 스토리 퀘스트로 넘겨야 한다(그 과제는 서브 목록에서 그대로 진행된다). + private bool ActiveQuestNeedsReselect() + => ActiveQuest == null || completedQuests.Contains(activeQuestId) + || ActiveQuest.category != QuestCategory.Story; + private void IncrementProgress(string questId, int amount = 1) { if (completedQuests.Contains(questId)) return; @@ -880,6 +1686,19 @@ private void GrantRewards(TutorialQuest quest) else Debug.LogWarning($"[Quest] itemInventory null — 아이템 보상 손실: {quest.questId} {quest.rewardItemId}x{quest.rewardItemCount}"); } + if (quest.rewardCoins > 0) + { + if (currencyWallet != null) currencyWallet.AddCoins(quest.rewardCoins); + else Debug.LogWarning($"[Quest] currencyWallet null — 코인 보상 손실: {quest.questId} (+{quest.rewardCoins})"); + } + + // 포함 조건은 QuestRewardFormatter와 같은 술어를 쓴다 — 한쪽만 바뀌면 "보이는데 안 주는" 어긋남이 생긴다. + if (QuestRewardFormatter.HasIslandObject(quest)) + { + if (islandManager != null) islandManager.GrantObject(quest.rewardIslandObjectId, quest.rewardIslandObjectCount); + else Debug.LogWarning($"[Quest] islandManager null — 섬 물건 보상 손실: {quest.questId} {quest.rewardIslandObjectId}x{quest.rewardIslandObjectCount}"); + } + if (!string.IsNullOrEmpty(quest.rewardInsectId)) { if (insectCollection != null) @@ -913,14 +1732,46 @@ private void GrantRewards(TutorialQuest quest) // --- 서브 퀘스트(다중 활성 + 반복 상승) --- - // 해금(prereq 완료)됐고, 반복이거나 아직 미완료면 활성. + // 해금(prereq 완료 + 리전 게이트)됐고, 반복이거나 아직 미완료면 활성. private bool IsSideActive(TutorialQuest q) + { + if (!IsSideUnlocked(q)) return false; + if (!q.repeatable && completedQuests.Contains(q.questId)) return false; + return true; + } + + /// + /// 서브 퀘스트가 열렸는가 — 선행 퀘스트 완료 + (지역 의뢰면) 그 리전 해금. + /// 목록 UI가 "미해금"을 가를 때도 이것을 쓴다. 저쪽이 prereq만 보면 잠긴 리전의 + /// 의뢰가 0/5 진행 중으로 떠서, 갈 수 없는 곳을 할 일처럼 보여 준다. + /// + public bool IsSideUnlocked(TutorialQuest q) { if (q == null || q.category != QuestCategory.Side) return false; if (!string.IsNullOrEmpty(q.prerequisiteQuestId) && !completedQuests.Contains(q.prerequisiteQuestId)) return false; - if (!q.repeatable && completedQuests.Contains(q.questId)) return false; - return true; + return QuestRegionGate.IsOpen(q.requiredRegionId, RegionAccessibleProbe); + } + + // 리전 해금 판정 대리자 — 목록 UI가 OnGUI 패스마다 IsSideUnlocked를 부르므로 + // 호출마다 람다를 새로 만들지 않게 한 번만 묶어 둔다. + private System.Func regionAccessibleProbe; + private System.Func RegionAccessibleProbe + => regionAccessibleProbe ?? (regionAccessibleProbe = IsRegionIdAccessible); + + private bool IsRegionIdAccessible(string regionId) + { + if (regionManager == null) return false; + RegionData region = regionManager.GetRegionById(regionId); + return region != null && regionManager.IsRegionAccessible(region); + } + + // 이번 행동이 지역 의뢰의 진행으로 세어지는가 — 행동이 일어난 순간의 리전으로 판정한다. + // CurrentRegion이 아니라 ActionRegionId다: 나의 섬에서는 null이라 섬 손님 곤충이 떠나기 전 리전의 의뢰로 세어지지 않는다. + private bool CountsHere(TutorialQuest q) + { + string current = regionManager != null ? regionManager.ActionRegionId : null; + return QuestRegionGate.Counts(q.requiredRegionId, current); } // 유효 목표 = 기본 + (반복 완료 횟수 × 증가량). 반복 아니면 기본 그대로. @@ -951,7 +1802,7 @@ private void ProgressSideQuests(QuestType type, int count) if (allQuests == null) return; foreach (TutorialQuest q in allQuests) { - if (q.type != type || !IsSideActive(q)) continue; + if (q.type != type || !IsSideActive(q) || !CountsHere(q)) continue; IncrementSideProgress(q, count); } } @@ -961,7 +1812,7 @@ private void ProgressSideCapture(InsectRarity rarity) if (allQuests == null) return; foreach (TutorialQuest q in allQuests) { - if (!IsSideActive(q)) continue; + if (!IsSideActive(q) || !CountsHere(q)) continue; if (q.type == QuestType.Capture) IncrementSideProgress(q, 1); else if (q.type == QuestType.CaptureRare && rarity >= InsectRarity.Uncommon) IncrementSideProgress(q, 1); else if (q.type == QuestType.CaptureRarity && rarity == q.requiredRarity) IncrementSideProgress(q, 1); @@ -1016,8 +1867,9 @@ private void ActivateNextQuest() activeQuestId = quest.questId; ActiveQuest = quest; SaveProgress(); - // 이미 충족된 DefeatGuardian이면 자동완료(CompleteQuest가 다음 퀘스트를 활성화). - if (ReconcileActiveGuardianQuest()) return; + // 이미 충족된 퀘스트면 자동완료(CompleteQuest가 다음 퀘스트를 활성화) — + // 먼저 깬 수문장, 미리 세어 둔 포획·전투. + if (ReconcileActiveQuest()) return; QuestActivated?.Invoke(quest); return; } @@ -1058,9 +1910,9 @@ public void ReloadFromDisk() if (!tutorialSessionStarted) return; - if (ActiveQuest == null || completedQuests.Contains(activeQuestId)) + if (ActiveQuestNeedsReselect()) ActivateNextQuest(); - else if (!ReconcileActiveGuardianQuest()) // 스톨된 가디언 퀘스트면 자동완료 + else if (!ReconcileActiveQuest()) // 스톨된 가디언·이미 채운 퀘스트면 자동완료 QuestActivated?.Invoke(ActiveQuest); } @@ -1072,11 +1924,11 @@ public void BeginTutorialForCurrentAccount() LoadProgress(); ActiveQuest = GetQuest(activeQuestId); - if (ActiveQuest == null || completedQuests.Contains(activeQuestId)) + if (ActiveQuestNeedsReselect()) { ActivateNextQuest(); } - else if (!ReconcileActiveGuardianQuest()) // 스톨된 가디언 퀘스트면 자동완료 + else if (!ReconcileActiveQuest()) // 스톨된 가디언·이미 채운 퀘스트면 자동완료 { QuestActivated?.Invoke(ActiveQuest); } @@ -1149,6 +2001,12 @@ public string GetQuestTitle(string questId) return quest != null ? quest.title : null; } + /// + /// questId로 퀘스트 정의를 찾는다(없으면 null). 의뢰 따라가기(StoryObjectiveTracker)가 + /// 진행 문구("연못에서 곤충 포획 3/5")를 만들 때 읽는다. 읽기 전용 — 진행은 바꾸지 않는다. + /// + public TutorialQuest FindQuest(string questId) => GetQuest(questId); + // 사용자가 퀘스트 창을 열어 완료 목록을 확인했을 때 호출 — 미확인 완료 배지를 0으로 리셋. public void MarkQuestsSeen() { diff --git a/Assets/Scripts/Core/VillageBuilder.cs b/Assets/Scripts/Core/VillageBuilder.cs index daf2dbd..307147f 100644 --- a/Assets/Scripts/Core/VillageBuilder.cs +++ b/Assets/Scripts/Core/VillageBuilder.cs @@ -473,9 +473,52 @@ private void BuildMainVillage(Data.RegionData meadow, VillageBuildResult result) BuildGachaHut(root, v, result); BuildHospital(root, v, result); BuildVillageDecorations(root); + BuildVillageWalkways(root, meadow); AddVillageVillagers(v, result); } + // Door 노드의 실제 위치를 사용해 건물 배치가 바뀌어도 문 앞과 광장이 이어진다. + private void BuildVillageWalkways(Transform root, Data.RegionData meadow) + { + Transform paths = Child(root, "Walkways", Vector3.zero); + Color dirt = new Color(0.68f, 0.56f, 0.38f); + foreach (Transform building in root) + { + Transform door = building.Find("Door"); + if (door == null) continue; + Vector3 start = root.InverseTransformPoint(door.position); + start.y = 0f; + Vector3 end = start.normalized * 7.6f; + BuildWalkway(paths, "Access_" + building.name, start, end, 1.4f, dirt); + } + + Vector3 entrance = root.InverseTransformPoint( + PlayerStartPlacement.ResolveMainVillageEntrance(new[] { meadow }).Position); + entrance.y = 0f; + // 병원과 첫 집 사이를 통과한다. 광장까지 직선으로 그리면 병원 벽을 관통한다. + Vector3[] route = { entrance, new Vector3(16f, 0f, 2.37f), + new Vector3(9f, 0f, 2.37f), new Vector3(7f, 0f, 2f) }; + for (int i = 0; i < route.Length - 1; i++) + BuildWalkway(paths, "Entrance_" + i, route[i], route[i + 1], 1.4f, dirt); + // 반폭 안쪽의 원형 캡으로 꺾인 두 평면의 바깥쪽 삼각 틈을 막는다. + // 원통 상면은 길과 같은 0.17m, 반경은 벽 이격 검사의 0.7m를 넘지 않는다. + for (int i = 1; i < route.Length - 1; i++) + Prim(PrimitiveType.Cylinder, "EntranceJoin_" + i, paths, + route[i] + Vector3.up * 0.16f, Vector3.zero, + new Vector3(1.4f, 0.01f, 1.4f), dirt); + + } + + private void BuildWalkway(Transform parent, string name, Vector3 from, Vector3 to, + float width, Color color) + { + Vector3 delta = to - from; + if (delta.sqrMagnitude < 0.01f) return; + Prim(PrimitiveType.Plane, name, parent, (from + to) * 0.5f + Vector3.up * 0.17f, + new Vector3(0f, Mathf.Atan2(delta.x, delta.z) * Mathf.Rad2Deg, 0f), + new Vector3(width / 10f, 1f, delta.magnitude / 10f), color); + } + /// 원형 광장(납작 실린더) + 중앙 우물(원통 벽 + 두레박 지붕). private void BuildPlazaAndWell(Transform root) { @@ -505,9 +548,9 @@ private void BuildPlazaAndWell(Transform root) Prim(PrimitiveType.Cylinder, "WellPostR", well, new Vector3(0.95f, 1.5f, 0f), Vector3.zero, new Vector3(0.12f, 1.0f, 0.12f), wood); Prim(PrimitiveType.Cube, "WellRoofF", well, - new Vector3(0f, 2.3f, 0.42f), new Vector3(-35f, 0f, 0f), new Vector3(2.3f, 0.1f, 1.1f), roofRed); + new Vector3(0f, 2.3f, 0.42f), new Vector3(35f, 0f, 0f), new Vector3(2.3f, 0.1f, 1.1f), roofRed); Prim(PrimitiveType.Cube, "WellRoofB", well, - new Vector3(0f, 2.3f, -0.42f), new Vector3(35f, 0f, 0f), new Vector3(2.3f, 0.1f, 1.1f), roofRed); + new Vector3(0f, 2.3f, -0.42f), new Vector3(-35f, 0f, 0f), new Vector3(2.3f, 0.1f, 1.1f), roofRed); // 두레박 + 밧줄 Prim(PrimitiveType.Cylinder, "WellRope", well, new Vector3(0f, 1.75f, 0f), Vector3.zero, new Vector3(0.03f, 0.35f, 0.03f), new Color(0.8f, 0.72f, 0.55f)); @@ -516,6 +559,8 @@ private void BuildPlazaAndWell(Transform root) } /// 집 5채 — 벽/지붕 색 변주. 벽만 콜라이더 유지, 문/창/지붕은 장식. + private Mesh houseGableMesh; + private void BuildHouses(Transform root, Vector3 villageCenter) { // (광장 기준 각도, 거리, 벽색, 지붕색) @@ -542,11 +587,12 @@ private void BuildHouses(Transform root, Vector3 villageCenter) new Vector3(0f, 1.4f, 0f), Vector3.zero, new Vector3(3.8f, 2.8f, 3.2f), h.wall, keepCollider: true); // 경사 지붕 2장 + 용마루 (Z축 회전 — 마루가 앞뒤 방향) Prim(PrimitiveType.Cube, "RoofR", house, - new Vector3(0.95f, 3.1f, 0f), new Vector3(0f, 0f, 35f), new Vector3(2.5f, 0.15f, 3.7f), h.roof); + new Vector3(0.95f, 3.52f, 0f), new Vector3(0f, 0f, -35f), new Vector3(2.5f, 0.15f, 3.7f), h.roof); Prim(PrimitiveType.Cube, "RoofL", house, - new Vector3(-0.95f, 3.1f, 0f), new Vector3(0f, 0f, -35f), new Vector3(2.5f, 0.15f, 3.7f), h.roof); + new Vector3(-0.95f, 3.52f, 0f), new Vector3(0f, 0f, 35f), new Vector3(2.5f, 0.15f, 3.7f), h.roof); Prim(PrimitiveType.Cube, "RoofRidge", house, - new Vector3(0f, 3.85f, 0f), Vector3.zero, new Vector3(0.35f, 0.12f, 3.75f), h.roof); + new Vector3(0f, 4.24f, 0f), Vector3.zero, new Vector3(0.35f, 0.12f, 3.75f), h.roof); + BuildHouseGable(house, h.wall); // 문/창 — 어두운 장식, 콜라이더 제거 Prim(PrimitiveType.Cube, "Door", house, new Vector3(0f, 0.95f, 1.63f), Vector3.zero, new Vector3(0.95f, 1.9f, 0.12f), doorColor); @@ -557,6 +603,27 @@ private void BuildHouses(Transform root, Vector3 villageCenter) } } + private void BuildHouseGable(Transform house, Color color) + { + if (houseGableMesh == null) + { + houseGableMesh = new Mesh { name = "VillageHouseGable" }; + // 닫힌 삼각기둥: 지붕 아래의 빈 전후면을 벽과 같은 재질로 메운다. + houseGableMesh.vertices = new[] { + new Vector3(-1.9f, 2.78f, -1.6f), new Vector3(1.9f, 2.78f, -1.6f), + new Vector3(0f, 4.10f, -1.6f), new Vector3(-1.9f, 2.78f, 1.6f), + new Vector3(1.9f, 2.78f, 1.6f), new Vector3(0f, 4.10f, 1.6f) }; + houseGableMesh.triangles = new[] { 0,2,1, 3,4,5, 0,1,4, 0,4,3, + 0,3,5, 0,5,2, 1,2,5, 1,5,4 }; + houseGableMesh.RecalculateNormals(); + houseGableMesh.RecalculateBounds(); + } + GameObject gable = new GameObject("Gable"); + gable.transform.SetParent(house, false); + gable.AddComponent().sharedMesh = houseGableMesh; + gable.AddComponent().sharedMaterial = Mat(color); + } + /// 상점 — 집보다 큰 건물 + 줄무늬 차양 + 간판. 문 앞 ItemShop 상호작용. private void BuildShop(Transform root, Vector3 villageCenter, VillageBuildResult result) { @@ -795,7 +862,7 @@ private void BuildVillageDecorations(Transform root) // 울타리 조각 3개 — 건물 사이 빈 각도, 접선 방향 정렬 (장식이므로 콜라이더 제거) Color fence = new Color(0.55f, 0.45f, 0.30f); - float[] fenceAngles = { 0f, 120f, 245f }; + float[] fenceAngles = { 120f, 245f }; // 동쪽은 플레이어가 들어오는 열린 입구. for (int i = 0; i < fenceAngles.Length; i++) { float a = fenceAngles[i]; @@ -836,6 +903,16 @@ private void AddVillageVillagers(Vector3 v, VillageBuildResult result) wanderRadius = wander }); } + + // 마을 이야기 — 꼬마 화가 달래. 광장 안쪽(110°/6m): 가챠 오두막(90°/12m)과 집3(145°/13m) + // 사이 빈 자리이고, 가로등(8.8m)·화분(9.5m) 고리보다 안이다. 마을 어르신과 22.2m, + // 숨겨진 웅덩이 진입 반경 밖으로 18m 떨어진다. + result.npcAnchors.Add(new NpcSpawnAnchor + { + position = Polar(v, 110f, 6f), + kind = NpcKind.StoryNpc, regionId = "meadow", + storyNpcId = "town_meadow", wanderRadius = 0f + }); } // ================= 전초기지 ================= @@ -883,9 +960,61 @@ private void BuildOutpost(Data.RegionData region, VillageBuildResult result) wanderRadius = 6f }); + AddTownStoryNpc(region, outpost, result); BuildOutpostFacilities(region, outpost, result); } + /// + /// 마을 이야기(Story.json town 챕터)의 주민 — 전초기지 모닥불 오른편에 선다. + /// + /// 로컬 (3.4, 0, 0.6)은 1막 6곳을 월드 좌표로 재서 고른 값이다(RegionDefinitions의 + /// WorldScale 1.5 적용 후, 전초기지는 리전 중심을 바라본다). 서브에리어 **진입 반경 밖 여유**: + /// 연못 갈대 +9.0 · 숲 깊은 숲 +18.5 · 습지 +19.1 · 산 동굴 +19.4 · 꽃밭 온실 +11.8 · 유적 신전 +12.5m. + /// 같은 리전의 다른 스토리 NPC와는 14.2m 이상(가장 가까운 것이 꽃밭 세라)이다. + /// 모닥불 왼편(-2.2, 3.4)도 재 봤는데 꽃밭 온실까지 +5.6m라 말을 걸려다 구역에 빨려 들 수 있어 버렸다. + /// + /// 2막은 리전마다 자리가 다르다. 전초기지 대부분이 230°라 라온 앵커(240°/0.28R)가 모닥불 바로 + /// 앞에 서 있고(1막 자리에서 2.2m), 모래언덕·우듬지는 가판대(상점 ±4.6 · 병원 · 훈련소 0,6.4)가 + /// 막는다. 그래서 오두막(+천막 기둥)·모닥불·가판대·상호작용 지점(3.5m)을 피하고 모닥불 7.5m 안에서 + /// **서브에리어 진입 반경 밖 8m 이상 · 다른 스토리 NPC 12m 이상**인 자리를 격자로 골랐다(월드 좌표): + /// 텅 빈 들 (-6.6,-1.0) 침묵의 자리 +20.3 · 라온 13.6 / 모래언덕 (-1.4,1.4) 창고 +22.0 · 집게 14.3 / + /// 서릿길 (-6.0,-1.0) 서고 +15.5 · 저울 13.1 · 라온 13.2 / 잿불 골짜기 (-2.0,1.4) 가마 +8.9 · 라온 12.9 / + /// 우듬지 (3.2,6.2) 수관 +20.3 · 병원 3.6 · 훈련소 3.7. 이름 없는 자리는 주민을 두지 않는다(결말 톤). + /// + /// **storyNpcId는 리터럴로 적는다** — game_facts.story_npc_ids()가 + /// storyNpcId = "..." 꼴을 줄 단위로 읽어 story_lint 검사 3(NpcTalk 대상이 월드에 있는가)을 + /// 돌린다. 그래서 지역변수 이름도 storyNpcId다. + /// + private static void AddTownStoryNpc(Data.RegionData region, Transform outpost, VillageBuildResult result) + { + string storyNpcId = null; + Vector3 local = new Vector3(3.4f, 0f, 0.6f); // 1막 공통 — 모닥불 오른편 + switch (region.regionId) + { + case "pond": storyNpcId = "town_pond"; break; + case "forest": storyNpcId = "town_forest"; break; + case "swamp": storyNpcId = "town_swamp"; break; + case "mountain": storyNpcId = "town_mountain"; break; + case "garden": storyNpcId = "town_garden"; break; + case "ruins": storyNpcId = "town_ruins"; break; + case "hollow": storyNpcId = "town_hollow"; local = new Vector3(-6.6f, 0f, -1.0f); break; + case "dunes": storyNpcId = "town_dunes"; local = new Vector3(-1.4f, 0f, 1.4f); break; + case "frostline": storyNpcId = "town_frostline"; local = new Vector3(-6.0f, 0f, -1.0f); break; + case "emberfall": storyNpcId = "town_emberfall"; local = new Vector3(-2.0f, 0f, 1.4f); break; + case "canopy": storyNpcId = "town_canopy"; local = new Vector3(3.2f, 0f, 6.2f); break; + } + if (storyNpcId == null) return; + + result.npcAnchors.Add(new NpcSpawnAnchor + { + position = outpost.TransformPoint(local), + kind = NpcKind.StoryNpc, + regionId = region.regionId, + storyNpcId = storyNpcId, + wanderRadius = 0f + }); + } + /// /// 2막 거점의 편의시설. 모래언덕(전반)·우듬지(후반) 두 곳에만 둔다. /// @@ -1069,7 +1198,9 @@ private void BuildLogCabin(Transform outpost) for (int i = 0; i < 3; i++) { Prim(PrimitiveType.Cylinder, $"FrontLog_{i}", hut, - new Vector3(0f, 0.5f + i * 0.6f, 1.45f), new Vector3(0f, 0f, 90f), new Vector3(0.22f, 1.55f, 0.22f), logDark); + new Vector3(-1.05f, 0.5f + i * 0.6f, 1.45f), new Vector3(0f, 0f, 90f), new Vector3(0.22f, 0.5f, 0.22f), logDark); + Prim(PrimitiveType.Cylinder, $"FrontLogRight_{i}", hut, + new Vector3(1.05f, 0.5f + i * 0.6f, 1.45f), new Vector3(0f, 0f, 90f), new Vector3(0.22f, 0.5f, 0.22f), logDark); } // 경사 지붕 2장 (마루가 좌우 방향) Prim(PrimitiveType.Cube, "RoofF", hut, @@ -1178,6 +1309,35 @@ private void BuildIceShelter(Transform outpost) // 눈처마 — 벽 위에 얹힌 눈. Prim(PrimitiveType.Cube, "SnowCap", shelter, new Vector3(0f, 1.92f, -1.5f), new Vector3(4f, 0f, 0f), new Vector3(4.2f, 0.16f, 1.7f), snow); + + // 이글루 — 바람벽만으로는 "쉼터"로 안 읽혔다(배치 캡처에서 검은 상자 몇 개). 벽 안쪽에 눈 돔, + // 앞으로 낮은 굴 입구, 안에서 새는 등불. 콜라이더는 두지 않는다 — 벽(keepCollider)이 이미 막는다. + Prim(PrimitiveType.Sphere, "IglooDome", shelter, + new Vector3(0f, 0.05f, -0.9f), Vector3.zero, new Vector3(3.0f, 2.5f, 2.8f), snow); + Prim(PrimitiveType.Cylinder, "IglooTunnel", shelter, + new Vector3(0f, 0.42f, 0.55f), new Vector3(90f, 0f, 0f), new Vector3(1.05f, 0.55f, 0.95f), snow); + Prim(PrimitiveType.Cube, "IglooDoor", shelter, + new Vector3(0f, 0.38f, 1.08f), Vector3.zero, new Vector3(0.62f, 0.55f, 0.04f), new Color(0.16f, 0.22f, 0.30f)); + GameObject glow = Prim(PrimitiveType.Cube, "IglooGlow", shelter, + new Vector3(0f, 0.3f, 1.1f), Vector3.zero, new Vector3(0.34f, 0.22f, 0.03f), new Color(1f, 0.78f, 0.42f)); + Glow(glow, new Color(1.3f, 0.85f, 0.4f)); + // 블록 이음매 — 돔 둘레의 얕은 띠 두 줄 + for (int k = 0; k < 2; k++) + { + Prim(PrimitiveType.Sphere, $"IglooBand_{k}", shelter, + new Vector3(0f, 0.05f + k * 0.42f, -0.9f), Vector3.zero, + new Vector3(3.04f - k * 0.34f, 0.05f, 2.84f - k * 0.34f), deepIce); + } + } + + /// + /// 발광 마감 — 같은 색을 쓰는 물체가 전부 함께 빛나므로(은 색 캐시) 발광체에는 고유색을 쓴다. + /// + private static void Glow(GameObject obj, Color emission) + { + if (obj == null) return; + MeshRenderer mr = obj.GetComponent(); + if (mr != null) SceneryMaterials.SetEmission(mr.sharedMaterial, emission); } /// 잿불 골짜기 — 현무암 판을 세운 은신처. 지붕에 재가 쌓였다. @@ -1206,8 +1366,24 @@ private void BuildBasaltShelter(Transform outpost) Prim(PrimitiveType.Cube, "AshRoof", shelter, new Vector3(0f, 1.95f, -0.6f), new Vector3(-7f, 0f, 0f), new Vector3(4.6f, 0.14f, 2.6f), ash); // 잉걸 — 벽 틈에서 새는 열. - Prim(PrimitiveType.Cube, "EmberCrack", shelter, + GameObject crack = Prim(PrimitiveType.Cube, "EmberCrack", shelter, new Vector3(0f, 0.35f, -0.95f), Vector3.zero, new Vector3(2.2f, 0.09f, 0.1f), ember); + Glow(crack, new Color(0.9f, 0.3f, 0.08f)); + + // 광부의 화덕 — 돌 받침 위 잉걸불과 굴뚝. 이 골짜기에서 사람이 버티는 방법이다. + Prim(PrimitiveType.Cube, "ForgeBase", shelter, + new Vector3(1.25f, 0.35f, -0.35f), new Vector3(0f, 12f, 0f), new Vector3(0.9f, 0.7f, 0.8f), new Color(0.30f, 0.27f, 0.26f)); + GameObject coals = Prim(PrimitiveType.Sphere, "ForgeCoals", shelter, + new Vector3(1.25f, 0.74f, -0.35f), Vector3.zero, new Vector3(0.62f, 0.16f, 0.52f), new Color(0.98f, 0.42f, 0.13f)); + Glow(coals, new Color(1.8f, 0.62f, 0.14f)); + Prim(PrimitiveType.Cylinder, "ForgeChimney", shelter, + new Vector3(1.55f, 1.55f, -0.75f), Vector3.zero, new Vector3(0.28f, 0.75f, 0.28f), basalt); + // 매달린 광산 등 + Prim(PrimitiveType.Cylinder, "LampHook", shelter, + new Vector3(-1.4f, 1.72f, 0.3f), Vector3.zero, new Vector3(0.04f, 0.2f, 0.04f), basalt); + GameObject lamp = Prim(PrimitiveType.Sphere, "MineLamp", shelter, + new Vector3(-1.4f, 1.46f, 0.3f), Vector3.zero, new Vector3(0.22f, 0.26f, 0.22f), new Color(1f, 0.84f, 0.50f)); + Glow(lamp, new Color(1.4f, 1.0f, 0.5f)); } /// 우듬지 — 나무 위 오두막. 사다리로 오른다. @@ -1412,7 +1588,11 @@ private GameObject Prim(PrimitiveType type, string name, Transform parent, if (!keepCollider) { Collider col = obj.GetComponent(); - if (col != null) Destroy(col); + if (col != null) + { + col.enabled = false; + Destroy(col); + } } return obj; } @@ -1474,26 +1654,23 @@ private void CreateSign(Transform building, string label, Vector3 plateLocalPos, /// /// 색상 캐시라 개수는 유한하고, 캐시가 안전한 이유는 가 생성 시 /// 색만 설정하고 그 뒤 아무도 변형하지 않기 때문이다 — 지형 빌더 둘은 - /// SetTransparent로 변형해서 같은 캐시를 쓸 수 없다(그쪽은 목록으로 추적한다). + /// 로 변형해서 같은 캐시를 쓸 수 없다(그쪽은 목록으로 추적한다). /// private void OnDestroy() { foreach (Material m in materialCache.Values) if (m != null) Destroy(m); materialCache.Clear(); + if (houseGableMesh != null) Destroy(houseGableMesh); + houseGableMesh = null; } - /// 색상당 1개 머티리얼 캐시 — RegionTerrainBuilder/부트스트랩과 동일한 셰이더 fallback 체인. + /// 색상당 1개 머티리얼 캐시 — 셰이더 폴백·무광 마감은 가 한다. private Material Mat(Color color) { if (materialCache.TryGetValue(color, out Material cached)) return cached; - 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; + Material mat = SceneryMaterials.Create(color); materialCache[color] = mat; return mat; } diff --git a/Assets/Scripts/Core/WeatherEffects.cs b/Assets/Scripts/Core/WeatherEffects.cs new file mode 100644 index 0000000..a7eb30d --- /dev/null +++ b/Assets/Scripts/Core/WeatherEffects.cs @@ -0,0 +1,398 @@ +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 날씨 입자 — 비(가는 줄기, 비스듬히) · 눈(느리게 흔들리며) · 센바람(옆으로 흐르는 먼지와 잎). 안개는 입자가 없고 + /// 조명 쪽 안개 밀도로 처리된다(). 카메라가 보는 곳 위에 큰 상자 방출기 셋을 얹어 따라다닌다. + /// + /// 무엇을 읽나. 세계 날씨의 전환(PreviousWeather→CurrentWeather, Blend01)을 지역 기준으로 바꿔 + /// ( — 설산에선 비가 눈, 사막에선 눈이 센바람) 종류별 가중치로 만들고, + /// 방출량을 그 가중치에 비례시킨다. 그래서 10초 전환 동안 비가 서서히 잦아들고 안개가 서서히 낀다. 섬에는 지역이 없으니 + /// 세계 날씨 그대로다. + /// + /// 켜지는 곳. 메인 필드와 나의 섬뿐이다( — 하늘 조명과 같은 판정). 동굴 같은 실내와 + /// 「챔피언의 꿈」에서는 즉시 끄고 남은 입자를 치운다(서서히 줄이면 그 사이 실내로 옮겨 간 방출기에서 몇 알이 새어 나온다). + /// + /// 입자가 아무도 가리지 않게 전부 가늘고 옅다 — 빗줄기 폭 4~6cm·알파 0.4, 눈송이 12~24cm, 렌즈에 붙은 입자는 + /// maxParticleSize(뷰포트 비율)로 잘라 화면을 덮지 못하게 했다. 깊이 테스트는 켜 둬서 건물·지형 뒤에서는 가려진다. + /// + /// 성능. 시스템 셋, 머티리얼·텍스처 하나(), 메시 없음. 살아 있는 입자는 데스크톱 기준 비 약 500 · + /// 눈 약 640 · 바람 약 120이고 모바일은 절반이다. 안 쓰는 시스템은 GameObject째 꺼서 비용이 0이다. Update에서 할당하지 않는다. + /// + /// 조명 없는 셰이더(Sprites/Default)라 밤에 입자만 밝게 뜨지 않도록 시각에 따라 색을 어둡게 푼다 + /// (). + /// + public class WeatherEffects : MonoBehaviour + { + [SerializeField] private WorldStateProvider worldState; + [SerializeField] private RegionManager regionManager; + + // 초당 방출량 — 살아 있는 입자 수 ≈ 방출량 × 수명 + private const float RainRate = 800f; // × 0.625s ≈ 500 — 고리(약 970㎡)가 옛 상자(784㎡)보다 넓어 같은 밀도로 올렸다 + private const float SnowRate = 95f; // × 6.75s ≈ 640 — 공중 전체에서 생겨 일부가 땅에 묻히는 만큼 올렸다(BuildSnow) + private const float WindRate = 70f; // × 1.7s ≈ 119 + private const int RainMax = 600; + private const int SnowMax = 800; + private const int WindMax = 200; + private const float MobileScale = 0.5f; + + /// 종류별 가중치가 목표로 가는 속도(초당). 10초 날씨 전환이 이미 부드러우므로 지역 경계·전환 시작의 계단만 다듬는다. + private const float FadePerSecond = 1.5f; + /// 방출기 상자 중심을 카메라 앞 이 거리에 둔다 — 게임 카메라가 플레이어를 11m 앞에서 본다. + private const float FollowDistance = 10f; + private const float BoxSize = 28f; + /// 비 고리의 바깥 반지름(m) — 고각 카메라가 보는 땅(발밑 약 16m 앞까지)을 덮는다. + private const float RainRingRadius = 18f; + /// 비를 뿌리지 않는 카메라 둘레 반지름(m) — 이보다 가까운 빗줄기는 화면을 가른다(). + private const float RainClearRadius = 4f; + private const float ColorRefreshSeconds = 0.25f; + private const float LookupSeconds = 2f; + + private sealed class Layer + { + public GameObject go; + public ParticleSystem ps; + public float baseRate; + public float weight; + public Color dayA, dayB, nightA, nightB; + } + + private Layer rain; + private Layer snow; + private Layer wind; + private Texture2D dot; + private Material material; + private float countScale = 1f; + private bool built; + private bool buildFailed; + private float colorClock; + private float lookupClock; + private float lastLevel = -1f; + private float tintT = 1f; // 0(한밤 색)~1(한낮 색) + + /// 시계·날씨 공급자와 현재 리전 정보를 주입한다. 안 주입하면 FindFirstObjectByType로 2초마다 찾는다. + public void AutoWire(WorldStateProvider provider, RegionManager regions) + { + if (provider != null) worldState = provider; + if (regions != null) regionManager = regions; + } + + private void OnDisable() + { + // 꺼지면 입자도 같이 치운다 — 다시 켜질 때 옛 자리에 남은 입자가 보이지 않게 + DeactivateAll(true); + } + + private void OnDestroy() + { + if (material != null) Destroy(material); + if (dot != null) Destroy(dot); + material = null; + dot = null; + } + + private void LateUpdate() + { + if (buildFailed) return; + if (!built && !Build()) return; + + if ((worldState == null || regionManager == null) && Time.unscaledTime >= lookupClock) + { + if (worldState == null) worldState = FindFirstObjectByType(); + if (regionManager == null) regionManager = FindFirstObjectByType(); + lookupClock = Time.unscaledTime + LookupSeconds; + } + + Camera cam = Camera.main; + if (cam == null) return; + + SubAreaData sub = regionManager != null ? regionManager.CurrentSubArea : null; + bool outdoors = WorldSkyRules.Applies(sub != null, sub != null ? sub.subAreaId : null, DreamPrologueState.Active); + if (!outdoors) + { + DeactivateAll(true); + return; + } + + // 지역 기준 날씨 — 섬·서브에리어는 지역이 없다(null). CurrentRegion은 서브에리어 안에서도 부모 리전이라 서브에리어를 먼저 거른다. + string regionId = sub == null && regionManager != null && regionManager.CurrentRegion != null + ? regionManager.CurrentRegion.regionId : null; + WorldSkyRules.WeatherMix mix = worldState != null + ? WorldSkyRules.WeatherMix.From(worldState.Weather, regionId) + : WorldSkyRules.WeatherMix.Of(WeatherType.Clear); + + float dt = Time.deltaTime; + UpdateLayer(rain, mix.rain, dt); + UpdateLayer(snow, mix.snow, dt); + UpdateLayer(wind, mix.wind, dt); + + colorClock -= Time.unscaledDeltaTime; + if (colorClock <= 0f) + { + colorClock = ColorRefreshSeconds; + RefreshColors(mix); + } + + // 방출기가 카메라가 보는 곳을 따라간다. 입자는 세계 좌표라 이미 떨어지는 것들은 제자리에서 계속 떨어진다. + Transform ct = cam.transform; + transform.position = ct.position + ct.forward * FollowDistance; + // 비 고리만 카메라 바로 위를 중심으로 — 높이는 다른 층과 같은 기준(시선 앞 지점)이다(BuildRain 주석). + if (rain != null) rain.go.transform.position = new Vector3(ct.position.x, transform.position.y, ct.position.z); + } + + // ── 층 갱신 ── + + private void UpdateLayer(Layer layer, float target, float dt) + { + if (layer == null) return; + layer.weight = Mathf.MoveTowards(layer.weight, Mathf.Clamp01(target), FadePerSecond * dt); + + if (layer.weight > 0.003f) + { + if (!layer.go.activeSelf) + { + layer.go.SetActive(true); + Tint(layer, tintT); // 첫 입자부터 장면 밝기에 맞는 색으로 — 기본 흰색이 한순간 비치지 않게 + layer.ps.Play(); + } + ParticleSystem.EmissionModule emission = layer.ps.emission; + emission.rateOverTime = layer.baseRate * layer.weight * countScale; + } + else if (layer.go.activeSelf) + { + ParticleSystem.EmissionModule emission = layer.ps.emission; + emission.rateOverTime = 0f; + // 방출을 멈춘 뒤 남은 입자가 다 떨어지면 끈다 + if (layer.ps.particleCount == 0) layer.go.SetActive(false); + } + } + + private void DeactivateAll(bool clear) + { + DeactivateLayer(rain, clear); + DeactivateLayer(snow, clear); + DeactivateLayer(wind, clear); + } + + private static void DeactivateLayer(Layer layer, bool clear) + { + if (layer == null || layer.go == null) return; + layer.weight = 0f; + if (!layer.go.activeSelf) return; + if (clear) layer.ps.Clear(true); + layer.go.SetActive(false); + } + + /// 시각에 맞춰 입자 색을 어둡게 푼다. 새로 방출되는 입자부터 바뀐다(이미 떠 있는 것은 수명이 짧아 곧 교체된다). + private void RefreshColors(WorldSkyRules.WeatherMix mix) + { + float hour = WorldSkyRules.NoonHour; + if (worldState != null && worldState.Clock != null) hour = worldState.Clock.GetHourFloat(); + float level = WorldSkyRules.Evaluate(hour, mix).lightLevel; + if (Mathf.Abs(level - lastLevel) < 0.02f) return; + lastLevel = level; + + tintT = Mathf.Clamp01((level - 0.4f) / 0.6f); + Tint(rain, tintT); + Tint(snow, tintT); + Tint(wind, tintT); + } + + private static void Tint(Layer layer, float t) + { + if (layer == null) return; + ParticleSystem.MainModule main = layer.ps.main; + main.startColor = new ParticleSystem.MinMaxGradient( + Color.Lerp(layer.nightA, layer.dayA, t), Color.Lerp(layer.nightB, layer.dayB, t)); + } + + // ── 만들기 ── + + private bool Build() + { + dot = SkyFx.CreateSoftDot(32); + material = SkyFx.CreateMaterial(dot, "~WeatherParticle"); + if (material == null) + { + // 날씨 입자만 포기한다 — 하늘 조명(안개·흐림)은 입자 없이도 돈다 + Debug.LogWarning("[WeatherEffects] Sprites/Default 셰이더를 못 찾아 날씨 입자를 끈다."); + if (dot != null) Destroy(dot); + dot = null; + buildFailed = true; + return false; + } + + countScale = Application.isMobilePlatform ? MobileScale : 1f; + rain = BuildRain(); + snow = BuildSnow(); + wind = BuildWind(); + Tint(rain, tintT); + Tint(snow, tintT); + Tint(wind, tintT); + built = true; + return true; + } + + private Layer NewLayer(string layerName, float rate, int maxParticles) + { + var layer = new Layer { baseRate = rate }; + layer.go = new GameObject("~" + layerName); + layer.go.transform.SetParent(transform, false); + layer.ps = layer.go.AddComponent(); + // AddComponent가 곧바로 재생을 시작한다 — 멈춘 뒤에 설정한다 + layer.ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); + + ParticleSystem.MainModule main = layer.ps.main; + main.playOnAwake = false; + main.loop = true; + main.simulationSpace = ParticleSystemSimulationSpace.World; + main.startSpeed = 0f; + main.gravityModifier = 0f; + main.maxParticles = Mathf.Max(8, Mathf.RoundToInt(maxParticles * (Application.isMobilePlatform ? MobileScale : 1f))); + + ParticleSystem.EmissionModule emission = layer.ps.emission; + emission.enabled = true; + emission.rateOverTime = 0f; + + ParticleSystem.ShapeModule shape = layer.ps.shape; + shape.enabled = true; + shape.shapeType = ParticleSystemShapeType.Box; + shape.rotation = Vector3.zero; + + ParticleSystemRenderer psr = layer.go.GetComponent(); + psr.sharedMaterial = material; + psr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + psr.receiveShadows = false; + psr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; + psr.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off; + + layer.go.SetActive(false); + return layer; + } + + private static void SetVelocity(ParticleSystem ps, Vector2 x, Vector2 y, Vector2 z) + { + ParticleSystem.VelocityOverLifetimeModule vel = ps.velocityOverLifetime; + vel.enabled = true; + vel.space = ParticleSystemSimulationSpace.World; + // 세 축이 같은 모드(두 상수 사이 무작위)여야 한다 + vel.x = new ParticleSystem.MinMaxCurve(x.x, x.y); + vel.y = new ParticleSystem.MinMaxCurve(y.x, y.y); + vel.z = new ParticleSystem.MinMaxCurve(z.x, z.y); + } + + private static void SetNoise(ParticleSystem ps, float strength, float frequency, float scroll) + { + ParticleSystem.NoiseModule noise = ps.noise; + noise.enabled = true; + noise.strength = strength; + noise.frequency = frequency; + noise.scrollSpeed = scroll; + noise.damping = true; + noise.quality = ParticleSystemNoiseQuality.Low; + } + + /// + /// 비 — 12m 위 얇은 고리에서 초속 약 21m로 비스듬히 떨어지는 늘어진 빌보드(폭 4~6cm, 길이 약 0.7m). + /// 수명 0.55~0.7초라 땅 높이에서 다 떨어지고 사라진다. + /// + /// 카메라 둘레 안에서는 뿌리지 않는다(고리의 중심을 매 프레임 카메라 바로 위에 둔다 — + /// ). 상자에서 뿌리면 렌즈 코앞을 지나는 빗방울이 화면을 위아래로 가르는 굵은 빛줄기가 됐다 + /// (2026-10-03 캡처). 늘어난 길이는 속도에서 나와 maxParticleSize로 잘리지 않는다. 고각 카메라는 자기 발밑을 + /// 26° 아래부터 보므로 그 빈 기둥은 화면에 드러나지 않는다. + /// + private Layer BuildRain() + { + Layer l = NewLayer("Rain", RainRate, RainMax); + ParticleSystem.MainModule main = l.ps.main; + main.startLifetime = new ParticleSystem.MinMaxCurve(0.55f, 0.7f); + main.startSize = new ParticleSystem.MinMaxCurve(0.035f, 0.055f); + + ParticleSystem.ShapeModule shape = l.ps.shape; + shape.shapeType = ParticleSystemShapeType.Circle; + shape.rotation = new Vector3(90f, 0f, 0f); // 원은 모양의 XY 평면에 놓인다 — 눕혀 수평 고리로 + shape.arc = 360f; + shape.radius = RainRingRadius; + shape.radiusThickness = 1f - RainClearRadius / RainRingRadius; // 바깥 테두리에서 안쪽으로 이만큼만 채운다 + shape.position = new Vector3(0f, 12f, 0f); + + SetVelocity(l.ps, new Vector2(-3.4f, -2.6f), new Vector2(-23f, -19f), new Vector2(-1.2f, -0.6f)); + + ParticleSystemRenderer r = l.go.GetComponent(); + r.renderMode = ParticleSystemRenderMode.Stretch; + r.lengthScale = 2f; + r.velocityScale = 0.03f; + r.cameraVelocityScale = 0f; + r.maxParticleSize = 0.03f; + + l.dayA = l.dayB = new Color(0.82f, 0.88f, 1f, 0.40f); + l.nightA = l.nightB = new Color(0.50f, 0.60f, 0.85f, 0.34f); + return l; + } + + /// + /// 눈 — 초속 1.3~2m로 느리게 내리며 잡음으로 좌우로 흔들리는 둥근 송이(12~24cm). 수명 6~7.5초. + /// + /// 판이 아니라 공중 전체(높이 0~12m)에서 생긴다. 비처럼 12m 판에서만 뿌리면 송이가 카메라 시야(고각 카메라는 자기 높이 + /// 아래만 본다)까지 내려오는 데 5초가 넘게 걸려, 동굴에서 나오거나 설산 경계를 넘거나 눈이 막 내리기 시작할 때 화면이 그동안 비어 + /// 있었다(2026-10-03 캡처). 낮게 생긴 송이는 땅에 묻혀 깊이 테스트로 가려질 뿐이라 비용은 입자 몇 개다. + /// + private Layer BuildSnow() + { + Layer l = NewLayer("Snow", SnowRate, SnowMax); + ParticleSystem.MainModule main = l.ps.main; + main.startLifetime = new ParticleSystem.MinMaxCurve(6f, 7.5f); + main.startSize = new ParticleSystem.MinMaxCurve(0.12f, 0.24f); + main.startRotation = new ParticleSystem.MinMaxCurve(0f, Mathf.PI * 2f); + + ParticleSystem.ShapeModule shape = l.ps.shape; + shape.scale = new Vector3(BoxSize, 12f, BoxSize); + shape.position = new Vector3(0f, 6f, 0f); + + SetVelocity(l.ps, new Vector2(-0.5f, 0.5f), new Vector2(-2f, -1.3f), new Vector2(-0.5f, 0.5f)); + SetNoise(l.ps, 0.9f, 0.3f, 0.15f); + + ParticleSystemRenderer r = l.go.GetComponent(); + r.renderMode = ParticleSystemRenderMode.Billboard; + r.maxParticleSize = 0.04f; + + l.dayA = l.dayB = new Color(1f, 1f, 1f, 0.90f); + l.nightA = l.nightB = new Color(0.72f, 0.80f, 1f, 0.80f); + return l; + } + + /// + /// 센바람 — 초속 9~13m로 옆으로 흐르는 가는 줄. 모래빛 먼지와 풀빛 잎이 섞인다(수명 1.4~2초). + /// 비·눈처럼 위에서 내리는 게 아니라 땅 위 0~5m 높이의 상자 안에서 일어난다. + /// + private Layer BuildWind() + { + Layer l = NewLayer("Wind", WindRate, WindMax); + ParticleSystem.MainModule main = l.ps.main; + main.startLifetime = new ParticleSystem.MinMaxCurve(1.4f, 2f); + main.startSize = new ParticleSystem.MinMaxCurve(0.05f, 0.11f); + + ParticleSystem.ShapeModule shape = l.ps.shape; + shape.scale = new Vector3(BoxSize, 5f, BoxSize); + shape.position = new Vector3(-6f, 1.5f, 0f); // 지면(초점 아래 약 1m)에서 4m 위까지. 바람이 불어오는 쪽(−x)으로 치우쳐 시야에 오래 머문다 + + SetVelocity(l.ps, new Vector2(9f, 13f), new Vector2(-0.4f, 0.8f), new Vector2(3f, 5f)); + SetNoise(l.ps, 1.2f, 0.5f, 0.3f); + + ParticleSystemRenderer r = l.go.GetComponent(); + r.renderMode = ParticleSystemRenderMode.Stretch; + r.lengthScale = 2f; + r.velocityScale = 0.12f; + r.cameraVelocityScale = 0f; + r.maxParticleSize = 0.02f; + + l.dayA = new Color(0.95f, 0.92f, 0.80f, 0.30f); // 먼지 + l.dayB = new Color(0.62f, 0.72f, 0.34f, 0.65f); // 잎 + l.nightA = new Color(0.50f, 0.55f, 0.70f, 0.25f); + l.nightB = new Color(0.30f, 0.40f, 0.30f, 0.45f); + return l; + } + } +} diff --git a/Assets/Scripts/Core/WeatherEffects.cs.meta b/Assets/Scripts/Core/WeatherEffects.cs.meta new file mode 100644 index 0000000..bc33df6 --- /dev/null +++ b/Assets/Scripts/Core/WeatherEffects.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f805da7222c41904ea00b51882863a0d \ No newline at end of file diff --git a/Assets/Scripts/Core/WeatherForecast.cs b/Assets/Scripts/Core/WeatherForecast.cs new file mode 100644 index 0000000..36da6ce --- /dev/null +++ b/Assets/Scripts/Core/WeatherForecast.cs @@ -0,0 +1,141 @@ +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 날씨 예보의 순수 규칙 — 다음 날씨·지속 시간·지역 기후. 은 이 답을 시계에 옮길 뿐이다. + /// 난수는 밖에서 받아(roll01) 테스트가 분포를 고정한다. + /// + /// 날씨는 세계에 하나지만 지역마다 다르게 보인다(). 설산(frostline·mountain)에서는 비가 + /// 눈이 되고, 사막·화산(dunes·emberfall)에서는 눈이 센바람이 된다. 스폰·조명·전투가 지역 기준 날씨를 읽는다. + /// + public static class WeatherForecast + { + // ── 등급표 — 합이 1이 아니어도 된다(정규화) ── + public const float ClearShare = 0.46f; + public const float RainShare = 0.20f; + public const float FogShare = 0.13f; + public const float WindShare = 0.09f; + public const float SnowShare = 0.12f; + + /// 게임을 켠 뒤 처음 이 시간(초)은 맑다 — 튜토리얼 첫 장면이 비·안개에 묻히지 않게. + public const float FirstClearSeconds = 180f; + + /// 날씨가 바뀔 때 하늘·안개·입자가 옮겨 가는 시간(초). + public const float TransitionSeconds = 10f; + + /// 맑은 날씨의 지속(초, 무작위). 게임 하루가 12분이라 하루에 두세 번 바뀐다. + public const float ClearDurationMin = 150f; + public const float ClearDurationMax = 330f; + + /// 궂은 날씨의 지속(초, 무작위) — 맑음보다 짧다. + public const float BadDurationMin = 90f; + public const float BadDurationMax = 200f; + + /// 궂은 날씨 다음에 맑음이 나올 몫을 키우는 배수 — 궂은 날이 줄줄이 이어지지 않게. + public const float ClearAfterBadBoost = 1.5f; + + internal const int WeatherCount = 5; + + internal static float Share(WeatherType weather) + { + switch (weather) + { + case WeatherType.Clear: return ClearShare; + case WeatherType.Rain: return RainShare; + case WeatherType.Fog: return FogShare; + case WeatherType.Wind: return WindShare; + case WeatherType.Snow: return SnowShare; + default: return 0f; + } + } + + /// + /// 다음 날씨. 같은 날씨가 연달아 나오지 않는다(현재를 뺀 표에서 굴린다). 궂은 날씨 뒤에는 맑음의 몫이 커진다. + /// + public static WeatherType Next(WeatherType current, float roll01) + { + float total = 0f; + for (int i = 0; i < WeatherCount; i++) total += NextShare(current, (WeatherType)i); + if (total <= 0f) return WeatherType.Clear; + + float target = Mathf.Clamp01(roll01) * total; + float acc = 0f; + WeatherType last = WeatherType.Clear; + for (int i = 0; i < WeatherCount; i++) + { + WeatherType w = (WeatherType)i; + float s = NextShare(current, w); + if (s <= 0f) continue; + last = w; + acc += s; + if (target < acc) return w; + } + return last; + } + + private static float NextShare(WeatherType current, WeatherType candidate) + { + if (candidate == current) return 0f; + float s = Share(candidate); + if (candidate == WeatherType.Clear && current != WeatherType.Clear) s *= ClearAfterBadBoost; + return s; + } + + /// 이 날씨가 지속되는 시간(초). + public static float RollDuration(WeatherType weather, float roll01) + { + float t = Mathf.Clamp01(roll01); + return weather == WeatherType.Clear + ? Mathf.Lerp(ClearDurationMin, ClearDurationMax, t) + : Mathf.Lerp(BadDurationMin, BadDurationMax, t); + } + + // ── 지역 기후 ── + + /// 추운 지역 — 비가 눈이 된다. + public static bool IsColdRegion(string regionId) + => regionId == "frostline" || regionId == "mountain"; + + /// 더운 지역 — 눈이 오지 않는다(센바람으로 바뀐다). + public static bool IsWarmRegion(string regionId) + => regionId == "dunes" || regionId == "emberfall"; + + /// + /// 이 지역에서 실제로 보이는 날씨. 지역을 모르면(null·길 위) 세계 날씨 그대로다. + /// 추운 곳 비 → 눈, 더운 곳 눈 → 센바람. 맑음·안개·바람은 어디서나 그대로다. + /// + public static WeatherType EffectiveIn(WeatherType global, string regionId) + { + if (string.IsNullOrEmpty(regionId)) return global; + if (global == WeatherType.Rain && IsColdRegion(regionId)) return WeatherType.Snow; + if (global == WeatherType.Snow && IsWarmRegion(regionId)) return WeatherType.Wind; + return global; + } + + /// 화면에 쓰는 이름. + public static string DisplayName(WeatherType weather) + { + switch (weather) + { + case WeatherType.Rain: return "비"; + case WeatherType.Fog: return "안개"; + case WeatherType.Wind: return "센바람"; + case WeatherType.Snow: return "눈"; + default: return "맑음"; + } + } + + /// 화면에 쓰는 시간대 이름. + public static string DisplayName(DayPhase phase) + { + switch (phase) + { + case DayPhase.Morning: return "아침"; + case DayPhase.Day: return "낮"; + case DayPhase.Evening: return "저녁"; + default: return "밤"; + } + } + } +} diff --git a/Assets/Scripts/Core/WeatherForecast.cs.meta b/Assets/Scripts/Core/WeatherForecast.cs.meta new file mode 100644 index 0000000..8e3647f --- /dev/null +++ b/Assets/Scripts/Core/WeatherForecast.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f91af98ef9ddafe4cbde6e732ed30b63 \ No newline at end of file diff --git a/Assets/Scripts/Core/WeatherSystem.cs b/Assets/Scripts/Core/WeatherSystem.cs index b744136..f584502 100644 --- a/Assets/Scripts/Core/WeatherSystem.cs +++ b/Assets/Scripts/Core/WeatherSystem.cs @@ -1,3 +1,4 @@ +using System; using UnityEngine; namespace InsectGame.Core @@ -7,18 +8,82 @@ public enum WeatherType Clear, Rain, Fog, - Wind + Wind, + Snow // 뒤에 덧붙였다 — 직렬화된 값(InsectSpawnCondition.allowedWeather)이 밀리지 않게 } + /// + /// 세계 날씨 — 시간이 흐르면 가 정한 대로 스스로 바뀐다. + /// 규칙(다음 날씨·지속·지역 기후)은 순수 클래스에 있고 여기는 시계·전환 진행만 든다. + /// + /// 전환은 로 읽는다 — 0이면 , 1이면 . + /// 하늘·안개·입자가 이 값으로 부드럽게 옮겨 간다. 자체는 바뀌는 순간 곧바로 새 값이다 + /// (스폰·전투 보정은 전환을 기다리지 않는다). + /// + /// 저장하지 않는다 — 로드하면 처음 몇 분은 맑음이다(). + /// public class WeatherSystem : MonoBehaviour { [SerializeField] private WeatherType currentWeather = WeatherType.Clear; + [SerializeField] private bool autoCycle = true; + + private WeatherType previousWeather = WeatherType.Clear; + private float blend01 = 1f; + private float secondsLeft = WeatherForecast.FirstClearSeconds; + private bool held; // SetWeather(hold: true) — 검수·꿈·연출이 날씨를 붙잡아 둔다 public WeatherType CurrentWeather => currentWeather; + public WeatherType PreviousWeather => previousWeather; + + /// 전환 진행 — 0이면 이전 날씨, 1이면 지금 날씨. + public float Blend01 => blend01; + + /// 날씨가 바뀌었다 (이전, 새 날씨). + public event Action WeatherChanged; + + private void Update() + { + if (blend01 < 1f) + blend01 = Mathf.Min(1f, blend01 + Time.deltaTime / WeatherForecast.TransitionSeconds); + + if (!autoCycle || held) return; + secondsLeft -= Time.deltaTime; + if (secondsLeft > 0f) return; + + WeatherType next = WeatherForecast.Next(currentWeather, UnityEngine.Random.value); + Apply(next, WeatherForecast.RollDuration(next, UnityEngine.Random.value), instant: false); + } + + /// + /// 날씨를 바로 바꾼다. 가 참이면 를 부를 때까지 스스로 바뀌지 않는다 + /// (검수 캡처·꿈 프롤로그가 쓴다). 같은 날씨여도 붙잡기만 갱신한다. + /// + public void SetWeather(WeatherType weather, bool hold = false, bool instant = false) + { + held = hold; + if (weather == currentWeather) + { + if (instant) blend01 = 1f; + return; + } + Apply(weather, WeatherForecast.RollDuration(weather, UnityEngine.Random.value), instant); + } + + /// 붙잡은 날씨를 풀고 다시 스스로 바뀌게 한다. + public void Release() + { + held = false; + secondsLeft = Mathf.Max(secondsLeft, 30f); + } - public void SetWeather(WeatherType weather) + private void Apply(WeatherType weather, float duration, bool instant) { + WeatherType old = currentWeather; + previousWeather = old; currentWeather = weather; + secondsLeft = duration; + blend01 = instant ? 1f : 0f; + WeatherChanged?.Invoke(old, weather); } } } diff --git a/Assets/Scripts/Core/WorldBackdropBuilder.cs b/Assets/Scripts/Core/WorldBackdropBuilder.cs new file mode 100644 index 0000000..cbb2ce8 --- /dev/null +++ b/Assets/Scripts/Core/WorldBackdropBuilder.cs @@ -0,0 +1,401 @@ +using System.Collections.Generic; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 원경 — 월드 둘레의 산맥과 하늘의 뭉게구름. + /// + /// 옛 원경은 월드를 1.5배로 넓히기 전 좌표에 박혀 있었다. 「먼 산」 네 개(지름 60~90m, + /// 높이 13~20m 납작 구)가 넓힌 뒤엔 꽃밭·유적·연못 바로 옆 필드 안쪽에 떨어져, 게임 카메라에 + /// 올리브색 돔으로 화면 절반을 덮었다(배치 캡처로 확인). 구름도 납작한 구 하나씩이라 회색 원반으로 보였다. + /// + /// 그래서 좌표를 적지 않는다 — **리전 원과 길()을 피해 바깥으로 훑어 나가며 + /// 처음 비는 자리**에 산을 세운다. 리전을 늘리거나 옮겨도 산맥이 필드를 덮지 않는다. + /// 산의 모양·색은 가장 가까운 리전을 따른다(서릿길 옆은 눈 덮인 봉우리, 모래언덕 옆은 사암 대지…). + /// + /// 콜라이더는 없다. 전부 테두리 울타리 바깥이라 닿을 일이 없고, 있으면 카메라 차폐 검사만 무거워진다. + /// 머티리얼별로 메시를 합쳐 산맥 전체가 드로우콜 몇 번으로 끝난다(모바일). + /// 이름을 Scenery_로 시작하는 건 서브에리어 진입 때 SubAreaWorldBuilder.HideMainWorld가 + /// 접두어로 메인 월드를 끄기 때문이다. + /// + public class WorldBackdropBuilder : MonoBehaviour + { + /// 배치 난수 시드 — 원경이 실행마다 같게. 전역 난수 상태는 끝나면 되돌린다. + private const int LayoutSeed = 20260928; + /// 경계 벽(±520) 안쪽. 산 중심이 이 안에 있어야 벽 앞을 가린다. + private const float BoundaryHalf = 500f; + /// 리전 원 가장자리와 산 기슭 사이 최소 여백(m). + private const float RegionClearance = 26f; + private const int Directions = 44; + + private enum Style { Dome, Peak, Mesa } + + private struct Theme + { + public Style style; + public Color body; + public Color cap; + public float capFrom; // 0~1 — 이 높이 비율부터 위를 cap 색으로 덮는다(1이면 cap 없음) + public Vector2 height; // m + public Vector2 radius; // m + public bool capGlow; // 잿불 분화구처럼 은은히 빛나는 cap + } + + private readonly List materials = new List(); + private readonly List meshes = new List(); + + public void Build(RegionData[] regions) + { + if (regions == null || regions.Length == 0) return; + Random.State prev = Random.state; + Random.InitState(LayoutSeed); + try + { + BuildRanges(regions); + BuildClouds(regions); + } + finally + { + Random.state = prev; + } + } + + // ======= 산맥 ======= + + private void BuildRanges(RegionData[] regions) + { + Vector3 origin = Vector3.zero; + foreach (RegionData r in regions) origin += r.centerPosition; + origin /= regions.Length; + origin.y = 0f; + + // 색 + 표면(발광)으로 묶는다 — 색만 키로 쓰면 어느 리전의 빛나는 cap과 같은 색인 다른 리전의 몸체가 + // 한 메시로 합쳐져 함께 빛난다(SceneryMaterialKey 주석). + var batches = new Dictionary>(); + var placed = new List(); // xyz = 위치, w = 반경 — 산끼리 너무 겹치지 않게 + + for (int i = 0; i < Directions; i++) + { + float angle = (i + Random.Range(-0.3f, 0.3f)) * Mathf.PI * 2f / Directions; + Vector3 dir = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)); + + // 앞줄(낮고 가까움) + 뒷줄(높고 멂) — 겹쳐야 산'맥'으로 읽힌다 + for (int row = 0; row < 2; row++) + { + float start = row == 0 ? 180f : 0f; + if (row == 1) + { + Vector4 last = placed.Count > 0 ? placed[placed.Count - 1] : Vector4.zero; + if (placed.Count == 0 || Vector3.Dot(((Vector3)last - origin).normalized, dir) < 0.95f) break; + start = ((Vector3)last - origin).magnitude + last.w * 0.9f; + } + + if (!TryPlace(regions, origin, dir, start, placed, out Vector3 pos, out Theme theme, row)) + break; + + float radius = Random.Range(theme.radius.x, theme.radius.y); + float height = Random.Range(theme.height.x, theme.height.y) * (row == 1 ? 1.35f : 1f); + placed.Add(new Vector4(pos.x, 0f, pos.z, radius)); + + int seed = Random.Range(int.MinValue, int.MaxValue); + Mesh body = MountainMesh(theme.style, radius, height, seed, 0f, theme.capFrom); + Add(batches, SceneryMaterialKey.Matte(theme.body), body, pos); + if (theme.capFrom < 0.999f) + { + Mesh cap = MountainMesh(theme.style, radius, height, seed, theme.capFrom, 1f); + Add(batches, theme.capGlow ? SceneryMaterialKey.Glowing(theme.cap, theme.cap * 0.55f) + : SceneryMaterialKey.Matte(theme.cap), cap, pos); + } + } + } + + Transform root = new GameObject("Scenery_BackdropRanges").transform; + root.SetParent(transform, false); + int n = 0; + foreach (var kv in batches) + { + var combined = new Mesh { name = "BackdropRange", indexFormat = UnityEngine.Rendering.IndexFormat.UInt32 }; + combined.CombineMeshes(kv.Value.ToArray(), true, true); + combined.RecalculateBounds(); + combined.UploadMeshData(true); // 정적 원경 — CPU 사본 불필요 + meshes.Add(combined); + foreach (CombineInstance ci in kv.Value) Destroy(ci.mesh); + + Material mat = SceneryMaterials.Create(kv.Key.Albedo); + if (kv.Key.Glow) SceneryMaterials.SetEmission(mat, kv.Key.Emission); + materials.Add(mat); + + // 칸에는 Scenery_ 접두어를 달지 않는다 — HideMainWorld가 루트만 끄면 되고, 칸까지 걸리면 진입마다 전수로 끈다 + var go = new GameObject($"BackdropRange_{n++}"); + go.transform.SetParent(root, false); + go.AddComponent().sharedMesh = combined; + var mr = go.AddComponent(); + mr.sharedMaterial = mat; + mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; // 원경 그림자는 비용만 든다 + mr.receiveShadows = false; + } + } + + /// + /// dir 방향으로 start부터 바깥으로 훑어 리전·길·다른 산과 겹치지 않는 첫 자리를 찾는다. + /// + private static bool TryPlace(RegionData[] regions, Vector3 origin, Vector3 dir, float start, + List placed, out Vector3 pos, out Theme theme, int row) + { + pos = Vector3.zero; + theme = default; + for (float d = start; d < 760f; d += 10f) + { + Vector3 p = origin + dir * d; + if (Mathf.Abs(p.x) > BoundaryHalf || Mathf.Abs(p.z) > BoundaryHalf) return false; + + RegionData near = NearestRegion(regions, p, out float edgeDist); + if (near == null) return false; + theme = ThemeFor(near.regionId); + float foot = theme.radius.y * (row == 1 ? 1.1f : 0.9f); + if (edgeDist < foot + RegionClearance) continue; + if (WorldRouteLayout.IsOnRoute(regions, p, foot)) continue; + + bool crowded = false; + foreach (Vector4 q in placed) + { + float gap = Vector2.Distance(new Vector2(p.x, p.z), new Vector2(q.x, q.z)); + if (gap < (q.w + foot) * 0.55f) { crowded = true; break; } + } + if (crowded) continue; + + pos = new Vector3(p.x, -0.6f, p.z); // 기슭을 살짝 묻어 떠 보이지 않게 + return true; + } + return false; + } + + private static RegionData NearestRegion(RegionData[] regions, Vector3 p, out float edgeDistance) + { + RegionData best = null; + edgeDistance = float.MaxValue; + foreach (RegionData r in regions) + { + if (r == null) continue; + Vector3 c = r.centerPosition; + float d = Vector2.Distance(new Vector2(p.x, p.z), new Vector2(c.x, c.z)) - r.radius; + if (d < edgeDistance) { edgeDistance = d; best = r; } + } + return best; + } + + private static Theme ThemeFor(string regionId) + { + switch (regionId) + { + case "frostline": + return new Theme { style = Style.Peak, body = new Color(0.52f, 0.58f, 0.66f), cap = new Color(0.93f, 0.95f, 0.98f), + capFrom = 0.38f, height = new Vector2(48f, 72f), radius = new Vector2(30f, 44f) }; + case "mountain": + return new Theme { style = Style.Peak, body = new Color(0.44f, 0.42f, 0.40f), cap = new Color(0.92f, 0.93f, 0.95f), + capFrom = 0.66f, height = new Vector2(44f, 66f), radius = new Vector2(30f, 44f) }; + case "emberfall": + return new Theme { style = Style.Peak, body = new Color(0.22f, 0.19f, 0.19f), cap = new Color(0.62f, 0.24f, 0.12f), + capFrom = 0.84f, height = new Vector2(40f, 58f), radius = new Vector2(30f, 42f), capGlow = true }; + case "dunes": + return new Theme { style = Style.Mesa, body = new Color(0.80f, 0.62f, 0.42f), cap = new Color(0.70f, 0.50f, 0.33f), + capFrom = 0.72f, height = new Vector2(20f, 32f), radius = new Vector2(30f, 44f) }; + case "ruins": + return new Theme { style = Style.Mesa, body = new Color(0.55f, 0.50f, 0.43f), cap = new Color(0.40f, 0.48f, 0.30f), + capFrom = 0.9f, height = new Vector2(24f, 36f), radius = new Vector2(30f, 42f) }; + case "forest": + case "canopy": + return new Theme { style = Style.Dome, body = new Color(0.24f, 0.40f, 0.20f), cap = new Color(0.16f, 0.32f, 0.15f), + capFrom = 0.45f, height = new Vector2(26f, 40f), radius = new Vector2(32f, 46f) }; + case "swamp": + return new Theme { style = Style.Dome, body = new Color(0.30f, 0.34f, 0.22f), cap = new Color(0.23f, 0.29f, 0.18f), + capFrom = 0.55f, height = new Vector2(16f, 24f), radius = new Vector2(30f, 42f) }; + case "hollow": + return new Theme { style = Style.Dome, body = new Color(0.56f, 0.55f, 0.48f), cap = new Color(0.50f, 0.49f, 0.43f), + capFrom = 0.6f, height = new Vector2(16f, 26f), radius = new Vector2(30f, 42f) }; + case "nameless": + return new Theme { style = Style.Peak, body = new Color(0.37f, 0.35f, 0.41f), cap = new Color(0.30f, 0.29f, 0.34f), + capFrom = 0.7f, height = new Vector2(34f, 52f), radius = new Vector2(28f, 40f) }; + default: // meadow · pond · garden — 완만한 초록 구릉 + return new Theme { style = Style.Dome, body = new Color(0.38f, 0.54f, 0.29f), cap = new Color(0.30f, 0.46f, 0.23f), + capFrom = 0.55f, height = new Vector2(18f, 28f), radius = new Vector2(32f, 46f) }; + } + } + + private static void Add(Dictionary> batches, SceneryMaterialKey key, Mesh mesh, Vector3 pos) + { + if (!batches.TryGetValue(key, out List list)) + { + list = new List(); + batches[key] = list; + } + list.Add(new CombineInstance + { + mesh = mesh, + transform = Matrix4x4.TRS(pos, Quaternion.Euler(0f, Random.Range(0f, 360f), 0f), Vector3.one), + }); + } + + /// + /// 각진(flat-shaded) 저폴리 봉우리. 같은 seed로 ~ 높이 + /// 구간만 뽑으면 몸체와 cap(눈·숲·분화구)이 같은 윤곽을 공유한다 — cap은 바깥으로 3% 부풀려 몸체를 덮는다. + /// + private static Mesh MountainMesh(Style style, float radius, float height, int seed, float from, float to) + { + var rng = new System.Random(seed); + float[] levels; + float[] radii; + switch (style) + { + case Style.Mesa: + levels = new[] { 0f, 0.28f, 0.62f, 0.72f, 0.96f }; + radii = new[] { 1f, 0.86f, 0.74f, 0.70f, 0.64f }; + break; + case Style.Dome: + levels = new[] { 0f, 0.28f, 0.55f, 0.8f, 0.95f }; + radii = new[] { 1f, 0.9f, 0.7f, 0.44f, 0.2f }; + break; + default: + levels = new[] { 0f, 0.3f, 0.58f, 0.8f, 0.93f }; + radii = new[] { 1f, 0.68f, 0.4f, 0.2f, 0.08f }; + break; + } + + const int segments = 11; + int ringCount = levels.Length; + var rings = new Vector3[ringCount][]; + float jag = style == Style.Peak ? 0.16f : 0.08f; + for (int k = 0; k < ringCount; k++) + { + rings[k] = new Vector3[segments]; + for (int s = 0; s < segments; s++) + { + float a = (s + (float)(rng.NextDouble() - 0.5) * 0.35f) * Mathf.PI * 2f / segments; + float rr = radius * radii[k] * (1f + (float)(rng.NextDouble() - 0.5) * 2f * jag); + float y = height * levels[k] * (1f + (k == 0 ? 0f : (float)(rng.NextDouble() - 0.5) * 0.08f)); + rings[k][s] = new Vector3(Mathf.Cos(a) * rr, y, Mathf.Sin(a) * rr); + } + } + Vector3 apex = style == Style.Mesa + ? new Vector3(0f, height * 0.97f, 0f) + : new Vector3((float)(rng.NextDouble() - 0.5) * radius * 0.15f, height, (float)(rng.NextDouble() - 0.5) * radius * 0.15f); + + bool isCap = from > 0.001f; + float inflate = isCap ? 1.03f : 1f; + var verts = new List(); + var tris = new List(); + + void Tri(Vector3 a, Vector3 b, Vector3 c) + { + int i0 = verts.Count; + verts.Add(Inflate(a, inflate, isCap)); + verts.Add(Inflate(b, inflate, isCap)); + verts.Add(Inflate(c, inflate, isCap)); + tris.Add(i0); tris.Add(i0 + 1); tris.Add(i0 + 2); + } + + for (int k = 0; k < ringCount - 1; k++) + { + float mid = (levels[k] + levels[k + 1]) * 0.5f; + if (mid < from || mid > to) continue; + for (int s = 0; s < segments; s++) + { + int n = (s + 1) % segments; + Vector3 a = rings[k][s], b = rings[k][n], c = rings[k + 1][s], d = rings[k + 1][n]; + Tri(a, c, b); + Tri(b, c, d); + } + } + float topMid = (levels[ringCount - 1] + 1f) * 0.5f; + if (topMid >= from && topMid <= to) + { + for (int s = 0; s < segments; s++) + { + int n = (s + 1) % segments; + Tri(rings[ringCount - 1][s], apex, rings[ringCount - 1][n]); + } + } + + var mesh = new Mesh { name = isCap ? "BackdropCap" : "BackdropBody" }; + mesh.SetVertices(verts); + mesh.SetTriangles(tris, 0); + mesh.RecalculateNormals(); + mesh.RecalculateBounds(); + return mesh; + } + + private static Vector3 Inflate(Vector3 v, float k, bool lift) + { + return new Vector3(v.x * k, v.y + (lift ? 0.25f : 0f), v.z * k); + } + + // ======= 구름 ======= + + /// + /// 뭉게구름 — 크기가 다른 구 5~8개를 겹친 무리. 아랫면이 환경광만 받아 회색으로 칙칙해지지 않게 + /// 은은한 자체 발광을 준다. 전부 한 메시로 합쳐 드로우콜 1번. + /// + private void BuildClouds(RegionData[] regions) + { + Vector3 min = new Vector3(float.MaxValue, 0f, float.MaxValue); + Vector3 max = new Vector3(float.MinValue, 0f, float.MinValue); + foreach (RegionData r in regions) + { + min = Vector3.Min(min, r.centerPosition - new Vector3(r.radius, 0f, r.radius)); + max = Vector3.Max(max, r.centerPosition + new Vector3(r.radius, 0f, r.radius)); + } + + Mesh puff = ProcMeshLibrary.LowSphere(0.5f, 0.5f, 0.5f, 7, 12); + var parts = new List(); + const int clusters = 16; + for (int i = 0; i < clusters; i++) + { + Vector3 c = new Vector3(Random.Range(min.x - 60f, max.x + 60f), Random.Range(62f, 84f), + Random.Range(min.z - 60f, max.z + 60f)); + float size = Random.Range(7f, 12f); + int puffs = Random.Range(5, 9); + float yaw = Random.Range(0f, 360f); + Quaternion rot = Quaternion.Euler(0f, yaw, 0f); + for (int j = 0; j < puffs; j++) + { + float t = puffs <= 1 ? 0f : (float)j / (puffs - 1) - 0.5f; + float s = size * Random.Range(0.6f, 1.15f) * (1f - Mathf.Abs(t) * 0.7f); + Vector3 off = rot * new Vector3(t * size * 3.2f, Random.Range(0f, 0.25f) * s, Random.Range(-0.35f, 0.35f) * size); + parts.Add(new CombineInstance + { + mesh = puff, + transform = Matrix4x4.TRS(c + off, rot, new Vector3(s * 1.5f, s * 0.8f, s * 1.2f)), + }); + } + } + + var combined = new Mesh { name = "BackdropClouds", indexFormat = UnityEngine.Rendering.IndexFormat.UInt32 }; + combined.CombineMeshes(parts.ToArray(), true, true); + combined.RecalculateBounds(); + combined.UploadMeshData(true); + meshes.Add(combined); // puff는 ProcMeshLibrary의 프로세스 캐시 — 파괴 금지 + + Material mat = SceneryMaterials.Create(new Color(0.97f, 0.98f, 1f)); + SceneryMaterials.SetEmission(mat, new Color(0.42f, 0.44f, 0.48f)); + materials.Add(mat); + + var go = new GameObject("Scenery_BackdropClouds"); + go.transform.SetParent(transform, false); + go.AddComponent().sharedMesh = combined; + var mr = go.AddComponent(); + mr.sharedMaterial = mat; + mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + mr.receiveShadows = false; + } + + private void OnDestroy() + { + foreach (Material m in materials) if (m != null) Destroy(m); + foreach (Mesh m in meshes) if (m != null) Destroy(m); + materials.Clear(); + meshes.Clear(); + } + } +} diff --git a/Assets/Scripts/Core/WorldBackdropBuilder.cs.meta b/Assets/Scripts/Core/WorldBackdropBuilder.cs.meta new file mode 100644 index 0000000..fab18a7 --- /dev/null +++ b/Assets/Scripts/Core/WorldBackdropBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8a00c68b5c66a6945bcf56036c192609 \ No newline at end of file diff --git a/Assets/Scripts/Core/WorldInteractionTypes.cs b/Assets/Scripts/Core/WorldInteractionTypes.cs index c5720b9..46f492b 100644 --- a/Assets/Scripts/Core/WorldInteractionTypes.cs +++ b/Assets/Scripts/Core/WorldInteractionTypes.cs @@ -9,7 +9,9 @@ public enum InteractionKind ItemShop, Training, Gacha, - Hospital + Hospital, + /// 본 마을 나루터 — 섬 나들목 창(내 섬 가기·방문)을 연다. + IslandDock } /// diff --git a/Assets/Scripts/Core/WorldRouteLayout.cs b/Assets/Scripts/Core/WorldRouteLayout.cs new file mode 100644 index 0000000..ce8e481 --- /dev/null +++ b/Assets/Scripts/Core/WorldRouteLayout.cs @@ -0,0 +1,181 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Core +{ + public readonly struct WorldRouteEdge + { + public string FromRegionId { get; } + public string ToRegionId { get; } + public WorldRouteEdge(string from, string to) { FromRegionId = from; ToRegionId = to; } + } + + /// 필드와 지도가 공유하는 길. 잠긴 지역을 관통하지 않고 각 지역의 한 입구를 공유한다. + public static class WorldRouteLayout + { + public const float RoadWidth = 2.5f; + public const float CorridorClearance = 2f; + private const float OuterOffset = 4f; + private const int RingSamples = 32; + public static IReadOnlyList FieldConnections { get; } = new[] { + new WorldRouteEdge("meadow", "pond"), new WorldRouteEdge("meadow", "swamp"), + new WorldRouteEdge("meadow", "garden"), new WorldRouteEdge("pond", "forest"), + new WorldRouteEdge("forest", "swamp"), new WorldRouteEdge("swamp", "mountain"), + new WorldRouteEdge("mountain", "ruins"), new WorldRouteEdge("ruins", "hollow"), + new WorldRouteEdge("hollow", "dunes"), new WorldRouteEdge("dunes", "frostline"), + new WorldRouteEdge("frostline", "emberfall"), new WorldRouteEdge("emberfall", "canopy"), + new WorldRouteEdge("canopy", "nameless") }; + + public static RegionData Find(RegionData[] regions, string id) + { + if (regions != null) foreach (RegionData region in regions) + if (region != null && region.regionId == id) return region; + return null; + } + + public static Vector3 GetGateway(RegionData region, RegionData[] regions) + { + Vector3 direction = Vector3.right; + float closest = float.MaxValue; + foreach (RegionData other in regions) + { + if (other == null || other == region) continue; + Vector3 delta = other.centerPosition - region.centerPosition; + delta.y = 0f; + if (delta.sqrMagnitude < closest) { closest = delta.sqrMagnitude; direction = delta.normalized; } + } + if (region.connections != null && region.connections.Length > 0 && region.connections[0] != null) + { + float angle = region.connections[0].gatewayAngle * Mathf.Deg2Rad; + direction = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)); + } + // 가장 가까운 방향부터 좌우로 조사한다. 겹치는 지역의 잠금 안으로 입구를 내지 않는다. + Vector3 preferred = direction; + for (int step = 0; step <= 72; step++) + { + float degrees = ((step + 1) / 2) * 5f * (step % 2 == 0 ? -1f : 1f); + direction = Quaternion.Euler(0f, degrees, 0f) * preferred; + Vector3 outer = region.centerPosition + direction * (region.radius + OuterOffset); + if (Clear(region.centerPosition, outer, regions, region) && + (region.regionId != "meadow" || DistanceToSegment( + VillageBuilder.GetMainVillageCenter(region.centerPosition, region.radius), + region.centerPosition, outer) > VillageBuilder.MainVillageFootprintRadius + CorridorClearance)) + return region.centerPosition + direction * (region.radius - 1f); + } + return region.centerPosition + preferred * (region.radius - 1f); + } + + private sealed class RouteCache + { + public int Stamp; + public readonly Dictionary Routes = new Dictionary(); + } + private static readonly ConditionalWeakTable routeCaches = + new ConditionalWeakTable(); + + public static Vector3[] BuildRoute(RegionData[] regions, string fromId, string toId) + { + if (regions == null) return new Vector3[0]; + int stamp = 17; + unchecked + { + foreach (RegionData region in regions) + { + if (region == null) continue; + stamp = stamp * 31 + region.centerPosition.GetHashCode(); + stamp = stamp * 31 + region.radius.GetHashCode(); + if (region.connections != null) foreach (RegionConnection connection in region.connections) + if (connection != null) stamp = stamp * 31 + connection.gatewayAngle.GetHashCode(); + } + } + RouteCache cache = routeCaches.GetValue(regions, _ => new RouteCache()); + if (cache.Stamp != stamp) { cache.Stamp = stamp; cache.Routes.Clear(); } + string key = fromId + ":" + toId; + if (!cache.Routes.TryGetValue(key, out Vector3[] route)) + { + route = CalculateRoute(regions, fromId, toId); + cache.Routes[key] = route; + } + return route; + } + + private static Vector3[] CalculateRoute(RegionData[] regions, string fromId, string toId) + { + RegionData from = Find(regions, fromId), to = Find(regions, toId); + if (from == null || to == null) return new Vector3[0]; + Vector3 gateA = GetGateway(from, regions), gateB = GetGateway(to, regions); + Vector3 outerA = from.centerPosition + (gateA - from.centerPosition).normalized * (from.radius + OuterOffset); + Vector3 outerB = to.centerPosition + (gateB - to.centerPosition).normalized * (to.radius + OuterOffset); + var nodes = new List { outerA, outerB }; + foreach (RegionData region in regions) + { + if (region == null) continue; + for (int i = 0; i < RingSamples; i++) + { + float angle = i * Mathf.PI * 2f / RingSamples; + Vector3 point = region.centerPosition + new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * (region.radius + OuterOffset); + if (Clear(point, point, regions, null)) nodes.Add(point); + } + } + int count = nodes.Count; + var distance = new float[count]; var previous = new int[count]; var visited = new bool[count]; + for (int i = 0; i < count; i++) { distance[i] = float.MaxValue; previous[i] = -1; } + distance[0] = 0f; + for (int step = 0; step < count; step++) + { + int best = -1; + for (int i = 0; i < count; i++) + if (!visited[i] && (best < 0 || distance[i] < distance[best])) best = i; + if (best < 0 || distance[best] == float.MaxValue) break; + if (best == 1) break; + visited[best] = true; + for (int next = 0; next < count; next++) + { + if (visited[next]) continue; + float candidate = distance[best] + Vector3.Distance(nodes[best], nodes[next]); + if (candidate >= distance[next] || !Clear(nodes[best], nodes[next], regions, null)) continue; + distance[next] = candidate; previous[next] = best; + } + } + if (previous[1] < 0) return new Vector3[0]; // 막힌 직선을 그리는 대신 검증에서 누락을 드러낸다. + var exterior = new List(); + for (int node = 1; node >= 0; node = previous[node]) exterior.Add(nodes[node]); + exterior.Reverse(); + var route = new List { from.centerPosition, gateA }; + route.AddRange(exterior); route.Add(gateB); route.Add(to.centerPosition); + return route.ToArray(); + } + + public static bool IsOnRoute(RegionData[] regions, Vector3 point, float objectRadius = 0f) + { + foreach (WorldRouteEdge edge in FieldConnections) + { + Vector3[] route = BuildRoute(regions, edge.FromRegionId, edge.ToRegionId); + for (int i = 1; i < route.Length; i++) + if (DistanceToSegment(point, route[i - 1], route[i]) < RoadWidth * 0.5f + objectRadius + 0.5f) + return true; + } + RegionData meadow = Find(regions, "meadow"); + return meadow != null && DistanceToSegment(point, meadow.centerPosition, + PlayerStartPlacement.ResolveMainVillageEntrance(regions).Position) < RoadWidth * 0.5f + objectRadius + 0.5f; + } + + public static float DistanceToSegment(Vector3 point, Vector3 from, Vector3 to) + { + point.y = from.y = to.y = 0f; + Vector3 delta = to - from; + float t = delta.sqrMagnitude > 0.0001f ? Mathf.Clamp01(Vector3.Dot(point - from, delta) / delta.sqrMagnitude) : 0f; + return Vector3.Distance(point, from + delta * t); + } + + private static bool Clear(Vector3 from, Vector3 to, RegionData[] regions, RegionData ignored) + { + foreach (RegionData region in regions) + if (region != null && region != ignored && + DistanceToSegment(region.centerPosition, from, to) < region.radius + CorridorClearance) return false; + return true; + } + } +} diff --git a/Assets/Scripts/Core/WorldRouteLayout.cs.meta b/Assets/Scripts/Core/WorldRouteLayout.cs.meta new file mode 100644 index 0000000..52441c5 --- /dev/null +++ b/Assets/Scripts/Core/WorldRouteLayout.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a1ce846bb0d05b649a51f0084969a7c6 \ No newline at end of file diff --git a/Assets/Scripts/Core/WorldSkyRules.cs b/Assets/Scripts/Core/WorldSkyRules.cs new file mode 100644 index 0000000..1144f4a --- /dev/null +++ b/Assets/Scripts/Core/WorldSkyRules.cs @@ -0,0 +1,573 @@ +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 낮·밤과 날씨가 하늘·햇빛·환경광·안개에 주는 순수 규칙(색 수학뿐 — 씬·시계·렌더 설정에 손대지 않는다). + /// 가 로 그 순간의 을 만들고 로 + /// 리전·섬의 "기본 상태"(보정 전) 위에 얹는다. 시계(GameClock)와 날씨(WeatherSystem)를 읽는 것도 그쪽이다 — + /// 여기는 숫자만 받으므로 테스트가 시각·날씨 조합을 전부 훑는다. + /// + /// 정오·맑음은 기본 상태를 그대로 돌려준다 — 이 규칙이 생기기 전의 한낮 모습(리전별 빛·연무)이 곧 기준이다. + /// 나머지 시각·날씨는 그 기준에서 곱하고 섞어 파생한다. 그래서 기본 상태가 바뀌어도(리전 대기 표) 낮·밤이 따라간다. + /// + /// 시간 곡선은 키프레임 표다() — 인접 키 사이를 smoothstep으로 이어서 시간대 경계에서 뚝 끊기지 않는다. + /// 일출 5~7시 · 정오 12시(최대) · 저녁노을 17~20시 · 해 질 녘 20~22시 · 밤 22~5시. 해는 6시에 뜨고 19시에 진다 + /// () — 게임 시간대(아침 6~11·낮 11~17·저녁 17~21·밤 21~6)와 겹쳐 읽히게 저녁을 길게 잡았다. + /// + /// 밤은 어둡되 보여야 한다. 밤에는 디렉셔널 라이트가 달빛(차갑고 푸른 약한 빛, )이 되고, + /// 환경광에 밝기 바닥( 등)을 건다 — 리전·날씨·시각이 어떤 조합이어도 바닥 아래로 + /// 내려가지 않는다(). 기준 수치(기본 필드, 평면 위쪽을 향한 면의 조도 = 환경광 + 빛×sin(고도)): + /// 정오 맑음 ≈ 1.43, 한밤 맑음 ≈ 0.62(44%), 한낮 비 ≈ 0.75, 한밤 비 ≈ 0.50. 이 게임은 감마 색공간이라 알베도 0.5짜리 풀밭이 + /// 한밤에 약 0.3(표시값 80/255)으로 그려진다 — 어두운 파랑이지만 곤충·캐릭터 윤곽이 읽히는 선이다. + /// 실기기 밝기가 어긋나면 와 세 바닥 상수만 만지면 된다. + /// + /// 새 게임은 6시(일출)에 시작한다(GameClock.startTime01 0.25) — 첫 장면이 이 곡선의 6시다. 그래서 일출 쪽은 해가 낮아도 + /// 바닥이 정오의 약 55%로 밝게 잡았다(). 맑은 첫 3분(WeatherForecast.FirstClearSeconds) 동안 + /// 일출 → 아침 → 정오가 지나간다. + /// + public static class WorldSkyRules + { + // ── 해·달 ── + + /// 달빛 세기(디렉셔널 라이트). 낮 기본 1.0~1.3의 약 1/3 — 그림자는 지되 화면을 지배하지 않는다. + public const float MoonIntensity = 0.45f; + + /// 달빛 방향의 고도(도). 높게 떠서 그림자가 짧고 캐릭터 발밑에 모인다. + public const float MoonPitch = 52f; + + /// 달빛의 방향 각(yaw)이 기본 햇빛과 벌어지는 정도(도) — 낮과 그림자 방향이 달라 밤으로 읽힌다. + public const float MoonYawOffset = 150f; + + /// + /// 해가 지평선 근처에서도 이보다 낮아지지 않는다(도). 더 낮으면 그림자가 한없이 늘어나고(그림자 지도 해상도가 낮은 모바일에서 뭉개진다) + /// 고각 카메라가 내려다보는 바닥이 어두워진다 — 14°에서는 첫 시작 시각인 6시의 위쪽 조도가 정오의 44%로 한밤 수준이었다. + /// 22°면 그림자 길이가 높이의 2.5배이고 6시 조도가 약 55%다. + /// + public const float SunMinPitch = 22f; + + /// 일출(−)에서 일몰(+)까지 햇빛 방향 각(yaw)이 기본에서 흔들리는 폭(도) — 그림자가 하루 동안 돈다. + public const float SunYawSwing = 80f; + + public const float SunriseHour = 6f; + public const float NoonHour = 12f; + public const float SunsetHour = 19f; + + public static readonly Color MoonColor = new Color(0.62f, 0.74f, 1f, 1f); + + // ── 환경광 밝기 바닥(색의 휘도) ── + // 평면 환경광(섬)은 사방이 같은 색이라 세 바닥이 모두 걸린다. 기본 필드의 낮 환경광은 위 0.55 · 옆 0.42 · 아래 0.24 정도. + + /// 위쪽(하늘 쪽) 환경광 휘도의 바닥 — 바닥·지붕처럼 위를 향한 면이 이 밑으로 어두워지지 않는다. + public const float AmbientFloorSky = 0.36f; + + /// 옆쪽(적도) 환경광 휘도의 바닥 — 캐릭터·곤충의 옆면이 이 밑으로 어두워지지 않는다. + public const float AmbientFloorEquator = 0.29f; + + /// 아래쪽 환경광 휘도의 바닥 — 처마 밑·그늘. + public const float AmbientFloorGround = 0.16f; + + /// 옆면이 받는 빛의 평균 비율(수평 성분 × 이 값) — 조도 비교용 근사. + public const float SideLightFactor = 0.64f; + + // ── 흐린 하늘의 연무 막 ── + // 절차적 스카이박스로는 회색 하늘을 못 낸다. 대기 두께를 올리면 뿌예지는 게 아니라 긴 경로에서 파랑이 빠져 지평선이 주황이 된다 + // (레일리 계수가 두께의 2.5제곱 — ×2.6이면 약 11배. 흐린 정오가 해 질 녘처럼 찍혔다). 그래서 스카이박스는 맑은 하늘 그대로 두고 + // 그 앞에 안개색 막을 씌운다. 막의 색이 곧 원경을 덮는 안개색이라 산맥이 지평선에서 끊김 없이 하늘로 녹는다. + + /// 이 흐림에서 연무 막이 지평선을 완전히 덮는다 — 안개 날씨의 흐림(0.70). 비·눈·안개는 전부 1, 센바람은 약 0.17. + public const float VeilFullOvercast = 0.70f; + + /// 지평선에서 이 고도(도)까지는 연무 막이 가장 짙다 — 원경 산맥·구름이 걸치는 띠. 그 아래(땅 쪽)도 가장 짙다. + public const float VeilBandDegrees = 8f; + + /// 이 고도(도)부터 위는 연무 막이 만큼만 덮는다. + public const float VeilTopDegrees = 60f; + + /// + /// 천정 쪽 연무 막 짙기(지평선 대비). 0.8이면 흐린 날 천정에 원래 하늘이 20% 비쳐 회청색으로 남는다 — + /// 낮은 구도에서도 화면 위끝이 고도 약 40°라 그 근처는 0.85 이상이다. + /// + public const float VeilZenithFactor = 0.8f; + + /// + /// 연무 막의 세로 분포(지평선 대비 0~1) — 고도(도) 아래는 1, 위는 + /// , 그 사이는 smoothstep. 지평선 쪽이 짙어야 경로가 긴 쪽이 더 뿌연 실제 하늘처럼 읽힌다. + /// + public static float VeilProfile(float elevationDegrees) + { + if (elevationDegrees <= VeilBandDegrees) return 1f; + float t = Smooth((elevationDegrees - VeilBandDegrees) / (VeilTopDegrees - VeilBandDegrees)); + return Mathf.Lerp(1f, VeilZenithFactor, t); + } + + /// + /// 섬 같은 분리 서브에리어(안개가 지수 Exp)에서 날씨 안개 가산에 곱하는 환산 계수. 같은 11m에서 Exp2와 비슷한 투과율이 되게 + /// 밀도를 줄인다(Exp2 0.026 → Exp 0.0104). + /// + public const float SubAreaFogScale = 0.4f; + + // ── 시각 키프레임 ── + + private struct Key + { + public float hour; + public float sun; // 햇빛 세기 배수(정오 = 1) + public Color sunTint; // 햇빛색에 곱 + public float moon; // 달빛 세기 배수(한밤 = 1) + public Color ambient; // 환경광에 곱 + public Color fog; // 안개·하늘 배경에 곱 + public float exposure; // 스카이박스 노출 배수 + public float stars; // 별 투명도 + } + + private static Color C(float r, float g, float b) => new Color(r, g, b, 1f); + + private static Key K(float hour, float sun, Color sunTint, float moon, Color ambient, Color fog, float exposure, float stars) + { + return new Key { hour = hour, sun = sun, sunTint = sunTint, moon = moon, ambient = ambient, fog = fog, exposure = exposure, stars = stars }; + } + + // 한밤 — 푸르고 낮은 휘도. 안개·하늘은 짙은 남색, 환경광은 바닥(위 0.36)에 걸릴 만큼 푸르다. + private static readonly Color NightAmbient = C(0.50f, 0.60f, 0.95f); + private static readonly Color NightFog = C(0.12f, 0.17f, 0.32f); + private static readonly Color White = C(1f, 1f, 1f); + + /// + /// 시각(0~24) 키프레임. 처음과 끝(0·24)은 같은 값이라 자정을 넘겨도 이어진다. + /// 일출 5.5~7 · 노을 17~20 · 해 질 녘 20~22 — 열(列) 순서: 햇빛 배수 · 햇빛 색 · 달빛 배수 · 환경광 색 · 안개/하늘 색 · 노출 · 별. + /// 달빛은 해가 지기 전(18~19시)부터 차오르고 새벽에도 6시까지 남는다 — 그러지 않으면 해가 약해진 해 질 녘이 + /// 한밤보다 어두운 "홈"이 생긴다(실측: 달빛이 20시에 시작하면 19시 조도가 한밤의 74%). + /// + private static readonly Key[] Keys = + { + K(0.0f, 0.00f, White, 1.00f, NightAmbient, NightFog, 0.10f, 1.00f), + K(4.5f, 0.00f, C(1f, 0.55f, 0.35f), 1.00f, NightAmbient, NightFog, 0.10f, 1.00f), + K(5.5f, 0.28f, C(1f, 0.60f, 0.42f), 0.70f, C(0.72f, 0.70f, 0.90f), C(0.36f, 0.31f, 0.46f), 0.40f, 0.35f), + K(6.0f, 0.72f, C(1f, 0.80f, 0.62f), 0.12f, C(0.92f, 0.88f, 0.94f), C(0.96f, 0.68f, 0.56f), 0.80f, 0.10f), + K(7.0f, 0.90f, C(1f, 0.92f, 0.80f), 0.00f, C(0.96f, 0.95f, 0.97f), C(0.96f, 0.88f, 0.82f), 0.97f, 0.00f), + K(9.0f, 0.97f, C(1f, 0.98f, 0.93f), 0.00f, C(0.99f, 0.98f, 0.99f), C(0.99f, 0.97f, 0.95f), 1.00f, 0.00f), + K(12.0f, 1.00f, White, 0.00f, White, White, 1.00f, 0.00f), + K(15.0f, 0.97f, C(1f, 0.98f, 0.94f), 0.00f, C(0.99f, 0.98f, 0.97f), C(1f, 0.98f, 0.95f), 1.00f, 0.00f), + K(17.0f, 0.86f, C(1f, 0.90f, 0.72f), 0.00f, C(0.95f, 0.91f, 0.90f), C(1f, 0.86f, 0.72f), 0.95f, 0.00f), + K(18.0f, 0.68f, C(1f, 0.76f, 0.54f), 0.12f, C(0.88f, 0.80f, 0.84f), C(1f, 0.72f, 0.54f), 0.85f, 0.00f), + K(19.0f, 0.42f, C(1f, 0.60f, 0.42f), 0.55f, C(0.78f, 0.68f, 0.84f), C(0.92f, 0.54f, 0.52f), 0.55f, 0.05f), + K(20.0f, 0.15f, C(0.90f, 0.46f, 0.46f), 0.90f, C(0.62f, 0.60f, 0.88f), C(0.58f, 0.38f, 0.52f), 0.30f, 0.30f), + K(21.0f, 0.00f, C(0.90f, 0.46f, 0.46f), 1.00f, C(0.52f, 0.60f, 0.94f), C(0.26f, 0.24f, 0.44f), 0.15f, 0.75f), + K(22.0f, 0.00f, White, 1.00f, NightAmbient, NightFog, 0.10f, 1.00f), + K(24.0f, 0.00f, White, 1.00f, NightAmbient, NightFog, 0.10f, 1.00f), + }; + + // ── 날씨 등급 ── + + private struct Grade + { + public float sunDim; // 햇빛 배수 + public float moonDim; // 달빛 배수 + public float ambientDim; // 환경광 배수 + public float overcast; // 0~1 흐림 — 색을 회색으로 풀고, 그림자·별·하늘에 번진다 + public float fogAdd; // 안개 밀도 가산(FieldFogMode 단위) + public Color cool; // 햇빛·환경광에 곱하는 색조 + } + + private static Grade GradeOf(WeatherType weather) + { + switch (weather) + { + // 센바람 — 맑되 먼지가 낀다. 약간 따뜻하고 누런 빛. + case WeatherType.Wind: + return new Grade { sunDim = 0.92f, moonDim = 0.95f, ambientDim = 0.98f, overcast = 0.12f, fogAdd = 0.001f, cool = C(1.00f, 0.98f, 0.93f) }; + // 비 — 두꺼운 구름. 햇빛이 40%로 줄고 그림자가 거의 사라진다. 푸른 회색. + case WeatherType.Rain: + return new Grade { sunDim = 0.40f, moonDim = 0.55f, ambientDim = 0.82f, overcast = 0.85f, fogAdd = 0.006f, cool = C(0.88f, 0.94f, 1.00f) }; + // 안개 — 빛이 안개에 퍼져 환경광은 거의 그대로, 직사만 줄어든다. 밀도 가산이 이 날씨의 본체다. + case WeatherType.Fog: + return new Grade { sunDim = 0.55f, moonDim = 0.65f, ambientDim = 0.95f, overcast = 0.70f, fogAdd = 0.026f, cool = C(0.96f, 0.97f, 0.98f) }; + // 눈 — 흐리지만 눈 반사로 환경광은 안 줄고, 밤에도 달빛이 덜 죽는다. 푸르스름한 흰빛. + case WeatherType.Snow: + return new Grade { sunDim = 0.50f, moonDim = 0.80f, ambientDim = 0.97f, overcast = 0.75f, fogAdd = 0.012f, cool = C(0.92f, 0.97f, 1.05f) }; + default: + return new Grade { sunDim = 1f, moonDim = 1f, ambientDim = 1f, overcast = 0f, fogAdd = 0f, cool = White }; + } + } + + // ── 값 ── + + /// + /// 날씨 가중치 — 전환(WeatherSystem.Blend01) 중에는 두 날씨가 섞인다. 합이 1이 아니어도 가 정규화한다. + /// 지역마다 보이는 날씨가 다르므로() 호출부가 지역 기준 날씨를 넣는다. + /// + public struct WeatherMix + { + public float clear, rain, fog, wind, snow; + + public static WeatherMix Of(WeatherType weather) + { + var m = new WeatherMix(); + m.Add(weather, 1f); + return m; + } + + /// + /// → 전환의 가중치. 0이면 from 100%, 1이면 to 100%. + /// 진행은 smoothstep으로 꺾어 전환 양끝이 부드럽다. + /// + public static WeatherMix Blend(WeatherType from, WeatherType to, float blend01) + { + float t = Smooth(blend01); + var m = new WeatherMix(); + m.Add(from, 1f - t); + m.Add(to, t); + return m; + } + + /// 의 전환 상태를 기준(섬·길 위는 null)으로 읽는다. + public static WeatherMix From(WeatherSystem weather, string regionId) + { + if (weather == null) return Of(WeatherType.Clear); + WeatherType from = WeatherForecast.EffectiveIn(weather.PreviousWeather, regionId); + WeatherType to = WeatherForecast.EffectiveIn(weather.CurrentWeather, regionId); + return Blend(from, to, weather.Blend01); + } + + public float Get(WeatherType weather) + { + switch (weather) + { + case WeatherType.Rain: return rain; + case WeatherType.Fog: return fog; + case WeatherType.Wind: return wind; + case WeatherType.Snow: return snow; + default: return clear; + } + } + + private void Add(WeatherType weather, float weight) + { + switch (weather) + { + case WeatherType.Rain: rain += weight; break; + case WeatherType.Fog: fog += weight; break; + case WeatherType.Wind: wind += weight; break; + case WeatherType.Snow: snow += weight; break; + default: clear += weight; break; + } + } + + public float Total => clear + rain + fog + wind + snow; + } + + /// + /// 조명 한 벌 — 기본(보정 전) 상태이자 보정 결과. 평면 환경광(섬·서브에리어)은 위·옆·아래를 같은 색으로 둔다. + /// + public struct LightingState + { + public Color lightColor; + public float lightIntensity; + public Quaternion lightRotation; + public float shadowStrength; + public Color ambientSky; + public Color ambientEquator; + public Color ambientGround; + public bool fogEnabled; + public Color fogColor; + public float fogDensity; + /// 카메라 배경(섬처럼 SolidColor인 곳에선 곧 하늘색). + public Color background; + + /// 두 상태를 섞는다. 한쪽만 안개가 켜져 있으면 꺼진 쪽은 밀도 0으로 본다(꺼진 안개의 잔여 밀도가 번지지 않게). + public static LightingState Lerp(LightingState a, LightingState b, float t) + { + t = Mathf.Clamp01(t); + float da = a.fogEnabled ? a.fogDensity : 0f; + float db = b.fogEnabled ? b.fogDensity : 0f; + return new LightingState + { + lightColor = Color.Lerp(a.lightColor, b.lightColor, t), + lightIntensity = Mathf.Lerp(a.lightIntensity, b.lightIntensity, t), + lightRotation = Quaternion.Slerp(a.lightRotation, b.lightRotation, t), + shadowStrength = Mathf.Lerp(a.shadowStrength, b.shadowStrength, t), + ambientSky = Color.Lerp(a.ambientSky, b.ambientSky, t), + ambientEquator = Color.Lerp(a.ambientEquator, b.ambientEquator, t), + ambientGround = Color.Lerp(a.ambientGround, b.ambientGround, t), + fogEnabled = a.fogEnabled || b.fogEnabled, + fogColor = Color.Lerp(a.fogColor, b.fogColor, t), + fogDensity = Mathf.Lerp(da, db, t), + background = Color.Lerp(a.background, b.background, t), + }; + } + } + + /// 한 순간의 하늘 — 의 결과. 기본 상태와 무관한 배수·색조·세기만 든다. + public struct SkyFrame + { + public float hour; + /// 햇빛 세기 배수(날씨 포함). 정오 맑음 = 1, 밤 = 0. + public float sunFactor; + /// 햇빛색에 곱하는 색조(노을 주황·날씨 색조 포함). + public Color sunTint; + /// 해의 높이 0(지평선)~1(정오) — 고도를 ~기본 고도로 보간한다. + public float sunHeight; + /// 해의 방위 −1(일출)~+1(일몰). + public float sunAzimuth; + /// 달빛 세기 배수(날씨 포함). 한밤 맑음 = 1. + public float moonFactor; + /// 환경광에 곱하는 색조(날씨 색조 포함). + public Color ambientTint; + public float ambientDim; + /// 0~1 흐림. + public float overcast; + /// 안개·하늘 배경에 곱하는 색조. + public Color fogTint; + /// 안개 밀도 가산( 단위). + public float fogAdd; + /// 스카이박스 노출 배수(흐리면 더 낮다). + public float skyExposure; + /// + /// 하늘을 덮는 연무 막의 짙기 0~1(에서 1) — 연무 돔(WorldSkyVisuals)의 알파다. + /// 맑음은 0이라 돔이 꺼진다. 비·눈·안개는 1이라 지평선이 안개색으로 완전히 덮인다. + /// + public float skyHaze; + /// 별 투명도 0~1(구름이 가리면 줄어든다). + public float starAlpha; + /// 그림자 세기 배수 — 흐리면 그림자가 거의 사라지고 해가 낮으면 옅다. + public float shadowScale; + /// 0.4(한밤)~1(맑은 정오) — 조명 없는 입자(비·눈)의 밝기를 장면에 맞추는 값. + public float lightLevel; + } + + // ── 판정 ── + + /// + /// 하늘 보정이 걸리는 곳인가 — 메인 필드와 나의 섬뿐이다. 동굴·방 같은 다른 서브에리어는 실내라 보정이 없고, + /// 「챔피언의 꿈」은 늘 맑은 한낮이다. 하늘 조명과 날씨 입자가 같은 판정을 쓴다. + /// + public static bool Applies(bool inSubArea, string subAreaId, bool dreamActive) + { + if (dreamActive) return false; + return !inSubArea || subAreaId == GameConstants.Island.SubAreaId; + } + + /// 해의 높이 0(지평선)~1(정오). 6시에 뜨고 12시에 가장 높고 19시에 진다 — 정오에서 정확히 최대(미분 0)다. + public static float SunHeight01(float hour) + { + hour = Mathf.Repeat(hour, 24f); + if (hour <= SunriseHour || hour >= SunsetHour) return 0f; + if (hour <= NoonHour) return Mathf.Sin(Mathf.PI * 0.5f * (hour - SunriseHour) / (NoonHour - SunriseHour)); + return Mathf.Cos(Mathf.PI * 0.5f * (hour - NoonHour) / (SunsetHour - NoonHour)); + } + + /// 해의 방위 −1(일출)~+1(일몰) — 해가 뜨기 전·진 뒤에는 양끝에 머문다. + public static float SunAzimuth(float hour) + { + hour = Mathf.Repeat(hour, 24f); + float a = hour < NoonHour ? (hour - NoonHour) / (NoonHour - SunriseHour) : (hour - NoonHour) / (SunsetHour - NoonHour); + return Mathf.Clamp(a, -1f, 1f); + } + + // ── 평가 ── + + /// 이 시각·날씨 가중치의 하늘. 시각은 0~24(소수) — GameClock.GetHourFloat(). + public static SkyFrame Evaluate(float hour, WeatherMix mix) + { + hour = Mathf.Repeat(hour, 24f); + Key k = SampleKey(hour); + + float total = mix.Total; + if (total <= 1e-5f) + { + mix = WeatherMix.Of(WeatherType.Clear); + total = 1f; + } + float inv = 1f / total; + + float sunDim = 0f, moonDim = 0f, ambientDim = 0f, overcast = 0f, fogAdd = 0f; + float coolR = 0f, coolG = 0f, coolB = 0f; + for (int i = 0; i < 5; i++) + { + WeatherType w = (WeatherType)i; + float weight = mix.Get(w) * inv; + if (weight <= 0f) continue; + Grade g = GradeOf(w); + sunDim += g.sunDim * weight; + moonDim += g.moonDim * weight; + ambientDim += g.ambientDim * weight; + overcast += g.overcast * weight; + fogAdd += g.fogAdd * weight; + coolR += g.cool.r * weight; + coolG += g.cool.g * weight; + coolB += g.cool.b * weight; + } + Color cool = new Color(coolR, coolG, coolB, 1f); + + var f = new SkyFrame + { + hour = hour, + sunFactor = k.sun * sunDim, + sunTint = Mul(k.sunTint, cool), + sunHeight = SunHeight01(hour), + sunAzimuth = SunAzimuth(hour), + moonFactor = k.moon * moonDim, + ambientTint = Mul(k.ambient, cool), + ambientDim = ambientDim, + overcast = overcast, + fogTint = k.fog, + fogAdd = fogAdd, + skyExposure = k.exposure * Mathf.Lerp(1f, 0.75f, overcast), + skyHaze = Mathf.Clamp01(overcast / VeilFullOvercast), + starAlpha = k.stars * (1f - overcast), + shadowScale = (1f - 0.85f * overcast) * Mathf.Lerp(0.7f, 1f, Mathf.Clamp01(k.sun)), + }; + f.lightLevel = Mathf.Lerp(0.40f, 1f, Mathf.Clamp01(f.sunFactor)); + return f; + } + + /// 한 날씨로 고정한 하늘. + public static SkyFrame Evaluate(float hour, WeatherType weather) => Evaluate(hour, WeatherMix.Of(weather)); + + /// + /// 기본 상태(, 리전·섬의 보정 전 값) 위에 하늘을 얹는다. + /// 가 참이면 메인 필드(, 지수제곱)의 안개 가산 규칙을, + /// 거짓이면 섬 같은 서브에리어(, 지수)의 환산·상한을 쓴다. + /// + public static LightingState Apply(LightingState b, SkyFrame f, bool fieldFog) + { + LightingState o = b; + float oc = f.overcast; + + // ── 해와 달: 라이트 하나를 둘이 나눠 쓴다. 세기 비율로 색·방향을 섞어 해 질 녘에 부드럽게 넘어간다 ── + Vector3 e = b.lightRotation.eulerAngles; + float basePitch = e.x > 180f ? e.x - 360f : e.x; + float baseYaw = e.y; + float sunPitch = Mathf.Lerp(SunMinPitch, basePitch, f.sunHeight); + Quaternion sunRot = Quaternion.Euler(sunPitch, baseYaw + f.sunAzimuth * SunYawSwing, 0f); + Quaternion moonRot = Quaternion.Euler(MoonPitch, baseYaw + MoonYawOffset, 0f); + + float sunI = b.lightIntensity * f.sunFactor; + float moonI = MoonIntensity * f.moonFactor; + float lightTotal = sunI + moonI; + Color sunColor = Desaturate(Mul(b.lightColor, f.sunTint), oc * 0.5f); + if (lightTotal > 1e-4f) + { + float moonShare = moonI / lightTotal; + o.lightIntensity = lightTotal; + o.lightColor = Color.Lerp(sunColor, MoonColor, moonShare); + o.lightRotation = Quaternion.Slerp(sunRot, moonRot, moonShare); + } + else + { + o.lightIntensity = 0f; + o.lightColor = sunColor; + o.lightRotation = sunRot; + } + o.shadowStrength = b.shadowStrength * f.shadowScale; + + // ── 환경광: 색조·밝기·흐림에 따른 탈색 → 바닥 ── + o.ambientSky = Ambient(b.ambientSky, f, AmbientFloorSky); + o.ambientEquator = Ambient(b.ambientEquator, f, AmbientFloorEquator); + o.ambientGround = Ambient(b.ambientGround, f, AmbientFloorGround); + + // ── 안개·하늘: 시각 색조 → 흐리면 회색으로 풀고 어둡게 ── + o.fogColor = SkyColor(b.fogColor, f); + o.background = SkyColor(b.background, f); + + float add = fieldFog ? f.fogAdd : f.fogAdd * SubAreaFogScale; + if (add > 0.0005f) + { + float cap = fieldFog ? RegionAtmosphere.MaxWeatherFogDensity : RegionAtmosphere.MaxWeatherFogDensitySubArea; + float baseDensity = b.fogEnabled ? b.fogDensity : 0f; + o.fogEnabled = true; + // 기본이 이미 상한 위인 경우는 없지만(리전 연무 ≤ 0.009) 있어도 기본을 깎지는 않는다 + o.fogDensity = Mathf.Max(baseDensity, Mathf.Min(baseDensity + add, cap)); + } + return o; + } + + // ── 조도(테스트·진단) ── + + /// 색의 휘도(감마 색공간 근사). + public static float Luminance(Color c) => 0.299f * c.r + 0.587f * c.g + 0.114f * c.b; + + /// 하늘을 향한 평면이 받는 조도 근사 = 위쪽 환경광 + 빛 × sin(고도). + public static float UpLuminance(LightingState s) + { + Vector3 forward = s.lightRotation * Vector3.forward; + float up = Mathf.Max(0f, -forward.y); + return Luminance(s.ambientSky) + s.lightIntensity * Luminance(s.lightColor) * up; + } + + /// 서 있는 캐릭터의 옆면이 받는 조도 근사 = 옆쪽 환경광 + 빛 × 수평 성분 × . + public static float SideLuminance(LightingState s) + { + Vector3 forward = s.lightRotation * Vector3.forward; + float horizontal = new Vector2(forward.x, forward.z).magnitude; + return Luminance(s.ambientEquator) + s.lightIntensity * Luminance(s.lightColor) * horizontal * SideLightFactor; + } + + // ── 내부 ── + + private static Key SampleKey(float hour) + { + int last = Keys.Length - 1; + for (int i = 0; i < last; i++) + { + Key a = Keys[i]; + Key b = Keys[i + 1]; + if (hour > b.hour) continue; + float span = Mathf.Max(0.0001f, b.hour - a.hour); + float t = Smooth((hour - a.hour) / span); + return new Key + { + hour = hour, + sun = Mathf.Lerp(a.sun, b.sun, t), + sunTint = Color.Lerp(a.sunTint, b.sunTint, t), + moon = Mathf.Lerp(a.moon, b.moon, t), + ambient = Color.Lerp(a.ambient, b.ambient, t), + fog = Color.Lerp(a.fog, b.fog, t), + exposure = Mathf.Lerp(a.exposure, b.exposure, t), + stars = Mathf.Lerp(a.stars, b.stars, t), + }; + } + return Keys[last]; + } + + private static float Smooth(float t) + { + t = Mathf.Clamp01(t); + return t * t * (3f - 2f * t); + } + + private static Color Mul(Color a, Color b) => new Color(a.r * b.r, a.g * b.g, a.b * b.b, 1f); + + /// 색을 자기 휘도의 회색 쪽으로 만큼 푼다. + private static Color Desaturate(Color c, float amount) + { + float l = Luminance(c); + return new Color(Mathf.Lerp(c.r, l, amount), Mathf.Lerp(c.g, l, amount), Mathf.Lerp(c.b, l, amount), 1f); + } + + private static Color Ambient(Color c, SkyFrame f, float floor) + { + Color a = Mul(c, f.ambientTint); + a = new Color(a.r * f.ambientDim, a.g * f.ambientDim, a.b * f.ambientDim, 1f); + return EnsureLuma(Desaturate(a, f.overcast * 0.35f), floor); + } + + private static Color SkyColor(Color c, SkyFrame f) + { + Color s = Desaturate(Mul(c, f.fogTint), f.overcast * 0.8f); + float dim = Mathf.Lerp(1f, 0.85f, f.overcast); + return new Color(s.r * dim, s.g * dim, s.b * dim, 1f); + } + + /// 휘도가 바닥보다 낮으면 색조를 지킨 채 바닥까지 끌어올린다 — 연속이다(바닥 위에선 손대지 않는다). + private static Color EnsureLuma(Color c, float floor) + { + float l = Luminance(c); + if (l >= floor) return c; + if (l < 1e-4f) return new Color(floor, floor, floor, 1f); + float k = floor / l; + return new Color(c.r * k, c.g * k, c.b * k, 1f); + } + } +} diff --git a/Assets/Scripts/Core/WorldSkyRules.cs.meta b/Assets/Scripts/Core/WorldSkyRules.cs.meta new file mode 100644 index 0000000..1f0ba9b --- /dev/null +++ b/Assets/Scripts/Core/WorldSkyRules.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2b7aa1a8790e0584097edc3378b46d31 \ No newline at end of file diff --git a/Assets/Scripts/Core/WorldSkyVisuals.cs b/Assets/Scripts/Core/WorldSkyVisuals.cs new file mode 100644 index 0000000..e326add --- /dev/null +++ b/Assets/Scripts/Core/WorldSkyVisuals.cs @@ -0,0 +1,413 @@ +using UnityEngine; + +namespace InsectGame.Core +{ + /// + /// 하늘의 "그려지는 부분" — 스카이박스 머티리얼 인스턴스, 밤별, 흐린 날의 연무 막. 가 소유하고 구동한다. + /// + /// 에셋을 고치지 않는다. 씬의 스카이박스(Default-Skybox)를 new Material(원본)으로 복제해 + /// RenderSettings.skybox에 걸고, 끝나면 원본을 되돌려 놓고 복제본을 파괴한다. 셰이더 키워드는 건드리지 않는다 + /// (빌드에서 변형이 빠지는 함정 — 여기서 바꾸는 건 노출·땅 색 값뿐이다). + /// + /// 대기 두께는 건드리지 않는다. 흐린 하늘을 뿌옇게 하려고 두께를 2.6배로 올렸더니 지평선이 진한 주황·금색이 됐다 + /// (2026-10-03 캡처, 정오 눈) — 절차적 스카이박스의 레일리 계수가 두께의 2.5제곱이라 긴 경로에서 파랑이 다 빠진다. + /// 흐림은 대신 연무 돔이 맡는다: 카메라를 따라다니는 큰 구에 지금 프레임의 안개색을 칠하고, 지평선 쪽이 짙은 세로 알파 + /// () × 연무 짙기()로 하늘을 덮는다. + /// 안개가 원경 산맥을 덮는 색과 같은 색이라 산이 지평선에서 끊김 없이 하늘로 녹는다. 맑은 날은 짙기 0이라 돔이 꺼진다(비용 0). + /// + /// 그리는 순서. 스카이박스는 불투명 물체 뒤(큐 2500 다음)에 그려지고, 별(큐 2501) → 연무(2502) → 나머지 반투명(3000~) 순이다. + /// 두 돔 다 깊이를 쓰지 않고 깊이 검사만 하므로 반경(먼 평면의 90%)보다 가까운 산·구름·지형이 앞에 남는다. 그보다 먼 것은 + /// 리전 연무에 이미 묻혀 안개색이다. 큐를 3000보다 앞에 둔 이유: 돔의 중심이 카메라라 같은 큐의 거리 정렬에서 늘 "가장 가까운 것"이 되어 + /// 맨 나중에 그려진다 — 물·유리 같은 반투명이 하늘을 배경으로 설 때 그 위를 덮어 버린다. 연무가 별보다 뒤라 구름이 별을 가린다. + /// Sprites/Default는 안개를 안 받으므로(별이 900m에서도 보이는 이유) 돔 색이 곧 화면 색이다. + /// + /// 게임 카메라는 고각(약 56° 아래)이라 평소엔 하늘이 거의 안 보인다. 지평선이 보이는 건 낮은 구도(배치 캡처·전투 연출)와 + /// 먼 곳이다. 그래서 둘 다 싼 것으로 족하다 — 별은 쿼드 170장, 연무는 고리 11개×32칸 구 하나로 각각 드로우콜 한 번이고, + /// 필요 없을 때는 GameObject째 꺼진다. 달은 따로 안 그린다: 절차적 스카이박스의 해 원반이 디렉셔널 라이트(밤엔 달빛) 방향·색으로 + /// 그려지므로 그것이 달이다. 흐린 날엔 그 원반이 연무 너머로 흐릿하게 비친다. + /// + internal sealed class WorldSkyVisuals + { + private const int StarCount = 170; + private const int StarSeed = 20261003; + /// 하늘 돔 반지름이 카메라 먼 평면의 이 비율 — 원경 산맥(월드 경계 ±520m)보다 멀어야 산이 별·연무 앞에 선다. + private const float DomeFarFraction = 0.9f; + private const float DomeMaxRadius = 900f; + + /// 스카이박스(불투명 다음) 바로 뒤, 다른 반투명(3000)보다 앞. + private const int StarQueue = 2501; + private const int VeilQueue = 2502; + + /// 연무 막을 켜는 최소 알파 — 그 아래는 꺼서 맑은 날 비용이 0이다. + private const float VeilMinAlpha = 0.01f; + private const int VeilSegments = 32; + /// 연무 돔의 고리 고도(도). 아래 반구는 한 고리로 덮고(늘 가장 짙다) 지평선 위를 촘촘히 나눠 세로 분포를 따라간다. + private static readonly float[] VeilRings = { -90f, -12f, 0f, 8f, 16f, 26f, 38f, 50f, 60f, 75f, 90f }; + + private static readonly int ExposureId = Shader.PropertyToID("_Exposure"); + private static readonly int GroundId = Shader.PropertyToID("_GroundColor"); + + // ── 스카이박스 ── + private Material originalSky; + private Material skyMat; + private float baseExposure = 1.3f; + private Color baseGround = new Color(0.369f, 0.349f, 0.341f); + private bool hasExposure, hasGround; + + // ── 별 ── + private GameObject starRoot; + private MeshFilter starFilter; + private Material starMat; + private Texture2D dot; + private bool starsFailed; + private float lastStarAlpha = -1f; + + // ── 연무 막 ── + private GameObject veilRoot; + private MeshFilter veilFilter; + private Material veilMat; + private bool veilFailed; + private Color lastVeilColor = new Color(-1f, -1f, -1f, -1f); + + /// 씬의 스카이박스를 복제해 건다. 스카이박스가 없으면(없는 씬·테스트) 조용히 넘어간다. + public void CreateSkybox() + { + if (skyMat != null) return; + originalSky = RenderSettings.skybox; + if (originalSky == null) return; + + skyMat = new Material(originalSky) { name = "~RuntimeSkybox" }; + hasExposure = skyMat.HasProperty(ExposureId); + hasGround = skyMat.HasProperty(GroundId); + if (hasExposure) baseExposure = skyMat.GetFloat(ExposureId); + if (hasGround) baseGround = skyMat.GetColor(GroundId); + RenderSettings.skybox = skyMat; + } + + /// + /// 하늘 한 프레임을 스카이박스·별·연무 막에 쓴다. 0이면 원본 값(돔 꺼짐), 1이면 완전 보정. + /// 는 이번 프레임에 실제로 쓴 안개색이다 — 연무 막이 원경 안개와 같은 색이어야 지평선이 녹는다. + /// + public void Apply(WorldSkyRules.SkyFrame f, float strength, Camera camera, Color veilColor) + { + strength = Mathf.Clamp01(strength); + + if (skyMat != null) + { + if (hasExposure) + skyMat.SetFloat(ExposureId, Mathf.Lerp(baseExposure, baseExposure * f.skyExposure, strength)); + if (hasGround) + { + // 지평선 아래(보일 일은 드물다)가 한밤에 하얗게 남지 않게 노출을 따라 어둡힌다 + float dim = Mathf.Lerp(1f, 0.25f + 0.75f * Mathf.Clamp01(f.skyExposure), strength); + skyMat.SetColor(GroundId, new Color(baseGround.r * dim, baseGround.g * dim, baseGround.b * dim, baseGround.a)); + } + } + + ApplyStars(f.starAlpha * strength, camera); + ApplyVeil(Mathf.Clamp01(f.skyHaze) * strength, veilColor, camera); + } + + /// 켜져 있는 돔(별·연무)을 카메라에 붙인다 — 꺼져 있으면 아무 일도 안 한다. + public void Follow(Camera camera) + { + if (camera == null) return; + bool stars = starRoot != null && starRoot.activeSelf; + bool veil = veilRoot != null && veilRoot.activeSelf; + if (!stars && !veil) return; + + float radius = Mathf.Min(DomeMaxRadius, camera.farClipPlane * DomeFarFraction); + Vector3 position = camera.transform.position; + var scale = new Vector3(radius, radius, radius); + if (stars) + { + starRoot.transform.position = position; + starRoot.transform.localScale = scale; + } + if (veil) + { + veilRoot.transform.position = position; + veilRoot.transform.localScale = scale; + } + } + + public void Dispose() + { + if (skyMat != null) + { + if (RenderSettings.skybox == skyMat) RenderSettings.skybox = originalSky; + Object.Destroy(skyMat); + skyMat = null; + } + if (starRoot != null) Object.Destroy(starRoot); + starRoot = null; + if (starMat != null) Object.Destroy(starMat); + starMat = null; + if (starFilter != null && starFilter.sharedMesh != null) Object.Destroy(starFilter.sharedMesh); + starFilter = null; + if (dot != null) Object.Destroy(dot); + dot = null; + + if (veilRoot != null) Object.Destroy(veilRoot); + veilRoot = null; + if (veilMat != null) Object.Destroy(veilMat); // 텍스처는 내장 흰색이라 파괴하지 않는다 + veilMat = null; + if (veilFilter != null && veilFilter.sharedMesh != null) Object.Destroy(veilFilter.sharedMesh); + veilFilter = null; + } + + // ── 별 ── + + private void ApplyStars(float alpha, Camera camera) + { + bool want = alpha > 0.01f; + if (!want) + { + if (starRoot != null && starRoot.activeSelf) starRoot.SetActive(false); + lastStarAlpha = -1f; + return; + } + if (starsFailed) return; + if (starRoot == null && !BuildStars()) return; + + if (!starRoot.activeSelf) starRoot.SetActive(true); + // 알파를 매 틱 쓰지 않는다 — 0.02 이상 바뀔 때만 + if (Mathf.Abs(alpha - lastStarAlpha) > 0.02f) + { + starMat.color = new Color(1f, 1f, 1f, Mathf.Clamp01(alpha)); + lastStarAlpha = alpha; + } + Follow(camera); + } + + private bool BuildStars() + { + dot = SkyFx.CreateSoftDot(32); + starMat = SkyFx.CreateMaterial(dot, "~StarMat"); + if (starMat == null) + { + starsFailed = true; + if (dot != null) Object.Destroy(dot); + dot = null; + return false; + } + starMat.renderQueue = StarQueue; + + starRoot = new GameObject("~SkyStars"); + starFilter = starRoot.AddComponent(); + starFilter.sharedMesh = BuildStarMesh(); + ConfigureRenderer(starRoot.AddComponent(), starMat); + starRoot.SetActive(false); + return true; + } + + private static void ConfigureRenderer(MeshRenderer renderer, Material material) + { + renderer.sharedMaterial = material; + renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + renderer.receiveShadows = false; + renderer.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; + renderer.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off; + } + + /// + /// 단위 구 위쪽 반구에 별 쿼드 장. 쿼드는 구 중심을 향한다. 위치 시드가 고정이라 매번 같은 별자리다. + /// 각크기 0.16~0.34°, 알파 0.45~1 — 밝은 별 몇 개와 흐린 별 다수. + /// + private static Mesh BuildStarMesh() + { + var rng = new System.Random(StarSeed); + var verts = new Vector3[StarCount * 4]; + var colors = new Color[StarCount * 4]; + var uvs = new Vector2[StarCount * 4]; + var tris = new int[StarCount * 6]; + + float minElevation = Mathf.Sin(6f * Mathf.Deg2Rad); + for (int i = 0; i < StarCount; i++) + { + float sinEl = minElevation + (float)rng.NextDouble() * (1f - minElevation); + float cosEl = Mathf.Sqrt(Mathf.Max(0f, 1f - sinEl * sinEl)); + float az = (float)rng.NextDouble() * Mathf.PI * 2f; + Vector3 dir = new Vector3(Mathf.Cos(az) * cosEl, sinEl, Mathf.Sin(az) * cosEl); + + Vector3 right = Vector3.Cross(Vector3.up, dir); + if (right.sqrMagnitude < 1e-6f) right = Vector3.right; + right.Normalize(); + Vector3 up = Vector3.Cross(dir, right); + + float half = Mathf.Tan(Mathf.Lerp(0.08f, 0.17f, (float)rng.NextDouble()) * Mathf.Deg2Rad); + float bright = (float)rng.NextDouble(); + bright = 0.45f + 0.55f * bright * bright; + // 대부분 푸른 흰색, 몇 개는 따뜻한 색 + Color tint = rng.NextDouble() < 0.15 ? new Color(1f, 0.9f, 0.75f, bright) : new Color(0.85f, 0.92f, 1f, bright); + + int v = i * 4; + verts[v] = dir - right * half - up * half; + verts[v + 1] = dir + right * half - up * half; + verts[v + 2] = dir + right * half + up * half; + verts[v + 3] = dir - right * half + up * half; + uvs[v] = new Vector2(0f, 0f); + uvs[v + 1] = new Vector2(1f, 0f); + uvs[v + 2] = new Vector2(1f, 1f); + uvs[v + 3] = new Vector2(0f, 1f); + colors[v] = colors[v + 1] = colors[v + 2] = colors[v + 3] = tint; + + int t = i * 6; + // 구 안쪽(중심의 카메라)에서 보이는 면이 앞면이 되게 감는다 — 셰이더는 Cull Off라 어느 쪽이든 그려지지만 일관되게 + tris[t] = v; tris[t + 1] = v + 2; tris[t + 2] = v + 1; + tris[t + 3] = v; tris[t + 4] = v + 3; tris[t + 5] = v + 2; + } + + var mesh = new Mesh { name = "~SkyStarsMesh" }; + mesh.vertices = verts; + mesh.uv = uvs; + mesh.colors = colors; + mesh.triangles = tris; + // 카메라를 따라다니는 단위 구 — 경계를 넉넉히 잡아 절두체 컬링에 잘리지 않게 한다 + mesh.bounds = new Bounds(Vector3.zero, Vector3.one * 2.2f); + return mesh; + } + + // ── 연무 막 ── + + /// + /// 연무 막을 켜고 끄고 칠한다. 색 = 안개색, 알파 = 짙기 × 정점 알파(세로 분포). 색은 0.004 넘게 바뀔 때만 쓴다 + /// (하늘 틱이 0.1초라 그대로 써도 싸지만, 시간이 멈춘 검수 중에는 아예 안 쓰게). + /// + private void ApplyVeil(float alpha, Color color, Camera camera) + { + if (alpha <= VeilMinAlpha) + { + if (veilRoot != null && veilRoot.activeSelf) veilRoot.SetActive(false); + lastVeilColor = new Color(-1f, -1f, -1f, -1f); + return; + } + if (veilFailed) return; + if (veilRoot == null && !BuildVeil()) return; + + if (!veilRoot.activeSelf) veilRoot.SetActive(true); + var target = new Color(color.r, color.g, color.b, Mathf.Clamp01(alpha)); + if (Mathf.Abs(target.r - lastVeilColor.r) > 0.004f || Mathf.Abs(target.g - lastVeilColor.g) > 0.004f + || Mathf.Abs(target.b - lastVeilColor.b) > 0.004f || Mathf.Abs(target.a - lastVeilColor.a) > 0.004f) + { + veilMat.color = target; + lastVeilColor = target; + } + Follow(camera); + } + + private bool BuildVeil() + { + // 텍스처는 내장 흰색 — 색은 전부 정점 알파와 머티리얼 색이 낸다 + veilMat = SkyFx.CreateMaterial(Texture2D.whiteTexture, "~SkyVeil"); + if (veilMat == null) + { + veilFailed = true; + return false; + } + veilMat.renderQueue = VeilQueue; + + veilRoot = new GameObject("~SkyVeil"); + veilFilter = veilRoot.AddComponent(); + veilFilter.sharedMesh = BuildVeilMesh(); + ConfigureRenderer(veilRoot.AddComponent(), veilMat); + veilRoot.SetActive(false); + return true; + } + + /// + /// 단위 구 — 고리() × 칸. 정점 색은 흰색, 알파는 그 고리 고도의 + /// . 땅 쪽 반구도 덮는다 — 월드 경계 밖 빈 곳에 스카이박스 땅색이 비치지 않게. + /// + private static Mesh BuildVeilMesh() + { + int rings = VeilRings.Length; + int cols = VeilSegments + 1; + var verts = new Vector3[rings * cols]; + var colors = new Color[rings * cols]; + var uvs = new Vector2[rings * cols]; + var tris = new int[(rings - 1) * VeilSegments * 6]; + + for (int r = 0; r < rings; r++) + { + float el = VeilRings[r] * Mathf.Deg2Rad; + float y = Mathf.Sin(el); + float h = Mathf.Cos(el); + float alpha = WorldSkyRules.VeilProfile(VeilRings[r]); + for (int s = 0; s < cols; s++) + { + float az = (float)s / VeilSegments * Mathf.PI * 2f; + int i = r * cols + s; + verts[i] = new Vector3(Mathf.Cos(az) * h, y, Mathf.Sin(az) * h); + colors[i] = new Color(1f, 1f, 1f, alpha); + uvs[i] = new Vector2((float)s / VeilSegments, (float)r / (rings - 1)); + } + } + + int t = 0; + for (int r = 0; r < rings - 1; r++) + { + for (int s = 0; s < VeilSegments; s++) + { + int a = r * cols + s; + int b = a + cols; + tris[t++] = a; tris[t++] = b; tris[t++] = a + 1; + tris[t++] = a + 1; tris[t++] = b; tris[t++] = b + 1; + } + } + + var mesh = new Mesh { name = "~SkyVeilMesh" }; + mesh.vertices = verts; + mesh.uv = uvs; + mesh.colors = colors; + mesh.triangles = tris; + mesh.bounds = new Bounds(Vector3.zero, Vector3.one * 2.2f); + return mesh; + } + } + + /// + /// 하늘·날씨 입자용 재료. Sprites/Default는 Always Included Shaders에 든 셰이더라 플레이어 빌드에 항상 있고 + /// (알파 블렌드·ZWrite Off·정점 색 지원) 키워드 변형이 없다 — Standard의 반투명 변형이 빌드에서 불투명으로 빠지는 함정이 없다. + /// 전투 이펙트(BattleArenaController.CreateFxMaterial)가 폴백으로 쓰는 셰이더와 같다. + /// + internal static class SkyFx + { + /// 가장자리가 부드럽게 사라지는 흰 원 — 별·눈송이·빗줄기가 같은 텍스처를 색과 크기만 바꿔 쓴다. + public static Texture2D CreateSoftDot(int size) + { + var tex = new Texture2D(size, size, TextureFormat.RGBA32, false) + { + name = "~SoftDot", + wrapMode = TextureWrapMode.Clamp, + filterMode = FilterMode.Bilinear, + }; + var px = new Color32[size * size]; + float c = (size - 1) * 0.5f; + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + float d = Mathf.Sqrt((x - c) * (x - c) + (y - c) * (y - c)) / c; + float a = Mathf.Clamp01(1f - d); + a = a * a * (3f - 2f * a); + px[y * size + x] = new Color32(255, 255, 255, (byte)Mathf.RoundToInt(a * 255f)); + } + } + tex.SetPixels32(px); + tex.Apply(false, true); + return tex; + } + + /// 알파 블렌드 입자 머티리얼. 셰이더를 못 찾으면 null — 호출부가 그 효과를 끈다. + public static Material CreateMaterial(Texture2D texture, string name) + { + Shader shader = Shader.Find("Sprites/Default"); + if (shader == null) return null; + var mat = new Material(shader) { name = name }; + mat.mainTexture = texture; + mat.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent; + return mat; + } + } +} diff --git a/Assets/Scripts/Core/WorldSkyVisuals.cs.meta b/Assets/Scripts/Core/WorldSkyVisuals.cs.meta new file mode 100644 index 0000000..7f2bb61 --- /dev/null +++ b/Assets/Scripts/Core/WorldSkyVisuals.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d329a90bd4bd4de4aa4db30ddcb09052 \ No newline at end of file diff --git a/Assets/Scripts/Core/WorldStateProvider.cs b/Assets/Scripts/Core/WorldStateProvider.cs index c8859af..f84d4fe 100644 --- a/Assets/Scripts/Core/WorldStateProvider.cs +++ b/Assets/Scripts/Core/WorldStateProvider.cs @@ -7,6 +7,9 @@ public struct WorldState public DayPhase DayPhase; public WeatherType Weather; public int Hour24; + + /// 밤인가 — 야행성 곤충이 깨어나고 습격이 일어나는 시간대. + public bool IsNight => DayPhase == DayPhase.Night; } public class WorldStateProvider : MonoBehaviour @@ -14,7 +17,20 @@ public class WorldStateProvider : MonoBehaviour [SerializeField] private GameClock gameClock; [SerializeField] private WeatherSystem weatherSystem; + public GameClock Clock => gameClock; + public WeatherSystem Weather => weatherSystem; + + /// 세계 날씨 그대로의 상태 — 지역을 모를 때(길 위·UI)의 값이다. public WorldState GetWorldState() + { + return GetWorldState(null); + } + + /// + /// 지역에서 실제로 보이는 상태 — 같은 세계 날씨라도 설산에서는 비가 눈이 되고 + /// 사막에서는 눈이 센바람이 된다(). 스폰·전투 보정이 이쪽을 읽는다. + /// + public WorldState GetWorldState(string regionId) { DayPhase phase = gameClock != null ? gameClock.GetDayPhase() : DayPhase.Day; int hour = gameClock != null ? gameClock.GetHour24() : 12; @@ -23,7 +39,7 @@ public WorldState GetWorldState() return new WorldState { DayPhase = phase, - Weather = weather, + Weather = WeatherForecast.EffectiveIn(weather, regionId), Hour24 = hour }; } diff --git a/Assets/Scripts/Core/WorldTerrainBuilder.cs b/Assets/Scripts/Core/WorldTerrainBuilder.cs index 38777f4..954361e 100644 --- a/Assets/Scripts/Core/WorldTerrainBuilder.cs +++ b/Assets/Scripts/Core/WorldTerrainBuilder.cs @@ -21,8 +21,9 @@ public void BuildTerrain(RegionData[] regions) private void ApplyElevation(RegionData[] regions) { - foreach (var r in regions) + for (int regionIndex = 0; regionIndex < regions.Length; regionIndex++) { + var r = regions[regionIndex]; float y = GetRegionElevation(r.regionId); r.centerPosition = new Vector3(r.centerPosition.x, y, r.centerPosition.z); @@ -34,7 +35,7 @@ private void ApplyElevation(RegionData[] regions) GameObject regionObj = GameObject.Find($"Region_{r.regionId}"); if (regionObj != null) - regionObj.transform.position = r.centerPosition + new Vector3(0f, 0.08f, 0f); + regionObj.transform.position = r.centerPosition + new Vector3(0f, 0.08f + regionIndex * 0.001f, 0f); } } @@ -81,6 +82,7 @@ private void BuildElevationSlopes(RegionData[] regions) private void CreateSlope(string name, Vector3 from, Vector3 to, float fromY, float toY, float width, Material mat) { + if (Mathf.Approximately(fromY, toY)) return; // 평탄화된 필드에 가짜 중심 직선길을 남기지 않는다. Vector3 flatFrom = new Vector3(from.x, 0f, from.z); Vector3 flatTo = new Vector3(to.x, 0f, to.z); Vector3 dir = flatTo - flatFrom; @@ -131,6 +133,7 @@ private void BuildCliffs(RegionData[] regions) Vector3 perp = Vector3.Cross(dir, Vector3.up).normalized; pos += perp * arcOffset; + if (WorldRouteLayout.IsOnRoute(regions, pos, 3f)) continue; GameObject cliff = GameObject.CreatePrimitive(PrimitiveType.Cube); cliff.name = $"Cliff_ForestMountain_{i}"; cliff.transform.position = pos + new Vector3(0f, 3f, 0f); @@ -157,6 +160,7 @@ private void BuildCliffs(RegionData[] regions) Vector3 pos = mountainCenter + d * mRad; pos.y = mountainCenter.y; + if (WorldRouteLayout.IsOnRoute(regions, pos, 4f)) continue; GameObject wall = GameObject.CreatePrimitive(PrimitiveType.Cube); wall.name = $"Cliff_Mountain_{i}"; wall.transform.position = pos + new Vector3(0f, 4f, 0f); @@ -168,109 +172,77 @@ private void BuildCliffs(RegionData[] regions) private void BuildRiver(RegionData[] regions) { - Vector3 pondCenter = GetCenter(regions, "pond"); - Vector3 meadowCenter = GetCenter(regions, "meadow"); - - // 강: pond에서 meadow 방향으로 흐름 (pond 리전 가장자리) - float pondRadius = GetRadius(regions, "pond"); - Vector3 riverDir = (meadowCenter - pondCenter).normalized; - Vector3 riverStart = pondCenter + riverDir * (pondRadius * 0.5f); - Vector3 riverEnd = pondCenter + riverDir * (pondRadius + 15f); - + RegionData pond = WorldRouteLayout.Find(regions, "pond"); + if (pond == null) return; + Vector3 road = (WorldRouteLayout.GetGateway(pond, regions) - pond.centerPosition).normalized; + Vector3 across = Vector3.Cross(road, Vector3.up).normalized; + Vector3 center = pond.centerPosition + road * (pond.radius * 0.7f); + const float length = 26f; + const float width = 5f; Material waterMat = CreateMat(new Color(0.15f, 0.35f, 0.65f, 0.7f)); - SetTransparent(waterMat); - Material bankMat = CreateMat(new Color(0.35f, 0.3f, 0.22f)); - - int segments = 5; - float riverWidth = 5f; - float bridgeT = 0.5f; - - for (int i = 0; i < segments; i++) + SceneryMaterials.MakeFade(waterMat); + GameObject water = GameObject.CreatePrimitive(PrimitiveType.Plane); + water.name = "River_Water_0"; + water.transform.position = center + Vector3.up * 0.14f; + water.transform.rotation = Quaternion.LookRotation(across); + water.transform.localScale = new Vector3(width / 10f, 1f, length / 10f); + water.GetComponent().sharedMaterial = waterMat; + DisableDecorationCollider(water); + + // 물은 다리 밑까지 연속, 통행 차단만 다리 양옆으로 나눈다. + const float passageHalfWidth = 2f; + float blockerLength = length * 0.5f - passageHalfWidth; + for (int side = -1; side <= 1; side += 2) { - float tStart = (float)i / segments; - float tEnd = (float)(i + 1) / segments; - float tMid = (tStart + tEnd) / 2f; - - if (Mathf.Abs(tMid - bridgeT) < 0.12f) continue; - - Vector3 from = Vector3.Lerp(riverStart, riverEnd, tStart); - Vector3 to = Vector3.Lerp(riverStart, riverEnd, tEnd); - Vector3 perp = Vector3.Cross(riverDir, Vector3.up).normalized; - float wave = Mathf.Sin(tMid * Mathf.PI * 2f) * 3f; - Vector3 mid = (from + to) / 2f + perp * wave; - - Vector3 segDir = to - from; - float len = segDir.magnitude; - float angle = Mathf.Atan2(segDir.x, segDir.z) * Mathf.Rad2Deg; - float y = Mathf.Lerp(pondCenter.y, pondCenter.y + 1f, tMid); - - GameObject water = GameObject.CreatePrimitive(PrimitiveType.Plane); - water.name = $"River_Water_{i}"; - water.transform.position = new Vector3(mid.x, y + 0.05f, mid.z); - water.transform.rotation = Quaternion.Euler(0f, angle, 0f); - water.transform.localScale = new Vector3(riverWidth / 10f, 1f, (len + 2f) / 10f); - water.GetComponent().material = waterMat; - Object.Destroy(water.GetComponent()); - - GameObject blocker = new GameObject($"River_Blocker_{i}"); - blocker.transform.position = new Vector3(mid.x, y + 0.5f, mid.z); - blocker.transform.rotation = Quaternion.Euler(0f, angle, 0f); - BoxCollider box = blocker.AddComponent(); - box.size = new Vector3(riverWidth, 2f, len + 2f); + GameObject blocker = new GameObject("River_Blocker_" + side); + blocker.transform.position = center + across * side * (passageHalfWidth + blockerLength * 0.5f) + Vector3.up; + blocker.transform.rotation = Quaternion.LookRotation(across); + blocker.AddComponent().size = new Vector3(width, 2f, blockerLength); } - - // 강둑 돌 - for (int i = 0; i < 14; i++) + Material bankMat = CreateMat(new Color(0.35f, 0.3f, 0.22f)); + for (int i = 0; i <= 12; i++) { - float t = (float)i / 14f; - Vector3 pos = Vector3.Lerp(riverStart, riverEnd, t); - Vector3 perp = Vector3.Cross(riverDir, Vector3.up).normalized; - float wave = Mathf.Sin(t * Mathf.PI * 2f) * 3f; - pos += perp * wave; - pos.y = Mathf.Lerp(pondCenter.y, pondCenter.y + 1f, t); - + float distance = -length * 0.5f + length * i / 12f; + if (Mathf.Abs(distance) < passageHalfWidth) continue; for (int side = -1; side <= 1; side += 2) { GameObject bank = GameObject.CreatePrimitive(PrimitiveType.Sphere); - bank.name = $"River_Bank_{i}_{(side > 0 ? "R" : "L")}"; - float s = Random.Range(0.4f, 0.7f); - bank.transform.position = pos + perp * (riverWidth * 0.5f + 0.5f) * side + new Vector3(0f, s * 0.15f, 0f); - bank.transform.localScale = new Vector3(s * 1.5f, s * 0.4f, s); - bank.GetComponent().material = bankMat; - Object.Destroy(bank.GetComponent()); + bank.name = "River_Bank_" + i + "_" + side; + bank.transform.position = center + across * distance + road * side * 2.8f + Vector3.up * 0.15f; + bank.transform.localScale = new Vector3(0.7f, 0.3f, 0.7f); + bank.GetComponent().sharedMaterial = bankMat; + DisableDecorationCollider(bank); } } } private void BuildBridges(RegionData[] regions) { - Material woodMat = CreateMat(new Color(0.5f, 0.35f, 0.15f)); - Material railMat = CreateMat(new Color(0.45f, 0.3f, 0.12f)); - Material plankMat = CreateMat(new Color(0.55f, 0.4f, 0.2f)); - - // 다리 1: pond 강 위 (50% 지점) - Vector3 pondCenter = GetCenter(regions, "pond"); - Vector3 meadowCenter = GetCenter(regions, "meadow"); - float pondRadius = GetRadius(regions, "pond"); - Vector3 riverDir = (meadowCenter - pondCenter).normalized; - Vector3 riverStart = pondCenter + riverDir * (pondRadius * 0.5f); - Vector3 riverEnd = pondCenter + riverDir * (pondRadius + 15f); - Vector3 bridgePos = Vector3.Lerp(riverStart, riverEnd, 0.5f); - bridgePos.y = pondCenter.y + 0.5f; - Vector3 bridgeDir = Vector3.Cross(riverDir, Vector3.up).normalized; - - CreateBridge("Bridge_PondRiver", bridgePos, bridgeDir, 8f, 3f, woodMat, railMat, plankMat); - - // 다리 2: mountain-ruins 연결 - Material stoneBridgeMat = CreateMat(new Color(0.5f, 0.48f, 0.42f)); - Material stoneRailMat = CreateMat(new Color(0.45f, 0.43f, 0.38f)); - Vector3 mountainCenter = GetCenter(regions, "mountain"); - Vector3 ruinsCenter = GetCenter(regions, "ruins"); - Vector3 bridge2Dir = (ruinsCenter - mountainCenter).normalized; - Vector3 bridge2Pos = Vector3.Lerp(mountainCenter, ruinsCenter, 0.5f); - bridge2Pos.y = (mountainCenter.y + ruinsCenter.y) / 2f; + RegionData pond = WorldRouteLayout.Find(regions, "pond"); + if (pond != null) + { + Vector3 direction = (WorldRouteLayout.GetGateway(pond, regions) - pond.centerPosition).normalized; + Vector3 center = pond.centerPosition + direction * (pond.radius * 0.7f); + Material wood = CreateMat(new Color(0.5f, 0.35f, 0.15f)); + Material rail = CreateMat(new Color(0.4f, 0.27f, 0.12f)); + CreateBridge("Bridge_PondRiver", center, direction, 8f, 3f, wood, rail, wood); + } + RegionData mountain = WorldRouteLayout.Find(regions, "mountain"); + if (mountain != null) + { + Vector3 direction = (WorldRouteLayout.GetGateway(mountain, regions) - mountain.centerPosition).normalized; + Material stone = CreateMat(new Color(0.5f, 0.48f, 0.42f)); + CreateBridge("Bridge_MountainRuins", mountain.centerPosition + direction * mountain.radius * 0.7f, + direction, 12f, 3.5f, stone, stone, stone); + } + } - CreateBridge("Bridge_MountainRuins", bridge2Pos, bridge2Dir, 12f, 3.5f, stoneBridgeMat, stoneRailMat, stoneBridgeMat); + private static void DisableDecorationCollider(GameObject obj) + { + Collider collider = obj.GetComponent(); + if (collider == null) return; + collider.enabled = false; + Object.Destroy(collider); } private void CreateBridge(string name, Vector3 pos, Vector3 dir, float length, float width, Material floorMat, Material railMat, Material plankMat) @@ -279,9 +251,9 @@ private void CreateBridge(string name, Vector3 pos, Vector3 dir, float length, f GameObject floor = GameObject.CreatePrimitive(PrimitiveType.Cube); floor.name = $"{name}_Floor"; - floor.transform.position = pos + new Vector3(0f, 0.15f, 0f); + floor.transform.position = pos + new Vector3(0f, 0.18f, 0f); floor.transform.rotation = Quaternion.Euler(0f, angle, 0f); - floor.transform.localScale = new Vector3(width, 0.3f, length); + floor.transform.localScale = new Vector3(width, 0.08f, length); floor.GetComponent().material = floorMat; Vector3 perp = new Vector3(-dir.z, 0f, dir.x).normalized; @@ -348,36 +320,41 @@ private void BuildMapBoundary() private void BuildExtraPaths(RegionData[] regions) { Material pathMat = CreateMat(new Color(0.55f, 0.48f, 0.32f)); - CreateSimplePath("Path_Meadow_Swamp", GetCenter(regions, "meadow"), GetCenter(regions, "swamp"), 2.5f, pathMat); - CreateSimplePath("Path_Meadow_Garden", GetCenter(regions, "meadow"), GetCenter(regions, "garden"), 2.5f, pathMat); - - // ── 1막 진행 사슬 ── 2막 블록이 아래에서 하는 일을 1막에도 한다. - // 해금은 meadow→pond→forest→swamp→mountain→ruins 한 줄로 도는데, 그중 땅에 길이 - // 깔린 건 mountain→ruins 하나뿐이었다. **`pond→forest`와 `swamp→mountain`은 - // 지도 간선조차 없었다** — 다음 목적지로 가는 길만 골라서 안 보였다. - // (meadow→garden은 위에 이미 있다. 사슬이 아닌 공간 인접선은 지도에만 둔다 — - // 길로 깔면 사슬과 구분이 안 돼 오히려 엉뚱한 쪽으로 이끈다.) - CreateSimplePath("Path_Meadow_Pond", GetCenter(regions, "meadow"), GetCenter(regions, "pond"), 2.5f, pathMat); - CreateSimplePath("Path_Pond_Forest", GetCenter(regions, "pond"), GetCenter(regions, "forest"), 2.5f, pathMat); - CreateSimplePath("Path_Forest_Swamp", GetCenter(regions, "forest"), GetCenter(regions, "swamp"), 2.5f, pathMat); - CreateSimplePath("Path_Swamp_Mountain", GetCenter(regions, "swamp"), GetCenter(regions, "mountain"), 2.5f, pathMat); - - Material stonePath = CreateMat(new Color(0.5f, 0.45f, 0.4f)); - CreateSimplePath("Path_Mountain_Ruins", GetCenter(regions, "mountain"), GetCenter(regions, "ruins"), 3f, stonePath); - - // ── 2막(ver2) 사슬 ── RegionMapUI.Connections와 같은 토폴로지를 월드에도 깐다. - // 지도에는 길이 있는데 땅에는 없으면 어디로 가야 할지 알 수 없다. - // 색을 바래게 둔 건 이 길들이 오래 방치된 땅으로 이어지기 때문이다. - Material fadedPath = CreateMat(new Color(0.46f, 0.44f, 0.38f)); - string[,] act2Chain = + PlayerStartPose entrance = PlayerStartPlacement.ResolveMainVillageEntrance(regions); + if (!entrance.IsFallback) { - { "ruins", "hollow" }, { "hollow", "dunes" }, { "dunes", "frostline" }, - { "frostline", "emberfall" }, { "emberfall", "canopy" }, { "canopy", "nameless" } - }; - for (int i = 0; i < act2Chain.GetLength(0); i++) + Vector3 meadowCenter = GetCenter(regions, "meadow"); + Vector3 destination = entrance.Position; + destination.y = meadowCenter.y; + CreateSimplePath("Path_Meadow_VillageEntrance", meadowCenter, destination, 2.5f, pathMat); + } + + var segments = new System.Collections.Generic.HashSet<(Vector3, Vector3)>(); + var joints = new System.Collections.Generic.HashSet(); + foreach (WorldRouteEdge edge in WorldRouteLayout.FieldConnections) { - string a = act2Chain[i, 0], b = act2Chain[i, 1]; - CreateSimplePath($"Path_{a}_{b}", GetCenter(regions, a), GetCenter(regions, b), 2.5f, fadedPath); + Vector3[] points = WorldRouteLayout.BuildRoute(regions, edge.FromRegionId, edge.ToRegionId); + if (points.Length < 2) + { + Debug.LogError("No safe field route: " + edge.FromRegionId + " -> " + edge.ToRegionId); + continue; + } + for (int i = 1; i < points.Length; i++) + { + if (segments.Contains((points[i], points[i - 1])) || !segments.Add((points[i - 1], points[i]))) continue; + CreateSimplePath("Path_" + edge.FromRegionId + "_" + edge.ToRegionId + "_" + i, + points[i - 1], points[i], WorldRouteLayout.RoadWidth, pathMat); + } + for (int i = 1; i < points.Length - 1; i++) + { + if (!joints.Add(points[i])) continue; + GameObject joint = GameObject.CreatePrimitive(PrimitiveType.Cylinder); + joint.name = "Path_Join_" + joints.Count; + joint.transform.position = points[i] + Vector3.up * 0.17f; + joint.transform.localScale = new Vector3(WorldRouteLayout.RoadWidth, 0.01f, WorldRouteLayout.RoadWidth); + joint.GetComponent().sharedMaterial = pathMat; + DisableDecorationCollider(joint); + } } } @@ -406,7 +383,7 @@ private void CreateSimplePath(string name, Vector3 from, Vector3 to, float width path.transform.rotation = Quaternion.Euler(0f, angle, 0f); path.transform.localScale = new Vector3(width / 10f, 1f, segLen / 10f); path.GetComponent().material = mat; - Object.Destroy(path.GetComponent()); + DisableDecorationCollider(path); } } @@ -427,7 +404,7 @@ private float GetRadius(RegionData[] regions, string id) /// /// 이 빌더가 만든 런타임 머티리얼 — RegionTerrainBuilder와 같은 이유로 회수한다 /// (GameObject를 지워도 머티리얼은 남고, 로그아웃·계정삭제가 씬을 재로드한다). - /// 여기서 나온 것도 SetTransparent로 변형되므로 색상 캐시를 쓰지 않는다. + /// 여기서 나온 것도 로 변형되므로 색상 캐시를 쓰지 않는다. /// private readonly System.Collections.Generic.List runtimeMaterials = new System.Collections.Generic.List(); @@ -439,26 +416,12 @@ private void OnDestroy() runtimeMaterials.Clear(); } + /// 셰이더 폴백·무광 마감은 가 한다 — 여기선 회수 목록에만 올린다. private Material CreateMat(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; + Material mat = SceneryMaterials.Create(color); runtimeMaterials.Add(mat); 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/Data/InsectDatabase.cs b/Assets/Scripts/Data/InsectDatabase.cs index ff3e885..0b1dd63 100644 --- a/Assets/Scripts/Data/InsectDatabase.cs +++ b/Assets/Scripts/Data/InsectDatabase.cs @@ -35,35 +35,19 @@ public List GetCandidates(WorldState state) public InsectData GetWeightedRandom(List candidates) { - if (candidates == null || candidates.Count == 0) - { - return null; - } - - float total = 0f; - foreach (InsectData data in candidates) - { - // null 가드 — 외부에서 직접 candidates 전달 시 NRE 차단(GetCandidates 외 경로). - if (data == null) continue; - total += Mathf.Max(0.01f, data.spawnWeight); - } - - float roll = Random.Range(0f, total); - float cumulative = 0f; - foreach (InsectData data in candidates) - { - if (data == null) continue; - cumulative += Mathf.Max(0.01f, data.spawnWeight); - if (roll <= cumulative) - { - return data; - } - } - - return candidates[0]; + return PickWeighted(candidates, Random.value); } - public InsectData GetWeightedRandomWithRareBoost(List candidates, float rareMultiplier) + /// + /// spawnWeight 가중 선택의 단일 출처 — (0~1)로 고른다. 난수를 밖에서 받아 + /// 테스트가 분포를 고정할 수 있다. 필드 스폰은 등급을 먼저 굴린 뒤 그 등급 후보 안에서 이걸 부른다 + /// (FieldSpawnRules). 가중치는 0.01 아래로 내리지 않는다 — 가챠 전용(0)이 섞여도 0으로 나누지 않는다. + /// + /// (여기 있던 GetWeightedRandomWithRareBoost·GetRarityWeight는 지웠다. 필드 스폰만 부르던 것인데 + /// 부스트가 1을 넘는 순간 등급 기본 가중치 표까지 함께 곱해 희귀가 오히려 줄었다 — 레어 부스트는 이제 + /// FieldSpawnRules.BoostedShare가 등급표에 건다.) + /// + public static InsectData PickWeighted(IReadOnlyList candidates, float roll01) { if (candidates == null || candidates.Count == 0) { @@ -71,65 +55,30 @@ public InsectData GetWeightedRandomWithRareBoost(List candidates, fl } float total = 0f; - foreach (InsectData data in candidates) + for (int i = 0; i < candidates.Count; i++) { + // null 가드 — 외부에서 직접 candidates 전달 시 NRE 차단(GetCandidates 외 경로). + InsectData data = candidates[i]; if (data == null) continue; - float weight = Mathf.Max(0.01f, data.spawnWeight) * GetRarityWeight(data, rareMultiplier); - total += weight; + total += Mathf.Max(0.01f, data.spawnWeight); } - float roll = Random.Range(0f, total); + float roll = Mathf.Clamp01(roll01) * total; float cumulative = 0f; - foreach (InsectData data in candidates) + InsectData last = null; + for (int i = 0; i < candidates.Count; i++) { + InsectData data = candidates[i]; if (data == null) continue; - float weight = Mathf.Max(0.01f, data.spawnWeight) * GetRarityWeight(data, rareMultiplier); - cumulative += weight; + last = data; + cumulative += Mathf.Max(0.01f, data.spawnWeight); if (roll <= cumulative) { return data; } } - return candidates[0]; - } - - private float GetRarityWeight(InsectData data, float rareMultiplier) - { - if (data == null) - { - return 1f; - } - - float baseWeight; - switch (data.rarity) - { - case InsectRarity.Common: - baseWeight = 1f; - break; - case InsectRarity.Uncommon: - baseWeight = 0.45f; - break; - case InsectRarity.Rare: - baseWeight = 0.12f; - break; - case InsectRarity.Epic: - baseWeight = 0.03f; - break; - case InsectRarity.Legendary: - baseWeight = 0.008f; - break; - default: - baseWeight = 1f; - break; - } - - if (data.rarity >= InsectRarity.Rare) - { - baseWeight *= Mathf.Max(1f, rareMultiplier); - } - - return baseWeight; + return last; } } } diff --git a/Assets/Scripts/Data/InsectHabitTable.cs b/Assets/Scripts/Data/InsectHabitTable.cs new file mode 100644 index 0000000..90f3115 --- /dev/null +++ b/Assets/Scripts/Data/InsectHabitTable.cs @@ -0,0 +1,340 @@ +using System.Collections.Generic; + +namespace InsectGame.Data +{ + /// + /// 곤충 성향 표 — 종마다 한 줄(의 저작 데이터). 규칙(배수·궁합·습격)은 InsectHabits.cs가 든다. + /// + /// 대상. 필드·서브에리어·가챠 전 종 194종 — 부트스트랩 EnsureExpandedDatabase가 만드는 기본 종과 + /// InsectExpansionDefinitions·InsectExpansion2Definitions 시드 전부다. 종을 늘리면 여기에 한 줄을 더한다 + /// (안 하면 이 되어 조용히 성향 없는 종이 된다 — InsectHabitsTests가 DB와 표를 맞춰 본다). + /// + /// 읽는 법. R(종 ID, 활동 시간대, 좋아하는 날씨, 싫어하는 날씨[, 기질]). 날씨는 |로 묶는다. + /// + /// 정한 방식 — 곤충학 근거를 계통 단위로 먼저 정하고(아래 구역 머리말), 종마다 서식지·생김새로 예외를 뒀다. + /// + /// 활동 시간대 주행성: 나비·벌·말벌·잠자리·무당벌레·매미·메뚜기·비단벌레·스카라베 (햇볕이 있어야 날고 체온을 올린다) + /// 야행성: 나방·반딧불·귀뚜라미·지네·집게벌레·모기·사슴벌레·장수풍뎅이·대벌레·여치·개미귀신 (밤에 먹이를 찾고 짝을 부른다) + /// 무관: 쥐며느리·진딧물·애벌레·물방개·소똥구리, 그리고 시간이 멈춘 땅(지워진 곳·이름 없는 자리)의 종 + /// 날씨 취향 비 ↓: 날개가 얇은 날것(나비·벌·반딧불·매미·말벌) 비 ↑: 물·습기에 사는 것(물방개·소금쟁이·모기·지네·잎 먹는 것) + /// 센바람 ↓: 줄을 치거나 불빛이 흩어지는 것(거미·반딧불·모기·소금쟁이) 센바람 ↑: 바람 속성(나비·벌·나방·잠자리)·먼지 바람 속의 종 + /// 눈 ↓: 따뜻한 철의 변온동물(나비·나방·귀뚜라미·물방개) 눈 ↑: 서릿길·산에 사는 종(사용자 요청) + /// 안개 ↑: 습기를 먹는 야행성·매복 포식자(안개가 엄폐다) 안개 ↓: 햇볕을 쬐어야 하는 것(무당벌레·메뚜기) + /// 맑음 ↑: 주행성 대부분·마른 땅의 종 맑음 ↓: 마름을 싫어하는 것(지네·집게벌레) + /// 속성 정합 물 속성은 비를 싫어하지 않고, 바람 속성은 센바람을 좋아한다(사용자 근거 예). 속성은 부트스트랩이 이름·서식지로 + /// 정하므로(InferPrimaryType) 날것이 대부분 바람 속성이다 — "날것은 센바람을 싫어함"과 부딪히는 자리라 바람 속성이 이긴다. + /// InsectHabitsTests가 DB의 속성으로 이 약속을 고정한다(속성이 바뀌면 표를 같이 고친다). + /// 기질 습격형: 매복·사냥하는 종 위주(말벌류·사마귀·지네·거미·몇몇 큰 종). 등급이 높을수록 많다 — + /// 일반 2 · 고급 3 · 희귀 7 · 영웅 8 · 전설 5. 전부 활동 시간대가 있다(시간 무관 습격형은 깨는 때가 없다). + /// 가챠 전용종은 필드에 나오지 않으므로 전부 온순이다. + /// + /// + /// 균형. 한 날씨에 풀의 대부분이 불리해지지 않게 맞췄다 — 어떤 리전 풀에서도 한 날씨가 불리한 종은 절반 아래이고 + /// (InsectHabitsTests가 풀마다 센다), 전체로도 어느 날씨든 싫어하는 종이 3분의 1을 넘지 않는다. 처음엔 나비·벌이 전부 + /// 비·바람을 싫어해 정원 풀 17종 중 15종이 비에 불리했다 — 계통 단위로만 정하면 이렇게 쏠리므로 종마다 예외를 두었다. + /// + /// 날씨는 지역에서 보이는 날씨다(WeatherForecast.EffectiveIn) — 설산에서는 비가 눈이 되므로 서릿길·산 종은 + /// 눈 날이 길다. 사막·잿불에는 눈이 오지 않는다(센바람으로 바뀐다). + /// + public static partial class InsectHabits + { + // 표를 짧게 적기 위한 별칭 — 이 파일 안에서만 쓴다. + private const InsectActivity Any = InsectActivity.Any; + private const InsectActivity Diurnal = InsectActivity.Diurnal; + private const InsectActivity Nocturnal = InsectActivity.Nocturnal; + private const WeatherSet None = WeatherSet.None; + private const WeatherSet Clear = WeatherSet.Clear; + private const WeatherSet Rain = WeatherSet.Rain; + private const WeatherSet Fog = WeatherSet.Fog; + private const WeatherSet Wind = WeatherSet.Wind; + private const WeatherSet Snow = WeatherSet.Snow; + private const InsectTemperament Docile = InsectTemperament.Docile; + private const InsectTemperament Ambusher = InsectTemperament.Ambusher; + + private static Dictionary BuildTable() + { + var t = new Dictionary(256); + int lines = 0; + + void R(string insectId, InsectActivity activity, WeatherSet likes, WeatherSet dislikes, + InsectTemperament temperament = Docile) + { + lines++; + t[insectId] = new InsectHabit(activity, likes, dislikes, temperament); + } + + // ── 꿀벌 ── + // 주행성 · 맑음 ↑ · 비 ↓ — 비행근육을 데우려 햇볕이 필요하고 비가 오면 벌집으로 돌아간다. 바람 속성이라 센바람 ↑(바람을 타고 난다). + // 범블비는 추위·비를 견뎌 싫은 날씨가 없고, 여왕벌은 벌집 안이라 맑음을 따지지 않는다. 설산 벌(glacier)은 눈 ↑. + R("bee_worker", Diurnal, Clear | Wind, Rain); + R("bee_bumble", Diurnal, Clear | Wind, None); + R("bee_carpenter", Diurnal, Clear | Wind, Rain); + R("bee_queen", Diurnal, Wind, Rain); + R("bee_digger", Diurnal, Clear | Wind, Rain); + R("bee_stingless", Diurnal, Fog | Wind, Rain); + R("bee_glacier", Diurnal, Clear | Wind | Snow, Rain); + R("bee_perfume", Diurnal, Clear | Wind, Rain); + + // ── 말벌·장수말벌 ── + // 주행성 포식자 — 비 ↓(둥지로 들어간다). 밤말벌(wasp_night)만 야행성 · 안개 ↑ · 눈 ↓. 종이말벌은 싫은 날씨가 없다. + // 사막·잿불 말벌은 건조한 열(맑음)·먼지 바람 ↑. 번개말벌(가챠)은 비·바람 ↑(폭풍). 습격형은 표에 Ambusher로 적었다. + R("hornet_asian", Diurnal, None, Rain, Ambusher); + R("hornet_emperor", Diurnal, Wind, Rain, Ambusher); + R("hornet_dune", Diurnal, Clear | Wind, Rain, Ambusher); + R("hornet_magma", Diurnal, Clear, Rain); + R("wasp_paper", Diurnal, Clear, None, Ambusher); + R("wasp_night", Nocturnal, Fog, Snow, Ambusher); + R("wasp_gold", Diurnal, Clear, Rain, Ambusher); + R("wasp_hawk", Diurnal, Clear, Rain, Ambusher); + R("wasp_ash", Diurnal, Wind, Rain); + R("gacha_storm_hornet", Diurnal, Rain | Wind, None); + + // ── 나비 ── + // 주행성 · 맑음 ↑ · 바람 속성이라 센바람 ↑. 얇은 날개 종(cabbage·azure·apollo·crown·worldtree)은 비 ↓, + // 변온 나비(monarch·swallowtail·peacock)와 열대 우림종(morpho·glasswing·alexandras)은 눈 ↓(변온동물은 눈을 싫어한다) — 열대종은 안개 ↑(습한 숲). + // 밤의 여제(midnight)는 달빛 아래 나는 야행성. 설원 나비(snowveil)는 눈 ↑. 지워진 땅의 나비(erased)는 시간을 안 탄다. + R("butterfly_cabbage", Diurnal, Clear | Wind, Rain); + R("butterfly_azure", Diurnal, Clear | Wind, Rain); + R("butterfly_monarch", Diurnal, Clear | Wind, Snow); + R("butterfly_swallowtail", Diurnal, Clear | Wind, Snow); + R("butterfly_morpho", Diurnal, Fog | Wind, Snow); + R("butterfly_alexandras", Diurnal, Clear | Fog | Wind, Snow); + R("butterfly_peacock", Diurnal, Clear | Wind, Snow); + R("butterfly_glasswing", Diurnal, Fog | Wind, Snow); + R("butterfly_apollo", Diurnal, Clear | Wind, Rain); + R("butterfly_midnight", Nocturnal, Clear | Wind, Rain); + R("butterfly_snowveil", Diurnal, Wind | Snow, None); + R("butterfly_crown", Diurnal, Clear | Wind, Rain); + R("butterfly_worldtree", Any, Wind, Rain); + R("butterfly_erased", Any, Fog | Wind, None); + R("gacha_rainbow_butterfly", Diurnal, Clear, Rain); + + // ── 나방 ── + // 야행성 · 안개 ↑(습한 밤공기) · 바람 속성이라 센바람 ↑. 낮에 나는 벌새나방은 주행성 · 안개 ↓. 설산 나방(snow·aurora)은 눈 ↑, + // 보통 나방(brown·night·luna)은 눈 ↓. 그림자나방(shadow)은 맑음 ↓(어둠 속 종), 잿불 나방(smoulder)은 비 ↓, 혜성나방(comet)은 맑은 정상 하늘 ↑. + // 지워진 땅·이름 없는 자리의 나방(ashen·pale·effaced·forgotten)은 시간을 안 탄다 — 그 땅은 멈춰 있다. + R("moth_brown", Nocturnal, Fog | Wind, Snow); + R("moth_night", Nocturnal, Fog | Wind, Snow); + R("moth_hummingbird", Diurnal, Clear | Wind, Fog); + R("moth_shadow", Nocturnal, Fog | Wind, Clear); + R("moth_comet", Nocturnal, Clear | Wind, None); + R("moth_ashen", Any, Wind, None); + R("moth_forgotten", Any, Fog | Wind, None); + R("moth_snow", Nocturnal, Wind | Snow, None); + R("moth_aurora", Nocturnal, Wind | Snow, None); + R("moth_pale", Any, Fog | Wind, None); + R("moth_effaced", Any, Fog | Wind, None); + R("moth_smoulder", Nocturnal, Clear | Wind, Rain); + R("moth_leafveil", Nocturnal, Rain | Fog | Wind, None); + R("atlas_moth_giant", Nocturnal, Fog | Wind, None); + R("luna_moth_silver", Nocturnal, Fog | Wind, Snow); + R("gacha_phantom_moth", Nocturnal, Fog, None); + + // ── 잠자리·실잠자리 ── + // 주행성 · 맑음 ↑(시각 사냥) · 바람 속성이라 센바람 ↑(활공). 작은 잠자리(scarlet·jade)는 비 ↓, 실잠자리는 가늘어서 비·안개 둘 다 ↓. + // 왕잠자리·고대잠자리는 날씨를 안 가린다. 호수잠자리(lake)는 물 속성이라 비 ↑, 습지잠자리(swamp_hawker)는 안개 선을 순찰해 안개 ↑. + R("dragonfly_lake", Diurnal, Clear | Rain | Wind, None); + R("dragonfly_emperor", Diurnal, Clear | Wind, None); + R("dragonfly_ancient", Diurnal, Clear | Wind, None, Ambusher); + R("dragonfly_scarlet", Diurnal, Clear | Wind, Rain); + R("dragonfly_jade", Diurnal, Clear | Wind, Rain); + R("dragonfly_swamp_hawker", Diurnal, Fog | Wind, None); + R("damselfly_blue", Diurnal, Clear | Wind, Rain | Fog); + R("damselfly_red", Diurnal, Clear | Wind, Rain | Fog); + R("gacha_crystal_dragonfly", Diurnal, Clear | Wind, None); + + // ── 파리·모기 ── + // 파리: 주행성 · 맑음 ↑. 집파리는 바람 ↓, 꽃등에는 안개 ↓(호버링). 각다귀(crane)는 비·안개 ↑, 모래·재 파리는 먼지 바람 ↑. + // 모기: 물 속성 · 야행성(호랑이모기만 주행) · 비 ↑ · 센바람 ↓. 호랑이·집모기는 눈도 ↓, 호랑이모기 외에는 안개 ↑(습도). + R("mosquito_common", Nocturnal, Rain | Fog, Wind | Snow); + R("mosquito_tiger", Diurnal, Rain, Wind | Snow); + R("mosquito_swamp", Nocturnal, Rain | Fog, Wind); + R("fly_house", Diurnal, Clear, Wind); + R("fly_hover", Diurnal, Clear, Fog); + R("fly_crane", Any, Rain | Fog, None); + R("fly_sand", Diurnal, Clear | Wind, None); + R("fly_ash", Any, Wind, None); + + // ── 반딧불이 ── + // 야행성 · 안개 ↑(습한 풀밭) · 비 ↓ — 비가 오면 발광을 멈춘다. 연못·습지 반딧불은 센바람도 ↓(불빛이 흩어진다). + R("firefly_blue", Nocturnal, Fog, Rain | Wind); + R("firefly_glow", Nocturnal, Fog, Rain); + R("firefly_marsh", Nocturnal, Fog, Rain | Wind); + R("firefly_swamp", Nocturnal, Fog, Rain | Wind); + R("gacha_neon_firefly", Nocturnal, Fog, Rain | Wind); + + // ── 무당벌레 ── + // 주행성 · 맑음 ↑(일광욕) · 안개 ↓. 고산 무당벌레(alpine)는 눈 ↑. + R("ladybug_seven", Diurnal, Clear, Fog); + R("ladybug_harlequin", Diurnal, Clear, Fog); + R("ladybug_alpine", Diurnal, Clear | Snow, Fog); + R("ladybug_canopy", Diurnal, Clear, Fog); + R("gacha_golden_ladybug", Diurnal, Clear, Fog); + + // ── 매미 ── + // 주행성 · 맑음 ↑ · 비 ↓(비 오면 울지 않는다). 여름매미는 눈도 ↓. 저녁매미는 해질녘 종이라 시간 무관. + // 고산 매미는 눈 ↑(고대매미는 비 ↓), 잿불 매미는 열 ↑, 우듬지 매미는 센바람 ↑. + R("cicada_evening", Any, Clear, None); + R("cicada_summer", Diurnal, Clear, Rain | Snow); + R("cicada_mountain", Diurnal, Clear | Snow, None); + R("cicada_ancient", Diurnal, Snow, Rain); + R("cicada_ember", Diurnal, Clear, None); + R("cicada_crown", Diurnal, Clear | Wind, Rain); + + // ── 귀뚜라미 ── + // 야행성 · 눈 ↓(추우면 울음이 멈춘다). 마른 땅 종(dune·ember·slag)은 맑음 ↑, 설산·고산 종(frost·stone)은 눈 ↑. + // 지워진 땅 종(hush·tomb)은 안개 ↑. 이름 없는 자리의 귀뚜라미(still)·슬래그 귀뚜라미는 시간 무관. + R("cricket_field", Nocturnal, None, Snow); + R("cricket_tree", Nocturnal, None, Snow); + R("cricket_stone", Nocturnal, Snow, None); + R("cricket_tomb", Nocturnal, Fog, None); + R("cricket_hush", Nocturnal, Fog, None); + R("cricket_dune", Nocturnal, Clear, None); + R("cricket_frost", Nocturnal, Snow, None); + R("cricket_ember", Nocturnal, Clear, None); + R("cricket_still", Any, Fog, None); + R("cricket_slag", Any, Clear, None); + + // ── 메뚜기·여치 ── + // 메뚜기: 주행성 · 맑음 ↑(일광욕) · 안개·눈 ↓. 사막메뚜기는 센바람 ↑(떼가 바람을 탄다), 바위메뚜기는 눈 ↑. + // 여치(katydid): 야행성 · 비 ↑(잎이 젖는다) · 눈 ↓. 우듬지 여치는 안개 ↑, 설원 여치는 눈 ↑. + R("grasshopper_green", Diurnal, Clear, Fog | Snow); + R("grasshopper_brown", Diurnal, Clear, Fog | Snow); + R("grasshopper_rock", Diurnal, Clear | Snow, None); + R("grasshopper_locust", Diurnal, Clear | Wind, None); + R("katydid_leaf", Nocturnal, Rain, Snow); + R("katydid_canopy", Nocturnal, Rain | Fog, None); + R("katydid_snowfield", Nocturnal, Snow, None); + + // ── 진딧물·애벌레·개미 ── + // 진딧물: 시간·날씨 무관(정원 진딧물은 바람 ↑, 우듬지 진딧물은 안개 ↑). 비에 씻기는 쪽으로 정했다가 뺐다 — + // 정원 풀 17종 가운데 비에 불리한 종이 절반을 넘었다. 애벌레: 시간 무관 · 비 ↑(잎이 자란다), 소나무 애벌레는 눈 ↑. 개미: 주행성 · 맑음 ↑ · 비 ↓. + R("ant_soldier", Diurnal, Clear, Rain); + R("caterpillar_green", Any, Rain, None); + R("caterpillar_pine", Any, Snow, None); + R("caterpillar_silk", Any, Rain, None); + R("aphid_colony", Any, None, None); + R("aphid_rose", Any, Wind, None); + R("aphid_canopy", Any, Fog, None); + + // ── 개미귀신 ── + // 야행성(성충) · 맑음 ↑(마른 모래여야 깔때기가 선다) · 비 ↓(함정이 무너진다). + R("antlion_pit", Nocturnal, Clear, Rain); + R("antlion_dune", Nocturnal, Clear, Rain); + + // ── 지네·집게벌레·쥐며느리 ── + // 지네·집게벌레: 야행성 · 비·안개 ↑(습기) · 맑음 ↓(마름). 유적 지네(ruin)는 안개만 ↑, 사막·잿불 지네는 열을 견뎌 맑음 ↓가 없다. + // 쥐며느리: 시간 무관 · 비·안개 ↑(습기). 사막 종은 무관, 바위·설산 종은 눈 ↑, 재 쥐며느리는 열 ↑. + R("centipede_common", Nocturnal, Rain | Fog, Clear, Ambusher); + R("centipede_red", Nocturnal, Rain | Fog, Clear, Ambusher); + R("centipede_venom", Nocturnal, Rain | Fog, Clear, Ambusher); + R("centipede_ruin", Nocturnal, Fog, Clear); + R("centipede_sand", Nocturnal, Fog, None, Ambusher); + R("centipede_ember", Nocturnal, Clear, None); + R("centipede_pale", Nocturnal, Fog, None); + R("centipede_frost", Nocturnal, Snow, None); + R("pill_bug_garden", Any, Rain | Fog, None); + R("pill_bug_mud", Any, Rain | Fog, None); + R("pill_bug_rock", Any, Snow, None); + R("pill_bug_desert", Any, None, None); + R("pill_bug_frost", Any, Snow, None); + R("pill_bug_cinder", Any, Clear, None); + R("earwig_common", Nocturnal, Rain | Fog, Clear); + R("earwig_swamp", Nocturnal, Rain | Fog, Clear); + + // ── 거미 ── + // 야행성(시간 무관인 종도 있다) · 안개 ↑(이슬 맺힌 거미줄) · 센바람 ↓(줄이 찢긴다). 습지 거미는 비도 ↑, 낙타거미는 맑음 ↑(사막). + // 정원 거미(garden)는 바람 속성이라 센바람 ↑. 황금무당거미는 주행성 — 햇살에 줄이 반짝인다. 설산 거미는 눈 ↑. + R("spider_garden", Any, Fog | Wind, None); + R("spider_golden_orb", Diurnal, Clear, Wind); + R("spider_marsh", Nocturnal, Rain | Fog, Wind, Ambusher); + R("spider_bog_widow", Nocturnal, Rain | Fog, Wind, Ambusher); + R("spider_cliff", Any, Fog, None); + R("spider_tomb", Any, Fog, None); + R("spider_threadbare", Any, Fog, Wind); + R("spider_camel", Nocturnal, Clear, None, Ambusher); + R("spider_frost", Any, Snow, None); + R("spider_blank", Any, Fog, None); + R("gacha_ice_spider", Nocturnal, Rain | Snow, None); + + // ── 사마귀 ── + // 매복 포식자 — 안개를 엄폐로 쓴다(안개 ↑). 꽃·잎에 숨는 종(green·orchid·canopy)과 사원·잿불 종은 주행성. + // 썩은잎·지워진 땅·설산 사마귀는 시간 무관. 유령사마귀는 눈 ↓, 흑요석 사마귀는 맑음 ↓(어둠 속 사냥꾼). + R("mantis_green", Diurnal, Clear, None, Ambusher); + R("mantis_ghost", Nocturnal, Fog, Snow, Ambusher); + R("mantis_orchid", Diurnal, Clear, None); + R("mantis_bark", Nocturnal, Fog, None, Ambusher); + R("mantis_swamp", Nocturnal, Rain | Fog, None, Ambusher); + R("mantis_obsidian", Nocturnal, Fog, Clear, Ambusher); + R("mantis_dead_leaf", Any, Fog, None); + R("mantis_mist", Nocturnal, Fog, None, Ambusher); + R("mantis_gold_temple", Diurnal, Clear, None, Ambusher); + R("mantis_hollow", Any, Fog, None); + R("mantis_icicle", Any, Snow, None); + R("mantis_ember", Diurnal, Clear, None); + R("mantis_canopy", Diurnal, Fog, None); + R("mantis_blank", Any, Fog, None); + R("mantis_unnamed", Nocturnal, Fog, None, Ambusher); + R("gacha_shadow_mantis", Nocturnal, Fog, None); + + // ── 사슴벌레·장수풍뎅이 ── + // 야행성(수액 채집·싸움) · 안개 ↑. 작은 종은 눈 ↓, 큰 종(titan·hercules·golden)은 눈을 견딘다. 설산·고산 사슴벌레는 눈 ↑. + R("stag_beetle", Nocturnal, Fog, None); + R("stag_beetle_saw", Nocturnal, Fog, Snow); + R("stag_beetle_mountain", Nocturnal, Snow, None); + R("stag_beetle_iron", Nocturnal, Snow, None); + R("stag_beetle_glacier", Nocturnal, Snow, None); + R("rhinoceros_beetle", Nocturnal, Fog, Snow); + R("rhinoceros_beetle_titan", Nocturnal, Fog, None, Ambusher); + R("beetle_hercules", Nocturnal, Fog, None, Ambusher); + R("beetle_golden_stag", Nocturnal, Fog, None); + + // ── 비단벌레·스카라베·기타 딱정벌레 ── + // 비단벌레·스카라베·모래·잿불 딱정벌레: 주행성 · 맑음 ↑(광택·열). 비단벌레는 안개 ↓. + // 풍뎅이·하늘소·소똥구리: 대부분 시간 무관 — 무늬만 다른 중립이다. 설원 딱정벌레는 눈 ↑, 우듬지·소똥 딱정벌레는 비 ↑, 장수하늘소 로살리아는 주행성. + R("beetle_basic", Any, None, None); + R("beetle_dung", Any, Rain, None); + R("beetle_click", Nocturnal, None, None); + R("beetle_longhorn_rosalia", Diurnal, Clear, None); + R("beetle_longhorn_oak", Any, None, None); + R("beetle_longhorn_alpine", Any, Snow, None); + R("beetle_husk", Any, Fog, None); + R("beetle_sand", Diurnal, Clear, None); + R("beetle_rime", Any, Snow, None); + R("beetle_cinder", Any, Clear, None); + R("beetle_longhorn_char", Any, Clear, None); + R("beetle_unwritten", Any, Fog, None); + R("beetle_hoarfrost", Any, Snow, None); + R("beetle_scorch", Diurnal, Clear, None); + R("beetle_bark_canopy", Any, Rain, None); + R("longhorn_beetle", Any, None, None); + R("scarab_ancient", Any, None, None); + R("scarab_relic", Diurnal, Clear, None); + R("scarab_pharaoh", Diurnal, Clear, None); + R("scarab_sand", Diurnal, Clear, None); + R("jewel_beetle_gold", Diurnal, Clear, Fog); + R("jewel_beetle_azure", Diurnal, Clear, Fog); + R("gacha_diamond_beetle", Diurnal, Clear, None); + R("gacha_celestial_beetle", Nocturnal, Clear, None); + + // ── 물방개·소금쟁이 ── + // 물방개: 시간 무관 · 비 ↑(수면 활동) · 눈 ↓(수면이 언다). 소금쟁이: 주행성 · 비 ↑ · 눈·센바람 ↓(수면 파문). + R("water_strider_pond", Diurnal, Rain, Wind | Snow); + R("water_strider_stream", Diurnal, Rain, Wind | Snow); + R("diving_beetle_deep", Any, Rain, Snow); + R("diving_beetle_small", Any, Rain, Snow); + R("diving_beetle_striped", Any, Rain, Snow); + R("diving_beetle_great", Any, Rain, Snow); + R("diving_beetle_king", Any, Rain, Snow); + + // ── 대벌레·잎벌레 ── + // 야행성 · 안개 ↑(잎 위 습기). 우듬지 대벌레는 비도 ↑. 긴 대벌레는 눈 ↓. + R("stick_insect_long", Nocturnal, Fog, Snow); + R("stick_insect_canopy", Nocturnal, Rain | Fog, None); + R("leaf_insect_phantom", Nocturnal, Fog, None); + + authoredRows = lines; + return t; + } + } +} diff --git a/Assets/Scripts/Data/InsectHabitTable.cs.meta b/Assets/Scripts/Data/InsectHabitTable.cs.meta new file mode 100644 index 0000000..9e3603d --- /dev/null +++ b/Assets/Scripts/Data/InsectHabitTable.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 930977a35f6e5614cb8bc1d639bd8924 \ No newline at end of file diff --git a/Assets/Scripts/Data/InsectHabits.cs b/Assets/Scripts/Data/InsectHabits.cs new file mode 100644 index 0000000..4981f0d --- /dev/null +++ b/Assets/Scripts/Data/InsectHabits.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.Data +{ + /// 활동 시간대 — 언제 깨어 있고 언제 잠드는가. + public enum InsectActivity : byte + { + /// 시간을 안 탄다(저녁·새벽에 도는 종, 땅속·물속 종, 멈춘 땅의 종). + Any = 0, + /// 주행성 — 아침·낮에 많고 밤에 잠든다. + Diurnal = 1, + /// 야행성 — 밤에 많고 낮에 잠든다. + Nocturnal = 2 + } + + /// 기질 — 사람을 보고 달아나기만 하는가, 먼저 싸움을 거는가. + public enum InsectTemperament : byte + { + Docile = 0, + /// 습격형 — 깨어 있는 때() 먼저 싸움을 건다. + Ambusher = 1 + } + + /// + /// 날씨 집합 — 비트 하나가 값 하나다(1 << (int)weather). + /// 은 뒤에만 덧붙이므로(눈이 그랬다) 이 비트도 밀리지 않는다 — 테스트가 정렬을 고정한다. + /// + [Flags] + public enum WeatherSet : byte + { + None = 0, + Clear = 1 << 0, + Rain = 1 << 1, + Fog = 1 << 2, + Wind = 1 << 3, + Snow = 1 << 4 + } + + /// 한 종의 성향 — 시간대·날씨·기질. 값 타입이라 조회에 할당이 없다. + public readonly struct InsectHabit + { + public readonly InsectActivity Activity; + public readonly WeatherSet LikedWeather; + public readonly WeatherSet DislikedWeather; + public readonly InsectTemperament Temperament; + + public InsectHabit(InsectActivity activity, WeatherSet likedWeather, WeatherSet dislikedWeather, + InsectTemperament temperament) + { + Activity = activity; + LikedWeather = likedWeather; + DislikedWeather = dislikedWeather; + Temperament = temperament; + } + + /// 시간도 날씨도 안 타는 온순한 종 — 표에 없는 종(앞으로 늘어날 종)의 기본값이다. + public static readonly InsectHabit Neutral = + new InsectHabit(InsectActivity.Any, WeatherSet.None, WeatherSet.None, InsectTemperament.Docile); + } + + /// + /// 곤충별 시간·날씨 성향의 순수 규칙 — 표(InsectHabitTable.cs)와 그 표를 읽는 함수가 여기 한 곳이다. + /// 스폰(InsectSpawner)·전투 보정·습격이 같은 답을 읽는다. 씬 없이 도는 정적 클래스라 테스트가 전부 고정한다. + /// + /// 세 축이다. ①활동 시간대(주행성·야행성·무관) ②날씨 취향(좋아하는 날씨 집합·싫어하는 날씨 집합) + /// ③기질(온순·습격형). 시간과 날씨를 합친 (−1 불리 / 0 보통 / +1 유리)이 전투 보정과 습격 판정을, + /// 곱 배수()가 필드 종 고르기를 정한다. + /// + /// 스폰에서의 자리. 등급은 전역 표로 먼저 굴린다(FieldSpawnRules.PickRarity) — 이 배수는 그 등급 안에서 + /// 종을 고를 때만 곱해진다. 그래서 등급 분포는 시간·날씨와 무관하고, 배수는 0이 되지 않아() + /// 풀의 어떤 종도 어떤 시간·날씨에서 후보에서 빠지지 않는다. 표에 없는 종은 이다. + /// + /// 시간대 경계는 다 — 아침 6~11 · 낮 11~17 · 저녁 17~21 · 밤 21~6. + /// + public static partial class InsectHabits + { + // ── 시간대 배수 — 필드 종 고르기 가중에 곱한다 ── + // + // 야행성은 밤에 ×2.5, 낮에 ×0.35(사용자 요청: 밤 ×2~2.5 · 낮 ×0.3~0.4). 아침은 잠들러 가는 때라 ×0.6, 저녁은 깨어나기 전이라 ×1. + // 주행성은 그 거울이다 — 낮 ×1.8 · 아침 ×1.6 · 저녁 ×1 · 밤 ×0.4. 밤이 하루의 3/8(9시간)이라 주행성의 밤 배수를 + // 야행성의 낮 배수만큼 깎지는 않았다(주행성 종이 밤의 3/8 동안 거의 사라지면 필드가 휑해진다). + public const float NocturnalNightMultiplier = 2.5f; + public const float NocturnalDayMultiplier = 0.35f; + public const float NocturnalMorningMultiplier = 0.6f; + public const float NocturnalEveningMultiplier = 1.0f; + public const float DiurnalDayMultiplier = 1.8f; + public const float DiurnalMorningMultiplier = 1.6f; + public const float DiurnalEveningMultiplier = 1.0f; + public const float DiurnalNightMultiplier = 0.4f; + + // ── 날씨 배수 ── + + /// 좋아하는 날씨의 종 가중 배수. + public const float FavorableWeatherMultiplier = 1.5f; + + /// 싫어하는 날씨의 종 가중 배수. + public const float UnfavorableWeatherMultiplier = 0.6f; + + // ── 합친 배수의 범위 ── + // + // 시간 × 날씨의 이론 범위는 0.21(야행성의 낮 + 싫은 날씨) ~ 3.75(야행성의 밤 + 좋은 날씨)다. 아래 상·하한은 + // 상수를 고치다 범위를 넘는 걸 막는 안전망이다 — 하한은 0이 아니다. 0이면 그 종이 후보에서 빠지고, 풀의 한 등급이 + // 통째로 비면 등급 대체가 돌아 등급 분포가 시간대를 따라 움직인다(전역 등급표가 깨진다). + public const float MinSpawnMultiplier = 0.15f; + public const float MaxSpawnMultiplier = 4.0f; + + // ── 전투 보정(다음 단계가 소비) ── + + /// 유리한 상태( +1)의 능력치 배수 — +10%. + public const float FavorableBattleMultiplier = 1.10f; + + /// 불리한 상태( −1)의 능력치 배수 — −10%. + public const float UnfavorableBattleMultiplier = 0.90f; + + private const float FitEpsilon = 1e-4f; + + // 표는 InsectHabitTable.cs가 채운다(저작 데이터와 규칙을 갈라 두었다). + private static readonly Dictionary table = BuildTable(); + + // 표에 적은 줄 수 — 같은 종을 두 줄 적으면 뒤 줄이 앞 줄을 덮는다(조용히). 테스트가 Count와 비교한다. + private static int authoredRows; + + /// 표에 성향이 적힌 종의 수. + public static int Count => table.Count; + + /// 표에 적은 줄 수 — 보다 크면 같은 종이 두 줄 적혔다. + internal static int AuthoredRows => authoredRows; + + /// 표에 성향이 적힌 종 ID 전부(테스트·검증용). + public static IEnumerable AllIds => table.Keys; + + /// 표에 있으면 그 종의 성향. + public static bool TryGet(string insectId, out InsectHabit habit) + { + habit = InsectHabit.Neutral; + return !string.IsNullOrEmpty(insectId) && table.TryGetValue(insectId, out habit); + } + + /// 종 ID의 성향. 표에 없는 종은 이다 — 새로 늘린 종이 필드에서 사라지지 않는다. + public static InsectHabit Of(string insectId) + { + return TryGet(insectId, out InsectHabit habit) ? habit : InsectHabit.Neutral; + } + + /// 종 데이터의 성향(null이면 중립). + public static InsectHabit For(InsectData data) + { + return data != null ? Of(data.insectId) : InsectHabit.Neutral; + } + + /// 이 날씨가 날씨 집합에 들어 있는가. + public static bool Contains(WeatherSet set, WeatherType weather) + { + return (set & SetOf(weather)) != 0; + } + + /// 날씨 하나만 든 집합 — 비트 위치가 값이다. + public static WeatherSet SetOf(WeatherType weather) + { + int bit = (int)weather; + return bit >= 0 && bit < 8 ? (WeatherSet)(1 << bit) : WeatherSet.None; + } + + // ── 시간대 ── + + /// 시간대가 이 활동형 종의 가중에 곱하는 배수(0.35~2.5). + public static float TimeMultiplier(InsectActivity activity, DayPhase phase) + { + switch (activity) + { + case InsectActivity.Nocturnal: + switch (phase) + { + case DayPhase.Night: return NocturnalNightMultiplier; + case DayPhase.Morning: return NocturnalMorningMultiplier; + case DayPhase.Day: return NocturnalDayMultiplier; + default: return NocturnalEveningMultiplier; + } + case InsectActivity.Diurnal: + switch (phase) + { + case DayPhase.Day: return DiurnalDayMultiplier; + case DayPhase.Morning: return DiurnalMorningMultiplier; + case DayPhase.Evening: return DiurnalEveningMultiplier; + default: return DiurnalNightMultiplier; + } + default: + return 1f; + } + } + + /// 시간대 궁합 — +1(제 시간) / 0(어스름·시간 무관) / −1(잠잘 때). 배수가 1보다 큰가 작은가로 정한다. + public static int TimeFit(InsectHabit habit, DayPhase phase) + { + float m = TimeMultiplier(habit.Activity, phase); + if (m > 1f + FitEpsilon) return 1; + if (m < 1f - FitEpsilon) return -1; + return 0; + } + + /// + /// 새 시간대가 이 종에게 어울리지 않는가(잠들 시간) — 시간대가 바뀔 때 스포너가 이런 개체의 만료를 앞당겨 + /// 새 시간대에 맞는 종으로 갈아입힌다(InsectSpawner.OnDayPhaseChanged). 어스름·시간 무관은 해당 없다. + /// + public static bool IsOutOfPhase(InsectHabit habit, DayPhase phase) + { + return TimeFit(habit, phase) < 0; + } + + // ── 날씨 ── + + /// 날씨 궁합 — +1(좋아함) / −1(싫어함) / 0. 두 집합에 다 들어 있으면 서로 지워 0이다(표는 겹치지 않게 적는다). + public static int WeatherFit(InsectHabit habit, WeatherType weather) + { + int fit = 0; + if (Contains(habit.LikedWeather, weather)) fit++; + if (Contains(habit.DislikedWeather, weather)) fit--; + return fit; + } + + /// 날씨가 이 종의 가중에 곱하는 배수 — 좋아하면 ×1.5, 싫어하면 ×0.6. + public static float WeatherMultiplier(InsectHabit habit, WeatherType weather) + { + int fit = WeatherFit(habit, weather); + if (fit > 0) return FavorableWeatherMultiplier; + if (fit < 0) return UnfavorableWeatherMultiplier; + return 1f; + } + + // ── 합친 판정 ── + + /// + /// 시간대와 날씨를 합친 궁합 — −1 불리 / 0 보통 / +1 유리. 둘을 더해 −1~+1로 가둔다: 제 시간에 싫은 날씨를 만나면 + /// 0(보통), 둘 다 좋으면 +1, 둘 다 나쁘면 −1이다. 전투 보정과 습격 판정이 이 값을 읽는다. + /// + public static int Fit(InsectHabit habit, WorldState state) + { + return Mathf.Clamp(TimeFit(habit, state.DayPhase) + WeatherFit(habit, state.Weather), -1, 1); + } + + /// + /// 필드 종 고르기의 가중 배수 — 시간대 배수 × 날씨 배수를 [, ]로 가둔다. + /// 절대 0이 되지 않는다. 같은 등급 안의 상대 비율만 바꾸고 등급표는 건드리지 않는다. + /// + public static float SpawnWeightMultiplier(InsectHabit habit, WorldState state) + { + float m = TimeMultiplier(habit.Activity, state.DayPhase) * WeatherMultiplier(habit, state.Weather); + return Mathf.Clamp(m, MinSpawnMultiplier, MaxSpawnMultiplier); + } + + /// + /// 전투 능력치 배수(BattleEnvironment가 소비) — 유리하면 +10%, 불리하면 −10%. + /// 시간과 날씨가 서로 지울 수 있어() 어중간한 상태에서는 1이다. + /// + public static float BattleStatMultiplier(InsectHabit habit, WorldState state) + { + int fit = Fit(habit, state); + if (fit > 0) return FavorableBattleMultiplier; + if (fit < 0) return UnfavorableBattleMultiplier; + return 1f; + } + + /// + /// 이 상태에서 습격형이 깨어 있는가 — 습격(AmbushRules)이 읽는다. 어두울 때만(), + /// 그리고 궁합이 유리할 때다. 그래서 야행성 습격형은 밤에(싫어하는 날씨가 아니면), 저녁에는 좋아하는 날씨일 때만 덤벼들고, + /// 주행성 습격형(말벌류)은 해 질 녘에 좋아하는 날씨일 때만 덤벼든다. 아침·낮에는 아무도 싸움을 걸지 않는다. + /// + /// 사용자 요청이 "밤에 싸움을 거는 곤충"이다. 처음엔 시간대 제한 없이 궁합만 봐서 주행성 말벌·사마귀가 한낮에 깨었고, + /// 맑은 낮(필드 한 칸의 4.6%)이 맑은 밤(4.1%)만큼 사나웠다 — 정원·숲은 오히려 낮이 더 사나웠다(2026-10-03). + /// 온순한 종은 언제나 false다. + /// + public static bool IsAmbusher(InsectHabit habit, WorldState state) + { + return habit.Temperament == InsectTemperament.Ambusher && IsAmbushHour(state.DayPhase) && Fit(habit, state) > 0; + } + + /// 습격이 일어날 수 있는 시간대 — 저녁(17~21시)과 밤(21~6시). + public static bool IsAmbushHour(DayPhase phase) => phase == DayPhase.Evening || phase == DayPhase.Night; + } +} diff --git a/Assets/Scripts/Data/InsectHabits.cs.meta b/Assets/Scripts/Data/InsectHabits.cs.meta new file mode 100644 index 0000000..4dedf48 --- /dev/null +++ b/Assets/Scripts/Data/InsectHabits.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c7c53ae33f82dd841ab0f0e75a4f302a \ No newline at end of file diff --git a/Assets/Scripts/Data/InsectRarity.cs b/Assets/Scripts/Data/InsectRarity.cs index 4995e74..acd3ef2 100644 --- a/Assets/Scripts/Data/InsectRarity.cs +++ b/Assets/Scripts/Data/InsectRarity.cs @@ -8,4 +8,23 @@ public enum InsectRarity Epic, Legendary } + + /// + /// 등급의 화면 표기 단일 출처. 도감이 자기 파일에 한글 표를 두고, 배틀팀·보유 곤충·포획 창은 + /// enum 이름을 그대로 찍어 한 게임 안에서 "희귀"와 "Rare"가 섞여 나왔다. + /// + public static class InsectRarityText + { + public static string Korean(this InsectRarity rarity) + { + switch (rarity) + { + case InsectRarity.Uncommon: return "고급"; + case InsectRarity.Rare: return "희귀"; + case InsectRarity.Epic: return "영웅"; + case InsectRarity.Legendary: return "전설"; + default: return "일반"; + } + } + } } diff --git a/Assets/Scripts/Data/InsectRewardCalculator.cs b/Assets/Scripts/Data/InsectRewardCalculator.cs index 2a0e47b..5ac6572 100644 --- a/Assets/Scripts/Data/InsectRewardCalculator.cs +++ b/Assets/Scripts/Data/InsectRewardCalculator.cs @@ -1,7 +1,14 @@ +using InsectGame.Core; +using UnityEngine; + namespace InsectGame.Data { public static class InsectRewardCalculator { + /// + /// 등급만 본 기준 EXP. 실제 지급은 레벨을 받는 오버로드를 쓴다 — 이것만 쓰면 Lv60 곤충도 + /// Lv1과 같은 EXP를 준다(2026-10-01 전까지 실제로 그랬다). + /// public static int GetExpReward(InsectData data) { if (data == null) @@ -14,6 +21,24 @@ public static int GetExpReward(InsectData data) return reward < 0 ? 0 : reward; } + /// + /// 캐릭터가 실제로 받는 EXP — 등급 × 곤충 레벨 × 캐릭터와의 레벨 차(). + /// 포획·전투·NPC 대결·레이드가 모두 이걸 부른다. 은 지급 전 레벨이다 — + /// 지급 뒤에 읽으면 방금 오른 레벨로 차를 재서 표시값과 지급값이 갈린다. + /// 아이템·의상 EXP 부스터는 호출부가 곱한다. + /// + public static int GetExpReward(InsectData data, int insectLevel, int trainerLevel) + { + if (data == null) + { + return 0; + } + + float scaled = data.expReward * GetRarityMultiplier(data.rarity) + * TrainerLevelGap.ExpMultiplier(trainerLevel, insectLevel); + return Mathf.Max(0, Mathf.RoundToInt(scaled)); + } + public static int GetCandyReward(InsectData data) { if (data == null) @@ -38,7 +63,11 @@ public static int GetItemRewardCount(InsectData data) return reward < 0 ? 0 : reward; } - private static float GetRarityMultiplier(InsectRarity rarity) + /// + /// 등급 배율(보상·훈련 공용). 능력치 훈련 비용도 이 표를 곱한다 — 희귀할수록 잡을 때 캔디를 + /// 더 주는 만큼 완성하는 데도 더 든다. 표가 두 벌이면 한쪽만 고쳐져 어긋난다. + /// + public static float GetRarityMultiplier(InsectRarity rarity) { switch (rarity) { diff --git a/Assets/Scripts/Data/SubAreaData.cs b/Assets/Scripts/Data/SubAreaData.cs index fcb89f7..b105cfb 100644 --- a/Assets/Scripts/Data/SubAreaData.cs +++ b/Assets/Scripts/Data/SubAreaData.cs @@ -14,6 +14,12 @@ public class SubAreaData public int minLevel = 1; public int maxLevel = 10; public string environmentType; + /// + /// 리전에 속하지 않고 위치와 무관하게 드나드는 분리 구역(나의 섬). RegionManager.EnterDetachedSubArea로만 + /// 들어가고, SubAreaWorldBuilder는 방을 짓지 않는다 — 그 구역의 주인(IslandWorldBuilder)이 짓는다. + /// "숨겨진 장소 방문" 퀘스트도 세지 않는다. + /// + public bool detached; public bool ContainsPoint(Vector3 point) { diff --git a/Assets/Scripts/Dex/DexDetailUIController.cs b/Assets/Scripts/Dex/DexDetailUIController.cs index 6564d9d..e6ed8d0 100644 --- a/Assets/Scripts/Dex/DexDetailUIController.cs +++ b/Assets/Scripts/Dex/DexDetailUIController.cs @@ -61,12 +61,12 @@ public void Show(string insectId) if (rarityText != null) { - rarityText.text = discovered ? $"등급: {data.rarity}" : "등급: ???"; + rarityText.text = discovered ? $"등급: {data.rarity.Korean()}" : "등급: ???"; rarityText.color = discovered ? GetRarityColor(data.rarity) : Color.white; } if (rarityTextTmp != null) { - rarityTextTmp.text = discovered ? $"등급: {data.rarity}" : "등급: ???"; + rarityTextTmp.text = discovered ? $"등급: {data.rarity.Korean()}" : "등급: ???"; rarityTextTmp.color = discovered ? GetRarityColor(data.rarity) : Color.white; } diff --git a/Assets/Scripts/Dex/DexScreenUI.cs b/Assets/Scripts/Dex/DexScreenUI.cs index d81919b..a95c397 100644 --- a/Assets/Scripts/Dex/DexScreenUI.cs +++ b/Assets/Scripts/Dex/DexScreenUI.cs @@ -14,6 +14,7 @@ public class DexScreenUI : MonoBehaviour, IModalUI private float previewAngle = 150f; // 도감 곤충 프리뷰 Y회전(좌우 버튼으로 시점 변경) private bool previewShiny; // 이로치(색다른 모습) 프리뷰 토글 [SerializeField] private PlayerInsectCollection insectCollection; + [SerializeField] private CollectionUI collectionUI; [SerializeField] private PlayerItemInventory itemInventory; // 아이템 표시명·설명 폴백(LookupItem) — 아래 switch에 없는 아이템을 생 ID로 뿌리지 않게. [SerializeField] private ItemDatabase itemDatabase; @@ -495,7 +496,10 @@ private void OnGUI() // 세이프에어리어 + 세로 마진을 뺀 전체 콘텐츠 영역(도감은 전체화면이라 여기가 곧 패널). Rect safeRect = UISafeLayout.Content; + bool previousEnabled = GUI.enabled; + GUI.enabled = previousEnabled && !detailModalOpen; float topBarH = DrawTopBar(safeRect); + GUI.enabled = previousEnabled; Rect contentRect = new Rect( safeRect.x + 10f, safeRect.y + topBarH + 10f, @@ -587,7 +591,10 @@ private void DrawPokedex(Rect contentRect) { // 좌우 분할(좌 34% 목록 + 우 상세)을 버리고 전체 폭 그리드 하나만 그린다. // 상세는 그 위에 중앙 모달로 겹친다 — 가로/세로가 같은 경로를 탄다. + bool previousEnabled = GUI.enabled; + GUI.enabled = previousEnabled && !detailModalOpen; DrawDexGrid(contentRect); + GUI.enabled = previousEnabled; if (detailModalOpen && selectedIndex >= 0 @@ -766,19 +773,19 @@ private void DrawDetailModal(InsectData insect) float panelH = UISafeLayout.ClampHeight(UIScale.IsPortrait ? 1500f : 920f); Rect panelRect = UISafeLayout.CenteredPanel(panelW, panelH); - // 딤 클릭 → 닫기. 패널 자체에도 투명 버튼을 겹쳐 깔아(아래 흡수 버튼) - // 패널 빈 곳 클릭이 여기까지 새지 않게 한다. IMGUI는 나중에 그린 쪽이 이긴다. - if (GUI.Button( - new Rect(0f, 0f, UIScale.VirtualScreenWidth, UIScale.VirtualScreenHeight), - string.Empty, GUIStyle.none)) + // 패널 밖 클릭만 닫는다. 화면 전체/패널 전체에 GUI.Button을 깔면 + // IMGUI의 첫 번째 컨트롤이 hotControl을 선점해 내부 버튼이 눌리지 않는다. + Event evt = Event.current; + if (evt != null && evt.type == EventType.MouseDown && evt.button == 0 + && !panelRect.Contains(UIScale.VirtualMousePosition)) { detailModalOpen = false; ScrollToSelected(); + evt.Use(); return; } DrawRoundedCard(panelRect, DetailBg, CardBorderCol); - GUI.Button(panelRect, string.Empty, GUIStyle.none); // 클릭 흡수 (반환값 의도적 무시) float navHeight = 74f; float navBtnH = Mathf.Max(UIScale.MinTouchHeight, 58f); @@ -841,6 +848,9 @@ private float GetDetailContentHeight(InsectData insect, bool found, bool caught) } float height = caught ? 1110f : 830f; + // Portrait stacks the model above a full-width information column. + if (caught && UIScale.IsPortrait) + height += 360f; if (string.IsNullOrEmpty(insect.habitatHint)) { height -= 100f; @@ -852,17 +862,7 @@ private float GetDetailContentHeight(InsectData insect, bool found, bool caught) return height; } - private string GetRarityLabel(InsectRarity rarity) - { - switch (rarity) - { - case InsectRarity.Uncommon: return "고급"; - case InsectRarity.Rare: return "희귀"; - case InsectRarity.Epic: return "영웅"; - case InsectRarity.Legendary: return "전설"; - default: return "일반"; - } - } + private static string GetRarityLabel(InsectRarity rarity) => rarity.Korean(); private void DrawElementBadges( Rect area, @@ -946,10 +946,10 @@ private void DrawDetail(float x, float y, float w, float h, InsectData ins) // 곤충 3D 프리뷰를 좌측에 크게(세로 가득), 텍스트는 우측 컬럼으로 재배치. // 가로 공간(detailW ~1400px)이 대부분 낭비되던 것을 활용 → 이미지 대형화, 폰트 크기는 불변. float rightEdge = x + w; - // 절대 상한(380) 추가 — w는 상세 패널 전체 폭(~1400px)이라 비율만으론 패널 따라 계속 커짐. + // 절대 상한(480) 추가 — w는 상세 패널 전체 폭(~1400px)이라 비율만으론 패널 따라 계속 커짐. // 2-인자 Min 중첩(3-인자는 params float[] 할당 — OnGUI 핫패스 회피). - float previewSz = Mathf.Min(Mathf.Min(h - 30f, w * 0.42f), 380f); - float boxX = x + 10f; + float previewSz = Mathf.Min(Mathf.Min(h - 30f, w * 0.44f), 480f); + float boxX = UIScale.IsPortrait ? x + (w - previewSz) * 0.5f : x + 10f; float boxY = y + 20f; Color portBg = rarityCol; @@ -994,14 +994,25 @@ private void DrawDetail(float x, float y, float w, float h, InsectData ins) previewShiny = !previewShiny; if (ownsShiny) GUI.Label( - new Rect(boxX, boxY + previewSz - 112f, previewSz, LineH(caughtSCache)), + new Rect(boxX, boxY + previewSz + 8f, previewSz, LineH(caughtSCache)), "★ 색다른 개체 보유 중", caughtSCache); - // 이후 모든 텍스트는 우측 컬럼에 그림 — x/w/cx/py만 재배치(폰트 스타일 불변). - x = boxX + previewSz + 30f; - w = rightEdge - x - 10f; - cx = x + w / 2f; - py = y + 20f; + if (UIScale.IsPortrait) + { + // 세로 화면은 두 열로 나누면 긴 한국어와 수치가 잘린다. + // 모델 아래의 전체 폭을 정보에 사용하고 스크롤 높이를 위에서 늘린다. + x += 20f; + w = rightEdge - x - 10f; + cx = x + w / 2f; + py = boxY + previewSz + (ownsShiny ? 72f : 24f); + } + else + { + x = boxX + previewSz + 30f; + w = rightEdge - x - 10f; + cx = x + w / 2f; + py = y + 20f; + } } else if (found) { @@ -1076,7 +1087,7 @@ private void DrawDetail(float x, float y, float w, float h, InsectData ins) float infoBoxX = x + 20; float infoBoxW = w - 40; DrawRoundedCard( - new Rect(infoBoxX, py, infoBoxW, caught ? 396 : 120), + new Rect(infoBoxX, py, infoBoxW, caught ? 436 : 120), InfoBoxBg, CardBorderCol); @@ -1122,9 +1133,10 @@ private void DrawDetail(float x, float y, float w, float h, InsectData ins) if (!string.IsNullOrEmpty(ins.habitatHint)) { py += 10; - GUI.Label(new Rect(infoBoxX + 16, py, 90, 40), "서식지", habitatLabelCache); + // 라벨 폭 128 — 90일 때 32pt "서식지"의 마지막 글자가 잘려 "서식"으로 보였다(데스크톱·세로 둘 다). + UIHelper.LabelFit(new Rect(infoBoxX + 16, py, 128, 44), "서식지", habitatLabelCache); // 값 라벨 높이 확대(40→84) + wordWrap으로 긴 서식지 설명이 여러 줄로 온전히 표시. - GUI.Label(new Rect(infoBoxX + 110, py, lw - 110, 84), ins.habitatHint, habitatValCache); + GUI.Label(new Rect(infoBoxX + 150, py, lw - 150, 84), ins.habitatHint, habitatValCache); py += 92; } @@ -1227,24 +1239,25 @@ private void DrawOwnedCard(float x, float y, float w, float h, PlayerInsectData { // 보유 곤충은 전부 포획 완료라 감쇠가 없다 — 여기 있던 `ic`도 그리드 타일과 같은 // "계산만 하고 못 넘기는 색"이었고, 이쪽은 넘길 곳조차 없어 그냥 죽은 지역 변수였다. - DrawRoundedRect(new Rect(x + 20f, y + 40f, 74f, 94f), DetailUnknownBg); - InsectVisual.Draw(x + 57f, y + h / 2f + 4, 78f, data, pid != null && pid.isShiny, 1f); + // 틀을 키운다 — 74×94에 78px 모델이라 곤충이 카드 안에서 성냥갑만 했다(2026-09-30 검수 캡처). + DrawRoundedRect(new Rect(x + 22f, y + (h - 112f) / 2f, 96f, 112f), Color.Lerp(DetailUnknownBg, rc, 0.14f)); + InsectVisual.Draw(x + 70f, y + h / 2f, 100f, data, pid != null && pid.isShiny, 1f); } string name = data != null ? data.displayName : pid.insectId; ownedNameCache.normal.textColor = rc; // 이름 폭은 우측 등급 컬럼(x+w-90) 앞까지로 제한 — 겹침 방지. // 좁아진 그 폭에 데이터가 정하는 이름을 넣으므로 LabelFit으로 줄여 맞춘다(ui-layout.md). - UIHelper.LabelFit(new Rect(x + 108, y + 12, w - 204, 52), name, ownedNameCache); + UIHelper.LabelFit(new Rect(x + 130, y + 12, w - 226, 52), name, ownedNameCache); string rStr = data != null ? GetRarityLabel(data.rarity) : "?"; // IV%는 우하단(x+w-90, y+82)과 아래 IV 상세줄에 이미 표시되므로 중간줄에선 생략 — // 폰트 확대(→34) + 좁은 폭(w-176≈269px)에 "Lv | 등급 | IV%"를 넣으면 뒤가 잘리던 회귀 차단. - GUI.Label(new Rect(x + 108, y + 70, w - 204, LineH(ownedInfoCache)), + GUI.Label(new Rect(x + 130, y + 70, w - 226, LineH(ownedInfoCache)), $"Lv.{pid.level} | {rStr}", ownedInfoCache); - GUI.Label(new Rect(x + 108, y + 126, w - 132, LineH(ownedStCache)), - $"HP:{pid.ivHp} ATK:{pid.ivAtk} DEF:{pid.ivDef}", ownedStCache); + UIHelper.LabelFit(new Rect(x + 130, y + 126, Mathf.Max(1f, w - 308f), LineH(ownedStCache)), + $"IV {pid.ivHp}/{pid.ivAtk}/{pid.ivDef}", ownedStCache); Color gc = UITheme.Instance.GetGradeColor(pid.Grade); ownedGrCache.normal.textColor = gc; @@ -1256,6 +1269,24 @@ private void DrawOwnedCard(float x, float y, float w, float h, PlayerInsectData ownedPctCache.normal.textColor = pctCol; GUI.Label(new Rect(x + w - 90, y + 82, 80, LineH(ownedPctCache)), $"{pid.IVPercent * 100:0}%", ownedPctCache); + + // 같은 종을 여러 마리 보유할 수 있으므로 종ID가 아닌 개체 고유ID로 연다. + Rect detailButton = new Rect(x + w - 166f, y + h - 62f, 150f, 54f); + if (DrawCuteButton(detailButton, "개체 정보", NavButtonBg, navButtonStyleCache) + && collectionUI != null + && collectionUI.OpenDetailForInstance(pid.instanceId)) + { + CloseModal(); + } + + Rect cardHit = new Rect(x, y, w, h); + if (GUI.Button(cardHit, string.Empty, GUIStyle.none) + && !ownedDirectScroll.IsDragging + && collectionUI != null + && collectionUI.OpenDetailForInstance(pid.instanceId)) + { + CloseModal(); + } } private void DrawItems(Rect contentRect) @@ -1627,6 +1658,11 @@ public void AutoWire(PlayerInsectCollection col, PlayerItemInventory items) if (itemInventory == null) itemInventory = items; } + public void AutoWire(CollectionUI collection) + { + if (collectionUI == null) collectionUI = collection; + } + public void AutoWire(InsectModelPreviewRenderer pr) { if (previewRenderer == null) previewRenderer = pr; diff --git a/Assets/Scripts/NPC/CatcherKidNpc.cs b/Assets/Scripts/NPC/CatcherKidNpc.cs index ca33790..9c8be6b 100644 --- a/Assets/Scripts/NPC/CatcherKidNpc.cs +++ b/Assets/Scripts/NPC/CatcherKidNpc.cs @@ -105,6 +105,7 @@ public void Initialize(NpcManager owner, NpcSpawnAnchor anchor, string id, int s rng = new System.Random(seed); animator = new NpcWalkAnimator(transform); groundY = transform.position.y; + SetStandY(0f); // 앵커가 둔덕 위면 그 윗면에 선다(평지는 그대로 — VillagerNpc.StandHeight) state = State.Idle; stateEndTime = 0f; cooldownUntilTime = 0f; @@ -199,9 +200,7 @@ public void TickMovement(float dt, float time) { // 점프 모션 — 남은 시간 기반 sin 바운스 float elapsed = CelebrateDuration - (stateEndTime - time); - Vector3 pos = transform.position; - pos.y = groundY + Mathf.Abs(Mathf.Sin(elapsed * 8f)) * 0.3f; - transform.position = pos; + SetStandY(Mathf.Abs(Mathf.Sin(elapsed * 8f)) * 0.3f); break; } @@ -364,9 +363,7 @@ private void EnterGiveUp(float time) private void EnterIdle(float time) { // Celebrate 점프 잔여 Y 복귀 - Vector3 pos = transform.position; - pos.y = groundY; - transform.position = pos; + SetStandY(0f); watchInsect = null; state = State.Idle; stateEndTime = time + RandomRange(1f, 3f); @@ -384,12 +381,24 @@ private bool MoveTowards(Vector3 target, float speed, float dt) if (IsBlockedAhead(dir, speed * dt)) return false; // 벽 관통 방지 — Wander는 Idle 복귀, Approach는 타임아웃→GiveUp Vector3 pos = transform.position + dir * (speed * dt); - pos.y = groundY; + // 둔덕은 콜라이더가 없어 groundY(레이)가 못 본다 — 매 스텝 그 자리 둔덕을 얹는다. 사구 위 곤충을 쫓아 올라간다. + pos.y = VillagerNpc.StandHeight(groundY, pos.x, pos.z); transform.position = pos; RotateTowards(dir, dt); return true; } + /// + /// 지금 자리 발 높이 + (축하 점프)로 선다. 발 높이 규칙은 주민과 하나다 + /// ( — 둔덕이 없는 곳에선 groundY 그대로). + /// + private void SetStandY(float lift) + { + Vector3 pos = transform.position; + pos.y = VillagerNpc.StandHeight(groundY, pos.x, pos.z) + lift; + transform.position = pos; + } + /// 진행 방향에 통행 불가 콜라이더(건물 벽 등)가 있는지 — 벽 관통 방지. private bool IsBlockedAhead(Vector3 dir, float step) { diff --git a/Assets/Scripts/NPC/DuelBanter.cs b/Assets/Scripts/NPC/DuelBanter.cs new file mode 100644 index 0000000..c62791b --- /dev/null +++ b/Assets/Scripts/NPC/DuelBanter.cs @@ -0,0 +1,320 @@ +using System.Collections.Generic; + +namespace InsectGame.NPC +{ + /// + /// 대결 상대가 전투 중에 하는 말 — 컷인(칭호·도발), 전투 중 한마디, 결과 한마디. + /// + /// 예전엔 간부전도 야생전과 똑같이 "○○와 마주쳤다" 한 줄로 시작해서, 관장과의 마지막 대결이 + /// 필드의 인분무와 구별되지 않았다. 대사는 스토리 비트(대결 전 대치 chN_confront — 그 마지막 줄의 도전 뒤 + /// 곧바로 대결이 열린다(StoryBeat.duelAfter) — ·대결 뒤 duel_*_win)가 이미 들고 있으니 여기엔 + /// 전투 한가운데에서만 할 수 있는 말만 둔다 — 겹치면 같은 말을 두 번 듣는다. + /// + /// 키는 대결 ID다. 간부는 storyNpcId 그대로이고(), 한 인물이 여러 번 + /// 싸우는 경우(라이벌 단계)는 별도 키를 쓴다 — 그래서 초상 인물()을 따로 든다. + /// + /// 순수 데이터 + 순수 계산이다(DuelBanterTests가 씬 없이 검증한다). 그리기는 BattleScreenUI.Duel. + /// + public static class DuelBanter + { + public struct Lines + { + /// 초상 인물 — NpcDialogueUI의 스토리 초상 표와 같은 ID. + public string npcId; + /// 표시명 — 스토리 대사창의 이름표와 같아야 한다(테스트가 대조한다). + public string name; + /// 컷인 칭호. 1막 하수는 정체가 밝혀지기 전이라 조직명을 쓰지 않는다. + public string title; + /// 컷인 도발 — 첫 합 전에. + public string intro; + /// 상대 곤충이 절반 아래로 — 흔들리기 시작한다. + public string half; + /// 상대 곤충이 위기 — 무너지기 직전. + public string crisis; + /// 내 곤충이 위기 — 상대가 몰아붙인다. + public string pressing; + /// 플레이어가 이겼을 때 상대의 마지막 말. + public string defeat; + /// 플레이어가 졌을 때 상대의 말. + public string victory; + /// + /// 팀 대결의 교체 한마디 — 상대가 다음 곤충을 내보낼 때. [0]이 두 번째 곤충, [1]이 세 번째 곤충… + /// 명부회 간부는 여럿을 데리고 싸운다(집게·저울 셋, 하월 다섯 — NpcBossDuels). 한 마리만 내는 상대(하수·라온)는 + /// 비워 둔다. 줄이 모자라면 마지막 줄을 되풀이한다 — 이 고른다. + /// 그리기는 ui-dev(BattleScreenUI.Duel)가 교체 순간에 말풍선으로 띄운다. + /// + public string[] sendOut; + } + + public enum Moment { None, Half, Crisis, Pressing } + + public const float HalfRatio = 0.5f; + public const float CrisisRatio = 0.2f; + public const float PressingRatio = 0.25f; + + /// 한 전투에서 이미 나온 순간 — 각 순간은 한 번만 말한다. + public struct Tracker + { + public bool half; + public bool crisis; + public bool pressing; + } + + /// + /// 이번 HP 비율로 새로 넘은 순간 하나. 한 번에 절반과 위기를 같이 넘으면 위기만 말한다 + /// (절반 대사는 건너뛴다 — "제법이군" 다음 프레임에 "이럴 리가"가 겹치면 둘 다 안 읽힌다). + /// 상대가 먼저다: 같은 교환에서 둘 다 넘었으면 상대 쪽을 말하고, 내 쪽은 다음 호출에서 나온다. + /// 0 이하(쓰러짐)는 말하지 않는다 — 그 자리는 결과 대사가 맡는다. + /// + public static Moment Next(ref Tracker tracker, float enemyRatio, float playerRatio) + { + if (enemyRatio > 0f) + { + if (!tracker.crisis && enemyRatio <= CrisisRatio) + { + tracker.crisis = true; + tracker.half = true; + return Moment.Crisis; + } + if (!tracker.half && enemyRatio <= HalfRatio) + { + tracker.half = true; + return Moment.Half; + } + } + if (!tracker.pressing && playerRatio > 0f && playerRatio <= PressingRatio) + { + tracker.pressing = true; + return Moment.Pressing; + } + return Moment.None; + } + + /// + /// 상대 HP로 정하는 순간(흔들림·위기)을 지금 나와 있는 곤충으로 말해도 되는가 — 팀 대결이면 에이스(마지막 곤충)에서만. + /// + /// 간부는 여럿을 데리고 싸운다(집게·저울 셋, 하월 다섯 — NpcBossDuels.teamInsectIds). 대사는 그 사람의 마지막 패를 두고 쓴 + /// 말이라("이 손이… 밀린다고?", "삼십 년 전에도 이 아이는 이렇게 날았지"), 첫 곤충이 절반 아래로 떨어진 순간에 나오면 아직 셋이 남았는데 + /// 무너지는 소리를 한다. 앞 곤충들 사이는 교체 한마디()가 맡는다. + /// 내 곤충이 몰리는 순간()은 상대 팀과 무관하다 — 막지 않는다. + /// + /// 상대 팀 크기(InsectBattleController.EnemyTeamSize). 1 이하면 한 마리 대결. + /// 지금 나와 있는 상대의 순번(0부터, EnemyTeamIndex). + public static bool EnemyMomentsAllowed(int enemyTeamSize, int enemyTeamIndex) + { + if (enemyTeamSize <= 1) return true; + return enemyTeamIndex >= enemyTeamSize - 1; + } + + /// + /// 팀 대결판 — 에이스가 나오기 전엔 상대 HP 순간을 건너뛴다(트래커도 건드리지 않아 + /// 에이스가 나온 뒤 처음부터 센다). 그리기(BattleScreenUI.Duel)가 팀 크기·순번을 넘겨 부른다. + /// + public static Moment Next(ref Tracker tracker, float enemyRatio, float playerRatio, int enemyTeamSize, int enemyTeamIndex) + { + float enemy = EnemyMomentsAllowed(enemyTeamSize, enemyTeamIndex) ? enemyRatio : 1f; + return Next(ref tracker, enemy, playerRatio); + } + + public static string LineFor(Lines lines, Moment moment) + { + switch (moment) + { + case Moment.Half: return lines.half; + case Moment.Crisis: return lines.crisis; + case Moment.Pressing: return lines.pressing; + default: return null; + } + } + + /// + /// 상대가 번째 곤충(0부터 — 첫 곤충은 0, 처음 교체해 나오는 곤충이 1)을 내보낼 때의 한마디. + /// 첫 곤충(0)·줄이 없는 상대는 null이다(첫 곤충의 말은 컷인 도발 가 맡는다). + /// 준비한 줄보다 팀이 길면 마지막 줄을 쓴다 — 팀 크기가 바뀌어도 말이 끊기지 않게. + /// + public static string SendOutLine(Lines lines, int incomingIndex) + { + if (incomingIndex <= 0 || lines.sendOut == null || lines.sendOut.Length == 0) return null; + int i = incomingIndex - 1; + if (i >= lines.sendOut.Length) i = lines.sendOut.Length - 1; + return lines.sendOut[i]; + } + + public static bool TryGet(string duelId, out Lines lines) + { + lines = default; + return !string.IsNullOrEmpty(duelId) && Table.TryGetValue(duelId, out lines); + } + + /// 표의 키 전체 — 테스트용. + public static IEnumerable Ids => Table.Keys; + + private static readonly Dictionary Table = new Dictionary + { + // ── 1막 하수 ── 이름도 조직도 모르는 채 싸운다. 칭호는 그들이 한 일로 부른다. + ["ledger_thug_pin"] = new Lines + { + npcId = "ledger_thug_pin", name = "검은 옷의 청년", title = "숲 그물터 당번", + intro = "…싸우라니까 싸우는 거야. 원망은 위에다 해.", + half = "…생각보다 아프네.", + crisis = "잠깐, 이건 내 몫이 아니라니까…!", + pressing = "봐, 그물이 제일 빨라. 한 번에 끝나잖아.", + defeat = "…졌다. 그물은 네가 걷어. 난 모르는 일이야.", + victory = "…미안. 치라는 대로 칠 뿐이야.", + }, + ["ledger_thug_rule"] = new Lines + { + npcId = "ledger_thug_rule", name = "검은 옷의 여자", title = "산길을 막는 자", + intro = "하나씩은 느리다고 했지. 보여 주지.", + half = "…제법이네. 그래도 방법이 틀렸어.", + crisis = "이럴 리가— 계산이 안 맞아.", + pressing = "봐. 한 번에 거두는 쪽이 빠르다고.", + defeat = "…오늘 장부엔 내가 적히겠군. 진 쪽으로.", + victory = "지키는 쪽은 우리야. 비켜.", + }, + ["ledger_thug_cord"] = new Lines + { + npcId = "ledger_thug_cord", name = "검은 옷의 사내", title = "이름을 묻지 않는 자", + intro = "이름은 필요 없다. 종만 대라.", + half = "…종이 아니라 이름으로 싸우는군.", + crisis = "이 녀석… 장부에 없는 움직임이다.", + pressing = "끝이다. 곧 장부에 오를 테니.", + defeat = "…이름이 이겼군. 적어 두지.", + victory = "종 하나. 적었다.", + }, + // ── 2막 명부회 ── 대결 뒤 스토리 비트(duel_*_win)가 바로 이어지므로 패배 대사는 짧게 끊는다. + ["ledger_grip"] = new Lines + { + npcId = "ledger_grip", name = "집게", title = "명부회 포획반장", + intro = "말은 끝났다. 손으로 증명해라.", + half = "…버티는군. 그물에 걸린 놈들은 이렇게 안 버텼는데.", + crisis = "이 손이… 밀린다고?", + pressing = "그래, 그거다. 걸린 놈은 결국 이렇게 된다.", + defeat = "크윽… 지네가 먼저 물러서다니.", + victory = "봐라. 한 마리씩으로는 안 된다.", + sendOut = new[] + { + "다음이다. 물러서지 마라.", + // 셋째가 에이스 지네(centipede_sand)다 — 패배 한마디("지네가 먼저 물러서다니")와 이어진다. + "마지막이다. 지네야, 끝까지 물고 늘어져라!", + }, + }, + ["ledger_scale"] = new Lines + { + npcId = "ledger_scale", name = "저울", title = "명부회 분류관", + intro = "재 보자. 네 쪽 말이 몇 근인지.", + half = "…오차 범위 밖이군.", + crisis = "수치가 맞지 않는다. 다시 잰다—", + pressing = "예상대로다. 네 곤충의 등급은 거기까지다.", + defeat = "……측정 종료.", + victory = "감상은 수치를 못 이긴다.", + sendOut = new[] + { + "다음 표본. 수치는 거짓말을 안 한다.", + "마지막 표본이다. 이번엔 틀림없이 잰다.", + }, + }, + ["ledger_chief"] = new Lines + { + npcId = "ledger_chief", name = "관장 하월", title = "명부회 관장", + intro = "마지막 수업이다. 첫 장의 아이가 상대해 주지.", + half = "…삼십 년 전에도 이 아이는 이렇게 날았지.", + crisis = "그래. 한 마리씩 만난 손이… 이렇게 무겁구나.", + pressing = "서두르게. 늦으면 전부 놓친다 — 나처럼.", + defeat = "그 손을… 놓지 말게.", + victory = "아직이군. …다시 오게.", + sendOut = new[] + { + "다음 장을 넘기지.", + "이 아이도 내 장부에 적힌 이름이다.", + "삼십 년 치 장부다. 아직 남았네.", + // 다섯째가 이름 잃은 나방(moth_effaced) — 대치의 "이 아이를 넘어 보게"가 가리킨 그 아이다. + "마지막 장이다. 이름 잃은 아이야, 나가거라.", + }, + }, + // ── 라온 라이벌(NpcRivalDuels) ── 한 인물이 단계마다 싸우므로 키가 단계 ID다. + // 이기고 지는 말이 적대가 아니라 장난스럽다 — 라온은 끝까지 "누가 더 구하나"의 경쟁자다. + // 초원은 **첫 전투**일 수 있다(첫 만남 대사 직후 곧바로 붙는다) — 말을 쉽고 짧게 둔다. + ["rival_meadow"] = new Lines + { + npcId = "catcher_rival", name = "라온", title = "초원의 라이벌", + intro = "첫 판이다! 내 여치, 얕보면 큰코다쳐!", + half = "어? 생각보다 세잖아!", + crisis = "잠깐, 잠깐! 아직이야!", + pressing = "봤지? 사흘 걸려 잡은 보람이 있다니까!", + defeat = "졌다! …그래도 재밌었어. 또 하자!", + victory = "이겼다! 다음엔 너도 더 세져서 와!", + }, + ["rival_pond"] = new Lines + { + npcId = "catcher_rival", name = "라온", title = "초원의 라이벌", + intro = "승부다! 오늘은 잡은 수 말고, 누가 더 센지로!", + half = "와, 제법인데? 방금 그거 어떻게 한 거야?", + crisis = "잠깐잠깐, 아직 안 끝났어!", + pressing = "봐, 물가는 내가 먼저 다 훑었다니까!", + defeat = "으악, 졌다! …다음엔 안 봐준다?", + victory = "이겼다! 할머니한테 자랑해야지!", + }, + ["rival_swamp"] = new Lines + { + npcId = "catcher_rival", name = "라온", title = "초원의 라이벌", + intro = "습지에서 한 판 더! 이번엔 나도 준비 좀 했거든.", + half = "…여치가 겁먹었어. 너 요즘 더 세졌지?", + crisis = "버텨! 조금만 더 버텨!", + pressing = "어때, 이번엔 내 차례지!", + defeat = "하… 또 졌네. 너한테 지는 건 이상하게 안 분해.", + victory = "봤지? 나도 놀고만 있던 거 아니라고!", + }, + ["rival_hollow"] = new Lines + { + npcId = "catcher_rival", name = "라온", title = "텅 빈 들의 라이벌", + intro = "여기 너무 조용해. 우리가 시끄럽게 해 주자!", + half = "좋아, 이 소리! 들판이 깨어나는 것 같아.", + crisis = "버텨, 여치야! 여기서 지면 창피해!", + pressing = "조용한 데서는 내 여치가 더 잘 들어!", + defeat = "졌다. 그래도 들판에 소리가 났잖아.", + victory = "내가 이겼다! 들판아, 들었지?", + }, + ["rival_dunes"] = new Lines + { + npcId = "catcher_rival", name = "라온", title = "모래언덕의 라이벌", + intro = "덥다! 빨리 끝내고 상자 열러 가자!", + half = "모래바람 속에서도 잘 버티네!", + crisis = "모래가 눈에… 아니, 핑계 아니야!", + pressing = "상자 생각하니까 힘이 나거든!", + defeat = "졌어. 이 힘은 상자 여는 데 쓸게.", + victory = "이겼다! 이 기세로 창고까지 가자!", + }, + ["rival_frost"] = new Lines + { + npcId = "catcher_rival", name = "라온", title = "서릿길의 라이벌", + intro = "손이 곱아도 승부는 승부야.", + half = "…너, 이제 진짜 멀리 왔구나.", + crisis = "아직… 아직 한 수 남았어!", + pressing = "뒤만 보는 줄 알았지? 앞도 볼 줄 안다고!", + defeat = "졌다. …다음엔 네 옆에서 싸우는 쪽이 좋겠다.", + victory = "헤헤, 오늘은 내가 이겼다. 세라한테는 비밀!", + }, + ["rival_ember"] = new Lines + { + npcId = "catcher_rival", name = "라온", title = "잿불의 라이벌", + intro = "땅이 뜨거워. 금방 끝내고 애들 구하러 가자!", + half = "역시 너야. 등 맡기기 딱 좋겠어.", + crisis = "아직 아니야! 갱도 가기 전에 한 방!", + pressing = "불 속에서도 내 여치는 안 물러서!", + defeat = "졌다. 됐어, 이제 진짜 구하러 가자.", + victory = "이겼다! 이 기분으로 갱도까지 간다!", + }, + ["rival_final"] = new Lines + { + npcId = "catcher_rival", name = "라온", title = "동네 최강자", + intro = "팔도 다 나았어. 이번엔 진짜 겨루자!", + half = "그래, 이거지! 이게 네 진짜 실력이지!", + crisis = "하하… 역시 세다. 그래도 안 져!", + pressing = "누워 있는 동안 머릿속으로 백 판은 했거든!", + defeat = "완패다. 최강자 자리, 네 거야.", + victory = "동네 최강자는 아직 나다! 언제든 다시 와!", + }, + }; + } +} diff --git a/Assets/Scripts/NPC/DuelBanter.cs.meta b/Assets/Scripts/NPC/DuelBanter.cs.meta new file mode 100644 index 0000000..40532d4 --- /dev/null +++ b/Assets/Scripts/NPC/DuelBanter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f693c1dc90abf2f4096d8b91fcaa0b44 \ No newline at end of file diff --git a/Assets/Scripts/NPC/NpcBossDuels.cs b/Assets/Scripts/NPC/NpcBossDuels.cs index 16a043f..4bd2682 100644 --- a/Assets/Scripts/NPC/NpcBossDuels.cs +++ b/Assets/Scripts/NPC/NpcBossDuels.cs @@ -8,7 +8,8 @@ namespace InsectGame.NPC /// 간부는 고정 상대·고정 레벨이라야 서사의 벽으로 기능한다 — 준비가 덜 되면 진다. /// /// 순수 데이터라 MonoBehaviour 밖에 둔다(EditMode 테스트가 씬 없이 표를 검증한다). - /// 곤충 ID·아이템 ID는 각각 InsectExpansion2Definitions / ItemDatabase에 실재해야 한다. + /// 곤충 ID(팀 곤충 포함)·아이템 ID는 각각 곤충 DB(1막 부트스트랩 + 확장 정의 둘) / ItemDatabase에 실재해야 한다. + /// 간부는 팀으로 싸운다( → InsectBattleController.StartTeamDuel). /// 고정하는 곳이 둘로 나뉜다: 곤충·레벨·앵커·유일성은 NpcBossDuelTests가, /// 보상 아이템 실재성은 quest_lint.py가 본다(아이템 ID가 캡처아이템·상점 /// 진열/지급·ItemDatabase 네 소스의 합집합이라 그 레지스트리를 이미 모으는 쪽에 붙였다 — @@ -50,8 +51,43 @@ public struct BossDuel /// (NpcBossDuelTests가 전원 MinThreshold 이상을 강제한다). /// public int ledgerThreshold; + + /// + /// 팀 대결의 상대 곤충 — 앞에서부터 내보내는 순서, 마지막이 에이스다. 비어 있으면(하수) 한 마리 대결이다. + /// 에이스는 ·과 같아야 한다(NpcBossDuelTests) — + /// 표를 읽는 쪽(장부 프로브·위계 테스트·BGM 테스트)이 그 둘을 "이 인물의 대표 곤충"으로 읽는다. + /// 배열 초기화 대신 Team(…)·Levels(…)를 쓰는 이유: 검사기(quest_lint·blight_lint)가 표의 항목 + /// 블록을 첫 닫는 중괄호에서 자른다 — 항목 안에 중괄호를 두면 그 뒤의 필드를 못 읽는다. + /// + public string[] teamInsectIds; + + /// 와 같은 순서·같은 길이의 레벨. 오름차순(에이스가 가장 높다). + public int[] teamLevels; + + /// 여러 마리를 차례로 내보내는 팀 대결인가. + public bool IsTeam => teamInsectIds != null && teamInsectIds.Length > 1; + + /// 내보내는 곤충 수 — 한 마리 대결이면 1. + public int RosterSize => IsTeam ? teamInsectIds.Length : 1; + + /// 번째로 내보내는 곤충(0부터). 한 마리 대결이면 . + public string RosterInsectId(int slot) + { + if (!IsTeam) return insectId; + return slot >= 0 && slot < teamInsectIds.Length ? teamInsectIds[slot] : null; + } + + /// 번째 곤충의 레벨. 한 마리 대결이면 . + public int RosterLevel(int slot) + { + if (!IsTeam) return level; + return teamLevels != null && slot >= 0 && slot < teamLevels.Length ? teamLevels[slot] : level; + } } + private static string[] Team(params string[] insectIds) => insectIds; + private static int[] Levels(params int[] levels) => levels; + private const float RetryCooldown = 120f; private static readonly BossDuel[] Table = @@ -95,7 +131,11 @@ public struct BossDuel // 실수해도 배울 여유가 있어야 한다 — 받아 적기만 하는 말단이라는 설정과도 맞는다. ledgerThreshold = 9, }, - // 집게 — 포획반장. 완력형이라 땅속을 헤집는 지네를 부린다. + // ── 간부는 팀으로 싸운다 ── 하수는 한 마리, 간부는 셋, 관장은 다섯 — 마릿수가 곧 계급이다. + // 레벨은 한 마리씩 오르고 마지막이 에이스(insectId·level과 같다). 장부(ledgerThreshold)는 + // 곤충이 아니라 **인물의 것**이라 교체로 비워지지 않는다(InsectBattleController.SendOutNextEnemy). + // + // 집게 — 포획반장. 완력형이라 모래언덕 땅속을 헤집는 것들을 부린다 — 굴리고, 파고, 헤엄친다. new BossDuel { storyNpcId = "ledger_grip", displayName = "집게", @@ -103,8 +143,10 @@ public struct BossDuel rewardItemId = "net_gold", rewardCount = 2, retryCooldownSeconds = RetryCooldown, ledgerThreshold = 6, + teamInsectIds = Team("scarab_sand", "antlion_dune", "centipede_sand"), + teamLevels = Levels(52, 53, 54), }, - // 저울 — 분류관. 곤충을 수치로만 보는 사람답게 미동도 없는 사마귀를 세운다. + // 저울 — 분류관. 곤충을 수치로만 보는 사람답게 서릿길의 얼어붙은 것들을 세운다 — 마지막은 미동도 없는 사마귀. new BossDuel { storyNpcId = "ledger_scale", displayName = "저울", @@ -114,8 +156,11 @@ public struct BossDuel // 분류관이라 **간부 중에서도 빠르다**(5). 곤충을 등급과 수치로만 보는 사람이니 // 플레이어의 수도 수로 본다 — 같은 수가 두 번 나오는 순간 적힌다. ledgerThreshold = 5, + teamInsectIds = Team("beetle_rime", "stag_beetle_glacier", "mantis_icicle"), + teamLevels = Levels(56, 57, 58), }, - // 관장 하월 — 이름이 지워진 나방을 데리고 다닌다. 그가 만든 빈칸의 산 증거다. + // 관장 하월 — 30년 동안 여러 지역에서 장부에 적어 온 곤충들. 마지막은 이름이 지워진 나방 — + // 장부 첫 장의 그것, 그가 만든 빈칸의 산 증거다. new BossDuel { storyNpcId = "ledger_chief", displayName = "관장 하월", @@ -126,6 +171,9 @@ public struct BossDuel // 3,000종을 적은 손이다 — **가장 빠르다**(4). MinThreshold(3) 바로 위이고 // 더 내리면 피할 방법이 사라진다(반복 두 번이면 이미 터진다). ledgerThreshold = 4, + teamInsectIds = Team("dragonfly_emperor", "beetle_golden_stag", "butterfly_apollo", + "hornet_emperor", "moth_effaced"), + teamLevels = Levels(68, 69, 70, 71, 72), }, }; diff --git a/Assets/Scripts/NPC/NpcDialogueDatabase.cs b/Assets/Scripts/NPC/NpcDialogueDatabase.cs index 4b805b4..b4fbfaf 100644 --- a/Assets/Scripts/NPC/NpcDialogueDatabase.cs +++ b/Assets/Scripts/NPC/NpcDialogueDatabase.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace InsectGame.NPC { @@ -9,6 +9,52 @@ namespace InsectGame.NPC /// public static class NpcDialogueDatabase { + /// 월드 이름과 대화의 ID 표기를 같은 이름으로 해석한다. + public static string StorySpeakerName(string storyId) + { + switch (storyId) + { + case "catcher_rival": return "라온"; + case "ruins_scholar": return "세라"; + // 명부회(2막) — 관장만 진명, 간부는 코드네임으로 부른다. + case "ledger_chief": return "관장 하월"; + case "ledger_grip": return "집게"; + case "ledger_scale": return "저울"; + case "ledger_ink": return "먹"; + // 1막 하수 — 정체가 밝혀지기 전이라 **이름이 아니라 인상으로 부른다**. + // 2막에서 명부회 간부를 만나고 나서야 이들이 무엇이었는지 알게 된다. + case "ledger_thug_cord": return "검은 옷의 사내"; + case "ledger_thug_rule": return "검은 옷의 여자"; + case "ledger_thug_pin": return "검은 옷의 청년"; + case "village_elder": return "마을 어르신"; + // 마을 이야기(town 챕터) — 전초기지마다 한 명. 처음 보는 사람이라 **역할을 이름 앞에** + // 붙인다("[E] 대화: 나무꾼 솔"). 이름만 두면 동행자와 구분이 안 된다. + case "town_meadow": return "꼬마 화가 달래"; + case "town_pond": return "물결 할머니"; + case "town_forest": return "나무꾼 솔"; + case "town_swamp": return "약초꾼 이끼"; + case "town_mountain": return "봉수지기 너울"; + case "town_garden": return "정원사 누리"; + case "town_ruins": return "탁본꾼 결"; + case "town_hollow": return "풍경지기 메아리"; + case "town_dunes": return "떠돌이 상인 모래"; + case "town_frostline": return "필사생 서리"; + case "town_emberfall": return "광부 숯"; + case "town_canopy": return "나무타기 잎새"; + default: return string.IsNullOrEmpty(storyId) ? "이야기" : storyId; + } + } + + /// 명시된 줄 화자를 우선한다. 내레이션/미등록 화자는 다른 인물 얼굴을 빌리지 않는다. + public static string StoryPortraitId(string lineSpeaker, string fallbackId) + { + if (string.IsNullOrEmpty(lineSpeaker)) return fallbackId; + if (lineSpeaker == "하월") return "ledger_chief"; + foreach (string id in StoryNpcLines.Keys) + if (lineSpeaker == id || lineSpeaker == StorySpeakerName(id)) return id; + return null; + } + // ── 주민 한글 이름 풀 (15개+) ── private static readonly string[] Names = { @@ -19,18 +65,27 @@ public static class NpcDialogueDatabase }; // ── 개인 고정 대사 (npcId 시드로 1인 1줄 고정 — 인물 톤) ── + // 세 줄(개인 + 리전 + 힌트)이 무작위로 조합되므로 **이 풀은 어느 리전·시각·날씨에도 참이어야 한다.** + // 사라짐을 말하면 사라진 적 없는 꽃밭·우듬지 주민이 모순되고("산책하기 딱 좋은 날씨"는 밤비에 거짓이 된다), + // "이름"을 말하면 이름을 입에 올리지 말라는 이름 없는 자리 줄과 부딪친다. 그런 말은 리전 줄이 맡는다. private static readonly string[] PersonalLines = { - "안녕하세요! 오늘도 좋은 하루네요.", - "우리 마을에 온 걸 환영해요!", - "곤충 채집가라니, 멋진 일을 하시네요.", - "요즘 허리가 쑤셔서 큰일이야... 그래도 산책은 빼먹을 수 없지.", + "어서 와요. 낯선 얼굴이 이렇게 반가울 줄은 몰랐네요.", + "곤충 채집가라니, 든든한 일을 하시네요.", + "채집망 손질이 잘 돼 있네요. 아끼는 사람의 손이에요.", "어릴 적엔 나도 곤충 박사가 꿈이었답니다.", - "당신 잠자리채, 꽤 좋아 보이는데요?", - "산책하기 딱 좋은 날씨예요.", + "요즘 허리가 쑤셔서 큰일이야... 그래도 산책은 빼먹을 수 없지.", + "전 곤충은 무서워서 못 만져요. 그래도 구경은 좋아해요.", + "도감을 채운다고요? 숙제 같은데, 다들 이상하게 즐거워 보이더라고요.", + "비 오는 날도 맑은 날도 곤충은 저마다 달라요. 그래서 질리질 않죠.", "마을 사람들은 다들 친절하니 편하게 지내요.", - "저녁 반찬거리를 고민 중이에요. 별일 없죠?", + "저기 곤충잡이 아이들 보셨어요? 어찌나 열심인지, 구경만 해도 힘이 나요.", "낯선 얼굴이네요. 종종 들러줘요!", + "든든히 챙겨 먹고 다녀요. 배고프면 곤충도 눈에 안 들어와요.", + "다친 곤충은 꼭 쉬게 해 줘요. 사람이나 곤충이나 쉬어야 낫는 법이에요.", + "요즘은 사람들 얘기가 죄다 곤충이에요. 누가 뭘 만났네, 어디 뭐가 나왔네.", + "신발 끈은 단단히 묶고 다녀요. 곤충 따라가다 보면 어디까지 가는지 몰라요.", + "저도 한때는 채집망을 들고 다녔어요. 지금은 구경꾼이지만, 구경이 제일 재밌어요.", }; // ── 리전별 대사 풀 (regionId → 3~5줄: 분위기/힌트) ── @@ -42,6 +97,11 @@ public static class NpcDialogueDatabase "풀숲을 천천히 살펴보세요. 메뚜기가 잘 숨어 있어요.", "초원 너머 연못 쪽엔 물가 곤충이 산대요.", "바람 부는 날엔 나비가 낮게 날아요. 잡기 좋죠.", + // 본 마을 주민이 전부 초원 소속이라 이 풀이 가장 많이 읽힌다 — 4줄로는 같은 말이 반복됐다. + "예전엔 이 풀밭이 밤새 시끄러웠대요. 요즘은 귀가 좀 심심하죠.", + "마을 어르신께 인사는 드렸어요? 말씀은 길어도 들어 두면 다 쓸모가 있어요.", + "초원 끝 수문장한테 라온이 두 번 졌다지 뭐예요. 본인 앞에선 말하지 마요.", + "채집망은 여분이 있어야 마음이 놓여요. 아끼다 놓치면 더 아깝잖아요.", }, ["pond"] = new[] { @@ -49,6 +109,7 @@ public static class NpcDialogueDatabase "물잠자리는 물가 가까이에서만 보여요.", "비 오는 날 연못엔 평소 못 보던 손님이 온답니다.", "연못 수문장은 만만치 않다던데... 준비 단단히 해요.", + "물가가 조용해진 지 꽤 됐어요. 물방울 튀는 소리 하나도 반가울 정도예요.", }, ["forest"] = new[] { @@ -56,24 +117,28 @@ public static class NpcDialogueDatabase "사슴벌레는 수액 냄새 나는 나무를 좋아해요.", "숲 깊은 곳에 숨겨진 장소가 있다는 소문이 있어요.", "밤의 숲은 위험하지만, 밤에만 나오는 곤충도 있죠.", + "숲에선 매미 소리가 길잡이였어요. 요즘은 소리가 줄어서 길 잃는 사람이 늘었죠.", }, ["swamp"] = new[] { "늪지대는 걸음이 느려지니 서두르지 말아요.", "여기 곤충들은 좀 사나워요. 조심해서 다가가요.", "안개 낀 날 늪에선 희귀한 그림자를 봤다는 사람도 있어요.", + "안개 속에선 반딧불이 불빛을 따라가요. 요즘은 불빛이 드물어서 걱정이에요.", }, ["mountain"] = new[] { "산바람이 차죠? 높은 곳 곤충은 튼튼하답니다.", "바위 틈을 잘 보세요. 단단한 등껍질이 숨어 있어요.", - "산 정상 쪽은 수문장을 이겨야 오를 수 있어요.", + "산 너머 유적으로 가려면 수문장을 이겨야 해요.", + "높은 데는 숨 쉬는 것도 일이에요. 천천히 오르는 사람이 먼저 닿는대요.", }, ["garden"] = new[] { "이 정원은 마을의 자랑이에요. 꽃마다 나비가 달라요.", "꽃향기가 진한 날엔 곤충이 더 모여들어요.", "정원사 말로는 새벽에 제일 예쁜 곤충이 온대요.", + "미로 울타리는 건드리지 말래요. 이유는 아무도 모르지만요.", }, ["ruins"] = new[] { @@ -81,6 +146,7 @@ public static class NpcDialogueDatabase "유적의 곤충은 어딘가 신비로워요. 빛나는 걸 봤다니까요.", "돌기둥 그늘을 살펴봐요. 고대 곤충이 숨어 있을지도.", "여기까지 온 채집가는 몇 없어요. 대단하네요.", + "바람이 기둥 사이를 지날 때 사람 목소리 같은 소리가 나요. 처음엔 깜짝 놀라요.", }, // ── 2막(ver2) ── 미등록이면 DefaultRegionLines("이 근처엔 재미있는 곤충이 많아요")로 // 떨어져 전초기지 주민 6곳이 전부 같은 말을 한다. 리전 색이 사라지는 자리라 채워 둔다. @@ -147,6 +213,12 @@ public static class NpcDialogueDatabase "잡은 곤충은 도감에 기록돼요. 전부 모아보는 건 어때요?", "보석이 부족하면 상점에서 충전할 수 있다더군요.", "포획 아이템을 쓰면 잡을 확률이 올라가요. 아끼지 말아요.", + // 아래는 실제로 있는 기능만 말한다 — 속성 상성·지속 HP(병원)·팀 5칸과 레이드·섬 방목·주간 크기 대결. + "곤충마다 속성이 달라요. 상성이 맞으면 훨씬 세게 때린대요.", + "전투가 끝나도 다친 곤충은 그대로예요. 병원에서 쉬게 하거나 약을 써 줘요.", + "팀은 다섯 칸이에요. 그 다섯이 함께 덤비는 큰 상대도 있다더군요.", + "곤충을 섬에 풀어 두면 가만히 있어도 캔디가 모인대요. 가끔 들러서 받아 가요.", + "같은 곤충도 크기가 제각각이래요. 큰 녀석은 주간 대결에서 상을 받는다더군요.", }; /// @@ -202,6 +274,55 @@ public static class NpcDialogueDatabase "시간이 없다. 나에게도, 저 아이들에게도.", "네 방식이 틀렸다곤 안 했다. 느리다고 했지.", } }, + // 마을 이야기 주민 — 이야기가 없는 차례(본편을 기다리는 중·다 들은 뒤)에 쓴다. + { "town_meadow", new[] { + "그림책 봐요! 무당벌레 점은 일곱 개로 그렸어요.", + "채집가님은 오늘 누구 만났어요?", + } }, + { "town_pond", new[] { + "배는 안 떠. 이야기는 떠도 되고.", + "기둥에 금이 느는 걸 보는 게 요즘 낙이야.", + } }, + { "town_forest", new[] { + "나무는 베면 심으면 돼. 그물은 걷으면 되고.", + "도끼 소리가 나면 녀석들이 구경을 와.", + } }, + { "town_swamp", new[] { + "해독약 떨어지면 말해. 바구니는 늘 채워 둘게.", + "안개 낀 날은 코로 찾아. 약초는 냄새가 먼저 와.", + } }, + { "town_mountain", new[] { + "오늘은 여치가 높게 울었어. 맑겠구먼.", + "공책은 번져도 괜찮아. 내일 또 쓰면 되니까.", + } }, + { "town_garden", new[] { + "미로 울타리는 건드리지 마요. 나머지는 마음대로!", + "가위를 들 때마다 할머니 목소리가 들려요.", + } }, + { "town_ruins", new[] { + "먹물은 천천히 말라야 안 번져요.", + "선생님은 잘 지내시죠? …물어볼 걸 그랬나.", + } }, + { "town_hollow", new[] { + "바람이 불면 귀를 기울여 봐요. 풍경이 먼저 알아요.", + "조용한 게 익숙해지면 안 돼요. 그게 제일 무서워요.", + } }, + { "town_dunes", new[] { + "물은 공짜 아니야. 이야기 한 자락이면 되고.", + "빈 상자는 안 팔아. 그건 파는 물건이 아니야.", + } }, + { "town_frostline", new[] { + "손이 곱아서 글씨가 삐뚤어요. 그래도 읽히면 돼요.", + "한 벌은 여기, 한 벌은 가져가요. 그게 제 원칙이에요.", + } }, + { "town_emberfall", new[] { + "기억나는 거 있으면 말해 줘. 곤충 얘기면 더 좋고.", + "갱도는 무너져도 사람은 또 파. 기록도 그래.", + } }, + { "town_canopy", new[] { + "고치는 만지면 안 돼요! 보기만 해요!", + "기다리는 건 잘해요. 제일 잘해요.", + } }, }; /// @@ -214,6 +335,19 @@ public static bool TryGetStoryNpcLines(string storyNpcId, out string[] lines) && StoryNpcLines.TryGetValue(storyNpcId, out lines); } + /// 실제 전투 결과를 읽되 새 줄거리나 보상을 만들지 않는 재대화. + public static bool TryGetStoryNpcLines(string storyNpcId, bool defeated, bool cleansed, out string[] lines) + { + if (!TryGetStoryNpcLines(storyNpcId, out lines)) return false; + if (defeated && storyNpcId.StartsWith("ledger_", System.StringComparison.Ordinal)) + { + lines = new[] { "…이번 대결은 네가 이겼다.", cleansed + ? "이곳을 붙잡고 있을 명분도 사라졌군. 더 막지는 않겠다." + : "지금은 더 겨룰 생각 없다. 네 갈 길을 가라." }; + } + return true; + } + /// /// npcId 해시 시드 기반 결정적 대사 3줄 — 개인 1 + 리전 1 + 공용 힌트 1. /// 같은 npcId/regionId면 항상 같은 조합(재방문 동일 인물 동일 톤). diff --git a/Assets/Scripts/NPC/NpcDuelController.cs b/Assets/Scripts/NPC/NpcDuelController.cs index 5c4ed40..5166f5e 100644 --- a/Assets/Scripts/NPC/NpcDuelController.cs +++ b/Assets/Scripts/NPC/NpcDuelController.cs @@ -45,6 +45,13 @@ public class NpcDuelController : MonoBehaviour, ICloudReloadable new System.Collections.Generic.Dictionary(); private bool bossStateLoaded; + // 라온 라이벌 대결(NpcRivalDuels) — 진행 중인 단계 ID. 비어 있지 않으면 지금 대결이 라이벌전이다. + // 격파 기록·재도전 대기는 간부와 같은 집합을 단계 ID로 쓴다(단계 ID는 rival_로 시작해 인물 ID와 안 겹친다). + private string activeRivalStageId = string.Empty; + // 스토리 진행 조회 — 단계가 열렸는지/닫혔는지. StoryDirector가 이미 이 컨트롤러를 참조하므로 + // 거꾸로 참조하면 순환이 된다 — 부트스트랩이 조회 함수만 넘겨 준다. + private System.Func storyBeatSeen; + private static string DefeatedBossKey => SaveScope.PrefsKey("InsectGame.DefeatedLedgerBosses"); /// 직전 대결 결과 문구 — 로 꺼내 간다. @@ -89,6 +96,12 @@ public void AutoWire(RegionBlightManager blightManager) if (blight == null) blight = blightManager; } + /// 라이벌 단계의 열림·닫힘 판정에 쓰는 스토리 열람 조회(StoryDirector.HasSeen). + public void AutoWireStoryGate(System.Func beatSeen) + { + if (storyBeatSeen == null) storyBeatSeen = beatSeen; + } + private void OnDestroy() { if (battleController != null) @@ -147,13 +160,44 @@ private bool CanRematchForBlight(string storyNpcId) } /// - /// 지금 이 간부에게 도전할 수 있는가. 표에 없거나 이미 이겼거나 재도전 쿨다운 중이면 false. - /// WorldInteractionController가 프롬프트 표시 여부 판정에도 그대로 쓴다. + /// 이 인물과의 대결을 지금 이 리전에서 열 수 있는가 — 오염 거점 보스()는 + /// 자기 거점이 있는 리전 안에서만 싸운다. 거점을 맡지 않은 간부(집게·저울·하월)는 리전과 무관하게 true. + /// + /// 하수 둘은 거점이 없는 리전에도 서 있다(검은 옷의 사내는 연못·습지, 여자는 숲). 거기서 소개 대사를 본 뒤 + /// 다시 말을 걸면 확인 창 없이 곧바로 대결이 열려 — 연못 구간(Lv6~20)의 플레이어가 Lv34 상대와 붙었다. + /// 거점 리전에 서야 대결이 서사(대치 → 격돌 → 정화)와 레벨 대역에 맞는다. + /// + /// 리전은 로 묻는다(나의 섬에서는 null — 섬은 어느 리전도 아니다). + /// 순수 판정이라 테스트가 매니저 없이 본다. + /// + public static bool BossDuelAllowedInRegion(string storyNpcId, string actionRegionId, RegionData[] regions) + { + if (string.IsNullOrEmpty(storyNpcId) || regions == null) return true; + bool siteBoss = false; + foreach (RegionData region in regions) + { + if (region == null || !region.HasBlightSite || region.blightBossNpcId != storyNpcId) continue; + siteBoss = true; + if (!string.IsNullOrEmpty(actionRegionId) && region.regionId == actionRegionId) return true; + } + return !siteBoss; + } + + /// + /// 지금 이 간부에게 도전할 수 있는가. 표에 없거나 이미 이겼거나 재도전 쿨다운 중이거나, + /// 오염 거점 보스인데 그 거점 리전 밖이면() false. + /// TryStartBossDuel이 이걸 먼저 묻고, WorldInteractionController는 말을 걸 때 + /// TryStartBossDuel로 들어오므로 막히면 대결 대신 평소 대사가 나온다. /// public bool CanBossDuel(string storyNpcId, float time) { if (battleController == null || database == null) return false; if (!NpcBossDuels.TryGet(storyNpcId, out NpcBossDuels.BossDuel duel)) return false; + // 거점 보스는 자기 거점 리전에서만. 리전 표를 모르면(매니저 미배선·초기화 전) 누가 거점 보스인지도 + // 알 수 없으므로 옛 동작 그대로 둔다 — 실제 게임에서는 부트스트랩이 늘 배선한다. + if (regionManager != null && !BossDuelAllowedInRegion( + storyNpcId, regionManager.ActionRegionId, regionManager.Regions)) + return false; // 이미 이긴 상대는 원칙적으로 다시 못 붙는다 — 단 **그자가 맡은 오염 거점이 아직 // 살아 있는 리전에 서 있다면** 예외다. 이 예외가 없으면 두 하수를 이미 이긴 세이브 // (2막 진행자 대부분)는 산·유적의 거점을 영영 부수지 못해 기능 자체를 못 본다. @@ -161,11 +205,41 @@ public bool CanBossDuel(string storyNpcId, float time) if (IsBossDefeated(storyNpcId) && !CanRematchForBlight(storyNpcId)) return false; if (bossRetryAt.TryGetValue(storyNpcId, out float readyAt) && time < readyAt) return false; // 상대 곤충이 DB에 없으면(데이터 오타) 프롬프트를 띄우지 않는다 — 눌러도 안 열리는 버튼 방지. - if (database.GetById(duel.insectId) == null) return false; + // 팀은 한 마리라도 남으면 연다(빠진 곤충은 ResolveBossTeam이 경고하고 건너뛴다 — 이야기를 막지 않는다). + if (database.GetById(duel.insectId) == null && !ResolveBossTeam(duel, out _, out _)) return false; return FindPlayerLeader() != null; } - /// 간부 대결 시작. 성공하면 true — 이후 흐름은 기존 배틀 화면이 처리한다. + /// + /// 보스가 내보낼 곤충과 레벨(앞에서부터 순서대로). 한 마리 보스면 1칸. DB에 없는 곤충은 경고하고 건너뛴다 — + /// 오타 하나로 간부와 영영 못 싸우는 것보다 한 마리 적은 팀이 낫다(오타는 NpcBossDuelTests가 배포 전에 잡는다). + /// + private bool ResolveBossTeam(NpcBossDuels.BossDuel duel, out InsectData[] team, out int[] levels) + { + var teamList = new System.Collections.Generic.List(duel.RosterSize); + var levelList = new System.Collections.Generic.List(duel.RosterSize); + for (int i = 0; i < duel.RosterSize; i++) + { + string id = duel.RosterInsectId(i); + InsectData data = database != null && !string.IsNullOrEmpty(id) ? database.GetById(id) : null; + if (data == null) + { + Debug.LogWarning($"[NpcDuel] {duel.storyNpcId}의 {i + 1}번째 곤충 '{id}'가 DB에 없다 — 건너뛴다"); + continue; + } + teamList.Add(data); + levelList.Add(duel.RosterLevel(i)); + } + team = teamList.ToArray(); + levels = levelList.ToArray(); + return team.Length > 0; + } + + /// + /// 간부 대결 시작. 성공하면 true — 이후 흐름은 기존 배틀 화면이 처리한다. + /// 표에 팀이 있으면() 팀 대결()로 + /// 연다 — 상대가 곤충을 차례로 내보내고 마지막이 쓰러져야 이긴다. 승패 처리()는 같다. + /// public bool TryStartBossDuel(string storyNpcId, float time) { if (!CanBossDuel(storyNpcId, time)) return false; @@ -173,36 +247,129 @@ public bool TryStartBossDuel(string storyNpcId, float time) PlayerInsectData leader = FindPlayerLeader(); InsectData leaderData = leader != null ? database.GetById(leader.insectId) : null; - InsectData enemyData = database.GetById(duel.insectId); - if (leaderData == null || enemyData == null) return false; + if (leaderData == null || !ResolveBossTeam(duel, out InsectData[] team, out int[] levels)) return false; + InsectData enemyData = team[team.Length - 1]; // 에이스 — 표의 insectId와 같다 InsectSkill[] equipped = collection != null ? collection.GetEquippedSkills(leader) : null; // 아이 대결과 달리 레벨을 플레이어에 맞추지 않는다 — 고정 레벨이라야 벽으로 기능한다. - if (!battleController.StartDuel( - leaderData, leader.level, enemyData, duel.level, - equippedSkills: equipped, playerPid: leader)) + // 한 마리만 남았으면(하수, 또는 팀이 데이터 누락으로 줄었으면) 옛 한 마리 대결 그대로다. + bool started = team.Length > 1 + ? battleController.StartTeamDuel( + leaderData, leader.level, team, levels, + equippedSkills: equipped, playerPid: leader) + : battleController.StartDuel( + leaderData, leader.level, enemyData, levels[0], + equippedSkills: equipped, playerPid: leader); + if (!started) { return false; } // 장부를 건다 — 이 압박은 명부회 보스전에만 붙는다(아이 대결·야생 전투엔 없다). + // 팀 대결에서도 **한 번만** 건다 — 장부는 인물의 것이라 곤충이 바뀌어도 이어진다(SendOutNextEnemy가 안 건드린다). battleController.ArmLedger(duel.ledgerThreshold); + // 연출이 상대를 알아보게 한다 — 컷인·전투 중 한마디·결과 한마디(BattleScreenUI.Duel). + battleController.SetDuelOpponent(storyNpcId); activeKid = null; + activeRivalStageId = string.Empty; activeBossId = storyNpcId; activeRarity = enemyData.rarity; // 보스 테마로 전환 — 간부전과 최종전을 가른다. 전투 종료 시 배틀 화면이 // 기존 경로로 탐험 BGM을 되돌리므로 여기서 복구를 따로 하지 않는다. if (Core.AudioManager.Instance != null) + Core.AudioManager.Instance.PlayBGM(BattleMusic.BossDuel(duel.isFinal)); + return true; + } + + // ── 라온 라이벌 대결 ── + + /// 지금 이 인물과 걸 수 있는 라이벌 단계(없으면 false). 대화창이 [대결] 버튼 표시에 쓴다. + public bool TryGetRivalStage(string storyNpcId, out NpcRivalDuels.Stage stage) + { + stage = default; + if (storyBeatSeen == null) return false; + string here = regionManager != null && regionManager.CurrentRegion != null + ? regionManager.CurrentRegion.regionId : string.Empty; + return NpcRivalDuels.TrySelect(storyNpcId, here, storyBeatSeen, IsBossDefeated, out stage); + } + + public bool CanRivalDuel(string storyNpcId, float time) + { + if (battleController == null || database == null) return false; + if (!TryGetRivalStage(storyNpcId, out NpcRivalDuels.Stage stage)) return false; + if (bossRetryAt.TryGetValue(stage.stageId, out float readyAt) && time < readyAt) return false; + if (database.GetById(stage.insectId) == null) return false; + return FindPlayerLeader() != null; + } + + /// 라이벌 대결 시작 — 간부처럼 고정 레벨이다(단계가 곧 라온의 성장이다). 장부는 없다. + public bool TryStartRivalDuel(string storyNpcId, float time) + { + if (!CanRivalDuel(storyNpcId, time)) return false; + TryGetRivalStage(storyNpcId, out NpcRivalDuels.Stage stage); + + PlayerInsectData leader = FindPlayerLeader(); + InsectData leaderData = leader != null ? database.GetById(leader.insectId) : null; + InsectData enemyData = database.GetById(stage.insectId); + if (leaderData == null || enemyData == null) return false; + + InsectSkill[] equipped = collection != null ? collection.GetEquippedSkills(leader) : null; + if (!battleController.StartDuel( + leaderData, leader.level, enemyData, stage.level, + equippedSkills: equipped, playerPid: leader)) { - Core.AudioManager.Instance.PlayBGM( - duel.isFinal ? Core.BgmType.BossFinal : Core.BgmType.BossLedger); + return false; } + battleController.SetDuelOpponent(stage.stageId); + + activeKid = null; + activeBossId = string.Empty; + activeRivalStageId = stage.stageId; + activeRarity = enemyData.rarity; + + // 라온 테마로 전환 — 간부전과 같은 자리·같은 이유다. 시작 신호(BattleUpdated)에 배틀 화면이 일반 전투 곡을 먼저 걸고 + // 여기서 덮는다(PlayBGM은 한 프레임 늦게 틀어 마지막 요청만 남는다). 끝나면 배틀 화면이 탐험 곡으로 되돌린다. + if (Core.AudioManager.Instance != null) + Core.AudioManager.Instance.PlayBGM(BattleMusic.RivalDuel); return true; } + private void OnRivalDuelEnded(bool playerWon) + { + string stageId = activeRivalStageId; + activeRivalStageId = string.Empty; + if (!NpcRivalDuels.TryGetStage(stageId, out NpcRivalDuels.Stage stage)) return; + string rival = NpcDialogueDatabase.StorySpeakerName(stage.storyNpcId); + + if (!playerWon) + { + bossRetryAt[stageId] = Time.time + NpcRivalDuels.RetryCooldownSeconds; + SetResult($"{rival}에게 졌다… 다시 도전해 보자"); + return; + } + + EnsureBossState(); + bool firstWin = defeatedBosses.Add(stageId); + if (firstWin) SaveBossState(); + bossRetryAt.Remove(stageId); + if (firstWin && !string.IsNullOrEmpty(stage.rewardItemId) && stage.rewardCount > 0 + && itemInventory != null) + itemInventory.AddItem(stage.rewardItemId, stage.rewardCount); + + // 아이·간부 대결과 같은 1v1 듀얼이다 — '동네 최강자' 서브 퀘스트에 센다 + // (post_rival_rematch가 바로 그 퀘스트를 연다: "도전은 네 쪽에서 해"). + TutorialQuestManager.Instance?.NotifyNpcDuelWon(); + + string itemName = ResolveItemName(stage.rewardItemId); + string gained = firstWin && !string.IsNullOrEmpty(itemName) + ? $" {itemName} ×{stage.rewardCount} 획득" + : string.Empty; + SetResult($"{rival}을(를) 이겼다!{gained}"); + } + /// /// 이 아이에게 도전할 수 있는지. 아직 곤충이 없으면 배정까지 시도하므로 /// 스캔에서 그대로 물어봐도 된다(프롬프트 표시 여부 판정 겸용). @@ -242,6 +409,7 @@ public bool TryStartDuel(CatcherKidNpc kid, float time) // 위쪽 `ConcludeDefeatWithoutSwap`이 상태 누출 자체를 막지만, 방어선을 한 겹 더 둔다 — // `DuelEnded`가 어떤 이유로든 빠지면 그 값이 그대로 다음 대결로 흘러가는 구조라서다. activeBossId = string.Empty; + activeRivalStageId = string.Empty; activeRarity = kid.DuelInsect.rarity; return true; } @@ -254,6 +422,11 @@ private void OnDuelEnded(bool playerWon) OnBossDuelEnded(playerWon); return; } + if (!string.IsNullOrEmpty(activeRivalStageId)) + { + OnRivalDuelEnded(playerWon); + return; + } CatcherKidNpc kid = activeKid; activeKid = null; @@ -405,6 +578,9 @@ private PlayerInsectData FindPlayerLeader() // 아직 잡은 게 없는 아이에게 현재 리전 풀에서 한 마리를 배정한다. // 아이 인스턴스 ID로 결정적으로 고르므로 같은 아이는 항상 같은 곤충을 들고 있다. + // **희귀 이상은 건너뛴다** — 아이는 필드에서 희귀 이상을 구경만 한다(NpcCatchRules.ShouldWatchOnly). + // 리전 풀이 등급마다 종을 갖추도록 보충된 뒤로(모든 리전에 희귀·영웅·전설) 거르지 않으면 해시 인덱스가 + // 영웅·희귀에 떨어진 아이가 생긴다(습지 아이가 밤말벌을 든다). 풀엔 일반·고급이 늘 있어 빈손은 안 된다. private void EnsureDuelInsect(CatcherKidNpc kid) { if (kid == null || kid.DuelInsect != null || database == null) return; @@ -417,7 +593,7 @@ private void EnsureDuelInsect(CatcherKidNpc kid) for (int attempt = 0; attempt < pool.Length; attempt++) { InsectData data = database.GetById(pool[PoolIndexFor(kid.NpcId, pool.Length, attempt)]); - if (data == null) continue; + if (data == null || NpcCatchRules.ShouldWatchOnly(data.rarity)) continue; PlayerInsectData leader = FindPlayerLeader(); kid.SetDuelInsect(data, leader != null ? leader.level : 1); return; @@ -474,8 +650,8 @@ private void SetResult(string text) /// 결과 문구를 한 번만 꺼내 간다 — 화면에 올리는 쪽이 자기 타이머로 표시한다. /// /// 옛 구현은 SetResult 시각을 찍고 소비자가 3.5초 창을 봤는데, 그 시각은 - /// DuelEnded 시점(= 전투 결과 화면이 열리는 프레임)이다. 결과 화면은 4초를 - /// 채운 뒤에야 닫히고(BattleScreenUI의 resultTimer > 4f) 그동안은 + /// DuelEnded 시점(= 전투 결과 화면이 열리는 프레임)이다. 결과 화면은 그 뒤에야 닫히고 + /// (당시 4초 자동 — 지금은 플레이어가 눌러서 닫으니 더 길 수 있다, BattleResultRules) 그동안은 /// 모달이라 필드가 그리지도 않는다 — **토스트는 필드가 보이기 0.5초 전에 이미 죽었다.** /// 승리 아이템도, 거점이 무너졌다는 문구도 플레이어에게 도달한 적이 없다. /// diff --git a/Assets/Scripts/NPC/NpcManager.cs b/Assets/Scripts/NPC/NpcManager.cs index 3bf3edd..423a072 100644 --- a/Assets/Scripts/NPC/NpcManager.cs +++ b/Assets/Scripts/NPC/NpcManager.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using InsectGame.Core; using InsectGame.Spawning; using UnityEngine; @@ -183,7 +183,7 @@ private VillagerNpc SpawnVillager(NpcSpawnAnchor anchor, int index) int seed = NpcDialogueDatabase.StableHash(npcId); GameObject go = CreateNpcObject($"Npc_{npcId}", anchor.position); - NpcVisualBuilder.Build(go.transform, NpcVisualBuilder.RandomVillager(seed)); + NpcVisualBuilder.Build(go.transform, NpcVisualBuilder.RandomVillager(seed, anchor.regionId)); // 리전 복장 VillagerNpc npc = go.AddComponent(); npc.Initialize(anchor, npcId, NpcDialogueDatabase.GetVillagerName(seed), seed); @@ -197,7 +197,7 @@ private CatcherKidNpc SpawnKid(NpcSpawnAnchor anchor, int index) int seed = NpcDialogueDatabase.StableHash(npcId); GameObject go = CreateNpcObject($"Npc_{npcId}", anchor.position); - NpcVisualBuilder.Build(go.transform, NpcVisualBuilder.RandomKid(seed)); + NpcVisualBuilder.Build(go.transform, NpcVisualBuilder.RandomKid(seed, anchor.regionId)); CatcherKidNpc npc = go.AddComponent(); npc.Initialize(this, anchor, npcId, seed); @@ -233,25 +233,10 @@ private VillagerNpc SpawnStoryNpc(NpcSpawnAnchor anchor, int index) } // 스토리 NPC 표시명. **여기에 case가 없으면 그 NPC는 "마을 어르신"으로 뜬다** — - // NpcVisualBuilder.StoryNpcAppearance의 외형 switch와 짝이라 둘을 함께 등록해야 한다. + // NpcVisualBuilder.StoryNpcFace의 외형 switch와 짝이라 둘을 함께 등록해야 한다. private static string StoryNpcDisplayName(string storyId) { - switch (storyId) - { - case "catcher_rival": return "라온"; - case "ruins_scholar": return "세라"; - // 명부회(2막) — 관장만 진명, 간부는 코드네임으로 부른다. - case "ledger_chief": return "관장 하월"; - case "ledger_grip": return "집게"; - case "ledger_scale": return "저울"; - case "ledger_ink": return "먹"; - // 1막 하수 — 정체가 밝혀지기 전이라 **이름이 아니라 인상으로 부른다**. - // 2막에서 명부회 간부를 만나고 나서야 이들이 무엇이었는지 알게 된다. - case "ledger_thug_cord": return "검은 옷의 사내"; - case "ledger_thug_rule": return "검은 옷의 여자"; - case "ledger_thug_pin": return "검은 옷의 청년"; - default: return "마을 어르신"; - } + return NpcDialogueDatabase.StorySpeakerName(storyId); } private GameObject CreateNpcObject(string name, Vector3 position) diff --git a/Assets/Scripts/NPC/NpcRivalDuels.cs b/Assets/Scripts/NPC/NpcRivalDuels.cs new file mode 100644 index 0000000..fcff8a9 --- /dev/null +++ b/Assets/Scripts/NPC/NpcRivalDuels.cs @@ -0,0 +1,183 @@ +using System; + +namespace InsectGame.NPC +{ + /// + /// 라온과의 라이벌 대결 — 장마다 한 번씩, 스토리를 따라 강해진다. + /// + /// 1장에서 라온이 "승부다!"를 외치고 엔딩 뒤에 "이번엔 진짜 겨루자. 도전은 네 쪽에서 해" + /// (post_rival_rematch)로 끝나는데, 그 사이에 라온과 실제로 붙는 길이 하나도 없었다. + /// + /// 단계는 라온이 실제로 서 있는 여덟 리전 중 일곱(초원·연못·습지·텅 빈 들·모래언덕·서릿길·잿불 골짜기) + + /// 엔딩 뒤 재대결이다(2026-10-04 — 「장마다 한 번」). 다치기 전 단계는 전부 갱도()에서 닫힌다. + /// 여는 곳은 둘이다: 대화창의 [승부하기](어느 단계든), 그리고 초원 첫 만남은 대사 직후 곧바로(StoryBeat.duelAfter). + /// + /// 간부 표()와 나눈 이유: 간부는 인물당 한 번이고 장부를 들고 + /// 오염 거점과 얽힌다. 라온은 한 인물이 여러 단계로 싸우고 장부가 없다. 같은 표에 두면 + /// "명부회 보스는 전원 장부를 든다"(NpcBossDuelTests)가 깨지고, storyNpcId 하나로 상대를 찾는 + /// TryGet이 단계를 못 가른다. + /// + /// 라온은 전부 여치를 부린다 — 풀벌레 소리만 듣고 "저건 여치" 하던 할머니(camp_pond)의 손자다. + /// + /// 격파 기록은 간부 격파 집합(NpcDuelController)에 단계 ID로 함께 적는다. 그 집합이 이미 + /// 클라우드에 오르므로 세이브 구조를 늘리지 않는다 — 단계 ID는 로 시작해 + /// 인물 ID와 겹치지 않는다. 순수 데이터 + 순수 선택이다(NpcRivalDuelTests). + /// + public static class NpcRivalDuels + { + public const string StagePrefix = "rival_"; + /// 패배 후 재도전 대기(초) — 아이 대결과 같다. + public const float RetryCooldownSeconds = 90f; + + public struct Stage + { + /// 단계 ID — 키이자 격파 기록 ID. + public string stageId; + public string storyNpcId; + /// 이 리전에 서 있을 때만 — 비면 어디서든(엔딩 뒤 재대결). + public string regionId; + /// 이 비트를 봤으면 열린다. + public string openBeatId; + /// 이 비트를 봤으면 닫힌다 — 비면 안 닫힌다. + public string closeBeatId; + public string insectId; + public int level; + public string rewardItemId; + public int rewardCount; + } + + /// + /// 다치기 전 단계를 모두 닫는 비트 — 잿불 골짜기 갱도에서 라온이 다친다(ch10_confront). 월드의 라온 앵커는 + /// 부상 뒤에도 서 있어서, 닫지 않으면 뼈가 상한 라온과 싸우게 된다. 복귀 뒤에는 엔딩 뒤 재대결만 열린다. + /// + public const string InjuryBeatId = "ch10_confront"; + + // **장마다 한 번** — 라온이 실제로 서 있는 리전(VillageBuilder의 catcher_rival 앵커)에만 단계를 둔다. + // 레벨은 그 리전 구간(요구~수문장) 안이고 표 순서대로 오른다(NpcRivalDuelTests). 순서가 곧 선택 순서라 + // 리전 단계를 앞에, 어디서든 열리는 재대결을 맨 뒤에 둔다. + // 여치는 지역을 따라 바뀐다 — 초원~모래언덕은 잎여치, 서릿길에서 눈밭여치를 얻고, 엔딩 뒤엔 우듬지여치다. + private static readonly Stage[] Table = + { + // 초원 — 첫 만남(ch1_rival_intro) 대사가 "승부다!"로 끝나면 곧바로 붙는다(StoryBeat.duelAfter). + // 첫 파트너가 Lv.6이라 그 아래(4)에 둔다 — 첫 전투에서 막히면 이야기가 시작도 전에 선다. 구간 Lv.1~13. + new Stage + { + stageId = "rival_meadow", storyNpcId = "catcher_rival", regionId = "meadow", + openBeatId = "ch1_rival_intro", closeBeatId = InjuryBeatId, + insectId = "katydid_leaf", level = 4, + rewardItemId = "net_basic", rewardCount = 3, + }, + // 연못 — 라온이 "물가 곤충들은 초원 애들이랑 또 다르지?"로 다시 나타난 뒤(ch2_water). 구간 Lv.6~20. + new Stage + { + stageId = "rival_pond", storyNpcId = "catcher_rival", regionId = "pond", + openBeatId = "ch2_water", closeBeatId = InjuryBeatId, + insectId = "katydid_leaf", level = 14, + rewardItemId = "net_silver", rewardCount = 2, + }, + // 습지 — 셋이 뭉친 뒤(ch4_reach_swamp). 구간 Lv.20~37. + new Stage + { + stageId = "rival_swamp", storyNpcId = "catcher_rival", regionId = "swamp", + openBeatId = "ch4_reach_swamp", closeBeatId = InjuryBeatId, + insectId = "katydid_leaf", level = 30, + rewardItemId = "wound_salve_great", rewardCount = 3, + }, + // 텅 빈 들 — 2막 첫 땅(ch7_arrive). 소리가 없는 들판에서의 한 판. 구간 Lv.42~48. + new Stage + { + stageId = "rival_hollow", storyNpcId = "catcher_rival", regionId = "hollow", + openBeatId = "ch7_arrive", closeBeatId = InjuryBeatId, + insectId = "katydid_leaf", level = 47, + rewardItemId = "wound_salve_great", rewardCount = 4, + }, + // 모래언덕 — 상자를 본 뒤(ch8_arrive). 구간 Lv.46~52. + new Stage + { + stageId = "rival_dunes", storyNpcId = "catcher_rival", regionId = "dunes", + openBeatId = "ch8_arrive", closeBeatId = InjuryBeatId, + insectId = "katydid_leaf", level = 51, + rewardItemId = "net_gold", rewardCount = 1, + }, + // 서릿길 — 눈밭여치를 처음 데리고 나온다(ch9_arrive). 구간 Lv.50~56. + new Stage + { + stageId = "rival_frost", storyNpcId = "catcher_rival", regionId = "frostline", + openBeatId = "ch9_arrive", closeBeatId = InjuryBeatId, + insectId = "katydid_snowfield", level = 55, + rewardItemId = "full_restore", rewardCount = 1, + }, + // 잿불 골짜기 — 다치기 전 마지막 기회. 도착(ch10_arrive)부터 갱도(ch10_confront)까지만 열린다. 구간 Lv.54~60. + new Stage + { + stageId = "rival_ember", storyNpcId = "catcher_rival", regionId = "emberfall", + openBeatId = "ch10_arrive", closeBeatId = InjuryBeatId, + insectId = "katydid_snowfield", level = 58, + rewardItemId = "full_restore", rewardCount = 2, + }, + // 엔딩 뒤 — "동네 최강자 자리, 내가 먼저 걸어 둘게"(post_rival_rematch). 관장(72)보다 위. + new Stage + { + stageId = "rival_final", storyNpcId = "catcher_rival", regionId = "", + openBeatId = "post_rival_rematch", + insectId = "katydid_canopy", level = 76, + rewardItemId = "full_restore", rewardCount = 3, + }, + }; + + /// 이 인물이 라이벌 단계를 하나라도 갖는가 — 대사 직후 대결(StoryDuelLauncher)이 간부와 가른다. + public static bool IsRival(string storyNpcId) + { + if (string.IsNullOrEmpty(storyNpcId)) return false; + for (int i = 0; i < Table.Length; i++) + if (Table[i].storyNpcId == storyNpcId) return true; + return false; + } + + /// 표 전체 — 테스트/검증용(값 복사 배열). + public static Stage[] All() + { + Stage[] copy = new Stage[Table.Length]; + Array.Copy(Table, copy, Table.Length); + return copy; + } + + public static bool TryGetStage(string stageId, out Stage stage) + { + for (int i = 0; i < Table.Length; i++) + { + if (Table[i].stageId == stageId) + { + stage = Table[i]; + return true; + } + } + stage = default; + return false; + } + + /// + /// 지금 이 인물에게 걸 수 있는 단계 — 표 순서대로 첫 번째. 열렸고, 안 닫혔고, 리전이 맞고, + /// 아직 안 이긴 단계다. 앞 단계를 건너뛴 세이브도 뒤 단계가 막히지 않는다(각 단계는 독립이다) — + /// 연못을 건너뛰고 습지로 가도 습지 단계가 뜬다. 다치기 전 단계는 갱도 뒤로 닫히므로 엔딩 뒤엔 어디서든 재대결이다. + /// + public static bool TrySelect(string storyNpcId, string currentRegionId, + Func beatSeen, Func stageDefeated, out Stage stage) + { + stage = default; + if (string.IsNullOrEmpty(storyNpcId) || beatSeen == null) return false; + for (int i = 0; i < Table.Length; i++) + { + Stage s = Table[i]; + if (s.storyNpcId != storyNpcId) continue; + if (!string.IsNullOrEmpty(s.regionId) && s.regionId != currentRegionId) continue; + if (!beatSeen(s.openBeatId)) continue; + if (!string.IsNullOrEmpty(s.closeBeatId) && beatSeen(s.closeBeatId)) continue; + if (stageDefeated != null && stageDefeated(s.stageId)) continue; + stage = s; + return true; + } + return false; + } + } +} diff --git a/Assets/Scripts/NPC/NpcRivalDuels.cs.meta b/Assets/Scripts/NPC/NpcRivalDuels.cs.meta new file mode 100644 index 0000000..0fbb2d0 --- /dev/null +++ b/Assets/Scripts/NPC/NpcRivalDuels.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9081a425a88c3834eb61bafff367119f \ No newline at end of file diff --git a/Assets/Scripts/NPC/NpcVisualBuilder.cs b/Assets/Scripts/NPC/NpcVisualBuilder.cs index dbff8ff..5dbcb76 100644 --- a/Assets/Scripts/NPC/NpcVisualBuilder.cs +++ b/Assets/Scripts/NPC/NpcVisualBuilder.cs @@ -1,9 +1,20 @@ -using System.Collections.Generic; +using System.Collections.Generic; using InsectGame.Core; using UnityEngine; namespace InsectGame.NPC { + /// 모자 모양. 기본값(0)이 옛 야구모자라 기존 외형 정의는 그대로 읽힌다. + public enum NpcHatStyle { Cap, Straw, Wide, Beanie, Helmet, Headwrap, Bandana, Hood } + + /// 겉옷. 색은 — 비어 있으면(알파 0) 상의색에서 파생. + public enum NpcWear { None, Vest, Apron, Coat, Robe } + + public enum NpcBag { None, Satchel, Backpack } + + /// 오른손 도구. 아이는 도구가 없을 때만 뜰채를 든다. + public enum NpcTool { None, Axe, Pickaxe, Staff, Lantern, Book, Scroll, Brush, Shears, Basket, Ledger } + /// NPC 외형 파라미터 — System.Random(seed) 기반 결정적 생성. public struct NpcAppearance { @@ -15,6 +26,18 @@ public struct NpcAppearance public Color bottom; public Color skin; public Color hat; + + // ── 옷차림(테마) ── 전부 기본값이 "없음"이라 옛 정의는 옛 모습 그대로다. + public NpcHatStyle hatStyle; + public NpcWear wear; + public Color wearColor; + public bool scarf; + public Color scarfColor; + public NpcBag bag; + public NpcTool tool; + public bool glasses; + /// 명부회 표식 — 왼팔 완장 + 가슴 배지. 제복이 색만 같던 걸 표식으로 묶는다. + public bool ledgerMark; } /// @@ -86,8 +109,163 @@ public static class NpcVisualBuilder new Color(0.85f, 0.3f, 0.3f), }; - /// seed 기반 결정적 성인 주민 외형. - public static NpcAppearance RandomVillager(int seed) + /// seed 기반 결정적 성인 주민 외형(리전 복장 없음 — 초원 기준). + public static NpcAppearance RandomVillager(int seed) => RandomVillager(seed, null); + + /// seed 기반 결정적 아이 외형(리전 복장 없음 — 초원 기준). + public static NpcAppearance RandomKid(int seed) => RandomKid(seed, null); + + /// + /// 리전 복장을 입힌 주민. 옛날엔 리전과 무관해서 서릿길 주민도 모래언덕 주민도 같은 반팔이었다. + /// 1막은 한국 시골 마을풍(밀짚모자·조끼·앞치마·수건), 2막은 기후 실용복(털모자·두건·안전모·코트)이다. + /// 옷차림은 별도 난수(seed 파생)로 뽑는다 — 기존 색·머리 추첨 순서를 건드리면 주민 얼굴이 통째로 바뀐다. + /// + public static NpcAppearance RandomVillager(int seed, string regionId) + { + NpcAppearance a = RandomVillagerBase(seed); + DressForRegion(ref a, regionId, new System.Random(seed ^ 0x5eed)); + CoverTop(ref a); + return a; + } + + /// 리전 복장을 입힌 곤충잡이 아이 — 뜰채는 그대로 들고, 모자·목도리만 기후를 따른다. + public static NpcAppearance RandomKid(int seed, string regionId) + { + NpcAppearance a = RandomKidBase(seed); + DressKidForRegion(ref a, regionId, new System.Random(seed ^ 0x5eed)); + return a; + } + + private static Color Pick(System.Random rng, params Color[] options) => options[rng.Next(options.Length)]; + + private static void DressForRegion(ref NpcAppearance a, string regionId, System.Random rng) + { + Color straw = new Color(0.86f, 0.74f, 0.46f); + switch (regionId) + { + case "forest": // 나무꾼·숲지기 + a.hasHat = rng.NextDouble() < 0.6; a.hatStyle = NpcHatStyle.Beanie; + a.hat = Pick(rng, new Color(0.45f, 0.30f, 0.18f), new Color(0.30f, 0.42f, 0.26f)); + a.wear = NpcWear.Vest; a.wearColor = Pick(rng, new Color(0.40f, 0.30f, 0.18f), new Color(0.32f, 0.40f, 0.24f)); + a.tool = rng.NextDouble() < 0.4 ? NpcTool.Axe : NpcTool.None; + a.bag = rng.NextDouble() < 0.3 ? NpcBag.Backpack : NpcBag.None; + break; + case "swamp": // 우비와 챙 넓은 비모자 + a.hasHat = true; a.hatStyle = NpcHatStyle.Wide; a.hat = new Color(0.36f, 0.40f, 0.26f); + a.wear = rng.NextDouble() < 0.6 ? NpcWear.Coat : NpcWear.Vest; + a.wearColor = Pick(rng, new Color(0.40f, 0.44f, 0.26f), new Color(0.46f, 0.40f, 0.24f)); + a.tool = rng.NextDouble() < 0.4 ? NpcTool.Basket : NpcTool.Staff; + break; + case "mountain": // 등산객·봉우리 사람들 + a.hasHat = rng.NextDouble() < 0.7; a.hatStyle = NpcHatStyle.Beanie; + a.hat = Pick(rng, new Color(0.78f, 0.28f, 0.22f), new Color(0.26f, 0.40f, 0.62f), new Color(0.90f, 0.72f, 0.26f)); + a.wear = rng.NextDouble() < 0.5 ? NpcWear.Coat : NpcWear.Vest; + a.wearColor = Pick(rng, new Color(0.30f, 0.36f, 0.46f), new Color(0.46f, 0.34f, 0.24f)); + a.bag = NpcBag.Backpack; + a.tool = rng.NextDouble() < 0.5 ? NpcTool.Staff : NpcTool.None; + break; + case "ruins": // 탐사대 + a.hasHat = true; a.hatStyle = NpcHatStyle.Wide; a.hat = new Color(0.66f, 0.58f, 0.42f); + a.wear = NpcWear.Vest; a.wearColor = new Color(0.58f, 0.50f, 0.36f); + a.bag = NpcBag.Satchel; + a.tool = rng.NextDouble() < 0.5 ? NpcTool.Book : NpcTool.Brush; + a.glasses = rng.NextDouble() < 0.3; + break; + case "hollow": // 떠나지 못한 사람들 — 빛바랜 두건과 목도리 + a.top = Color.Lerp(a.top, new Color(0.6f, 0.58f, 0.52f), 0.55f); + a.hasHat = rng.NextDouble() < 0.6; a.hatStyle = NpcHatStyle.Bandana; a.hat = new Color(0.62f, 0.58f, 0.50f); + a.scarf = true; a.scarfColor = new Color(0.70f, 0.66f, 0.58f); + a.wear = rng.NextDouble() < 0.5 ? NpcWear.Vest : NpcWear.None; a.wearColor = new Color(0.50f, 0.47f, 0.40f); + break; + case "dunes": // 사막 행상 — 두건·로브·얼굴 가리개 + a.hasHat = true; a.hatStyle = NpcHatStyle.Headwrap; + a.hat = Pick(rng, new Color(0.92f, 0.86f, 0.70f), new Color(0.76f, 0.40f, 0.26f), new Color(0.30f, 0.46f, 0.62f)); + a.wear = NpcWear.Robe; a.wearColor = Pick(rng, new Color(0.86f, 0.76f, 0.56f), new Color(0.72f, 0.58f, 0.40f)); + a.scarf = rng.NextDouble() < 0.6; a.scarfColor = a.hat; + a.bag = rng.NextDouble() < 0.5 ? NpcBag.Satchel : NpcBag.None; + break; + case "frostline": // 털모자·솜 코트·목도리 + a.hasHat = true; a.hatStyle = NpcHatStyle.Beanie; + a.hat = Pick(rng, new Color(0.80f, 0.26f, 0.24f), new Color(0.26f, 0.38f, 0.62f), new Color(0.92f, 0.92f, 0.90f)); + a.wear = NpcWear.Coat; a.wearColor = Pick(rng, new Color(0.30f, 0.40f, 0.56f), new Color(0.56f, 0.30f, 0.26f), new Color(0.40f, 0.44f, 0.36f)); + a.scarf = true; a.scarfColor = Pick(rng, new Color(0.86f, 0.30f, 0.26f), new Color(0.94f, 0.80f, 0.36f), new Color(0.92f, 0.92f, 0.90f)); + a.bag = rng.NextDouble() < 0.4 ? NpcBag.Backpack : NpcBag.None; + break; + case "emberfall": // 광부 — 안전모·가죽 앞치마·곡괭이 + a.hasHat = true; a.hatStyle = NpcHatStyle.Helmet; a.hat = Pick(rng, new Color(0.95f, 0.62f, 0.18f), new Color(0.92f, 0.80f, 0.24f)); + a.wear = NpcWear.Apron; a.wearColor = new Color(0.40f, 0.28f, 0.18f); + a.tool = rng.NextDouble() < 0.5 ? NpcTool.Pickaxe : NpcTool.Lantern; + break; + case "canopy": // 나무 위 사람들 — 잎빛 조끼, 열매 바구니 + a.hasHat = rng.NextDouble() < 0.5; a.hatStyle = NpcHatStyle.Bandana; a.hat = new Color(0.32f, 0.54f, 0.26f); + a.wear = NpcWear.Vest; a.wearColor = new Color(0.30f, 0.50f, 0.26f); + a.bag = NpcBag.Satchel; + a.tool = rng.NextDouble() < 0.5 ? NpcTool.Basket : NpcTool.None; + break; + case "nameless": + a.hasHat = true; a.hatStyle = NpcHatStyle.Hood; a.hat = new Color(0.44f, 0.43f, 0.48f); + a.wear = NpcWear.Robe; a.wearColor = new Color(0.42f, 0.41f, 0.46f); + break; + case "garden": // 정원사 — 챙 넓은 모자·앞치마·전지가위 + a.hasHat = rng.NextDouble() < 0.7; a.hatStyle = NpcHatStyle.Wide; a.hat = Pick(rng, straw, new Color(0.96f, 0.66f, 0.30f)); + a.wear = NpcWear.Apron; a.wearColor = Pick(rng, new Color(0.42f, 0.58f, 0.34f), new Color(0.86f, 0.80f, 0.66f)); + a.tool = rng.NextDouble() < 0.5 ? NpcTool.Shears : NpcTool.Basket; + break; + case "pond": // 나루터 — 밀짚모자·목수건 + a.hasHat = rng.NextDouble() < 0.7; a.hatStyle = NpcHatStyle.Straw; a.hat = straw; + a.scarf = rng.NextDouble() < 0.5; a.scarfColor = new Color(0.94f, 0.94f, 0.90f); + a.wear = rng.NextDouble() < 0.4 ? NpcWear.Vest : NpcWear.None; a.wearColor = new Color(0.30f, 0.42f, 0.56f); + break; + default: // 초원(본 마을) — 한국 시골 마을풍 + a.hasHat = rng.NextDouble() < 0.5; a.hatStyle = NpcHatStyle.Straw; a.hat = straw; + a.scarf = rng.NextDouble() < 0.3; a.scarfColor = new Color(0.94f, 0.94f, 0.90f); // 목에 두른 수건 + double w = rng.NextDouble(); + a.wear = w < 0.3 ? NpcWear.Vest : w < 0.55 ? NpcWear.Apron : NpcWear.None; + a.wearColor = Pick(rng, new Color(0.42f, 0.32f, 0.22f), new Color(0.30f, 0.40f, 0.52f), new Color(0.86f, 0.80f, 0.66f)); + a.tool = rng.NextDouble() < 0.2 ? NpcTool.Basket : NpcTool.None; + break; + } + } + + /// + /// 코트·로브는 상체도 덮는다 — 몸통·팔(상의색)을 겉옷색으로 바꾼다. 스토리 NPC는 여기를 거치지 않는다 + /// (상의색이 대사창 초상과 짝이라 바꾸면 두 화면의 인상이 갈린다). + /// + private static void CoverTop(ref NpcAppearance a) + { + if (a.wear == NpcWear.Coat || a.wear == NpcWear.Robe) a.top = a.wearColor; + } + + private static void DressKidForRegion(ref NpcAppearance a, string regionId, System.Random rng) + { + switch (regionId) + { + case "frostline": + a.hasHat = true; a.hatStyle = NpcHatStyle.Beanie; + a.scarf = true; a.scarfColor = Pick(rng, new Color(0.94f, 0.80f, 0.36f), new Color(0.86f, 0.30f, 0.26f)); + a.wear = NpcWear.Coat; a.wearColor = a.top; + break; + case "dunes": + a.hasHat = true; a.hatStyle = NpcHatStyle.Headwrap; a.hat = new Color(0.94f, 0.88f, 0.72f); + break; + case "mountain": + a.bag = NpcBag.Backpack; + break; + case "canopy": + case "forest": + a.hatStyle = NpcHatStyle.Bandana; + break; + default: + if (rng.NextDouble() < 0.4) + { + a.hatStyle = NpcHatStyle.Straw; + a.hat = new Color(0.86f, 0.74f, 0.46f); // 밀짚은 밀짚색 — 모자 팔레트(파랑·빨강)를 그대로 쓰면 색칠한 판자 같다 + } + break; + } + } + + private static NpcAppearance RandomVillagerBase(int seed) { System.Random rng = new System.Random(seed); return new NpcAppearance @@ -104,7 +282,7 @@ public static NpcAppearance RandomVillager(int seed) } /// seed 기반 결정적 아이 외형 — 밝은 상의 팔레트 + 모자 확률 높음. - public static NpcAppearance RandomKid(int seed) + private static NpcAppearance RandomKidBase(int seed) { System.Random rng = new System.Random(seed); return new NpcAppearance @@ -123,12 +301,72 @@ public static NpcAppearance RandomKid(int seed) /// /// 스토리 NPC 고정 외형 — 구분되는 실루엣. seed 대신 storyNpcId로 결정. /// 동행자(어르신/라온/세라)와 명부회 4인(관장·집게·저울·먹). - /// **여기에 case를 빠뜨리면 그 NPC가 default(마을 어르신) 외형으로 뜬다** — - /// 의 이름 switch와 짝이다(둘 다 등록할 것). + /// 얼굴·색은 , 옷차림·소품은 에 등록한다. + /// **StoryNpcFace에 case를 빠뜨리면 그 NPC가 default(마을 어르신) 외형으로 뜬다** — + /// 의 이름 switch와 짝이다(둘 다 등록할 것, story_lint 22가 읽는다). /// 명부회는 아이보리 상의 + 남색 하의로 제복처럼 통일하고 머리·모자로 개체를 가른다 /// (세라의 보라 상의와 겹치지 않게 함). /// public static NpcAppearance StoryNpcAppearance(string storyNpcId) + { + NpcAppearance a = StoryNpcFace(storyNpcId); + StorySignature(ref a, storyNpcId); + return a; + } + + /// + /// 스토리 NPC의 옷차림·소품 — 주석으로만 약속하던 것(밀짚모자·털모자·안전모·챙 넓은 모자·도끼·책)을 + /// 실제 모양으로 만든다. 색·머리(얼굴)와 모자를 쓰는지(hasHat)·모자색(hat)은 가 정하고, + /// 여기엔 모양(hatStyle)과 겉옷·목도리·가방·도구만 둔다. 대사창 초상은 둘을 합친 를 + /// 받으므로(몸통은 겉옷 색) 어느 쪽을 고쳐도 대사창이 따라간다 — 나눈 건 역할 구분이지 초상 때문이 아니다. + /// + private static void StorySignature(ref NpcAppearance a, string storyNpcId) + { + Color ivory = TopPalette[5]; + Color navy = new Color(0.18f, 0.22f, 0.30f); + switch (storyNpcId) + { + // 명부회 — 간부는 아이보리 긴 코트, 하수는 아이보리 상의 위 남색 조끼. 넷 다 완장·배지. + // 1막 하수가 간부와 같은 아이보리를 입는 게 단서라(StoryNpcFace 주석) 상의는 건드리지 않는다. + case "ledger_chief": a.wear = NpcWear.Coat; a.wearColor = ivory; a.ledgerMark = true; a.tool = NpcTool.Ledger; a.glasses = true; break; + case "ledger_grip": a.wear = NpcWear.Coat; a.wearColor = ivory; a.ledgerMark = true; a.bag = NpcBag.Satchel; break; + case "ledger_scale": a.wear = NpcWear.Coat; a.wearColor = ivory; a.ledgerMark = true; a.glasses = true; a.tool = NpcTool.Book; break; + case "ledger_ink": a.wear = NpcWear.Coat; a.wearColor = ivory; a.ledgerMark = true; a.hatStyle = NpcHatStyle.Beanie; a.tool = NpcTool.Brush; a.bag = NpcBag.Satchel; break; + case "ledger_thug_cord": a.wear = NpcWear.Vest; a.wearColor = navy; a.ledgerMark = true; a.hatStyle = NpcHatStyle.Wide; break; + case "ledger_thug_rule": a.wear = NpcWear.Vest; a.wearColor = navy; a.ledgerMark = true; a.tool = NpcTool.Staff; break; + case "ledger_thug_pin": a.wear = NpcWear.Vest; a.wearColor = navy; a.ledgerMark = true; break; + case "catcher_rival": a.bag = NpcBag.Backpack; break; // 라온 — 뜰채 + 채집 배낭 + case "ruins_scholar": a.glasses = true; a.bag = NpcBag.Satchel; a.tool = NpcTool.Book; // 세라 — 안경·책·가방 + a.wear = NpcWear.Coat; a.wearColor = new Color(0.36f, 0.28f, 0.46f); break; + case "town_meadow": a.tool = NpcTool.Book; break; // 달래 — 그림책을 든 아이 + case "town_pond": a.hatStyle = NpcHatStyle.Straw; // 물결 할머니 — 밀짚모자(밀짚색은 StoryNpcFace) + a.scarf = true; a.scarfColor = new Color(0.94f, 0.94f, 0.90f); a.tool = NpcTool.Staff; break; + case "town_forest": a.hatStyle = NpcHatStyle.Beanie; a.tool = NpcTool.Axe; // 솔 — 나무꾼 + a.wear = NpcWear.Vest; a.wearColor = new Color(0.40f, 0.30f, 0.18f); break; + case "town_swamp": a.tool = NpcTool.Basket; a.wear = NpcWear.Apron; a.wearColor = new Color(0.44f, 0.46f, 0.30f); break; // 이끼 — 약초 바구니 + case "town_mountain": a.hatStyle = NpcHatStyle.Beanie; a.tool = NpcTool.Lantern; // 너울 — 봉수지기 + a.wear = NpcWear.Coat; a.wearColor = Color.Lerp(a.top, Color.black, 0.2f); break; + case "town_garden": a.hatStyle = NpcHatStyle.Wide; a.tool = NpcTool.Shears; // 누리 — 챙 넓은 모자·전지가위 + a.wear = NpcWear.Apron; a.wearColor = new Color(0.86f, 0.80f, 0.66f); break; + case "town_ruins": a.bag = NpcBag.Satchel; a.tool = NpcTool.Scroll; break; // 결 — 탁본 두루마리 + case "town_hollow": a.scarf = true; a.scarfColor = new Color(0.72f, 0.70f, 0.62f); a.tool = NpcTool.Lantern; break; // 메아리 — 풍경지기 + case "town_dunes": a.hatStyle = NpcHatStyle.Wide; a.wear = NpcWear.Robe; a.wearColor = new Color(0.84f, 0.72f, 0.50f); // 모래 — 떠돌이 상인 + a.bag = NpcBag.Backpack; break; + case "town_frostline": a.hatStyle = NpcHatStyle.Beanie; a.wear = NpcWear.Coat; a.wearColor = new Color(0.34f, 0.44f, 0.58f); // 서리 — 필사생 + a.scarf = true; a.scarfColor = new Color(0.92f, 0.92f, 0.90f); a.tool = NpcTool.Scroll; break; + case "town_emberfall": a.hatStyle = NpcHatStyle.Helmet; a.wear = NpcWear.Apron; a.wearColor = new Color(0.40f, 0.28f, 0.18f); // 숯 — 광부 + a.tool = NpcTool.Pickaxe; break; + case "town_canopy": a.hatStyle = NpcHatStyle.Bandana; a.tool = NpcTool.Basket; break; // 잎새 — 잎 두건(쓰는지·잎색은 StoryNpcFace) + default: // village_elder — 챙 넓은 모자·조끼·지팡이 + a.hatStyle = NpcHatStyle.Wide; a.wear = NpcWear.Vest; a.wearColor = new Color(0.44f, 0.34f, 0.24f); a.tool = NpcTool.Staff; break; + } + } + + /// + /// 스토리 NPC 얼굴·색(피부·머리·상의·모자색). 대사창 초상(NpcDialogueUI.GetStoryPortrait)이 이 값을 그대로 받는다 — + /// 초상 쪽에 색 사본이 없으므로 여기를 고치면 필드와 대사창이 함께 바뀐다. + /// + private static NpcAppearance StoryNpcFace(string storyNpcId) { switch (storyNpcId) { @@ -198,6 +436,94 @@ public static NpcAppearance StoryNpcAppearance(string storyNpcId) hair = HairPalette[0], top = TopPalette[4], bottom = BottomPalette[0], skin = SkinPalette[0], hat = HatPalette[1], }; + // ── 마을 이야기 주민 12인(1막 일곱 + 2막 다섯) ── 대사창 초상(NpcDialogueUI.GetStoryPortrait)도 이 색을 받는다. + // 리전이 서로 떨어져 있어 한 화면에 둘이 서지 않으므로 색이 겹쳐도 되지만, + // **같은 리전의 동행자·검은 옷(TopPalette[5])과는 겹치지 않게** 골랐다. + case "town_meadow": // 달래 — 그림책을 든 아이. 분홍 상의, 묶은 머리 + return new NpcAppearance + { + isChild = true, hairStyle = 2, hasHat = false, + hair = HairPalette[1], top = KidTopPalette[4], bottom = BottomPalette[2], + skin = SkinPalette[2], hat = HatPalette[0], + }; + case "town_pond": // 물결 할머니 — 나루터. 물빛 상의, 백발 쪽머리, 밀짚모자 + return new NpcAppearance + { + isChild = false, hairStyle = 2, hasHat = true, + hair = HairPalette[3], top = TopPalette[1], bottom = BottomPalette[1], + skin = SkinPalette[1], hat = new Color(0.86f, 0.74f, 0.46f), // 밀짚색 — 모자 팔레트 주황이면 밀짚모자가 색칠한 판자 같다 + }; + case "town_forest": // 솔 — 나무꾼. 붉은 상의, 갈색 모자 + return new NpcAppearance + { + isChild = false, hairStyle = 0, hasHat = true, + hair = HairPalette[0], top = TopPalette[0], bottom = BottomPalette[1], + skin = SkinPalette[3], hat = HatPalette[2], + }; + case "town_swamp": // 이끼 — 약초꾼. 풀색 상의, 묶은 머리, 모자 없음 + return new NpcAppearance + { + isChild = false, hairStyle = 2, hasHat = false, + hair = HairPalette[1], top = TopPalette[2], bottom = BottomPalette[2], + skin = SkinPalette[0], hat = HatPalette[2], + }; + case "town_mountain": // 너울 — 봉수지기 노인. 어르신과 같은 백발이라 모자색(청회)으로 가른다 + return new NpcAppearance + { + isChild = false, hairStyle = 1, hasHat = true, + hair = HairPalette[3], top = TopPalette[3], bottom = BottomPalette[3], + skin = SkinPalette[3], hat = HatPalette[1], + }; + case "town_garden": // 누리 — 정원사. 연두 상의, 챙 넓은 주황 모자 + return new NpcAppearance + { + isChild = false, hairStyle = 1, hasHat = true, + hair = HairPalette[2], top = KidTopPalette[2], bottom = BottomPalette[2], + skin = SkinPalette[2], hat = HatPalette[0], + }; + case "town_ruins": // 결 — 탁본꾼(세라의 옛 조수). 푸른 상의, 짙은 보라 머리 + return new NpcAppearance + { + isChild = false, hairStyle = 0, hasHat = false, + hair = HairPalette[4], top = TopPalette[1], bottom = BottomPalette[0], + skin = SkinPalette[0], hat = HatPalette[1], + }; + // ── 2막 다섯 ── 같은 리전의 세라(TopPalette[4])·라온(KidTopPalette[0])·명부회(TopPalette[5])와 겹치지 않게. + case "town_hollow": // 메아리 — 풍경지기. 하늘색 상의, 모자 없음 + return new NpcAppearance + { + isChild = false, hairStyle = 1, hasHat = false, + hair = HairPalette[2], top = TopPalette[1], bottom = BottomPalette[3], + skin = SkinPalette[1], hat = HatPalette[1], + }; + case "town_dunes": // 모래 — 떠돌이 상인. 모래빛 상의, 주황 햇볕 모자 + return new NpcAppearance + { + isChild = false, hairStyle = 0, hasHat = true, + hair = HairPalette[0], top = TopPalette[3], bottom = BottomPalette[1], + skin = SkinPalette[3], hat = HatPalette[0], + }; + case "town_frostline": // 서리 — 필사생. 얼음빛 상의, 청회 털모자 + return new NpcAppearance + { + isChild = false, hairStyle = 2, hasHat = true, + hair = HairPalette[4], top = KidTopPalette[1], bottom = BottomPalette[0], + skin = SkinPalette[2], hat = HatPalette[1], + }; + case "town_emberfall": // 숯 — 광부. 잿빛 상의, 주황 안전모 + return new NpcAppearance + { + isChild = false, hairStyle = 0, hasHat = true, + hair = HairPalette[0], top = BottomPalette[3], bottom = BottomPalette[0], + skin = SkinPalette[3], hat = HatPalette[0], + }; + case "town_canopy": // 잎새 — 나무 위에서 자란 아이. 노란 상의, 잎빛 두건 + return new NpcAppearance + { + isChild = true, hairStyle = 1, hasHat = true, + hair = HairPalette[1], top = KidTopPalette[3], bottom = BottomPalette[2], + skin = SkinPalette[0], hat = new Color(0.34f, 0.58f, 0.28f), + }; default: // village_elder — 마을 어르신(백발·모자·따뜻한 상의) return new NpcAppearance { @@ -212,6 +538,7 @@ public static NpcAppearance StoryNpcAppearance(string storyNpcId) public static void Build(Transform root, NpcAppearance a) { if (root == null) return; + partMats.Clear(); Material topMat = MakeMaterial(a.top, SurfaceKind.Cloth); Material bottomMat = MakeMaterial(a.bottom, SurfaceKind.Cloth); @@ -229,10 +556,13 @@ public static void Build(Transform root, NpcAppearance a) MakeBoxPart("Shirt", root, new Vector3(0f, 0.83f, 0.10f), new Vector3(0.24f, 0.36f, 0.20f), 0.05f, 2, shirtMat); + MakePart(PrimitiveType.Cylinder, "Neck", root, + new Vector3(0f, 1f, 0.02f), new Vector3(0.14f, 0.05f, 0.12f), skinMat); + // ── 머리 (HeadPivot 컨테이너 + Head 구) ── GameObject headPivot = new GameObject("HeadPivot"); headPivot.transform.SetParent(root, false); - headPivot.transform.localPosition = new Vector3(0f, 1.22f, 0.03f); + headPivot.transform.localPosition = PlayerVisualBuilder.HeadAnchor; headPivot.transform.localScale = Vector3.one * 0.60f; MakeMeshPart("Head", headPivot.transform, UnitSphere(10, 14), @@ -253,17 +583,14 @@ public static void Build(Transform root, NpcAppearance a) new Vector3(0.12f, -0.04f, 0.35f), new Vector3(0.09f, 0.11f, 0.02f), pupilMat); // ── 머리카락 (스타일 3종 단순 변주) ── - BuildHair(headPivot.transform, a.hairStyle, hairMat); + // 머리를 감싸는 모자(털모자·안전모·두건·후드) 밑으로 올림머리 번이 뚫고 나오지 않게 뺀다 + bool covered = a.hasHat && (a.hatStyle == NpcHatStyle.Beanie || a.hatStyle == NpcHatStyle.Helmet + || a.hatStyle == NpcHatStyle.Headwrap || a.hatStyle == NpcHatStyle.Hood); + BuildHair(headPivot.transform, covered && a.hairStyle == 2 ? 0 : a.hairStyle, hairMat); // ── 모자 ── - if (a.hasHat) - { - Material hatMat = MakeMaterial(a.hat, SurfaceKind.Cloth); - MakePart(PrimitiveType.Cylinder, "Cap", headPivot.transform, - new Vector3(0f, 0.3f, -0.02f), new Vector3(0.30f, 0.12f, 0.30f), hatMat); - MakePart(PrimitiveType.Cube, "CapBrim", headPivot.transform, - new Vector3(0f, 0.14f, 0.28f), new Vector3(0.28f, 0.03f, 0.14f), hatMat); - } + if (a.hasHat) BuildHat(headPivot.transform, a.hatStyle, a.hat); + if (a.glasses) BuildGlasses(headPivot.transform); // ── 팔 ── Mesh armMesh = UnitCapsule(0.72f); @@ -281,18 +608,32 @@ public static void Build(Transform root, NpcAppearance a) BuildLeg(root, "L", -0.13f, bottomMat, shoesMat); BuildLeg(root, "R", 0.13f, bottomMat, shoesMat); - // ── 아이 전용: 루트 스케일 축소 + 뜰채 ── + // ── 겉옷·목도리·가방·도구·명부회 표식 ── + Transform body = root.Find("Body"); + if (body != null) + { + if (a.wear != NpcWear.None) BuildWear(body, a.wear, a.wearColor.a > 0.01f ? a.wearColor : Color.Lerp(a.top, Color.black, 0.25f)); + if (a.scarf) BuildScarf(body, a.scarfColor.a > 0.01f ? a.scarfColor : Color.white); + if (a.bag != NpcBag.None) BuildBag(body, a.bag); + if (a.ledgerMark) BuildLedgerMark(root, body); + } + Transform handR = root.Find("HandR"); + if (handR != null && a.tool != NpcTool.None) BuildTool(handR, a.tool); + + // ── 아이 전용: 루트 스케일 축소 + 뜰채(다른 도구를 들었으면 뜰채는 없다) ── if (a.isChild) { root.localScale = Vector3.one * 0.75f; - + } + if (a.isChild && a.tool == NpcTool.None) + { Material netHandleMat = MakeMaterial(new Color(0.6f, 0.4f, 0.2f), SurfaceKind.Leather); Material netRingMat = MakeMaterial(new Color(0.95f, 0.92f, 0.88f), SurfaceKind.Metal); GameObject handle = MakePart(PrimitiveType.Cylinder, "NetHandle", root, - new Vector3(0.29f, 0.74f, 0.02f), new Vector3(0.04f, 0.40f, 0.04f), netHandleMat); - handle.transform.localRotation = Quaternion.Euler(20f, 0f, -15f); + new Vector3(0.29f, 0.74f, 0f), new Vector3(0.04f, 0.40f, 0.04f), netHandleMat); + handle.transform.localRotation = Quaternion.identity; GameObject ring = MakePart(PrimitiveType.Cylinder, "NetRing", root, - new Vector3(0.34f, 1.14f, 0.06f), new Vector3(0.20f, 0.02f, 0.20f), netRingMat); + new Vector3(0.29f, 1.14f, 0f), new Vector3(0.20f, 0.02f, 0.20f), netRingMat); ring.transform.localRotation = Quaternion.Euler(-20f, 0f, 0f); } @@ -303,6 +644,7 @@ public static void Build(Transform root, NpcAppearance a) capsule.radius = 0.28f; capsule.height = 1.5f; capsule.isTrigger = true; + partMats.Clear(); // 다음 NPC가 이 NPC의 머티리얼을 물려받지 않게(파괴는 CleanupMaterials가 한다) } @@ -319,11 +661,250 @@ public static void CleanupMaterials(Transform root) for (int i = 0; i < renderers.Length; i++) { if (renderers[i] == null) continue; + // **글자(TextMesh)의 머티리얼은 건너뛴다.** 그건 Build가 만든 인스턴스가 아니라 폰트 에셋의 + // 머티리얼이다 — 지우면 "Destroying assets is not permitted" 에러가 나고(씬 재로드마다), + // 에디터에선 폰트 자체를 망가뜨린다. 머리 위 의뢰 표식(VillagerNpc.QuestMark)이 여기 걸렸다. + if (renderers[i].TryGetComponent(out TextMesh _)) continue; Material m = renderers[i].sharedMaterial; if (m != null && seen.Add(m)) Object.Destroy(m); } } + // ── 옷차림 부품 ── (콜라이더 없음. 머티리얼은 한 NPC 안에서 색·재질 단위로 공유 — CleanupMaterials가 회수) + + /// + /// 키는 (색, 재질)이다. 색만 키로 쓰면 먼저 캐시된 재질이 이긴다 — 안전모는 모자 천(BuildHat 첫 줄의 + /// Cloth)이 같은 색으로 먼저 들어가 있어 Leather를 요청해도 천 인스턴스를 돌려받았고, 광부 안전모가 무광 천으로 그려졌다. + /// + private static readonly Dictionary<(Color, SurfaceKind), Material> partMats = new Dictionary<(Color, SurfaceKind), Material>(); + + private static Material PartMat(Color color, SurfaceKind kind) + { + if (partMats.TryGetValue((color, kind), out Material m) && m != null) return m; + m = MakeMaterial(color, kind); + partMats[(color, kind)] = m; + return m; + } + + private static Mesh Ball => ProcMeshLibrary.LowSphere(0.5f, 0.5f, 0.5f, 6, 10); + + private static GameObject Ball3(string name, Transform parent, Vector3 pos, Vector3 scale, Material mat) + { + return MakeMeshPart(name, parent, Ball, pos, scale, mat); + } + + private static GameObject Box(string name, Transform parent, Vector3 pos, Vector3 size, Material mat, float round = 0.02f) + { + return MakeBoxPart(name, parent, pos, size, Mathf.Min(round, Mathf.Min(size.x, Mathf.Min(size.y, size.z)) * 0.45f), 1, mat); + } + + /// + /// 모자 7종. 좌표는 HeadPivot 공간(머리 구 반지름 ≈0.35, 정수리 ≈0.39, 얼굴 +Z). 옛 모자는 전부 + /// 같은 야구모자 모양이어서 밀짚모자·털모자·안전모라는 주석이 색 말고는 구분되지 않았다. + /// + private static void BuildHat(Transform head, NpcHatStyle style, Color color) + { + // 안전모만 단단한 껍데기(Leather)고 나머지는 천이다. 천을 먼저 만들어 두면 안전모 분기에선 렌더러에 + // 안 붙은 채 남는다 — CleanupMaterials는 렌더러를 훑어 회수하므로 그건 씬이 끝날 때까지 샌다. + Material hat = PartMat(color, style == NpcHatStyle.Helmet ? SurfaceKind.Leather : SurfaceKind.Cloth); + switch (style) + { + case NpcHatStyle.Straw: + case NpcHatStyle.Wide: + { + float brim = style == NpcHatStyle.Wide ? 1.22f : 1.08f; + MakePart(PrimitiveType.Cylinder, "HatBrim", head, new Vector3(0f, 0.25f, 0f), new Vector3(brim, 0.018f, brim), hat); + MakePart(PrimitiveType.Cylinder, "HatCrown", head, new Vector3(0f, 0.36f, -0.01f), new Vector3(0.68f, 0.11f, 0.68f), hat); + Color band = style == NpcHatStyle.Straw ? new Color(0.62f, 0.22f, 0.18f) : Color.Lerp(color, Color.black, 0.45f); + MakePart(PrimitiveType.Cylinder, "HatBand", head, new Vector3(0f, 0.285f, -0.01f), new Vector3(0.7f, 0.035f, 0.7f), PartMat(band, SurfaceKind.Cloth)); + break; + } + case NpcHatStyle.Beanie: + Ball3("Beanie", head, new Vector3(0f, 0.2f, -0.03f), new Vector3(0.76f, 0.5f, 0.74f), hat); + MakePart(PrimitiveType.Cylinder, "BeanieFold", head, new Vector3(0f, 0.1f, -0.03f), new Vector3(0.76f, 0.05f, 0.74f), + PartMat(Color.Lerp(color, Color.white, 0.2f), SurfaceKind.Cloth)); + Ball3("BeaniePom", head, new Vector3(0f, 0.47f, -0.05f), Vector3.one * 0.18f, PartMat(new Color(0.95f, 0.95f, 0.92f), SurfaceKind.Cloth)); + break; + case NpcHatStyle.Helmet: + Ball3("Helmet", head, new Vector3(0f, 0.19f, -0.02f), new Vector3(0.8f, 0.54f, 0.8f), hat); + MakePart(PrimitiveType.Cylinder, "HelmetBrim", head, new Vector3(0f, 0.12f, 0.02f), new Vector3(0.9f, 0.018f, 0.92f), hat); + Material lamp = MakeMaterial(new Color(1f, 0.92f, 0.6f), SurfaceKind.Wet); + SceneryMaterials.SetEmission(lamp, new Color(1.2f, 1.0f, 0.55f)); + Ball3("HelmetLamp", head, new Vector3(0f, 0.25f, 0.37f), new Vector3(0.14f, 0.12f, 0.08f), lamp); + break; + case NpcHatStyle.Headwrap: + Ball3("Headwrap", head, new Vector3(0f, 0.19f, -0.04f), new Vector3(0.8f, 0.56f, 0.78f), hat); + Box("HeadwrapTail", head, new Vector3(0.1f, -0.12f, -0.34f), new Vector3(0.16f, 0.36f, 0.05f), hat); + break; + case NpcHatStyle.Bandana: + MakePart(PrimitiveType.Cylinder, "Bandana", head, new Vector3(0f, 0.14f, -0.01f), new Vector3(0.74f, 0.06f, 0.73f), hat); + Ball3("BandanaKnot", head, new Vector3(0f, 0.13f, -0.37f), new Vector3(0.12f, 0.1f, 0.08f), hat); + break; + case NpcHatStyle.Hood: + Ball3("Hood", head, new Vector3(0f, 0.07f, -0.1f), new Vector3(0.84f, 0.84f, 0.8f), hat); + break; + default: // Cap — 옛 모양 그대로(노드명 Cap/CapBrim 유지) + MakePart(PrimitiveType.Sphere, "Cap", head, new Vector3(0f, 0.24f, -0.02f), PlayerVisualBuilder.CapSize, hat); + MakePart(PrimitiveType.Sphere, "CapBrim", head, new Vector3(0f, 0.16f, 0.29f), PlayerVisualBuilder.CapBrimSize, hat); + break; + } + } + + private static void BuildGlasses(Transform head) + { + Material frame = PartMat(new Color(0.16f, 0.13f, 0.12f), SurfaceKind.Leather); + for (int side = -1; side <= 1; side += 2) + { + Box("GlassTop", head, new Vector3(side * 0.12f, 0.07f, 0.365f), new Vector3(0.2f, 0.025f, 0.02f), frame, 0.005f); + Box("GlassBottom", head, new Vector3(side * 0.12f, -0.125f, 0.365f), new Vector3(0.2f, 0.02f, 0.02f), frame, 0.005f); + Box("GlassSide", head, new Vector3(side * 0.22f, -0.027f, 0.35f), new Vector3(0.02f, 0.2f, 0.02f), frame, 0.005f); + } + Box("GlassBridge", head, new Vector3(0f, 0.0f, 0.37f), new Vector3(0.06f, 0.02f, 0.02f), frame, 0.005f); + } + + /// 겉옷 — 좌표는 Body 공간(몸통 0.46×0.46×0.36, 중심 = 루트 y 0.77). 셔츠 앞판(z≈0.2)은 가리지 않는다. + private static void BuildWear(Transform body, NpcWear wear, Color color) + { + Material m = PartMat(color, SurfaceKind.Cloth); + // 짙은 덧감(주머니·띠·단추선)은 앞치마·코트만 쓴다 — 조끼에서 미리 만들면 렌더러 없이 남아 샌다(BuildHat 주석) + Material Dark() => PartMat(Color.Lerp(color, Color.black, 0.35f), SurfaceKind.Cloth); + switch (wear) + { + case NpcWear.Vest: + Box("VestL", body, new Vector3(-0.155f, 0.01f, 0.185f), new Vector3(0.15f, 0.42f, 0.035f), m); + Box("VestR", body, new Vector3(0.155f, 0.01f, 0.185f), new Vector3(0.15f, 0.42f, 0.035f), m); + Box("VestBack", body, new Vector3(0f, 0.01f, -0.19f), new Vector3(0.46f, 0.44f, 0.035f), m); + Box("VestSideL", body, new Vector3(-0.24f, 0.0f, 0f), new Vector3(0.03f, 0.4f, 0.34f), m); + Box("VestSideR", body, new Vector3(0.24f, 0.0f, 0f), new Vector3(0.03f, 0.4f, 0.34f), m); + break; + case NpcWear.Apron: + Box("Apron", body, new Vector3(0f, -0.14f, 0.205f), new Vector3(0.36f, 0.56f, 0.025f), m); + Box("ApronPocket", body, new Vector3(0f, -0.24f, 0.222f), new Vector3(0.2f, 0.1f, 0.012f), Dark()); + Box("ApronTie", body, new Vector3(0f, -0.06f, 0f), new Vector3(0.48f, 0.04f, 0.38f), Dark()); + break; + case NpcWear.Coat: + case NpcWear.Robe: + { + // 허리 아래 자락 — 코트는 무릎, 로브는 발목까지. 다리가 그 안에서 걷는다 + float len = wear == NpcWear.Robe ? 0.5f : 0.32f; + Box("CoatSkirt", body, new Vector3(0f, -0.23f - len * 0.5f, 0f), new Vector3(0.5f, len, 0.4f), m, 0.05f); + Box("CoatCollar", body, new Vector3(0f, 0.22f, 0f), new Vector3(0.36f, 0.06f, 0.3f), m); + Box("CoatPlacket", body, new Vector3(0f, -0.2f, 0.2f), new Vector3(0.04f, len + 0.1f, 0.012f), Dark(), 0.005f); + Box("CoatBelt", body, new Vector3(0f, -0.21f, 0f), new Vector3(0.49f, 0.05f, 0.39f), Dark()); + break; + } + } + } + + private static void BuildScarf(Transform body, Color color) + { + Material m = PartMat(color, SurfaceKind.Cloth); + MakePart(PrimitiveType.Cylinder, "Scarf", body, new Vector3(0f, 0.22f, 0.01f), new Vector3(0.36f, 0.05f, 0.32f), m); + Box("ScarfTail", body, new Vector3(-0.09f, 0.08f, 0.2f), new Vector3(0.08f, 0.24f, 0.025f), m); + } + + private static void BuildBag(Transform body, NpcBag bag) + { + Material leather = PartMat(new Color(0.46f, 0.32f, 0.20f), SurfaceKind.Leather); + Material strap = PartMat(new Color(0.30f, 0.22f, 0.15f), SurfaceKind.Leather); + if (bag == NpcBag.Satchel) + { + Box("Satchel", body, new Vector3(0.27f, -0.2f, 0.04f), new Vector3(0.09f, 0.18f, 0.22f), leather); + GameObject s = Box("SatchelStrap", body, new Vector3(0.02f, 0.02f, 0.195f), new Vector3(0.05f, 0.56f, 0.015f), strap, 0.005f); + s.transform.localRotation = Quaternion.Euler(0f, 0f, 38f); + } + else + { + Box("Backpack", body, new Vector3(0f, 0.02f, -0.28f), new Vector3(0.36f, 0.4f, 0.18f), leather, 0.05f); + Box("BackpackFlap", body, new Vector3(0f, 0.17f, -0.36f), new Vector3(0.34f, 0.12f, 0.04f), strap); + Box("BackpackRoll", body, new Vector3(0f, 0.25f, -0.28f), new Vector3(0.38f, 0.08f, 0.1f), PartMat(new Color(0.40f, 0.46f, 0.30f), SurfaceKind.Cloth)); + for (int side = -1; side <= 1; side += 2) + Box("BackpackStrap", body, new Vector3(side * 0.12f, 0.03f, 0.195f), new Vector3(0.045f, 0.4f, 0.015f), strap, 0.005f); + } + } + + /// 명부회 표식 — 왼팔 남색 완장(팔과 함께 흔들린다) + 가슴의 금 배지. + private static void BuildLedgerMark(Transform root, Transform body) + { + Material navy = PartMat(new Color(0.18f, 0.22f, 0.30f), SurfaceKind.Cloth); + Transform armL = root.Find("ArmL"); + if (armL != null) + { + // ArmL은 (0.135, 0.23, 0.135)로 눌린 캡슐이라 자식 좌표도 그 공간이다 — 팔 둘레보다 15% 굵게 + MakePart(PrimitiveType.Cylinder, "Armband", armL, new Vector3(0f, 0.3f, 0f), new Vector3(1.15f, 0.12f, 1.15f), navy); + } + Material gold = MakeMaterial(new Color(0.95f, 0.78f, 0.30f), SurfaceKind.Metal); + Ball3("LedgerBadge", body, new Vector3(-0.13f, 0.1f, 0.21f), new Vector3(0.06f, 0.06f, 0.02f), gold); + } + + /// 오른손 도구 — HandR의 자식이라 팔 스윙을 그대로 따른다(좌표는 손 중심 기준). + private static void BuildTool(Transform hand, NpcTool tool) + { + // 나무·쇠는 쓰는 도구에서만 만든다 — 책·두루마리·등불·바구니에서 미리 만들면 렌더러 없이 남아 샌다(BuildHat 주석). + // PartMat이 캐시하므로 한 도구 안에서 여러 번 불러도 인스턴스는 하나다. + Material Wood() => PartMat(new Color(0.46f, 0.32f, 0.18f), SurfaceKind.Leather); + Material Iron() => PartMat(new Color(0.52f, 0.54f, 0.58f), SurfaceKind.Metal); + switch (tool) + { + case NpcTool.Axe: + MakePart(PrimitiveType.Cylinder, "ToolHandle", hand, new Vector3(0f, 0.08f, 0.05f), new Vector3(0.035f, 0.24f, 0.035f), Wood()); + Box("AxeHead", hand, new Vector3(0.06f, 0.3f, 0.05f), new Vector3(0.14f, 0.1f, 0.025f), Iron()); + break; + case NpcTool.Pickaxe: + MakePart(PrimitiveType.Cylinder, "ToolHandle", hand, new Vector3(0f, 0.08f, 0.05f), new Vector3(0.035f, 0.26f, 0.035f), Wood()); + Box("PickHead", hand, new Vector3(0f, 0.33f, 0.05f), new Vector3(0.36f, 0.045f, 0.045f), Iron()); + break; + case NpcTool.Staff: + MakePart(PrimitiveType.Cylinder, "Staff", hand, new Vector3(0f, 0.02f, 0.06f), new Vector3(0.035f, 0.52f, 0.035f), Wood()); + break; + case NpcTool.Lantern: + { + Material dark = PartMat(new Color(0.18f, 0.16f, 0.14f), SurfaceKind.Metal); + Box("LanternCage", hand, new Vector3(0f, -0.16f, 0.05f), new Vector3(0.12f, 0.15f, 0.12f), dark, 0.01f); + Material glow = MakeMaterial(new Color(1f, 0.85f, 0.5f), SurfaceKind.Wet); + SceneryMaterials.SetEmission(glow, new Color(1.3f, 0.95f, 0.45f)); + Ball3("LanternGlow", hand, new Vector3(0f, -0.16f, 0.05f), new Vector3(0.13f, 0.13f, 0.13f), glow); + break; + } + case NpcTool.Book: + case NpcTool.Ledger: + { + Color cover = tool == NpcTool.Ledger ? new Color(0.16f, 0.18f, 0.24f) : new Color(0.62f, 0.24f, 0.20f); + Box("BookCover", hand, new Vector3(0f, -0.02f, 0.08f), new Vector3(0.17f, 0.22f, tool == NpcTool.Ledger ? 0.07f : 0.045f), PartMat(cover, SurfaceKind.Leather), 0.01f); + Box("BookPages", hand, new Vector3(0.012f, -0.02f, 0.08f), new Vector3(0.155f, 0.2f, tool == NpcTool.Ledger ? 0.058f : 0.034f), PartMat(new Color(0.94f, 0.91f, 0.82f), SurfaceKind.Cloth), 0.005f); + break; + } + case NpcTool.Scroll: + { + GameObject roll = MakePart(PrimitiveType.Cylinder, "Scroll", hand, new Vector3(0f, -0.04f, 0.07f), new Vector3(0.06f, 0.13f, 0.06f), PartMat(new Color(0.92f, 0.88f, 0.76f), SurfaceKind.Cloth)); + roll.transform.localRotation = Quaternion.Euler(0f, 0f, 80f); + break; + } + case NpcTool.Brush: + MakePart(PrimitiveType.Cylinder, "BrushStem", hand, new Vector3(0f, 0.08f, 0.05f), new Vector3(0.022f, 0.12f, 0.022f), Wood()); + Ball3("BrushTip", hand, new Vector3(0f, 0.22f, 0.05f), new Vector3(0.04f, 0.07f, 0.04f), PartMat(new Color(0.12f, 0.10f, 0.10f), SurfaceKind.Hair)); + break; + case NpcTool.Shears: + { + Material iron = Iron(); + GameObject a1 = Box("ShearA", hand, new Vector3(0f, 0.1f, 0.06f), new Vector3(0.025f, 0.24f, 0.012f), iron, 0.004f); + a1.transform.localRotation = Quaternion.Euler(0f, 0f, 12f); + GameObject a2 = Box("ShearB", hand, new Vector3(0f, 0.1f, 0.07f), new Vector3(0.025f, 0.24f, 0.012f), iron, 0.004f); + a2.transform.localRotation = Quaternion.Euler(0f, 0f, -12f); + break; + } + case NpcTool.Basket: + { + Material wicker = PartMat(new Color(0.72f, 0.56f, 0.34f), SurfaceKind.Leather); + MakePart(PrimitiveType.Cylinder, "Basket", hand, new Vector3(0f, -0.2f, 0.06f), new Vector3(0.24f, 0.07f, 0.2f), wicker); + GameObject h = Box("BasketHandle", hand, new Vector3(0f, -0.07f, 0.06f), new Vector3(0.2f, 0.02f, 0.02f), wicker, 0.005f); + h.transform.localRotation = Quaternion.Euler(0f, 0f, 90f); + Ball3("BasketFill", hand, new Vector3(0f, -0.13f, 0.06f), new Vector3(0.2f, 0.06f, 0.16f), PartMat(new Color(0.46f, 0.62f, 0.28f), SurfaceKind.Cloth)); + break; + } + } + } + private static void BuildLeg(Transform root, string side, float x, Material bottomMat, Material shoesMat) { GameObject pivot = new GameObject($"Leg{side}Pivot"); @@ -409,7 +990,9 @@ private static GameObject MakePart(PrimitiveType type, string name, Transform pa go.transform.localScale = localScale; go.GetComponent().material = mat; Collider col = go.GetComponent(); - if (col != null) Object.Destroy(col); + // Destroy는 프레임 끝까지 미뤄진다 — 그동안 non-trigger 콜라이더가 살아 있으면 런타임 스폰 프레임의 + // 물리 질의(스폰 안전 좌표·이동 차단)에 걸린다. 먼저 끈다(SubAreaWorldBuilder.NoCollider·SubAreaGateBuilder.Part와 같게). + if (col != null) { col.enabled = false; Object.Destroy(col); } return go; } diff --git a/Assets/Scripts/NPC/NpcWalkAnimator.cs b/Assets/Scripts/NPC/NpcWalkAnimator.cs index 082aab3..018a3e4 100644 --- a/Assets/Scripts/NPC/NpcWalkAnimator.cs +++ b/Assets/Scripts/NPC/NpcWalkAnimator.cs @@ -1,4 +1,5 @@ -using UnityEngine; +using UnityEngine; +using InsectGame.Core; namespace InsectGame.NPC { @@ -9,6 +10,8 @@ namespace InsectGame.NPC /// public class NpcWalkAnimator { + private readonly Transform handL, handR; + private readonly Vector3 netHandleBasePos, netRingBasePos; private readonly Transform armL; private readonly Transform armR; private readonly Transform legPivotL; @@ -39,6 +42,8 @@ public class NpcWalkAnimator public NpcWalkAnimator(Transform root) { // 노드 캐시 — NPC 모델은 재생성되지 않으므로 생성자 1회로 충분 (PlayerMovement lazy 캐시 참고) + handL = root.Find("HandL"); + handR = root.Find("HandR"); armL = root.Find("ArmL"); armR = root.Find("ArmR"); legPivotL = root.Find("LegLPivot"); @@ -51,6 +56,8 @@ public NpcWalkAnimator(Transform root) // 뜰채 base 회전 — NPC는 도구 교체가 없어 생성 시 1회 고정 캐시 if (netHandle != null) netHandleBaseRot = netHandle.localRotation; if (netRing != null) netRingBaseRot = netRing.localRotation; + if (netHandle != null) netHandleBasePos = netHandle.localPosition; + if (netRing != null) netRingBasePos = netRing.localPosition; // idle 위상차 — 위치 기반 결정적 값(할당 없음). 근처 NPC끼리 호흡/고개 타이밍이 어긋난다. Vector3 p = root.position; @@ -131,6 +138,19 @@ public void Tick(float time, float dt, bool walking) if (netRing != null) netRing.localRotation = Quaternion.Euler(rightArmDeg, 0f, 0f) * netRingBaseRot; + if (handL != null && armL != null) + { + handL.localPosition = PlayerVisualBuilder.RotateAttachment(new Vector3(-0.29f, 0.52f, 0f), armL.localPosition, leftArmDeg); + handL.localRotation = armL.localRotation; + } + if (handR != null && armR != null) + { + handR.localPosition = PlayerVisualBuilder.RotateAttachment(new Vector3(0.29f, 0.52f, 0f), armR.localPosition, rightArmDeg); + handR.localRotation = armR.localRotation; + if (netHandle != null) netHandle.localPosition = PlayerVisualBuilder.RotateAttachment(netHandleBasePos, armR.localPosition, rightArmDeg); + if (netRing != null) netRing.localPosition = PlayerVisualBuilder.RotateAttachment(netRingBasePos, armR.localPosition, rightArmDeg); + } + // 다리 (팔과 반대) — LegPivot 회전으로 Leg+Boot 함께 전파. 제스처는 전부 상체라 관여하지 않는다. if (legPivotL != null) legPivotL.localRotation = Quaternion.Euler(-swingDeg * 0.8f, 0f, 0f); if (legPivotR != null) legPivotR.localRotation = Quaternion.Euler(swingDeg * 0.8f, 0f, 0f); diff --git a/Assets/Scripts/NPC/VillagerNpc.cs b/Assets/Scripts/NPC/VillagerNpc.cs index 77cc377..35fa4e9 100644 --- a/Assets/Scripts/NPC/VillagerNpc.cs +++ b/Assets/Scripts/NPC/VillagerNpc.cs @@ -4,6 +4,21 @@ namespace InsectGame.NPC { + /// + /// 머리 위 표식 — 마을 이야기 주민과, 본편이 지금 가리키는 한 사람에게 붙는다 + /// (StoryObjectiveTracker가 정한다). + /// + public enum QuestMark + { + None, + /// 말을 걸면 새 이야기가 나온다(!). + New, + /// 의뢰를 끝냈으니 알리러 오면 된다(?). + Report, + /// 본편이 지금 이 사람에게 말을 걸라고 한다(!, 민트) — 한 번에 한 명뿐이다. + Main, + } + /// /// 마을 주민 NPC — Idle(2~6s) ⇄ Wander(앵커 wanderRadius, 속도 1.8) + 대화/연출 상태. /// 개별 Update 없음: NpcManager가 TickAI(라운드로빈)/TickMovement(40m 이내 매 프레임)를 호출. @@ -71,6 +86,88 @@ private enum MoveResult { Moving, Arrived, Blocked } /// 대화 가능 여부 — 이미 대화 중이 아니고 활성 상태일 때. public bool CanTalk => state != State.Talking && isActiveAndEnabled; + // ── 머리 위 의뢰 표식 ── + // 월드 오브젝트라 UITheme(UI 모듈)를 끌어오지 않는다 — 색은 지도·미니맵 배지와 같은 계열 + // (호박=새 이야기, 민트=보고)로 맞춘 리터럴이다. + private const float QuestMarkHeight = 2.35f; + // !·? 한 색 — 지도·미니맵에서 ?를 민트로 두었더니 이야기 목표·서브에리어 입구(민트)와 섞였다. + // 세 곳(머리 위·지도·미니맵)이 같은 색·같은 기호를 쓰고, 새 이야기와 보고는 기호로 가른다. + private static readonly Color QuestMarkColor = new Color(1f, 0.8f, 0.25f); + // 본편 표식은 지도의 이야기 목표 마커와 같은 민트 계열이다. 지도 토큰(#4FC98A) 그대로면 초원 풀색과 + // 붙어 버려서 흰 쪽으로 많이 띄웠고, 의뢰 표식보다 크다 — "지금 가야 할 한 사람"이 여러 !들 사이에서 + // 먼저 보여야 한다. + private static readonly Color MainQuestMarkColor = new Color(0.72f, 1f, 0.86f); + private const float QuestMarkSize = 0.12f; + private const float MainQuestMarkSize = 0.16f; + // Camera.main은 호출마다 태그 검색이다 — InsectEntity의 이름표와 같은 이유로 정적 캐시. + private static Camera questMarkCamera; + private QuestMark questMark = QuestMark.None; + private TextMesh questMarkText; + // 표식은 **몸이 보일 때만** 보인다. DistanceCulling은 Awake에 몸 렌더러만 캐시해서 나중에 만든 + // 표식을 모른다 — 그대로 두면 컬링된(보이지 않는) NPC 위에 !만 떠 있고, TickMovement 밖(40m+)에선 + // 카메라를 향하는 갱신도 멈춰 옆으로 누운 글자가 된다. 그래서 몸 렌더러 하나를 따라 켜고 끈다. + private Renderer questMarkRenderer; + private Renderer questMarkBodyRenderer; + + /// + /// 머리 위 표식을 바꾼다. 같은 값이면 아무것도 안 한다(트래커가 0.5초마다 부른다). + /// 표식은 처음 필요할 때 만든다 — 일반 주민·동행자에게는 끝내 생기지 않는다. + /// + public void SetQuestMark(QuestMark mark) + { + if (mark == questMark) return; + questMark = mark; + + if (mark == QuestMark.None) + { + if (questMarkText != null) questMarkText.gameObject.SetActive(false); + return; + } + + if (questMarkText == null) questMarkText = CreateQuestMark(); + bool main = mark == QuestMark.Main; + questMarkText.text = mark == QuestMark.Report ? "?" : "!"; + questMarkText.color = main ? MainQuestMarkColor : QuestMarkColor; + questMarkText.characterSize = main ? MainQuestMarkSize : QuestMarkSize; + questMarkText.gameObject.SetActive(true); + SyncQuestMarkVisibility(); // 멀리서(컬링 중) 켜질 수 있다 — 틱을 기다리지 않고 바로 맞춘다 + } + + private void SyncQuestMarkVisibility() + { + if (questMarkRenderer == null) return; + questMarkRenderer.enabled = questMarkBodyRenderer == null || questMarkBodyRenderer.enabled; + } + + private TextMesh CreateQuestMark() + { + // 표식보다 **먼저** 몸 렌더러를 잡는다 — 뒤에 잡으면 표식 자신이 걸린다. + questMarkBodyRenderer = GetComponentInChildren(); + GameObject go = new GameObject("QuestMark"); + go.transform.SetParent(transform, false); + go.transform.localPosition = new Vector3(0f, QuestMarkHeight, 0f); + TextMesh text = go.AddComponent(); + text.characterSize = QuestMarkSize; + text.fontSize = 96; + text.fontStyle = FontStyle.Bold; + text.anchor = TextAnchor.MiddleCenter; + text.alignment = TextAlignment.Center; + questMarkRenderer = go.GetComponent(); // TextMesh가 MeshRenderer를 함께 붙인다 + return text; + } + + // 카메라를 향하고 살짝 떠오르내린다. TickMovement(플레이어 40m 이내)에서만 불리므로 + // 멀리 있는 표식은 갱신하지 않는다 — 어차피 안 보인다. + private void TickQuestMark(float time) + { + if (questMarkText == null || questMark == QuestMark.None) return; + SyncQuestMarkVisibility(); + if (questMarkCamera == null) questMarkCamera = Camera.main; + Transform mt = questMarkText.transform; + if (questMarkCamera != null) mt.rotation = questMarkCamera.transform.rotation; + mt.localPosition = new Vector3(0f, QuestMarkHeight + Mathf.Sin(time * 3f) * 0.08f, 0f); + } + /// NpcManager가 스폰 직후 호출. 시각 모델은 NpcVisualBuilder.Build로 이미 생성된 상태. public void Initialize(NpcSpawnAnchor anchor, string id, string name, int seed, string storyId = null) { @@ -83,10 +180,36 @@ public void Initialize(NpcSpawnAnchor anchor, string id, string name, int seed, rng = new System.Random(seed); animator = new NpcWalkAnimator(transform); groundY = transform.position.y; + // 고정 배치(wanderRadius 0 — 스토리 NPC·전초기지)는 한 번도 걷지 않아 이 자리 높이가 끝까지 간다 + StandOnGround(); state = State.Idle; stateEndTime = 0f; // 첫 TickAI에서 즉시 새 Idle 타이머 시작 } + /// + /// NPC 발 높이(월드 y) — 레이가 맞은 콜라이더 윗면(, groundY)에 둔덕을 얹는다. + /// 둔덕(사구·재 더미·초원 언덕 …)은 콜라이더가 없어 레이로 못 보므로 에 묻는다. + /// + /// Max(콜라이더, min(콜라이더, 바닥) + 둔덕 높이)인 이유: + /// + /// 둔덕이 없으면(LiftAt 0) 콜라이더 그대로 — 마을·서브에리어·평지는 옛 높이에서 한 치도 안 바뀐다 + /// (플레이어처럼 Max(콜라이더, SurfaceY)로 두면 리전 평면 0.08에 서 있던 NPC가 전부 2cm 오른다). + /// 리전 평면(바닥 이하) 위라면 평면 + 둔덕 높이 — 둔덕 윗면을 따라 오르내린다. + /// 바닥보다 높은 콜라이더(바위·다리) 위라면 그 윗면과 둔덕 윗면 중 높은 쪽 — 둔덕 위 바위에서 둔덕 높이를 + /// 한 번 더 얹어 뜨지 않는다(플레이어 접지 PlayerMovement.GroundHeight와 같은 답). + /// + /// 잡기 아이()도 이 식을 쓴다 — 사구 위 곤충을 쫓아 올라간다. + /// + internal static float StandHeight(float colliderY, float x, float z) + => Mathf.Max(colliderY, Mathf.Min(colliderY, FieldGround.FloorY) + FieldGround.LiftAt(x, z)); + + private void StandOnGround() + { + Vector3 pos = transform.position; + pos.y = StandHeight(groundY, pos.x, pos.z); + transform.position = pos; + } + /// 대화 시작 — 정지 + 플레이어 방향 바라봄. NpcDialogueUI.Show가 호출. public void BeginTalk(Transform player) { @@ -174,7 +297,9 @@ public void WarpTo(Vector3 worldPosition, Transform lookAt = null) groundY = worldPosition.y; } - transform.position = new Vector3(worldPosition.x, groundY, worldPosition.z); + // 레이는 둔덕을 못 본다(콜라이더 없음) — 콜라이더 높이에 둔덕을 얹는다(StandHeight). groundY는 콜라이더 값으로 + // 남겨 둔다: SampleGround의 ±0.75m 클램프가 그걸 기준으로 다음 레이를 받는다. + transform.position = new Vector3(worldPosition.x, StandHeight(groundY, worldPosition.x, worldPosition.z), worldPosition.z); if (lookAt != null) FaceTowards(lookAt.position); } @@ -258,6 +383,7 @@ public void TickAI(float time) /// 이동/애니 틱 — 플레이어 40m 이내에서만 매 프레임 호출. public void TickMovement(float dt, float time) { + TickQuestMark(time); if (animator == null) return; bool walking = false; @@ -300,20 +426,38 @@ private MoveResult MoveTowards(Vector3 target, float arriveDistance, float speed if (IsBlockedAhead(dir, step)) return MoveResult.Blocked; Vector3 pos = transform.position + dir * step; - pos.y = groundY; + pos.y = StandHeight(groundY, pos.x, pos.z); // groundY(콜라이더)는 0.3초마다, 둔덕은 매 스텝 — 사구를 따라 걷는다 transform.position = pos; RotateTowards(dir, dt); return MoveResult.Moving; } + // 물을 피해 다시 뽑는 횟수 — 다 실패하면 제자리(앵커)로 돌아간다. + private const int WanderWaterRetries = 6; + private Vector3 PickWanderTarget() { - float angle = (float)(rng.NextDouble() * Mathf.PI * 2.0); - float radius = (float)(rng.NextDouble()) * wanderRadius; - return new Vector3( - anchorPosition.x + Mathf.Sin(angle) * radius, - groundY, - anchorPosition.z + Mathf.Cos(angle) * radius); + // 연못 호수를 키운 뒤로 물가 주민의 배회 원이 물에 걸친다 — 목적지도, 가는 길도 물이면 다시 뽑는다 + // (물 판정은 RegionDressingBuilder가 실제로 그린 물이 단일 출처. 곤충 스폰과 같은 판정이다). + for (int attempt = 0; attempt < WanderWaterRetries; attempt++) + { + float angle = (float)(rng.NextDouble() * Mathf.PI * 2.0); + float radius = (float)(rng.NextDouble()) * wanderRadius; + var target = new Vector3( + anchorPosition.x + Mathf.Sin(angle) * radius, + groundY, + anchorPosition.z + Mathf.Cos(angle) * radius); + if (!PathTouchesWater(transform.position, target)) return target; + } + return new Vector3(anchorPosition.x, groundY, anchorPosition.z); + } + + // 곧게 걸어가는 길을 네 토막으로 짚는다 — 배회 반경(8m 안팎)에선 2m 간격이면 웅덩이를 건너뛰지 않는다. + private static bool PathTouchesWater(Vector3 from, Vector3 to) + { + for (int i = 1; i <= 4; i++) + if (RegionDressingBuilder.IsOnWater(Vector3.Lerp(from, to, i / 4f), 0.3f)) return true; + return false; } private void SampleGround() diff --git a/Assets/Scripts/Opening/OpeningReplayCoordinator.cs b/Assets/Scripts/Opening/OpeningReplayCoordinator.cs index 2495f29..73f364c 100644 --- a/Assets/Scripts/Opening/OpeningReplayCoordinator.cs +++ b/Assets/Scripts/Opening/OpeningReplayCoordinator.cs @@ -66,6 +66,14 @@ public bool CanReplay return false; if (battleScreen.IsBattleActive || raidScreen.IsRaidActive || captureMinigame.IsActive) return false; + if (DreamPrologueState.Active) + return false; // 꿈이 UI 루트와 카메라를 쓰는 중이다 + // 컷신·스토리 영상·대화 같은 다른 모달이 도는 중엔 시작하지 않는다 — 다시보기가 UI 루트를 끄고 + // timeScale을 0으로 잡는 동안 그쪽 복구(프리즈·카메라)가 뒤엉킨다. 설정 화면 자신은 제외한다 + // (버튼이 그 안에 있다). 지금은 설정 UI가 모달 중엔 열리지 않아 도달하지 않지만, 그 가드는 + // 저쪽 파일의 사정이라 여기서도 스스로 지킨다. + if (ModalUIRegistry.IsAnyOpenExcept(typeof(AccountSettingsUI))) + return false; return true; } } diff --git a/Assets/Scripts/Opening/OpeningSceneController.cs b/Assets/Scripts/Opening/OpeningSceneController.cs index e5cb6d1..0e39a31 100644 --- a/Assets/Scripts/Opening/OpeningSceneController.cs +++ b/Assets/Scripts/Opening/OpeningSceneController.cs @@ -3,6 +3,7 @@ using InsectGame.UI; using UnityEngine; using UnityEngine.SceneManagement; +using UnityEngine.Video; namespace InsectGame.Opening { @@ -24,7 +25,27 @@ public class OpeningSceneController : MonoBehaviour private const string HorizonPath = "UI/Opening/opening_02_"; private const string GatheringPath = "UI/Opening/opening_03_"; private const string FallbackIconPath = "UI/insect-game-icon"; - private const string ThemePath = "Audio/Opening/opening_theme"; + /// + /// 20초 음원 — 앞 10초는 테마곡과 같은 D조로 합성한 전주, 뒤 10초가 원래 테마곡이다 + /// (Tools/Video/opening_audio.py). 곡의 강세가 에 떨어진다. + /// + private const string ThemePath = "Audio/Opening/opening_prologue"; + /// 전주 음원이 없을 때의 대체 — 10초라 뒤 절반은 무음이 되지만 오프닝은 선다. + private const string LegacyThemePath = "Audio/Opening/opening_theme"; + + /// + /// 프롤로그 영상 — StreamingAssets/Video/opening_prologue_{landscape|portrait}.mp4. + /// 세로 원화는 따로 구도를 잡은 그림이라 영상도 방향별로 한 편씩이다. + /// 처럼 URL로 스트리밍한다(임포터를 안 탄다). + /// + private const string VideoFolder = "Video"; + private const string VideoFilePrefix = "opening_prologue_"; + /// 디코더 준비를 이만큼 기다린다. 넘기면 정지 그림으로 대신한다 — 첫 실행이 검은 화면에 묶이지 않게. + private const float VideoPrepareTimeoutSeconds = 3f; + /// 영상 시각이 오프닝 시계와 이만큼 벌어지면 되감는다(음악·자막은 시계를 따른다). + private const float VideoResyncThresholdSeconds = 0.35f; + /// 영상 시각이 이만큼 멈춰 있으면 디코더가 죽은 것으로 보고 정지 그림으로 넘긴다. + private const float VideoStallSeconds = 1.2f; private const string EnglishTitleText = "INSECT EXPLORATION"; private const string KoreanTitleText = "곤충탐험"; private const string SubtitleText = "발견하고, 성장시키고, 함께 모험하세요"; @@ -33,7 +54,8 @@ public class OpeningSceneController : MonoBehaviour /// 오프닝 내레이션 — 이 게임이 무슨 이야기인지를 처음 켠 사람에게 알린다. /// 예전엔 부제 한 줄("발견하고, 성장시키고")뿐이라 서사가 전혀 전달되지 않았다. /// - /// 세 줄로 1막의 전제를 세운다: 사라짐 → 그것을 노리는 자들 → 플레이어가 할 일. + /// 네 줄로 1막의 전제를 세운다: 사라짐 → 그것을 노리는 자들 → 플레이어가 할 일 → 시작. + /// 줄마다 영상의 한 장면이 받친다(빛이 꺼지는 숲 / 길 끝의 그물 / 파트너 / 타이틀). /// 답을 주지 않고 질문만 남긴다 — 답은 마을 어르신이 한다. /// 순서·시각은 의 Narration* 상수가 정한다. /// @@ -41,9 +63,13 @@ public class OpeningSceneController : MonoBehaviour { "곤충이 사라지고 있다.", "그리고 그것을 남김없이 거두려는 자들이 있다.", - "사라지는 이름을 기록하는 일 — 거기서부터 시작된다.", + "사라지는 이름을 기록하는 일.", + "거기서부터 시작된다.", }; + /// 문장 수 — 와 같아야 한다(테스트가 본다). + internal static int NarrationLineCount => NarrationLines.Length; + private static readonly string[] ImagePaths = { GlowPath, @@ -61,7 +87,22 @@ public class OpeningSceneController : MonoBehaviour private readonly OpeningPlaybackClock playbackClock = new OpeningPlaybackClock(); private readonly OpeningSkipInputGate skipInputGate = new OpeningSkipInputGate(); + private enum VideoMode + { + /// 영상 없이 정지 그림 3장 — 파일 없음·디코더 실패·준비 시간 초과의 대체 경로. + Stills, + /// 디코더 준비 중 — 오프닝 시계를 세우지 않고 검은 화면으로 기다린다. + Preparing, + Playing + } + private OpeningSequenceState sequence; + private VideoPlayer videoPlayer; + private RenderTexture videoTexture; + private VideoMode videoMode = VideoMode.Stills; + private float videoPrepareSeconds; + private double lastVideoTime; + private float videoStalledSeconds; private AudioSource openingAudioSource; private AudioClip openingTheme; private Texture2D fallbackIcon; @@ -99,6 +140,23 @@ private void Awake() openingAudioSource.loop = false; openingAudioSource.spatialBlend = 0f; openingAudioSource.ignoreListenerPause = true; + + // 영상은 무음이다 — 소리는 위 AudioSource가 오프닝 시계에 맞춰 튼다(되감기·건너뛰기·일시정지가 + // 이미 그 시계 하나로 돌아가므로 영상 쪽 오디오 트랙을 두면 두 소리가 어긋난다). + videoPlayer = gameObject.AddComponent(); + videoPlayer.playOnAwake = false; + videoPlayer.isLooping = false; + videoPlayer.source = VideoSource.Url; + videoPlayer.renderMode = VideoRenderMode.RenderTexture; + videoPlayer.audioOutputMode = VideoAudioOutputMode.None; + videoPlayer.skipOnDrop = true; + videoPlayer.waitForFirstFrame = true; + // 다시보기는 뒤의 월드를 세우려고 timeScale = 0, AudioListener.pause = true를 건다 + // (OpeningReplayCoordinator). 게임 시간이나 DSP 시계를 따르면 그동안 영상이 멈춘다 — 오프닝 + // 시계(realtimeSinceStartup)와 같은 비스케일 실시간을 쓴다. + videoPlayer.timeUpdateMode = VideoTimeUpdateMode.UnscaledGameTime; + videoPlayer.prepareCompleted += OnVideoPrepared; + videoPlayer.errorReceived += OnVideoError; } private void Start() @@ -115,13 +173,28 @@ private void Update() if (sequence == null || sequence.IsCompleted || applicationPaused) return; - bool portrait = Screen.height > Screen.width; - if (!orientationLoaded || portrait != loadedPortrait) - ReloadOrientationResourcesDuringPlayback(portrait); + if (videoMode == VideoMode.Preparing) + { + // 준비 동안은 시계를 세운다 — 첫 프레임이 늦게 나와도 자막·음악이 앞서 가지 않는다. + videoPrepareSeconds += Mathf.Min(Time.unscaledDeltaTime, OpeningPlaybackClock.MaxFrameDelta); + if (videoPrepareSeconds > VideoPrepareTimeoutSeconds) + FallBackToStills("준비 시간 초과"); + return; + } + + // 정지 그림만 방향별로 다시 싣는다. 영상은 방향이 바뀌어도 가운데를 잘라 계속 튼다 + // (다른 방향 영상으로 갈아타면 준비 동안 화면이 끊긴다). + if (videoMode == VideoMode.Stills) + { + bool portrait = Screen.height > Screen.width; + if (!orientationLoaded || portrait != loadedPortrait) + ReloadOrientationResourcesDuringPlayback(portrait); + } float playbackDelta = playbackClock.Consume(Time.realtimeSinceStartupAsDouble); sequence.Advance(playbackDelta); SynchronizeOpeningAudio(); + SynchronizeVideo(playbackDelta); UpdateAudioVolume(); if (sequence.IsCompleted) @@ -142,6 +215,12 @@ private void OnApplicationPause(bool pauseStatus) applicationPaused = pauseStatus; playbackClock.Reset(Time.realtimeSinceStartupAsDouble); skipInputGate.Reset(); + if (videoMode == VideoMode.Playing && videoPlayer != null) + { + if (pauseStatus) videoPlayer.Pause(); + else if (sequence != null && !sequence.IsCompleted) videoPlayer.Play(); + videoStalledSeconds = 0f; + } if (openingAudioSource == null) return; @@ -165,6 +244,13 @@ private void OnDestroy() // 클립을 언로드하기 전에 재생을 멈춘다. replay가 중단(Destroyed)되면 // OnSequenceCompleted가 돌지 않아 AudioSource가 아직 openingTheme을 물고 있다. StopOpeningAudio(); + StopVideo(); + if (videoPlayer != null) + { + videoPlayer.prepareCompleted -= OnVideoPrepared; + videoPlayer.errorReceived -= OnVideoError; + } + ReleaseVideoTexture(); UnloadOrientationResources(); // fallback icon은 다른 UI도 쓰는 공유 Resources 자산이라 강제 unload하지 않는다. fallbackIcon = null; @@ -215,13 +301,14 @@ private bool BeginPlayback() loadError = null; sequence = new OpeningSequenceState(); sequence.Completed += OnSequenceCompleted; - LoadOrientationResources(Screen.height > Screen.width); RefreshMasterVolume(); - StartOpeningAudio(); - // Resources.Load에 걸린 시간은 시네마틱 타임라인에 포함하지 않는다. - playbackClock.Reset(Time.realtimeSinceStartupAsDouble); - skipInputGate.Reset(); - Debug.Log("[OpeningSceneController] 10초 오프닝 재생을 시작합니다."); + + // 영상이 있으면 준비가 끝날 때까지 시계를 세워 둔다(OnVideoPrepared가 시작한다). + // 없으면 곧바로 정지 그림으로 시작한다. + if (TryPrepareVideo(Screen.height > Screen.width)) + return true; + + FallBackToStills(null); return true; } catch (Exception e) @@ -230,6 +317,7 @@ private bool BeginPlayback() if (sequence != null) sequence.Completed -= OnSequenceCompleted; sequence = null; StopOpeningAudio(); + StopVideo(); if (manualReplay) ReportReplayResult(OpeningPlaybackResult.Error); return false; } @@ -238,6 +326,7 @@ private bool BeginPlayback() private void OnSequenceCompleted() { StopOpeningAudio(); + StopVideo(); if (manualReplay) { OpeningPlaybackResult result = sequence.WasSkipped @@ -268,6 +357,11 @@ private void StartOpeningAudio() return; openingTheme = Resources.Load(ThemePath); + if (openingTheme == null) + { + Debug.LogWarning($"[OpeningSceneController] 프롤로그 음원 누락, 옛 테마로 대신: Resources/{ThemePath}"); + openingTheme = Resources.Load(LegacyThemePath); + } if (openingTheme == null) return; @@ -371,6 +465,156 @@ private static SkipInputSnapshot ReadSkipInput() return result; } + // ───────────────────────── 프롤로그 영상 ───────────────────────── + + private bool TryPrepareVideo(bool portrait) + { + if (videoPlayer == null) + return false; + + // Path.Combine은 Windows에서 역슬래시를 넣는데 Android jar 경로는 '/'만 받는다(StoryVideoDirector와 같다). + string url = Application.streamingAssetsPath + "/" + VideoFolder + "/" + VideoFilePrefix + + (portrait ? "portrait" : "landscape") + ".mp4"; +#if UNITY_EDITOR || UNITY_STANDALONE + // 에디터·데스크톱은 파일 유무를 미리 안다. Android는 StreamingAssets가 jar 안이라 File.Exists가 + // 늘 false여서 보지 않는다 — 없으면 errorReceived가 대체 경로로 보낸다. + if (!System.IO.File.Exists(url)) + { + Debug.LogWarning($"[OpeningSceneController] 프롤로그 영상 없음, 정지 그림으로: {url}"); + return false; + } +#endif + videoMode = VideoMode.Preparing; + videoPrepareSeconds = 0f; + videoResyncCooldown = 0f; + videoPlayer.url = url; + videoPlayer.Prepare(); + return true; + } + + private void OnVideoPrepared(VideoPlayer source) + { + // 준비가 늦게 끝났으면(이미 정지 그림으로 넘어갔거나 건너뛰었으면) 틀지 않는다. + if (videoMode != VideoMode.Preparing || sequence == null || sequence.IsCompleted) + { + source.Stop(); + return; + } + + EnsureVideoTexture((int)source.width, (int)source.height); + source.targetTexture = videoTexture; + videoMode = VideoMode.Playing; + lastVideoTime = 0d; + videoStalledSeconds = 0f; + // 음원을 먼저 싣고(동기 Resources.Load) 시계를 세운 **뒤** 영상을 튼다 — 거꾸로면 로드하는 동안 + // 영상만 앞서 가 첫 프레임부터 되감기가 걸린다. + StartTimeline(); + source.Play(); + } + + private void OnVideoError(VideoPlayer source, string message) + { + if (videoMode == VideoMode.Stills) + return; + FallBackToStills(message); + } + + /// + /// 영상을 못 틀 때 — 같은 시계로 정지 그림 3장을 넘긴다. 이미 흐르던 타임라인이면 그 자리에서 + /// 이어 간다(자막·음악은 시계를 따르므로 끊기지 않는다). + /// + private void FallBackToStills(string reason) + { + bool timelineRunning = videoMode == VideoMode.Playing; + if (!string.IsNullOrEmpty(reason)) + Debug.LogWarning($"[OpeningSceneController] 프롤로그 영상 실패({reason}) — 정지 그림으로 대신한다"); + StopVideo(); + videoMode = VideoMode.Stills; + LoadOrientationResources(Screen.height > Screen.width); + if (timelineRunning) + playbackClock.Reset(Time.realtimeSinceStartupAsDouble); // 그림을 싣는 데 걸린 시간은 빼고 이어 간다 + else + StartTimeline(); + } + + private void StartTimeline() + { + StartOpeningAudio(); + // Resources.Load·디코더 준비에 걸린 시간은 시네마틱 타임라인에 포함하지 않는다. + playbackClock.Reset(Time.realtimeSinceStartupAsDouble); + skipInputGate.Reset(); + Debug.Log($"[OpeningSceneController] {OpeningSequenceState.Duration:0}초 오프닝 재생을 시작합니다({videoMode})."); + } + + private float videoResyncCooldown; + + private void SynchronizeVideo(float playbackDelta) + { + if (videoMode != VideoMode.Playing || videoPlayer == null || sequence == null || sequence.IsSkipping) + return; + // 끝자락(페이드아웃)에선 영상이 끝나 멈춰 있는 게 정상이다 — 되감지도, 죽었다고 보지도 않는다. + if (sequence.Elapsed >= OpeningSequenceState.FinalFadeStart) + return; + + videoResyncCooldown = Mathf.Max(0f, videoResyncCooldown - playbackDelta); + double videoTime = videoPlayer.time; + + // 멈춤 감지 — 시계는 가는데 영상 시각이 안 움직이면 디코더가 죽은 것이다. + if (playbackDelta > 0f && Math.Abs(videoTime - lastVideoTime) < 1e-4) + { + videoStalledSeconds += playbackDelta; + if (videoStalledSeconds > VideoStallSeconds) + { + FallBackToStills("영상 시각이 멈췄다"); + return; + } + } + else + { + videoStalledSeconds = 0f; + } + lastVideoTime = videoTime; + + // 렌더·로딩 stall로 벌어지면 영상을 시계에 되감는다. 느린 기기에서 되감기가 되감기를 부르지 않게 + // 2초에 한 번만 — 그 사이 벌어짐은 skipOnDrop이 프레임을 버려 스스로 줄인다. + float drift = (float)(videoTime - sequence.Elapsed); + if (Mathf.Abs(drift) > VideoResyncThresholdSeconds && videoResyncCooldown <= 0f) + { + videoPlayer.time = sequence.Elapsed; + videoResyncCooldown = 2f; + videoStalledSeconds = 0f; + } + } + + private void StopVideo() + { + // 준비 중이어도 부른다 — 진행 중인 Prepare를 취소해 디코더를 닫는다(idle이면 no-op). + if (videoPlayer != null) + videoPlayer.Stop(); + } + + private void EnsureVideoTexture(int width, int height) + { + if (width <= 0 || height <= 0) { width = 1280; height = 720; } + if (videoTexture != null && videoTexture.width == width && videoTexture.height == height) + return; + ReleaseVideoTexture(); + videoTexture = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32); + videoTexture.name = "OpeningPrologueRT"; + videoTexture.Create(); + } + + private void ReleaseVideoTexture() + { + if (videoTexture == null) + return; + if (videoPlayer != null) + videoPlayer.targetTexture = null; + videoTexture.Release(); + Destroy(videoTexture); + videoTexture = null; + } + private void ReloadOrientationResourcesDuringPlayback(bool portrait) { bool resumeAudio = openingAudioSource != null && openingAudioSource.isPlaying; @@ -477,19 +721,40 @@ private void OnGUI() private void DrawSequence(Rect screenRect) { - int current = sequence.CurrentImageIndex; - DrawImageOrFallback(screenRect, openingImages[current], current, 1f); + // 디코더 준비 중 — 시계가 서 있으니 자막도 버튼도 없다. 첫 프레임이 나오면 곧바로 시작한다. + if (videoMode == VideoMode.Preparing) + { + DrawSolid(screenRect, Color.black); + return; + } + + bool videoVisible = videoMode == VideoMode.Playing && videoTexture != null; + if (videoVisible) + { + // 방향별 영상이어도 기기 비율은 제각각이다 — 가운데를 잘라 채운다(cover). + Rect uv = InsectGame.UI.UIHelper.CalculateCoverUv(screenRect, videoTexture.width, videoTexture.height); + GUI.DrawTextureWithTexCoords(screenRect, videoTexture, uv, false); + } + else + { + int current = sequence.CurrentImageIndex; + DrawImageOrFallback(screenRect, openingImages[current], current, 1f); - int next = sequence.NextImageIndex; - float blend = sequence.ImageBlend; - if (next >= 0 && blend > 0f) - DrawImageOrFallback(screenRect, openingImages[next], next, blend); + int next = sequence.NextImageIndex; + float blend = sequence.ImageBlend; + if (next >= 0 && blend > 0f) + DrawImageOrFallback(screenRect, openingImages[next], next, blend); + } // 비네트 → 곤충 → 빛 순서. 곤충은 배경 앞을 지나는 그림자라 빛보다 뒤에 있어야 - // 빛이 곤충 위로 떠오르는 것처럼 보인다. + // 빛이 곤충 위로 떠오르는 것처럼 보인다. 영상은 빛·곤충을 이미 그리므로 정지 그림일 때만 얹는다 + // (겹치면 반딧불이 두 벌이 되고, 빛이 꺼지는 장면에서 IMGUI 빛만 계속 떠다닌다). DrawVignette(screenRect, 1f - sequence.FadeAlpha); - DrawDriftingInsects(screenRect, sequence.Elapsed, 1f - sequence.FadeAlpha); - DrawFloatingLights(screenRect, sequence.Elapsed, 1f - sequence.FadeAlpha); + if (!videoVisible) + { + DrawDriftingInsects(screenRect, sequence.Elapsed, 1f - sequence.FadeAlpha); + DrawFloatingLights(screenRect, sequence.Elapsed, 1f - sequence.FadeAlpha); + } float titleAlpha = sequence.TitleAlpha * (1f - sequence.FadeAlpha); Rect safeRect = GetGuiSafeArea(screenRect); @@ -516,7 +781,7 @@ private void DrawImageOrFallback(Rect target, Texture2D texture, int imageIndex, return; } - Rect uv = CalculateCoverUv(target, texture.width, texture.height); + Rect uv = InsectGame.UI.UIHelper.CalculateCoverUv(target, texture.width, texture.height); uv = ApplyKenBurns(uv, imageIndex, sequence != null ? sequence.Elapsed : 0f); Color previous = GUI.color; GUI.color = new Color(previous.r, previous.g, previous.b, previous.a * alpha); @@ -559,7 +824,7 @@ private void DrawTitle(Rect safeRect, float alpha) float availableWidth = Mathf.Max(1f, safeRect.width - 32f); // CalcSize는 텍스트를 실제로 측정한다. 화면 크기가 그대로면 결과도 같으므로 - // 타이틀 구간(6.2~10초) 내내 OnGUI 패스마다 3회씩 반복 측정하지 않는다. + // 타이틀 구간(TitleStart~Duration) 내내 OnGUI 패스마다 3회씩 반복 측정하지 않는다. if (minSide != titleFitMinSide || availableWidth != titleFitAvailableWidth) { titleFitMinSide = minSide; @@ -781,23 +1046,6 @@ private void EnsureStyles() }; } - private static Rect CalculateCoverUv(Rect target, int textureWidth, int textureHeight) - { - if (target.width <= 0f || target.height <= 0f || textureWidth <= 0 || textureHeight <= 0) - return new Rect(0f, 0f, 1f, 1f); - - float targetAspect = target.width / target.height; - float textureAspect = textureWidth / (float)textureHeight; - if (textureAspect > targetAspect) - { - float visibleWidth = targetAspect / textureAspect; - return new Rect((1f - visibleWidth) * 0.5f, 0f, visibleWidth, 1f); - } - - float visibleHeight = textureAspect / targetAspect; - return new Rect(0f, (1f - visibleHeight) * 0.5f, 1f, visibleHeight); - } - private static Rect ApplyKenBurns(Rect coverUv, int imageIndex, float elapsed) { float phase = imageIndex * 1.73f; diff --git a/Assets/Scripts/Opening/OpeningSequenceState.cs b/Assets/Scripts/Opening/OpeningSequenceState.cs index c2c06b1..cb801ee 100644 --- a/Assets/Scripts/Opening/OpeningSequenceState.cs +++ b/Assets/Scripts/Opening/OpeningSequenceState.cs @@ -100,17 +100,38 @@ public enum OpeningVisualPhase /// /// Unity 생명주기와 분리된 오프닝 타임라인. 호출자가 unscaled delta를 공급한다. + /// + /// 20초 프롤로그 영상과 맞물린다. 그림은 영상(StreamingAssets/Video/opening_prologue_*.mp4)이 + /// 그리고, 내레이션·타이틀·건너뛰기는 여기 시각대로 IMGUI가 얹는다. 영상은 + /// Tools/Video/opening_prologue.py, 소리는 Tools/Video/opening_audio.py가 만드는데 + /// 두 스크립트의 사건 시각이 아래 상수와 같다 — 한쪽을 바꾸면 셋을 함께 고친다. + /// + /// + /// 0.0 ~ 9.6 그림1 밤숲 길 — 빛이 꺼지고 길 끝에 그물을 든 검은 코트. 8.3~9.4 암전 + /// 10.0 테마곡 시작(음원 안에서 앞 10초는 같은 D조 전주) + /// 9.8 ~ 15.0 그림2 파트너 — 꺼졌던 빛이 돌아온다. 14.0~15.0 그림3으로 맞춤 컷 + /// 16.0 곡의 강세 = 타이틀 + /// 19.2 ~ 20.0 페이드아웃 + /// + /// + /// 영상을 못 틀면(파일 없음·디코더 실패) 같은 시계로 정지 그림 3장을 넘긴다 — 아래 이미지 구간 상수는 + /// 그 대체 경로의 것이다. /// public sealed class OpeningSequenceState { - public const float GlowCrossFadeStart = 2.4f; - public const float HorizonStart = 3f; - public const float HorizonCrossFadeStart = 5.2f; - public const float GatheringStart = 5.8f; - public const float TitleStart = 6.2f; - public const float TitleHoldStart = 8f; - public const float FinalFadeStart = 9.2f; - public const float Duration = 10f; + public const float GlowCrossFadeStart = 9.0f; + public const float HorizonStart = 10f; + public const float HorizonCrossFadeStart = 14f; + public const float GatheringStart = 15f; + public const float TitleStart = 16f; + public const float TitleHoldStart = 17.4f; + public const float FinalFadeStart = 19.2f; + public const float Duration = 20f; + + /// 영상 속 암전 시작 — 마지막 빛이 그물에 거둬지고 화면이 검게 가라앉는다. + public const float BlackoutStart = 8.3f; + /// 암전 뒤 파트너가 떠올라 화면이 다 밝아지는 시각. + public const float PartnerRevealEnd = 10.9f; public const float SkipUnlockTime = 1f; public const float SkipFadeDuration = 0.25f; @@ -189,19 +210,25 @@ public float TitleAlpha /// 스토리 내레이션 — 오프닝이 게임 소개("발견하고, 성장시키고")만 하고 무슨 이야기인지는 /// 한 마디도 안 했다. 처음 켠 사람이 몰입할 근거가 없었다. /// - /// 타이틀(6.2s)과 겹치지 않게 앞의 두 줄은 그 전에 끝내고, 세 번째 줄만 타이틀이 - /// 자리잡은 뒤(8.2s) 얹는다 — 마지막 줄이 게임의 목적을 말하므로 타이틀과 함께 남는다. + /// 네 줄이 영상의 네 장면에 하나씩 붙는다: 빛이 꺼진다 → 길 끝의 그물 → 파트너 → 타이틀. + /// 두 번째 줄은 암전() 전에 끝나고 세 번째 줄은 파트너가 다 떠오른 뒤 + /// 뜬다 — 검은 화면에 글자만 떠 있으면 영상이 멈춘 것처럼 보인다. 마지막 줄만 타이틀이 + /// 자리잡은 뒤 얹는다(게임의 목적을 말하는 줄이라 타이틀과 함께 남는다). /// - public const float Narration1Start = 0.9f; - public const float Narration1End = 3.0f; - public const float Narration2Start = 3.3f; - public const float Narration2End = 5.9f; - public const float Narration3Start = 8.2f; - public const float Narration3End = 9.6f; + public const float Narration1Start = 1.0f; + public const float Narration1End = 4.3f; + public const float Narration2Start = 4.8f; + public const float Narration2End = 8.2f; + public const float Narration3Start = 10.9f; + public const float Narration3End = 13.9f; + public const float Narration4Start = 17.6f; + public const float Narration4End = 19.5f; + /// 내레이션 줄 수 — OpeningSceneController의 문장 배열 길이와 같아야 한다(테스트가 본다). + public const int NarrationCount = 4; private const float NarrationFade = 0.45f; /// - /// 지금 보여줄 내레이션 줄(0~2)과 그 알파. 없으면 index가 -1. + /// 지금 보여줄 내레이션 줄(0~3)과 그 알파. 없으면 index가 -1. /// 순수 계산이라 OpeningSequenceTests가 씬 없이 고정한다. /// public void GetNarration(out int index, out float alpha) @@ -209,6 +236,7 @@ public void GetNarration(out int index, out float alpha) if (TryNarration(0, Narration1Start, Narration1End, out index, out alpha)) return; if (TryNarration(1, Narration2Start, Narration2End, out index, out alpha)) return; if (TryNarration(2, Narration3Start, Narration3End, out index, out alpha)) return; + if (TryNarration(3, Narration4Start, Narration4End, out index, out alpha)) return; index = -1; alpha = 0f; } diff --git a/Assets/Scripts/Spawning/AmbushRules.cs b/Assets/Scripts/Spawning/AmbushRules.cs new file mode 100644 index 0000000..17cb2c9 --- /dev/null +++ b/Assets/Scripts/Spawning/AmbushRules.cs @@ -0,0 +1,283 @@ +using System; +using InsectGame.Core; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Spawning +{ + /// + /// 습격을 막은 까닭 — 가 처음 걸린 하나를 돌려준다(순서가 곧 우선순위다). + /// 다가오던 곤충은 이 값을 보고 기다릴지() 물러날지 정한다. + /// + public enum AmbushRefusal : byte + { + None = 0, + /// 습격형이 아니거나, 습격형이어도 지금 시간·날씨에는 잠잠하다(). + NotAwake, + /// 수문장 — 길목에서 기다리는 상대지 덤벼드는 상대가 아니다. + Guardian, + /// 이 개체가 이미 포획·전투·아이 NPC에게 붙잡혀 있다. + Engaged, + /// 「챔피언의 꿈」 도중 — 꿈 밖 기록을 건드리지 않는다(rules/dream-prologue.md). + Dream, + /// + /// 서브에리어 안 — 동굴·방은 실내라 시간·날씨를 보지 않고(서브에리어 스폰도 그렇다), 나의 섬(분리 구역)은 집이다 — 밤·비에 + /// 찾아오는 손님 곤충(진짜 야생 개체)도 덤벼들지 않는다. 전투 보정의 실내 판정(BattleEnvironment.IsIndoor)보다 넓게 잡았다. + /// + SubArea, + /// 플레이어가 다른 교전(선택 창·미니게임·전투·레이드) 중이다. + PlayerInEncounter, + /// 이 몸이 얼마 전에 붙잡혔다 풀려났거나 습격을 접었다. + EntityCooldown, + /// 얼마 전에 습격(또는 다른 교전)이 끝났다 — 연달아 덤벼들지 않는다. + GlobalCooldown, + /// 이 곤충과 싸울 수단이 없다(기절 안 한 출전 곤충 · 레이드면 5마리 팀). + NoFighter, + /// 플레이어가 멈춰 있다(대화·연출) — 다가오던 곤충은 기다린다. + PlayerFrozen, + /// 메뉴·창이 열려 있다 — 다가오던 곤충은 기다린다. + ModalOpen + } + + /// + /// 습격 — 깨어 있는 습격형 곤충이 도망치는 대신 플레이어에게 다가와 싸움을 거는 판정의 순수 규칙. + /// 씬 없이 도는 정적 클래스라 거절 조건·쿨다운 경계를 테스트(AmbushRulesTests)가 전부 고정한다. + /// + /// 흐름. InsectEntity가 플레이어를 안에서 알아채면 판정(InsectEntity.AmbushGate — + /// CaptureInputController가 세운다)을 묻고, 허가되면 멈칫한 뒤 로 다가간다. + /// 안에 닿으면 「습격!」 창(CaptureChoiceUI.ShowAmbush)이 뜨고 [싸우기]/[도망치기]를 고른다. + /// 도망은 전투의 도주 확률(BattleEscapeRules.Chance — 단일 출처는 battle-dev 쪽)을 그대로 쓰고, 실패하면 싸운다. + /// + /// 성향은 매 판정 시점의 상태로 본다 — 플레이어가 있는 리전에서 보이는 날씨(WorldStateProvider.GetWorldState(regionId))다. + /// 스폰 때 굴린 값이 아니므로 밤이 되면 이미 서 있던 사마귀가 그 자리에서 사나워진다. + /// + public static class AmbushRules + { + // ── 쿨다운 ── + // + // 전역 60초의 근거(표·로스터·밀도로 잰 값 — 기기 실측이 아니다. 기기에서 밤 한 번을 걸어 보고 조정할 것): + // · 필드 밀도는 설 수 있는 땅 550㎡당 1칸(FieldSpawnRules). 걸음 8m/s로 알아채는 반경 8m 띠를 훑으면 분당 약 7,700㎡ ≈ 14칸을 지난다. + // · 그 칸이 「지금 깨어 있는 습격형」일 몫 — 진짜 성향 표 × 진짜 필드 로스터 × 전역 등급표로 재면 전체 평균이 밤 맑음 4.1% · 밤 비 10.8% · + // 밤 안개 10.1% · 밤 센바람 6.8% · 밤 눈 8.1%(밤 평균 약 8%), 아침·낮은 2.5~4.6%다(AmbushRulesTests가 같은 표를 로그로 남긴다). + // 리전마다는 크게 갈린다 — swamp 밤 15~41%(흔한 지네·거미가 습격형), garden 낮 12~17%·forest 낮 10~14%(말벌·사마귀), meadow·hollow·frostline·canopy는 0%. + // · 그러면 밤에 쉬지 않고 걸을 때 습격형과 마주치는 빈도는 평균 분당 약 1회, swamp의 비·안개 밤에는 분당 5회 남짓이다. 쿨다운이 없으면 + // 전투(1~2분)가 끝나자마자 다음 습격이 붙는다 — 「연달아 습격당한다」가 바로 그 모습이고, 늪에서는 쿨다운이 곧 습격 간격이 된다. + // · 습격 교전이 끝난 뒤 60초를 두면 습격 사이가 「전투 + 60초」 ≈ 2~3분이 되어, 현실 4.5분짜리 밤(하루 12분의 9/24)에 가장 사나운 + // 늪에서도 1~2번이다. 위협은 되고 사냥을 못 할 만큼 잦지는 않은 자리로 잡았다. + + /// 습격 교전(창 → 전투)이 끝난 뒤 다음 습격까지(초). + public const float GlobalCooldownSeconds = 60f; + + /// + /// 다른 교전(포획 선택 창·미니게임·전투·레이드)이 끝난 직후 숨 돌릴 틈(초). 결과 창을 닫자마자 옆의 사마귀가 덮치지 않게 한다. + /// 씬이 막 열렸을 때도 같은 틈을 둔다(로드 직후 바로 덤벼들지 않게). + /// + public const float AfterEncounterGraceSeconds = 8f; + + /// + /// 한 몸이 다시 습격할 수 있기까지(초) — 붙잡혔다 풀려났거나(포획 창 취소 등) 습격을 접은 몸. 그 사이에는 온순한 곤충처럼 행동한다 + /// (빠르게 다가오면 달아난다). 몸에 붙은 값이라 풀로 돌아갔다 다시 서면 지워진다 — 그 틈은 전역 쿨다운이 덮는다. + /// + public const float EntityCooldownSeconds = 45f; + + // ── 거리·속도 ── + + /// + /// 깨어 있는 습격형이 플레이어를 알아채는 반경(m). 온순한 곤충의 경계 반경(6.5~8.9m, 등급이 높을수록 멀다)과 비슷하게 두어 + /// 고레어가 덜 예민해지지 않게 했다 — 게임 카메라(뒤 6m·위 9m)에 이 거리는 화면 안이다. + /// + public const float NoticeRadius = 8f; + + /// 닿은 것으로 치는 수평 거리(m) — 몸(등급 배율 최대 1.9)과 플레이어 캡슐이 맞닿는 정도. + public const float ReachDistance = 1.3f; + + /// + /// 다가오는 속도(m/s). 플레이어 기본 걸음(PlayerMovement.moveSpeed 8m/s)의 75%다 — 보고 달아나면 떼어 낼 수 있고 + /// (초당 2m씩 벌어진다), 가만히 있거나 못 보면 닿는다. 더 빠르면 피할 수 없고, 더 느리면 위협이 안 된다(테스트가 걸음보다 느린지 본다). + /// + public const float ApproachSpeed = 6f; + + /// 알아챈 뒤 발을 떼기 전의 멈칫(초) — 고개를 들고 노려보는 틈. 이 사이에 [E]로 먼저 말을 걸거나 돌아서 달아날 수 있다. + public const float HesitateSeconds = 0.7f; + + /// 이만큼 멀어지면(수평 m) 쫓기를 접는다 — 알아채는 반경보다 넉넉히 커서 경계에서 들락날락하지 않는다. + public const float LeashDistance = 14f; + + /// 쫓는 시간 상한(초, 기다린 시간은 빼고) — 장애물을 빙빙 돌며 끝없이 쫓지 않는다. + public const float MaxChaseSeconds = 8f; + + /// 다가오는 길을 다시 재는 간격(초). 재기는 그때만 한다(최대 7방향 스피어캐스트) — 매 프레임 쏘지 않는다. + public const float ReplanSeconds = 0.3f; + + /// 한 번 잰 길로 가는 최대 거리(m) — 다음 재기까지( × 속도)보다 넉넉하다. + public const float MaxLegDistance = 3f; + + /// 앞이 막혀 한 걸음도 못 나간 채 이만큼(초) 지나면 쫓기를 접는다. + public const float StuckGiveUpSeconds = 0.8f; + + // ── 창 ── + + /// + /// 습격 창이 뜬 뒤 입력을 받기까지(초). 창은 플레이어가 고르지 않은 순간에 뜬다 — 걷던 손가락·누르던 키가 그대로 + /// [싸우기]/[도망치기]를 누르지 않게 한다. + /// + public const float InputDelaySeconds = 0.45f; + + /// 도망에 실패한 뒤 「도망치지 못했다!」를 보여 주고 싸움으로 넘어가기까지(초). + public const float EscapeFailPauseSeconds = 1.1f; + + /// + /// 다가오는 방향 후보 — 플레이어 쪽(0°)에서 좌우로 벌린다. ±90°까지는 벽을 끼고 돌아가는 길이고, 그보다 더 돌면 + /// 물러나는 꼴이라 넣지 않는다(도주의 와 같은 형태, 기준만 반대다). + /// + internal static readonly float[] ApproachAngles = { 0f, 30f, -30f, 60f, -60f, 90f, -90f }; + + /// 판정에 들어가는 것 전부. 호출부(CaptureInputController)가 프레임마다 한 번 채우고 개체 칸만 바꿔 쓴다. + public struct Context + { + /// 이 종의 성향(). + public InsectHabit Habit; + /// 지금 플레이어가 있는 리전에서 보이는 상태(GetWorldState(regionId)). + public WorldState State; + public bool IsGuardian; + /// 이 개체가 포획·전투·아이 NPC에게 붙잡혀 있다(InsectEntity.IsEngaged). + public bool IsEngaged; + /// 이 몸의 남은 쿨다운(초). 0 이하면 끝났다. + public float EntityCooldownLeft; + /// 마지막 습격 교전이 끝난 뒤 흐른 시간(초). 한 번도 없었으면 아주 큰 값. + public float SinceLastAmbushEnded; + /// 마지막 교전(습격 포함)이 끝난 뒤 흐른 시간(초). 씬이 열린 뒤 처음이면 씬이 열린 뒤 흐른 시간. + public float SinceLastEncounterEnded; + /// 플레이어가 다른 교전(선택 창·미니게임·전투·레이드) 중이다. + public bool PlayerInEncounter; + public bool DreamActive; + public bool InSubArea; + public bool PlayerFrozen; + public bool ModalOpen; + /// 이 곤충과 싸울 수 있다(). + public bool CanFight; + } + + /// + /// 지금 이 곤충이 습격할 수 있는가 — 막혔다면 처음 걸린 까닭을 돌려준다. 순서가 우선순위다: + /// 습격형이 깨어 있나 → 수문장 → 붙잡힘 → 꿈 → 서브에리어 → 플레이어 교전 중 → 개체 쿨다운 → 전역 쿨다운 → 싸울 곤충 → + /// 멈춤 → 창. 멈춤·창이 맨 뒤인 이유: 다가오던 곤충은 그 둘에서만 기다리고() 나머지에서는 물러난다. + /// 습격 창·전투도 플레이어를 멈추고 모달을 여는데, 그때는 「교전 중」이 먼저 걸려 다른 곤충이 기다렸다 덮치지 않는다. + /// + public static AmbushRefusal Check(in Context c) + { + if (!InsectHabits.IsAmbusher(c.Habit, c.State)) return AmbushRefusal.NotAwake; + if (c.IsGuardian) return AmbushRefusal.Guardian; + if (c.IsEngaged) return AmbushRefusal.Engaged; + if (c.DreamActive) return AmbushRefusal.Dream; + if (c.InSubArea) return AmbushRefusal.SubArea; + if (c.PlayerInEncounter) return AmbushRefusal.PlayerInEncounter; + if (c.EntityCooldownLeft > 0f) return AmbushRefusal.EntityCooldown; + if (c.SinceLastAmbushEnded < GlobalCooldownSeconds) return AmbushRefusal.GlobalCooldown; + if (c.SinceLastEncounterEnded < AfterEncounterGraceSeconds) return AmbushRefusal.GlobalCooldown; + if (!c.CanFight) return AmbushRefusal.NoFighter; + if (c.PlayerFrozen) return AmbushRefusal.PlayerFrozen; + if (c.ModalOpen) return AmbushRefusal.ModalOpen; + return AmbushRefusal.None; + } + + /// 가 막지 않았는가. + public static bool CanAmbush(in Context c) => Check(c) == AmbushRefusal.None; + + /// + /// 다가오던 곤충이 기다리기만 하는 까닭인가 — 플레이어가 대화·메뉴로 잠시 멈춘 것은 지나가므로 그 자리에서 노려본다. + /// 나머지(잠잠해짐·쿨다운·다른 교전·서브에리어 등)는 쫓기를 접고 물러난다. + /// + public static bool IsPauseOnly(AmbushRefusal refusal) + => refusal == AmbushRefusal.PlayerFrozen || refusal == AmbushRefusal.ModalOpen; + + /// + /// 이 곤충과 싸울 수 있는가 — 1v1이면 기절 안 한 출전 곤충이 하나라도, 레이드 대상(영웅·전설)이면 5칸이 다 찼고 그중 + /// 하나라도 싸울 수 있어야 한다(CaptureChoiceUI의 레이드 버튼과 같은 조건). 싸울 수 없으면 습격하지 않는다 — + /// 습격 창엔 그냥 닫는 길이 없으므로(도망에 실패하면 싸운다) 싸울 수 없는 창은 갇힌 창이 된다. 튜토리얼 초반 첫 파트너가 없을 때가 그 경우다. + /// + public static bool CanFight(bool raidTarget, int filledSlots, int readySlots) + { + if (readySlots <= 0) return false; + return !raidTarget || filledSlots >= BattleTeamManager.MaxSlots; + } + + /// 쿨다운이 끝날 때까지 남은 시간(초, 0 이상). + public static float Remaining(float now, float readyAt) => Mathf.Max(0f, readyAt - now); + + /// 닿았는가(수평 거리). + public static bool HasReached(float planarDistance) => planarDistance <= ReachDistance; + + /// 쫓기를 접을 때인가 — 너무 멀어졌거나 너무 오래 쫓았다. + public static bool ShouldGiveUp(float planarDistance, float chaseSeconds) + => planarDistance > LeashDistance || chaseSeconds > MaxChaseSeconds; + + /// + /// 다가갈 방향을 고른다 — 플레이어 쪽부터 좌우로 벌려 가며 이번 걸음(에서 닿는 거리의 절반을 뺀 만큼, + /// 최대 )이 장애물 앞 여유()까지 비어 있는 처음 방향을 쓴다. + /// 다 막혔으면 가장 멀리 가는 방향으로 장애물 앞까지만 간다(0이면 제자리 — 호출부가 뒤에 접는다). + /// 도주()와 달리 "다 비었다"의 기준이 이번 걸음 길이다 — 플레이어 뒤의 벽까지 비어 있을 필요는 없다. + /// + /// −1이면 좌우를 뒤집는다(한 습격 동안 같은 쪽으로 돈다 — 벽 앞에서 좌우로 흔들리지 않게). + /// 방향 → 막히지 않고 갈 수 있는 거리(m). + internal static Vector3 ChooseApproach(Vector3 toward, float distanceToTarget, float sideSign, + Func clearance, out float allowedDistance) + { + toward.y = 0f; + if (toward.sqrMagnitude < 1e-4f) toward = Vector3.forward; + toward.Normalize(); + float want = Mathf.Clamp(distanceToTarget - ReachDistance * 0.5f, 0f, MaxLegDistance); + float sign = sideSign < 0f ? -1f : 1f; + + Vector3 best = toward; + float bestClear = -1f; + for (int i = 0; i < ApproachAngles.Length; i++) + { + Vector3 dir = Quaternion.AngleAxis(ApproachAngles[i] * sign, Vector3.up) * toward; + float c = clearance != null ? clearance(dir) : float.PositiveInfinity; + if (c >= want + FleePath.WallMargin) + { + allowedDistance = want; + return dir; + } + if (c > bestClear) + { + bestClear = c; + best = dir; + } + } + allowedDistance = Mathf.Clamp(bestClear - FleePath.WallMargin, 0f, want); + return best; + } + + /// 창에 띄우는 "왜 덤벼들었나" 한 줄 — 시간대가 깨운 것이면 시간대를, 아니면(어스름) 좋아하는 날씨를 말한다. + public static string ReasonLine(InsectHabit habit, WorldState state) + { + if (InsectHabits.TimeFit(habit, state.DayPhase) > 0) + { + switch (state.DayPhase) + { + case DayPhase.Night: return "밤이 되자 사나워졌다"; + case DayPhase.Morning: return "아침 사냥에 나서 있었다"; + case DayPhase.Day: return "한낮이라 사냥에 한창이다"; + default: return "저물녘에 사나워졌다"; + } + } + if (InsectHabits.WeatherFit(habit, state.Weather) > 0) + { + switch (state.Weather) + { + case WeatherType.Fog: return "안개를 틈타 덮쳐 왔다"; + case WeatherType.Rain: return "비가 오자 사나워졌다"; + case WeatherType.Wind: return "센바람을 타고 덤벼들었다"; + case WeatherType.Snow: return "눈발 속에서 굶주려 있었다"; + default: return "맑은 날씨에 힘이 넘친다"; + } + } + return FallbackReason; + } + + /// 까닭을 모를 때(상태를 못 읽었을 때)의 한 줄. + public const string FallbackReason = "갑자기 덤벼들었다"; + } +} diff --git a/Assets/Scripts/Spawning/AmbushRules.cs.meta b/Assets/Scripts/Spawning/AmbushRules.cs.meta new file mode 100644 index 0000000..e6c93ed --- /dev/null +++ b/Assets/Scripts/Spawning/AmbushRules.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 638568201e7824845a5e3f300ef80daf \ No newline at end of file diff --git a/Assets/Scripts/Spawning/CaptureItemPickup.cs b/Assets/Scripts/Spawning/CaptureItemPickup.cs index 702489d..a40ad84 100644 --- a/Assets/Scripts/Spawning/CaptureItemPickup.cs +++ b/Assets/Scripts/Spawning/CaptureItemPickup.cs @@ -10,6 +10,9 @@ public class CaptureItemPickup : MonoBehaviour private PlayerItemInventory inventory; private float bobOffset; private float lifetime; + // 떠 있는 기준 높이(월드 y) — 스폰 자리가 정한다(CaptureItemSpawner: 그 자리 지면 + 떠 있는 높이). + // 옛날엔 Update가 절대 0.8을 박아서 재 더미(꼭대기 바닥 위 1.05)에 묻히고 사구에 잠겼다. + private float baseY; public void Initialize(CaptureItemData data, PlayerItemInventory inv) { @@ -17,6 +20,7 @@ public void Initialize(CaptureItemData data, PlayerItemInventory inv) inventory = inv; bobOffset = Random.value * Mathf.PI * 2f; lifetime = 0f; + baseY = transform.position.y; MeshFilter mf = gameObject.GetComponent(); if (mf == null) mf = gameObject.AddComponent(); @@ -51,7 +55,7 @@ private void Update() lifetime += Time.deltaTime; float bob = Mathf.Sin(Time.time * 2f + bobOffset) * 0.15f; Vector3 pos = transform.position; - pos.y = 0.8f + bob; + pos.y = baseY + bob; transform.position = pos; transform.Rotate(Vector3.up, 90f * Time.deltaTime, Space.World); diff --git a/Assets/Scripts/Spawning/CaptureItemSpawner.cs b/Assets/Scripts/Spawning/CaptureItemSpawner.cs index 1d8302a..4e524d2 100644 --- a/Assets/Scripts/Spawning/CaptureItemSpawner.cs +++ b/Assets/Scripts/Spawning/CaptureItemSpawner.cs @@ -44,16 +44,29 @@ private void Update() } } + /// + /// 지면 위로 떠 있는 높이(m). 옛 절대 높이 0.8 = 둔덕 밖 지면( 0.1) + 0.7이라 + /// 평지에선 그대로고, 사구·재 더미 위에서는 그 윗면에서 0.7 뜬다. + /// + internal const float HoverHeight = 0.7f; + + /// 플레이어에서 이만큼(m)은 떨어져 놓는다(옛 지역 변수 minDist 그대로). + private const float MinSpawnDistance = 8f; + private void SpawnRandomItem() { CaptureItemData chosen = PickWeighted(); if (chosen == null) return; Vector3 center = playerTransform != null ? playerTransform.position : Vector3.zero; - Vector2 off = Random.insideUnitCircle * spawnRadius; - float minDist = 8f; - if (off.magnitude < minDist) off = off.normalized * minDist; - Vector3 pos = center + new Vector3(off.x, 0.8f, off.y); + Vector3 pos = PickItemPosition(() => + { + Vector2 off = Random.insideUnitCircle * spawnRadius; + // 원점에서 normalized는 0이라 플레이어 발밑에 떨어졌다 — 방향이 없으면 한쪽으로 둔다 + if (off.magnitude < MinSpawnDistance) + off = (off.sqrMagnitude > 1e-8f ? off.normalized : Vector2.right) * MinSpawnDistance; + return center + new Vector3(off.x, 0f, off.y); + }); GameObject go = new GameObject($"Pickup_{chosen.displayName}"); go.transform.position = pos; @@ -64,6 +77,18 @@ private void SpawnRandomItem() activePickups.Add(go); } + /// + /// 순수 판정 — 곤충 스폰과 같은 자리 규칙(: 물 위 후보는 최대 8번 + /// 다시 굴리고, 끝까지 물이면 물가로 밀어내고, 그 자리 둔덕 윗면에 세운다)을 지난 뒤 만큼 띄운다. + /// 옛 스폰은 반경 80m 어디든 놓아 연못 호수 한가운데에도 떴다. + /// + internal static Vector3 PickItemPosition(System.Func roll) + { + Vector3 p = InsectSpawner.PickSpawnPosition(roll); + p.y += HoverHeight; + return p; + } + private CaptureItemData PickWeighted() { float roll = Random.value * totalWeight; diff --git a/Assets/Scripts/Spawning/FieldPopulation.cs b/Assets/Scripts/Spawning/FieldPopulation.cs new file mode 100644 index 0000000..8a69ff3 --- /dev/null +++ b/Assets/Scripts/Spawning/FieldPopulation.cs @@ -0,0 +1,247 @@ +using System.Collections.Generic; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Spawning +{ + /// 슬롯이 지금 개체를 들고 있는가. + public enum FieldSlotState + { + /// 비었다 — 가 되면 새 개체를 굴린다. + Empty, + /// 개체가 산다 — 실체(월드의 곤충)가 있든 없든 기록은 그대로다. + Alive + } + + /// + /// 필드의 곤충 한 자리. 기록이 곧 개체다 — 월드에 서 있는 는 플레이어가 + /// 가까이 있는 동안만 빌려 쓰는 몸이고, 멀어져 풀로 돌아가도 종·레벨·색다름·자리가 여기 남는다. + /// 그래서 리전을 떠났다 돌아와도 같은 곤충이 같은 자리에 있다. + /// + public sealed class FieldSlot + { + /// 소속 — 메인 필드면 리전 ID, 서브에리어면 InsectSpawner가 붙인 서브에리어 키. + public readonly string Key; + /// 소속 안 번호. 서브에리어 종 순환()이 쓴다. + public readonly int Index; + public readonly bool IsSubArea; + + public FieldSlotState State = FieldSlotState.Empty; + public string InsectId; + /// 종 데이터(조회 캐시). 테스트처럼 DB 없이 기록만 다룰 땐 null이어도 된다. + public InsectData Data; + public int Level; + public bool Shiny; + public bool Erased; + public Vector3 Position; + /// 비었을 때 다시 굴릴 시각(Time.time). + public float RespawnAt; + /// 살아 있을 때 수명이 다하는 시각. 서브에리어는 무한(전용종이라 시간대와 무관하다). + public float ExpiresAt = float.PositiveInfinity; + /// 이 자리에 몇 번째 개체인가(첫 개체 0). 새로 굴릴 때마다 오른다. + public int Generation = -1; + /// 지금 빌려 쓰는 몸. 없으면 null — Unity 파괴 객체도 null로 읽힌다. + public InsectEntity Entity; + + /// 실체화 정렬용 거리 캐시(스포너가 틱마다 쓴다). + internal float SortKey; + + public FieldSlot(string key, int index, bool isSubArea) + { + Key = key ?? string.Empty; + Index = index; + IsSubArea = isSubArea; + } + + public bool IsAlive => State == FieldSlotState.Alive; + public bool IsMaterialized => Entity != null; + } + + /// + /// 필드 개체군 기록부 — 순수 C#이라 씬 없이 테스트한다. 슬롯의 상태 전이 규칙도 여기 모았다. + /// + /// 사라지는 방식이 둘이다. ①게임플레이 퇴장(포획·전투·아이 NPC) — 그 자리는 비고 재생 지연 뒤에 + /// 새로 찬다. ②스포너가 거둠(멀어짐·서브에리어 전환·씬 정리) — 몸만 풀로 돌아가고 개체는 그대로다. + /// 둘을 섞으면 "멀어졌다 돌아오면 새 곤충"(옛 리롤)으로 되돌아가거나, 잡은 곤충이 영영 안 사라진다. + /// 필드의 놓침 도주는 셋째 길이다 — 개체는 그대로 두고 자리만 옮긴다(). + /// + public sealed class FieldPopulation + { + private readonly Dictionary> slotsByKey = new Dictionary>(); + private readonly List keys = new List(); + + public IReadOnlyList Keys => keys; + + public bool Contains(string key) => key != null && slotsByKey.ContainsKey(key); + + /// 그 소속의 슬롯 목록(없으면 빈 목록을 만든다). + public List SlotsOf(string key) + { + key = key ?? string.Empty; + if (!slotsByKey.TryGetValue(key, out List slots)) + { + slots = new List(); + slotsByKey[key] = slots; + keys.Add(key); + } + return slots; + } + + /// + /// 슬롯 수를 에 맞춘다. 모자라면 빈 슬롯을 지금 굴릴 차례로 더하고, 남으면 + /// 빈 것 → 몸 없는 산 것 순으로 뺀다. 몸이 있는(플레이어 앞에 서 있는) 슬롯은 빼지 않는다 — + /// 눈앞의 곤충이 이유 없이 사라지면 안 된다. 그 몫은 거둬진 뒤 다음 호출에서 빠진다. + /// + /// 오염 거점의 상한(BlightPolicy.MaxActiveFor)이 로그인·클라우드 재적재·정화로 바뀌는 걸 이 한 곳이 받는다. + /// + /// 더한 수(양수) 또는 뺀 수(음수). + public int EnsureSlotCount(string key, int desired, float now, bool isSubArea) + { + List slots = SlotsOf(key); + desired = Mathf.Max(0, desired); + int changed = 0; + + while (slots.Count < desired) + { + slots.Add(new FieldSlot(key, slots.Count, isSubArea) { State = FieldSlotState.Empty, RespawnAt = now }); + changed++; + } + + for (int pass = 0; pass < 2 && slots.Count > desired; pass++) + { + for (int i = slots.Count - 1; i >= 0 && slots.Count > desired; i--) + { + FieldSlot s = slots[i]; + if (s.IsMaterialized) continue; + if (pass == 0 && s.IsAlive) continue; // 첫 바퀴는 빈 것만 + slots.RemoveAt(i); + changed--; + } + } + return changed; + } + + /// 그 소속에 이 종이 살아 있는가(실체 유무와 무관). + public bool HasAlive(string key, string insectId) + { + if (string.IsNullOrEmpty(insectId) || key == null || !slotsByKey.TryGetValue(key, out List slots)) + return false; + for (int i = 0; i < slots.Count; i++) + if (slots[i].IsAlive && slots[i].InsectId == insectId) return true; + return false; + } + + /// 그 소속에 살아 있는 종 ID를 에 모은다(비우지 않는다). + public void CollectAliveIds(string key, HashSet into) + { + if (into == null || key == null || !slotsByKey.TryGetValue(key, out List slots)) return; + for (int i = 0; i < slots.Count; i++) + if (slots[i].IsAlive && !string.IsNullOrEmpty(slots[i].InsectId)) into.Add(slots[i].InsectId); + } + + public void Clear() + { + slotsByKey.Clear(); + keys.Clear(); + } + + // ── 슬롯 상태 전이 ── + + /// 새 개체를 들인다. 몸은 붙이지 않는다(실체화는 거리로 따로 정한다). + public static void Fill(FieldSlot slot, string insectId, InsectData data, int level, bool shiny, bool erased, + Vector3 position, float expiresAt) + { + slot.State = FieldSlotState.Alive; + slot.InsectId = insectId; + slot.Data = data; + slot.Level = level; + slot.Shiny = shiny; + slot.Erased = erased; + slot.Position = position; + slot.ExpiresAt = expiresAt; + slot.Generation++; + } + + /// + /// 게임플레이로 사라졌다(포획·전투·도주·아이 NPC) — 자리가 비고 초 뒤에 다시 찬다. + /// + public static void MarkRemovedByGameplay(FieldSlot slot, float now, float delay) + { + Vacate(slot, now + Mathf.Max(0f, delay)); + } + + /// + /// 스포너가 몸만 거뒀다(멀어짐·서브에리어 전환) — 개체는 그대로다. 상태·종·레벨·자리·수명 어느 것도 + /// 건드리지 않는다. 다시 가까워지면 같은 곤충이 같은 자리에 선다. + /// + public static void MarkRecalled(FieldSlot slot) + { + slot.Entity = null; + } + + /// + /// 놓침 도주로 달아났다 — 개체는 그대로(종·레벨·색다름·수명) 자리만 으로 옮기고 + /// 몸을 뗀다. 포획·전투처럼 자리를 비우면 플레이어가 대화·전투로 잠깐 서 있기만 해도 주변 곤충이 달아나 + /// 1~2분씩 빈 들판이 남는다(멈춰 서도 인내심이 조금씩 닳는다 — InsectEntity 경계 로직). + /// + public static void RelocateAfterFlee(FieldSlot slot, Vector3 newPosition) + { + if (slot.State != FieldSlotState.Alive) return; + slot.Position = newPosition; + slot.Entity = null; + } + + /// 자리를 비운다 — 에 다시 굴린다. 수명 순환·재시도도 이 길로 온다. + public static void Vacate(FieldSlot slot, float respawnAt) + { + slot.State = FieldSlotState.Empty; + slot.InsectId = null; + slot.Data = null; + slot.Entity = null; + slot.ExpiresAt = float.PositiveInfinity; + slot.RespawnAt = respawnAt; + } + + /// 빈 자리이고 다시 찰 시각이 됐는가. + public static bool IsDueForRoll(FieldSlot slot, float now) + => slot.State == FieldSlotState.Empty && now >= slot.RespawnAt; + + public static bool IsExpired(FieldSlot slot, float now) + => slot.State == FieldSlotState.Alive && now >= slot.ExpiresAt; + + /// + /// 시간대가 바뀌어 이 개체가 새 시간대에 어울리지 않는다 — 수명이 다하는 시각을 now + delay로 당긴다 + /// (이미 그보다 일찍 만료라면 그대로). 개체를 지우거나 바꾸지는 않는다: 실제 교체는 평소 순환()이 + /// 하므로 플레이어 25m 안의 몸은 그대로 서 있다가 멀어진 뒤에 바뀐다. 서브에리어 슬롯은 수명이 없어(전용종은 시간대와 무관) + /// 건드리지 않는다. + /// + /// 만료를 당겼으면 true. + public static bool PullExpiryForward(FieldSlot slot, float now, float delay) + { + if (slot == null || !slot.IsAlive || slot.IsSubArea) return false; + float at = now + Mathf.Max(0f, delay); + if (slot.ExpiresAt <= at) return false; + slot.ExpiresAt = at; + return true; + } + + /// + /// 수명이 다한 개체를 지금 새 개체로 바꿔도 되는가. 몸이 없으면(멀리 있으면) 언제든, 몸이 있으면 + /// 플레이어가 밖이고 포획·경계·도주 중이 아닐 때만. + /// + public static bool CanRotate(FieldSlot slot, float now, float playerDistance, bool busy) + { + if (!IsExpired(slot, now)) return false; + if (!slot.IsMaterialized) return true; + return !busy && playerDistance >= FieldSpawnRules.RotateMinPlayerDistance; + } + + /// 살아 있고 몸이 없는데 플레이어 실체화 반경 안인가. + public static bool ShouldMaterialize(FieldSlot slot, float playerDistance) + => slot.IsAlive && !slot.IsMaterialized && playerDistance <= FieldSpawnRules.MaterializeRadius; + + /// 몸이 있는데 거둠 반경 밖이고 붙잡혀 있지 않은가. + public static bool ShouldRecall(FieldSlot slot, float playerDistance, bool busy) + => slot.IsMaterialized && !busy && playerDistance > FieldSpawnRules.RecallRadius; + } +} diff --git a/Assets/Scripts/Spawning/FieldPopulation.cs.meta b/Assets/Scripts/Spawning/FieldPopulation.cs.meta new file mode 100644 index 0000000..3c4b2c4 --- /dev/null +++ b/Assets/Scripts/Spawning/FieldPopulation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9a35b80ec2c8a534c83a060b7d05a91d \ No newline at end of file diff --git a/Assets/Scripts/Spawning/FieldSpawnRules.cs b/Assets/Scripts/Spawning/FieldSpawnRules.cs new file mode 100644 index 0000000..18271e0 --- /dev/null +++ b/Assets/Scripts/Spawning/FieldSpawnRules.cs @@ -0,0 +1,376 @@ +using System.Collections.Generic; +using InsectGame.Core; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.Spawning +{ + /// + /// 필드 곤충 개체군의 규칙 — 순수 계산부. 등급표·등급 대체·레어 부스트·레벨·슬롯 수·재생 시간이 + /// 전부 여기 한 곳에 있다. 는 이 답을 월드에 옮길 뿐이다. + /// + /// 희귀도는 리전과 무관하고 레벨만 리전을 따른다(사용자 결정 2026-09-29). 예전엔 리전 풀의 종 가중치를 + /// 그대로 굴려서 풀의 등급 구성이 곧 그 리전의 등급 분포였다 — 초원은 희귀 이상이 0%, 유적은 일반이 0%에 + /// 희귀 50%·전설 10%였다. 이제 등급을 먼저 전역 표로 굴리고, 종은 그 등급 안에서 리전 풀로 고른다. + /// + /// 등급표 다섯 값은 progression_sim.py가 이 파일에서 직접 읽는다(game_facts.field_rarity_shares) — + /// 값을 바꿔도 시뮬 쪽에 사본이 없다. 이름(…Share)을 바꾸면 추출기가 ExtractorBroken으로 죽는다. + /// + public static class FieldSpawnRules + { + // ── 등급표 — 리전과 무관 ── + // + // 사용자 확정 값(2026-09-29). 바꿀 땐 이 다섯 줄만 고친다 — 합이 1이 아니어도 된다(정규화한다). + // 모든 리전 풀이 다섯 등급을 다 갖도록 채우는 게 전제다(RegionDefinitions — game-designer 경계). + // 아래 대체 규칙(Fallback)은 그 전제가 깨졌을 때(풀 편집 실수·시간대 필터)의 안전망이다. + public const float CommonShare = 0.60f; + public const float UncommonShare = 0.25f; + public const float RareShare = 0.11f; + public const float EpicShare = 0.035f; + public const float LegendaryShare = 0.005f; + + internal const int RarityCount = 5; + + /// 등급표의 원래 몫(정규화 전). + internal static float BaseShare(int rarity) + { + switch ((InsectRarity)rarity) + { + case InsectRarity.Common: return CommonShare; + case InsectRarity.Uncommon: return UncommonShare; + case InsectRarity.Rare: return RareShare; + case InsectRarity.Epic: return EpicShare; + case InsectRarity.Legendary: return LegendaryShare; + default: return 0f; + } + } + + /// + /// 레어 부스트(아이템·의상 GetRareSpawnMultiplier)를 얹은 몫. 희귀 이상에만 곱한다 — 옛 + /// GetWeightedRandomWithRareBoost와 같은 의미다. 재정규화는 호출부가 합으로 나눠서 한다. + /// + /// (옛 경로는 부스트가 1을 넘는 순간 등급 기본 가중치 표 — 일반 1 / 고급 0.45 / 희귀 0.12 … — 까지 함께 + /// 곱해서, 배수 1.5짜리 부스터를 켜면 오히려 희귀가 줄었다. 부스트 없는 경로는 그 표를 안 썼다.) + /// + internal static float BoostedShare(int rarity, float rareBoost) + { + float share = BaseShare(rarity); + if (rarity >= (int)InsectRarity.Rare) share *= Mathf.Max(1f, rareBoost); + return share; + } + + /// + /// 원하는 등급이 후보에 없을 때 대신 쓸 등급. 가까운 아래 등급 → 위 등급 순이다. + /// 후보가 하나도 없으면 −1. 풀이 다섯 등급을 다 갖는 한 쓰이지 않는 안전망이다. + /// + /// 아래부터 보는 이유: 빠진 등급을 위로 올려 채우면 희귀가 흔해진다 — 일반·고급뿐인 풀에서 + /// 전설 0.5%는 고급으로 내려와야 등급표의 "귀함"이 유지된다. 위로 가는 건 아래가 통째로 + /// 없을 때(일반이 없는 풀)뿐이다. + /// + internal static int Fallback(int wanted, bool[] available) + { + if (available == null) return -1; + if (wanted >= 0 && wanted < available.Length && available[wanted]) return wanted; + for (int r = Mathf.Min(wanted, available.Length) - 1; r >= 0; r--) + if (available[r]) return r; + for (int r = Mathf.Max(wanted + 1, 0); r < available.Length; r++) + if (available[r]) return r; + return -1; + } + + /// + /// 등급 하나를 굴린다 — 부스트를 얹은 표에서 뽑고() 후보에 없으면 . + /// 는 등급(enum 순서)별로 "이 리전·지금 시간대에 그 등급 종이 있는가". + /// + internal static int PickRarity(float roll01, float rareBoost, bool[] available) + { + float total = 0f; + for (int r = 0; r < RarityCount; r++) total += BoostedShare(r, rareBoost); + if (total <= 0f) return Fallback(0, available); + + float target = Mathf.Clamp01(roll01) * total; + int wanted = RarityCount - 1; + float acc = 0f; + for (int r = 0; r < RarityCount; r++) + { + acc += BoostedShare(r, rareBoost); + if (target < acc) { wanted = r; break; } + } + return Fallback(wanted, available); + } + + /// + /// 대체까지 반영한 최종 등급 분포(합 1, 후보가 없으면 전부 0). 가 따르는 분포와 같다 — + /// 테스트와 progression_sim의 리전 수입 모델이 이 형태를 쓴다. + /// + internal static float[] EffectiveShares(float rareBoost, bool[] available) + { + var shares = new float[RarityCount]; + float total = 0f; + for (int r = 0; r < RarityCount; r++) total += BoostedShare(r, rareBoost); + if (total <= 0f) return shares; + for (int r = 0; r < RarityCount; r++) + { + int to = Fallback(r, available); + if (to >= 0) shares[to] += BoostedShare(r, rareBoost) / total; + } + return shares; + } + + // ── 레벨 — 리전 대역만 본다 ── + + /// + /// 메인 필드 레벨 분포의 지수. 1보다 크면 대역의 낮은 쪽이 더 흔하다 — 1.5면 대역 아래 1/3에 약 48%, + /// 위 1/3에 약 24%가 떨어진다. + /// + /// 옛 규칙은 리전 대역을 버리고 항상 Lv.1부터 지수 3.5 + 등급 보정(전설 +1.5)으로 굴렸다 — 유적(Lv.36~50)에서도 + /// 절반 가까이가 Lv.10 아래였다. 이제 레벨은 리전만으로 정하고 등급은 보지 않는다. + /// + public const float FieldLevelPower = 1.5f; + + /// 리전 대역 [, ] 안의 레벨. 등급과 무관하다. + internal static int RollFieldLevel(int min, int max, float roll01) + { + min = Mathf.Max(1, min); + max = Mathf.Max(min, max); + float weighted = Mathf.Pow(Mathf.Clamp01(roll01), FieldLevelPower); + int level = min + Mathf.FloorToInt(weighted * (max - min + 1)); + return Mathf.Clamp(level, min, max); + } + + // ── 개체군 크기 ── + + /// + /// 곤충 한 마리가 차지하는 땅(㎡). 옛 스포너는 리전당 10마리를 플레이어 둘레 10~43m 고리(약 5,500㎡)에 + /// 몰아 두었다 — 그 체감 밀도(약 550㎡당 1마리)를 리전 전체로 편 값이다. 플레이어 45m 안(약 6,360㎡)에 + /// 평균 11~12마리가 선다. + /// + public const float SquareMetersPerInsect = 550f; + public const int MinRegionSlots = 8; + public const int MaxRegionSlots = 40; + + /// + /// 리전 슬롯 수 — 곤충이 설 수 있는 땅 넓이(, ㎡) × 밀도. 오염 거점의 감소는 + /// 여기가 아니라 BlightPolicy.MaxActiveFor가 이 값에 얹는다(InsectSpawner.RegionCap). + /// + internal static int SlotCountFor(float usableArea) + { + int n = Mathf.RoundToInt(Mathf.Max(0f, usableArea) / SquareMetersPerInsect); + return Mathf.Clamp(n, MinRegionSlots, MaxRegionSlots); + } + + // ── 시간·날씨에 따른 개체 수 ── + + /// 밤이면 리전 슬롯이 이만큼 늘어난다 — 어둠 속에서 더 많은 곤충이 움직인다(사용자 요청). + public const int NightBonusSlots = 1; + + /// 비·안개면 리전 슬롯이 이만큼 늘어난다 — 습한 날 곤충이 더 나온다. 눈·센바람·맑음은 늘리지 않는다. + public const int WetWeatherBonusSlots = 1; + + /// 보너스의 상한 — 밤이면서 비·안개여도 둘이다("한두 개 정도 더"). + public const int MaxBonusSlots = 2; + + /// + /// 이 상태(그 리전에서 보이는 날씨 — WorldStateProvider.GetWorldState(regionId))에서 리전 슬롯에 더하는 수. + /// 밤 +1, 비·안개 +1, 합 최대 . 오염 상한과 합치는 순서는 InsectSpawner.RegionCap이 정한다 + /// (보너스를 더한 뒤 오염 감소를 곱하므로 황폐한 땅은 보너스도 같이 줄어든다). + /// + public static int BonusSlots(WorldState state) + { + int bonus = 0; + if (state.IsNight) bonus += NightBonusSlots; + if (state.Weather == WeatherType.Rain || state.Weather == WeatherType.Fog) bonus += WetWeatherBonusSlots; + return Mathf.Clamp(bonus, 0, MaxBonusSlots); + } + + // ── 시간대 전환 갈아입기 ── + + /// + /// 시간대가 바뀌었을 때, 새 시간대에 어울리지 않는 개체(잠들 시간의 종)의 남은 수명을 이 범위(초)로 당긴다. + /// 그 뒤의 교체는 평소 순환 그대로다 — 플레이어 안이면 기다리고, 스토리 포획 목표종은 면제다. + /// 한꺼번에 바꾸지 않고 5~60초에 흩어 필드가 한순간에 갈아엎이지 않게 한다. + /// + public const float PhaseSwapDelayMin = 5f; + public const float PhaseSwapDelayMax = 60f; + + internal static float RollPhaseSwapDelay(float roll01) + => Mathf.Lerp(PhaseSwapDelayMin, PhaseSwapDelayMax, Mathf.Clamp01(roll01)); + + // ── 종 고르기 — 성향 배수 ── + + /// InsectDatabase.PickWeighted와 같은 가중 하한 — 가챠 전용(0)이 섞여도 0으로 나누지 않는다. + public const float MinSpawnWeight = 0.01f; + + /// + /// 가중 선택 — InsectDatabase.PickWeighted와 같되 후보마다 (시간·날씨 성향 배수, + /// )를 곱한다. InsectData.spawnWeight는 건드리지 않는다. + /// 배수 목록이 null이거나 짧으면 모자란 쪽은 ×1이다. 배수가 전부 1이면 InsectDatabase.PickWeighted와 같은 답이다 + /// (테스트가 훑어서 고정한다 — 두 곳의 가중식이 갈라지지 않게). + /// + /// 등급은 이미 정해진 뒤다 — 이 함수는 한 등급의 후보 안에서만 부른다. 그래서 배수가 등급 분포를 못 건드린다. + /// + internal static InsectData PickWeighted(IReadOnlyList candidates, IReadOnlyList multipliers, + float roll01) + { + if (candidates == null || candidates.Count == 0) return null; + + float total = 0f; + for (int i = 0; i < candidates.Count; i++) + { + if (candidates[i] == null) continue; + total += CandidateWeight(candidates[i], multipliers, i); + } + + float roll = Mathf.Clamp01(roll01) * total; + float cumulative = 0f; + InsectData last = null; + for (int i = 0; i < candidates.Count; i++) + { + InsectData data = candidates[i]; + if (data == null) continue; + last = data; + cumulative += CandidateWeight(data, multipliers, i); + if (roll <= cumulative) return data; + } + return last; + } + + private static float CandidateWeight(InsectData data, IReadOnlyList multipliers, int index) + { + float multiplier = multipliers != null && index < multipliers.Count ? multipliers[index] : 1f; + return Mathf.Max(MinSpawnWeight, data.spawnWeight) * multiplier; + } + + // ── 실체화 · 재생 · 순환 ── + + /// 실체화·재생 판정 주기(초). 매 프레임 돌 일이 아니다 — 45m를 0.5초에 달려 들어올 수 없다. + public const float TickSeconds = 0.5f; + + /// 이 거리(m) 안의 살아 있는 개체를 월드에 세운다. 게임 카메라(0,9,−6 고각)가 보는 범위보다 넉넉히 밖이다. + public const float MaterializeRadius = 45f; + + /// + /// 이 거리(m) 밖이면 조용히 풀로 돌린다 — 기록은 남는다. 실체화 반경과 10m를 벌려 경계에서 서성일 때 + /// 매 틱 세웠다 거뒀다 하지 않게 한다. + /// + public const float RecallRadius = 55f; + + /// 포획·전투·도주로 빈 자리가 다시 차기까지(초, 무작위). 리전을 오가도 줄어들지 않는다. + public const float RespawnDelayMin = 60f; + public const float RespawnDelayMax = 120f; + + /// + /// 살아 있는 개체의 수명(초, 무작위). 다 되면 그 시점의 시간대·날씨로 새 개체가 들어선다 — 게임 하루가 + /// 12분이라 한 시간대(약 3분)가 지날 때마다 필드의 일부가 바뀐다. + /// + public const float LifetimeMin = 240f; + public const float LifetimeMax = 420f; + + /// + /// 수명이 다한 개체를 바꿔도 되는 플레이어와의 거리(m). 그보다 가까우면 기다린다 — 눈앞에서 딴 곤충으로 + /// 바뀌면 "리전을 오가면 리롤"과 다를 게 없다. + /// + public const float RotateMinPlayerDistance = 25f; + + /// 새로 굴린 개체가 플레이어 코앞에 갑자기 생기지 않게 두는 거리(m). + public const float SpawnMinPlayerDistance = 20f; + + /// + /// 시작 채우기의 거리(m). 장면이 막 떴을 때라 "갑자기 생김"이 없다 — 다만 시작 자리 바로 옆이면 + /// 첫 걸음에 놀라 달아난다. + /// + public const float InitialSpawnMinPlayerDistance = 8f; + + /// 정화 직후 채우기의 거리(m). 돌아온 것이 보여야 하는 사건이라 평소보다 가깝게 둔다. + public const float CleanseSpawnMinPlayerDistance = 10f; + + /// 리전 가장자리 울타리 안쪽 여유(m). + public const float RegionEdgeMargin = 3f; + + /// + /// 서브에리어 게이트 원 바깥 여유(m). 원 안에 서면 잡기 E와 진입 E가 한 프레임에 겹친다 — + /// 부트스트랩 PushOutOfSubAreas와 같은 마진이다. + /// + public const float SubAreaGateMargin = 9f; + + /// 같은 리전 곤충끼리 최소 간격(m) — 풀 더미가 겹쳐 한 덩어리로 보이지 않게. + public const float MinSlotSpacing = 1.5f; + + /// 자리·종을 못 구했을 때 다시 시도하기까지(초). + public const float RetrySeconds = 5f; + + internal static float RollRespawnDelay(float roll01) + => Mathf.Lerp(RespawnDelayMin, RespawnDelayMax, Mathf.Clamp01(roll01)); + + internal static float RollLifetime(float roll01) + => Mathf.Lerp(LifetimeMin, LifetimeMax, Mathf.Clamp01(roll01)); + + /// + /// 시작 채우기의 수명 — 전부 4~7분짜리로 시작하면 4분쯤에 필드 전체가 한꺼번에 바뀐다. 첫 판은 + /// 1~7분에 흩어 둔다. + /// + internal static float RollInitialLifetime(float roll01) + => Mathf.Lerp(LifetimeMin * 0.25f, LifetimeMax, Mathf.Clamp01(roll01)); + + // ── 서브에리어 ── + + /// 서브에리어 동시 개체 수 — 전용종 수 + 1과 튜닝 상한 중 작은 쪽(옛 규칙 그대로). + internal static int SubAreaSlotCount(int exclusiveCount, int activeCount) + { + if (exclusiveCount <= 0) return 0; + return Mathf.Min(exclusiveCount + 1, Mathf.Max(1, activeCount)); + } + + /// + /// 서브에리어 슬롯의 종 — 첫 판은 옛 규칙(ids[i % n])과 같고, 다시 찰 때마다 슬롯 수만큼 건너뛴다. + /// + /// 옛 규칙은 슬롯 번호만 봐서 슬롯 수(2)보다 뒤의 전용종이 영영 안 나왔다 — 초원 동굴의 공벌레, + /// 깊은 연못의 왕물방개, 사원의 황금 사마귀 등 9종이 그 서브에리어에서 한 번도 뜨지 않았다. + /// + internal static string SubAreaSpecies(string[] ids, int slotIndex, int slotCount, int generation) + { + if (ids == null || ids.Length == 0) return null; + long k = (long)Mathf.Max(0, slotIndex) + (long)Mathf.Max(0, generation) * Mathf.Max(1, slotCount); + return ids[(int)(k % ids.Length)]; + } + + // ── 스토리 포획 보조 ── + + /// + /// 스토리가 지금 기다리는 종(예: 연못의 왕잠자리)이 그 리전에 살아 있지 않을 때, 새로 굴리는 자리가 그 종이 될 확률. + /// + /// 리전 이동 리롤이 사라져서 필요해졌다. 영웅 3.5%를 풀의 영웅 종 수로 나누면 특정 영웅 한 종은 수백 마리에 + /// 한 번이다 — 예전처럼 리전을 오가며 다시 굴릴 수도 없으니 본편이 거기서 멈춘 것처럼 느껴진다. + /// + public const float StoryAssistChance = 0.2f; + + /// + /// 스토리 보조로 고를 종. 가운데 이번 후보()에 있고 + /// 그 리전에 살아 있지 않은() 종이 대상이다. 이 + /// 이상이거나 대상이 없으면 null(평소 규칙으로 굴린다). + /// + internal static string PickStoryTarget(IReadOnlyList wanted, ICollection candidateIds, + ICollection aliveIds, float chanceRoll, float pickRoll) + { + if (wanted == null || wanted.Count == 0 || chanceRoll >= StoryAssistChance) return null; + + int eligible = 0; + for (int i = 0; i < wanted.Count; i++) + if (IsStoryEligible(wanted[i], candidateIds, aliveIds)) eligible++; + if (eligible == 0) return null; + + int pick = Mathf.Min(eligible - 1, Mathf.FloorToInt(Mathf.Clamp01(pickRoll) * eligible)); + for (int i = 0; i < wanted.Count; i++) + { + if (!IsStoryEligible(wanted[i], candidateIds, aliveIds)) continue; + if (pick-- == 0) return wanted[i]; + } + return null; + } + + private static bool IsStoryEligible(string id, ICollection candidateIds, ICollection aliveIds) + => !string.IsNullOrEmpty(id) + && (candidateIds == null || candidateIds.Contains(id)) + && (aliveIds == null || !aliveIds.Contains(id)); + } +} diff --git a/Assets/Scripts/Spawning/FieldSpawnRules.cs.meta b/Assets/Scripts/Spawning/FieldSpawnRules.cs.meta new file mode 100644 index 0000000..edb504c --- /dev/null +++ b/Assets/Scripts/Spawning/FieldSpawnRules.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0c0896e43ed7e4342a043ab174986782 \ No newline at end of file diff --git a/Assets/Scripts/Spawning/FirstShinyEncounter.cs b/Assets/Scripts/Spawning/FirstShinyEncounter.cs new file mode 100644 index 0000000..2c838a7 --- /dev/null +++ b/Assets/Scripts/Spawning/FirstShinyEncounter.cs @@ -0,0 +1,100 @@ +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.Spawning +{ + /// 첫 색다른 조우의 순수 판정 — 언제 일어나는가. + public static class FirstShinyRules + { + /// 이 퀘스트를 하는 동안 일어난다 — 「곤충 3마리 포획」. + public const string QuestId = "q_capture3"; + /// 그 퀘스트의 진행이 이만큼 됐을 때. 2면 마지막 한 마리를 남긴 시점이다. + public const int AtProgress = 2; + + /// + /// 지금이 그 순간인가. 세 번째 포획을 앞둔 때로 잡은 것은, 포획에 익숙해진 직후이면서 + /// 라온이 내기를 건 바로 그때라서다 — "마지막 한 마리"가 평범한 숙제에서 반짝이는 표적으로 바뀐다. + /// 그 퀘스트를 이미 끝낸 세이브에서는 일어나지 않는다(도입부 연출이다). + /// + public static bool ShouldTrigger(string activeQuestId, int activeProgress) + { + return activeQuestId == QuestId && activeProgress >= AtProgress; + } + } + + /// + /// 도입부의 확정 조우 — 세 번째 포획을 앞두고 근처 곤충 한 마리가 색다른 개체로 바뀐다. 한 계정에 한 번. + /// + /// 색다른 개체는 필드에서 1%라 첫 10분 안에 볼 일이 거의 없다. 이 게임의 "특별한 것을 만났다"는 감각이 + /// 처음 생기는 자리를 운에 맡기지 않는다. 실제 전환은 가 한다 — + /// 새로 스폰하지 않고 이미 서 있는 개체를 바꾸므로 슬롯·등급표·리전 풀에 영향이 없다. + /// + public class FirstShinyEncounter : MonoBehaviour + { + private const float CheckInterval = 2f; + // 너무 가까우면 눈앞에서 색이 바뀌는 게 보이고, 너무 멀면 못 찾는다. + private const float MinDistance = 7f; + private const float MaxDistance = 26f; + + private InsectSpawner spawner; + private TutorialQuestManager questManager; + private RegionManager regionManager; + private PlayerMovement playerMovement; + private Transform playerTransform; + private FieldMomentFeed momentFeed; + + private float timer; + // 불러온 계정 스코프 키 — 로그인·계정 전환으로 키가 바뀌면 다시 읽는다. + private string prefsKey; + private bool given; + + public void AutoWire(InsectSpawner insectSpawner, TutorialQuestManager quests, RegionManager region, + PlayerMovement movement, Transform player, FieldMomentFeed feed) + { + if (spawner == null) spawner = insectSpawner; + if (questManager == null) questManager = quests; + if (regionManager == null) regionManager = region; + if (playerMovement == null) playerMovement = movement; + if (playerTransform == null) playerTransform = player; + if (momentFeed == null) momentFeed = feed; + } + + private void EnsureStateLoaded() + { + string key = AuthManager.ScopedKey(GameConstants.PrefsKeys.FirstShinyGiven); + if (key == prefsKey) return; + prefsKey = key; + given = PlayerPrefs.GetInt(key, 0) != 0; + } + + private void Update() + { + timer -= Time.deltaTime; + if (timer > 0f) return; + timer = CheckInterval; + + EnsureStateLoaded(); + if (given || spawner == null || questManager == null || playerTransform == null) return; + + TutorialQuest active = questManager.ActiveQuest; + if (active == null || !FirstShinyRules.ShouldTrigger(active.questId, questManager.ActiveProgress)) return; + // 대사·포획 창이 떠 있거나 서브에리어(동굴·섬) 안이면 기다린다 — 필드를 걷고 있을 때 일어나야 보인다. + if (playerMovement != null && playerMovement.IsFrozen) return; + if (regionManager != null && regionManager.CurrentSubArea != null) return; + + InsectEntity entity = spawner.TryMakeNearbyShiny(playerTransform.position, MinDistance, MaxDistance); + if (entity == null) return; // 근처에 맞는 곤충이 없다 — 다음 확인에서 다시 본다 + + given = true; + PlayerPrefs.SetInt(prefsKey, 1); + PlayerPrefs.Save(); + + if (momentFeed != null) + { + string name = entity.Data != null ? entity.Data.displayName : "곤충"; + momentFeed.Push(FieldMomentKind.Discovery, "색다른 곤충이 나타났습니다!", + $"근처 어딘가에 반짝이는 {name}이(가) 있습니다 — 찾아서 잡아 보세요"); + } + } + } +} diff --git a/Assets/Scripts/Spawning/FirstShinyEncounter.cs.meta b/Assets/Scripts/Spawning/FirstShinyEncounter.cs.meta new file mode 100644 index 0000000..4ce0c6e --- /dev/null +++ b/Assets/Scripts/Spawning/FirstShinyEncounter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 80ac500a8507c7e43977fd3320f8fec5 \ No newline at end of file diff --git a/Assets/Scripts/Spawning/FleePath.cs b/Assets/Scripts/Spawning/FleePath.cs new file mode 100644 index 0000000..c7abbc7 --- /dev/null +++ b/Assets/Scripts/Spawning/FleePath.cs @@ -0,0 +1,88 @@ +using UnityEngine; + +namespace InsectGame.Spawning +{ + /// + /// 도주 방향 고르기 — 순수 판정부. 실제 장애물 측정(스피어캐스트)은 가 하고 + /// 여기는 "어느 방향을, 얼마나"만 답한다. + /// + /// 예전 도주는 플레이어 반대쪽으로 1.1초간 곧장 최대 8m를 갔다 — 벽·건물·바위·나무 줄기 콜라이더를 + /// 그대로 뚫었고 서브에리어 방 벽도 넘어 방 밖 허공으로 나갔다(곤충은 몸 콜라이더가 없어 물리가 막지 않는다). + /// 이제 반대쪽부터 좌우로 벌려 가며 뚫린 방향을 찾고, 다 막혔으면 가장 멀리 갈 수 있는 쪽으로 장애물 앞까지만 간다. + /// + internal static class FleePath + { + /// + /// 도주 1.1초의 최대 수평 이동(m). 가장 빠른 비행(7.5m/s × 1.1 ≈ 8.3m)을 덮는다 — + /// 기어다님 6.6m, 점프 약 6.7m는 이보다 짧다. + /// + internal const float MaxDistance = 8.5f; + + /// 장애물 앞에서 멈추는 여유(m) — 몸(등급 배율 최대 1.9)이 벽면에 파묻히지 않게. + internal const float WallMargin = 0.6f; + + /// + /// 후보 방향 — 플레이어 반대쪽(0°)에서 좌우로 벌린다. ±110°까지 가면 옆으로 비껴 달아나는 셈이다. + /// 그보다 더 돌면 플레이어 쪽으로 뛰어드는 꼴이라 넣지 않는다. + /// + internal static readonly float[] CandidateAngles = { 0f, 35f, -35f, 70f, -70f, 110f, -110f }; + + /// 한 방향을 잴 때 쏘는 길이 — 끝까지 가도 여유만큼 남아야 뚫린 것으로 친다. + internal static float ProbeLength => MaxDistance + WallMargin; + + /// + /// 번째 후보 방향. 이 −1이면 좌우를 뒤집는다 — + /// 늘 같은 쪽부터 보면 벽 앞의 곤충들이 전부 한쪽으로만 비껴 달아난다. + /// + internal static Vector3 DirectionFor(Vector3 away, int index, float sideSign) + { + away.y = 0f; + if (away.sqrMagnitude < 1e-4f) away = Vector3.forward; + away.Normalize(); + float angle = CandidateAngles[Mathf.Clamp(index, 0, CandidateAngles.Length - 1)] * (sideSign < 0f ? -1f : 1f); + return Quaternion.AngleAxis(angle, Vector3.up) * away; + } + + /// 그 방향으로 갈 수 있는 거리()가 도주 전체를 덮는가. + internal static bool IsClear(float clearance) => clearance >= ProbeLength - 1e-4f; + + /// 장애물까지 m일 때 실제로 갈 거리 — 여유를 빼고 0~최대로 자른다. + internal static float AllowedDistance(float clearance) + => Mathf.Clamp(clearance - WallMargin, 0f, MaxDistance); + + /// + /// 방향을 고른다. 후보를 순서대로 재서 처음 뚫린 방향을 쓰고, 전부 막혔으면 가장 멀리 가는 방향을 쓴다. + /// 는 방향 → 막히지 않고 갈 수 있는 거리(m, 막힘 없으면 이상). + /// + internal static Vector3 Choose(Vector3 away, float sideSign, System.Func clearance, + out float allowedDistance) + { + Vector3 best = DirectionFor(away, 0, sideSign); + float bestClear = -1f; + for (int i = 0; i < CandidateAngles.Length; i++) + { + Vector3 dir = DirectionFor(away, i, sideSign); + float c = clearance != null ? clearance(dir) : ProbeLength; + if (IsClear(c)) + { + allowedDistance = MaxDistance; + return dir; + } + if (c > bestClear) + { + bestClear = c; + best = dir; + } + } + allowedDistance = AllowedDistance(bestClear); + return best; + } + + /// + /// 이번 프레임에 실제로 갈 수평 거리 — 남은 허용 거리( − )를 + /// 넘지 않는다. 다 쓰면 0이라 장애물 앞에 멈춘 채 타이머가 끝나 사라진다. + /// + internal static float StepDistance(float wanted, float allowed, float travelled) + => Mathf.Clamp(wanted, 0f, Mathf.Max(0f, allowed - travelled)); + } +} diff --git a/Assets/Scripts/Spawning/FleePath.cs.meta b/Assets/Scripts/Spawning/FleePath.cs.meta new file mode 100644 index 0000000..6fc354c --- /dev/null +++ b/Assets/Scripts/Spawning/FleePath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0d3b02d962f23eb41a9bec757052f499 \ No newline at end of file diff --git a/Assets/Scripts/Spawning/InsectEntity.cs b/Assets/Scripts/Spawning/InsectEntity.cs index 90f9e18..5d281ff 100644 --- a/Assets/Scripts/Spawning/InsectEntity.cs +++ b/Assets/Scripts/Spawning/InsectEntity.cs @@ -1,4 +1,4 @@ -using System; +using System; using InsectGame.Data; using UnityEngine; @@ -10,9 +10,12 @@ public class InsectEntity : MonoBehaviour [SerializeField] private int level = 1; private Action onDespawn; - private SpawnPoint ownerPoint; + // 소속 리전(필드 곤충). 서브에리어·배틀·수문장 개체는 빈 문자열. + private string regionId = string.Empty; private float bobPhase; private Vector3 basePosition; + // basePosition 자리의 둔덕 윗면(FieldGround.SurfaceY) — 이동 중 높이를 그 자리 지면만큼 오르내리는 기준(GroundRise) + private float baseSurfaceY = Core.FieldGround.FloorY; private float wingPhase; private bool shiny; private bool erased; // 「지워진 개체」 — IsErased 요약 참조 @@ -42,6 +45,9 @@ public class InsectEntity : MonoBehaviour private float alertGraceTimer; // 경계 직후 도주 유예(반응 시간 보장) private Vector3 fleeDir; private float fleeTimer; + // 도주로 갈 수 있는 수평 거리와 지금까지 간 거리 — 장애물 앞에서 멈추게 한다(FleePath 주석). + private float fleeAllowed; + private float fleeTravelled; private bool engaged; // 포획 상호작용 중 — 절대 도주 안 함 // 플레이어 추적(전 곤충 공유, 프레임당 1회 계산) private static Transform cachedPlayer; @@ -51,9 +57,72 @@ public class InsectEntity : MonoBehaviour // 아이템 도주 방지 확률 제공자 — 부트스트랩이 세팅(itemEffects.GetFleePreventChance). null이면 0(방지 없음). // InsectEntity는 풀링 객체라 AutoWire/provider 참조가 없어 static 훅으로 주입. public static System.Func FleePreventChanceProvider; + + // ── 습격(AmbushRules) ── + // 깨어 있는 습격형은 플레이어를 알아채면 달아나는 대신 멈칫 → 다가간다. 경계·도주와 같은 칸(alertState)을 쓴다 — + // 3이 습격이고 IsAlerted가 참이라 스포너가 그동안 거두거나 바꾸지 않는다. CanBeEngaged의 뜻은 그대로다(다가오는 중에도 [E]로 말을 걸 수 있다). + private const int AmbushState = 3; + private bool ambusherSpecies; // 이 종이 습격형인가 — 몸을 지을 때 한 번 정한다(온순한 종은 판정을 묻지도 않는다) + private float ambushReadyTime; // 이 몸이 다시 습격할 수 있는 시각(Time.time) — 개체 쿨다운(AmbushRules.EntityCooldownSeconds) + private float ambushHesitate; // 남은 멈칫(초). 0 이하면 다가가는 중 + private float ambushChaseTime; // 다가간 시간(기다린 시간 제외) + private float ambushReplanTimer; + private float ambushStuckTime; + private float ambushSide = 1f; + private Vector3 ambushDir; + private float ambushLegAllowed; + private float ambushLegTravelled; + private bool ambushAnnounced; + // 풀 더미가 서 있는 자리 — 습격으로 몸이 자리를 옮겨도(RebaseHere) 풀은 처음 자리에 남는다. + private Vector3 grassAnchor; + + // 도주가 나갈 수 없는 구역 — (출발점, 방향) → 그 방향으로 구역 안에서 갈 수 있는 거리(m). null이면 제약 없음(필드). + // 섬 손님 곤충이 쓴다(SetFleeArea): 물리 측정은 벽·건물만 보고 "빈 칸"을 모르므로, 그대로 두면 꽃밭 너머·섬 가장자리 너머로 달아난다. + private Func fleeArea; + + /// + /// 습격 판정 — 이 곤충이 지금 습격할 수 있는가(막혔다면 까닭). CaptureInputController가 세운다. 풀 객체라 AutoWire가 없어 + /// 와 같은 static 훅이다. null이면 습격이 없다 — 모든 곤충이 예전처럼 달아나기만 한다. + /// + public static Func AmbushGate; + + /// 습격형이 멈칫을 끝내고 발을 뗐다 — 필드 경고 문구가 듣는다. + public static event Action AmbushStarted; + + /// + /// 습격형이 플레이어에게 닿았다 — 받는 쪽이 「습격!」 창을 연다(SetEngaged(true)). 아무도 안 열면 이 곤충은 맴돌지 않고 물러난다. + /// + public static event Action AmbushReached; + private bool despawnedThisCycle; // Despawn 다중 호출 가드 (Battle/Capture 동시 호출 시 풀 중복 반환 차단) // 수문장 표식 — 기본은 빈 문자열(야생). 풀 재사용마다 반드시 지운다(GuardianRegionId 주석 참조). private string guardianRegionId = string.Empty; + // 몸을 새로 지을 때마다 오르는 번호 — SpawnSerial 요약 참조. + private int spawnSerial; + private static int nextSpawnSerial; + + /// + /// 필드 곤충이 「색다른 개체」로 나올 확률. 스포너가 슬롯에 개체를 들일 때 한 번 굴려 기록한다 — + /// 몸을 다시 세울 때마다 굴리면 멀어졌다 돌아온 같은 곤충의 색이 바뀐다. (gacha_sim이 이 상수를 읽는다.) + /// + internal const float FieldShinyChance = 0.01f; + + // 도주 경로 측정 — 도주를 시작할 때만 쏜다(매 프레임이 아니다). 전 곤충이 한 스레드에서 번갈아 쓰므로 정적 버퍼 하나로 족하다. + /// + /// 이번 퇴장이 놓침 도주의 끝인가( 직전에 선다). 스포너가 본다 — 도주는 개체가 죽은 게 아니라 + /// 달아난 것이라, 자리를 비우고 1~2분 기다리는 대신 같은 개체를 플레이어 눈 밖 다른 자리로 옮긴다. + /// + internal bool Fled => fled; + private bool fled; + + // NonAlloc 결과는 거리순이 아니다 — 버퍼가 차면 가장 가까운 벽이 빠졌을 수 있어 그 방향은 막힌 것으로 친다. + private static readonly RaycastHit[] fleeProbeHits = new RaycastHit[32]; + private static Vector3 fleeProbeOrigin; + private static Func fleeClearanceProbe; + private static Func approachClearanceProbe; // 습격 접근용(울타리 층 포함) — 한 번만 묶는다 + /// 도주 경로 측정 높이(발 위 m)와 굵기 — 낮은 풀·돌턱은 넘고 벽·줄기·바위 옆면에는 걸린다. + private const float FleeProbeHeight = 0.6f; + private const float FleeProbeRadius = 0.3f; // Camera.main은 매 호출마다 FindGameObjectWithTag — 최대 20마리×매 프레임 핫패스 회피. private static Camera cachedMainCam; @@ -101,17 +170,39 @@ public class InsectEntity : MonoBehaviour /// public bool CanBeEngaged => (!forBattle || IsGuardian) && !engaged && alertState != 2 && !despawnedThisCycle; - public SpawnPoint OwnerPoint => ownerPoint; - public string RegionId => ownerPoint != null ? ownerPoint.regionId : string.Empty; + /// 소속 리전 ID(필드 곤충). 서브에리어·배틀·수문장 개체는 빈 문자열. + public string RegionId => regionId; + + /// 포획·전투에 붙잡혀 있는가. 스포너는 이 개체를 거두거나 바꾸지 않는다. + public bool IsEngaged => engaged; + + /// 플레이어를 알아챘거나(경계) 달아나는 중인가. 스포너가 수명 교체를 미룬다. + public bool IsAlerted => alertState != 0; + + /// + /// 습격 중인가(멈칫해 노려보거나 다가오는 중). 연출(경계 포즈·붉은 기운)이 읽을 자리다 — 모양·색은 visual-dev 영역이라 여기서 칠하지 않는다. + /// + public bool IsAmbushing => alertState == AmbushState; + + /// 이 몸의 습격 쿨다운이 끝날 때까지 남은 시간(초, 0 이상). + public float AmbushCooldownLeft => AmbushRules.Remaining(Time.time, ambushReadyTime); + + /// + /// 몸을 지을 때마다(· + /// ) 새로 붙는 번호. 풀 객체는 같은 참조가 다른 개체로 되살아나므로, + /// 참조를 쥔 쪽(지도 레이드 마커)이 "아직 그 개체인가"를 이걸로 묻는다 — 활성 여부만 보면 + /// 한 프레임 안에 거뒀다 다시 꺼낸 몸을 옛 개체로 착각한다. + /// + public int SpawnSerial => spawnSerial; /// /// 이 개체가 어느 리전의 수문장인가. 수문장이 아니면 빈 문자열이다. /// /// 왜 좌표가 아니라 정체성인가. 예전엔 격파 판정이 "수문장 자리에서 15m 안이었나"를 - /// 봤는데, 그 반경은 야생 스폰이 그대로 들어온다 — InsectSpawner.RelocateSpawnPoints가 - /// 현재 리전 포인트를 플레이어로부터 10~43m 나선 위로 끌어오고, 거기서 다시 - /// SpawnPoint.radius(5m)만큼 흩어진다. 최근접 스폰이 플레이어에서 5m다. - /// 수문장과 싸우려면 그 앞에 서야 하니 야생이 반경 안에 들어오는 건 우연이 아니라 구조고, + /// 봤는데, 그 반경은 야생 스폰이 그대로 들어왔다 — 당시 스포너는 현재 리전 스폰 포인트를 + /// 플레이어로부터 10~43m 나선 위로 끌어오고 거기서 다시 5m만큼 흩었다. 최근접 스폰이 플레이어에서 5m였다. + /// 지금은 야생이 리전 원판 전체에 흩어져 기록되지만(FieldPopulation) 수문장 앞이라고 비켜 두지 않으니 + /// 야생이 반경 안에 들어오는 건 여전히 구조고, /// 13곳 중 9곳은 수문장 종이 자기 리전 야생 풀에도 있어 종·레벨 조건까지 함께 맞는다. /// /// 그래서 "그 자리였나"가 아니라 "바로 그 개체였나"를 묻는다. 수문장은 @@ -128,16 +219,35 @@ public void MarkAsGuardian(string regionId) guardianRegionId = string.IsNullOrEmpty(regionId) ? string.Empty : regionId; } + /// + /// 색다름·지워짐을 여기서 굴려 세운다 — 기록 없이 한 번 쓰고 마는 개체용(옛 진입점, 테스트). + /// 필드 곤충은 스포너가 슬롯에 굴려 둔 값을 넘기는 아래 오버로드를 쓴다. + /// public void Initialize(InsectData insectData, int insectLevel, SpawnPoint point, Action despawnCallback, float erasedChance = 0f) + { + bool rolledShiny = UnityEngine.Random.value < FieldShinyChance; + // 지워진 개체 — 확률은 스폰너가 리전에서 정해 넘긴다(여기에 리전 목록을 두지 않는다). + bool rolledErased = erasedChance > 0f && UnityEngine.Random.value < erasedChance; + Initialize(insectData, insectLevel, point != null ? point.regionId : null, despawnCallback, + rolledShiny, rolledErased); + } + + /// + /// 기록된 개체를 그대로 세운다 — 종·레벨·색다름·지워짐을 굴리지 않는다. 스포너가 슬롯(FieldSlot)에 + /// 적어 둔 값을 넘기므로, 멀어졌다 돌아와 몸을 다시 세워도 같은 곤충이다. 자리는 호출 전에 옮겨 둔다 + /// (transform.position이 곧 배회·풀 더미의 기준 basePosition이 된다). + /// + public void Initialize(InsectData insectData, int insectLevel, string homeRegionId, + Action despawnCallback, bool isShiny, bool isErased) { data = insectData; level = insectLevel; - ownerPoint = point; + regionId = homeRegionId ?? string.Empty; onDespawn = despawnCallback; - shiny = UnityEngine.Random.value < 0.01f; // 1% 확률 색다른 곤충 - // 지워진 개체 — 확률은 스폰너가 리전에서 정해 넘긴다(여기에 리전 목록을 두지 않는다). - erased = erasedChance > 0f && UnityEngine.Random.value < erasedChance; + shiny = isShiny; + erased = isErased; + spawnSerial = ++nextSpawnSerial; // 풀 재사용 회귀 방지: BuildForBattle에서 true로 설정된 forBattle이 남아있으면 // 다음 Update에서 회전 안 하는 정적 곤충이 됨. 매 Initialize마다 명시적 false. forBattle = false; @@ -154,9 +264,16 @@ public void Initialize(InsectData insectData, int insectLevel, SpawnPoint point, nameLabelResolved = false; alertState = 0; fleeTimer = 0f; + fleeAllowed = 0f; + fleeTravelled = 0f; engaged = false; despawnedThisCycle = false; + fled = false; guardianRegionId = string.Empty; // 풀에서 왔다면 직전 개체의 표식을 물려받지 않는다 + // 습격 — 풀에서 왔다면 직전 개체의 쿨다운·쫓기를 물려받지 않는다. 종이 습격형인지는 여기서 한 번만 본다. + ResetAmbush(); + ambusherSpecies = data != null && InsectHabits.For(data).Temperament == InsectTemperament.Ambusher; + fleeArea = null; // 풀 재사용 — 섬 손님이던 몸이 필드에서 섬 경계를 물려받지 않게 ClearChildren(); BuildModel(); @@ -166,6 +283,8 @@ public void Initialize(InsectData insectData, int insectLevel, SpawnPoint point, float scale = GetRarityScale(); transform.localScale = Vector3.one * scale; basePosition = transform.position; + grassAnchor = basePosition; + baseSurfaceY = Core.FieldGround.SurfaceY(basePosition.x, basePosition.z); bobPhase = UnityEngine.Random.Range(0f, Mathf.PI * 2f); wingPhase = UnityEngine.Random.Range(0f, Mathf.PI * 2f); } @@ -179,6 +298,8 @@ public void BuildForBattle(InsectData insectData, int insectLevel, bool shinyOve // 풀 재사용 회귀 방지 — 명시하지 않으면 직전 개체의 erased가 남아 도감 프리뷰까지 검게 나온다. erased = erasedOverride; forBattle = true; + regionId = string.Empty; + spawnSerial = ++nextSpawnSerial; cachedNameLabel = null; cachedShinySparkle = null; cachedShinyShift = -1f; @@ -191,13 +312,20 @@ public void BuildForBattle(InsectData insectData, int insectLevel, bool shinyOve nameLabelResolved = false; alertState = 0; fleeTimer = 0f; + fleeAllowed = 0f; + fleeTravelled = 0f; engaged = false; despawnedThisCycle = false; + fled = false; guardianRegionId = string.Empty; // 풀에서 왔다면 직전 개체의 표식을 물려받지 않는다 + ResetAmbush(); + ambusherSpecies = false; // 아레나·전시·수문장 몸은 덤벼들지 않는다 + fleeArea = null; ClearChildren(); BuildModel(); basePosition = transform.position; + grassAnchor = basePosition; bobPhase = UnityEngine.Random.Range(0f, Mathf.PI * 2f); wingPhase = UnityEngine.Random.Range(0f, Mathf.PI * 2f); } @@ -247,19 +375,18 @@ private void Update() private void UpdateMovement() { float t = Time.time; - if (forBattle) - { - // 배틀: 전투 포즈 유지 — 가벼운 상하만(회전·드리프트·경계 없음) - float bs = 1.6f + (bobPhase % 1.5f); - transform.position = basePosition + new Vector3(0f, Mathf.Sin(t * bs + bobPhase) * 0.25f, 0f); - return; - } + // The arena owns battle root translation and rotation (lunge, recoil, + // grounded idle). Wing animation still runs separately in Update. + if (forBattle) return; EnsureMoveStyle(); UpdatePlayerTracking(); float dt = Time.deltaTime; // ===== 도주 진행 (이동 방식별로 다른 도주 모션) ===== + // 도주는 1.1초에 최대 8m를 간다 — 높이를 스폰 자리(basePosition.y)에 묶어 두면 사구·재 더미를 지날 때 몸이 + // 둔덕 속에 묻히고, 둔덕 위에서 내려오면 허공에 뜬다. 세 모션 모두 지금 자리 지면만큼 오르내린다(GroundRise). + // 수평으로는 도주를 시작할 때 잰 거리(fleeAllowed)까지만 간다 — 벽·건물·줄기를 뚫지 않고 그 앞에 멈춘다(BeginFlee). if (alertState == 2) { fleeTimer -= dt; @@ -267,8 +394,8 @@ private void UpdateMovement() if (cachedMoveStyle == 1) { // 비행: 날개로 날아오르며 멀어짐 — 점점 고도 상승(하늘로 사라짐) - Vector3 p = transform.position + fleeDir * 7.5f * dt; - p.y = basePosition.y + 0.55f + elapsed * 2.8f; + Vector3 p = transform.position + FleeStep(7.5f * dt); + p.y = basePosition.y + GroundRise(baseSurfaceY, p.x, p.z) + 0.55f + elapsed * 2.8f; transform.position = p; FaceFlee(dt, 8f); } @@ -277,21 +404,28 @@ private void UpdateMovement() // 점프: 큰 포물선 도약으로 튀어 달아남 — 공중에 뜬 동안 더 멀리, 착지 땐 멈칫 float ph = (elapsed % 0.45f) / 0.45f; float hop = Mathf.Sin(ph * Mathf.PI); - Vector3 p = transform.position + fleeDir * (6.5f * (0.3f + hop)) * dt; - p.y = basePosition.y + hop * 0.75f; + Vector3 p = transform.position + FleeStep(6.5f * (0.3f + hop) * dt); + p.y = basePosition.y + GroundRise(baseSurfaceY, p.x, p.z) + hop * 0.75f; transform.position = p; FaceFlee(dt, 11f); } else { // 기어다님: 지면에 낮게 빠르게 허둥지둥 - Vector3 p = transform.position + fleeDir * 6.0f * dt; - p.y = basePosition.y + 0.05f + Mathf.Abs(Mathf.Sin(elapsed * 24f)) * 0.07f; + Vector3 p = transform.position + FleeStep(6.0f * dt); + p.y = basePosition.y + GroundRise(baseSurfaceY, p.x, p.z) + 0.05f + Mathf.Abs(Mathf.Sin(elapsed * 24f)) * 0.07f; transform.position = p; FaceFlee(dt, 12f); } AnchorGrass(); - if (fleeTimer <= 0f) Despawn(); // 놓침 — 사라짐 + if (fleeTimer <= 0f) { fled = true; Despawn(); } // 놓침 — 눈앞에서 사라짐(스포너가 개체를 다른 자리로 옮긴다) + return; + } + + // ===== 습격 진행 — 멈칫 → 다가가기(AmbushRules). 붙잡히면(engaged) 아래 경계 분기가 받는다 ===== + if (alertState == AmbushState && !engaged) + { + UpdateAmbush(dt, t); return; } @@ -305,6 +439,12 @@ private void UpdateMovement() { alertState = 1; // 포획 중 — 경계 포즈 유지, 도주 분기 진입 안 함 } + else if (ambusherSpecies && dist < AmbushRules.NoticeRadius && TryBeginAmbush()) + { + // 깨어 있는 습격형 — 달아나지 않고 덤벼든다. 판정이 막으면(잠잠·쿨다운·싸울 곤충 없음 등) 아래로 내려가 온순한 곤충처럼 군다. + UpdateAmbush(dt, t); + return; + } else if (dist < alertR) { if (alertState != 1) { alertState = 1; patience = 2.6f - skit * 0.95f; alertGraceTimer = 0.5f; } @@ -323,10 +463,7 @@ private void UpdateMovement() } else { - alertState = 2; - Vector3 away = transform.position - cachedPlayer.position; away.y = 0f; - fleeDir = away.sqrMagnitude > 0.01f ? away.normalized : Vector3.forward; - fleeTimer = 1.1f; + BeginFlee(transform.position - cachedPlayer.position); return; } } @@ -380,14 +517,29 @@ private void UpdateMovement() rotSpeed = 8f; } - transform.position = basePosition + offset; + Vector3 pos = basePosition + offset; + // 수평으로 떠도는 건 비행 드리프트(±0.55m)·경계 떨림뿐이다 — 재 더미 가장자리(턱 0.33m)에서는 그만큼으로도 + // 둔덕 속을 드나든다. 제자리 모션(기어다님·점프)은 xz가 0이라 둔덕 조회를 건너뛴다. + if (offset.x != 0f || offset.z != 0f) + pos.y += GroundRise(baseSurfaceY, pos.x, pos.z); + transform.position = pos; if (rotSpeed > 0f) transform.Rotate(Vector3.up, rotSpeed * Time.deltaTime, Space.World); + // 오르내린 지면만큼은 마커도 따라간다 — 상쇄는 모션 높이(offset.y)만(마커는 발밑 지면에 붙는다) AnchorGroundMarker(offset.y); AnchorGrass(); } + /// + /// (x, z) 지면이 스폰 자리 지면()보다 얼마나 높은가(m, 음수면 낮다). 둔덕은 콜라이더가 + /// 없어 에 묻는다 — 스폰(InsectSpawner.PickSpawnPosition)과 같은 지면이다. + /// 서브에리어는 (2000,·,2000) 너머라 둔덕이 없어 늘 0이다. 스폰 자리가 둔덕 밖 콜라이더(물가 바위 등) 위였다면 + /// 그 높이는 옛날처럼 이동 내내 유지된다 — 콜라이더를 매 프레임 쏘지 않는다. + /// + internal static float GroundRise(float baseSurfaceY, float x, float z) + => Core.FieldGround.SurfaceY(x, z) - baseSurfaceY; + // 이동 스타일 1회 판정(캐시): grasshopper/cricket/katydid=점프, WingL 보유=비행, 그 외=일반. // 지상 곤충(기어다님/점프)은 풀숲 은신 더미 생성(비행 곤충은 공중이라 제외). private void EnsureMoveStyle() @@ -408,8 +560,14 @@ private void EnsureMoveStyle() // 진입 시 인내심·유예 리셋 → 포획 취소 직후 즉시 도망가지 않게(관대). public void SetEngaged(bool value) { + bool wasEngaged = engaged; + // 다가오던 몸이 붙잡혔다(습격 창·[E]·아이 NPC) — 지금 자리를 기준으로 삼는다. 안 그러면 경계 포즈(basePosition 기준)가 + // 몸을 스폰 자리로 순간이동시킨다(창 뒤에서, 그리고 전투 아레나가 그 자리를 적 위치로 읽는다). + if (value && alertState == AmbushState) RebaseHere(); engaged = value; if (value) { alertState = 1; patience = 2.6f; alertGraceTimer = 0.6f; } + // 풀려났다(포획 창 취소·습격 도망 등) — 한동안 덤벼들지 않는다. 창을 닫자마자 바로 옆에서 다시 닿지 않게. + else if (wasEngaged) ambushReadyTime = Mathf.Max(ambushReadyTime, Time.time + AmbushRules.EntityCooldownSeconds); } public void ScareAway() @@ -417,13 +575,277 @@ public void ScareAway() if (!CanBeEngaged) return; UpdatePlayerTracking(); - alertState = 2; - Vector3 away = cachedPlayer != null + BeginFlee(cachedPlayer != null ? transform.position - cachedPlayer.position - : transform.forward; - away.y = 0f; - fleeDir = away.sqrMagnitude > 0.01f ? away.normalized : Vector3.forward; + : transform.forward); + } + + /// + /// 도주를 시작한다 — 도주가 시작되는 두 곳(인내 소진·)이 함께 쓴다. + /// + /// 플레이어 반대쪽부터 좌우로 벌려 가며 몸 높이에서 장애물을 재고() 뚫린 방향을 고른다. + /// 다 막혔으면 가장 멀리 가는 쪽으로 장애물 앞까지만 간다. 곤충은 몸 콜라이더가 없어 물리가 막아 주지 + /// 않으므로 여기서 재지 않으면 벽·건물·바위·나무 줄기, 서브에리어 방 벽을 그대로 뚫고 나간다. + /// 측정은 도주 시작 때 한 번이다(최대 7방향) — 매 프레임 쏘지 않는다. + /// + private void BeginFlee(Vector3 away) + { + alertState = 2; fleeTimer = 1.1f; + fleeTravelled = 0f; + away.y = 0f; + if (away.sqrMagnitude <= 0.01f) away = Vector3.forward; + + if (fleeClearanceProbe == null) fleeClearanceProbe = MeasureFleeClearance; + fleeProbeOrigin = new Vector3(transform.position.x, basePosition.y + FleeProbeHeight, transform.position.z); + float side = UnityEngine.Random.value < 0.5f ? -1f : 1f; + Func probe = fleeClearanceProbe; + if (fleeArea != null) + { + // 구역이 정해진 몸(섬 손님) — 벽까지의 거리와 구역 끝까지의 거리 중 짧은 쪽. 도주를 시작할 때만 만든다. + Func area = fleeArea; + Vector3 origin = fleeProbeOrigin; + probe = dir => Mathf.Min(MeasureFleeClearance(dir), area(origin, dir)); + } + fleeDir = FleePath.Choose(away, side, probe, out fleeAllowed); + } + + /// + /// 도주가 이 구역 밖으로 나가지 않게 한다 — 은 (출발점, 방향) → 그 방향으로 구역 안에서 갈 수 있는 + /// 거리(m). 나의 섬 손님 곤충이 "빈 칸 위에서만" 달아나게 쓴다(물리 측정은 벽·건물만 본다). 와 + /// 이 지우므로 몸을 세운 뒤에 부를 것. + /// + public void SetFleeArea(Func areaRun) + { + fleeArea = areaRun; + } + + /// + /// fleeProbeOrigin에서 로 막히지 않고 갈 수 있는 거리(m). 트리거(NPC 몸통·줍기 구)는 + /// 장애물이 아니고, 곤충·플레이어 몸도 아니다. 위를 향한 면(바닥·바위 윗면)은 올라타는 곳이지 벽이 아니다. + /// + private static float MeasureFleeClearance(Vector3 dir) => MeasureClearance(dir, Physics.DefaultRaycastLayers); + + /// + /// 습격 접근용 — 도주 측정과 같되 Ignore Raycast 층도 본다. 울타리 난간 차단(RegionTerrainBuilder.AddRailBlocker)이 + /// 카메라·탭 레이를 피하려고 그 층에 있어서, 기본 층만 쏘면 플레이어를 향해 울타리를 뚫고 온다. 플레이어 몸도 그 층이지만 아래에서 거른다. + /// + private static float MeasureApproachClearance(Vector3 dir) => MeasureClearance(dir, Physics.AllLayers); + + private static float MeasureClearance(Vector3 dir, int layerMask) + { + float length = FleePath.ProbeLength; + int n = Physics.SphereCastNonAlloc(fleeProbeOrigin, FleeProbeRadius, dir, fleeProbeHits, length, + layerMask, QueryTriggerInteraction.Ignore); + if (n >= fleeProbeHits.Length) return 0f; // 버퍼가 찼다 — 빠진 충돌 중 벽이 있을 수 있다 + float nearest = length; + for (int i = 0; i < n; i++) + { + RaycastHit hit = fleeProbeHits[i]; + Collider c = hit.collider; + if (c == null) continue; + if (c.GetComponentInParent() != null) continue; + if (c.GetComponentInParent() != null) continue; + // 시작부터 겹친 콜라이더는 거리 0으로 온다 — 그 방향은 막힌 것이다(법선을 믿지 않는다). + if (hit.distance > 0f && hit.normal.y > 0.7f) continue; + if (hit.distance < nearest) nearest = hit.distance; + } + return nearest; + } + + /// 이번 프레임 도주 이동 — 시작 때 잰 허용 거리를 넘지 않는다(장애물 앞에서 멈춤). + private Vector3 FleeStep(float wanted) + { + float step = FleePath.StepDistance(wanted, fleeAllowed, fleeTravelled); + fleeTravelled += step; + return fleeDir * step; + } + + // ── 습격(AmbushRules) ────────────────────────────────────────────── + + private void ResetAmbush() + { + ambushReadyTime = 0f; + ambushHesitate = 0f; + ambushChaseTime = 0f; + ambushReplanTimer = 0f; + ambushStuckTime = 0f; + ambushLegAllowed = 0f; + ambushLegTravelled = 0f; + ambushAnnounced = false; + } + + /// + /// 깨어 있는 습격형이 플레이어를 알아챘다 — 판정()이 허가하면 습격을 시작한다(멈칫부터). + /// 막히면 false — 호출부가 온순한 곤충의 경계·도주로 내려간다. + /// + private bool TryBeginAmbush() + { + if (forBattle || IsGuardian || despawnedThisCycle || cachedPlayer == null) return false; + if (Time.time < ambushReadyTime) return false; // 판정도 보지만, 묻기 전에 싸게 거른다 + if (AmbushGate == null || AmbushGate(this) != AmbushRefusal.None) return false; + + alertState = AmbushState; + ambushHesitate = AmbushRules.HesitateSeconds; + ambushChaseTime = 0f; + ambushReplanTimer = 0f; + ambushStuckTime = 0f; + ambushLegAllowed = 0f; + ambushLegTravelled = 0f; + ambushAnnounced = false; + // 한 습격 동안 같은 쪽으로 돌아간다 — 벽 앞에서 좌우로 흔들리지 않게(늘 같은 쪽이면 곤충들이 한쪽으로만 돈다). + ambushSide = UnityEngine.Random.value < 0.5f ? -1f : 1f; + return true; + } + + /// + /// 습격 한 프레임 — 판정을 다시 묻고(대화·메뉴면 기다리고, 그 밖에 막히면 물러난다) 멈칫 → 다가가기 → 닿기. + /// 다가가는 길은 마다 몸 높이에서 장애물을 재어 고른다 — 곤충은 몸 콜라이더가 없어 + /// 물리가 막아 주지 않으므로, 재지 않으면 벽·건물·바위·울타리를 그대로 뚫고 온다(도주와 같은 이유). + /// + private void UpdateAmbush(float dt, float t) + { + if (cachedPlayer == null) { GiveUpAmbush(); return; } + AmbushRefusal gate = AmbushGate != null ? AmbushGate(this) : AmbushRefusal.NotAwake; + if (gate != AmbushRefusal.None) + { + if (AmbushRules.IsPauseOnly(gate)) HoldAmbushPose(dt, t); // 대화·메뉴 — 그 자리에서 노려보며 기다린다 + else GiveUpAmbush(); + return; + } + + Vector3 toPlayer = cachedPlayer.position - transform.position; + toPlayer.y = 0f; + float planar = toPlayer.magnitude; + + if (ambushHesitate > 0f) + { + ambushHesitate -= dt; + HoldAmbushPose(dt, t); + if (AmbushRules.HasReached(planar)) ReachPlayer(); // 멈칫하는 사이 플레이어가 먼저 다가와 닿았다 + return; + } + + if (!ambushAnnounced) + { + ambushAnnounced = true; + RaiseAmbushEvent(AmbushStarted); + if (engaged || alertState != AmbushState) return; // 듣는 쪽이 이 곤충을 붙잡았다 + } + + if (AmbushRules.HasReached(planar)) { ReachPlayer(); return; } + + ambushChaseTime += dt; + if (AmbushRules.ShouldGiveUp(planar, ambushChaseTime)) { GiveUpAmbush(); return; } + + // 길은 일정 간격으로만 다시 잰다. 걸음을 다 써도 바로 재지 않는다 — 막혀서 조금씩만 열리는 곳에서 매 프레임 쏘지 않게. + ambushReplanTimer -= dt; + if (ambushReplanTimer <= 0f) + { + PlanAmbushLeg(toPlayer, planar); + ambushReplanTimer = AmbushRules.ReplanSeconds; + } + + float step = FleePath.StepDistance(AmbushRules.ApproachSpeed * dt, ambushLegAllowed, ambushLegTravelled); + ambushLegTravelled += step; + if (step <= 1e-4f) + { + ambushStuckTime += dt; + if (ambushStuckTime > AmbushRules.StuckGiveUpSeconds) { GiveUpAmbush(); return; } + } + else + { + ambushStuckTime = 0f; + } + + Vector3 p = transform.position + ambushDir * step; + float hover = AmbushMotionHeight(t, true); + p.y = basePosition.y + GroundRise(baseSurfaceY, p.x, p.z) + hover; + transform.position = p; + if (ambushDir.sqrMagnitude > 0.01f) + transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(ambushDir), dt * 10f); + AnchorGroundMarker(hover); + AnchorGrass(); + } + + /// 이번 걸음의 방향과 거리를 잰다 — 몸 높이(지금 자리 지면 + )에서 플레이어 쪽부터. + private void PlanAmbushLeg(Vector3 toPlayer, float planar) + { + if (approachClearanceProbe == null) approachClearanceProbe = MeasureApproachClearance; + Vector3 pos = transform.position; + fleeProbeOrigin = new Vector3(pos.x, basePosition.y + GroundRise(baseSurfaceY, pos.x, pos.z) + FleeProbeHeight, pos.z); + ambushDir = AmbushRules.ChooseApproach(toPlayer, planar, ambushSide, approachClearanceProbe, out ambushLegAllowed); + ambushLegTravelled = 0f; + } + + /// + /// 멈칫·기다림 — 지금 자리에서 고개를 들고 떨며 플레이어를 노려본다. 높이는 경계 포즈와 같고, 기준은 스폰 자리가 아니라 + /// 지금 자리다(다가오던 도중에 멈춰도 제자리로 튀지 않는다). + /// + private void HoldAmbushPose(float dt, float t) + { + Vector3 p = transform.position; + float hover = AmbushMotionHeight(t, false); + p.y = basePosition.y + GroundRise(baseSurfaceY, p.x, p.z) + hover + Mathf.Sin(t * 27f) * 0.02f; + transform.position = p; + if (cachedPlayer != null) + { + Vector3 look = cachedPlayer.position - p; + look.y = 0f; + if (look.sqrMagnitude > 0.01f) + transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(look), dt * 8f); + } + AnchorGroundMarker(hover); + AnchorGrass(); + } + + /// 습격 중 몸 높이(지면 위 m). 노려볼 때는 경계 포즈 높이, 다가올 때는 날것은 떠서·뛰는 것은 뛰며·기는 것은 낮게 빠르게. + private float AmbushMotionHeight(float t, bool approaching) + { + if (cachedMoveStyle == 1) return 0.55f + Mathf.Sin(t * 5f) * (approaching ? 0.12f : 0.18f); + if (!approaching) return 0.34f + Mathf.Abs(Mathf.Sin(t * 6f)) * 0.06f; + if (cachedMoveStyle == 2) return Mathf.Sin(((t % 0.45f) / 0.45f) * Mathf.PI) * 0.6f; + return 0.06f + Mathf.Abs(Mathf.Sin(t * 20f)) * 0.06f; + } + + /// + /// 닿았다 — 듣는 쪽이 「습격!」 창을 열면 로 경계 상태가 된다. 아무도 안 열었으면(창이 다른 일로 막혔다) + /// 닿은 채 매 프레임 다시 쏘지 않도록 물러난다. + /// + private void ReachPlayer() + { + RaiseAmbushEvent(AmbushReached); + if (!engaged && alertState == AmbushState) GiveUpAmbush(); + } + + /// + /// 쫓기를 접고 물러난다 — 놓침 도주와 같은 길이다(스포너가 같은 개체를 플레이어 눈 밖 다른 자리로 옮긴다). 다가오며 스폰 자리를 + /// 떠난 몸을 제자리로 순간이동시키지 않고 치우는 가장 단순한 길이다. 몸에는 쿨다운을 걸어 둔다. + /// + private void GiveUpAmbush() + { + ambushReadyTime = Mathf.Max(ambushReadyTime, Time.time + AmbushRules.EntityCooldownSeconds); + alertState = 1; + BeginFlee(cachedPlayer != null ? transform.position - cachedPlayer.position : transform.forward); + } + + /// + /// 지금 자리를 배회·경계 포즈의 기준으로 삼는다 — 습격으로 스폰 자리를 떠난 몸이 붙잡혔을 때. 높이 기준도 지금 자리 지면으로 옮긴다 + /// (가 새 자리에서 0이 되게). 풀 더미는 처음 자리(grassAnchor)에 남는다. + /// + private void RebaseHere() + { + Vector3 p = transform.position; + basePosition = new Vector3(p.x, basePosition.y + GroundRise(baseSurfaceY, p.x, p.z), p.z); + baseSurfaceY = Core.FieldGround.SurfaceY(p.x, p.z); + } + + private void RaiseAmbushEvent(Action handler) + { + if (handler == null) return; + // 듣는 쪽이 던져도 이 곤충의 Update가 매 프레임 같은 예외로 멈추지 않게 — 닿기는 물러나기로 정리된다. + try { handler(this); } + catch (Exception ex) { Debug.LogException(ex); } } // 플레이어 위치/속도 추적 — 프레임당 1회만 계산(전 곤충 공유). @@ -484,7 +906,7 @@ private void FaceFlee(float dt, float turnSpeed) private void AnchorGrass() { if (cachedGrass == null) return; - cachedGrass.position = basePosition; + cachedGrass.position = grassAnchor; cachedGrass.rotation = Quaternion.identity; } @@ -672,6 +1094,8 @@ private void BuildModel() BuildClickBeetle(col, dark); else BuildGenericBeetle(col, dark); + + BindWingSurfaces(); } private void BuildGenericBeetle(Color body, Color dark) @@ -685,10 +1109,6 @@ private void BuildGenericBeetle(Color body, Color dark) MakePart("Prothorax", PrimitiveType.Sphere, new Vector3(0f, 0.12f, 0.32f), new Vector3(0.5f, 0.34f, 0.32f), dark); MakePart("Head", PrimitiveType.Sphere, new Vector3(0f, 0.05f, 0.56f), new Vector3(0.46f, 0.42f, 0.42f), dark); MakeEyes(0.68f, 0.13f); - MakePart("FrontLegL", PrimitiveType.Capsule, new Vector3(-0.28f, -0.15f, 0.3f), new Vector3(0.06f, 0.22f, 0.06f), - dark, Quaternion.Euler(0f, 0f, 25f)); - MakePart("FrontLegR", PrimitiveType.Capsule, new Vector3(0.28f, -0.15f, 0.3f), new Vector3(0.06f, 0.22f, 0.06f), - dark, Quaternion.Euler(0f, 0f, -25f)); MakeLegs(dark, 3, 0f); MakeAntennae(dark, 0.45f); } @@ -718,49 +1138,39 @@ private void BuildStagBeetle(Color body, Color dark) Color jaw = new Color(dark.r * 0.85f + 0.04f, dark.g * 0.72f + 0.03f, dark.b * 0.6f + 0.03f); MakePart("Body", PrimitiveType.Sphere, Vector3.zero, new Vector3(0.78f, 0.46f, 1.0f), body); MakeTopGloss(Vector3.zero, new Vector3(0.78f, 0.46f, 1.0f), 0.12f); - MakePart("Shell", PrimitiveType.Sphere, new Vector3(0f, 0.18f, -0.08f), new Vector3(0.72f, 0.3f, 0.86f), dark); - MakePart("ShellLineL", PrimitiveType.Cylinder, new Vector3(-0.14f, 0.26f, -0.08f), new Vector3(0.015f, 0.01f, 0.7f), body); - MakePart("ShellLineR", PrimitiveType.Cylinder, new Vector3(0.14f, 0.26f, -0.08f), new Vector3(0.015f, 0.01f, 0.7f), body); + MakeSculpture("ShellL", InsectSculptureMeshes.Shape.ElytronLeft, body); + MakeSculpture("ShellR", InsectSculptureMeshes.Shape.ElytronRight, body); // 각진 전흉(pronotum) — 사슴벌레 특유의 넓적한 가슴판 MakePart("Pronotum", PrimitiveType.Sphere, new Vector3(0f, 0.14f, 0.42f), new Vector3(0.62f, 0.3f, 0.4f), dark); MakePart("Head", PrimitiveType.Sphere, new Vector3(0f, 0.1f, 0.68f), new Vector3(0.42f, 0.32f, 0.4f), dark); - // === 큰 집게턱 (좌우 대칭, 3분절 곡선) === - MakePart("MandBaseL", PrimitiveType.Capsule, new Vector3(-0.17f, 0.12f, 0.86f), new Vector3(0.06f, 0.17f, 0.06f), - jaw, Quaternion.Euler(72f, 0f, 26f)); - MakePart("MandBaseR", PrimitiveType.Capsule, new Vector3(0.17f, 0.12f, 0.86f), new Vector3(0.06f, 0.17f, 0.06f), - jaw, Quaternion.Euler(72f, 0f, -26f)); - MakePart("MandMidL", PrimitiveType.Capsule, new Vector3(-0.29f, 0.13f, 1.06f), new Vector3(0.05f, 0.15f, 0.05f), - jaw, Quaternion.Euler(82f, 0f, 44f)); - MakePart("MandMidR", PrimitiveType.Capsule, new Vector3(0.29f, 0.13f, 1.06f), new Vector3(0.05f, 0.15f, 0.05f), - jaw, Quaternion.Euler(82f, 0f, -44f)); - // 안쪽 돌기(이빨) — 사슴벌레 턱 안쪽의 톱니 - MakePart("MandToothL", PrimitiveType.Capsule, new Vector3(-0.2f, 0.13f, 1.12f), new Vector3(0.03f, 0.08f, 0.03f), - jaw, Quaternion.Euler(90f, 0f, -54f)); - MakePart("MandToothR", PrimitiveType.Capsule, new Vector3(0.2f, 0.13f, 1.12f), new Vector3(0.03f, 0.08f, 0.03f), - jaw, Quaternion.Euler(90f, 0f, 54f)); - // 끝 — 안쪽으로 굽어 마주봄 - MakePart("MandTipL", PrimitiveType.Capsule, new Vector3(-0.16f, 0.14f, 1.26f), new Vector3(0.04f, 0.13f, 0.04f), - jaw, Quaternion.Euler(96f, 0f, 72f)); - MakePart("MandTipR", PrimitiveType.Capsule, new Vector3(0.16f, 0.14f, 1.26f), new Vector3(0.04f, 0.13f, 0.04f), - jaw, Quaternion.Euler(96f, 0f, -72f)); - MakePart("MandPointL", PrimitiveType.Sphere, new Vector3(-0.07f, 0.14f, 1.34f), Vector3.one * 0.04f, jaw); - MakePart("MandPointR", PrimitiveType.Sphere, new Vector3(0.07f, 0.14f, 1.34f), Vector3.one * 0.04f, jaw); - MakePart("ClawL", PrimitiveType.Cube, new Vector3(-0.28f, -0.24f, 0.28f), new Vector3(0.05f, 0.08f, 0.11f), dark); - MakePart("ClawR", PrimitiveType.Cube, new Vector3(0.28f, -0.24f, 0.28f), new Vector3(0.05f, 0.08f, 0.11f), dark); + MakeSculpture("MandBaseL", InsectSculptureMeshes.Shape.StagJawLeft, jaw); + MakeSculpture("MandBaseR", InsectSculptureMeshes.Shape.StagJawRight, jaw); + foreach (int sign in new[] { -1, 1 }) + MakeSegment("MandTooth" + (sign < 0 ? "L" : "R"), new Vector3(sign * .29f, .13f, 1.03f), + new Vector3(sign * .16f, .13f, 1.08f), .042f, jaw); + // The short front tarsal claws begin at the front feet. The old + // cubes floated beneath the face in the gallery's front view. + MakeSegment("ClawL", new Vector3(-.46f, -.39f, .344f), new Vector3(-.48f, -.42f, .40f), .018f, dark); + MakeSegment("ClawR", new Vector3(.46f, -.39f, .344f), new Vector3(.48f, -.42f, .40f), .018f, dark); MakeEyes(0.78f, 0.11f, 0.18f); MakeLegs(dark, 3, 0f); } private void BuildButterfly(Color body, Color dark) { + string id = data != null ? data.insectId ?? "" : ""; MakePart("Body", PrimitiveType.Capsule, Vector3.zero, new Vector3(0.18f, 0.34f, 0.18f), dark, Quaternion.Euler(90f, 0f, 0f)); + MakePart("Thorax", PrimitiveType.Sphere, new Vector3(0f, .025f, .19f), new Vector3(.20f, .21f, .26f), dark); MakePart("Head", PrimitiveType.Sphere, new Vector3(0f, 0.05f, 0.45f), new Vector3(0.3f, 0.3f, 0.28f), dark); - Color wingCol = new Color(body.r, body.g, body.b, 0.85f); + float wingAlpha = id.Contains("glasswing") || id.Contains("snowveil") ? .38f : .90f; + Color wingCol = new Color(body.r, body.g, body.b, wingAlpha); Color wingSpot = new Color(Mathf.Min(1, body.r + 0.3f), Mathf.Min(1, body.g + 0.3f), body.b * 0.5f); Color wingEdge = new Color(dark.r, dark.g, dark.b, 0.7f); - MakeWing("WingL", new Vector3(-0.5f, 0.1f, 0f), new Vector3(0.7f, 0.02f, 0.6f), wingCol); - MakeWing("WingR", new Vector3(0.5f, 0.1f, 0f), new Vector3(0.7f, 0.02f, 0.6f), wingCol); + MakeSculpture("WingL", InsectSculptureMeshes.Shape.ButterflyForewing, wingCol, + new Vector3(-.49f, .10f, .04f), new Vector3(.90f, 1f, .72f)); + MakeSculpture("WingR", InsectSculptureMeshes.Shape.ButterflyForewing, wingCol, + new Vector3(.49f, .10f, .04f), new Vector3(.90f, 1f, .72f)); MakePart("SpotL1", PrimitiveType.Sphere, new Vector3(-0.45f, 0.12f, 0.1f), new Vector3(0.15f, 0.02f, 0.15f), wingSpot); MakePart("SpotR1", PrimitiveType.Sphere, new Vector3(0.45f, 0.12f, 0.1f), new Vector3(0.15f, 0.02f, 0.15f), wingSpot); MakePart("SpotL2", PrimitiveType.Sphere, new Vector3(-0.55f, 0.12f, 0f), new Vector3(0.12f, 0.02f, 0.12f), wingSpot); @@ -769,8 +1179,16 @@ private void BuildButterfly(Color body, Color dark) MakePart("SpotR3", PrimitiveType.Sphere, new Vector3(0.4f, 0.12f, -0.1f), new Vector3(0.1f, 0.02f, 0.1f), wingSpot); MakePart("WingTipL", PrimitiveType.Sphere, new Vector3(-0.88f, 0.1f, 0.05f), new Vector3(0.18f, 0.025f, 0.24f), wingEdge); MakePart("WingTipR", PrimitiveType.Sphere, new Vector3(0.88f, 0.1f, 0.05f), new Vector3(0.18f, 0.025f, 0.24f), wingEdge); - MakePart("WingLB", PrimitiveType.Sphere, new Vector3(-0.35f, 0.08f, -0.25f), new Vector3(0.45f, 0.02f, 0.4f), wingCol); - MakePart("WingRB", PrimitiveType.Sphere, new Vector3(0.35f, 0.08f, -0.25f), new Vector3(0.45f, 0.02f, 0.4f), wingCol); + MakeSculpture("WingLB", InsectSculptureMeshes.Shape.ButterflyHindwing, wingCol, + new Vector3(-.37f, .08f, -.18f), new Vector3(.65f, 1f, .55f)); + MakeSculpture("WingRB", InsectSculptureMeshes.Shape.ButterflyHindwing, wingCol, + new Vector3(.37f, .08f, -.18f), new Vector3(.65f, 1f, .55f)); + MakeFlightLegs(dark); + if (id.Contains("swallowtail") || id.Contains("birdwing")) + { + MakeSegment("HindTailL", new Vector3(-.58f, .07f, -.38f), new Vector3(-.68f, .05f, -.66f), .018f, wingEdge); + MakeSegment("HindTailR", new Vector3(.58f, .07f, -.38f), new Vector3(.68f, .05f, -.66f), .018f, wingEdge); + } MakeAntennae(dark, 0.35f); MakePart("AntBallL", PrimitiveType.Sphere, new Vector3(-0.15f, 0.42f, 0.57f), Vector3.one * 0.06f, dark); MakePart("AntBallR", PrimitiveType.Sphere, new Vector3(0.15f, 0.42f, 0.57f), Vector3.one * 0.06f, dark); @@ -787,8 +1205,15 @@ private void BuildMoth(Color body, Color dark) MakePart("Head", PrimitiveType.Sphere, new Vector3(0f, 0.05f, 0.4f), new Vector3(0.3f, 0.28f, 0.28f), dark); Color wingCol = new Color(body.r * 0.8f, body.g * 0.7f, body.b * 0.6f); Color eyeSpotCol = new Color(Mathf.Min(1, body.r + 0.1f), body.g * 0.4f, body.b * 0.3f); - MakeWing("WingL", new Vector3(-0.55f, 0.05f, 0.05f), new Vector3(0.8f, 0.02f, 0.7f), wingCol); - MakeWing("WingR", new Vector3(0.55f, 0.05f, 0.05f), new Vector3(0.8f, 0.02f, 0.7f), wingCol); + MakeSculpture("WingL", InsectSculptureMeshes.Shape.ButterflyForewing, wingCol, + new Vector3(-.53f, .06f, .06f), new Vector3(.98f, 1f, .65f)); + MakeSculpture("WingR", InsectSculptureMeshes.Shape.ButterflyForewing, wingCol, + new Vector3(.53f, .06f, .06f), new Vector3(.98f, 1f, .65f)); + MakeSculpture("WingLB", InsectSculptureMeshes.Shape.ButterflyHindwing, wingCol, + new Vector3(-.38f, .04f, -.19f), new Vector3(.69f, 1f, .51f)); + MakeSculpture("WingRB", InsectSculptureMeshes.Shape.ButterflyHindwing, wingCol, + new Vector3(.38f, .04f, -.19f), new Vector3(.69f, 1f, .51f)); + MakeFlightLegs(dark); MakePart("EyeSpotL", PrimitiveType.Sphere, new Vector3(-0.5f, 0.07f, 0.05f), new Vector3(0.18f, 0.02f, 0.18f), eyeSpotCol); MakePart("EyeSpotR", PrimitiveType.Sphere, new Vector3(0.5f, 0.07f, 0.05f), new Vector3(0.18f, 0.02f, 0.18f), eyeSpotCol); MakePart("EyeSpotCoreL", PrimitiveType.Sphere, new Vector3(-0.5f, 0.08f, 0.05f), new Vector3(0.08f, 0.02f, 0.08f), Color.black); @@ -825,27 +1250,27 @@ private void BuildMoth(Color body, Color dark) private void BuildMantis(Color body, Color dark) { - MakePart("Body", PrimitiveType.Capsule, new Vector3(0f, 0f, -0.15f), new Vector3(0.2f, 0.5f, 0.2f), body, - Quaternion.Euler(80f, 0f, 0f)); - MakePart("Thorax", PrimitiveType.Sphere, new Vector3(0f, 0.15f, 0.15f), new Vector3(0.25f, 0.2f, 0.25f), body); - MakePart("Head", PrimitiveType.Sphere, new Vector3(0f, 0.3f, 0.25f), new Vector3(0.38f, 0.28f, 0.25f), dark); - MakePart("HeadCrest", PrimitiveType.Cube, new Vector3(0f, 0.38f, 0.22f), new Vector3(0.12f, 0.06f, 0.12f), dark); - MakePart("ArmUpperL", PrimitiveType.Capsule, new Vector3(-0.22f, 0.15f, 0.3f), new Vector3(0.08f, 0.2f, 0.08f), - body, Quaternion.Euler(-10f, 0f, 20f)); - MakePart("ArmUpperR", PrimitiveType.Capsule, new Vector3(0.22f, 0.15f, 0.3f), new Vector3(0.08f, 0.2f, 0.08f), - body, Quaternion.Euler(-10f, 0f, -20f)); - MakePart("ArmLowerL", PrimitiveType.Capsule, new Vector3(-0.28f, 0.28f, 0.42f), new Vector3(0.06f, 0.18f, 0.06f), - body, Quaternion.Euler(-40f, 0f, 15f)); - MakePart("ArmLowerR", PrimitiveType.Capsule, new Vector3(0.28f, 0.28f, 0.42f), new Vector3(0.06f, 0.18f, 0.06f), - body, Quaternion.Euler(-40f, 0f, -15f)); - MakePart("ClawL", PrimitiveType.Cube, new Vector3(-0.3f, 0.38f, 0.55f), new Vector3(0.05f, 0.18f, 0.04f), dark); - MakePart("ClawR", PrimitiveType.Cube, new Vector3(0.3f, 0.38f, 0.55f), new Vector3(0.05f, 0.18f, 0.04f), dark); - Color wingFold = new Color(body.r * 0.7f, body.g * 0.8f, body.b * 0.6f, 0.5f); - MakePart("WingFoldL", PrimitiveType.Cube, new Vector3(-0.08f, 0.12f, -0.2f), new Vector3(0.15f, 0.01f, 0.4f), wingFold); - MakePart("WingFoldR", PrimitiveType.Cube, new Vector3(0.08f, 0.12f, -0.2f), new Vector3(0.15f, 0.01f, 0.4f), wingFold); - MakeLegs(dark, 2, -0.15f); - MakeAntennae(dark, 0.3f); - MakeEyes(0.3f, 0.22f); + Color leaf = Color.Lerp(body, new Color(.32f, .49f, .16f), .72f); + Color vein = Color.Lerp(leaf, new Color(.14f, .24f, .07f), .50f); + MakePart("Body", PrimitiveType.Sphere, new Vector3(0f, -.035f, -.30f), new Vector3(.24f, .15f, .68f), vein); + MakeSculpture("Thorax", InsectSculptureMeshes.Shape.MantisThorax, leaf); + MakeSculpture("Head", InsectSculptureMeshes.Shape.MantisHead, leaf); + foreach (int sign in new[] { -1, 1 }) + { + string side = sign < 0 ? "L" : "R"; + MakeSculpture("ArmUpper" + side, sign < 0 ? InsectSculptureMeshes.Shape.MantisFemurLeft : InsectSculptureMeshes.Shape.MantisFemurRight, leaf); + MakeSculpture("Claw" + side, sign < 0 ? InsectSculptureMeshes.Shape.MantisBladeLeft : InsectSculptureMeshes.Shape.MantisBladeRight, vein); + // Eyes sit on the corners of the triangular head, not below it. + MakePart("Eye" + side, PrimitiveType.Sphere, new Vector3(sign * .18f, .38f, .33f), new Vector3(.13f, .12f, .12f), new Color(.51f, .64f, .24f)); + MakePart("EyeGlint" + side, PrimitiveType.Sphere, new Vector3(sign * .17f, .405f, .38f), Vector3.one * .023f, new Color(.94f, .96f, .80f)); + MakeSegment("Antenna" + side, new Vector3(sign * .09f, .38f, .28f), new Vector3(sign * .16f, .66f, .37f), .015f, vein); + } + // Folded opaque tegmina read as two tapered leaves; no transparent boards. + MakeSculpture("WingFoldL", InsectSculptureMeshes.Shape.Wing, leaf, + new Vector3(-.062f, .035f, -.25f), new Vector3(.55f, .040f, .18f), Quaternion.Euler(0f, 90f, 0f)); + MakeSculpture("WingFoldR", InsectSculptureMeshes.Shape.Wing, leaf, + new Vector3(.062f, .035f, -.25f), new Vector3(.55f, .040f, .18f), Quaternion.Euler(0f, 90f, 0f)); + MakeLegs(vein, 2, -.15f); } private void BuildDragonfly(Color body, Color dark) @@ -859,20 +1284,21 @@ private void BuildDragonfly(Color body, Color dark) MakePart("Head", PrimitiveType.Sphere, new Vector3(0f, 0.08f, 0.35f), new Vector3(0.35f, 0.25f, 0.3f), dark); Color wingCol = new Color(0.8f, 0.9f, 1f, 0.4f); Color veinCol = new Color(0.3f, 0.3f, 0.3f, 0.5f); - MakeWing("WingL", new Vector3(-0.45f, 0.1f, 0.15f), new Vector3(0.7f, 0.01f, 0.15f), wingCol); - MakeWing("WingR", new Vector3(0.45f, 0.1f, 0.15f), new Vector3(0.7f, 0.01f, 0.15f), wingCol); - MakePart("VeinFL", PrimitiveType.Cylinder, new Vector3(-0.45f, 0.11f, 0.15f), new Vector3(0.01f, 0.01f, 0.13f), veinCol, - Quaternion.Euler(0f, 0f, 85f)); - MakePart("VeinFR", PrimitiveType.Cylinder, new Vector3(0.45f, 0.11f, 0.15f), new Vector3(0.01f, 0.01f, 0.13f), veinCol, - Quaternion.Euler(0f, 0f, -85f)); - MakePart("WingLB", PrimitiveType.Cube, new Vector3(-0.4f, 0.08f, -0.05f), new Vector3(0.6f, 0.01f, 0.13f), wingCol); - MakePart("WingRB", PrimitiveType.Cube, new Vector3(0.4f, 0.08f, -0.05f), new Vector3(0.6f, 0.01f, 0.13f), wingCol); - MakePart("VeinBL", PrimitiveType.Cylinder, new Vector3(-0.4f, 0.09f, -0.05f), new Vector3(0.01f, 0.01f, 0.11f), veinCol, - Quaternion.Euler(0f, 0f, 85f)); - MakePart("VeinBR", PrimitiveType.Cylinder, new Vector3(0.4f, 0.09f, -0.05f), new Vector3(0.01f, 0.01f, 0.11f), veinCol, - Quaternion.Euler(0f, 0f, -85f)); + MakeSculpture("WingL", InsectSculptureMeshes.Shape.DragonflyWing, wingCol, + new Vector3(-.43f, .12f, .25f), new Vector3(.76f, 1f, .85f)); + MakeSculpture("WingR", InsectSculptureMeshes.Shape.DragonflyWing, wingCol, + new Vector3(.43f, .12f, .25f), new Vector3(.76f, 1f, .85f)); + MakeSegment("VeinFL", new Vector3(-.09f, .134f, .25f), new Vector3(-.77f, .134f, .25f), .008f, veinCol); + MakeSegment("VeinFR", new Vector3(.09f, .134f, .25f), new Vector3(.77f, .134f, .25f), .008f, veinCol); + MakeSculpture("WingLB", InsectSculptureMeshes.Shape.DragonflyWing, wingCol, + new Vector3(-.40f, .10f, .08f), new Vector3(.72f, 1f, .92f)); + MakeSculpture("WingRB", InsectSculptureMeshes.Shape.DragonflyWing, wingCol, + new Vector3(.40f, .10f, .08f), new Vector3(.72f, 1f, .92f)); + MakeSegment("VeinBL", new Vector3(-.09f, .114f, .08f), new Vector3(-.73f, .114f, .08f), .008f, veinCol); + MakeSegment("VeinBR", new Vector3(.09f, .114f, .08f), new Vector3(.73f, .114f, .08f), .008f, veinCol); MakePart("EyeL", PrimitiveType.Sphere, new Vector3(-0.15f, 0.15f, 0.4f), Vector3.one * 0.16f, new Color(0.2f, 0.8f, 0.3f)); MakePart("EyeR", PrimitiveType.Sphere, new Vector3(0.15f, 0.15f, 0.4f), Vector3.one * 0.16f, new Color(0.2f, 0.8f, 0.3f)); + MakeLegs(dark, 3, 0.18f); } private void BuildBee(Color body, Color dark) @@ -976,17 +1402,27 @@ private void BuildAnt(Color body, Color dark) MakePart("Neck", PrimitiveType.Capsule, new Vector3(0f, 0.03f, 0.18f), new Vector3(0.06f, 0.06f, 0.06f), dark, Quaternion.Euler(90f, 0f, 0f)); MakePart("Head", PrimitiveType.Sphere, new Vector3(0f, 0.05f, 0.3f), new Vector3(0.3f, 0.28f, 0.28f), dark); - MakePart("MandibleL", PrimitiveType.Cube, new Vector3(-0.08f, 0f, 0.45f), new Vector3(0.06f, 0.04f, 0.1f), body); - MakePart("MandibleR", PrimitiveType.Cube, new Vector3(0.08f, 0f, 0.45f), new Vector3(0.06f, 0.04f, 0.1f), body); + // Paired hooked mandibles emerge from the head and curve inward. + // Keep the MandibleL/R node contract used by model inspections. + foreach (int sign in new[] { -1, 1 }) + { + string side = sign < 0 ? "L" : "R"; + Vector3 root = new Vector3(sign * .08f, -.025f, .40f); + Vector3 bend = new Vector3(sign * .15f, -.07f, .51f); + Vector3 tip = new Vector3(sign * .05f, -.065f, .56f); + MakeSegment("Mandible" + side, root, bend, .027f, body); + MakeSegment("MandibleTip" + side, bend, tip, .017f, body); + } MakeLegs(dark, 3, -0.05f); - MakePart("ElbowAntL", PrimitiveType.Capsule, new Vector3(-0.1f, 0.2f, 0.45f), new Vector3(0.03f, 0.15f, 0.03f), - dark, Quaternion.Euler(-50f, 0f, 15f)); - MakePart("ElbowAntR", PrimitiveType.Capsule, new Vector3(0.1f, 0.2f, 0.45f), new Vector3(0.03f, 0.15f, 0.03f), - dark, Quaternion.Euler(-50f, 0f, -15f)); - MakePart("ElbowAntL2", PrimitiveType.Capsule, new Vector3(-0.14f, 0.35f, 0.52f), new Vector3(0.025f, 0.12f, 0.025f), - dark, Quaternion.Euler(-10f, 0f, 5f)); - MakePart("ElbowAntR2", PrimitiveType.Capsule, new Vector3(0.14f, 0.35f, 0.52f), new Vector3(0.025f, 0.12f, 0.025f), - dark, Quaternion.Euler(-10f, 0f, -5f)); + foreach (int sign in new[] { -1, 1 }) + { + string side = sign < 0 ? "L" : "R"; + Vector3 basePoint = new Vector3(sign * 0.09f, 0.13f, 0.38f); + Vector3 elbow = new Vector3(sign * 0.18f, 0.29f, 0.51f); + MakeSegment("ElbowAnt" + side, basePoint, elbow, 0.027f, dark); + MakeSegment("ElbowAnt" + side + "2", elbow, + new Vector3(sign * 0.29f, 0.30f, 0.67f), 0.021f, dark); + } MakeEyes(0.3f, 0.1f); } @@ -1057,31 +1493,31 @@ private void BuildJewelBeetle(Color body, Color dark) private void BuildRhinocerosBeetle(Color body, Color dark) { - Color gloss = new Color(Mathf.Min(1, body.r + 0.15f), Mathf.Min(1, body.g + 0.1f), body.b * 0.8f); - MakePart("Body", PrimitiveType.Sphere, Vector3.zero, new Vector3(0.9f, 0.55f, 1.1f), body); - MakePart("Shell", PrimitiveType.Sphere, new Vector3(0f, 0.2f, -0.05f), new Vector3(0.82f, 0.3f, 0.95f), dark); - MakePart("ShellGloss", PrimitiveType.Sphere, new Vector3(0f, 0.25f, -0.05f), new Vector3(0.7f, 0.12f, 0.8f), - new Color(1f, 1f, 1f, 0.12f)); - MakePart("Head", PrimitiveType.Sphere, new Vector3(0f, 0.12f, 0.6f), new Vector3(0.55f, 0.45f, 0.5f), dark); - MakePart("HornMain", PrimitiveType.Cylinder, new Vector3(0f, 0.45f, 0.7f), new Vector3(0.1f, 0.35f, 0.1f), body, - Quaternion.Euler(25f, 0f, 0f)); - // 3분절 곡선으로 뿔이 부드럽게 휨(옛 직선 실린더 2개 = 뚝뚝 끊김) - MakePart("HornCurve", PrimitiveType.Cylinder, new Vector3(0f, 0.6f, 0.82f), new Vector3(0.08f, 0.18f, 0.08f), body, - Quaternion.Euler(40f, 0f, 0f)); - MakePart("HornMid", PrimitiveType.Sphere, new Vector3(0f, 0.62f, 0.88f), Vector3.one * 0.09f, body); - MakePart("HornTip", PrimitiveType.Cylinder, new Vector3(0f, 0.72f, 0.96f), new Vector3(0.06f, 0.13f, 0.06f), gloss, - Quaternion.Euler(52f, 0f, 0f)); - // 끝 분기(Y자 뿔) — 장수풍뎅이 시그니처 실루엣 - MakePart("HornForkL", PrimitiveType.Cylinder, new Vector3(-0.05f, 0.78f, 1.0f), new Vector3(0.04f, 0.1f, 0.04f), gloss, - Quaternion.Euler(50f, 0f, 12f)); - MakePart("HornForkR", PrimitiveType.Cylinder, new Vector3(0.05f, 0.78f, 1.0f), new Vector3(0.04f, 0.1f, 0.04f), gloss, - Quaternion.Euler(50f, 0f, -12f)); - MakePart("HornSmall", PrimitiveType.Cylinder, new Vector3(0f, 0.3f, 0.55f), new Vector3(0.07f, 0.15f, 0.07f), dark, - Quaternion.Euler(15f, 0f, 0f)); - MakePart("ClawL", PrimitiveType.Cube, new Vector3(-0.3f, -0.25f, 0.3f), new Vector3(0.06f, 0.08f, 0.12f), dark); - MakePart("ClawR", PrimitiveType.Cube, new Vector3(0.3f, -0.25f, 0.3f), new Vector3(0.06f, 0.08f, 0.12f), dark); - MakeEyes(0.78f, 0.14f, 0.22f); - MakeLegs(dark, 3, 0f); + Color chitin = Color.Lerp(body, new Color(.24f, .13f, .075f), .72f); + Color underside = Color.Lerp(chitin, Color.black, .42f); + MakePart("Body", PrimitiveType.Sphere, new Vector3(0f, -.045f, -.08f), new Vector3(.90f, .36f, .95f), underside); + MakeSculpture("ShellL", InsectSculptureMeshes.Shape.ElytronLeft, chitin); + MakeSculpture("ShellR", InsectSculptureMeshes.Shape.ElytronRight, chitin); + Color forebody = Color.Lerp(chitin, underside, .22f); + MakeSculpture("Pronotum", InsectSculptureMeshes.Shape.RhinoPronotum, forebody); + MakePart("Head", PrimitiveType.Sphere, new Vector3(0f, .10f, .61f), new Vector3(.48f, .31f, .38f), forebody); + MakeSculpture("HornMain", InsectSculptureMeshes.Shape.RhinoHorn, chitin); + MakeSculpture("HornForkL", InsectSculptureMeshes.Shape.RhinoForkLeft, chitin); + MakeSculpture("HornForkR", InsectSculptureMeshes.Shape.RhinoForkRight, chitin); + MakeSculpture("HornSmall", InsectSculptureMeshes.Shape.ThoraxHorn, underside); + MakeEyes(.73f, .09f, .19f); + MakeLegs(underside, 3, 0f); + // Keep the short clubbed antennae ahead of the head. The old tall + // antenna roots crossed the horn and looked like a dark cut at its base. + foreach (int sign in new[] { -1, 1 }) + { + string side = sign < 0 ? "L" : "R"; + Vector3 elbow = new Vector3(sign * .28f, .11f, .79f); + Vector3 tip = new Vector3(sign * .28f, .18f, .85f); + MakeSegment("AntBase" + side, new Vector3(sign * .18f, .08f, .68f), elbow, .025f, underside); + MakeSegment("AntMid" + side, elbow, tip, .02f, underside); + MakePart("AntTip" + side, PrimitiveType.Sphere, tip, new Vector3(.055f, .03f, .045f), underside); + } } private void BuildOrchidMantis(Color body, Color dark) @@ -1358,8 +1794,8 @@ private void BuildDamselfly(Color body, Color dark) Color wingCol = new Color(0.85f, 0.92f, 1f, 0.35f); MakeWing("WingL", new Vector3(-0.3f, 0.08f, 0.05f), new Vector3(0.5f, 0.01f, 0.1f), wingCol); MakeWing("WingR", new Vector3(0.3f, 0.08f, 0.05f), new Vector3(0.5f, 0.01f, 0.1f), wingCol); - MakePart("WingLB", PrimitiveType.Cube, new Vector3(-0.28f, 0.06f, -0.08f), new Vector3(0.45f, 0.01f, 0.08f), wingCol); - MakePart("WingRB", PrimitiveType.Cube, new Vector3(0.28f, 0.06f, -0.08f), new Vector3(0.45f, 0.01f, 0.08f), wingCol); + MakePart("WingLB", PrimitiveType.Sphere, new Vector3(-0.28f, 0.06f, -0.08f), new Vector3(0.45f, 0.01f, 0.08f), wingCol); + MakePart("WingRB", PrimitiveType.Sphere, new Vector3(0.28f, 0.06f, -0.08f), new Vector3(0.45f, 0.01f, 0.08f), wingCol); MakePart("EyeL", PrimitiveType.Sphere, new Vector3(-0.1f, 0.12f, 0.35f), Vector3.one * 0.12f, new Color(0.3f, 0.7f, 0.9f)); MakePart("EyeR", PrimitiveType.Sphere, new Vector3(0.1f, 0.12f, 0.35f), Vector3.one * 0.12f, new Color(0.3f, 0.7f, 0.9f)); MakeLegs(dark, 3, 0.1f); @@ -1511,79 +1947,137 @@ private GameObject MakePart(string name, PrimitiveType type, Vector3 localPos, V return part; } + private GameObject MakeSculpture(string name, InsectSculptureMeshes.Shape shape, Color color, + Vector3? position = null, Vector3? scale = null, Quaternion? rotation = null) + { + var part = new GameObject(name); + part.transform.SetParent(transform, false); + part.transform.localPosition = position ?? Vector3.zero; + part.transform.localScale = scale ?? Vector3.one; + part.transform.localRotation = rotation ?? Quaternion.identity; + part.AddComponent().sharedMesh = InsectSculptureMeshes.Get(shape); + part.AddComponent(); + ApplyColor(part, color); + return part; + } + private void MakeWing(string name, Vector3 pos, Vector3 scale, Color color) { - GameObject wing = GameObject.CreatePrimitive(PrimitiveType.Cube); - wing.name = name; - wing.transform.SetParent(transform, false); - wing.transform.localPosition = pos; - wing.transform.localScale = scale; - Collider col = wing.GetComponent(); - if (col != null) UnityEngine.Object.Destroy(col); - ApplyColor(wing, color); + // An ellipsoid gives a tapered membrane rather than a rectangular plank. + scale.x = Mathf.Max(scale.x, 2f * (Mathf.Abs(pos.x) - 0.06f)); + MakeSculpture(name, InsectSculptureMeshes.Shape.Wing, color, pos, scale); + } + + private void BindWingSurfaces() + { + // Keep WingL/R as the animation contract, but rotate at the thorax. + // Spots, veins and hindwings must follow the same hinge. + foreach (string side in new[] { "L", "R" }) + { + Transform surface = transform.Find("Wing" + side); + if (surface == null) continue; + Vector3 center = surface.localPosition; + surface.name = "WingSurface" + side; + Transform hinge = new GameObject("Wing" + side).transform; + hinge.SetParent(transform, false); + hinge.localPosition = new Vector3(side == "L" ? -0.07f : 0.07f, center.y, center.z); + var attached = new System.Collections.Generic.List { surface }; + for (int i = 0; i < transform.childCount; i++) + { + Transform child = transform.GetChild(i); + string n = child.name; + if (child == surface || child == hinge) continue; + bool decoration = n.StartsWith("Spot") || n.StartsWith("EyeSpot") || n.StartsWith("Vein") + || n.StartsWith("Wing") || n.StartsWith("HindTail") || n.StartsWith("TailCurl"); + if (decoration && (side == "L" ? child.localPosition.x < 0f : child.localPosition.x > 0f)) + attached.Add(child); + } + foreach (Transform child in attached) child.SetParent(hinge, true); + } + } + + private GameObject MakeSegment(string name, Vector3 from, Vector3 to, float width, Color color) + { + Vector3 delta = to - from; + return MakePart(name, PrimitiveType.Capsule, (from + to) * 0.5f, + new Vector3(width, delta.magnitude * 0.5f, width), color, + Quaternion.FromToRotation(Vector3.up, delta.normalized)); } private void MakeLegs(Color color, int pairs, float zOffset) { - Color joint = new Color(color.r * 0.7f + 0.03f, color.g * 0.7f + 0.03f, color.b * 0.7f + 0.03f); + Color joint = Color.Lerp(color, Color.black, 0.22f); + // Attach to the narrow thorax on slender species, the shell on beetles. + Transform thorax = transform.Find("Thorax"); + MeshFilter thoraxMesh = thorax != null ? thorax.GetComponent() : null; + float thoraxWidth = thoraxMesh != null && thoraxMesh.sharedMesh != null + ? thoraxMesh.sharedMesh.bounds.size.x * thorax.localScale.x : .55f; + float hipX = Mathf.Clamp(thoraxWidth * .4f, .07f, .22f); for (int i = 0; i < pairs; i++) { float z = zOffset + (i - (pairs - 1) * 0.5f) * 0.2f; - // 앞·중·뒷다리 각도 변주(기계적 동일각 해소) + z 부채꼴 펼침 - float zSpread = (i - (pairs - 1) * 0.5f) * 0.04f; - float upAng = 26f + i * 4f; - float loAng = 8f + i * 3f; - // 대퇴 (상단) - MakePart($"LegUL{i}", PrimitiveType.Capsule, new Vector3(-0.22f, -0.1f, z + zSpread), - new Vector3(0.055f, 0.12f, 0.055f), color, Quaternion.Euler(0f, 0f, upAng)); - MakePart($"LegUR{i}", PrimitiveType.Capsule, new Vector3(0.22f, -0.1f, z + zSpread), - new Vector3(0.055f, 0.12f, 0.055f), color, Quaternion.Euler(0f, 0f, -upAng)); - // 관절 - MakePart($"KneeL{i}", PrimitiveType.Sphere, new Vector3(-0.3f, -0.2f, z + zSpread), - Vector3.one * 0.05f, joint); - MakePart($"KneeR{i}", PrimitiveType.Sphere, new Vector3(0.3f, -0.2f, z + zSpread), - Vector3.one * 0.05f, joint); - // 경절 (하단) - MakePart($"LegLL{i}", PrimitiveType.Capsule, new Vector3(-0.32f, -0.3f, z + zSpread), - new Vector3(0.038f, 0.12f, 0.038f), color, Quaternion.Euler(0f, 0f, loAng)); - MakePart($"LegLR{i}", PrimitiveType.Capsule, new Vector3(0.32f, -0.3f, z + zSpread), - new Vector3(0.038f, 0.12f, 0.038f), color, Quaternion.Euler(0f, 0f, -loAng)); - // 발끝(tarsus) — 접지감(옛엔 발끝 없어 공중에 뜬 느낌) - MakePart($"FootL{i}", PrimitiveType.Sphere, new Vector3(-0.345f, -0.4f, z + zSpread), - Vector3.one * 0.03f, joint); - MakePart($"FootR{i}", PrimitiveType.Sphere, new Vector3(0.345f, -0.4f, z + zSpread), - Vector3.one * 0.03f, joint); + float fan = (i - (pairs - 1) * 0.5f) * 0.09f; + foreach (int sign in new[] { -1, 1 }) + { + string side = sign < 0 ? "L" : "R"; + Vector3 hip = new Vector3(sign * hipX, -0.055f, z); + Vector3 knee = new Vector3(sign * (hipX + 0.18f), -0.16f, z + fan); + Vector3 foot = new Vector3(sign * (hipX + 0.24f), -0.39f, z + fan * 1.6f); + MakeSegment($"LegU{side}{i}", hip, knee, 0.055f, color); + MakePart($"Knee{side}{i}", PrimitiveType.Sphere, knee, Vector3.one * 0.056f, joint); + MakeSegment($"LegL{side}{i}", knee, foot, 0.035f, color); + MakePart($"Foot{side}{i}", PrimitiveType.Sphere, foot, + new Vector3(0.06f, 0.028f, 0.065f), joint); + } } } private void MakeAntennae(Color color, float zBase, bool feathered = false) { - // 2분절 굴절로 부드러운 S곡선(옛 직선 캡슐 1개 = 막대기 느낌 해소). - MakePart("AntBaseL", PrimitiveType.Capsule, new Vector3(-0.1f, 0.18f, zBase + 0.13f), - new Vector3(0.03f, 0.14f, 0.03f), color, Quaternion.Euler(-38f, 0f, 16f)); - MakePart("AntBaseR", PrimitiveType.Capsule, new Vector3(0.1f, 0.18f, zBase + 0.13f), - new Vector3(0.03f, 0.14f, 0.03f), color, Quaternion.Euler(-38f, 0f, -16f)); - MakePart("AntMidL", PrimitiveType.Capsule, new Vector3(-0.15f, 0.36f, zBase + 0.2f), - new Vector3(0.025f, 0.12f, 0.025f), color, Quaternion.Euler(-10f, 0f, 8f)); - MakePart("AntMidR", PrimitiveType.Capsule, new Vector3(0.15f, 0.36f, zBase + 0.2f), - new Vector3(0.025f, 0.12f, 0.025f), color, Quaternion.Euler(-10f, 0f, -8f)); - float tipScale = feathered ? 0.08f : 0.055f; - MakePart("AntTipL", PrimitiveType.Sphere, new Vector3(-0.17f, 0.46f, zBase + 0.23f), Vector3.one * tipScale, color); - MakePart("AntTipR", PrimitiveType.Sphere, new Vector3(0.17f, 0.46f, zBase + 0.23f), Vector3.one * tipScale, color); - if (feathered) + // Explicit shared endpoints avoid the gaps made by independently + // rotated capsules (most visible on the butterfly side portrait). + for (int sign = -1; sign <= 1; sign += 2) { - // 나방/모기 깃털 더듬이 — 끝에 양옆 작은 깃 - MakePart("AntFeatherL", PrimitiveType.Cube, new Vector3(-0.16f, 0.40f, zBase + 0.22f), - new Vector3(0.07f, 0.012f, 0.03f), color, Quaternion.Euler(0f, 0f, 20f)); - MakePart("AntFeatherR", PrimitiveType.Cube, new Vector3(0.16f, 0.40f, zBase + 0.22f), - new Vector3(0.07f, 0.012f, 0.03f), color, Quaternion.Euler(0f, 0f, -20f)); + string side = sign < 0 ? "L" : "R"; + Vector3 root = new Vector3(sign * .085f, .145f, zBase + .08f); + Vector3 elbow = new Vector3(sign * .145f, .30f, zBase + .17f); + Vector3 tip = new Vector3(sign * .205f, .43f, zBase + .25f); + MakeSegment("AntBase" + side, root, elbow, .022f, color); + MakeSegment("AntMid" + side, elbow, tip, .016f, color); + MakePart("AntTip" + side, PrimitiveType.Sphere, tip, + Vector3.one * (feathered ? .066f : .046f), color); + if (feathered) + MakeSegment("AntFeather" + side, elbow + new Vector3(0f, .035f, 0f), + elbow + new Vector3(sign * .10f, .015f, .015f), .012f, color); + } + } + + private void MakeFlightLegs(Color color) + { + // Six narrow hanging legs on moths and butterflies. They remain + // tucked beneath the thorax so wing portraits keep the focal shape. + for (int i = 0; i < 3; i++) + { + float z = .17f - i * .12f; + for (int sign = -1; sign <= 1; sign += 2) + { + string side = sign < 0 ? "L" : "R"; + Vector3 hip = new Vector3(sign * .075f, -.065f, z); + Vector3 knee = new Vector3(sign * .16f, -.17f, z - .025f); + Vector3 foot = new Vector3(sign * .20f, -.26f, z + .01f); + MakeSegment("LegU" + side + i, hip, knee, .017f, color); + MakeSegment("LegL" + side + i, knee, foot, .012f, color); + } } } private void MakeEyes(float zPos, float size, float xSpread = 0.12f) { - MakePart("EyeL", PrimitiveType.Sphere, new Vector3(-xSpread, 0.15f, zPos), Vector3.one * size, Color.white); - MakePart("EyeR", PrimitiveType.Sphere, new Vector3(xSpread, 0.15f, zPos), Vector3.one * size, Color.white); + string id = data != null ? data.insectId ?? "" : ""; + bool armored = id.Contains("beetle") || id.Contains("stag") || id.StartsWith("ant") || id.Contains("_ant"); + Color eyeColor = armored ? new Color(0.055f, 0.075f, 0.09f) : new Color(0.76f, 0.87f, 0.67f); + MakePart("EyeL", PrimitiveType.Sphere, new Vector3(-xSpread, 0.15f, zPos), Vector3.one * size, eyeColor); + MakePart("EyeR", PrimitiveType.Sphere, new Vector3(xSpread, 0.15f, zPos), Vector3.one * size, eyeColor); // 큰 동공 (치비 톤: 64%) float pupilSize = size * 0.64f; MakePart("PupilL", PrimitiveType.Sphere, new Vector3(-xSpread, 0.15f, zPos + 0.04f), Vector3.one * pupilSize, new Color(0.05f, 0.05f, 0.08f)); @@ -1598,13 +2092,16 @@ private void MakeEyes(float zPos, float size, float xSpread = 0.12f) MakePart("GlintR", PrimitiveType.Sphere, new Vector3(xSpread - 0.025f, 0.115f, zPos + 0.05f), Vector3.one * glintSize, new Color(1f, 1f, 1f, 0.8f)); } - // 곤충 등껍질 상단 흰색 반투명 글로스 — 입체 광택(딱정벌레/풍뎅이류 1줄 호출). + // Use the actual shell surface for gloss; no floating transparent white blob. private void MakeTopGloss(Vector3 bodyCenter, Vector3 bodyScale, float intensity = 0.14f) { - MakePart("TopGloss", PrimitiveType.Sphere, - bodyCenter + new Vector3(0f, bodyScale.y * 0.35f, bodyScale.z * 0.05f), - new Vector3(bodyScale.x * 0.7f, bodyScale.y * 0.18f, bodyScale.z * 0.75f), - new Color(1f, 1f, 1f, intensity)); + Transform body = transform.Find("Body"); + Renderer renderer = body != null ? body.GetComponent() : null; + Material mat = renderer != null ? renderer.sharedMaterial : null; + if (mat == null) return; + float smoothness = Mathf.Clamp01(0.48f + intensity); + if (mat.HasProperty("_Glossiness")) mat.SetFloat("_Glossiness", smoothness); + if (mat.HasProperty("_Smoothness")) mat.SetFloat("_Smoothness", smoothness); } // 모델 파츠 색칠 — shiny면 종별 색변환을 거쳐 전 파츠(하드코딩 색 포함)가 이로치 팔레트로 바뀜. @@ -1638,19 +2135,28 @@ private void ApplyColorRaw(GameObject go, Color color) { Renderer r = go.GetComponent(); if (r == null) return; - 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"); - if (shader == null) return; + // 셰이더 폴백 체인은 SceneryMaterials.LitShader가 단일 출처다. 광택·반투명은 공유하지 않는다 — + // 아래 키틴·눈·막 광택이 곤충의 외형이고(무광 마감 ApplyFinish를 쓰면 전 종이 점토가 된다), + // 반투명 경로엔 날개뿐 아니라 샤이니 반짝임·오라·바닥 마커 같은 이펙트가 섞여 있다. + // 공유 체인의 마지막 방어선(에러 셰이더)은 칠하지 않는다 — 옛 체인은 Sprites/Default에서 멈추고 + // 못 찾으면 프리미티브의 기본 머티리얼을 그대로 두었다. + Shader shader = InsectGame.Core.SceneryMaterials.LitShader; + if (shader == null || shader.name == "Hidden/InternalErrorShader") return; Material mat = new Material(shader); mat.color = color; + if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color); // PBR 광택: 옛 ApplyColor는 색만 칠해 전 곤충이 무광 점토처럼 보였음(품질 저하 핵심). // Standard/URP Lit에서만 _Glossiness/_Metallic 설정(Unlit/Sprites fallback은 프로퍼티 없어 가드). bool pbr = shader.name == "Standard" || shader.name.Contains("Lit"); if (color.a < 1f) { - mat.SetFloat("_Mode", 3); + if (mat.HasProperty("_Mode")) mat.SetFloat("_Mode", 3); + if (mat.HasProperty("_Surface")) + { + mat.SetFloat("_Surface", 1f); + mat.EnableKeyword("_SURFACE_TYPE_TRANSPARENT"); + } + mat.SetOverrideTag("RenderType", "Transparent"); mat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha); mat.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); mat.SetInt("_ZWrite", 0); @@ -1659,14 +2165,23 @@ private void ApplyColorRaw(GameObject go, Color color) mat.DisableKeyword("_ALPHAPREMULTIPLY_ON"); mat.renderQueue = 3000; // 날개/반투명: 막·천 느낌(번들거림 억제) - if (pbr) { mat.SetFloat("_Glossiness", 0.2f); mat.SetFloat("_Smoothness", 0.2f); mat.SetFloat("_Metallic", 0f); } + if (pbr) + { + if (mat.HasProperty("_Glossiness")) mat.SetFloat("_Glossiness", .18f); + if (mat.HasProperty("_Smoothness")) mat.SetFloat("_Smoothness", .18f); + if (mat.HasProperty("_Metallic")) mat.SetFloat("_Metallic", 0f); + } } else if (pbr) { // 외골격 키틴 광택 + 미세 금속감 — 전 34종 동시 개선 - mat.SetFloat("_Glossiness", 0.55f); - mat.SetFloat("_Smoothness", 0.55f); - mat.SetFloat("_Metallic", 0.15f); + bool eye = go.name.Contains("Eye") || go.name.Contains("Pupil"); + bool membrane = go.name.StartsWith("Wing") || go.name.StartsWith("Fur"); + bool chitin = go.name.StartsWith("Shell") || go.name.StartsWith("Horn") || go.name.StartsWith("Mand"); + float smoothness = eye ? .78f : membrane ? .18f : chitin ? .52f : .32f; + if (mat.HasProperty("_Glossiness")) mat.SetFloat("_Glossiness", smoothness); + if (mat.HasProperty("_Smoothness")) mat.SetFloat("_Smoothness", smoothness); + if (mat.HasProperty("_Metallic")) mat.SetFloat("_Metallic", chitin ? .025f : 0f); } r.material = mat; } @@ -1932,7 +2447,7 @@ private float GetRarityScale() public void Despawn() { - // 수문장은 풀 객체가 아니다(onDespawn·ownerPoint 둘 다 없음) — 아래 래치를 걸면 아무것도 + // 수문장은 풀 객체가 아니다(onDespawn도 소속 리전도 없음) — 아래 래치를 걸면 아무것도 // 반환·파괴되지 않은 채 CanBeEngaged만 영구 false가 돼, **한 번 지거나 도주하면 눈앞에 // 서 있는 수문장에게 다시 말을 걸 수 없고 리전이 영영 잠긴다**(2026-09-09). 격파 시 실제 // 제거는 PlaySceneBootstrap.RemoveGuardianSeal이 한다. 여기서는 교전만 풀어 준다. @@ -1949,9 +2464,24 @@ public void Despawn() // 풀 반환 전 진행 중 코루틴 정리 (다음 인스턴스 사용 시 잔존 영향 방지) StopAllCoroutines(); - if (ownerPoint != null) - ownerPoint.NotifyDespawned(); onDespawn?.Invoke(this); } + + /// + /// 스포너가 몸만 조용히 거둔다(플레이어가 멀어짐·서브에리어 전환) — 게임플레이 퇴장이 아니다. + /// 과 달리 콜백을 부르지 않는다: 콜백은 "그 자리가 비었다"(재생 지연 시작)는 뜻이라, + /// 거리로 거둔 것까지 그 길로 보내면 멀어졌다 돌아올 때마다 새 곤충이 된다(옛 리전 이동 리롤). + /// 다중 호출 가드는 같이 건다 — 이 참조를 쥔 쪽(아이 NPC 등)이 뒤늦게 Despawn을 불러도 no-op이다. + /// 거뒀으면 true(호출부가 풀에 돌린다). 수문장·이미 퇴장한 몸은 false. + /// + internal bool Recall() + { + if (IsGuardian || despawnedThisCycle) return false; + despawnedThisCycle = true; + engaged = false; + alertState = 0; + StopAllCoroutines(); + return true; + } } } diff --git a/Assets/Scripts/Spawning/InsectSculptureMeshes.cs b/Assets/Scripts/Spawning/InsectSculptureMeshes.cs new file mode 100644 index 0000000..00a07e1 --- /dev/null +++ b/Assets/Scripts/Spawning/InsectSculptureMeshes.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace InsectGame.Spawning +{ + /// Finite sculpture catalogue: shared meshes, never per insect allocations. + /// Local coordinates match InsectEntity anatomy; consumers must not destroy these meshes. + public static class InsectSculptureMeshes + { + public enum Shape + { + RhinoHorn, RhinoForkLeft, RhinoForkRight, ThoraxHorn, RhinoPronotum, + ElytronLeft, ElytronRight, StagJawLeft, StagJawRight, + MantisThorax, MantisFemurLeft, MantisFemurRight, + MantisBladeLeft, MantisBladeRight, MantisHead, Wing, + ButterflyForewing, ButterflyHindwing, DragonflyWing + } + + private static readonly Dictionary Cache = new Dictionary(); + public static int CachedCount => Cache.Count; + + public static Mesh Get(Shape shape) + { + if (!Enum.IsDefined(typeof(Shape), shape)) throw new ArgumentOutOfRangeException(nameof(shape)); + if (Cache.TryGetValue(shape, out Mesh mesh) && mesh != null) return mesh; + mesh = Build(shape); + mesh.name = "InsectSculpture_" + shape; + mesh.hideFlags = HideFlags.HideAndDontSave; + Cache[shape] = mesh; + return mesh; + } + + // Domain-reload-disabled sessions also release their previous finite catalogue. + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetCache() + { + foreach (Mesh mesh in Cache.Values) + { + if (mesh == null) continue; + if (Application.isPlaying) UnityEngine.Object.Destroy(mesh); + else UnityEngine.Object.DestroyImmediate(mesh); + } + Cache.Clear(); + } + + private static Vector3 V(float x, float y, float z) => new Vector3(x, y, z); + + private static Mesh Build(Shape shape) + { + switch (shape) + { + case Shape.RhinoHorn: + return Sweep(V(0f, .15f, .57f), V(0f, .47f, .62f), V(0f, .78f, .85f), V(0f, .78f, 1.04f), .105f, .028f, 0f, .78f); + case Shape.RhinoForkLeft: + case Shape.RhinoForkRight: + return Mirrored(Sweep(V(0f, .78f, 1.01f), V(.05f, .80f, 1.05f), V(.13f, .82f, 1.08f), V(.16f, .83f, 1.20f), .033f, .002f, 0f, .72f), shape == Shape.RhinoForkLeft); + case Shape.RhinoPronotum: + return Sweep(V(0f, .08f, .10f), V(0f, .16f, .22f), V(0f, .14f, .48f), + V(0f, .08f, .65f), .18f, .14f, .075f, .65f); + case Shape.ThoraxHorn: + return Sweep(V(0f, .20f, .22f), V(0f, .35f, .32f), V(0f, .46f, .44f), V(0f, .48f, .65f), .10f, .004f, 0f, .8f); + case Shape.ElytronLeft: + case Shape.ElytronRight: + return Mirrored(Elytron(), shape == Shape.ElytronLeft); + case Shape.StagJawLeft: + case Shape.StagJawRight: + return Mirrored(Sweep(V(.14f, .12f, .80f), V(.41f, .13f, .95f), V(.35f, .14f, 1.23f), V(.06f, .14f, 1.39f), .085f, .003f, .006f, .60f), shape == Shape.StagJawLeft); + case Shape.MantisThorax: + return Sweep(V(0f, -.015f, -.20f), V(0f, .08f, .02f), V(0f, .25f, .21f), V(0f, .32f, .31f), .075f, .046f, .008f, .82f); + case Shape.MantisFemurLeft: + case Shape.MantisFemurRight: + return Mirrored(Sweep(V(.075f, .18f, .17f), V(.24f, .16f, .25f), V(.27f, .25f, .43f), V(.23f, .38f, .53f), .028f, .022f, .009f, .58f), shape == Shape.MantisFemurLeft); + case Shape.MantisBladeLeft: + case Shape.MantisBladeRight: + return Mirrored(Sweep(V(.235f, .34f, .51f), V(.23f, .29f, .50f), V(.21f, .245f, .465f), V(.20f, .22f, .42f), .021f, .004f, .004f, .55f), shape == Shape.MantisBladeLeft); + case Shape.MantisHead: + return Head(); + case Shape.Wing: + return Sweep(V(-.5f, 0f, 0f), V(-.17f, .08f, .07f), V(.22f, .04f, .09f), V(.5f, 0f, 0f), .002f, .002f, .49f, 1f); + case Shape.ButterflyForewing: + return WingPlate(new[] { + new Vector2(-.50f, -.04f), new Vector2(-.44f, .20f), + new Vector2(-.30f, .43f), new Vector2(-.08f, .54f), + new Vector2(.14f, .50f), new Vector2(.34f, .34f), + new Vector2(.50f, .08f), new Vector2(.45f, -.12f), + new Vector2(.26f, -.18f), new Vector2(.02f, -.14f), + new Vector2(-.25f, -.17f) + }, .014f); + case Shape.ButterflyHindwing: + return WingPlate(new[] { + new Vector2(-.47f, .13f), new Vector2(-.30f, .25f), + new Vector2(-.07f, .27f), new Vector2(.18f, .21f), + new Vector2(.42f, .08f), new Vector2(.50f, -.14f), + new Vector2(.36f, -.38f), new Vector2(.12f, -.51f), + new Vector2(-.14f, -.47f), new Vector2(-.39f, -.28f) + }, .012f); + case Shape.DragonflyWing: + return WingPlate(new[] { + new Vector2(-.50f, 0f), new Vector2(-.40f, .075f), + new Vector2(-.16f, .14f), new Vector2(.18f, .13f), + new Vector2(.43f, .065f), new Vector2(.50f, 0f), + new Vector2(.39f, -.075f), new Vector2(.10f, -.12f), + new Vector2(-.20f, -.11f), new Vector2(-.42f, -.06f) + }, .008f); + default: throw new ArgumentOutOfRangeException(nameof(shape)); + } + } + + // A thin closed plate gives flying insects a readable wing outline from + // above and from the battle camera's shallow angle. Shared by every + // instance of a family, so the extra contour has no per-spawn mesh cost. + private static Mesh WingPlate(Vector2[] outline, float halfThickness) + { + int count = outline.Length; + var vertices = new Vector3[count * 2 + 2]; + var uv = new Vector2[vertices.Length]; + var triangles = new int[count * 12]; + Vector2 center = Vector2.zero; + for (int i = 0; i < count; i++) center += outline[i] / count; + for (int i = 0; i < count; i++) + { + vertices[i] = V(outline[i].x, halfThickness, outline[i].y); + vertices[i + count] = V(outline[i].x, -halfThickness, outline[i].y); + uv[i] = uv[i + count] = outline[i] + Vector2.one * .5f; + } + int top = count * 2, bottom = top + 1; + vertices[top] = V(center.x, halfThickness, center.y); + vertices[bottom] = V(center.x, -halfThickness, center.y); + uv[top] = uv[bottom] = center + Vector2.one * .5f; + int k = 0; + for (int i = 0; i < count; i++) + { + int next = (i + 1) % count; + triangles[k++] = top; triangles[k++] = i; triangles[k++] = next; + triangles[k++] = bottom; triangles[k++] = next + count; triangles[k++] = i + count; + triangles[k++] = i; triangles[k++] = i + count; triangles[k++] = next; + triangles[k++] = i + count; triangles[k++] = next + count; triangles[k++] = next; + } + var mesh = new Mesh { vertices = vertices, triangles = triangles, uv = uv }; + mesh.RecalculateNormals(); + mesh.RecalculateBounds(); + return mesh; + } + + private static Mesh Elytron() + { + Mesh mesh = Sweep(V(.205f, -.005f, -.54f), V(.215f, .145f, -.30f), + V(.215f, .145f, .09f), V(.205f, .045f, .34f), .015f, .045f, .18f, .64f); + Vector3[] vertices = mesh.vertices; + for (int i = 0; i < vertices.Length; i++) + { + // Curl the outer lip into the abdomen. A symmetric tube left its + // farthest lateral edge suspended above the narrower body. + float outer = Mathf.Clamp01((vertices[i].x - .215f) / .205f); + vertices[i].y -= .09f * outer * outer; + } + mesh.vertices = vertices; + mesh.RecalculateNormals(); + mesh.RecalculateBounds(); + return mesh; + } + + private static Mesh Mirrored(Mesh mesh, bool mirror) + { + if (!mirror) return mesh; + Vector3[] vertices = mesh.vertices; + for (int i = 0; i < vertices.Length; i++) vertices[i].x = -vertices[i].x; + int[] indices = mesh.triangles; + for (int i = 0; i < indices.Length; i += 3) + { + int index = indices[i]; indices[i] = indices[i + 1]; indices[i + 1] = index; + } + mesh.vertices = vertices; + mesh.triangles = indices; + mesh.RecalculateNormals(); + mesh.RecalculateBounds(); + return mesh; + } + + // Closed cubic sweep, 17 interior rings × 10 sides + 2 rounded poles. Parallel-transported + // frames avoid the twist discontinuity of crossing a fixed world-up vector. + private static Mesh Sweep(Vector3 a, Vector3 b, Vector3 c, Vector3 d, + float startRadius, float endRadius, float bulge, float flatten) + { + const int rings = 17, sides = 10; + var vertices = new Vector3[rings * sides + 2]; + var uv = new Vector2[vertices.Length]; + var indices = new int[((rings - 1) * sides * 2 + sides * 2) * 3]; + Vector3 previousTangent = (b - a).normalized; + Vector3 reference = Mathf.Abs(Vector3.Dot(previousTangent, Vector3.up)) < .9f ? Vector3.up : Vector3.forward; + Vector3 normal = Vector3.Cross(previousTangent, reference).normalized; + for (int i = 0; i < rings; i++) + { + float t = (i + 1f) / (rings + 1f), u = 1f - t; + Vector3 center = u * u * u * a + 3f * u * u * t * b + 3f * u * t * t * c + t * t * t * d; + Vector3 tangent = (3f * u * u * (b - a) + 6f * u * t * (c - b) + 3f * t * t * (d - c)).normalized; + normal = Quaternion.FromToRotation(previousTangent, tangent) * normal; + Vector3 binormal = Vector3.Cross(tangent, normal).normalized; + float radius = Mathf.Lerp(startRadius, endRadius, t) + bulge * Mathf.Sin(Mathf.PI * t); + // The terminal vertices are true poles, not centers of flat cut + // disks. Ease the nearby rings into each pole like a rounded cap. + float capProfile = Mathf.Sqrt(Mathf.Clamp01(Mathf.Min(t, 1f - t) / .12f)); + radius *= capProfile; + for (int j = 0; j < sides; j++) + { + float angle = 2f * Mathf.PI * j / sides; + vertices[i * sides + j] = center + radius * (Mathf.Cos(angle) * normal + Mathf.Sin(angle) * binormal * flatten); + uv[i * sides + j] = new Vector2(j / (float)sides, t); + } + previousTangent = tangent; + } + int k = 0; + for (int i = 0; i < rings - 1; i++) + for (int j = 0; j < sides; j++) + { + int p = i * sides + j, q = i * sides + (j + 1) % sides; + indices[k++] = p; indices[k++] = q; indices[k++] = p + sides; + indices[k++] = q; indices[k++] = q + sides; indices[k++] = p + sides; + } + int start = rings * sides, end = start + 1; + vertices[start] = a; vertices[end] = d; + for (int j = 0; j < sides; j++) + { + int next = (j + 1) % sides; + indices[k++] = start; indices[k++] = next; indices[k++] = j; + indices[k++] = end; indices[k++] = (rings - 1) * sides + j; indices[k++] = (rings - 1) * sides + next; + } + Mesh mesh = new Mesh { vertices = vertices, triangles = indices, uv = uv }; + mesh.RecalculateNormals(); mesh.RecalculateBounds(); + return mesh; + } + + private static Mesh Head() + { + // Rounded triangular head: narrow forward chin, broad eye-bearing crown. + // The old six-vertex prism presented a flat rectangular side to the camera. + return Sweep(V(0f, .22f, .39f), V(0f, .27f, .35f), V(0f, .36f, .30f), + V(0f, .40f, .28f), .018f, .16f, .02f, .42f); + } + } +} diff --git a/Assets/Scripts/Spawning/InsectSculptureMeshes.cs.meta b/Assets/Scripts/Spawning/InsectSculptureMeshes.cs.meta new file mode 100644 index 0000000..f77dc2d --- /dev/null +++ b/Assets/Scripts/Spawning/InsectSculptureMeshes.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fbbfc16e6d79dfd47999653eb1291feb \ No newline at end of file diff --git a/Assets/Scripts/Spawning/InsectSpawner.cs b/Assets/Scripts/Spawning/InsectSpawner.cs index 532a5b6..aa63a48 100644 --- a/Assets/Scripts/Spawning/InsectSpawner.cs +++ b/Assets/Scripts/Spawning/InsectSpawner.cs @@ -5,6 +5,39 @@ namespace InsectGame.Spawning { + /// 필드 리전 하나의 표 — 풀과 레벨 대역(). + public readonly struct FieldRegionTable + { + public readonly string RegionId; + public readonly int MinLevel; + public readonly int MaxLevel; + public readonly IReadOnlyList Pool; + + public FieldRegionTable(string regionId, int minLevel, int maxLevel, IReadOnlyList pool) + { + RegionId = regionId; + MinLevel = minLevel; + MaxLevel = maxLevel; + Pool = pool; + } + } + + /// + /// 필드 곤충 개체군 — 기록(슬롯)이 곧 개체이고, 월드의 곤충은 가까울 때만 빌려 쓰는 몸이다. + /// + /// 리전마다 면적에 비례한 슬롯()이 있고 각 슬롯이 종·레벨·색다름·자리를 기록한다. + /// 플레이어 45m 안의 산 슬롯만 풀에서 몸을 꺼내 세우고, 55m 밖이면 몸만 풀로 돌린다. 그래서 + /// 리전을 오가도 곤충이 새로 굴려지지 않는다 — 돌아오면 같은 곤충이 같은 자리에 있다. + /// 새 개체가 들어서는 건 시간뿐이다: 잡거나 이기거나 놓친 자리는 1~2분 뒤에, 수명(4~7분)이 다한 자리는 + /// 플레이어 눈 밖에서 그 시점의 시간대·날씨로 바뀐다. + /// + /// 예전엔 5초마다 1마리씩 상한(리전 10·전체 32)까지 채우고 60m 밖은 지웠으며, 8초마다 현재 리전 스폰 + /// 포인트를 플레이어 둘레 10~43m로 끌어왔다 — 리전을 옮기면 옛 곤충은 사라지고 새로 굴린 곤충이 채워졌다 + /// (지역 이동 리롤). 서브에리어도 들어갈 때마다 새로 굴렸다. + /// + /// 수치·규칙은 , 슬롯 전이는 이 든다. 세션 간에는 + /// 저장하지 않는다 — 로드하면 새로 채운다. + /// public class InsectSpawner : MonoBehaviour { [Header("References")] @@ -15,45 +48,148 @@ public class InsectSpawner : MonoBehaviour [Header("Spawn Settings")] [SerializeField] private GameObject defaultPrefab; - // 메인 필드 곤충이 너무 적던 문제 — 동시/리전/초기 수를 늘리고 스폰 간격을 단축. - // 맵 1.5배(면적 2.25배) 확장에 맞춰 상한 재상향 (18/12/7 → 32/20/10). - [SerializeField] private float spawnIntervalSeconds = 5f; + // 동시 실체 상한 — 기록(슬롯) 수가 아니라 월드에 세운 몸의 수다. 플레이어 45m 안에는 보통 10~14마리가 선다. [SerializeField] private int maxActiveTotal = 32; [SerializeField] private int prewarmPoolSize = 32; - [SerializeField] private int initialSpawnCount = 20; - [SerializeField] private int maxActivePerRegion = 10; + [SerializeField] private int subAreaActiveCount = 2; + [SerializeField] private float subAreaRespawnSeconds = 45f; /// 정화 직후 곤충을 되돌리기까지의 사이(초) — 연출이 끝나고 채운다. private const float RepopulateDelaySeconds = 1.2f; - /// 정화 직후 한 번에 채우는 최대 마리 수(상한에 걸리면 그 전에 멈춘다). - private const int RepopulateBurst = 6; - private Core.RegionBlightManager blight; - [SerializeField] private int subAreaActiveCount = 2; - [SerializeField] private float subAreaRespawnSeconds = 45f; + /// + /// 정화 직후 플레이어 둘레 고리(~m)에 세우는 수(귀환종 포함). + /// 나머지 늘어난 자리는 리전 전체에 흩는다 — 전부 발밑에 몰면 오염 전보다 붐빈다. + /// + private const int RepopulateNearCount = 5; + private const float NearPlayerRingMin = 10f; + private const float NearPlayerRingMax = 30f; - private float spawnTimer; - private float cleanupTimer; - private float relocateTimer; - private float subAreaRespawnTimer; - private readonly List activeInsects = new List(); + private Core.RegionBlightManager blight; + private OutfitBonusProvider outfitBonus; + private RegionManager regionManager; + private GameClock subscribedClock; + private SubAreaData currentSubArea; + private string currentSubAreaKey; - /// 현재 활성 곤충 목록(읽기 전용) — NPC 등 외부 시스템이 FindObjectsByType 없이 소비. - public IReadOnlyList ActiveInsects => activeInsects; private SimpleObjectPool pool; private bool poolInitialized; - private int totalSpawned; - private RegionManager regionManager; - private Data.SubAreaData currentSubArea; + private readonly List activeInsects = new List(); + + /// 지금 월드에 서 있는 곤충(읽기 전용) — NPC 등 외부 시스템이 FindObjectsByType 없이 소비. + public IReadOnlyList ActiveInsects => activeInsects; + /// + /// 영웅·전설 곤충이 월드에 섰다(몸을 세울 때마다) — 지도(RegionMapUI)가 레이드 표식을 단다. + /// 멀어졌다 돌아와 같은 개체가 다시 서면 다시 울린다. 옛 표식은 몸이 풀로 돌아가면 지도가 스스로 지우고, + /// 풀에서 같은 몸이 다른 개체로 되살아난 경우는 로 가린다. + /// public event System.Action RaidBossSpawned; + /// + /// 스토리가 지금 기다리는 포획 종 — (리전 ID, 채울 목록). 부트스트랩이 StoryDirector에 배선한다. + /// 스포닝이 스토리를 직접 참조하지 않게 정적 훅으로 둔다(와 + /// 같은 형태). null이면 보조 없이 등급표대로만 굴린다. + /// + public static System.Action> StoryCaptureTargetProvider; + + private readonly FieldPopulation population = new FieldPopulation(); + private readonly Dictionary slotByEntity = new Dictionary(); + private readonly List regionInfos = new List(); + private readonly Dictionary regionInfoById = new Dictionary(); + private readonly List pendingCleansed = new List(); + + /// + /// 정화 복구()를 기다리는 리전 — 그동안 평소 틱이 슬롯을 늘리지 않는다. + /// + /// 정화는 목록에 먼저 들고 이벤트가 나중이라, 늘어난 상한을 다음 틱(0.5초 안)이 먼저 보고 새 슬롯을 평소 규칙 + /// (리전 전역·플레이어 20m 밖)으로 채워 버렸다. 1.2초 뒤의 복구는 빈 자리가 없어 "플레이어 둘레 다섯 마리"를 + /// 한 번도 못 했고, 귀환종이 먼 곳에 먼저 뽑히면 그것마저 가까이 오지 않았다(2026-09-29 리뷰). + /// + private readonly HashSet cleanseHold = new HashSet(); + + /// 한 틱에 새로 굴리는 최대 수 — 서브에리어에 오래 있다 나오면 만료된 자리가 한꺼번에 몰린다. + private const int MaxRollsPerTick = 24; + + private readonly List rotationWanted = new List(); + + private float tickTimer; + private bool fieldFillAllowed; // 물 기록을 기다렸다(Start) + private bool fieldReady; // 메인 필드 첫 채우기가 끝났다 + private Vector3 subAreaAnchor; // 서브에리어 진입 직후 플레이어 자리 — 방 안 판정의 눈 + private bool subAreaAnchored; + private bool storyProviderWarned; + + // 틱마다 재사용하는 버퍼 — Update 경로에서 할당하지 않는다. + private readonly List materializeQueue = new List(); + private readonly List regionCandidates = new List(); + private readonly List rarityCandidates = new List(); + private readonly bool[] rarityAvailable = new bool[FieldSpawnRules.RarityCount]; + private readonly List storyWanted = new List(); + private readonly HashSet storyCandidateIds = new HashSet(); + private readonly HashSet storyAliveIds = new HashSet(); + // 종 고르기 가중 배수(등급 후보와 같은 순서) — 성향(InsectHabits)을 곱한 값. 굴릴 때마다 비우고 다시 채운다. + private readonly List rarityMultipliers = new List(); + private readonly Collider[] bodyProbeHits = new Collider[16]; + private static readonly System.Comparison ByDistance = (a, b) => a.SortKey.CompareTo(b.SortKey); + + // 자리 굴리기 고리 — PickSpawnPosition에 넘기는 대리자를 매번 새로 만들지 않게 필드로 든다. + private Vector3 rollCenter; + private float rollInnerRadius; + private float rollOuterRadius; + private System.Func rollInRing; + + /// 리전 하나의 스폰 표 — 풀·레벨 대역은 스폰 포인트가, 원판·서브에리어 게이트·귀환종은 리전 정의가 든다. + private sealed class RegionSpawnInfo + { + public string RegionId; + public Vector3 Center; + public float Radius; + public int MinLevel; + public int MaxLevel; + public SubAreaData[] SubAreas; + public RegionData Data; + /// 오염 전 슬롯 수(설 수 있는 땅 × 밀도). 오염 감소는 만 얹는다. + public int BaseSlots; + public readonly List Pool = new List(); + + // 이 리전에서 보이는 시간대·날씨(설산은 비가 눈, 사막은 눈이 센바람)와 그 상태에 나올 수 있는 종 ID. + // 리전마다 따로 든다 — 같은 세계 날씨라도 리전의 후보가 다르다. 상태가 바뀔 때만 다시 만든다. + public bool StateValid; + public WorldState State; + public readonly HashSet CandidateIds = new HashSet(); + } + + /// + /// 초기 채우기가 물 기록()을 기다리는 최대 프레임 수. 장식 빌더도 + /// 부트스트랩 Awake에서 붙어 같은 프레임의 Start에서 물을 적는데, 같은 프레임 Start끼리의 순서는 정해져 있지 않다 — + /// 스포너가 먼저 돌면 나루터 물가·습지 웅덩이·강을 모른 채 첫 개체군을 놓는다. 한 프레임이면 모든 Start가 끝난다. + /// 장식 빌더가 없는 씬(월드 미생성·테스트)에서도 영원히 기다리지 않게 상한을 둔다. + /// + private const int WaterWaitFrames = 2; + private void Start() { EnsureSelfSufficient(); TryInitPool(); - SpawnInitialInsects(); + if (RegionDressingBuilder.WaterRecorded) AllowFieldFill(); + else StartCoroutine(AllowFieldFillAfterWater()); + } + + private System.Collections.IEnumerator AllowFieldFillAfterWater() + { + for (int i = 0; i < WaterWaitFrames && !RegionDressingBuilder.WaterRecorded; i++) + yield return null; + AllowFieldFill(); + } + + private void AllowFieldFill() + { + fieldFillAllowed = true; + // 그 사이 서브에리어로 들어갔을 수 있다(마지막 서브에리어 복원) — 그땐 메인 월드 콜라이더가 숨겨져 + // 자리를 못 잰다. 나오는 첫 틱에 채운다(Update). + if (currentSubArea == null) FillField(); } private void EnsureSelfSufficient() @@ -88,7 +224,10 @@ private void EnsureSelfSufficient() { worldStateProvider = FindFirstObjectByType(); if (worldStateProvider != null) + { Debug.Log("[InsectSpawner] WorldStateProvider 자동 탐색 완료"); + SubscribeClock(); + } } if (database == null) @@ -150,466 +289,843 @@ private void TryInitPool() private void Update() { - if (database == null || worldStateProvider == null || spawnPoints == null || spawnPoints.Length == 0) + if (database == null || defaultPrefab == null) { return; } - if (defaultPrefab == null) + tickTimer += Time.deltaTime; + if (tickTimer < FieldSpawnRules.TickSeconds) { return; } + tickTimer = 0f; - // SubArea 진입 중: 메인 월드 스폰/정리는 스킵하되, SubArea 곤충이 캡처/배틀로 줄어들면 - // 재스폰. 빈 SubArea가 되는 것 방지. - if (currentSubArea != null) - { - CleanupDeadEntities(); - if (currentSubArea.exclusiveInsectIds != null - && currentSubArea.exclusiveInsectIds.Length > 0 - && activeInsects.Count < 1) - { - subAreaRespawnTimer += Time.deltaTime; - if (subAreaRespawnTimer >= subAreaRespawnSeconds) - { - subAreaRespawnTimer = 0f; - SpawnSubAreaInsects(currentSubArea); - } - } - return; - } - subAreaRespawnTimer = 0f; + CleanupDeadEntities(); - cleanupTimer += Time.deltaTime; - if (cleanupTimer > 5f) - { - cleanupTimer = 0f; - CleanupDeadEntities(); - DespawnFarInsects(); - } - - relocateTimer += Time.deltaTime; - if (relocateTimer > 8f) - { - relocateTimer = 0f; - RelocateSpawnPoints(); - } - - if (activeInsects.Count >= maxActiveTotal) + // 서브에리어 안: 메인 필드 기록은 그대로 두고(시간은 계속 흐른다) 방의 슬롯만 돌본다. + if (currentSubArea != null) { + TickSubArea(); return; } - spawnTimer += Time.deltaTime; - if (spawnTimer < spawnIntervalSeconds) + if (!fieldReady) { - return; + if (fieldFillAllowed) FillField(); + if (!fieldReady) return; } - spawnTimer = 0f; - string underpopulatedRegion = GetUnderpopulatedRegionId(); - TrySpawn(underpopulatedRegion); + TickField(); } - private void TrySpawn() - { - TrySpawn(null); - } + // ======= 메인 필드 — 채우기 · 재생 · 순환 · 실체화 ======= - private void TrySpawn(string preferredRegionId) + /// + /// 시작 채우기 — 모든 리전의 슬롯을 한 번에 굴린다. 기록은 싸다(몸은 플레이어 둘레에만 선다). + /// 리전별로 따로 채우던 옛 흐름(현재 리전만 채우고, 옮기면 다시)은 없다. + /// + private void FillField() { + if (fieldReady || currentSubArea != null) return; + if (!BuildRegionInfos()) return; TryInitPool(); + // 부트스트랩이 막 세운 콜라이더(나무 줄기·건물·바위)를 잰다 — 자동 동기화가 꺼져 있어 + // (DynamicsManager m_AutoSyncTransforms 0) 첫 물리 스텝 전엔 옮긴 자리를 모른다. + Physics.SyncTransforms(); - SpawnPoint point = GetAvailableSpawnPoint(preferredRegionId); - if (point == null) + float now = Time.time; + for (int k = 0; k < regionInfos.Count; k++) { - return; + RegionSpawnInfo info = regionInfos[k]; + if (!cleanseHold.Contains(info.RegionId)) ReconcileRegionSlots(info, now); + List slots = population.SlotsOf(info.RegionId); + for (int i = 0; i < slots.Count; i++) + { + if (FieldPopulation.IsDueForRoll(slots[i], now)) + RollFieldSlot(slots[i], info, now, FieldSpawnRules.InitialSpawnMinPlayerDistance, + FieldSpawnRules.RollInitialLifetime(Random.value)); + } } + fieldReady = true; + + if (TryGetPlayerPosition(out Vector3 playerPos)) SyncFieldBodies(playerPos); - if (!string.IsNullOrEmpty(point.regionId) && CountActiveInRegion(point.regionId) >= RegionCap(point.regionId)) + // 한 번만 남기는 요약 — 배치 캡처·기기 로그로 개체군 크기와 첫 화면의 몸 수를 확인한다. + int slotTotal = 0, alive = 0; + var perRegion = new System.Text.StringBuilder(); + for (int k = 0; k < regionInfos.Count; k++) { - return; + List slots = population.SlotsOf(regionInfos[k].RegionId); + slotTotal += slots.Count; + for (int i = 0; i < slots.Count; i++) if (slots[i].IsAlive) alive++; + perRegion.Append(k > 0 ? ", " : string.Empty).Append(regionInfos[k].RegionId).Append(' ') + .Append(slots.Count).Append('/').Append(regionInfos[k].BaseSlots); } + Debug.Log($"[InsectSpawner] 필드 개체군 채움 — 리전 {regionInfos.Count}곳, 슬롯 {slotTotal}(산 개체 {alive}), " + + $"지금 선 몸 {activeInsects.Count} | 리전별 슬롯/기본: {perRegion}"); + } + + private void TickField() + { + float now = Time.time; - WorldState state = worldStateProvider.GetWorldState(); - List candidates = database.GetCandidates(state); - if (candidates == null || candidates.Count == 0) + // 서브에리어 안에서 정화된 리전 — 메인 월드가 보이는 지금 채운다. + if (pendingCleansed.Count > 0) { - return; + for (int i = 0; i < pendingCleansed.Count; i++) RepopulateCleansed(pendingCleansed[i]); + pendingCleansed.Clear(); } - if (point.regionInsectIds != null && point.regionInsectIds.Length > 0) + bool hasPlayer = TryGetPlayerPosition(out Vector3 playerPos); + int rolls = 0; + for (int k = 0; k < regionInfos.Count; k++) { - List regionFiltered = new List(); - foreach (var c in candidates) + RegionSpawnInfo info = regionInfos[k]; + // 오염 상한은 로그인·클라우드 재적재로도 바뀐다 — 매 틱 맞춘다(바뀌지 않았으면 비교만 한다). + // 정화 복구를 기다리는 리전은 복구가 늘린다(cleanseHold 주석). + if (!cleanseHold.Contains(info.RegionId)) ReconcileRegionSlots(info, now); + + List slots = population.SlotsOf(info.RegionId); + for (int i = 0; i < slots.Count && rolls < MaxRollsPerTick; i++) { - foreach (string rid in point.regionInsectIds) + FieldSlot slot = slots[i]; + if (FieldPopulation.IsDueForRoll(slot, now)) { - if (c.insectId == rid) { regionFiltered.Add(c); break; } + // 빈 자리 재생 — 그 시점의 시간대·날씨로, 플레이어 코앞은 피해서. + RollFieldSlot(slot, info, now, FieldSpawnRules.SpawnMinPlayerDistance, + FieldSpawnRules.RollLifetime(Random.value)); + rolls++; + continue; } + + if (!FieldPopulation.IsExpired(slot, now)) continue; + float d = hasPlayer ? PlanarDistance(slot.Position, playerPos) : float.PositiveInfinity; + bool busy = slot.IsMaterialized && IsBusy(slot.Entity); + if (!FieldPopulation.CanRotate(slot, now, d, busy)) continue; + // 본편이 기다리는 포획 목표종은 수명으로 바꾸지 않는다 — 찾으러 오는 사이 사라지면 보조(20%)로 + // 다시 나오기까지 또 기다려야 한다. 비트가 발화하면 목표에서 빠져 다음 수명에 평소대로 돈다. + if (IsStoryCaptureTarget(info.RegionId, slot.InsectId)) continue; + rolls++; + + // 수명 순환 — 몸이 있으면(25m 밖) 먼저 거두고 그 자리를 새 개체로 바꾼다. + RecallSlot(slot); + FieldPopulation.Vacate(slot, now); + // 자리가 상한보다 많다(보너스가 걷혔거나 오염이 번졌다) — 이 자리는 다시 굴리지 않는다. 비운 자리는 다음 틱 + // ReconcileRegionSlots가 뺀다. 여기서 또 굴리면 서 있던 몸이 새 개체로 이어져 남는다. 몸이 선 자리는 이 길을 + // 수명이 다하고 플레이어 25m 밖일 때만 타므로, 눈앞의 곤충이 사라지는 일은 없다. + if (slots.Count > RegionCap(info)) continue; + RollFieldSlot(slot, info, now, FieldSpawnRules.SpawnMinPlayerDistance, + FieldSpawnRules.RollLifetime(Random.value)); } - if (regionFiltered.Count > 0) - candidates = regionFiltered; } - // (서브에리어 전용 곤충 필터가 여기 있었으나 **도달할 수 없는 코드**였다. - // Update는 currentSubArea가 있으면 조기 반환하고, TrySpawn의 다른 두 호출부 - // — SpawnInitialInsects(Start)와 RepopulateCleansedRegion(서브에리어 가드) — 도 - // 서브에리어 중에는 오지 않는다. 서브에리어 스폰은 SpawnSubAreaInsects가 전담한다. - // 게다가 검사에 쓰던 GetRandomPosition()이 실제 스폰 좌표와 **다른 난수**라 - // 살아 있었다 해도 엉뚱한 지점으로 판정했다.) + if (hasPlayer) SyncFieldBodies(playerPos); + } - float rareBoost = (itemEffects != null ? itemEffects.GetRareSpawnMultiplier() : 1f) - * (outfitBonus != null ? outfitBonus.GetRareSpawnMultiplier() : 1f); - InsectData selected = rareBoost > 1f - ? database.GetWeightedRandomWithRareBoost(candidates, rareBoost) - : database.GetWeightedRandom(candidates); - if (selected == null) + /// + /// 몸을 세우고 거둔다. 세우기가 먼저다 — 먼저 거두면 방금 풀로 돌린 몸이 같은 틱에 다른 개체로 다시 나와, + /// 그 참조를 쥔 쪽(지도 레이드 표식·아이 NPC 예약)이 한 프레임 안의 교체를 못 본다. + /// + private void SyncFieldBodies(Vector3 playerPos) + { + materializeQueue.Clear(); + for (int k = 0; k < regionInfos.Count; k++) { - return; + List slots = population.SlotsOf(regionInfos[k].RegionId); + for (int i = 0; i < slots.Count; i++) + { + FieldSlot slot = slots[i]; + if (!slot.IsAlive || slot.IsMaterialized) continue; + float d = PlanarDistance(slot.Position, playerPos); + if (!FieldPopulation.ShouldMaterialize(slot, d)) continue; + slot.SortKey = d; + materializeQueue.Add(slot); + } } + // 동시 상한에 걸리면 가까운 것부터 선다. + if (materializeQueue.Count > 1) materializeQueue.Sort(ByDistance); + for (int i = 0; i < materializeQueue.Count && activeInsects.Count < maxActiveTotal; i++) + Materialize(materializeQueue[i], materializeQueue[i].Key); - SpawnAt(selected, point); + for (int i = activeInsects.Count - 1; i >= 0; i--) + { + if (i >= activeInsects.Count) continue; + InsectEntity e = activeInsects[i]; + if (ReferenceEquals(e, null) || !slotByEntity.TryGetValue(e, out FieldSlot slot) || slot.IsSubArea) continue; + if (FieldPopulation.ShouldRecall(slot, PlanarDistance(slot.Position, playerPos), IsBusy(e))) + RecallSlot(slot); + } } /// - /// 고른 종을 이 스폰 포인트에 실제로 띄운다 — 풀 취득·배치·초기화·집계. - /// - /// (무작위 선택)과 (종 지정)이 - /// 공유한다. 사본을 두면 풀 반환·레이드 통지·erased 확률 중 하나가 한쪽에만 반영돼 - /// 조용히 어긋난다. + /// 빈 슬롯에 새 개체를 들인다 — 종(등급표 → 리전 풀)·자리(리전 원판 전체)·레벨(리전 대역)을 한 번 굴려 기록한다. + /// 종이나 자리를 못 구하면 뒤에 다시 굴린다(스폰을 영영 건너뛰지 않는다). /// - private void SpawnAt(InsectData selected, SpawnPoint point) + private bool RollFieldSlot(FieldSlot slot, RegionSpawnInfo info, float now, float minPlayerDistance, + float lifetime, InsectData forced = null, bool nearPlayer = false) { - if (selected == null || point == null) return; - - GameObject prefab = selected.prefabOverride != null ? selected.prefabOverride : defaultPrefab; - if (prefab == null) + InsectData data = forced != null ? forced : PickFieldSpecies(info); + if (data == null || !TryPickFieldPosition(info, minPlayerDistance, nearPlayer, out Vector3 position)) { - return; + FieldPopulation.Vacate(slot, now + FieldSpawnRules.RetrySeconds); + return false; } - GameObject instance; - if (pool != null && prefab == defaultPrefab) - { - instance = pool.Get(); - } - else + int level = FieldSpawnRules.RollFieldLevel(info.MinLevel, info.MaxLevel, Random.value); + // 색다름·지워짐도 여기서 한 번만 굴린다 — 몸을 다시 세울 때마다 굴리면 같은 곤충의 색이 바뀐다. + bool shiny = Random.value < InsectEntity.FieldShinyChance; + float erasedChance = GetErasedChance(info.RegionId); + bool erased = erasedChance > 0f && Random.value < erasedChance; + FieldPopulation.Fill(slot, data.insectId, data, level, shiny, erased, position, now + lifetime); + return true; + } + + /// + /// 종 고르기 — 등급을 먼저 전역 표로 굴리고(, 레어 부스트 포함), + /// 그 등급의 후보(리전 풀 ∩ 이 리전에서 보이는 시간대·날씨) 안에서 spawnWeight × 성향 배수로 종을 고른다. + /// 그래서 등급 분포는 리전·시간·날씨와 무관하다(성향 배수는 같은 등급 안의 상대 비율만 바꾼다). 스토리가 기다리는 종이 + /// 그 리전에 없으면 일정 확률로 그 종을 먼저 준다. + /// + private InsectData PickFieldSpecies(RegionSpawnInfo info) + { + if (info.Pool.Count == 0) return null; + + regionCandidates.Clear(); + bool hasState = RefreshStateCandidates(info); + if (hasState) { - instance = Instantiate(prefab, transform); + for (int i = 0; i < info.Pool.Count; i++) + if (info.CandidateIds.Contains(info.Pool[i].insectId)) regionCandidates.Add(info.Pool[i]); } - instance.SetActive(true); - instance.transform.position = point.GetRandomPosition(); + // 시간대·날씨에 리전 풀이 통째로 걸러지면 풀 전체로 굴린다. 옛 코드는 이때 전역 DB 후보로 새서 + // 그 리전과 무관한 종(초원 한복판에 유적 전설)이 뜰 수 있었다. + if (regionCandidates.Count == 0) regionCandidates.AddRange(info.Pool); - InsectEntity entity = instance.GetComponent(); - if (entity == null) + InsectData story = PickStoryAssist(info); + if (story != null) return story; + + for (int r = 0; r < rarityAvailable.Length; r++) rarityAvailable[r] = false; + for (int i = 0; i < regionCandidates.Count; i++) { - entity = instance.AddComponent(); + int r = (int)regionCandidates[i].rarity; + if (r >= 0 && r < rarityAvailable.Length) rarityAvailable[r] = true; } + int rarity = FieldSpawnRules.PickRarity(Random.value, RareSpawnBoost(), rarityAvailable); + if (rarity < 0) return null; - point.NotifySpawned(); - int level = GetSpawnLevel(selected, point); - entity.Initialize(selected, level, point, DespawnEntity, GetErasedChance(point.regionId)); - activeInsects.Add(entity); - totalSpawned++; - - if (selected.rarity == InsectRarity.Epic || selected.rarity == InsectRarity.Legendary) + rarityCandidates.Clear(); + rarityMultipliers.Clear(); + for (int i = 0; i < regionCandidates.Count; i++) { - RaidBossSpawned?.Invoke(entity); + if ((int)regionCandidates[i].rarity != rarity) continue; + rarityCandidates.Add(regionCandidates[i]); + // 시간대·날씨 성향 — 야행성은 밤에, 주행성은 낮에 흔하다. 0으로 내려가지 않으므로 후보는 줄지 않는다. + // 상태를 모르면(공급자 없음) 배수 없이 spawnWeight만 본다. + rarityMultipliers.Add(hasState + ? InsectHabits.SpawnWeightMultiplier(InsectHabits.For(regionCandidates[i]), info.State) + : 1f); } + return FieldSpawnRules.PickWeighted(rarityCandidates, rarityMultipliers, Random.value); + } + + /// 아이템·의상의 희귀 출현 배수 — 등급표의 희귀 이상 몫에 곱한다(). + private float RareSpawnBoost() + { + return (itemEffects != null ? itemEffects.GetRareSpawnMultiplier() : 1f) + * (outfitBonus != null ? outfitBonus.GetRareSpawnMultiplier() : 1f); } /// - /// 특정 종을 특정 리전에 한 마리 띄운다 — 정화 직후 "돌아온 종"을 확정 노출하는 용도. - /// - /// 무작위 스폰과 달리 월드 상태(시간·날씨) 필터를 거치지 않는다. 정화는 그 자리에서 - /// 눈으로 확인해야 하는 사건이라, 밤이라서 혹은 비가 와서 안 나오면 연출이 통째로 죽는다. - /// 대신 리전 풀 밖의 종은 띄우지 않는다(스폰 포인트가 그 리전 소속이면 자동으로 만족). + /// 이 리전에서 지금 보이는 시간대·날씨(info.State)와, 그 상태에 나올 수 있는 종 ID(info.CandidateIds). + /// 리전별 상태다 — 설산에서는 세계 날씨가 비여도 눈이 보이고 사막에서는 눈이 센바람으로 보인다 + /// (WorldStateProvider.GetWorldState(regionId)). 상태가 바뀔 때만 후보를 다시 만든다(게임 하루 12분에 몇 번). + /// 공급자가 없으면 false — 걸러 내지 않고 성향 배수도 곱하지 않는다. /// - private bool TrySpawnSpecific(string insectId, string regionId) + private bool RefreshStateCandidates(RegionSpawnInfo info) { - if (database == null || string.IsNullOrEmpty(insectId)) return false; - if (activeInsects.Count >= maxActiveTotal) return false; - // 지금 유일한 호출부(정화 직후)는 상한이 막 넓어진 뒤라 무해하지만, 검사를 빼 두면 - // 다음 호출부가 리전 상한을 조용히 넘긴다. - if (CountActiveInRegion(regionId) >= RegionCap(regionId)) return false; - - InsectData data = database.GetById(insectId); - if (data == null) return false; - - SpawnPoint point = GetAvailableSpawnPoint(regionId); - if (point == null || point.regionId != regionId) return false; - - SpawnAt(data, point); + if (worldStateProvider == null || database == null) return false; + WorldState state = worldStateProvider.GetWorldState(info.RegionId); + if (info.StateValid && state.DayPhase == info.State.DayPhase + && state.Weather == info.State.Weather && state.Hour24 == info.State.Hour24) + return true; + + info.StateValid = true; + info.State = state; + info.CandidateIds.Clear(); + List list = database.GetCandidates(state); + for (int i = 0; i < list.Count; i++) + if (list[i] != null) info.CandidateIds.Add(list[i].insectId); return true; } - private void SpawnInitialInsects() + /// 이 종이 지금 이 리전에서 본편 포획 목표인가(수명 순환 면제용). 조회 실패는 "아니다"로 본다. + private bool IsStoryCaptureTarget(string regionId, string insectId) + { + System.Action> provider = StoryCaptureTargetProvider; + if (provider == null || string.IsNullOrEmpty(insectId)) return false; + rotationWanted.Clear(); + try { provider(regionId, rotationWanted); } + catch (System.Exception) { return false; } // 경고는 PickStoryAssist가 한 번 남긴다 + return rotationWanted.Contains(insectId); + } + + /// + /// 스토리 포획 보조 — 발화를 기다리는 CaptureInsect 비트의 종이 이 리전에 살아 있지 않으면 + /// 확률로 그 종을 준다. 확률을 먼저 굴려 대부분의 굴림은 + /// 스토리 조회 없이 지나간다. + /// + private InsectData PickStoryAssist(RegionSpawnInfo info) { - if (database == null || worldStateProvider == null || spawnPoints == null || spawnPoints.Length == 0) + System.Action> provider = StoryCaptureTargetProvider; + if (provider == null || string.IsNullOrEmpty(info.RegionId)) return null; + + float chance = Random.value; + if (chance >= FieldSpawnRules.StoryAssistChance) return null; + + storyWanted.Clear(); + try { - return; + provider(info.RegionId, storyWanted); } - - HashSet seededRegions = new HashSet(); - int spawnCount = Mathf.Clamp(initialSpawnCount, 0, maxActiveTotal); - if (maxActivePerRegion > 0) + catch (System.Exception e) { - foreach (SpawnPoint point in spawnPoints) + // 스토리 쪽 예외가 스폰을 멈추게 두지 않는다 — 보조만 빼고 평소대로 굴린다. + if (!storyProviderWarned) { - if (point == null || string.IsNullOrEmpty(point.regionId) || !seededRegions.Add(point.regionId)) - { - continue; - } - - if (activeInsects.Count >= spawnCount) - { - break; - } - - TrySpawn(point.regionId); + storyProviderWarned = true; + Debug.LogWarning($"[InsectSpawner] 스토리 포획 보조 조회 실패 — 보조 없이 굴린다: {e.Message}"); } + return null; } + if (storyWanted.Count == 0) return null; + + storyCandidateIds.Clear(); + for (int i = 0; i < regionCandidates.Count; i++) storyCandidateIds.Add(regionCandidates[i].insectId); + storyAliveIds.Clear(); + population.CollectAliveIds(info.RegionId, storyAliveIds); + + string id = FieldSpawnRules.PickStoryTarget(storyWanted, storyCandidateIds, storyAliveIds, chance, Random.value); + if (id == null) return null; + for (int i = 0; i < regionCandidates.Count; i++) + if (regionCandidates[i].insectId == id) return regionCandidates[i]; + return null; + } + + // ======= 메인 필드 자리 — 리전 원판 · 높이(둔덕) · 물 · 콜라이더 ======= + // + // 메인 필드 자리는 전부 TryPickFieldPosition 한 곳을 지난다(시작 채우기 · 재생 · 수명 순환 · 정화 복구). + // 물 윤곽(호수·나루터 물가·습지 웅덩이·강 …)의 단일 출처는 RegionDressingBuilder의 물 판정 절이다 — 필드 아이템 + // (CaptureItemSpawner)도 같은 PickSpawnPosition을 지난다. 서브에리어는 PickSubAreaSlotPosition 주석. + + /// 메인 필드 자리를 굴리는 최대 횟수 — 물·게이트·콜라이더·플레이어 근처·겹침을 거르고 남는 자리를 찾는다. + internal const int FieldPositionAttempts = 12; - int attempts = 0; - int maxAttempts = Mathf.Max(spawnCount * 2, spawnPoints.Length * 6); - while (activeInsects.Count < spawnCount && attempts < maxAttempts) + /// + /// 몸 자리 검사(발 위 높이·반경). 나무 줄기·건물 벽·바위·울타리 기둥 속이면 거른다 — 그 안에 선 곤충은 + /// 보이지도 잡히지도 않는다. 낮은 풀·돌턱(0.35m 아래)은 걸리지 않는다. + /// + private const float BodyProbeHeight = 0.8f; + private const float BodyProbeRadius = 0.45f; + + private bool TryPickFieldPosition(RegionSpawnInfo info, float minPlayerDistance, bool nearPlayer, + out Vector3 position) + { + position = default; + bool hasPlayer = TryGetPlayerPosition(out Vector3 playerPos); + float usableRadius = Mathf.Max(1f, info.Radius - FieldSpawnRules.RegionEdgeMargin); + if (rollInRing == null) rollInRing = RollInRing; + + // 정화 직후처럼 "보여야 하는" 개체는 플레이어 둘레 고리에서 먼저 찾는다(플레이어가 그 리전 안일 때만). + bool ring = nearPlayer && hasPlayer && PlanarDistance(playerPos, info.Center) < usableRadius; + int attempts = ring ? FieldPositionAttempts * 2 : FieldPositionAttempts; + for (int attempt = 0; attempt < attempts; attempt++) { - if (activeInsects.Count >= maxActiveTotal) + if (ring && attempt < FieldPositionAttempts) { - break; + rollCenter = playerPos; + rollInnerRadius = Mathf.Max(NearPlayerRingMin, minPlayerDistance); + rollOuterRadius = Mathf.Max(rollInnerRadius + 1f, NearPlayerRingMax); } - - int beforeCount = activeInsects.Count; - TrySpawn(); - attempts++; - if (activeInsects.Count == beforeCount) + else { - break; + rollCenter = info.Center; + rollInnerRadius = 0f; + rollOuterRadius = usableRadius; } + + Vector3 p = PickSpawnPosition(rollInRing); + if (PlanarDistance(p, info.Center) > usableRadius) continue; // 고리가 리전 밖으로 나갔거나 물가로 밀려 나갔다 + if (IsInsideSubAreaGate(p, info.SubAreas)) continue; + if (hasPlayer && minPlayerDistance > 0f && PlanarDistance(p, playerPos) < minPlayerDistance) continue; + if (IsCrowded(info.RegionId, p)) continue; + p.y = PlayerMovement.GroundHeight(ProbeColliderTop(p.x, p.y, p.z, 0.01f), p.x, p.z, false); + if (IsBodyBlocked(p)) continue; + position = p; + return true; + } + return false; + } + + /// 고리(안 반경 0이면 원판)에서 넓이에 고르게 한 점 — 반지름을 제곱근으로 뽑아야 가운데로 몰리지 않는다. + private Vector3 RollInRing() + { + float inner2 = rollInnerRadius * rollInnerRadius; + float outer2 = rollOuterRadius * rollOuterRadius; + float r = Mathf.Sqrt(Mathf.Lerp(inner2, outer2, Random.value)); + float a = Random.value * Mathf.PI * 2f; + return rollCenter + new Vector3(Mathf.Cos(a) * r, 0f, Mathf.Sin(a) * r); + } + + /// + /// 서브에리어 게이트 원 + 여유 안인가. 원 안의 곤충을 잡으러 들어가면 잡기 E와 진입 E가 한 프레임에 겹친다 + /// (부트스트랩 PushOutOfSubAreas가 스폰 포인트에 두던 것과 같은 마진). + /// + private static bool IsInsideSubAreaGate(Vector3 p, SubAreaData[] subAreas) + { + if (subAreas == null) return false; + for (int i = 0; i < subAreas.Length; i++) + { + SubAreaData sub = subAreas[i]; + if (sub == null) continue; + float safe = sub.radius + FieldSpawnRules.SubAreaGateMargin; + if (PlanarSqr(p, sub.centerPosition) < safe * safe) return true; + } + return false; + } + + /// 같은 리전의 산 개체와 너무 붙는가 — 풀 더미가 겹쳐 한 덩어리로 보이지 않게. + private bool IsCrowded(string key, Vector3 p) + { + List slots = population.SlotsOf(key); + float min2 = FieldSpawnRules.MinSlotSpacing * FieldSpawnRules.MinSlotSpacing; + for (int i = 0; i < slots.Count; i++) + if (slots[i].IsAlive && PlanarSqr(slots[i].Position, p) < min2) return true; + return false; + } + + private bool IsBodyBlocked(Vector3 foot) + { + int n = Physics.OverlapSphereNonAlloc(foot + Vector3.up * BodyProbeHeight, BodyProbeRadius, bodyProbeHits, + Physics.DefaultRaycastLayers, QueryTriggerInteraction.Ignore); + for (int i = 0; i < n; i++) + { + Collider c = bodyProbeHits[i]; + if (c == null) continue; + if (c.GetComponentInParent() != null) continue; + if (c.GetComponentInParent() != null) continue; + // 얇은 판(바닥 평면·데크)은 설 자리를 막지 않는다 — 플레이어 이동 차단(IsBlockedPosition)과 같은 기준. + if (c.bounds.size.y < 0.25f) continue; + return true; } + return false; + } + + /// + /// 물 위 후보를 버리고 다시 뽑는 최대 횟수. 원이 거의 다 물이면 끝까지 실패할 수 있어 상한을 두고, + /// 그때는 로 물가에 올린다. 물가로 밀린 자리가 리전·게이트 밖이면 + /// 이 그 후보를 버리고 원판에서 다시 굴린다. + /// + internal const int SpawnPositionRolls = 8; + + /// + /// 물가에서 이 거리(m)까지는 물로 친다 — 곤충 발밑 풀 더미(반경 0.34~0.54m × 등급 배율 1.0~1.9)가 + /// 물가에 걸쳐 수면 위로 솟지 않게. + /// + internal const float WaterShoreMargin = 1.0f; - spawnTimer = 0f; + /// 물가로 밀어낼 때 한 걸음(m). 호수 끝(물가 + 여유)까지 수십 걸음이다. + private const float ShoreStep = 0.5f; + private const int MaxShoreSteps = 400; + + /// + /// 발() 위 한 걸음()에서 아래로 쏴 맞은 콜라이더 윗면. + /// 없으면 음의 무한대. 플레이어 접지 레이와 같은 출발 높이라 플레이어가 못 올라서는 것(나무 줄기·벽·지붕)은 + /// 레이가 그 안에서 출발해 안 맞는다. 트리거(NPC 몸통·아이템 줍기 구·포획 근접 구)는 땅이 아니다. + /// + private static float ProbeColliderTop(float x, float footY, float z, float depthBelowFoot) + { + float step = PlayerMovement.StepHeight; + return Physics.Raycast(new Vector3(x, footY + step, z), Vector3.down, out RaycastHit hit, + step + depthBelowFoot, Physics.DefaultRaycastLayers, QueryTriggerInteraction.Ignore) + ? hit.point.y + : float.NegativeInfinity; } + /// + /// 순수 판정 — (무작위 자리)을 물이 아닐 때까지 최대 + /// 번 굴리고, 끝까지 물이면 마지막 후보를 물가로 밀어낸 뒤 그 자리 지면에 세운다. + /// + /// y는 대입이다(+=가 아니다). 둔덕은 콜라이더가 없어 평면 y에 선 곤충이 사구 속에 묻혔다(바닥 위 + /// 모래언덕 사구 최대 0.62m, 재 더미 1.05m). 옛 스폰 포인트는 플레이어 둘레로 끌려오며 플레이어 y를 복사해 두었는데 + /// 플레이어도 둔덕 높이만큼 올라서 있으므로, 거기에 둔덕 높이를 더하면 두 번 올랐다. 굴린 후보의 y는 쓰지 않는다. + /// 평지에서는 (0.1)다. + /// + /// 물 곤충(소금쟁이 등)도 예외 없이 뭍에 둔다 — 데이터에 수면 서식 표시가 없고(habitatHint는 "연못" 같은 + /// 리전 문구, 물 속성은 모기·물방개까지 묶는다), 필드 곤충 연출이 기어다니는 종에 풀 더미를 둘러 세워 + /// 물 위에 두면 수면에서 풀이 솟는다. 물가 1m 밖에 서므로 여전히 "물가 곤충"으로 읽힌다. + /// + internal static Vector3 PickSpawnPosition(System.Func roll) + { + Vector3 p = roll(); + for (int i = 1; i < SpawnPositionRolls && IsOnWater(p); i++) + p = roll(); + if (IsOnWater(p)) + p = PushOutOfWater(p); + p.y = FieldGround.SurfaceY(p.x, p.z); + return p; + } + /// + /// 이 자리가 물 위인가(물가에서 만큼 바깥까지 물로 친다). 윤곽은 + /// — 호수·나루터 물가·습지 웅덩이·강·옛 원기둥 물 전부다. + /// + internal static bool IsOnWater(Vector3 p) => RegionDressingBuilder.IsOnWater(p, WaterShoreMargin); + /// + /// 물 밖(여유 포함)까지 한 방향으로 민다 — 처음 덮은 물의 중심에서 이 자리를 지나는 반직선(강이면 가까운 둑 쪽). + /// 가장 가까운 물가는 아니어도 방향이 보존돼 굴린 자리 쪽 물가에 선다(). + /// + internal static Vector3 PushOutOfWater(Vector3 p) + => RegionDressingBuilder.PushOutOfWater(p, WaterShoreMargin, ShoreStep, MaxShoreSteps); + // ======= 리전 표 ======= - private SpawnPoint GetAvailableSpawnPoint(string preferredRegionId = null) + /// + /// 필드 리전 표(풀·레벨 대역)를 에 옮겨 담는다 — 나의 섬 손님 곤충(IslandWorldBuilder)이 + /// 해금된 리전만 골라 읽는다. 레벨 대역의 단일 출처가 여기다(부트스트랩이 스폰 포인트에 적은 값). 표가 아직 + /// 안 만들어졌으면(스폰 포인트가 없다) 0개다. 풀 목록은 복사하지 않는다 — 읽기만 할 것. + /// + public int CopyFieldRegionTables(List into) { - if (spawnPoints == null || spawnPoints.Length == 0) + if (into == null) return 0; + into.Clear(); + BuildRegionInfos(); + for (int i = 0; i < regionInfos.Count; i++) { - return null; + RegionSpawnInfo info = regionInfos[i]; + if (string.IsNullOrEmpty(info.RegionId) || info.Pool.Count == 0) continue; + into.Add(new FieldRegionTable(info.RegionId, info.MinLevel, info.MaxLevel, info.Pool)); } + return into.Count; + } - // 60m 디스폰 밖 포인트에서의 스폰은 5초 내 정리되는 낭비(churn) — 55m 안쪽만 사용. - // (리전 링이 정적으로 유지되면서 원거리 포인트가 상시 존재하게 된 데 대한 방어) - Transform player = GetPlayerTransform(); - bool useDistanceGate = player != null; - Vector3 playerPos = useDistanceGate ? player.position : Vector3.zero; - const float maxSpawnDistSq = 55f * 55f; + /// + /// 스폰 포인트(풀·레벨 대역)와 리전 정의(원판·게이트·귀환종)를 리전 단위로 묶는다. 한 번만 만든다. + /// 서브에리어 포인트는 거른다 — 부모 리전 ID를 달고 있어 그대로 두면 전용종이 필드 풀에 섞인다. + /// + private bool BuildRegionInfos() + { + if (regionInfos.Count > 0) return true; + if (database == null || spawnPoints == null || spawnPoints.Length == 0) return false; - if (!string.IsNullOrEmpty(preferredRegionId)) + for (int i = 0; i < spawnPoints.Length; i++) { - int preferredStart = Random.Range(0, spawnPoints.Length); - for (int i = 0; i < spawnPoints.Length; i++) + SpawnPoint point = spawnPoints[i]; + if (point == null || point.isSubAreaPoint) continue; + string id = point.regionId ?? string.Empty; + if (regionInfoById.ContainsKey(id)) continue; + + var info = new RegionSpawnInfo { - SpawnPoint point = spawnPoints[(preferredStart + i) % spawnPoints.Length]; - if (point != null && point.CanSpawn && point.regionId == preferredRegionId - && (!useDistanceGate || (point.transform.position - playerPos).sqrMagnitude <= maxSpawnDistSq)) + RegionId = id, + MinLevel = Mathf.Max(1, point.regionMinLevel), + }; + info.MaxLevel = Mathf.Max(info.MinLevel, point.regionMaxLevel); + + if (point.regionInsectIds != null && point.regionInsectIds.Length > 0) + { + for (int k = 0; k < point.regionInsectIds.Length; k++) { - return point; + InsectData d = database.GetById(point.regionInsectIds[k]); + if (d != null && !info.Pool.Contains(d)) info.Pool.Add(d); } } - } + else if (database.insects != null) + { + // 풀을 모르는 포인트(스폰 포인트 없이 뜬 씬의 자체 생성분) — 옛 규칙처럼 리전 필터 없이 DB 전체. + for (int k = 0; k < database.insects.Count; k++) + if (database.insects[k] != null) info.Pool.Add(database.insects[k]); + } - int startIndex = Random.Range(0, spawnPoints.Length); - for (int i = 0; i < spawnPoints.Length; i++) - { - SpawnPoint point = spawnPoints[(startIndex + i) % spawnPoints.Length]; - if (point != null && point.CanSpawn - && (!useDistanceGate || (point.transform.position - playerPos).sqrMagnitude <= maxSpawnDistSq)) + RegionData region = regionManager != null && id.Length > 0 ? regionManager.GetRegionById(id) : null; + if (region != null) { - return point; + info.Data = region; + info.Center = region.centerPosition; + info.Radius = region.radius; + info.SubAreas = region.subAreas; } - } + else + { + EstimateDiscFromPoints(info); + } + info.BaseSlots = FieldSpawnRules.SlotCountFor(UsableArea(info)); - return null; + regionInfos.Add(info); + regionInfoById[id] = info; + } + return regionInfos.Count > 0; } - private string GetUnderpopulatedRegionId() + /// 리전 정의가 없는 씬 — 그 리전 포인트들을 감싸는 원으로 어림한다. + private void EstimateDiscFromPoints(RegionSpawnInfo info) { - if (maxActivePerRegion <= 0 || spawnPoints == null || activeInsects.Count >= maxActiveTotal) + Vector3 sum = Vector3.zero; + int n = 0; + for (int i = 0; i < spawnPoints.Length; i++) { - return null; + SpawnPoint p = spawnPoints[i]; + if (p == null || p.isSubAreaPoint || (p.regionId ?? string.Empty) != info.RegionId) continue; + sum += p.transform.position; + n++; } - - HashSet regions = new HashSet(); - foreach (SpawnPoint point in spawnPoints) + info.Center = n > 0 ? sum / n : Vector3.zero; + float r = 0f; + for (int i = 0; i < spawnPoints.Length; i++) { - if (point != null && !string.IsNullOrEmpty(point.regionId)) - { - regions.Add(point.regionId); - } + SpawnPoint p = spawnPoints[i]; + if (p == null || p.isSubAreaPoint || (p.regionId ?? string.Empty) != info.RegionId) continue; + r = Mathf.Max(r, PlanarDistance(p.transform.position, info.Center)); } + info.Radius = Mathf.Max(15f, r + 5f); + } - foreach (string regionId in regions) + /// + /// 곤충이 설 수 있는 땅 넓이(㎡) — 리전 원판(가장자리 여유 제외)에서 물과 서브에리어 게이트를 뺀 몫. + /// 해바라기 씨 배열(Vogel 나선)로 고르게 찍어 잰다 — 무작위로 재면 실행마다 슬롯 수가 달라진다. + /// 물 위 자리까지 셈에 넣으면 호수가 큰 리전(연못)은 뭍의 밀도만 두 배가 된다. + /// + private const int AreaSamples = 256; + + private static float UsableArea(RegionSpawnInfo info) + { + float r = Mathf.Max(1f, info.Radius - FieldSpawnRules.RegionEdgeMargin); + const float goldenAngle = 2.39996323f; + int land = 0; + for (int i = 0; i < AreaSamples; i++) { - // 오염 리전은 상한이 낮아 금방 "부족하지 않음"이 된다 — 이 자리를 안 고치면 - // 오염 리전이 계속 우선 스폰 대상으로 뽑혀 다른 리전이 굶는다. - if (CountActiveInRegion(regionId) < RegionCap(regionId) && HasAvailableSpawnPoint(regionId)) - { - return regionId; - } + float rr = r * Mathf.Sqrt((i + 0.5f) / AreaSamples); + float a = i * goldenAngle; + Vector3 p = info.Center + new Vector3(Mathf.Cos(a) * rr, 0f, Mathf.Sin(a) * rr); + if (IsOnWater(p) || IsInsideSubAreaGate(p, info.SubAreas)) continue; + land++; } + return Mathf.PI * r * r * land / AreaSamples; + } - return null; + /// 메인 필드 슬롯 수를 오염 상한에 맞춘다 — 슬롯 수를 바꾸는 유일한 길(서브에리어 제외). + private void ReconcileRegionSlots(RegionSpawnInfo info, float now) + { + population.EnsureSlotCount(info.RegionId, RegionCap(info), now, isSubArea: false); } - private int CountActiveInRegion(string regionId) + /// + /// 이 리전의 슬롯 수. 기본은 설 수 있는 땅 × 밀도이고, 오염 거점이 살아 있으면 줄어든다. + /// + /// 0으로 내려가지 않는 것이 중요하다 — 그 리전에서의 포획·전투를 조건으로 건 + /// 스토리 비트가 여럿이라(오염 아크 둘 + 1막의 특정 종 포획) 곤충이 아예 안 뜨면 + /// 발화 지점에 영영 도달하지 못한다. 하한은 BlightPolicy.MinActive가 든다. + /// + private int RegionCap(RegionSpawnInfo info) { - int count = 0; - for (int i = 0; i < activeInsects.Count; i++) - { - InsectEntity entity = activeInsects[i]; - if (entity != null && entity.RegionId == regionId) - { - count++; - } - } + bool blighted = blight != null && blight.IsBlighted(info.RegionId); + // 밤·비·안개의 보너스 슬롯(+1~+2)을 먼저 더하고 오염 감소를 그 합에 얹는다. 순서가 중요하다 — 오염 상한을 + // 먼저 구해 놓고 보너스를 더하면 황폐한 땅(8칸 → 2칸)이 밤·비에 3~4칸이 되어 줄인 몫이 도로 찬다. + // 합에 얹으면 보너스도 같이 3분의 1로 줄어 큰 리전은 오염 상한이 그대로고(24칸+2 → 8칸 = 24칸 → 8칸), + // 작은 리전도 한 칸 이내로만 는다. 오염 리전이 같은 상태의 멀쩡한 리전보다 많아지는 일은 없다 + // (BlightPolicy.MaxActiveFor가 baseMax를 넘기지 않는다). 0으로 내려가지 않는 하한(MinActive)도 그대로다. + return Core.BlightPolicy.MaxActiveFor(blighted, info.BaseSlots + BonusSlotsFor(info)); + } - return count; + /// 이 리전에서 지금 보이는 시간대·날씨가 더하는 슬롯 수 — 공급자가 없으면 0. + private int BonusSlotsFor(RegionSpawnInfo info) + { + if (worldStateProvider == null) return 0; + return FieldSpawnRules.BonusSlots(worldStateProvider.GetWorldState(info.RegionId)); } - private bool HasAvailableSpawnPoint(string regionId) + // ======= 몸 — 세우기 · 거두기 · 게임플레이 퇴장 ======= + + /// 기록된 개체 그대로 몸을 세운다(종·레벨·색다름·지워짐·자리). 굴리는 것은 없다. + private bool Materialize(FieldSlot slot, string homeRegionId) { - if (spawnPoints == null || string.IsNullOrEmpty(regionId)) + InsectData data = slot.Data != null ? slot.Data : (database != null ? database.GetById(slot.InsectId) : null); + if (data == null) { + FieldPopulation.Vacate(slot, Time.time + FieldSpawnRules.RetrySeconds); return false; } + slot.Data = data; - for (int i = 0; i < spawnPoints.Length; i++) - { - SpawnPoint point = spawnPoints[i]; - if (point != null && point.regionId == regionId && point.CanSpawn) - { - return true; - } - } + TryInitPool(); + GameObject prefab = data.prefabOverride != null ? data.prefabOverride : defaultPrefab; + if (prefab == null) return false; - return false; - } + GameObject instance = pool != null && prefab == defaultPrefab ? pool.Get() : Instantiate(prefab, transform); + instance.SetActive(true); + // Initialize 전이어야 한다 — Initialize가 이 좌표를 basePosition(배회·풀 더미의 기준)으로 삼는다. + instance.transform.position = slot.Position; - private void DespawnEntity(InsectEntity entity) - { - if (entity == null) - { - return; - } + InsectEntity entity = instance.GetComponent(); + if (entity == null) entity = instance.AddComponent(); + entity.Initialize(data, slot.Level, homeRegionId, DespawnEntity, slot.Shiny, slot.Erased); - activeInsects.Remove(entity); + slot.Entity = entity; + slotByEntity[entity] = slot; + activeInsects.Add(entity); - if (pool != null && entity.gameObject.activeSelf && defaultPrefab != null) + if (data.rarity == InsectRarity.Epic || data.rarity == InsectRarity.Legendary) { - pool.Return(entity.gameObject); - } - else - { - Destroy(entity.gameObject); + RaidBossSpawned?.Invoke(entity); } + return true; } - private void CleanupDeadEntities() + /// + /// 플레이어 가까이 서 있는 필드 곤충 하나를 색다른 개체로 바꾼다(첫 색다른 조우 연출 — + /// ). 새로 스폰하지 않는다: 슬롯 수·등급표·리전 풀을 건드리지 않고 + /// 이미 서 있는 개체의 기록에 색다름만 적은 뒤 몸을 다시 세운다(색은 몸을 세울 때 입힌다). + /// + /// 미니게임으로 잡을 수 있는 등급만 고른다 — 영웅·전설은 레이드로만 잡혀서 첫 조우로는 닿지 않는다. + /// 수문장·지워진 개체·이미 색다른 개체·누군가와 엮인 개체(포획·전투 중)는 건너뛴다. + /// + /// 바뀐 개체. 조건에 맞는 곤충이 근처에 없으면 null — 호출부가 조금 뒤에 다시 부른다. + public InsectEntity TryMakeNearbyShiny(Vector3 playerPos, float minDistance, float maxDistance) { - for (int i = activeInsects.Count - 1; i >= 0; i--) + FieldSlot best = null; + float bestDistance = maxDistance; + for (int k = 0; k < regionInfos.Count; k++) { - if (activeInsects[i] == null || !activeInsects[i].gameObject.activeInHierarchy) - activeInsects.RemoveAt(i); + List slots = population.SlotsOf(regionInfos[k].RegionId); + for (int i = 0; i < slots.Count; i++) + { + FieldSlot slot = slots[i]; + if (!slot.IsAlive || !slot.IsMaterialized || slot.IsSubArea || slot.Shiny || slot.Erased) continue; + InsectData data = slot.Data; + if (data == null || data.rarity >= InsectRarity.Epic) continue; + InsectEntity e = slot.Entity; + if (e.IsGuardian || IsBusy(e)) continue; + + float d = PlanarDistance(e.transform.position, playerPos); + if (d < minDistance || d > bestDistance) continue; + best = slot; + bestDistance = d; + } } + if (best == null) return null; + + best.Shiny = true; + // 지금 서 있는 자리에서 다시 선다 — 기록된 자리(스폰 지점)로 되돌리면 눈앞에서 순간이동한다. + best.Position = best.Entity.transform.position; + string homeRegionId = best.Key; + RecallSlot(best); + return Materialize(best, homeRegionId) ? best.Entity : null; } - private void DespawnFarInsects() + /// + /// 몸만 풀로 돌린다 — 개체는 그대로 남는다(). + /// 게임플레이 퇴장 콜백()을 거치지 않으므로 자리가 비지 않는다. + /// + private void RecallSlot(FieldSlot slot) { - Transform player = GetPlayerTransform(); - if (player == null) return; + InsectEntity e = slot.Entity; + FieldPopulation.MarkRecalled(slot); + if (ReferenceEquals(e, null)) return; + slotByEntity.Remove(e); + activeInsects.Remove(e); + if (e != null && e.Recall()) ReturnToPool(e); + } - float maxDist = 60f; + /// + /// 서 있는 몸을 전부 거둔다(서브에리어 진입·이탈) — 기록은 남는다. 메인 필드 곤충은 돌아오면 같은 자리에, + /// 서브에리어 곤충은 다시 들어오면 같은 방에 선다. + /// + private void RecallAllBodies() + { for (int i = activeInsects.Count - 1; i >= 0; i--) { - InsectEntity entity = activeInsects[i]; - if (entity == null) { activeInsects.RemoveAt(i); continue; } - float dist = Vector3.Distance(player.position, entity.transform.position); - if (dist > maxDist) + if (i >= activeInsects.Count) continue; + InsectEntity e = activeInsects[i]; + if (!ReferenceEquals(e, null) && slotByEntity.TryGetValue(e, out FieldSlot slot)) { - entity.Despawn(); + RecallSlot(slot); + continue; } + activeInsects.RemoveAt(i); + if (e != null && e.Recall()) ReturnToPool(e); } } - // EnsureSpawnPoints가 배치한 리전 링 원위치 스냅샷 — 리전 이탈 시 복귀용 - private Vector3[] spawnPointHomes; - - private void RelocateSpawnPoints() + /// + /// 게임플레이 퇴장(포획 성공·실패, 전투 승패·도주 종료, 레이드, 아이 NPC 가로채기) — 단 필드의 놓침 도주는 + /// 퇴장이 아니라 이동이다(같은 개체를 눈 밖 다른 자리로 — ). + /// 의 콜백이다. 그 자리는 비고 재생 지연 뒤에 새로 찬다 + /// (필드 1~2분, 서브에리어 subAreaRespawnSeconds). + /// + private void DespawnEntity(InsectEntity entity) { - Transform player = GetPlayerTransform(); - if (player == null || spawnPoints == null) return; + if (entity == null) + { + return; + } - if (spawnPointHomes == null || spawnPointHomes.Length != spawnPoints.Length) + activeInsects.Remove(entity); + if (slotByEntity.TryGetValue(entity, out FieldSlot slot)) { - spawnPointHomes = new Vector3[spawnPoints.Length]; - for (int i = 0; i < spawnPoints.Length; i++) + slotByEntity.Remove(entity); + if (ReferenceEquals(slot.Entity, entity) && entity.Fled && !slot.IsSubArea + && regionInfoById.TryGetValue(slot.Key, out RegionSpawnInfo fledInfo) + && TryPickFieldPosition(fledInfo, FieldSpawnRules.SpawnMinPlayerDistance, false, out Vector3 away)) { - if (spawnPoints[i] != null) spawnPointHomes[i] = spawnPoints[i].transform.position; + // 달아났다 — 같은 개체가 플레이어 눈 밖 다른 자리로 간다(FieldPopulation.RelocateAfterFlee 주석). + FieldPopulation.RelocateAfterFlee(slot, away); + } + else if (ReferenceEquals(slot.Entity, entity)) + { + float delay = slot.IsSubArea + ? subAreaRespawnSeconds + : FieldSpawnRules.RollRespawnDelay(Random.value); + FieldPopulation.MarkRemovedByGameplay(slot, Time.time, delay); } } - string playerRegionId = regionManager != null && regionManager.CurrentRegion != null - ? regionManager.CurrentRegion.regionId - : null; + ReturnToPool(entity); + } - // 현재 리전 라벨의 포인트만 플레이어 주변으로 당기고, 나머지는 링 원위치 유지. - // (옛: 전 포인트를 무조건 플레이어 나선(10+i*3m)으로 → 배열 앞쪽(초원/연못) 라벨만 - // 60m 디스폰 안에 남아, 어느 리전에 가도 초원/연못 곤충만 체감되는 문제) - // 포인트 카운트를 실제 활성 개체로 재동기화한다. 옛 코드는 상한에 걸린 포인트를 - // 무조건 0으로 밀어(ResetCount) 상한 2가 "동시 2마리"가 아니라 "8초당 2마리"가 됐다. - for (int i = 0; i < spawnPoints.Length; i++) - if (spawnPoints[i] != null) spawnPoints[i].BeginRecount(); - for (int i = 0; i < activeInsects.Count; i++) + private void ReturnToPool(InsectEntity entity) + { + if (entity == null) return; + if (pool != null && entity.gameObject.activeSelf && defaultPrefab != null) { - InsectEntity live = activeInsects[i]; - if (live != null && live.OwnerPoint != null) live.OwnerPoint.NotifyLive(); + pool.Return(entity.gameObject); } - - int localIndex = 0; - for (int i = 0; i < spawnPoints.Length; i++) + else { - if (spawnPoints[i] == null) continue; - - // **서브에리어 포인트는 옮기지 않는다.** 부모 리전 ID를 그대로 달고 있어 - // 아래 리전 필터에 함께 걸리는데, 끌려오면 게이트 원 바깥 가장자리에만 있어야 할 - // 전용종이 필드 한복판에 뜬다(숲이면 필드 포인트 5개 대 서브 포인트 8개라 - // 플레이어 주변 스폰의 과반이 전용종이 된다). 한 번 끌려오면 리전을 뜨기 전까지 - // 원위치로도 안 돌아온다. - if (spawnPoints[i].isSubAreaPoint) continue; + Destroy(entity.gameObject); + } + } - if (playerRegionId != null && spawnPoints[i].regionId == playerRegionId) - { - float angle = localIndex * 2.399963f; // 황금각 — 포인트 수 무관 고른 방위 - float dist = 10f + (localIndex % 12) * 3f; // 10~43m (60m 디스폰 안쪽) - spawnPoints[i].transform.position = player.position - + new Vector3(Mathf.Cos(angle) * dist, 0f, Mathf.Sin(angle) * dist); - localIndex++; - } - else + private void CleanupDeadEntities() + { + for (int i = activeInsects.Count - 1; i >= 0; i--) + { + InsectEntity e = activeInsects[i]; + if (e != null && e.gameObject.activeInHierarchy) continue; + activeInsects.RemoveAt(i); + // 파기·비활성으로 몸을 잃었다 — 개체는 그대로 두고 몸만 뗀다(다음 틱에 다시 선다). + if (!ReferenceEquals(e, null) && slotByEntity.TryGetValue(e, out FieldSlot slot)) { - spawnPoints[i].transform.position = spawnPointHomes[i]; + slotByEntity.Remove(e); + if (ReferenceEquals(slot.Entity, e)) FieldPopulation.MarkRecalled(slot); } } } - // 옛은 GetPlayerTransform이 매 호출 GameObject.FindWithTag + GameObject.Find — DespawnFarInsects(5초) - // /RelocateSpawnPoints(8초)에서 호출. lazy 캐싱으로 첫 1회 후 재사용. + /// 포획·전투에 붙잡혔거나 플레이어를 알아챈(경계·도주) 몸 — 거두지도, 수명으로 바꾸지도 않는다. + private static bool IsBusy(InsectEntity e) => e != null && (e.IsEngaged || e.IsAlerted); + + // 옛은 GetPlayerTransform이 매 호출 GameObject.FindWithTag + GameObject.Find — lazy 캐싱으로 첫 1회 후 재사용. private Transform cachedPlayerTransformForSpawner; private Transform GetPlayerTransform() @@ -621,64 +1137,23 @@ private Transform GetPlayerTransform() return cachedPlayerTransformForSpawner; } - private int GetSpawnLevel(InsectData data, SpawnPoint point) + private bool TryGetPlayerPosition(out Vector3 position) { - if (data == null) - { - return 1; - } - - int min, max; - if (point != null && point.regionMaxLevel > 0) - { - min = Mathf.Max(1, point.regionMinLevel); - max = Mathf.Max(min, point.regionMaxLevel); - } - else - { - min = Mathf.Max(1, data.minLevel); - max = Mathf.Max(min, data.maxLevel); - } - - // 메인 필드: Lv.1~max 범위에서 지수 분포 (항상 1부터) - // 서브구역: min~max 범위에서 완화된 지수 분포 (고렙 보상) - // 옛 판정은 `regionInsectIds.Length <= 5` 휴리스틱이었다. 지금은 오판이 없지만 - // (리전 풀 14~20종 / 서브에리어 2~4종) 리전 풀이 줄거나 서브에리어에 6번째 종이 - // 붙는 순간 레벨 곡선이 조용히 뒤집힌다 — add-region/add-insect 어느 쪽도 안 잡는다. - bool isSubArea = point != null && point.isSubAreaPoint; + Transform player = GetPlayerTransform(); + position = player != null ? player.position : Vector3.zero; + return player != null; + } - int spawnMin, spawnMax; - float power; - if (isSubArea) - { - spawnMin = min; - spawnMax = max; - power = 2.0f; - } - else - { - spawnMin = 1; - spawnMax = max; - power = 3.5f; - } + private static float PlanarSqr(Vector3 a, Vector3 b) + { + float dx = a.x - b.x; + float dz = a.z - b.z; + return dx * dx + dz * dz; + } - // 등급이 높을수록 고레벨 나올 확률 추가 감소 (완화된 보정) - if (data != null) - { - switch (data.rarity) - { - case InsectRarity.Uncommon: power += 0.3f; break; - case InsectRarity.Rare: power += 0.6f; break; - case InsectRarity.Epic: power += 1.0f; break; - case InsectRarity.Legendary: power += 1.5f; break; - } - } + private static float PlanarDistance(Vector3 a, Vector3 b) => Mathf.Sqrt(PlanarSqr(a, b)); - float roll = Random.value; - float weighted = Mathf.Pow(roll, power); - int level = spawnMin + Mathf.FloorToInt(weighted * (spawnMax - spawnMin + 1)); - return Mathf.Clamp(level, spawnMin, spawnMax); - } + // ======= 배선 ======= public void AutoWire(InsectDatabase db, WorldStateProvider provider, SpawnPoint[] points) { @@ -696,6 +1171,9 @@ public void AutoWire(InsectDatabase db, WorldStateProvider provider, SpawnPoint[ { spawnPoints = points; } + + // 시간대 신호는 공급자가 든 시계에서 온다 — 공급자가 들어온 지금 구독한다(OnEnable은 이보다 먼저 불렸다). + SubscribeClock(); } public void AutoWire(ItemEffectManager effects) @@ -706,8 +1184,6 @@ public void AutoWire(ItemEffectManager effects) } } - private OutfitBonusProvider outfitBonus; - public void AutoWire(OutfitBonusProvider bonus) { if (outfitBonus == null) @@ -742,6 +1218,26 @@ private void Subscribe() blight.RegionCleansed -= OnRegionCleansed; blight.RegionCleansed += OnRegionCleansed; } + SubscribeClock(); + } + + /// + /// 시계의 시간대 신호 — 새 시간대에 어울리지 않는 개체를 갈아입힌다(). + /// 어느 시계를 구독 중인지 들고 있어 해지가 같은 시계를 가리킨다(-= 뒤 +=라 중복 구독은 되지 않는다). + /// + private void SubscribeClock() + { + UnsubscribeClock(); + GameClock clock = worldStateProvider != null ? worldStateProvider.Clock : null; + if (clock == null) return; + subscribedClock = clock; + clock.DayPhaseChanged += OnDayPhaseChanged; + } + + private void UnsubscribeClock() + { + if (subscribedClock != null) subscribedClock.DayPhaseChanged -= OnDayPhaseChanged; + subscribedClock = null; } private void OnEnable() @@ -750,7 +1246,31 @@ private void OnEnable() } /// - /// 오염 거점 상태 — 거점이 살아 있는 리전은 동시 출현 수를 줄이고, 무너지면 되돌린다. + /// 시간대가 바뀌었다 — 새 시간대에 어울리지 않는 산 개체(밤이 오면 주행성, 아침이 오면 야행성)의 수명을 + /// 5~60초 안으로 당긴다(~). + /// + /// 개체를 지우지도 바꾸지도 않는다 — 만료 시각만 당긴다. 실제 교체는 평소 순환(의 + /// )이 하므로 플레이어 25m 안의 몸은 그대로 서 있다가 멀어진 뒤에 바뀌고 + /// (눈앞에서 바뀌면 리전 이동 리롤과 같다), 스토리 포획 목표종은 면제다(). + /// 기록만 만지므로 서브에리어 안에 있어도 안전하다(메인 필드의 시간은 계속 흐른다). + /// + private void OnDayPhaseChanged(DayPhase phase) + { + float now = Time.time; + for (int k = 0; k < regionInfos.Count; k++) + { + List slots = population.SlotsOf(regionInfos[k].RegionId); + for (int i = 0; i < slots.Count; i++) + { + FieldSlot slot = slots[i]; + if (!slot.IsAlive || !InsectHabits.IsOutOfPhase(InsectHabits.Of(slot.InsectId), phase)) continue; + FieldPopulation.PullExpiryForward(slot, now, FieldSpawnRules.RollPhaseSwapDelay(Random.value)); + } + } + } + + /// + /// 오염 거점 상태 — 거점이 살아 있는 리전은 슬롯 수를 줄이고, 무너지면 되돌린다. /// public void AutoWire(Core.RegionBlightManager blightManager) { @@ -766,31 +1286,24 @@ private void OnDisable() regionManager.SubAreaChanged -= OnSubAreaChanged; if (blight != null) blight.RegionCleansed -= OnRegionCleansed; + UnsubscribeClock(); + // 꺼지면 복구 코루틴도 멈춘다 — 보류만 남으면 그 리전 슬롯이 영영 안 는다. 다시 켜진 첫 틱이 채운다. + foreach (string r in cleanseHold) + if (!pendingCleansed.Contains(r)) pendingCleansed.Add(r); } - /// - /// 이 리전의 동시 출현 상한. 오염 거점이 살아 있으면 줄어든다. - /// - /// 0으로 내려가지 않는 것이 중요하다 — 그 리전에서의 포획·전투를 조건으로 건 - /// 스토리 비트가 여럿이라(오염 아크 둘 + 1막의 특정 종 포획) 곤충이 아예 안 뜨면 - /// 발화 지점에 영영 도달하지 못한다. 하한은 BlightPolicy.MinActive가 든다. - /// - private int RegionCap(string regionId) - { - bool blighted = blight != null && blight.IsBlighted(regionId); - return Core.BlightPolicy.MaxActiveFor(blighted, maxActivePerRegion); - } + // ======= 정화 복구 — 이동이 아니라 사건이라 즉시 채운다 ======= /// /// 거점이 무너졌다 — 그 리전의 곤충을 즉시 되돌린다. /// - /// 다음 Update를 기다리면 스폰 간격만큼 빈 들판이 남아 "돌아왔다"가 안 읽힌다. - /// 귀환종을 먼저 한 마리 확정 스폰하는 것도 같은 이유다 — 무작위에 맡기면 정화 직후 - /// 화면에 흔한 종만 뜰 수 있다. + /// 다음 틱의 재생을 기다리면 빈 들판이 남아 "돌아왔다"가 안 읽힌다. 귀환종을 먼저 한 자리에 확정하는 것도 + /// 같은 이유다 — 무작위에 맡기면 정화 직후 화면에 흔한 종만 뜰 수 있다. /// private void OnRegionCleansed(string regionId) { if (!isActiveAndEnabled || string.IsNullOrEmpty(regionId)) return; + cleanseHold.Add(regionId); // 이 프레임부터 평소 틱이 늘어난 상한을 먼저 채우지 않게 StartCoroutine(RepopulateCleansedRegion(regionId)); } @@ -799,115 +1312,290 @@ private System.Collections.IEnumerator RepopulateCleansedRegion(string regionId) // 정화 연출·컷신이 카메라를 잡고 있는 동안 곤충이 튀어나오면 화면이 어수선하다. // 한 박자 뒤에 채운다. yield return new WaitForSeconds(RepopulateDelaySeconds); - if (blight == null || blight.IsBlighted(regionId)) yield break; // 그새 상태가 바뀌었다 + if (blight == null || blight.IsBlighted(regionId)) // 그새 상태가 바뀌었다 + { + cleanseHold.Remove(regionId); + yield break; + } - // 그 사이 서브에리어로 들어갔을 수 있다 — 형제 코루틴 SpawnSubAreaInsectsDelayed가 - // 같은 자리에 같은 가드를 둔다. 안 두면 HideMainWorld가 숨긴 메인 월드 좌표에 - // 곤충이 떠서 보이지도 않는 채 maxActiveTotal 예산만 먹는다. - if (currentSubArea != null) yield break; + // 그 사이 서브에리어로 들어갔거나 첫 채우기 전이면 메인 월드 콜라이더로 자리를 못 잰다(HideMainWorld가 + // 숨겼다) — 메인 필드로 나오는 첫 틱에 채운다(TickField). 버리면 늘어난 자리가 평소 재생으로만 차서 + // 귀환종이 확정되지 않는다. + if (currentSubArea != null || !fieldReady) + { + if (!pendingCleansed.Contains(regionId)) pendingCleansed.Add(regionId); + yield break; + } + RepopulateCleansed(regionId); + } + + private void RepopulateCleansed(string regionId) + { + cleanseHold.Remove(regionId ?? string.Empty); + if (!regionInfoById.TryGetValue(regionId ?? string.Empty, out RegionSpawnInfo info)) return; + float now = Time.time; + ReconcileRegionSlots(info, now); // 상한이 늘어난 만큼 빈 자리가 생긴다 + List slots = population.SlotsOf(info.RegionId); + int near = 0; + + // 귀환종 먼저 — 한 자리에 확정. 월드 상태(시간·날씨) 필터를 거치지 않는다: 정화는 그 자리에서 눈으로 + // 확인해야 하는 사건이라, 밤이라서 혹은 비가 와서 안 나오면 연출이 통째로 죽는다. 리전 풀 밖의 종은 + // 띄우지 않는다(blight_lint 4가 귀환종 ∈ 리전 풀을 고정한다). + InsectData returning = ReturningSpecies(info); + if (returning != null && !population.HasAlive(info.RegionId, returning.insectId)) + { + FieldSlot target = FindSlotForReturning(slots); + if (target != null) + { + RecallSlot(target); + FieldPopulation.Vacate(target, now); + if (RollFieldSlot(target, info, now, FieldSpawnRules.CleanseSpawnMinPlayerDistance, + FieldSpawnRules.RollLifetime(Random.value), returning, nearPlayer: true)) + near++; + } + } - Data.RegionData region = regionManager != null ? regionManager.GetRegionById(regionId) : null; - if (region != null && !string.IsNullOrEmpty(region.blightReturningInsectId)) - TrySpawnSpecific(region.blightReturningInsectId, regionId); + // 나머지 빈 자리도 지금 채운다. 앞 몇 자리만 플레이어 둘레에 — 전부 몰면 오염 전보다 붐빈다. + for (int i = 0; i < slots.Count; i++) + { + FieldSlot slot = slots[i]; + if (!FieldPopulation.IsDueForRoll(slot, now)) continue; + bool nearPlayer = near < RepopulateNearCount; + if (RollFieldSlot(slot, info, now, FieldSpawnRules.CleanseSpawnMinPlayerDistance, + FieldSpawnRules.RollLifetime(Random.value), null, nearPlayer) && nearPlayer) + near++; + } + + // 가까우면 바로 선다. + if (TryGetPlayerPosition(out Vector3 playerPos)) SyncFieldBodies(playerPos); + } + + private static InsectData ReturningSpecies(RegionSpawnInfo info) + { + string id = info.Data != null ? info.Data.blightReturningInsectId : null; + if (string.IsNullOrEmpty(id)) return null; + for (int i = 0; i < info.Pool.Count; i++) + if (info.Pool[i].insectId == id) return info.Pool[i]; + return null; + } - int cap = RegionCap(regionId); - for (int i = 0; i < RepopulateBurst; i++) + /// 귀환종을 들일 자리 — 빈 자리가 먼저, 없으면 몸 없이 멀리 있는 산 자리(눈앞의 곤충은 바꾸지 않는다). + private static FieldSlot FindSlotForReturning(List slots) + { + FieldSlot fallback = null; + for (int i = 0; i < slots.Count; i++) { - if (CountActiveInRegion(regionId) >= cap) break; - if (activeInsects.Count >= maxActiveTotal) break; - // **GetAvailableSpawnPoint는 선호 리전에 쓸 포인트가 없으면 아무 리전으로 폴백한다.** - // 그런데 위 탈출 조건은 이 리전의 마릿수만 센다 — 가드가 없으면 스폰될 때마다 - // NotifySpawned가 포인트를 쿨다운에 넣어 후반이 폴백에 걸리고, 정작 정화한 - // 리전은 안 차는데 이웃 리전에 여섯 마리를 밀어 넣는다. - if (!HasAvailableSpawnPoint(regionId)) break; - TrySpawn(regionId); + FieldSlot s = slots[i]; + if (!s.IsAlive) return s; + if (fallback == null && !s.IsMaterialized) fallback = s; } + return fallback; } + // ======= 서브에리어 — 같은 원칙(기록이 개체), 슬롯마다 재생 ======= + private void OnSubAreaChanged(SubAreaData subArea) { currentSubArea = subArea; + currentSubAreaKey = subArea != null ? SubAreaKey(subArea) : null; + subAreaAnchored = false; - if (subArea != null) - { - // SubArea 진입: 메인 월드 활성 곤충 전부 풀로 반환 (메인 월드가 SetActive(false)되어 잔존 방지) - DespawnAllActiveInsects(); + // 진입이든 이탈이든 서 있는 몸은 전부 거둔다 — 기록은 남는다. 진입 땐 메인 월드가 SetActive(false)로 + // 숨겨지고, 이탈 땐 방이 파기되므로 어느 쪽이든 남겨 두면 허공에 뜬 몸이 된다. + RecallAllBodies(); - if (subArea.exclusiveInsectIds != null && subArea.exclusiveInsectIds.Length > 0) - { - // SubAreaWorldBuilder.EnterSubArea가 같은 이벤트에서 player를 SubAreaOrigin으로 - // 텔레포트하지만 구독 순서상 InsectSpawner가 먼저 호출됨. 1프레임 yield 후 스폰해야 - // player가 새 좌표에 있는 상태에서 anchor 계산 → 곤충과 player가 같은 공간. - StartCoroutine(SpawnSubAreaInsectsDelayed(subArea)); - } - } - else + if (subArea != null && HasExclusives(subArea)) { - // SubArea 종료: SubArea 전용 곤충은 메인 좌표와 무관한 위치에 있으므로 - // 메인 복귀 후 즉시 정리해야 화면에 잔존하지 않음 (다음 Update에서 메인 스폰 재개). - DespawnAllActiveInsects(); + // SubAreaWorldBuilder.EnterSubArea가 같은 이벤트에서 player를 방 입구로 텔레포트하지만 구독 순서상 + // 이쪽이 먼저 불린다. 한 프레임 뒤에 앵커를 잡아야 곤충과 player가 같은 공간에 선다. + StartCoroutine(EnterSubAreaDelayed(subArea)); } } - private System.Collections.IEnumerator SpawnSubAreaInsectsDelayed(SubAreaData subArea) + private System.Collections.IEnumerator EnterSubAreaDelayed(SubAreaData subArea) { yield return null; - // currentSubArea가 그새 바뀌었다면(빠른 Exit) 스폰 스킵 + // currentSubArea가 그새 바뀌었다면(빠른 Exit) 스킵 if (currentSubArea != subArea) yield break; - SpawnSubAreaInsects(subArea); + Transform pTrans = GetPlayerTransform(); + subAreaAnchor = pTrans != null ? pTrans.position : subArea.centerPosition; + subAreaAnchored = true; + RefreshSubArea(subArea, Time.time, true); } - private void DespawnAllActiveInsects() + private void TickSubArea() + { + if (!subAreaAnchored || currentSubArea == null || !HasExclusives(currentSubArea)) return; + RefreshSubArea(currentSubArea, Time.time, false); + } + + /// + /// 방의 슬롯을 돌본다 — 빈 자리가 시간이 되면 채우고, 산 개체에 몸을 세운다. 나갔다 다시 들어오면 + /// 같은 개체가 같은 자리에 선다(방은 늘 (2000,·,2000)에 지어지므로 월드 좌표 그대로 기록한다). + /// 옛 규칙은 들어올 때마다 새로 굴렸고, 방 안이 "0마리가 된 뒤 45초"를 기다렸다 — 이제 자리마다 따로 45초다. + /// + private void RefreshSubArea(SubAreaData sub, float now, bool entering) { - // 스냅샷 후 List 비움 — entity.Despawn() 콜백이 activeInsects.Remove 호출 시 no-op (안전) - var snapshot = activeInsects.ToArray(); - activeInsects.Clear(); - foreach (var entity in snapshot) + string key = currentSubAreaKey ?? SubAreaKey(sub); + int count = FieldSpawnRules.SubAreaSlotCount(sub.exclusiveInsectIds.Length, subAreaActiveCount); + population.EnsureSlotCount(key, count, now, isSubArea: true); + List slots = population.SlotsOf(key); + for (int i = 0; i < slots.Count; i++) { - if (entity != null) entity.Despawn(); + FieldSlot slot = slots[i]; + if (FieldPopulation.IsDueForRoll(slot, now)) + { + RollSubAreaSlot(slot, sub, slots.Count, now); + } + else if (entering && slot.IsAlive && !slot.IsMaterialized + && !IsInsideSubAreaRoom(subAreaAnchor, slot.Position)) + { + // 방이 달라졌다(동굴 미로는 들어올 때마다 새로 짓는다) — 개체는 그대로, 자리만 다시 잡는다. + slot.Position = PickSubAreaSlotPosition(sub); + } + + if (slot.IsAlive && !slot.IsMaterialized && activeInsects.Count < maxActiveTotal) + Materialize(slot, string.Empty); } } - private void SpawnSubAreaInsects(SubAreaData subArea) + private void RollSubAreaSlot(FieldSlot slot, SubAreaData sub, int slotCount, float now) { - if (database == null) return; + string id = FieldSpawnRules.SubAreaSpecies(sub.exclusiveInsectIds, slot.Index, slotCount, slot.Generation + 1); + InsectData data = database != null ? database.GetById(id) : null; + if (data == null) + { + // 전용종 ID가 DB에 없다 — 다음 차례 종으로 넘어가게 세대를 올려 두고 잠시 뒤 다시. + slot.Generation++; + FieldPopulation.Vacate(slot, now + FieldSpawnRules.RetrySeconds); + return; + } - // (직전 OnSubAreaChanged에서 DespawnAllActiveInsects로 메인 곤충 전부 정리 완료) + int level = Random.Range(sub.minLevel, sub.maxLevel + 1); + bool shiny = Random.value < InsectEntity.FieldShinyChance; + // 서브에리어 전용종은 시간대와 무관하다 — 수명 없이 잡히거나 놓칠 때까지 산다. + FieldPopulation.Fill(slot, data.insectId, data, level, shiny, false, PickSubAreaSlotPosition(sub), + float.PositiveInfinity); + } - // SubArea 진입 시 SubAreaWorldBuilder가 player를 별도 좌표(SubAreaOrigin)로 텔레포트했으므로 - // subArea.centerPosition(메인 좌표) 기준 스폰은 player와 다른 공간이라 절대 만날 수 없다. - // player 현재 위치(=SubAreaOrigin 근처)를 anchor로 사용. - Vector3 anchor = subArea.centerPosition; - Transform pTrans = GetPlayerTransform(); - if (pTrans != null) anchor = pTrans.position; + private static bool HasExclusives(SubAreaData sub) + => sub != null && sub.exclusiveInsectIds != null && sub.exclusiveInsectIds.Length > 0; + + // 리전 ID와 겹치지 않게 접두사를 붙인다 — 슬롯 기록부는 메인 필드와 서브에리어를 한 사전에 든다. + private static string SubAreaKey(SubAreaData sub) => "sub:" + (sub != null ? sub.subAreaId : string.Empty); + + /// 새로 차는 서브에리어 곤충이 플레이어 바로 옆에 생기지 않게 두는 거리(m) — 방이 작아 필드보다 짧다. + private const float SubAreaMinPlayerDistance = 4f; + private const int SubAreaPlayerGapAttempts = 4; - int spawnCount = Mathf.Min(subArea.exclusiveInsectIds.Length + 1, subAreaActiveCount); - for (int i = 0; i < spawnCount; i++) + /// + /// 서브에리어 슬롯 자리 — 진입 앵커 둘레(0.6R)에서 방 안만 받는다(). + /// 높이는 플레이어 접지와 같은 규칙(서브에리어판 — 콜라이더만)으로 그 자리 바닥에서 잰다. 메인 필드 규칙의 + /// 둔덕·호수(PickSpawnPosition)는 걸지 않는다: (2000,·,2000) 너머라 둘 다 없고 바닥도 FloorY(0.1)가 아니라 y=0이다. + /// 플레이어 y를 쓰지 않는 이유 — 진입 1프레임 뒤라 플레이어가 입구 텔레포트의 +0.5에 떠 있을 수 있다(얼어 있으면 + /// 접지를 안 돈다). 레이 깊이 2m는 플레이어 접지 레이(groundCheckDistance)와 같다. 방 안 판정이 같은 레이로 바닥을 + /// 이미 확인했다 — 안 잡힐 수 있는 건 당기기까지 실패한 마지막 안전망(앵커 자리)뿐이고, 그때는 앵커 y 그대로 둔다. + /// + private Vector3 PickSubAreaSlotPosition(SubAreaData sub) + { + Vector3 anchor = subAreaAnchor; + bool hasPlayer = TryGetPlayerPosition(out Vector3 playerPos); + Vector3 spawnPos = anchor; + for (int attempt = 0; attempt < SubAreaPlayerGapAttempts; attempt++) { - string insectId = subArea.exclusiveInsectIds[i % subArea.exclusiveInsectIds.Length]; - InsectData data = database.GetById(insectId); - if (data == null) continue; + spawnPos = PickSubAreaSpawnPosition(anchor, sub.radius * 0.6f); + if (!hasPlayer || PlanarDistance(spawnPos, playerPos) >= SubAreaMinPlayerDistance) break; + } - Vector2 offset = Random.insideUnitCircle * (subArea.radius * 0.6f); - Vector3 spawnPos = anchor + new Vector3(offset.x, 0f, offset.y); - int level = Random.Range(subArea.minLevel, subArea.maxLevel + 1); + float floorTop = ProbeColliderTop(spawnPos.x, anchor.y, spawnPos.z, 2f); + if (!float.IsNegativeInfinity(floorTop)) + spawnPos.y = PlayerMovement.GroundHeight(floorTop, spawnPos.x, spawnPos.z, true); + return spawnPos; + } - GameObject instance; - if (pool != null) - instance = pool.Get(); - else - instance = Instantiate(defaultPrefab, transform); + // ======= 서브에리어 스폰 자리 — 방 안쪽 ======= + // + // 서브에리어는 벽으로 봉한 방이다(SubAreaWorldBuilder.CreateBoundaryWalls, 반쪽 크기 9~16m — 테마마다 다르다). 플레이어는 + // 남쪽 입구(방 로컬 z −8)에 서는데 옛 스폰은 그 둘레 반경 0.6R(월드 배율 뒤 9~10.8m)에 뿌려서, 남쪽 벽(−9~−16) 너머와 + // 바닥 판 밖까지 나갔다 — 벽 속·허공에 뜬 곤충은 보이지도 잡히지도 않는다. + // + // 방 크기를 여기 다시 적지 않는다. 그 값은 빌더에 private으로만 있고(읽기 전용 조회 없음) story_lint 21이 소스에서 + // 같은 값을 읽는다 — 사본을 두면 벽을 옮길 때 조용히 어긋난다. 대신 지어진 방에 직접 묻는다: ①후보 발밑에 바닥이 + // 있고 ②플레이어 눈높이에서 후보까지 벽이 없으면 방 안이다. 벽 두께(1m) 속이나 벽 너머면 선이 벽 안쪽 면에 막히고, + // 바닥 판 밖이면 바닥 레이가 허공을 친다. 미로·기둥 뒤도 같은 이유로 버려진다(플레이어가 볼 수 있는 자리에만 선다). + + /// 서브에리어 자리를 굴리는 최대 횟수 — 방 밖 후보는 버리고 다시 뽑는다. + internal const int SubAreaSpawnRolls = 12; - instance.SetActive(true); - instance.transform.position = spawnPos; + /// 끝까지 방 밖이면 마지막 후보를 플레이어 쪽으로 반씩 당기는 횟수(1/8까지). + internal const int SubAreaPullSteps = 3; - InsectEntity entity = instance.GetComponent(); - if (entity == null) entity = instance.AddComponent(); + /// + /// 시야 검사 높이(발 위 m). 바위·그루터기 같은 낮은 소품은 넘겨 보고, 벽(3~7m)·기둥·미로 벽에는 막힌다. + /// 진입 직후 플레이어는 입구 텔레포트의 +0.5에 떠 있을 수 있어 선은 바닥 위 0.9~1.4m를 지난다. + /// + private const float SubAreaSightHeight = 0.9f; + + // NonAlloc 결과는 거리순이 아니다 — 버퍼가 차면 벽이 빠졌을 수 있어 방 밖으로 친다. + private readonly RaycastHit[] subAreaSightHits = new RaycastHit[32]; + + private Vector3 PickSubAreaSpawnPosition(Vector3 anchor, float radius) + { + return PickContainedPosition( + () => + { + Vector2 o = Random.insideUnitCircle * radius; + return anchor + new Vector3(o.x, 0f, o.y); + }, + anchor, p => IsInsideSubAreaRoom(anchor, p), SubAreaSpawnRolls); + } - entity.Initialize(data, level, null, DespawnEntity); - activeInsects.Add(entity); - totalSpawned++; + /// + /// 순수 판정 — 을 가 참일 때까지 최대 번 굴린다. + /// 끝까지 거짓이면 마지막 후보를 쪽으로 반씩 당겨(번) 들어오는 + /// 첫 자리를 쓴다. 플레이어는 방 안에 서 있으므로 당길수록 들어온다. 그래도 밖이면 플레이어 자리다 — + /// 스폰을 건너뛰지 않는다(방이 작아 자리 하나 못 구해 슬롯이 비면 그 방 곤충이 눈에 띄게 준다). y는 후보 것 그대로다. + /// + internal static Vector3 PickContainedPosition(System.Func roll, Vector3 anchor, + System.Func inside, int rolls) + { + Vector3 last = anchor; + for (int i = 0; i < rolls; i++) + { + last = roll(); + if (inside(last)) return last; } + for (int k = 0; k < SubAreaPullSteps; k++) + { + last = new Vector3((anchor.x + last.x) * 0.5f, last.y, (anchor.z + last.z) * 0.5f); + if (inside(last)) return last; + } + return new Vector3(anchor.x, last.y, anchor.z); + } + + private bool IsInsideSubAreaRoom(Vector3 anchor, Vector3 p) + { + // ① 발밑 바닥 — 바닥 판 밖(방 너머의 허공)이면 아무것도 안 맞는다. 높이 규칙과 같은 레이다(아래 floorTop). + if (float.IsNegativeInfinity(ProbeColliderTop(p.x, anchor.y, p.z, 2f))) return false; + + // ② 플레이어 눈높이 → 후보. 트리거(입구·출구 구)는 벽이 아니고, 먼저 놓은 곤충·플레이어 몸도 벽이 아니다. + Vector3 from = anchor + Vector3.up * SubAreaSightHeight; + Vector3 to = new Vector3(p.x, from.y, p.z); + Vector3 d = to - from; + float len = d.magnitude; + if (len < 1e-3f) return true; + int n = Physics.RaycastNonAlloc(from, d / len, subAreaSightHits, len, + Physics.DefaultRaycastLayers, QueryTriggerInteraction.Ignore); + if (n >= subAreaSightHits.Length) return false; + for (int i = 0; i < n; i++) + { + Collider c = subAreaSightHits[i].collider; + if (c == null) continue; + if (c.GetComponentInParent() != null) continue; + if (c.GetComponentInParent() != null) continue; + return false; + } + return true; } /// @@ -942,10 +1630,8 @@ public void ApplyTuning(Core.GameplayTuningProfile profile) return; } - spawnIntervalSeconds = Mathf.Max(1f, profile.spawnIntervalSeconds); + // 리전 곤충 수는 프로파일이 아니라 리전의 설 수 있는 땅이 정한다(FieldSpawnRules.SlotCountFor). maxActiveTotal = Mathf.Max(1, profile.maxActiveTotal); - initialSpawnCount = Mathf.Clamp(profile.initialSpawnCount, 1, maxActiveTotal); - maxActivePerRegion = Mathf.Max(1, profile.maxActivePerRegion); subAreaActiveCount = Mathf.Max(1, profile.subAreaActiveCount); subAreaRespawnSeconds = Mathf.Max(5f, profile.subAreaRespawnSeconds); } diff --git a/Assets/Scripts/Spawning/SpawnPoint.cs b/Assets/Scripts/Spawning/SpawnPoint.cs index 53923b6..78ea8db 100644 --- a/Assets/Scripts/Spawning/SpawnPoint.cs +++ b/Assets/Scripts/Spawning/SpawnPoint.cs @@ -2,11 +2,17 @@ namespace InsectGame.Spawning { + /// + /// 리전(또는 서브에리어)의 스폰 표 — 서식 풀과 레벨 대역을 스포너에 넘긴다. + /// + /// 이제 자리를 정하지 않는다. 예전엔 이 포인트 둘레 5m가 곧 스폰 자리였고 스포너가 8초마다 현재 리전 + /// 포인트를 플레이어 둘레 10~43m로 끌어왔다 — 그래서 리전을 옮기면 곤충이 새로 굴려졌다. 지금 자리는 + /// 가 리전 원판 전체에서 슬롯마다 한 번 정해 기록한다(). + /// 레벨 대역(requiredLevel + GetRegionLevelRange)의 출처가 부트스트랩이라 표는 이 컴포넌트가 계속 나른다. + /// 월드 좌표는 리전 정의(RegionData)를 못 찾는 씬에서만 리전 원을 어림하는 데 쓴다. + /// public class SpawnPoint : MonoBehaviour { - [SerializeField] private float radius = 5f; - [SerializeField] private int maxLocalActive = 2; - public string regionId; /// @@ -14,51 +20,13 @@ public class SpawnPoint : MonoBehaviour /// /// regionId만으로는 구분할 수 없다 — 서브에리어 포인트도 부모 리전의 ID를 /// 그대로 달고 있어서(부트스트랩이 그렇게 만든다) 리전 필터에 함께 걸린다. - /// 그래서 명시 플래그를 둔다: 스폰 레벨 곡선 판정과 재배치 제외가 이걸 본다. + /// 그래서 명시 플래그를 둔다: 메인 필드 스폰 표를 모을 때 이걸로 거른다 + /// (전용종이 필드 풀에 섞이면 필드 한복판에 서브에리어 전용종이 뜬다). /// public bool isSubAreaPoint; public string[] regionInsectIds; public int regionMinLevel = 1; public int regionMaxLevel = 5; - - private int localActiveCount; - - public bool CanSpawn => localActiveCount < maxLocalActive; - - public Vector3 GetRandomPosition() - { - Vector2 offset = Random.insideUnitCircle * radius; - return transform.position + new Vector3(offset.x, 0f, offset.y); - } - - public void NotifySpawned() - { - localActiveCount = Mathf.Max(0, localActiveCount + 1); - } - - public void NotifyDespawned() - { - localActiveCount = Mathf.Max(0, localActiveCount - 1); - } - - /// - /// 재동기화 시작 — 카운트를 0으로 두고 로 실제 개체를 다시 센다. - /// - /// 옛 ResetCount는 여기서 끝이라 살아 있는 곤충이 붙어 있어도 0으로 밀었다. - /// 재배치가 8초마다 그걸 부르니 상한 2가 "동시 2마리"가 아니라 "8초당 2마리"가 되어 - /// 같은 반경 5m에 네 마리 넘게 뭉쳤고, 이후 NotifyDespawned의 0 클램프 때문에 - /// 카운터가 실제보다 영구히 낮게 남았다. - /// - public void BeginRecount() - { - localActiveCount = 0; - } - - /// 재동기화 중 이 포인트 소속으로 살아 있는 개체 하나를 센다. - public void NotifyLive() - { - localActiveCount++; - } } } diff --git a/Assets/Scripts/Story/CutsceneDirector.cs b/Assets/Scripts/Story/CutsceneDirector.cs index 1917ce6..5e5e868 100644 --- a/Assets/Scripts/Story/CutsceneDirector.cs +++ b/Assets/Scripts/Story/CutsceneDirector.cs @@ -6,7 +6,7 @@ namespace InsectGame.Story { /// /// 프로시저럴 컷신 재생기 — 카메라 워크 + 흔들림 + 딤 + 자막을 시간축으로 돌린다. - /// 에셋을 쓰지 않는다(이 게임엔 영상도 컷신 스틸도 없다). + /// 에셋을 쓰지 않는다. 실제 영상(mp4)은 가 같은 시점에 따로 재생한다. /// /// 스토리 비트의 대사가 끝난 뒤(StoryBeatCompleted) 재생한다. 발화 시점에 걸면 /// 대사 모달과 화면을 다투게 되고, 읽는 순서도 "장면을 보고 나서 대사"가 되어 어색하다. @@ -58,6 +58,18 @@ public void AutoWire(StoryDirector director, CameraFollower follower, Subscribe(); } + // 1대1·레이드 결과 화면이 떠 있는가 — 부트스트랩이 넘긴다(Story가 UI를 모르게 함수 하나). 미뤄 둔 컷신의 포기 시계를 멈춘다. + private System.Func battleResultShowing; + + /// + /// 결과 화면 탐침. 결과 화면은 눌러야 닫혀서(BattleResultRules) 그 시간을 "굳은 전투 화면"으로 세면 + /// 보상을 오래 본 사람의 컷신이 사라진다(). 없으면 예전처럼 결과 화면도 센다. + /// + public void AutoWire(System.Func resultShowing) + { + if (battleResultShowing == null) battleResultShowing = resultShowing; + } + // AutoWire와 OnEnable이 함께 부른다 — `-=` 뒤 `+=`라 중복 구독이 되지 않는다. private void Subscribe() { @@ -88,9 +100,10 @@ private void OnBeatCompleted(StoryBeat beat) return; } - // **전투 화면이 아직 떠 있으면 미룬다.** `fin_seal`처럼 `BattleWin` 비트에 붙은 - // 컷신이 여기 걸린다 — 결과 화면은 4초 뒤 스스로 닫히는데 대사를 그보다 빨리 - // 넘기면 컷신이 전투 UI 밑에서 재생된다. 더 나쁜 건 카메라다: 그 시점의 + // **전투 화면이 아직 떠 있으면 미룬다.** 대사가 전투 화면이 걷히기 전에 끝나면(카메라가 배선 안 된 대사 큐가 + // 종료 통지 없이 상한을 넘겨 쏜 경우 등) 컷신이 전투 UI 밑에서 재생된다. 정상 경로에서는 `BattleWin` 비트 + // (fin_seal 등)가 결과 화면이 닫힌 **뒤에** 발화하므로(StoryDirector의 지연 큐) 여기 오지 않는다 — 이건 안전망이다. + // 더 나쁜 건 카메라다: 그 시점의 // Play는 InBattleMode = true를 "원래 상태"로 기록하고, 끝날 때 // 그 상태로 되돌려 **전투 구도에 갇힌다**(전투는 이미 끝났는데). // @@ -108,31 +121,34 @@ private void OnBeatCompleted(StoryBeat beat) // ── 전투 화면 때문에 미뤄 둔 컷신 ── private CutsceneShot[] pendingShots; + // 포기 시계 — 전투 카메라가 **결과 화면 밖에서** 안 풀린 시간만 센다(StoryBattleWait). + // 상한(StoryBattleWait.SceneGiveUpSeconds)을 넘으면 **연출을 잃되 진행은 잃지 않는다** — 비트는 이미 완료됐고, + // 억지로 재생하면 카메라가 전투 구도에 갇힌다. 결과 화면은 이제 눌러야 닫히지만 그 시간은 세지 않으므로 + // 보상을 오래 봐도 여기 닿지 않는다 — 닿는 건 결과 화면이 아닌데 카메라가 굳은 경우뿐이다. private float pendingSeconds; - /// - /// 미뤄 둔 컷신을 포기하는 시각(초). 전투가 끝나지 않으면 연출을 잃되 진행은 잃지 - /// 않는다 — 비트는 이미 완료됐고, 억지로 재생하면 카메라가 전투 구도에 갇힌다. - /// 결과 화면은 4초면 닫히므로 정상 경로에서는 여기 닿지 않는다. - /// - private const float PendingGiveUpSeconds = 12f; - private void TickPending() + /// 미뤄 둔 컷신의 한 프레임. 무엇을 했는지 돌려준다(테스트가 본다). + private StoryBattleWait.SceneStep TickPending(float deltaSeconds) { - if (pendingShots == null) return; + if (pendingShots == null) return StoryBattleWait.SceneStep.None; - if (cameraFollower == null || !cameraFollower.InBattleMode) + StoryBattleWait.SceneStep step = StoryBattleWait.TickScene(ref pendingSeconds, deltaSeconds, + cameraFollower != null && cameraFollower.InBattleMode, + StoryBattleWait.ReadProbe(battleResultShowing), + modalOpen: false); + + if (step == StoryBattleWait.SceneStep.Play) { CutsceneShot[] queued = pendingShots; pendingShots = null; Play(queued); - return; } - - pendingSeconds += Time.unscaledDeltaTime; - if (pendingSeconds < PendingGiveUpSeconds) return; - - Debug.LogWarning("[Cutscene] 전투 화면이 닫히지 않아 컷신을 건너뛴다"); - pendingShots = null; + else if (step == StoryBattleWait.SceneStep.GiveUp) + { + Debug.LogWarning("[Cutscene] 전투 화면(결과 화면 밖)이 닫히지 않아 컷신을 건너뛴다"); + pendingShots = null; + } + return step; } public void Play(CutsceneShot[] definition) @@ -208,7 +224,7 @@ private void Update() { if (!playing) { - TickPending(); + TickPending(Time.unscaledDeltaTime); return; } diff --git a/Assets/Scripts/Story/CutsceneLibrary.cs b/Assets/Scripts/Story/CutsceneLibrary.cs index 65d127d..39647f5 100644 --- a/Assets/Scripts/Story/CutsceneLibrary.cs +++ b/Assets/Scripts/Story/CutsceneLibrary.cs @@ -3,8 +3,12 @@ namespace InsectGame.Story { /// - /// 컷신 저작. 에셋 0개로 만든다 — 이 게임에는 영상도 컷신용 스틸도 없고, - /// 카메라 워크 + 기존 월드 + 조명 + 자막만으로 장면을 만든다. + /// 프로시저럴 컷신 저작. 에셋 0개로 만든다 — 카메라 워크 + 기존 월드 + 조명 + 자막만으로 + /// 장면을 만든다. 실제 영상 파일은 가 따로 맡는다. + /// + /// 이야기의 큰 장면(이름 벽·울타리·그림자·결말)은 2026-10-05에 그림책 영상(대사 앞 introVideoId)으로 옮겼고 + /// 그 네 컷신(cs_seal_discovery·cs_seal_opening·cs_nameless_confront·cs_final_seal)은 지웠다. + /// 남은 것은 오염 거점 정화()와 쓰지 않는 폴백()이다. /// /// 좌표는 전부 플레이어 기준 상대이므로 어느 리전·서브에리어에서 발화해도 동작한다. /// y가 큰 값은 위에서 내려다보는 각, z 음수는 플레이어 뒤쪽이다. @@ -13,16 +17,12 @@ namespace InsectGame.Story /// public static class CutsceneLibrary { - /// 1막 개막 — 마을 어르신에게 이야기를 듣기 직전, 초원을 둘러본다. + /// + /// 1막 개막 — 초원을 둘러본다. 지금은 쓰이지 않는다: ch1_intro는 영상 + /// vid_ch1_prologue()로 대체됐다. 상수·case·테스트는 + /// 영상 파일이 없는 환경의 폴백 후보로 남긴다. + /// public const string StoryPrologue = "cs_story_prologue"; - /// 2막 개막 — 유적 지하에서 봉인이 열리는 장면. - public const string SealOpening = "cs_seal_opening"; - /// 최종장 — 무명과의 대치. - public const string NamelessConfront = "cs_nameless_confront"; - /// 1막 클라이맥스 — 유적 신전에서 봉인의 정체(기록)를 본 순간. - public const string SealDiscovery = "cs_seal_discovery"; - /// 최종장 마무리 — 마지막 빈칸이 메워지고 그것이 설 자리를 잃는다. - public const string FinalSeal = "cs_final_seal"; /// /// 오염 거점이 무너지고 그 리전에 곤충이 돌아온다. 거점 전부가 같은 컷신을 쓴다 — /// 좌표가 전부 플레이어 기준 상대라 산에서도 유적에서도 그대로 맞고, 자막도 장소를 @@ -35,10 +35,6 @@ public static bool TryGet(string cutsceneId, out CutsceneShot[] shots) switch (cutsceneId) { case StoryPrologue: shots = BuildStoryPrologue(); return true; - case SealOpening: shots = BuildSealOpening(); return true; - case NamelessConfront: shots = BuildNamelessConfront(); return true; - case SealDiscovery: shots = BuildSealDiscovery(); return true; - case FinalSeal: shots = BuildFinalSeal(); return true; case BlightCleanse: shots = BuildBlightCleanse(); return true; default: shots = null; return false; } @@ -92,195 +88,12 @@ private static CutsceneShot[] BuildStoryPrologue() }; } - /// - /// 「봉인이 열린 날」 — 기록을 되살린 그 행위가 안에 갇혀 있던 것을 가둔 순간. - /// 플레이어의 선의가 사건을 일으켰다는 것이 2막의 골자이므로, 카메라는 제단이 아니라 - /// 플레이어를 먼저 비춘다. - /// - private static CutsceneShot[] BuildSealOpening() - { - return new[] - { - // 1. 플레이어에게서 천천히 물러난다 — 방금 한 일의 크기를 보여주는 각. - new CutsceneShot(2.6f, - camFrom: new Vector3(0f, 2.4f, -3.2f), - camTo: new Vector3(0f, 5.2f, -7.4f), - lookAt: new Vector3(0f, 1.2f, 0f), - subtitle: "기록이 제자리를 찾았다."), - - // 2. 정적. 자막 없이 그림만 — 다음 흔들림을 크게 만드는 건 이 침묵이다. - new CutsceneShot(1.4f, - camFrom: new Vector3(0f, 5.2f, -7.4f), - camTo: new Vector3(0.6f, 5.0f, -7.0f), - lookAt: new Vector3(0f, 1.2f, 0f)), - - // 3. 균열. 카메라가 아래로 꺾이며 흔들린다. - new CutsceneShot(2.2f, - camFrom: new Vector3(0.6f, 5.0f, -7.0f), - camTo: new Vector3(0.2f, 2.8f, -4.6f), - lookAt: new Vector3(0f, 0.2f, 2.4f), - subtitle: "그리고 바닥이 갈라졌다.", - shake: 0.42f), - - // 4. 갈라진 틈으로 시선을 내린다. 딤이 들어오며 빛이 줄어든다. - new CutsceneShot(3.0f, - camFrom: new Vector3(0.2f, 2.8f, -4.6f), - camTo: new Vector3(0f, 1.6f, -2.2f), - lookAt: new Vector3(0f, -0.6f, 2.8f), - subtitle: "울타리는 무언가를 지키려고 세운 것이 아니었다.", - dim: 0.30f), - - // 5. 반전. 가장 어둡고 가장 가깝다. - new CutsceneShot(3.4f, - camFrom: new Vector3(0f, 1.6f, -2.2f), - camTo: new Vector3(0f, 1.3f, -1.6f), - lookAt: new Vector3(0f, -0.8f, 2.6f), - subtitle: "가두려고 세운 것이었다 — 그리고 방금, 안에 있던 것이 갇혔다.", - shake: 0.22f, - dim: 0.52f), - - // 6. 복귀. 딤을 걷으며 원래 시점으로. - new CutsceneShot(1.8f, - camFrom: new Vector3(0f, 1.3f, -1.6f), - camTo: new Vector3(0f, 3.0f, -4.0f), - lookAt: new Vector3(0f, 1.0f, 0f), - dim: 0.16f), - }; - } - - /// - /// 최종장 — 「무명」과의 대치. 이름이 없는 것 앞에서는 카메라도 초점을 잡지 못한다는 - /// 인상을 주려고 시선 지점을 조금씩 어긋나게 둔다. - /// - private static CutsceneShot[] BuildNamelessConfront() - { - return new[] - { - new CutsceneShot(2.4f, - camFrom: new Vector3(0f, 2.2f, -3.0f), - camTo: new Vector3(-1.4f, 2.6f, -3.8f), - lookAt: new Vector3(0f, 1.4f, 3.0f), - subtitle: "이름을 부를 수 없는 것이 거기 서 있었다.", - dim: 0.24f), - - // 시선이 미끄러진다 — lookAt을 좌우로 흔들어 초점이 안 잡히는 느낌을 만든다. - new CutsceneShot(2.0f, - camFrom: new Vector3(-1.4f, 2.6f, -3.8f), - camTo: new Vector3(1.6f, 2.4f, -3.6f), - lookAt: new Vector3(-1.2f, 1.6f, 3.4f), - subtitle: "보고 있는데도, 무엇을 보고 있는지 알 수 없었다.", - dim: 0.34f), - - new CutsceneShot(2.6f, - camFrom: new Vector3(1.6f, 2.4f, -3.6f), - camTo: new Vector3(0f, 1.8f, -2.4f), - lookAt: new Vector3(0f, 1.5f, 3.2f), - subtitle: "빈칸은 사라지지 않는다. 다만 지금은, 이름을 부를 차례다.", - shake: 0.3f, - dim: 0.46f), - - new CutsceneShot(1.6f, - camFrom: new Vector3(0f, 1.8f, -2.4f), - camTo: new Vector3(0f, 3.0f, -4.0f), - lookAt: new Vector3(0f, 1.0f, 0f), - dim: 0.12f), - }; - } - - /// - /// 「벽은 목록이었다」 — 1막의 클라이맥스. 세라가 대사로 이미 설명을 마친 뒤에 재생되므로 - /// (StoryBeatCompleted) 여기서는 설명하지 않고 보여준다. - /// - /// 카메라가 플레이어를 떠나 벽으로 갔다가 다시 돌아오는 왕복 구조다. 2막의 - /// 이 같은 자리에서 바닥으로 내려가는 것과 짝을 이룬다 — - /// 여기서 올려다본 것이 저기서 발밑에서 갈라진다. - /// - /// 총 10.8초. 상한은 PlayerMovement.AutoUnfreezeTime(20초)이고 - /// CutsceneTimelineTests가 여유 4초를 강제한다. - /// - private static CutsceneShot[] BuildSealDiscovery() - { - return new[] - { - // 1. 플레이어 어깨 너머에서 벽 쪽으로 — 무엇을 보고 있는지부터 맞춘다. - new CutsceneShot(2.6f, - camFrom: new Vector3(0f, 2.0f, -3.2f), - camTo: new Vector3(-1.2f, 2.4f, -1.8f), - lookAt: new Vector3(0f, 2.2f, 3.4f), - subtitle: "벽은 장식이 아니었다. 목록이었다."), - - // 2. 벽을 옆으로 훑는다. 자막 없이 그림만 — 수를 세게 하려는 컷이다. - new CutsceneShot(2.4f, - camFrom: new Vector3(-1.2f, 2.4f, -1.8f), - camTo: new Vector3(2.4f, 2.6f, -1.4f), - lookAt: new Vector3(0.8f, 2.4f, 3.6f)), - - // 3. 올라가 전체를 담는다. 빈칸이 보이기 시작하는 각. - new CutsceneShot(2.8f, - camFrom: new Vector3(2.4f, 2.6f, -1.4f), - camTo: new Vector3(0.4f, 5.6f, -4.2f), - lookAt: new Vector3(0f, 2.0f, 3.0f), - subtitle: "이름 하나가 바랠 때마다, 세계에서 한 종이 지워졌다.", - dim: 0.18f), - - // 4. 내려와 플레이어 곁으로. 딤을 걷으며 끝낸다. - new CutsceneShot(3.0f, - camFrom: new Vector3(0.4f, 5.6f, -4.2f), - camTo: new Vector3(0f, 2.4f, -3.4f), - lookAt: new Vector3(0f, 1.3f, 0.8f), - subtitle: "그리고 방금, 한 줄이 다시 채워졌다."), - }; - } - - /// - /// 「들어올 틈이 없어졌다」 — 최종전 직후. 이 딤을 쌓으며 - /// 들어갔으므로 여기서는 딤을 걷으며 나온다(0.40 → 0.22 → 0.08 → 0). - /// 화면이 밝아지는 것 자체가 결말이라, 마지막 컷에는 자막을 두지 않는다. - /// - /// 「무명」을 이름으로 부르지 않는다 — 이 이야기의 규칙이자 승리 조건이다(StoryBible 2장). - /// 총 9.0초. - /// - private static CutsceneShot[] BuildFinalSeal() - { - return new[] - { - // 1. 가장 어둡고 가장 가깝다 — 대치의 마지막 프레임을 이어받는다. - new CutsceneShot(2.4f, - camFrom: new Vector3(0f, 1.5f, -2.0f), - camTo: new Vector3(0f, 1.9f, -2.8f), - lookAt: new Vector3(0f, 1.4f, 2.6f), - subtitle: "빌려 쓴 모습들이 하나씩 제자리로 돌아갔다.", - dim: 0.40f), - - // 2. 물러나며 올라간다. 자막 없이 — 빈 자리를 눈으로 확인하는 사이. - new CutsceneShot(2.2f, - camFrom: new Vector3(0f, 1.9f, -2.8f), - camTo: new Vector3(-0.8f, 3.4f, -4.6f), - lookAt: new Vector3(0f, 1.2f, 2.0f), - dim: 0.22f), - - // 3. 가장 먼 각. 여기서만 결말을 말한다. - new CutsceneShot(2.6f, - camFrom: new Vector3(-0.8f, 3.4f, -4.6f), - camTo: new Vector3(0.6f, 4.4f, -6.0f), - lookAt: new Vector3(0f, 1.0f, 1.6f), - subtitle: "그것은 죽지 않았다. 들어올 틈이 없어졌을 뿐이다.", - dim: 0.08f), - - // 4. 플레이어 곁으로 복귀. 딤 0 — 멀리서 끝내면 카메라가 튀어 돌아온다. - new CutsceneShot(1.8f, - camFrom: new Vector3(0.6f, 4.4f, -6.0f), - camTo: new Vector3(0f, 2.6f, -3.6f), - lookAt: new Vector3(0f, 1.2f, 0.6f)), - }; - } - /// /// 거점이 무너진 직후 — 걷어 간 손이 사라진 땅. /// /// 딤을 걷으며 나온다(0.42 → 0). 오염 아크의 다른 장면이 아니라 이 장면 하나가 - /// 아크의 마침표라, 들어갈 때가 아니라 나올 때 밝아지는 것이 맞다(FinalSeal이 - /// 같은 형태다). 흔들림은 첫 컷에만 약하게 — 구조물이 주저앉는 순간을 대신한다. + /// 아크의 마침표라, 들어갈 때가 아니라 나올 때 밝아지는 것이 맞다(옛 결말 컷신 cs_final_seal도 + /// 같은 형태였다 — 지금 결말은 영상 vid_fin_return이 맡는다). 흔들림은 첫 컷에만 약하게 — 구조물이 주저앉는 순간을 대신한다. /// /// 마지막 컷은 플레이어 가까이서 끝낸다. 멀리서 끝나면 컷신이 카메라를 놓는 /// 순간 추적 카메라가 튀어 돌아온다. diff --git a/Assets/Scripts/Story/DreamIntroVideo.cs b/Assets/Scripts/Story/DreamIntroVideo.cs new file mode 100644 index 0000000..af6f210 --- /dev/null +++ b/Assets/Scripts/Story/DreamIntroVideo.cs @@ -0,0 +1,273 @@ +using InsectGame.Core; +using InsectGame.UI; +using UnityEngine; +using UnityEngine.Video; + +namespace InsectGame.Story +{ + /// + /// 「챔피언의 꿈」 도입 영상(경기장 입장 8초) 재생기. 무음 영상 + 별도 음원을 한 시계로 튼다 — + /// OpeningSceneController와 같은 방식이라 건너뛰기·일시정지가 시계 하나로 끝나고, 영상이 못 떠도 + /// 호출부()가 글자 카드로 이어 간다. + /// + /// 상태는 하나다: 준비 중(검은 화면) → 재생 → 끝남. 못 튼 모든 길(파일 없음·디코더 오류· + /// 준비 3초 초과·재생 중 멈춤)이 /로 모이므로 호출부는 둘만 보면 된다. + /// 영상 파일은 StreamingAssets/Video/dream_arena.mp4, 음원은 Resources/Audio/Dream/dream_arena이며 + /// 둘 다 Tools/Video/dream_arena*.py가 만든다(시각표의 단일 출처는 그 스크립트). + /// + public sealed class DreamIntroVideo + { + public enum State { Idle, Preparing, Playing, Ended, Failed } + + public const string FileName = "dream_arena.mp4"; + public const string AudioResourcePath = "Audio/Dream/dream_arena"; + private const string VideoFolder = "Video"; + // 처음 여는 디코더는 느릴 수 있다 — Windows QA 빌드에서 3.8초가 걸렸다(StoryVideoDirector와 같은 5초). + private const float PrepareTimeoutSeconds = 5f; + // 긴 프레임(씬 로딩 직후의 끊김)이 준비 시간을 한꺼번에 먹지 않게 프레임당 상한을 둔다(OpeningPlaybackClock과 같다). + private const float MaxFrameDelta = 0.1f; + // 소리와 영상이 이만큼 벌어지면 소리를 영상 시각에 맞춘다(디코더가 첫 프레임을 늦게 내는 기기). + private const float AudioDriftSeconds = 0.25f; + private const float StallSeconds = 1.2f; + private const float OverrunGraceSeconds = 1f; + // 영상 음성(환호)과 월드 BGM이 8초 내내 겹치지 않게 — StoryVideoDirector와 같은 계열. + private const float BgmDuckFactor = 0.12f; + + private readonly GameObject host; + private VideoPlayer player; + private AudioSource audioSource; + private RenderTexture texture; + private AudioClip clip; + private bool componentsReady; + private bool ducked; + private float prepareSeconds; + private float clock; + private float lastVideoTime; + private float stalledSeconds; + + public DreamIntroVideo(GameObject host) { this.host = host; } + + public State Current { get; private set; } = State.Idle; + + /// 재생 시작(첫 프레임)부터 흐른 시간(초). 재생 중에만 의미가 있다. + public float Clock => clock; + + public bool IsActive => Current == State.Preparing || Current == State.Playing; + + private static string BuildUrl() => + // Path.Combine은 Windows에서 역슬래시를 넣는데 Android jar 경로는 '/'만 받는다. + Application.streamingAssetsPath + "/" + VideoFolder + "/" + FileName; + + private void EnsureComponents() + { + if (componentsReady || host == null) return; + componentsReady = true; + + audioSource = host.AddComponent(); + audioSource.playOnAwake = false; + audioSource.loop = false; + audioSource.spatialBlend = 0f; + + player = host.AddComponent(); + player.playOnAwake = false; + player.isLooping = false; + player.source = VideoSource.Url; + player.renderMode = VideoRenderMode.RenderTexture; + // 영상은 무음이다 — 소리는 위 AudioSource가 같은 시계로 튼다(두 트랙이 어긋나지 않게). + player.audioOutputMode = VideoAudioOutputMode.None; + player.skipOnDrop = true; + player.waitForFirstFrame = true; + player.timeUpdateMode = VideoTimeUpdateMode.UnscaledGameTime; + player.prepareCompleted += OnPrepared; + player.loopPointReached += OnReachedEnd; + player.errorReceived += OnError; + } + + /// + /// 준비를 시작한다. 파일이 없다는 걸 미리 알 수 있을 때(에디터·데스크톱)만 false — 그 밖의 실패는 + /// 이 로 알린다(Android는 StreamingAssets가 jar 안이라 미리 볼 수 없다). + /// + public bool Play() + { + Stop(); + EnsureComponents(); + if (player == null) { Current = State.Failed; return false; } + + string url = BuildUrl(); +#if UNITY_EDITOR || UNITY_STANDALONE + if (!System.IO.File.Exists(url)) + { + Debug.LogWarning($"[DreamIntro] 파일이 없다: {url} — 글자 카드로 대신한다"); + Current = State.Failed; + return false; + } +#endif + clip = Resources.Load(AudioResourcePath); // 없으면 무음으로 간다 + prepareSeconds = 0f; + clock = 0f; + lastVideoTime = 0f; + stalledSeconds = 0f; + Current = State.Preparing; + ApplyDuck(true); + player.url = url; + player.Prepare(); + return true; + } + + /// 매 프레임(비스케일 시간). 시간 초과·멈춤·끝을 상태로 바꾼다. + public void Tick(float dt) + { + switch (Current) + { + case State.Preparing: + prepareSeconds += Mathf.Min(dt, MaxFrameDelta); + if (prepareSeconds > PrepareTimeoutSeconds) Fail("준비 시간 초과"); + break; + + case State.Playing: + // 앱을 백그라운드에서 되돌릴 때의 큰 dt가 시계를 한꺼번에 밀어 영상이 끝난 것으로 오판하지 않게 한다. + clock += Mathf.Min(dt, MaxFrameDelta); + // 디코더가 한 프레임에 서 버리면 오래 붙들지 않는다 — 영상 시각이 안 흐르는 시간을 센다. + // 첫 프레임 전(time == 0)은 세지 않는다. + float videoTime = (float)player.time; + if (videoTime > lastVideoTime + 0.001f) { lastVideoTime = videoTime; stalledSeconds = 0f; } + else if (lastVideoTime > 0f) stalledSeconds += Mathf.Min(dt, MaxFrameDelta); // 끊긴 한 프레임을 멈춤으로 오인하지 않게 + + // 영상 시각이 기준이다 — 건너뛰기·되감기가 아니라 시작 지연이 만드는 어긋남만 바로잡는다. + if (clip != null && audioSource != null && audioSource.isPlaying && videoTime > 0f) + { + float want = Mathf.Min(videoTime, clip.length - 0.01f); + if (Mathf.Abs(audioSource.time - want) > AudioDriftSeconds) audioSource.time = want; + } + + if (stalledSeconds > StallSeconds) + { + Debug.LogWarning("[DreamIntro] 재생이 멈춰 카드로 넘어간다"); + Conclude(State.Ended); + } + else if (clock > DreamPrologueData.IntroSeconds + OverrunGraceSeconds) Conclude(State.Ended); + break; + } + } + + /// + /// 화면 전체를 영상으로 덮는다(cover — 세로 화면은 가운데를 잘라 채운다). 준비 중에는 검은 화면 위에 점 하나만 — + /// 멈춘 것처럼 보이지 않게(StoryVideoDirector와 같다). + /// + public void Draw(Rect full) + { + if (Current == State.Preparing) + { + float pulse = 0.35f + 0.35f * Mathf.Sin(Time.unscaledTime * 4f); + Color previous = GUI.color; + GUI.color = new Color(1f, 1f, 1f, pulse); + GUI.DrawTexture(new Rect(full.width * 0.5f - 5f, full.height * 0.5f - 5f, 10f, 10f), Texture2D.whiteTexture); + GUI.color = previous; + return; + } + if (Current != State.Playing || texture == null) return; + Rect uv = UIHelper.CalculateCoverUv(full, texture.width, texture.height); + GUI.DrawTextureWithTexCoords(full, texture, uv, false); + } + + /// 앱이 백그라운드로 가면 영상과 소리를 함께 멈춘다. + public void Pause(bool paused) + { + if (Current != State.Playing || player == null) return; + if (paused) { player.Pause(); if (audioSource != null) audioSource.Pause(); } + else { player.Play(); if (audioSource != null) audioSource.UnPause(); } + } + + /// 재생을 접고 처음 상태(Idle)로 돌아간다 — 두 번 불려도 안전하다. + public void Stop() + { + if (player != null && IsActive) player.Stop(); // 준비 중이어도 부른다 — 진행 중인 Prepare를 취소해 디코더를 닫는다 + if (audioSource != null) audioSource.Stop(); + ApplyDuck(false); + Current = State.Idle; + } + + /// 호스트가 사라질 때 — 구독과 렌더 텍스처를 풀어 준다. + public void Dispose() + { + Stop(); + if (player != null) + { + player.prepareCompleted -= OnPrepared; + player.loopPointReached -= OnReachedEnd; + player.errorReceived -= OnError; + player.targetTexture = null; + } + if (texture != null) + { + texture.Release(); + Object.Destroy(texture); + texture = null; + } + } + + private void OnPrepared(VideoPlayer source) + { + if (Current != State.Preparing) return; + Debug.Log($"[DreamIntro] 준비 완료 {prepareSeconds:0.00}초 ({source.width}x{source.height}, {source.frameRate:0.#}fps)"); + EnsureTexture((int)source.width, (int)source.height); + source.targetTexture = texture; + Current = State.Playing; + clock = 0f; + lastVideoTime = 0f; + stalledSeconds = 0f; + source.Play(); + if (clip != null && audioSource != null) + { + audioSource.clip = clip; + audioSource.volume = Mathf.Clamp01(PlayerPrefs.GetFloat( + GameConstants.PrefsKeys.MasterVolume, GameConstants.Defaults.MasterVolume)); + audioSource.time = 0f; + audioSource.Play(); + } + } + + private void OnReachedEnd(VideoPlayer source) + { + if (Current == State.Playing) Conclude(State.Ended); + } + + private void OnError(VideoPlayer source, string message) => Fail(message); + + private void Fail(string reason) + { + Debug.LogWarning($"[DreamIntro] 재생 실패: {reason} — 글자 카드로 대신한다"); + Conclude(State.Failed); + } + + // 끝낸 뒤에도 상태(Ended/Failed)는 호출부가 읽을 때까지 남긴다. 디코더·소리·BGM 더킹은 여기서 놓는다. + private void Conclude(State result) + { + if (player != null) player.Stop(); + if (audioSource != null) audioSource.Stop(); + ApplyDuck(false); + Current = result; + } + + private void ApplyDuck(bool on) + { + if (ducked == on) return; + ducked = on; + if (AudioManager.Instance != null) AudioManager.Instance.SetBgmDuck(on ? BgmDuckFactor : 1f); + } + + private void EnsureTexture(int width, int height) + { + if (width <= 0 || height <= 0) { width = 1280; height = 720; } + if (texture != null && texture.width == width && texture.height == height) return; + if (texture != null) + { + if (player != null) player.targetTexture = null; + texture.Release(); + Object.Destroy(texture); + } + texture = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32) { name = "DreamIntroRT" }; + texture.Create(); + } + } +} diff --git a/Assets/Scripts/Story/DreamIntroVideo.cs.meta b/Assets/Scripts/Story/DreamIntroVideo.cs.meta new file mode 100644 index 0000000..73eba68 --- /dev/null +++ b/Assets/Scripts/Story/DreamIntroVideo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 68066ffefa8978d4883ca26d557ba9f2 \ No newline at end of file diff --git a/Assets/Scripts/Story/DreamPrologueDirector.cs b/Assets/Scripts/Story/DreamPrologueDirector.cs new file mode 100644 index 0000000..dfe9b57 --- /dev/null +++ b/Assets/Scripts/Story/DreamPrologueDirector.cs @@ -0,0 +1,987 @@ +using InsectGame.Battle; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.UI; +using UnityEngine; + +namespace InsectGame.Story +{ + /// + /// 「챔피언의 꿈」 — 새 계정이 처음 필드에 서면 한 번 도는 프롤로그(설정에서 다시 볼 수 있다). + /// + /// + /// 챔피언전: 최상급 전설 곤충으로 실제 전투 화면에서 싸운다. 규칙은 + /// — 반드시 이기고 3~4턴에 끝난다. + /// 챔피언의 섬: 꾸며진 섬(방문 모드의 스냅샷)을 직접 걸으며 이동·곤충 살펴보기를 익힌다. + /// 깨어남: 섬이 흐려지고 마을 어르신의 목소리에 깬다 — 꿈이었다. 곤충 소리 없는 초원이 본편의 시작이다. + /// + /// + /// 꿈 밖의 상태는 하나도 바뀌지 않는다. 그 약속은 하나로 지킨다 — + /// 퀘스트 진행·필드 HUD·자동 주행·섬 HUD가 그 표지를 읽고 물러난다. 전투는 샌드박스라 보상·도감·저장이 없다. + /// 끝나는 길이 여럿이라(끝까지·건너뛰기·오류·씬 재로드) 정리는 한 곳에 모으고 + /// OnDisable/OnDestroy도 같은 곳을 지난다 — 표지가 켜진 채 남으면 그 세션의 퀘스트가 영영 안 올라간다. + /// + public class DreamPrologueDirector : MonoBehaviour + { + private enum Phase { Idle, Opening, Battle, ToIsland, Island, ToWake, Wake } + private enum IslandStep { Move, Approach, Fountain, Linger } + + // ── 시간표(초) ── + // 영상이 못 뜰 때의 예전 도입: 페이드 → 글자 카드. 영상 뒤에는 이미 검어서 카드만 짧게 뜬다. + private const float OpenFadeSeconds = 0.8f; + private const float OpenTitleSeconds = 2.8f; + private const float IntroCardSeconds = 2.4f; + private const float IntroPrepFadeSeconds = 0.5f; + private const float BattleRevealSeconds = 1.0f; + private const float FlashSeconds = 0.5f; + private const float IslandRevealSeconds = 1.1f; + private const float IslandTitleSeconds = 3.2f; + private const float WakeFadeOutSeconds = 1.0f; + private const float WakeFadeInSeconds = 1.8f; + private const float CaptionSeconds = 2.7f; + private const float InsectCardSeconds = 4.2f; + private const float LingerSeconds = 2.8f; + // 어디서 막혀도 꿈이 끝나게 하는 상한들. + private const float BattleStartTimeoutSeconds = 3f; + private const float BattleEndTimeoutSeconds = 14f; + private const float IslandTimeoutSeconds = 180f; + + // ── 걸음 기준 ── + private const float MoveGoalMeters = 8f; + private const float ApproachMeters = 3.2f; + private const float FountainMeters = 2.6f; + private const float TeleportMeters = 5f; // 한 프레임에 이만큼 움직였으면 걸음이 아니라 순간이동이다 + + private static readonly (string speaker, string text)[] WakeCaptions = + { + ("마을 어르신", "이봐… 일어나게나."), + ("", "꿈이었나."), + ("", "그 많던 곤충들이… 하나도 보이지 않는다."), + }; + + private TutorialQuestManager questManager; + private PlayerMovement playerMovement; + private Transform playerTransform; + private RegionManager regionManager; + private IslandWorldBuilder islandWorld; + private InsectBattleController battleController; + private BattleScreenUI battleScreen; + private InsectDatabase database; + private FieldMomentFeed momentFeed; + + private Phase phase = Phase.Idle; + private float phaseTime; + private float stableTimer; + private bool battleStarted; + private bool battleEnded; + private bool subscribed; + private int battleTurns; + private float battleEndedAt; + private string championName = string.Empty; + private string challengerName = string.Empty; + // OnGUI 패스마다 문자열을 새로 만들지 않는다 — 값이 바뀔 때만 다시 만든다. + private string versusLine = string.Empty; + private int shownMeters = -1; + private string meterLabel = string.Empty; + + // 페이드(전체 화면 한 장). 색은 검정(꿈으로 들어갈 때)·흰색(장면 전환·깨어남). + private float fadeAlpha; + private Color fadeColor = Color.black; + + private IslandStep step; + private float stepTime; + private float movedMeters; + private Vector3 lastPlayerPos; + // (cardStart/cardDuration이 카드 시간을 든다) + private string cardTitle = string.Empty; + private string cardSub = string.Empty; + private float cardStart; + private float cardDuration; + private bool enteredIsland; + private int captionIndex; + private float captionTime; + + private Camera cachedCamera; + // 검수 촬영이 한 단계를 세워 두는 동안 시간이 흐르지 않게 한다. enabled=false는 못 쓴다 — OnDisable이 꿈을 끝낸다. + private bool pausedForCapture; + // 도입: 경기장 입장 영상 → 글자 카드. 영상이 못 뜨면(openVideoDone이 곧바로 참) 예전처럼 카드만. + private DreamIntroVideo introVideo; + private bool openVideoDone; + private float openClock; // 카드 단계의 시계 + private float openCardStart; // 카드 글자가 뜨기 시작하는 시각(openClock 기준) + private float openCardEnd; + private GUIStyle skipStyle; + private GUIStyle titleStyle, subStyle, bigStyle, hintStyle, captionStyle, speakerStyle, glyphStyle, distStyle; + private bool stylesReady; + + public bool IsRunning => phase != Phase.Idle; + + public void AutoWire(TutorialQuestManager quests, PlayerMovement movement, Transform player, + RegionManager region, IslandWorldBuilder island, InsectBattleController battle, + BattleScreenUI battleUi, InsectDatabase db, FieldMomentFeed feed) + { + if (questManager == null) questManager = quests; + if (playerMovement == null) playerMovement = movement; + if (playerTransform == null) playerTransform = player; + if (regionManager == null) regionManager = region; + if (islandWorld == null) islandWorld = island; + if (battleController == null) battleController = battle; + if (battleScreen == null) battleScreen = battleUi; + if (database == null) database = db; + if (momentFeed == null) momentFeed = feed; + } + + private static string PlayedKey => SaveScope.PrefsKey(DreamPrologueRules.PlayedPrefsKey); + + // ── 외부 진입점 ── + + /// 설정 화면의 「다시 보기」가 눌러도 되는가. + public bool CanReplay => CanReplayIgnoring(null); + + /// + /// (버튼이 든 설정 화면)를 뺀 다른 모달이 없고 조작이 풀려 있는가. + /// 설정 화면은 자기가 모달이라 그냥 물으면 늘 막힌다. + /// + public bool CanReplayIgnoring(System.Type modalToIgnore) => DreamPrologueRules.CanReplay( + IsRunning, ModalUIRegistry.IsAnyOpenExcept(modalToIgnore), + playerMovement != null && playerMovement.IsFrozen, + regionManager != null && regionManager.CurrentSubArea != null) + && !DreamPrologueState.Active && battleController != null && battleScreen != null && islandWorld != null; + + public bool TryReplay() + { + if (!CanReplay) return false; + Begin(); + return true; + } + + // ── 생명주기 ── + + private void Update() + { + if (pausedForCapture) return; + if (phase == Phase.Idle) + { + WatchAutoStart(); + return; + } + + float dt = Time.unscaledDeltaTime; + phaseTime += dt; + switch (phase) + { + case Phase.Opening: UpdateOpening(dt); break; + case Phase.Battle: UpdateBattle(dt); break; + case Phase.ToIsland: UpdateToIsland(); break; + case Phase.Island: UpdateIsland(dt); break; + case Phase.ToWake: UpdateToWake(); break; + case Phase.Wake: UpdateWake(dt); break; + } + } + + private void OnDisable() { if (phase != Phase.Idle) Finish(false); } + + private void OnDestroy() + { + if (phase != Phase.Idle) Finish(false); + else DreamPrologueState.End(); + if (introVideo != null) introVideo.Dispose(); + } + + private void OnApplicationPause(bool paused) + { + if (introVideo != null) introVideo.Pause(paused); + } + + private void WatchAutoStart() + { + if (questManager == null || playerMovement == null || regionManager == null) return; + + TutorialQuest active = questManager.ActiveQuest; + bool playedBefore = PlayerPrefs.GetInt(PlayedKey, 0) != 0; + bool ok = battleController != null && battleScreen != null && islandWorld != null + && DreamPrologueRules.ShouldAutoStart( + active != null ? active.questId : null, questManager.ActiveProgress, playedBefore, + ModalUIRegistry.IsAnyOpen(), playerMovement.IsFrozen, regionManager.CurrentSubArea != null); + + stableTimer = ok ? stableTimer + Time.unscaledDeltaTime : 0f; + if (stableTimer >= DreamPrologueRules.StableSeconds) Begin(); + } + + // ── 시작 ── + + private void Begin() + { + stableTimer = 0f; + DreamPrologueState.Begin(); + if (playerMovement != null) playerMovement.SetFrozen(true); + + championName = ReadChampionName(); + InsectData foe = ResolveSpecies(DreamPrologueData.ChallengerInsectId); + challengerName = foe != null ? foe.displayName : string.Empty; + versusLine = string.IsNullOrEmpty(challengerName) ? string.Empty : $"vs {challengerName}"; + enteredIsland = false; + battleStarted = false; + battleEnded = false; + battleTurns = 0; + + SetPhase(Phase.Opening); + fadeColor = Color.black; + fadeAlpha = 0f; + StartIntro(); + } + + private static string ReadChampionName() + { + string name = PlayerPrefs.GetString(SaveScope.PrefsKey("InsectGame.Character.Name"), string.Empty); + return string.IsNullOrWhiteSpace(name) ? "챔피언" : name.Trim(); + } + + private void SetPhase(Phase next) + { + phase = next; + phaseTime = 0f; + } + + // ── 도입(경기장 입장 영상 → 검은 화면의 타이틀 카드) ── + + // 영상을 틀고(성공하면 영상 단계), 파일을 미리 못 찾으면 글자 카드로 대신한다. + private void StartIntro() + { + if (introVideo == null) introVideo = new DreamIntroVideo(gameObject); + if (introVideo.Play()) { openVideoDone = false; openClock = 0f; } + else SetOpeningCard(false); + } + + // 카드 단계의 시간표. 영상 뒤엔 이미 검은 화면이라 곧바로 글자가 뜨고, 영상 없이는 페이드 인 뒤에 뜬다. + private void SetOpeningCard(bool afterVideo) + { + openVideoDone = true; + openClock = 0f; + openCardStart = afterVideo ? 0f : OpenFadeSeconds; + openCardEnd = openCardStart + (afterVideo ? IntroCardSeconds : OpenTitleSeconds); + } + + private void UpdateOpening(float dt) + { + if (!openVideoDone) + { + introVideo.Tick(dt); + DreamIntroVideo.State state = introVideo.Current; + if (state == DreamIntroVideo.State.Preparing) + { + fadeAlpha = Mathf.Clamp01(phaseTime / IntroPrepFadeSeconds); // 준비하는 동안 검게 가라앉는다 + return; + } + if (state == DreamIntroVideo.State.Playing) + { + fadeAlpha = 1f; // 영상이 화면을 덮는다 — 이 검정은 첫 프레임 전의 틈 메우기다 + return; + } + // 끝났거나(마지막 프레임은 검다) 못 틀었다 — 카드로 이어 간다. + bool played = state == DreamIntroVideo.State.Ended; + introVideo.Stop(); + SetOpeningCard(played); + return; + } + + openClock += dt; + fadeAlpha = openCardStart <= 0f ? 1f : Mathf.Clamp01(openClock / openCardStart); + if (openClock < openCardEnd) return; + + if (TryStartBattle()) SetPhase(Phase.Battle); + else BeginIsland(); // 전투를 못 세우면(종 없음 등) 섬부터 — 꿈은 계속된다 + } + + // ── 챔피언전 ── + + private bool TryStartBattle() + { + InsectData ace = ResolveSpecies(DreamPrologueData.AceInsectId); + InsectData foe = ResolveSpecies(DreamPrologueData.ChallengerInsectId); + if (ace == null || foe == null || battleController == null || battleScreen == null) return false; + + PlayerInsectData champion = DreamPrologueData.BuildChampionInsect(ace); + InsectSkill[] skills = DreamPrologueData.PickChampionSkills(ace, DreamPrologueData.AceLevel); + + SubscribeBattle(); + if (!battleController.StartSandbox(ace, DreamPrologueData.AceLevel, foe, + DreamPrologueData.ChallengerLevel, skills, champion)) + { + UnsubscribeBattle(); + return false; + } + battleStarted = true; + return true; + } + + private void SubscribeBattle() + { + if (subscribed || battleController == null) return; + subscribed = true; + battleController.BattleUpdated += OnBattleUpdated; + battleController.BattleEnded += OnBattleEnded; + } + + private void UnsubscribeBattle() + { + if (!subscribed || battleController == null) { subscribed = false; return; } + subscribed = false; + battleController.BattleUpdated -= OnBattleUpdated; + battleController.BattleEnded -= OnBattleEnded; + } + + private void OnBattleUpdated(InsectBattleStats player, InsectBattleStats enemy) + { + if (battleController != null && battleController.IsSandbox && battleController.PlayerActedThisRound) + battleTurns++; + } + + private void OnBattleEnded(bool playerWon) + { + battleEnded = true; + battleEndedAt = phaseTime; + } + + private void UpdateBattle(float dt) + { + // 검은 화면이 걷히며 전투 연출이 드러난다. + fadeAlpha = Mathf.Clamp01(1f - phaseTime / BattleRevealSeconds); + + bool screenActive = battleScreen != null && battleScreen.IsBattleActive; + // 전투 화면이 안 뜨면 꿈이 거기서 멈춘다 — 상한 뒤에 섬으로 넘어간다. + if (!screenActive && !battleEnded && phaseTime > BattleStartTimeoutSeconds) { EndBattlePhase(); return; } + // 끝난 뒤 결과 화면이 닫히면(EndBattle) 다음으로. + if (battleEnded && !screenActive) { EndBattlePhase(); return; } + if (battleEnded && phaseTime - battleEndedAt > BattleEndTimeoutSeconds) EndBattlePhase(); + } + + private void EndBattlePhase() + { + UnsubscribeBattle(); + SetPhase(Phase.ToIsland); + // 전투 화면이 막 사라져 월드가 한 프레임 비칠 수 있다 — 흰색으로 곧바로 덮는다. + fadeColor = Color.white; + fadeAlpha = 1f; + } + + // ── 장면 전환(흰 섬광) ── + + private void UpdateToIsland() + { + fadeAlpha = 1f; + if (phaseTime < FlashSeconds) return; // 흰 화면을 잠깐 머문 뒤 섬을 연다 + BeginIsland(); + } + + // ── 챔피언의 섬 ── + + private void BeginIsland() + { + fadeColor = Color.white; + fadeAlpha = 1f; + if (!EnterIsland()) + { + BeginWake(); // 섬을 못 열면 곧바로 깨어난다 — 꿈을 어디서든 마칠 수 있어야 한다 + return; + } + SetPhase(Phase.Island); + step = IslandStep.Move; + stepTime = 0f; + movedMeters = 0f; + lastPlayerPos = playerTransform != null ? playerTransform.position : Vector3.zero; + ShowCard("챔피언의 섬", "함께 싸운 곤충들이 모두 모여 있다", IslandTitleSeconds); + if (AudioManager.Instance != null) AudioManager.Instance.PlayBGM(BgmType.ExploreMeadow); + } + + private bool EnterIsland() + { + if (islandWorld == null) return false; + islandWorld.DreamMode = true; + IslandSnapshot snapshot = DreamPrologueData.BuildIslandSnapshot(championName); + IslandSaveRules.SanitizeSnapshot(snapshot); // 남이 만든 데이터와 같은 길 — 모르는 ID·겹침은 여기서 걸러진다 + if (playerMovement != null) playerMovement.SetFrozen(false); + enteredIsland = islandWorld.EnterVisit(snapshot); + if (!enteredIsland) islandWorld.DreamMode = false; + return enteredIsland; + } + + private void UpdateIsland(float dt) + { + // 섬광이 걷히며 섬이 드러난다. + fadeAlpha = Mathf.Clamp01(1f - phaseTime / IslandRevealSeconds); + stepTime += dt; + + if (phaseTime > IslandTimeoutSeconds) { BeginWake(); return; } + if (islandWorld == null || playerTransform == null || !islandWorld.IsOnIsland) + { + // 섬에서 쫓겨났다(끼임 복구·강제 이탈) — 같은 꿈을 이어 갈 수 없다. + BeginWake(); + return; + } + + Vector3 pos = playerTransform.position; + Vector3 delta = pos - lastPlayerPos; + delta.y = 0f; + float stepMeters = delta.magnitude; + if (stepMeters < TeleportMeters) movedMeters += stepMeters; + lastPlayerPos = pos; + + switch (step) + { + case IslandStep.Move: + if (movedMeters >= MoveGoalMeters) NextStep(IslandStep.Approach); + break; + case IslandStep.Approach: + if (islandWorld.TryGetNearestInsect(pos, out Vector3 insectPos, out InsectData data, out int level) + && PlanarDistance(pos, insectPos) <= ApproachMeters) + { + ShowCard($"{data.displayName}", $"{data.rarity.Korean()} · Lv.{level} · 당신의 파트너", InsectCardSeconds); + if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.LevelUp); + NextStep(IslandStep.Fountain); + } + break; + case IslandStep.Fountain: + // 카드를 읽을 시간을 준다 — 곧바로 다음 목표로 시선을 뺏지 않는다. + if (stepTime > 1.5f && PlanarDistance(pos, DreamPrologueData.FountainWorldPosition()) <= FountainMeters) + { + if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.SetComplete); + NextStep(IslandStep.Linger); + } + break; + case IslandStep.Linger: + if (stepTime >= LingerSeconds) BeginWake(); + break; + } + } + + private void NextStep(IslandStep next) + { + step = next; + stepTime = 0f; + } + + private static float PlanarDistance(Vector3 a, Vector3 b) + { + a.y = 0f; + b.y = 0f; + return Vector3.Distance(a, b); + } + + private void ShowCard(string title, string sub, float seconds) + { + cardTitle = title; + cardSub = sub; + cardStart = Time.unscaledTime; + cardDuration = seconds; + } + + // ── 깨어남 ── + + private void BeginWake() + { + UnsubscribeBattle(); + SetPhase(Phase.ToWake); + fadeColor = Color.white; + } + + private void UpdateToWake() + { + fadeAlpha = Mathf.Clamp01(phaseTime / WakeFadeOutSeconds); + if (fadeAlpha < 1f) return; + + LeaveIsland(); + if (playerMovement != null) playerMovement.SetFrozen(true); + if (AudioManager.Instance != null) AudioManager.Instance.RestoreExploreBGM(); + captionIndex = 0; + captionTime = 0f; + SetPhase(Phase.Wake); + } + + private void LeaveIsland() + { + if (islandWorld == null) return; + if (islandWorld.IsOnIsland) islandWorld.ExitIsland(); + islandWorld.DreamMode = false; + enteredIsland = false; + } + + private void UpdateWake(float dt) + { + fadeAlpha = Mathf.Clamp01(1f - phaseTime / WakeFadeInSeconds); + captionTime += dt; + if (captionTime < CaptionSeconds) return; + + captionTime = 0f; + captionIndex++; + if (captionIndex >= WakeCaptions.Length) Finish(true); + } + + // ── 건너뛰기 ── + + private void Skip() + { + switch (phase) + { + case Phase.Opening: + // 도입을 건너뛰어도 전투는 본다 — 건너뛰기가 연출 전체를 날리는 버튼이 되면 안 된다. + if (introVideo != null) introVideo.Stop(); + SetOpeningCard(true); + openClock = openCardEnd; // 카드도 건너뛴다 — 다음 프레임에 전투가 선다 + break; + case Phase.Battle: + // 전투는 정상 종료 길(결과 화면·정리)을 타게 이긴 것으로 끝낸다. + if (battleController != null) battleController.ForceSandboxVictory(); + break; + case Phase.Island: + BeginWake(); + break; + case Phase.Wake: + Finish(true); + break; + } + } + + // ── 정리 ── + + /// + /// 꿈을 끝낸다 — 모든 종료 길이 여기를 지난다. 이 줄들 중 하나라도 빠지면 표지가 켜진 채 남거나 + /// 섬에 갇히거나 플레이어가 얼어 붙는다. + /// + private void Finish(bool completed) + { + if (introVideo != null) introVideo.Stop(); // 영상 소리·디코더·BGM 더킹을 놓는다 + UnsubscribeBattle(); + LeaveIsland(); + Phase was = phase; + phase = Phase.Idle; + fadeAlpha = 0f; + DreamPrologueState.End(); + if (playerMovement != null) playerMovement.SetFrozen(false); + if (AudioManager.Instance != null && was != Phase.Idle) AudioManager.Instance.RestoreExploreBGM(); + + // 끝까지 보았거나 건너뛰었을 때만 "봤다"고 적는다 — 오류·씬 재로드로 끊긴 것은 다음 기회에 다시 보여 준다. + if (completed) + { + PlayerPrefs.SetInt(PlayedKey, 1); + PlayerPrefs.Save(); + if (momentFeed != null) + momentFeed.Push(FieldMomentKind.Discovery, "꿈속의 섬을 현실로", + "곤충을 모으고 키워서 나만의 섬을 꾸며 보세요"); + } + } + + private InsectData ResolveSpecies(string insectId) + { + if (database == null) return null; + InsectData data = database.GetById(insectId); + if (data != null) return data; + // ID가 사라졌다면 아무 전설종이라도 — 꿈이 통째로 안 뜨는 것보다 낫다. 테스트가 ID 존재를 따로 고정한다. + foreach (InsectData candidate in database.insects) + if (candidate != null && candidate.rarity == InsectRarity.Legendary) return candidate; + return null; + } + + // ── 그리기 ── + + private void InitStyles() + { + if (stylesReady) return; + stylesReady = true; + UITheme t = UITheme.Instance; + titleStyle = new GUIStyle(GUI.skin.label) { fontSize = 26, alignment = TextAnchor.MiddleCenter }; + subStyle = new GUIStyle(GUI.skin.label) { fontSize = 28, alignment = TextAnchor.MiddleCenter }; + bigStyle = new GUIStyle(GUI.skin.label) { fontSize = 66, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + hintStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + captionStyle = new GUIStyle(GUI.skin.label) { fontSize = 38, alignment = TextAnchor.MiddleCenter }; + speakerStyle = new GUIStyle(GUI.skin.label) { fontSize = 24, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + speakerStyle.normal.textColor = t.accentAmber; + glyphStyle = new GUIStyle(GUI.skin.label) { fontSize = 26, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + glyphStyle.normal.textColor = t.surfaceBase; + distStyle = new GUIStyle(GUI.skin.label) { fontSize = 20, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + skipStyle = new GUIStyle(GUI.skin.label) { fontSize = 22, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + skipStyle.normal.textColor = t.textPrimary; + } + + private void OnGUI() + { + if (phase == Phase.Idle) return; + + GUI.depth = -100; // 전투 화면·섬 위에 얹는다 + UIScale.Begin(); + InitStyles(); + // 페이드가 먼저다 — 검은 화면 위의 타이틀 카드와 흰 화면 위의 자막이 페이드 **위에** 보여야 한다. + DrawFade(); + switch (phase) + { + case Phase.Opening: DrawOpening(); break; + case Phase.Battle: DrawBattleHint(); break; + case Phase.Island: DrawIslandOverlay(); break; + case Phase.Wake: DrawCaption(); break; + } + if (phase != Phase.ToIsland && phase != Phase.ToWake) DrawSkipButton(); + GUI.color = Color.white; + UIScale.End(); + } + + private void DrawFade() + { + if (fadeAlpha <= 0.001f) return; + GUI.color = new Color(fadeColor.r, fadeColor.g, fadeColor.b, fadeAlpha); + GUI.DrawTexture(new Rect(0f, 0f, UIScale.VirtualScreenWidth, UIScale.VirtualScreenHeight), Texture2D.whiteTexture); + GUI.color = Color.white; + } + + private void DrawOpening() + { + if (!openVideoDone) + { + if (introVideo != null) + introVideo.Draw(new Rect(0f, 0f, UIScale.VirtualScreenWidth, UIScale.VirtualScreenHeight)); + return; + } + DrawOpeningCard(); + } + + private void DrawOpeningCard() + { + float textAlpha = Mathf.Clamp01((openClock - openCardStart) / 0.7f) + * Mathf.Clamp01((openCardEnd - openClock) / 0.5f); + if (textAlpha <= 0f) return; + UITheme t = UITheme.Instance; + Rect panel = UISafeLayout.CenteredPanel(Mathf.Min(900f, UISafeLayout.ContentWidth), 300f); + + titleStyle.normal.textColor = Faded(t.accentAmber, textAlpha); + UIHelper.LabelFit(new Rect(panel.x, panel.y, panel.width, 50f), "— 챔피언 결정전 —", titleStyle); + bigStyle.normal.textColor = Faded(t.textPrimary, textAlpha); + UIHelper.LabelFit(new Rect(panel.x, panel.y + 60f, panel.width, 110f), championName, bigStyle); + if (versusLine.Length > 0) + { + subStyle.normal.textColor = Faded(t.textSecondary, textAlpha); + UIHelper.LabelFit(new Rect(panel.x, panel.y + 190f, panel.width, 50f), versusLine, subStyle); + } + } + + private static Color Faded(Color c, float alpha) => new Color(c.r, c.g, c.b, c.a * alpha); + + // 전투 중 안내 — 한 턴에 한 줄. 위치는 전투 화면의 상단 HUD 아래다. + private void DrawBattleHint() + { + if (battleEnded || phaseTime < BattleRevealSeconds * 0.6f) return; + // 세로 화면에서는 양쪽 HP 카드가 폭을 거의 다 차지한다 — 그 아래로 내린다(2026-10-03 검수 캡처). + DrawHintPill(BattleHintFor(battleTurns), UIScale.IsPortrait ? 250f : 168f); + } + + private static string BattleHintFor(int turns) + { + bool mobile = UIScale.IsMobileLayout; + switch (turns) + { + case 0: return mobile ? "아래 기술 카드를 눌러 공격하세요" : "기술 카드를 누르거나 [1]~[4] 키로 공격하세요"; + case 1: return "상성이 맞으면 '잘 통했다!'가 떠요 — 기술마다 대기 시간이 있어요"; + case 2: return "마지막 일격! 가장 강한 기술을 써 보세요"; + default: return "결승전, 마무리!"; + } + } + + /// 섬 걷기 안내 알약의 윗변 — 안전 영역 위에서 이만큼(건너뛰기 버튼과 같은 줄, 가운데). + public const float IslandPillTop = 40f; + + /// 안내 알약의 자리 — 순수 계산(겹침 전수 검사가 섬 단계의 것을 읽는다). + public static Rect HintPillRect(HudFrame f, float topOffset) + { + Rect pill = f.TopPanel(Mathf.Min(f.ContentWidth, f.Mobile ? 640f : 900f), 64f); + pill.y += topOffset; + return pill; + } + + private void DrawHintPill(string text, float topOffset) + { + UITheme t = UITheme.Instance; + Rect pill = HintPillRect(HudFrame.Current, topOffset); + FieldHudInput.RegisterBlockingRect(pill); + UISurface.HudCard(pill); + hintStyle.normal.textColor = t.textPrimary; + UIHelper.LabelFit(new Rect(pill.x + 18f, pill.y, pill.width - 36f, pill.height), text, hintStyle); + } + + // ── 섬 ── + + private void DrawIslandOverlay() + { + UITheme t = UITheme.Instance; + + // 목표 안내 + string hint = null; + Vector3 target = Vector3.zero; + bool hasTarget = false; + switch (step) + { + case IslandStep.Move: + hint = UIScale.IsMobileLayout ? "1/3 왼쪽 조이스틱으로 걸어 보세요" : "1/3 [WASD] 또는 클릭으로 걸어 보세요"; + break; + case IslandStep.Approach: + hint = "2/3 곤충에게 다가가 보세요"; + if (islandWorld != null && playerTransform != null + && islandWorld.TryGetNearestInsect(playerTransform.position, out Vector3 insectPos, out _, out _)) + { + target = insectPos; + hasTarget = true; + } + break; + case IslandStep.Fountain: + hint = "3/3 분수 앞으로 가 보세요"; + target = DreamPrologueData.FountainWorldPosition(); + hasTarget = true; + break; + case IslandStep.Linger: + hint = "모두가 당신을 기다리고 있었다"; + break; + } + if (hint != null && phaseTime > 1f) DrawHintPill(hint, IslandPillTop); + if (hasTarget && phaseTime > 1f) DrawWaypoint(target, t.accentMint); + + // 카드(섬 이름·곤충 소개) — 0.3초에 나타나 끝나기 0.4초 전부터 사라진다 + float shown = Time.unscaledTime - cardStart; + if (cardDuration > 0f && shown < cardDuration) + DrawCard(Mathf.Clamp01(shown / 0.3f) * Mathf.Clamp01((cardDuration - shown) / 0.4f)); + } + + /// + /// 섬 카드(섬 이름·곤충 소개)의 자리 — 순수 계산. 아래 가운데. 세로 모바일은 잡기 버튼 열(오른쪽 아래)이 폭 760 카드의 + /// 오른쪽 끝에 걸리므로 그 위의 습격 경고 글자보다 위로 올린다. + /// + public static Rect IslandCardRect(HudFrame f) + { + Rect card = f.BottomPanel(Mathf.Min(f.ContentWidth, 760f), 150f); + card.y -= 24f; + if (f.Mobile && f.Portrait) + card.y = Mathf.Min(card.y, InsectGame.Capture.CatchButtonLayout.WarnRect(f).y - UITheme.Space.S - card.height); + return card; + } + + private void DrawCard(float alpha) + { + UITheme t = UITheme.Instance; + Rect card = IslandCardRect(HudFrame.Current); + FieldHudInput.RegisterBlockingRect(card); + GUI.color = new Color(1f, 1f, 1f, alpha); + UISurface.Card(card, t.surfaceCard, Color.Lerp(t.surfaceBorder, t.accentAmber, 0.6f)); + UISurface.Flat(new Rect(card.x + UITheme.Radius.Card, card.y + 3f, card.width - UITheme.Radius.Card * 2f, 4f), t.accentAmber); + GUI.color = Color.white; + bigStyle.fontSize = 44; + bigStyle.normal.textColor = Faded(t.accentAmber, alpha); + UIHelper.LabelFit(new Rect(card.x + 20f, card.y + 16f, card.width - 40f, 70f), cardTitle, bigStyle); + bigStyle.fontSize = 66; + subStyle.normal.textColor = Faded(t.textSecondary, alpha); + UIHelper.LabelFit(new Rect(card.x + 20f, card.y + 90f, card.width - 40f, 44f), cardSub, subStyle); + } + + // 목표 표식 — 화면 안이면 그 자리에 고리와 거리, 밖이면 가장자리에 붙여 방향만 알린다(지도 쐐기와 같은 약속). + private void DrawWaypoint(Vector3 world, Color color) + { + if (cachedCamera == null) cachedCamera = Camera.main; + if (cachedCamera == null || playerTransform == null) return; + + Vector3 sp = cachedCamera.WorldToScreenPoint(world + Vector3.up * 1.2f); + if (sp.z < 0f) { sp.x = Screen.width - sp.x; sp.y = Screen.height - sp.y; } + float s = Mathf.Max(0.3f, UIScale.Scale); + Vector2 g = new Vector2(sp.x / s, (Screen.height - sp.y) / s); + + float margin = 70f; + Rect inner = new Rect(UIScale.VirtualSafeLeft + margin, UISafeLayout.ContentTop + margin + 110f, + UIScale.VirtualScreenWidth - UIScale.VirtualSafeLeft - UIScale.VirtualSafeRight - margin * 2f, + UISafeLayout.ContentHeight - margin * 2f - 220f); + bool onScreen = sp.z > 0f && inner.Contains(g); + Vector2 p = new Vector2(Mathf.Clamp(g.x, inner.x, inner.xMax), Mathf.Clamp(g.y, inner.y, inner.yMax)); + + float pulse = 0.5f + 0.5f * Mathf.Sin(Time.unscaledTime * 4f); + float r = 26f + 6f * pulse; + UIShapes.Ellipse(new Rect(p.x - r - 6f, p.y - r - 6f, (r + 6f) * 2f, (r + 6f) * 2f), new Color(color.r, color.g, color.b, 0.3f)); + UIShapes.Ellipse(new Rect(p.x - 26f, p.y - 26f, 52f, 52f), color); + + if (onScreen) + { + GUI.Label(new Rect(p.x - 26f, p.y - 26f, 52f, 52f), "!", glyphStyle); + } + else + { + // 방향 화살표 — 화면 가운데에서 목표로 향하는 각도로 돌린다. + Vector2 center = new Vector2(UIScale.VirtualScreenWidth * 0.5f, UIScale.VirtualScreenHeight * 0.5f); + Vector2 dir = g - center; + float angle = Mathf.Atan2(dir.x, -dir.y) * Mathf.Rad2Deg; + Matrix4x4 saved = GUI.matrix; + GUI.matrix = MapMarkerProjection.PivotMatrix(saved, p, angle); + GUI.Label(new Rect(-26f, -26f, 52f, 52f), "▲", glyphStyle); + GUI.matrix = saved; + } + + int meters = Mathf.RoundToInt(PlanarDistance(playerTransform.position, world)); + if (meters != shownMeters) + { + shownMeters = meters; + meterLabel = meters + "m"; + } + distStyle.normal.textColor = Color.white; + UIHelper.LabelFit(new Rect(p.x - 50f, p.y + 32f, 100f, 26f), meterLabel, distStyle); + } + + // ── 깨어남의 자막 ── + + private void DrawCaption() + { + if (captionIndex >= WakeCaptions.Length) return; + // 흰 화면이 걷히는 동안 첫 줄이 먼저 뜬다 — 눈을 뜨는 것과 목소리가 겹친다. + float inA = Mathf.Clamp01(captionTime / 0.4f); + float outA = Mathf.Clamp01((CaptionSeconds - captionTime) / 0.4f); + float alpha = inA * outA; + if (alpha <= 0f) return; + + UITheme t = UITheme.Instance; + var (speaker, text) = WakeCaptions[captionIndex]; + Rect panel = UISafeLayout.BottomPanel(Mathf.Min(UISafeLayout.ContentWidth, 1000f), 150f); + panel.y -= 60f; + bool hasSpeaker = !string.IsNullOrEmpty(speaker); + // 흰 화면이 걷히는 동안에는 밝은 바탕 위에 흰 글자가 얹힌다 — 어두운 띠를 깐다. + UISurface.Rounded(new Rect(panel.x, panel.y - 8f, panel.width, hasSpeaker ? 128f : 108f), + new Color(t.surfaceBase.r, t.surfaceBase.g, t.surfaceBase.b, 0.78f * alpha)); + if (hasSpeaker) + { + speakerStyle.normal.textColor = Faded(t.accentAmber, alpha); + UIHelper.LabelFit(new Rect(panel.x, panel.y, panel.width, 34f), speaker, speakerStyle); + } + captionStyle.normal.textColor = Faded(hasSpeaker ? t.textPrimary : t.textSecondary, alpha); + UIHelper.LabelFit(new Rect(panel.x, panel.y + (hasSpeaker ? 40f : 20f), panel.width, 70f), text, captionStyle); + } + + // ── 건너뛰기 ── + + /// + /// 건너뛰기 버튼의 자리 — 순수 계산. 오른쪽 위. 전투 중에는 오른쪽 위가 배속 버튼 자리라 아레나 바닥 쪽(아래 행동 패널 바로 위)으로 내린다. + /// + public static Rect SkipRect(HudFrame f, bool battle) + { + float w = f.Mobile ? 170f : 150f; + float h = f.Mobile ? 64f : 50f; + float y = battle ? Mathf.Clamp(f.Height * 0.6f, f.ContentTop, f.ContentBottom - h) : f.ContentTop + 10f; + return new Rect(f.Width - f.SafeRight - w - 20f, y, w, h); + } + + private void DrawSkipButton() + { + UITheme t = UITheme.Instance; + Rect rect = SkipRect(HudFrame.Current, phase == Phase.Battle); + FieldHudInput.RegisterBlockingRect(rect); + skipStyle.fontSize = UIScale.IsMobileLayout ? 26 : 22; + if (UISurface.Button(rect, "건너뛰기 ›", Faded(t.surfaceRaised, 0.85f), skipStyle)) Skip(); + } + +#if UNITY_EDITOR || DEVELOPMENT_BUILD + // ── 검수 빌드 전용 ── + + /// + /// 지정한 단계의 화면을 세워 둔다(실제 IMGUI 촬영용). 섬 단계는 실제 섬을 짓고, 전투 단계는 호출부가 + /// 전투 화면을 직접 띄운 뒤 힌트만 맡는다. + /// + public void ShowForCapture(string stage, float seconds) + { + if (introVideo != null) introVideo.Stop(); // 앞 단계의 영상이 뒤에서 계속 돌지 않게 + DreamPrologueState.Begin(); + championName = "하늘"; + challengerName = "태고의 비천룡"; + versusLine = "vs 태고의 비천룡"; + pausedForCapture = false; + switch (stage) + { + case "opening": + SetPhase(Phase.Opening); + SetOpeningCard(false); + openClock = openCardStart + seconds; + fadeColor = Color.black; + fadeAlpha = 1f; + break; + case "opening-video": + // 진짜 영상을 실시간으로 튼다 — 시간을 세워 두지 않는다(촬영기가 시각을 골라 찍는다). + SetPhase(Phase.Opening); + fadeColor = Color.black; + fadeAlpha = 0f; + StartIntro(); + break; + case "battle": + SetPhase(Phase.Battle); + phaseTime = seconds; + battleTurns = 0; + break; + case "island-title": + EnterIslandForCapture(); + phaseTime = seconds; + fadeAlpha = 0f; + ShowCard("챔피언의 섬", "함께 싸운 곤충들이 모두 모여 있다", 60f); // 도착 카드를 그대로 세워 둔다 + break; + case "island-move": + case "island-approach": + case "island-fountain": + EnterIslandForCapture(); + phaseTime = seconds; + cardDuration = 0f; + step = stage == "island-move" ? IslandStep.Move + : stage == "island-approach" ? IslandStep.Approach : IslandStep.Fountain; + stepTime = 0f; + fadeAlpha = 0f; + break; + case "island-card": + EnterIslandForCapture(); + phaseTime = seconds; + fadeAlpha = 0f; + ShowCard("헤라클레스 천공각", "전설 · Lv.80 · 당신의 파트너", 60f); + break; + case "wake": + SetPhase(Phase.Wake); + captionIndex = 0; + captionTime = seconds; + phaseTime = seconds; + fadeColor = Color.white; + fadeAlpha = Mathf.Clamp01(1f - phaseTime / WakeFadeInSeconds); + break; + } + // 이 단계가 시간으로 넘어가지 않게 멈춰 둔다(영상 단계만 실시간으로 돈다). + pausedForCapture = stage != "opening-video"; + } + + // 섬은 한 번만 짓는다 — 단계마다 다시 지으면 곤충이 처음 자리로 돌아가고 촬영이 느려진다. + private void EnterIslandForCapture() + { + if (islandWorld != null && islandWorld.IsOnIsland) + { + SetPhase(Phase.Island); + return; + } + BeginIsland(); + } + + /// 도입 영상이 실제로 재생 중일 때의 영상 시각(초). 재생 중이 아니면 -1 — 촬영기가 영상 시각을 기다릴 때 쓴다. + public float IntroClockForCapture => + introVideo != null && introVideo.Current == DreamIntroVideo.State.Playing ? introVideo.Clock : -1f; + + /// 도입 영상이 실제로 끝나(또는 못 틀어) 글자 카드 단계로 넘어갔는가. + public bool IntroReachedCardForCapture => openVideoDone; + + /// 실제 전투 장면 검수용 — 도입을 건너뛰고 샌드박스 전투를 바로 연다. 이후 흐름(섬·깨어남)은 실제 그대로 돈다. + public bool StartBattleForCapture() + { + DreamPrologueState.Begin(); + championName = "하늘"; + challengerName = "태고의 비천룡"; + if (!TryStartBattle()) return false; + SetPhase(Phase.Battle); + return true; + } + + public void EndCapture() + { + pausedForCapture = false; + Finish(false); + } +#endif + } +} diff --git a/Assets/Scripts/Story/DreamPrologueDirector.cs.meta b/Assets/Scripts/Story/DreamPrologueDirector.cs.meta new file mode 100644 index 0000000..5e0f109 --- /dev/null +++ b/Assets/Scripts/Story/DreamPrologueDirector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4fb3852e1ce347b448bb941f2681b0fb \ No newline at end of file diff --git a/Assets/Scripts/Story/RivalRaceController.cs b/Assets/Scripts/Story/RivalRaceController.cs new file mode 100644 index 0000000..462c84e --- /dev/null +++ b/Assets/Scripts/Story/RivalRaceController.cs @@ -0,0 +1,226 @@ +using InsectGame.Core; +using InsectGame.UI; +using UnityEngine; + +namespace InsectGame.Story +{ + /// 라온과의 포획 내기 — 순수 규칙. 시간표와 보상을 여기 한곳에 둔다. + public static class RivalRaceRules + { + /// 먼저 이만큼 잡는 쪽이 이긴다. + public const int Target = 3; + + // 라온이 한 마리씩 잡는 시각 — 플레이어가 **필드에서 자유롭게 움직인 시간**(초)으로 잰다. + // 대사·메뉴·미니게임 중에는 흐르지 않으므로 창을 읽느라 지는 일은 없다. + // 곤충 한 마리에 15초쯤 쓰면 45초에 끝나 라온은 한 마리, 30초씩 쓰면 90초에 따라잡힌다. + private const float FirstCatch = 25f; + private const float SecondCatch = 55f; + private const float ThirdCatch = 90f; + + public const int WinCandy = 15; + public const string WinItemId = "net_silver"; + public const int WinItemCount = 2; + public const int LoseCandy = 5; + + /// 필드 시간이 이만큼 흘렀을 때 라온이 잡은 수(0~). + public static int RivalCountAt(float fieldSeconds) + { + if (fieldSeconds >= ThirdCatch) return 3; + if (fieldSeconds >= SecondCatch) return 2; + return fieldSeconds >= FirstCatch ? 1 : 0; + } + + /// 승부가 났는가. 같은 순간에 둘 다 채웠으면 플레이어가 이긴다(잡은 쪽이 직접 한 일이다). + public static bool IsDecided(int playerCount, int rivalCount, out bool playerWon) + { + playerWon = playerCount >= Target; + return playerWon || rivalCount >= Target; + } + } + + /// + /// 라온과의 포획 내기. 라온이 "좋아, 승부다! 누가 더 많은 친구를 구해내는지"라고 말하는 비트 + /// (ch1_rival_intro)가 끝나면 시작한다 — 예전엔 그 대사가 말뿐이었고 그 뒤는 혼자 세 마리를 채우는 + /// 숙제였다. 먼저 세 마리를 잡는 쪽이 이기고, 라온은 시간이 흐르면 한 마리씩 잡는다. + /// + /// 퀘스트가 아니다. 지든 이기든 본편 진행과 무관하고(스토리는 이 결과를 읽지 않는다) 한 계정에 한 번이다. + /// 화면(FieldMomentsUI)은 구독 없이 와 두 점수를 읽는다. + /// + public class RivalRaceController : MonoBehaviour + { + /// 내기를 여는 비트. 저작 데이터의 ID라 여기 하나에만 둔다. + public const string StartBeatId = "ch1_rival_intro"; + + private const string StateActive = "active"; + private const string StateDone = "done"; + + private StoryDirector storyDirector; + private PlayerInsectCollection insectCollection; + private PlayerCandyInventory candyInventory; + private PlayerItemInventory itemInventory; + private PlayerMovement playerMovement; + private FieldMomentFeed momentFeed; + + private bool active; + private float fieldSeconds; + // 불러온 계정 스코프 키 — 로그인·계정 전환으로 키가 바뀌면 다시 읽는다(StoryObjectiveTracker와 같은 방식). + private string prefsKey; + private string savedState = string.Empty; + + public bool IsActive => active; + public int PlayerCount { get; private set; } + public int RivalCount { get; private set; } + + public void AutoWire(StoryDirector director, PlayerInsectCollection collection, PlayerCandyInventory candy, + PlayerItemInventory items, PlayerMovement movement, FieldMomentFeed feed) + { + if (storyDirector == null) storyDirector = director; + if (insectCollection == null) insectCollection = collection; + if (candyInventory == null) candyInventory = candy; + if (itemInventory == null) itemInventory = items; + if (playerMovement == null) playerMovement = movement; + if (momentFeed == null) momentFeed = feed; + Subscribe(); + } + + private void Subscribe() + { + if (storyDirector != null) + { + storyDirector.StoryBeatCompleted -= OnStoryBeatCompleted; + storyDirector.StoryBeatCompleted += OnStoryBeatCompleted; + } + if (insectCollection != null) + { + insectCollection.InsectCaptured -= OnInsectCaptured; + insectCollection.InsectCaptured += OnInsectCaptured; + } + } + + private void OnDestroy() + { + if (storyDirector != null) storyDirector.StoryBeatCompleted -= OnStoryBeatCompleted; + if (insectCollection != null) insectCollection.InsectCaptured -= OnInsectCaptured; + } + + private void EnsureStateLoaded() + { + string key = AuthManager.ScopedKey(GameConstants.PrefsKeys.RivalRace); + if (key == prefsKey) return; + prefsKey = key; + savedState = PlayerPrefs.GetString(key, string.Empty); + // 계정이 바뀌었다 — 앞 계정의 내기를 들고 넘어가지 않는다. + active = false; + } + + private void SaveState(string state) + { + savedState = state; + if (string.IsNullOrEmpty(prefsKey)) return; + PlayerPrefs.SetString(prefsKey, state); + PlayerPrefs.Save(); + } + + private void OnStoryBeatCompleted(StoryBeat beat) + { + if (beat == null || beat.beatId != StartBeatId) return; + EnsureStateLoaded(); + if (active || savedState == StateDone) return; + Begin("라온과의 내기 시작!"); + } + + private void Begin(string title) + { + active = true; + PlayerCount = 0; + RivalCount = 0; + fieldSeconds = 0f; + SaveState(StateActive); + if (momentFeed != null) + momentFeed.Push(FieldMomentKind.Rival, title, $"먼저 곤충 {RivalRaceRules.Target}마리를 잡는 쪽이 이깁니다"); + } + + private void OnInsectCaptured(PlayerInsectData insect) + { + if (!active) return; + PlayerCount++; + Decide(); + } + + // 계정 키 확인 주기. 키 문자열은 호출마다 새로 만들어지므로(AuthManager.ScopedKey) 매 프레임 묻지 않는다 — + // 로그인·계정 전환을 1초 안에 알아채면 충분하다. + private const float StateCheckInterval = 1f; + private float stateCheckTimer; + + private void Update() + { + stateCheckTimer -= Time.deltaTime; + if (stateCheckTimer <= 0f) + { + stateCheckTimer = StateCheckInterval; + EnsureStateLoaded(); + } + + // 내기 도중에 앱을 닫았다 — 0:0에서 다시 붙는다(점수는 저장하지 않는다. 한 번뿐인 짧은 내기다). + if (!active && savedState == StateActive && CanResume()) + Begin("라온과의 내기가 이어집니다"); + if (!active) return; + + // 라온의 시계는 **플레이어가 필드에서 움직일 수 있을 때만** 간다. + if (ModalUIRegistry.IsAnyOpen() || (playerMovement != null && playerMovement.IsFrozen)) return; + + fieldSeconds += Time.deltaTime; + int rival = RivalRaceRules.RivalCountAt(fieldSeconds); + if (rival <= RivalCount) return; + + RivalCount = rival; + if (momentFeed != null && rival < RivalRaceRules.Target) + momentFeed.Push(FieldMomentKind.Rival, $"라온이 {rival}마리째를 잡았습니다", + $"나 {PlayerCount} : {rival} 라온"); + Decide(); + } + + // 이어 붙이는 것은 조작이 풀린 필드에서만 — 로그인 직후의 로비·대사 위에 소식을 얹지 않는다. + private bool CanResume() + { + return storyDirector != null && storyDirector.HasSeen(StartBeatId) + && !ModalUIRegistry.IsAnyOpen() + && playerMovement != null && !playerMovement.IsFrozen; + } + + private void Decide() + { + if (!RivalRaceRules.IsDecided(PlayerCount, RivalCount, out bool playerWon)) return; + + active = false; + // **기록을 먼저 닫고 지급한다** — 지급 뒤 저장 전에 죽으면 다음 실행에서 내기가 다시 열려 보상을 또 받는다. + SaveState(StateDone); + + int candy = playerWon ? RivalRaceRules.WinCandy : RivalRaceRules.LoseCandy; + if (candyInventory != null) candyInventory.AddCandy(candy); + else Debug.LogWarning($"[RivalRace] candyInventory null — 캔디 보상 손실 (+{candy})"); + + string rewardText = $"캔디 +{candy}"; + if (playerWon) + { + if (itemInventory != null) itemInventory.AddItem(RivalRaceRules.WinItemId, RivalRaceRules.WinItemCount); + else Debug.LogWarning($"[RivalRace] itemInventory null — 아이템 보상 손실 {RivalRaceRules.WinItemId}"); + string itemName = momentFeed != null ? momentFeed.ItemName(RivalRaceRules.WinItemId) : RivalRaceRules.WinItemId; + rewardText += $" · {itemName} ×{RivalRaceRules.WinItemCount}"; + } + + if (momentFeed != null) + { + if (playerWon) + momentFeed.Push(FieldMomentKind.Rival, "라온과의 내기에서 이겼습니다!", + $"라온: \"…졌다. 다음엔 안 봐줘!\" {rewardText}"); + else + momentFeed.Push(FieldMomentKind.Rival, "라온이 먼저 세 마리를 잡았습니다", + $"라온: \"내가 이겼지! 다음엔 더 서둘러.\" {rewardText}"); + } + + // 한 번뿐인 보상이라 자동저장(120초)을 기다리지 않는다(퀘스트 완료와 같은 취급). + if (CloudSaveManager.Instance != null) CloudSaveManager.Instance.SaveToCloud(); + } + } +} diff --git a/Assets/Scripts/Story/RivalRaceController.cs.meta b/Assets/Scripts/Story/RivalRaceController.cs.meta new file mode 100644 index 0000000..0afc6c4 --- /dev/null +++ b/Assets/Scripts/Story/RivalRaceController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 74e96cc3ca53a01489388aa617a7f0a9 \ No newline at end of file diff --git a/Assets/Scripts/Story/StoryBattleWait.cs b/Assets/Scripts/Story/StoryBattleWait.cs new file mode 100644 index 0000000..2e76e88 --- /dev/null +++ b/Assets/Scripts/Story/StoryBattleWait.cs @@ -0,0 +1,83 @@ +namespace InsectGame.Story +{ + /// + /// 전투 화면 뒤로 미룬 이야기(대사 트리거·컷신·영상)의 대기 시계 — 순수 규칙. · + /// ·가 함께 쓴다. + /// + /// 상한이 왜 있나. 세 곳 모두 "전투 화면이 아직 떠 있으면 미룬다"를 카메라의 배틀 모드(CameraFollower.InBattleMode)로 + /// 판정한다(BattleScreenUI는 모달이 아니다). 그 신호가 어떤 이유로든 굳으면(컷신 복원 누락·EndBattle 중단·씬 교체) + /// 미뤄 둔 것이 영원히 기다린다 — 상한은 그때를 위한 안전장치다. 컷신·영상은 연출이라 버리고(비트는 이미 완료됐다 — 억지로 틀면 + /// 카메라가 전투 구도에 갇힌다), 카메라가 배선되지 않은 대사 큐는 이야기의 진행이라 그냥 쏜다. + /// + /// 결과 화면 시간은 세지 않는다. 2026-10-04부터 결과 화면은 눌러야 닫힌다(BattleResultRules — 예전엔 1대1 4초·레이드 + /// 5초 뒤 저절로). 보상을 천천히 보는 아이에게 결과 화면 30초는 정상이고, 언제든 누르면 닫히는 화면이라 굳은 것도 아니다. + /// 그 시간을 셌다면 결과를 12초 넘게 본 사람의 미뤄 둔 컷신·영상이 조용히 사라지고, 카메라 없는 대사 큐는 결과 화면 위로 대사를 띄운다. + /// 결과 화면이 아닌데 전투 카메라가 안 풀리는 것(진짜로 멈춘 화면)은 그대로 센다. + /// + /// 모달(대화·메뉴)도 세지 않는다() — 역시 플레이어가 닫는 화면이다. 예전 영상 대기는 이 시간을 함께 세어, + /// 전투가 닫힌 뒤 이어진 대사를 12초 넘게 읽으면 영상이 사라졌다. + /// + /// 결과 화면 여부는 Story가 UI를 모르게 함수 하나(Func<bool>)로 받는다 — 부트스트랩이 BattleScreenUI·RaidBattleUI의 + /// ResultShownSeconds로 만들어 세 지휘자에게 AutoWire한다. 없으면(미배선·테스트) 예전처럼 결과 화면도 센다. + /// + public static class StoryBattleWait + { + /// 미뤄 둔 컷신·영상을 포기하는 대기(초) — 결과 화면·모달을 뺀, 전투 카메라가 안 풀린 시간. + public const float SceneGiveUpSeconds = 12f; + + /// + /// 카메라 미배선 대사 큐가 전투 화면 종료 통지를 기다리는 상한(초) — 결과 화면을 뺀 시간. 넘으면 겹치더라도 쏜다 + /// (진행을 잃는 것보다 낫다). 카메라가 배선돼 있으면 쓰이지 않는다 — 그 경로는 전투 화면 위로 대사를 쏘지 않는다. + /// + public const float DialogueFallbackSeconds = 12f; + + /// 대사 큐가 이만큼(결과 화면 제외) 기다린 뒤에야 흘렀으면 경고를 남긴다 — 진단용이다. 발화를 앞당기지 않는다. + public const float DialogueLongWaitWarnSeconds = 60f; + + /// 미뤄 둔 컷신·영상의 한 프레임 판정. + public enum SceneStep + { + /// 미뤄 둔 것이 없다. + None, + /// 아직 막혀 있다 — 계속 들고 있는다. + Wait, + /// 화면이 비었다 — 지금 튼다. + Play, + /// 전투 화면이 상한 넘게 안 풀렸다 — 버린다. + GiveUp + } + + /// + /// 대기 시계를 한 프레임 올린다. 결과 화면이 떠 있으면 그대로 둔다(멈춤). 음수·0 프레임도 그대로. + /// + public static float Advance(float waited, float deltaSeconds, bool resultScreenShowing) + { + if (resultScreenShowing || deltaSeconds <= 0f) return waited; + return waited + deltaSeconds; + } + + /// + /// 미뤄 둔 컷신·영상의 한 프레임. 시계()는 전투 카메라가 결과 화면 밖에서 안 풀린 시간만 센다. + /// + /// 지금까지 센 대기(초) — 갱신된다. + /// 이번 프레임의 실제 초(Time.unscaledDeltaTime). + /// 전투 카메라가 아직 배틀 모드다(결과 화면 포함). + /// 1대1·레이드 결과 화면이 떠 있다. + /// 다른 모달이 떠 있어 기다려야 한다(영상만 본다 — 컷신은 넘긴다 false). + public static SceneStep TickScene(ref float waited, float deltaSeconds, + bool battleOnScreen, bool resultScreenShowing, bool modalOpen) + { + if (!battleOnScreen && !modalOpen) return SceneStep.Play; + if (battleOnScreen) waited = Advance(waited, deltaSeconds, resultScreenShowing); + return waited >= SceneGiveUpSeconds ? SceneStep.GiveUp : SceneStep.Wait; + } + + /// 결과 화면 탐침을 읽는다 — 없으면(미배선) 거짓. 탐침이 던져도 거짓(대기 규칙이 전투 화면 쪽 예외로 죽지 않게). + public static bool ReadProbe(System.Func probe) + { + if (probe == null) return false; + try { return probe(); } + catch (System.Exception) { return false; } + } + } +} diff --git a/Assets/Scripts/Story/StoryBattleWait.cs.meta b/Assets/Scripts/Story/StoryBattleWait.cs.meta new file mode 100644 index 0000000..ad97feb --- /dev/null +++ b/Assets/Scripts/Story/StoryBattleWait.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7b08ffd02e53e9047a8ccaf6af2b7adb \ No newline at end of file diff --git a/Assets/Scripts/Story/StoryBeat.cs b/Assets/Scripts/Story/StoryBeat.cs index 7c20858..faaf131 100644 --- a/Assets/Scripts/Story/StoryBeat.cs +++ b/Assets/Scripts/Story/StoryBeat.cs @@ -55,20 +55,78 @@ public class StoryBeat // 실재성을 고정한다(오타는 런타임에 LogWarning만 찍고 조용히 안 나온다). // JsonUtility는 JSON에 없는 필드를 건드리지 않으므로 기존 비트 전부 null로 남아 호환된다. public string cutsceneId; + // 대사가 끝난 뒤 재생할 **영상 파일**(옵션). StoryVideoLibrary의 ID여야 한다 — story_lint 검사 25가 + // 실재성·switch 배선·파일 배치를 본다. **cutsceneId·stageExitId와 같은 비트에 두지 않는다**(검사 13) — + // 셋 다 StoryBeatCompleted를 구독해 조작·모달을 뺏으므로 서로의 복구를 덮어쓴다. + // 대사 **뒤**의 마무리다 — "그 대사가 끝난 다음 화면에 무엇이 남아야 하는가". 대사 **앞**은 introVideoId. + public string videoId; + // 대사 **앞**에 재생할 영상 파일(옵션) — 설명을 영상이 맡는다(초등 고학년 대상: "영상이 설명하고, 대사는 짧게"). + // StoryVideoLibrary의 ID여야 한다(검사 25). 순서는 **영상 → NPC 등장 연출(stageEnterId) → 「지난 이야기」 카드 → 대사**다 — + // NpcDialogueUI의 연출 게이트(IStoryStagePrelude) 하나를 StoryPreludeChain이 영상·연출 차례로 이어 준다. + // 영상은 어떤 경로로 끝나든(끝·건너뛰기·파일 없음·디코더 오류·시간 초과·비활성) 대사를 연다 — 못 열면 그 비트가 + // pendingBeatId에 갇혀 캠페인이 멈춘다(StoryVideoDirector.TryPlayPrelude). + // **videoId와 같은 비트에 두지 않는다**(검사 13 — 한 지휘자가 두 편을 한 비트에 틀 수 없다). 영상이 설명하므로 + // 이 비트의 대사는 **6줄 이하**다(검사 36). 저널 다시보기에서는 대사를 다시 읽어도 이 영상이 앞에 붙지 않는다 + // (「▶ 영상」 버튼이 따로 튼다). 비면 영상 없음 — 기존 비트는 전부 null로 호환된다. + public string introVideoId; // 대사 **앞**에 재생할 NPC 연출(옵션) — 등장·다가옴. StoryStageLibrary의 ID여야 한다. // 대사가 없는 비트에는 무의미하다(모달 자체가 안 뜨므로 게이트가 걸리지 않는다). public string stageEnterId; // 대사 **뒤**에 재생할 NPC 연출(옵션) — 퇴장·안내. // **cutsceneId와 같은 비트에 함께 두지 않는다** — 둘 다 조작·카메라를 뺏어 다툰다. - // story_lint 검사 13이 그 조합을 막는다. + // story_lint 검사 13이 그 조합을 막는다. **duelAfter와도 함께 두지 않는다**(검사 35 — 상대가 퇴장해 버린다). public string stageExitId; + // HUD 목표의 "왜"(옵션) — 이 비트가 다음 목표로 뽑혔을 때 할 일 아래에 붙는 짧은 이유 한 줄 + // ("모래언덕으로" ← "상자에 갇힌 곤충이 있대"). 목표 문구는 트리거가 만들고(StoryObjectiveResolver), + // 이유만 저작한다. 스파인 비트에 비어 있으면 story_lint가 잡는다. 기존 비트는 null — 이유 없이 할 일만 뜬다. + public string why; + // 대사가 끝나면 **곧바로** 이 상대와 대결한다(옵션) — 값은 대결 상대의 storyNpcId다. + // 명부회 간부(NpcBossDuels)면 간부전, 라온(NpcRivalDuels)이면 그 자리에서 열 수 있는 라이벌 단계다. + // 예전엔 대치 대사가 "다시 말을 걸면 붙는다"(talk_*)로 끝나 이야기 속 싸움이 한 박자 뒤로 밀려 있었다. + // + // **순서**: 대사 → 이 비트의 영상·컷신·NPC 연출 → (선택지가 있으면) 고른 결과 대사 → 미뤄 둔 다른 대사 → 대결. + // StoryDuelLauncher가 StoryBeatCompleted에서 대기열에 넣었다가 모달·전투 화면·스토리 큐가 모두 빈 첫 순간에 연다. + // 시작하지 못하면(재도전 대기·출전 곤충 없음) 조용히 버린다 — 그 상대에게 다시 말을 걸면 시작되는 길이 따로 있다 + // (간부: WorldInteractionController → TryStartBossDuel, 라온: 대화창 [승부하기]). + // + // **stageExitId와 함께 두지 않는다** — 퇴장 연출이 대결 상대를 걸어 나가게 한다. 대상이 대결 표에 있는지, 라온이면 + // 이 비트를 본 직후 그 리전에서 단계가 열리는지는 story_lint 검사 35가 본다(오타는 런타임에 로그 한 줄로 조용히 사라진다). + // 대화창은 이 값을 읽어 마지막 버튼을 「승부!」로 바꿀 수 있다. 비면 대결 없음 — 기존 비트는 전부 null로 호환된다. + public string duelAfter; + } + + /// + /// 장 하나의 「지난 이야기」 — 그 장을 여는 비트()의 대사 **앞에** 카드로 뜨고, + /// 저널의 장 탭 머리에도 보인다. 플레이어가 며칠 만에 돌아와도 "어디까지 왔고 왜 여기 왔나"를 세 줄로 되짚게 한다. + /// 장 ID가 아니라 여는 비트로 거는 이유: 장의 첫 발화가 늘 도착 비트는 아니다 — gd_ruins(7장)는 + /// 7장 개막보다 먼저 뜰 수 있어서, "처음 뜬 7장 비트"에 카드를 걸면 반전 전에 7장 요약이 나온다. + /// + [System.Serializable] + public class StoryChapter + { + public string chapterId; + /// "8장 · 모래언덕" — 저널 탭 라벨과 같은 형식. + public string title; + /// 이 비트의 대사 앞에 카드를 띄운다. 비면 카드 없음(저널에만 보인다). + public string openingBeatId; + /// 지난 이야기 — 세 줄 안팎, 한 줄 30자 안팎. 비면 카드 없음(1장처럼 지난 이야기가 없는 장). + public List recap = new List(); + /// 이번 장에서 할 일 한 줄. + public string goal; } [System.Serializable] public class StoryLine { + // 화자 표시명("라온"·"세라"·"검은 옷의 사내"…). NpcDialogueDatabase.StoryPortraitId가 초상으로 푼다. + // **"지문"은 인물이 아니라 해설이다** — 초상·이름표 없이 가운데 기울임으로 그린다 + // (StoryDialogueStaging.NarrationSpeaker). 오타는 조용히 초상만 사라지므로 story_lint 검사 27이 본다. public string speaker; public string text; + // 줄 연출(옵션) — 쉼표로 여럿: shake·flash·pause·shout·whisper·slow·dark. + // 해석은 StoryDialogueStaging.ParseFx 한 곳이고, 모르는 토큰은 story_lint 검사 28이 FAIL로 잡는다. + // JsonUtility는 JSON에 없는 필드를 건드리지 않으므로 기존 줄은 전부 null(연출 없음)로 호환된다. + public string fx; } [System.Serializable] @@ -102,10 +160,52 @@ public class StoryReward public string unlockQuestId; } - // JsonUtility 래퍼 — 루트 { "beats": [ ... ] } (InsectLoreList와 동형). + /// + /// 이야기 보상을 한 줄로 — "캔디 +5 · 은빛 채집망 ×2 · 하늘소 Lv.6". 순수 함수다. + /// 포함 조건은 StoryDirector.GrantReward의 지급 조건과 같아야 한다 — 어긋나면 "받았는데 안 뜨거나 + /// 뜨는데 안 준" 소식이 된다(퀘스트 쪽 QuestRewardFormatter가 같은 이유로 있다). + /// + public static class StoryRewardText + { + /// 아이템 ID → 표시명. null이거나 빈 값을 주면 ID를 그대로 쓴다. + /// + /// 실제로 준 곤충의 표시명. 첫 파트너는 플레이어가 고른 종으로 바뀌므로 보상 데이터의 이름 + /// ("첫 파트너")이 아니라 지급부가 넘겨준 이름을 쓴다. 곤충을 못 줬으면(컬렉션 없음) null. + /// + public static string Format(StoryReward reward, System.Func itemName, string grantedInsectName) + { + if (reward == null) return string.Empty; + var text = new System.Text.StringBuilder(); + + if (reward.rewardCandy > 0) Append(text, "캔디 +" + reward.rewardCandy); + if (reward.rewardExp > 0) Append(text, "경험치 +" + reward.rewardExp); + if (!string.IsNullOrEmpty(reward.rewardItemId) && reward.rewardItemCount > 0) + { + string name = itemName != null ? itemName(reward.rewardItemId) : null; + if (string.IsNullOrEmpty(name)) name = reward.rewardItemId; + Append(text, name + " ×" + reward.rewardItemCount); + } + if (!string.IsNullOrEmpty(reward.rewardInsectId) && !string.IsNullOrEmpty(grantedInsectName)) + { + int level = UnityEngine.Mathf.Max(1, reward.rewardInsectLevel); + Append(text, level > 1 ? grantedInsectName + " Lv." + level : grantedInsectName); + } + return text.ToString(); + } + + private static void Append(System.Text.StringBuilder text, string part) + { + if (text.Length > 0) text.Append(" · "); + text.Append(part); + } + } + + // JsonUtility 래퍼 — 루트 { "beats": [ ... ], "chapters": [ ... ] } (InsectLoreList와 동형). [System.Serializable] public class StoryList { public List beats = new List(); + // 장별 「지난 이야기」. 배열 순서가 곧 장 순서다. 옛 JSON엔 없어 빈 목록으로 남는다. + public List chapters = new List(); } } diff --git a/Assets/Scripts/Story/StoryDialogueCapture.cs b/Assets/Scripts/Story/StoryDialogueCapture.cs new file mode 100644 index 0000000..33d78da --- /dev/null +++ b/Assets/Scripts/Story/StoryDialogueCapture.cs @@ -0,0 +1,81 @@ +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System.Collections; +using System.IO; +using System.Reflection; +using InsectGame.Core; +using InsectGame.UI; +using UnityEngine; + +namespace InsectGame.Story +{ + /// Actual IMGUI dialogue preview, isolated from story progress and save services. + public static class StoryDialogueCapture + { + public static IEnumerator Run(string output, Camera camera) + { + var regions = RegionDefinitions.CreateAll(); + new GameObject("VillageQA").AddComponent().Build(regions); + Vector3 center = GameObject.Find("MainVillage").transform.position; + camera.transform.position = center + new Vector3(0, 14, 23); + camera.transform.LookAt(center + Vector3.up); + Light light = new GameObject("StoryQAKey").AddComponent(); + light.type = LightType.Directional; + light.intensity = 1.1f; + light.transform.rotation = Quaternion.Euler(48, -30, 0); + var ground = GameObject.CreatePrimitive(PrimitiveType.Plane); + ground.transform.position = center; + ground.transform.localScale = Vector3.one * 20; + ground.GetComponent().material.color = new Color(.30f, .40f, .29f); + NpcDialogueUI ui = new GameObject("DialogueQA").AddComponent(); + // No StoryDirector is created or injected: replay cannot grant rewards or write progress. + FieldInfo index = typeof(NpcDialogueUI).GetField("lineIndex", BindingFlags.Instance | BindingFlags.NonPublic); + // 타자 효과 — 줄을 다 드러낸 상태로 찍는다(반쯤 나온 글자는 레이아웃 검수에 방해만 된다). + FieldInfo revealAll = typeof(NpcDialogueUI).GetField("lineRevealAll", BindingFlags.Instance | BindingFlags.NonPublic); + int captures = 0; + using (var manifest = new StreamWriter(Path.Combine(output, "dialogue.tsv"))) + { + manifest.WriteLine("beat\tline\tspeaker\ttext"); + // 무대(좌우 인물·이름표·지문·번쩍임) 검수용 — 셋이 주고받는 장면, 지문과 연출이 많은 클라이맥스, + // 2막 대치(셋째 화자 교대), 2막 절정(외침·붕괴), 최종장(지문 목소리·마지막 줄 선택지). + foreach (string id in new[] { "ch4_reach_swamp", "ch6_secret", "ch8_confront", "ch10_confront", "fin_unnamed" }) + { + if (!StoryService.TryGetBeat(id, out StoryBeat beat)) + { Debug.LogError("Missing dialogue fixture " + id); Application.Quit(3); yield break; } + ui.ShowStoryReplay(beat); + for (int line = 0; line < beat.lines.Count; line++) + { + index.SetValue(ui, line); + if (revealAll != null) revealAll.SetValue(ui, true); + yield return new WaitForSecondsRealtime(.8f); + yield return new WaitForEndOfFrame(); + Texture2D shot = ScreenCapture.CaptureScreenshotAsTexture(); + File.WriteAllBytes(Path.Combine(output, id + "-" + line + ".png"), shot.EncodeToPNG()); + UnityEngine.Object.Destroy(shot); + manifest.WriteLine($"{id}\t{line}\t{beat.lines[line].speaker}\t{beat.lines[line].text}"); + captures++; + } + ui.CloseModal(); + } + } + var npc = new GameObject("AmbientQA").AddComponent(); + npc.Initialize(new NpcSpawnAnchor { regionId = "meadow" }, "qa_resident", "마을 주민", 8173); + var tracker = new GameObject("ObjectiveLayoutQA").AddComponent(); + tracker.enabled = false; + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + typeof(StoryObjectiveTracker).GetField("hasObjective", flags).SetValue(tracker, true); + typeof(StoryObjectiveTracker).GetField("label", flags).SetValue(tracker, "마을 어르신에게 말 걸기"); + ui.AutoWire(tracker); + ui.Show(npc); + yield return new WaitForSecondsRealtime(.8f); + yield return new WaitForEndOfFrame(); + Texture2D ambientShot = ScreenCapture.CaptureScreenshotAsTexture(); + File.WriteAllBytes(Path.Combine(output, "ambient-objective.png"), ambientShot.EncodeToPNG()); + UnityEngine.Object.Destroy(ambientShot); + ui.CloseModal(); + File.WriteAllText(Path.Combine(output, "README.txt"), + $"Actual standalone IMGUI. {captures} lines from real story data. Replay with no StoryDirector/save services. Ambient goal row uses an injected layout sample, not live objective selection. Village background is a staging fixture, not the authored chapter location. Does not validate campaign triggering or physical input."); + Application.Quit(captures > 0 ? 0 : 3); + } + } +} +#endif diff --git a/Assets/Scripts/Story/StoryDialogueCapture.cs.meta b/Assets/Scripts/Story/StoryDialogueCapture.cs.meta new file mode 100644 index 0000000..c68b857 --- /dev/null +++ b/Assets/Scripts/Story/StoryDialogueCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2eaa810a5b23d3f448594798cf67e2cc \ No newline at end of file diff --git a/Assets/Scripts/Story/StoryDirector.cs b/Assets/Scripts/Story/StoryDirector.cs index 949c942..fe806da 100644 --- a/Assets/Scripts/Story/StoryDirector.cs +++ b/Assets/Scripts/Story/StoryDirector.cs @@ -78,7 +78,15 @@ public event Action StoryBeatTriggered // 부류라 이 트리거를 쓰는 비트는 **leaf 전용**이다. 어떤 비트의 prerequisiteBeatId도 // 되어선 안 된다. 스파인에 걸면 그 순간 prereq가 미충족인 세이브는 캠페인이 영구 정지한다. internal const string TriggerGuardianDefeat = "GuardianDefeat"; - /// 명부회 간부 대결 승리. param = 간부의 storyNpcId. 소스는 NpcDuelController.BossDuelWon. + /// + /// 명부회 간부 대결 승리. param = 간부의 storyNpcId. 소스는 NpcDuelController.BossDuelWon. + /// + /// 재발화형이다 — 스파인에 걸어도 된다(2026-10-04부터 duel_grip_win·duel_scale_win·duel_chief_win이 + /// 다음 장 도착·최종장의 선행이다). 이긴 순간의 이벤트는 일생 1회지만, 가 + /// 격파 기록(NpcDuelController.IsBossDefeated — 저장·클라우드 동기)을 보고 시작·리전 이동·비트 완료·클라우드 + /// 재적재 때마다 다시 흘린다. 그래서 그 순간을 놓친 세이브(앱 종료·선행 미충족)도, 이 비트를 필수로 바꾸기 전에 이미 + /// 그 간부를 이긴 세이브도 다음 재확인에서 자동으로 통과한다. story_lint 검사 8이 이 재확인 줄을 소스에서 확인한다. + /// internal const string TriggerDuelWin = "DuelWin"; // 도감에 이름을 새긴 종 수가 임계에 닿으면 발화. param=정수 임계값. // LevelReach와 같은 누적형이라 **재발화 트리거다** — 임계를 넘긴 뒤 도감이 갱신될 때마다 @@ -104,6 +112,14 @@ public void AutoWire(RegionManager region, InsectBattleController battle, } // 보상 인벤토리 주입(캔디/아이템). 곤충/EXP는 위 AutoWire의 collection/progress 재사용. + // 이야기 보상을 받았다는 소식을 넣는 곳. 없으면 예전처럼 말없이 지급한다(지급 자체는 그대로다). + private FieldMomentFeed momentFeed; + + public void AutoWire(FieldMomentFeed feed) + { + if (momentFeed == null) momentFeed = feed; + } + public void AutoWire(PlayerCandyInventory candy, PlayerItemInventory items) { if (candyInventory == null) candyInventory = candy; @@ -176,6 +192,8 @@ private void Start() // Immediate 트리거는 이벤트가 없으므로 시작 시 1회 평가. RouteTrigger(TriggerImmediate, null); ResweepCompletedQuests(); + ResweepPersistentConditions(); + DrainPendingTriggers(); } /// @@ -192,12 +210,57 @@ private void ResweepCompletedQuests() // 스냅샷 — 발화 체인이 어떤 경로로든 CompleteQuest에 닿으면 열거 중 변경 예외가 된다. string[] completed = System.Linq.Enumerable.ToArray(questManager.CompletedQuestIds); for (int i = 0; i < completed.Length; i++) - RouteTrigger(TriggerQuestComplete, completed[i]); + DeferTrigger(TriggerQuestComplete, completed[i]); + } + + // 누적 조건은 선행 대화보다 먼저 달성할 수 있다. 다음 레벨업/포획/재대결을 + // 요구하지 않고 현재 저장 상태로 재평가한다. 이동·포획·승리 이벤트는 재현하지 않는다. + // + // **간부 대결 승리(DuelWin)의 자동 통과가 여기다.** 이긴 기록이 있는데 승리 비트를 아직 안 봤으면 다시 흘린다 — + // 간부전이 본편 필수가 되면서(duel_*_win이 다음 장의 선행) 이 줄이 캠페인 정지를 막는 유일한 회복 경로가 됐다. + // 지우면 story_lint 검사 8이 FAIL을 낸다(DuelWin 스파인의 재발화 근거가 사라진다). + private void ResweepPersistentConditions() + { + if (progressController != null && HasUnseenBeatOfType(TriggerLevelReach)) + DeferTrigger(TriggerLevelReach, progressController.Level.ToString()); + if (dexController != null && HasUnseenBeatOfType(TriggerDexProgress)) + DeferTrigger(TriggerDexProgress, dexController.CapturedSpeciesCount.ToString()); + + foreach (StoryBeat beat in StoryService.AllBeats()) + { + if (beat == null || beat.trigger == null || IsSeen(beat.beatId)) continue; + string type = beat.trigger.type; + string param = beat.trigger.param; + if ((type == TriggerDuelWin && HasDefeatedStoryNpc(param)) + || (type == TriggerRegionCleansed && IsRegionCleansed(param))) + DeferTrigger(type, param); + } } + public bool HasDefeatedStoryNpc(string npcId) => + !string.IsNullOrEmpty(npcId) && duelController != null && duelController.IsBossDefeated(npcId); + + /// + /// 이야기가 아직 할 말을 들고 있는가 — 대사가 떠 있거나(pendingBeatId), 렌더러를 기다리는 비트가 있거나, + /// 미뤄 둔 트리거가 큐에 남아 있다(선택지 결과·전투 뒤 대사·재확인). + /// + /// StoryDuelLauncher가 묻는다 — 모달 판정만으로는 영상이 끝난 프레임과 큐가 다음 대사를 여는 프레임 사이가 + /// 비어 보여서, 선택지 결과 대사(ch9_confront)보다 대결이 먼저 열린다. 큐는 막힌 게 없으면 몇 프레임 안에 빈다 + /// (1회성 트리거도 번 뒤 버려진다). + /// + public bool IsBusy => !string.IsNullOrEmpty(pendingBeatId) || deferredBeat != null || pendingTriggers.Count > 0; + + public bool IsRegionCleansed(string regionId) => + !string.IsNullOrEmpty(regionId) && blight != null && blight.IsCleansed(regionId); + private void OnDestroy() { UnsubscribeEvents(); + // 부트스트랩이 스포너의 정적 훅에 이 인스턴스를 걸었다 — 씬 재로드(로그아웃·계정 삭제)로 파기된 뒤에도 + // 붙잡고 있으면 옛 계정 진행으로 답한다. 내 것일 때만 푼다(새 씬이 먼저 새 인스턴스를 걸었을 수 있다). + System.Action> hook = InsectGame.Spawning.InsectSpawner.StoryCaptureTargetProvider; + if (hook != null && ReferenceEquals(hook.Target, this)) + InsectGame.Spawning.InsectSpawner.StoryCaptureTargetProvider = null; } // --- 이벤트 구독 (SubscribeEvents/UnsubscribeEvents 짝) --- @@ -275,6 +338,7 @@ private void OnRegionChanged(RegionData region) // 이미 열람한 비트는 어차피 다시 안 뜨므로 중복 발화 걱정은 없다. if (blight != null && blight.IsCleansed(region.regionId)) RouteTrigger(TriggerRegionCleansed, region.regionId); + ResweepPersistentConditions(); } /// @@ -297,26 +361,23 @@ private void OnSubAreaChanged(SubAreaData subArea) /// /// 이기자마자 대사를 띄우지 않는다. BattleEnded는 KO 순간에 울리는데 - /// 전투 결과 화면은 그로부터 4초를 더 떠 있다(연출 페이즈가 끼면 6초 가까이). 그 위로 + /// 전투 결과 화면은 그 뒤로 플레이어가 누를 때까지 떠 있다(2026-10-04 전엔 4초 뒤 저절로 닫혔다). 그 위로 /// 대화 모달이 열리면 획득 EXP·캔디가 적힌 보상 패널을 통째로 덮는다 — 무엇을 /// 얻었는지 못 본 채 대사를 읽게 된다. BattleWin 비트 12개 전부에 해당한다. /// - /// 그래서 결과 화면이 스스로 닫힐 때(BattleScreenUI.EndBattle)까지 미룬다. + /// 그래서 결과 화면이 닫힐 때(BattleScreenUI.EndBattle)까지 미룬다 — 얼마나 오래 보든. /// 같은 판단을 CutsceneDirector가 컷신에 대해 이미 하고 있다 — 거기서는 /// 카메라의 배틀 모드를 신호로 쓴다(BattleScreenUI는 IModalUI가 아니라 레지스트리로 /// 알 수 없다). 이쪽은 화면 쪽이 끝났다고 알려 주는 형태다: UI가 스토리를 아는 /// 방향은 허용되지만 그 반대는 의존 방향에 어긋난다. /// - /// - /// 이기자마자 대사를 띄우지 않는다. BattleEnded는 KO 순간에 울리는데 - /// 전투 결과 화면은 그로부터 4초를 더 떠 있다(연출 페이즈가 끼면 6초 가까이). 그 위로 - /// 대화 모달이 열리면 획득 EXP·캔디가 적힌 보상 패널을 통째로 덮는다. - /// private void OnBattleEnded(bool playerWon) { // **종 ID를 지금 읽어 큐에 싣는다.** 발화는 결과 화면이 닫힌 뒤로 미뤄지는데, 그때 // 컨트롤러를 다시 물으면 이미 다음 전투가 시작됐을 수 있다(필드에서 연달아 붙는다). // 무param 비트는 이 값을 보지 않으므로 기존 저작은 그대로다. + // 꿈속의 챔피언전은 이야기의 전투가 아니다 — BattleWin을 쏘면 1막의 "첫 전투" 비트가 꿈에서 열린다. + if (battleController != null && battleController.IsSandbox) return; if (playerWon) DeferTrigger(TriggerBattleWin, battleController != null ? battleController.EnemyInsectId : null); } @@ -408,16 +469,28 @@ private bool HasUnseenBeatFor(string type, string param) // 전투 화면이 닫혔다는 통지를 받았다 — 그 뒤로는 모달만 기다리면 되고 시간은 안 센다. private bool presentationClosed; + // 상한 둘은 StoryBattleWait에 있다(컷신·영상과 같은 대기 시계 규칙). + // - DialogueFallbackSeconds(12): 카메라가 **배선되지 않았을 때만** 쓴다. 미뤄 둔 발화를 포기하지 않고 **그냥 쏘는** + // 시각이다 — 컷신·영상은 연출이라 버려도 되지만 여기는 이야기의 진행이라 버리면 안 된다. 전투 화면이 닫혔다고 + // 알려 주지 않으면(미배선·예외로 EndBattle 중단·씬 교체) 보상 패널을 덮는 쪽이 진행이 멈추는 것보다 낫다. + // - DialogueLongWaitWarnSeconds(60): 진단 경고뿐이다. **카메라 경로는 전투 화면 위로 대사를 쏘지 않는다** — + // Update가 ShouldDeferNow에서 먼저 돌아가므로 이 값이 발화를 앞당긴 적은 없다(예전 주석은 "절대 상한"이라 적었다). + // 둘 다 결과 화면이 떠 있는 시간은 세지 않는다 — 결과 화면은 2026-10-04부터 눌러야 닫힌다(BattleResultRules). + + // 1대1·레이드 결과 화면이 떠 있는가 — 부트스트랩이 넘긴다(Story가 UI를 모르게 함수 하나). + private Func battleResultShowing; + /// - /// 미뤄 둔 발화를 포기하지 않고 그냥 쏘는 시각(초). 컷신의 - /// PendingGiveUpSeconds와 같은 값이지만 처리가 반대다 — 저쪽은 연출이라 버려도 - /// 되지만 여기는 이야기의 진행이라 버리면 안 된다. 전투 화면이 어떤 이유로든 - /// 닫혔다고 알려 주지 않으면(미배선·예외로 EndBattle 중단·씬 교체) 보상 패널을 덮는 - /// 쪽이 진행이 멈추는 것보다 훨씬 낫다. + /// 결과 화면 탐침 — 가 전투 화면으로 치고, 대기 시계는 그동안 멈춘다(). + /// 카메라가 배선돼 있으면 판정은 바뀌지 않는다(결과 화면 동안 카메라도 배틀 모드다). 카메라가 없을 때 12초 폴백이 + /// 결과 화면 위로 대사를 띄우던 길을 막는다 — 결과 화면은 이제 눌러야 닫혀서 아이가 30초씩 본다. /// - private const float PendingGiveUpSeconds = 12f; - /// 카메라가 배선돼 있어도 넘기지 않는 절대 상한(초). - private const float PendingAbsoluteGiveUpSeconds = 60f; + public void AutoWire(Func resultShowing) + { + if (battleResultShowing == null) battleResultShowing = resultShowing; + } + + private bool BattleResultShowing => StoryBattleWait.ReadProbe(battleResultShowing); /// /// 지금 대사를 띄워도 되는가. 안 되면 큐가 들고 있다가 전투·대화가 끝난 뒤 흘린다. @@ -440,6 +513,7 @@ private bool HasUnseenBeatFor(string type, string param) private bool ShouldDeferNow() { if (InsectGame.UI.ModalUIRegistry.IsAnyOpen()) return true; // 대화·컷신·상점·도감 + if (BattleResultShowing) return true; // 결과 화면(카메라 미배선에도 안다) return cameraFollower != null && cameraFollower.InBattleMode; // 전투 화면(결과 포함) } @@ -452,7 +526,7 @@ private bool ShouldDeferNow() /// private bool RouteTrigger(string type, string param) { - if (ShouldDeferNow()) + if (!string.IsNullOrEmpty(pendingBeatId) || ShouldDeferNow()) { DeferTrigger(type, param); return false; @@ -498,9 +572,18 @@ private bool BeginDefer(string type, string param) public void NotifyBattlePresentationClosed() { presentationClosed = true; + BattlePresentationClosed?.Invoke(); DrainPendingTriggers(); } + /// + /// 전투 화면이 닫혔다 — 미뤄 둔 대사를 흘리기 직전에 울린다. 수문장 배지 연출(BadgeCeremonyUI)이 + /// 여기서 모달을 먼저 열어 gd_* 대사보다 배지가 먼저 보이게 한다. 드레인은 모달 가드에 막혔다가 + /// 연출이 닫히면 가 이어 흘린다. 폴링으로는 이 순서를 못 만든다 — 드레인이 같은 호출 + /// 안에서 동기로 대사를 열어 버린다. + /// + public event System.Action BattlePresentationClosed; + /// /// 미뤄 둔 트리거를 하나씩 흘린다. 한 편이 화면에 뜨면 거기서 멈추고, 그 비트가 /// 완료될 때() 다음이 이어진다. @@ -549,9 +632,18 @@ private void DrainPendingTriggers() private void Update() { if (pendingTriggers.Count == 0) return; - // timeScale에 끌려다니면 안 된다 — 히트스톱·슬로모션이 결과 화면 직전까지 걸린다. - pendingSeconds += Time.unscaledDeltaTime; + TickPendingTriggers(Time.unscaledDeltaTime); + } + + /// 미뤄 둔 트리거의 한 프레임(가 부른다 — 테스트는 시간을 넣어 직접 부른다). + private void TickPendingTriggers(float deltaSeconds) + { + if (pendingTriggers.Count == 0) return; + + // **결과 화면이 떠 있는 동안은 시계가 멈춘다**(StoryBattleWait). 결과 화면은 눌러야 닫히므로 보상을 천천히 보는 + // 시간이 "통지가 안 온다"로 세어지면 안 된다 — 카메라가 없을 때 12초 폴백이 결과 화면 위로 대사를 띄웠다. + pendingSeconds = StoryBattleWait.Advance(pendingSeconds, deltaSeconds, BattleResultShowing); // 대사·컷신·전투 화면이 떠 있는 동안은 시간이 지나도 밀어 넣지 않는다(위 주석의 그 손실). // 12초 포기 타이머가 있어도 여기서 막히므로, 대화가 길어도 대사가 겹치지 않는다. @@ -563,18 +655,19 @@ private void Update() // 아래 12초 타이머는 원래 "전투 화면이 떠 있는지 알 방법이 없어서" 둔 안전망인데, // 그대로 두면 **대화 때문에 미뤄 둔 트리거가 대화를 닫고도 12초를 더 기다린다.** // - // 카메라가 없을 때(미배선)만 옛 경로로 떨어진다 — 통지 아니면 12초. + // 카메라가 없을 때(미배선)만 옛 경로로 떨어진다 — 통지 아니면 12초(결과 화면 시간 제외). if (cameraFollower == null && !presentationClosed) { - if (pendingSeconds < PendingGiveUpSeconds) return; + if (pendingSeconds < StoryBattleWait.DialogueFallbackSeconds) return; // 그게 끝내 안 오면 겹치더라도 쏜다 — 진행을 잃는 것보다 낫다. Debug.LogWarning("[Story] 전투 화면 종료 통지가 없어 미뤄 둔 트리거를 그대로 발화한다"); } - else if (cameraFollower != null && pendingSeconds >= PendingAbsoluteGiveUpSeconds) + else if (cameraFollower != null && pendingSeconds >= StoryBattleWait.DialogueLongWaitWarnSeconds) { - // 카메라 경로에는 상한이 없었다 — InBattleMode가 어떤 이유로든 true로 굳으면(컷신 복원 - // 누락 등) 큐가 무기한 멈춘다. 훨씬 긴 절대 상한 하나를 남긴다. - Debug.LogWarning($"[Story] {PendingAbsoluteGiveUpSeconds:0}초 넘게 화면이 안 닫혀 미뤄 둔 트리거를 그대로 발화한다"); + // 진단만 한다 — 여기 왔다는 건 화면이 이미 비었다는 뜻이고(ShouldDeferNow가 거짓), 어차피 지금 흘린다. + // 카메라 경로에는 발화를 앞당기는 상한이 없다: InBattleMode가 굳으면(컷신 복원 누락 등) 큐는 그 동안 기다린다. + // 결과 화면 밖에서 그렇게 오래 기다렸다면 카메라나 모달이 굳었던 것이다 — 로그로 남긴다. + Debug.LogWarning($"[Story] 미뤄 둔 트리거가 결과 화면 밖에서 {StoryBattleWait.DialogueLongWaitWarnSeconds:0}초 넘게 기다린 뒤 흐른다 — 전투 카메라·모달이 오래 안 풀렸다"); } DrainPendingTriggers(); @@ -589,6 +682,10 @@ private void OnDexUpdated(InsectGame.Dex.DexSaveData _) private void OnQuestCompleted(TutorialQuest quest) { + // **목표 캐시를 버린다.** requiredQuestId가 걸린 비트(ch1_intro·마을 이야기의 매듭)는 + // 퀘스트가 끝나는 순간 자격을 얻는데, 캐시는 MarkSeen·클라우드 재적재에서만 비워져서 + // 다음 비트를 볼 때까지 HUD가 낡은 목표에 굳어 있었다. + objectiveDirty = true; if (quest != null) RouteTrigger(TriggerQuestComplete, quest.questId); } @@ -612,9 +709,16 @@ private void OnInsectCaptured(PlayerInsectData insect) // **여기서 바로 쏘지 않는다.** 전투·레이드 안에서 잡은 것이면 곧 결과 화면이 뜨는데, // 그걸 알려 주는 신호(BattleEnded/RaidEnded)는 **같은 프레임 뒤에** 온다. // 지금 쏘면 대사창이 획득 EXP·캔디 패널 위로 열린다 — `BattleWin`을 미루는 그 이유다. + if (grantingStarter) return; // 첫 파트너는 받은 것이지 잡은 것이 아니다 — 아래 GrantReward 참조 frameCaptures.Add(insect != null ? insect.insectId : null); } + // 첫 파트너를 건네는 동안만 선다. 그 지급이 `CaptureInsect`를 울리면 어르신의 "첫 포획 축하" + // (`ch1_first_capture` — "훌륭해! 이 초원에서 곤충을 거둬 왔구나")가 **아무것도 잡기 전에** 뜬다. + // 정작 처음 잡았을 때는 그 대사가 이미 지나가 라온이 대신 나온다(2026-10-02 걸음 보고서에서 확인). + // 다른 스토리 보상 곤충은 그대로 `CaptureInsect`를 울린다 — 그쪽은 그 발화에 기대는 비트가 있다(`ch12_sign`). + private bool grantingStarter; + /// /// 포획을 프레임 끝에 판정한다. 여기까지 오면 같은 프레임의 전투 종료가 /// 이미 큐에 들어와 있으므로, "전투 안에서 잡았는가"를 상태를 묻지 않고 알 수 있다. @@ -677,6 +781,7 @@ public bool OnNpcTalked(string npcId) private bool EvaluateTriggers(string triggerType, string eventParam) { if (string.IsNullOrEmpty(triggerType)) return false; + if (!string.IsNullOrEmpty(pendingBeatId)) return false; StoryBeat chosen = null; @@ -819,8 +924,8 @@ public void QueueChoice(string nextBeatId) private bool RegionGateSatisfied(StoryBeat beat) { if (beat == null || string.IsNullOrEmpty(beat.requiredRegionId)) return true; - string current = regionManager != null && regionManager.CurrentRegion != null - ? regionManager.CurrentRegion.regionId : null; + // ActionRegionId — 나의 섬에서는 null이다(섬 손님 곤충을 잡아도 떠나기 전 리전의 비트가 발화하지 않게). + string current = regionManager != null ? regionManager.ActionRegionId : null; return beat.requiredRegionId == current; } @@ -923,6 +1028,9 @@ public void CompleteBeat(string beatId) StoryBeatCompleted?.Invoke(beat); // 모달 닫힘 → 조우 카메라 포커스 조기 릴리즈 + ResweepCompletedQuests(); + ResweepPersistentConditions(); + // 한 편이 끝났으니 미뤄 둔 다음 편을 이어 붙인다. 컷신이 방금 시작됐다면 // (StoryBeatCompleted 구독자) 레지스트리 가드에 걸려 여기서는 넘어가고, // 컷신이 끝난 뒤 Update가 집는다. @@ -969,8 +1077,13 @@ private void RecomputeObjective() { // 퀘스트 게이트를 함께 넘긴다 — 안 넘기면 튜토리얼 중에 "마을 어르신에게 말 걸기"를 // 안내해 놓고 정작 가서 말을 걸면 아무 일도 안 일어난다. + // + // 스파인은 **아직 뒤가 남은 것만** 앞세운다(CollectLiveSpineBeatIds). 선행을 나중에 끼워 넣은 자리 — + // 간부 승리(duel_*_win)를 다음 장의 선행으로 바꾼 것 — 에서, 그 장을 이미 지나온 세이브는 뒤 비트를 다 봤는데도 + // 끼워 넣은 스파인이 목표 1순위로 떠 HUD가 지난 장으로 되돌려 보냈다. 그런 비트는 leaf처럼 뒤로 민다. StoryBeat beat = StoryObjectiveResolver.SelectObjectiveBeat( - StoryService.AllBeats(), IsSeen, SpineBeatIds(), + StoryService.AllBeats(), IsSeen, + StoryObjectiveResolver.CollectLiveSpineBeatIds(StoryService.AllBeats(), IsSeen), questManager != null ? questManager.IsQuestCompleted : (System.Func)null, ChoiceTargetIds()); @@ -1004,6 +1117,29 @@ public bool HasSeen(string beatId) return IsSeen(beatId); } + /// + /// 이 리전에서 지금 발화를 기다리는 종 지정 포획 비트(CaptureInsect + param)의 종 ID를 모은다 + /// (를 비우지 않는다). 필드 스포너의 스토리 포획 보조가 부트스트랩 배선으로 부른다 — + /// 리전 이동으로 곤충을 다시 굴릴 수 없게 된 뒤(시간 기반 재생) 특정 영웅·희귀 한 종을 찾다 본편이 멈추지 않게. + /// + /// 게이트는 발화()와 같은 것을 그대로 부른다 — 미열람·대기 중 아님·선행· + /// 퀘스트·진행 게이트. 리전 게이트만 "지금 리전"이 아니라 묻는 리전으로 바꿔 본다(스포너는 플레이어가 없는 + /// 리전의 자리도 굴린다). 읽기 전용이라 진행은 건드리지 않는다. + /// + public void CollectPendingCaptureSpecies(string regionId, List into) + { + if (into == null || string.IsNullOrEmpty(regionId)) return; + foreach (StoryBeat beat in StoryService.AllBeats()) + { + if (beat == null || beat.trigger == null || beat.trigger.type != TriggerCaptureInsect) continue; + if (string.IsNullOrEmpty(beat.trigger.param)) continue; // 아무 포획이나 — 도울 종이 없다 + if (beat.requiredRegionId != regionId) continue; + if (IsSeen(beat.beatId) || beat.beatId == pendingBeatId) continue; + if (!PrerequisiteSatisfied(beat) || !QuestGateSatisfied(beat) || !BeatGateSatisfied(beat)) continue; + if (!into.Contains(beat.trigger.param)) into.Add(beat.trigger.param); + } + } + /// 열람한 비트 수 — 저널 헤더의 진행률 표시용. public int SeenCount => progress != null && progress.seenBeatIds != null ? progress.seenBeatIds.Count : 0; @@ -1026,6 +1162,61 @@ public bool HasMetStoryNpc(string npcId) return StoryObjectiveResolver.HasMetNpc(StoryService.AllBeats(), IsSeen, npcId); } + // ------------------------------------------------------------------ + // 마을 이야기 — 따라가기·표식(StoryObjectiveTracker)이 소비. 판정은 StoryTaleResolver. + // ------------------------------------------------------------------ + + // Story.json에서만 나오므로 1회 계산(스파인 캐시와 같은 성격). + private List taleNpcIdsCache; + // 의뢰 → 주민. 퀘스트 목록이 행을 펼쳐 두면 OnGUI 패스마다 묻는다 — 비트 전체를 매번 훑지 않게 + // 답(없음 포함)을 기억한다. 비트는 진행과 무관하게 고정이라 무효화가 필요 없다. + private Dictionary taleNpcByQuestCache; + // 트래커가 0.5초마다 주민 수만큼 묻는다 — 호출마다 대리자를 새로 만들지 않게 묶어 둔다. + private System.Func isSeenProbe; + private System.Func isQuestDoneProbe; + + /// 마을 이야기 주민(storyNpcId) 목록. 저작 순서대로. + public IReadOnlyList TaleNpcIds + { + get + { + if (taleNpcIdsCache == null) + taleNpcIdsCache = StoryTaleResolver.CollectTaleNpcIds(StoryService.AllBeats()); + return taleNpcIdsCache; + } + } + + /// + /// 이 주민의 이야기에서 지금 할 일. Errand면 에 기다리는 의뢰가 온다. + /// 게이트는 발화 쪽과 같다 — 퀘스트 판정기가 없으면 처럼 통과시킨다. + /// + public TaleStepKind GetTaleStep(string npcId, out string questId) + { + if (isSeenProbe == null) isSeenProbe = IsSeen; + if (isQuestDoneProbe == null) isQuestDoneProbe = IsQuestDoneForGate; + return StoryTaleResolver.ResolveStep(StoryService.AllBeats(), npcId, + isSeenProbe, isQuestDoneProbe, out _, out questId); + } + + /// 이 의뢰를 기다리는 주민(storyNpcId). 마을 이야기가 아닌 퀘스트면 null. + public string FindTaleNpcForQuest(string questId) + { + if (string.IsNullOrEmpty(questId)) return null; + if (taleNpcByQuestCache == null) taleNpcByQuestCache = new Dictionary(); + if (!taleNpcByQuestCache.TryGetValue(questId, out string npcId)) + { + npcId = StoryTaleResolver.FindNpcForQuest(StoryService.AllBeats(), questId); + taleNpcByQuestCache[questId] = npcId; + } + return npcId; + } + + // QuestGateSatisfied와 같은 규칙 — 매니저가 없으면 막지 않는다. + private bool IsQuestDoneForGate(string questId) + { + return questManager == null || questManager.IsQuestCompleted(questId); + } + // 보상 지급 — TutorialQuestManager.CompleteQuest 패턴 동일(null 시 경고 후 계속). /// /// 어느 비트의 보상인가. 첫 파트너 곤충만 플레이어 선택으로 바꾸므로 그 판정에 쓴다 — @@ -1034,6 +1225,8 @@ public bool HasMetStoryNpc(string npcId) private void GrantReward(StoryReward reward, string beatId) { if (reward == null) return; + // 실제로 건넨 곤충의 이름 — 첫 파트너는 고른 종으로 바뀌므로 보상 데이터의 이름과 다르다. + string grantedInsectName = null; if (reward.rewardCandy > 0) { @@ -1072,7 +1265,11 @@ private void GrantReward(StoryReward reward, string beatId) Debug.LogWarning($"[Story] 첫 파트너 '{insectId}'가 DB에 없어 비트 보상 '{reward.rewardInsectId}'로 대체한다"); insectId = reward.rewardInsectId; } - insectCollection.AddCapturedInsect(insectId, Mathf.Max(1, reward.rewardInsectLevel)); + grantingStarter = beatId == StarterInsectCatalog.StarterBeatId; + try { insectCollection.AddCapturedInsect(insectId, Mathf.Max(1, reward.rewardInsectLevel)); } + finally { grantingStarter = false; } + InsectData granted = insectCollection.GetInsectData(insectId); + grantedInsectName = granted != null ? granted.displayName : insectId; // 도감 등록은 지급과 한 쌍이다(`TutorialQuestManager`와 같은 형태). 빠뜨리면 // 준 곤충이 도감에 없어 100% 완주가 막히는데, **여기선 자기 발등도 찍는다** — @@ -1089,6 +1286,13 @@ private void GrantReward(StoryReward reward, string beatId) } // unlockQuestId: 스토리→퀘스트 역주입은 설계상 배제(단방향 관찰). 여기서 처리하지 않는다. + + // 받은 것을 알린다 — 예전엔 대사가 닫히면 캔디·그물·파트너가 말없이 들어와 있었다. + if (momentFeed != null) + { + string text = StoryRewardText.Format(reward, momentFeed.ItemName, grantedInsectName); + if (!string.IsNullOrEmpty(text)) momentFeed.Push(FieldMomentKind.Reward, "보상을 받았습니다", text); + } } // --- 저장/로드 (SaveScope 계정별 격리, DexSaveService 패턴) --- @@ -1135,6 +1339,7 @@ public void ReloadFromDisk() // 계정 전환·클라우드 로드로 완료 퀘스트 집합이 채워지는 시점이 여기다 — 리전을 옮기기 전까지 // 재스윕이 없으면 완료 퀘스트 비트가 이번 세션 내내 안 뜬다. ResweepCompletedQuests(); + ResweepPersistentConditions(); } } } diff --git a/Assets/Scripts/Story/StoryDuelLauncher.cs b/Assets/Scripts/Story/StoryDuelLauncher.cs new file mode 100644 index 0000000..48ee00c --- /dev/null +++ b/Assets/Scripts/Story/StoryDuelLauncher.cs @@ -0,0 +1,198 @@ +using InsectGame.Core; +using InsectGame.NPC; +using InsectGame.UI; +using UnityEngine; + +namespace InsectGame.Story +{ + /// + /// 대사 직후 대결 — 비트의 를 읽어, 그 장면이 다 끝난 첫 순간에 대결을 연다. + /// + /// 예전엔 대치 대사가 "한 번 더 말을 걸면 붙는다"(talk_grip)로 끝나서, 이야기 속 싸움이 플레이어가 다시 + /// 말을 걸 때까지 한 박자 밀렸다(아이들은 그 한 박자를 모른다 — 대사만 보고 지나갔다). 2026-10-04 사용자 결정: + /// 명부회 간부전은 본편 필수이고 대치 대사가 끝나면 곧바로 붙는다. 라온의 초원 첫 만남도 "승부다!" 뒤 곧바로다. + /// + /// 기다리는 것(): 같은 비트의 영상·컷신·NPC 연출, 선택지 결과 대사, 그 사이 미뤄 둔 + /// 다른 대사, 다른 전투(결과 화면 포함), 조작 잠금. 모달만 보면 영상이 끝난 프레임과 큐가 선택 결과를 여는 프레임 + /// 사이가 비어 보여 결과 대사보다 대결이 먼저 열린다(ch9_confront) — 그래서 스토리 큐()도 본다. + /// + /// 못 열면 버린다 — 재도전 대기·출전 곤충 없음·자리를 떠남·너무 오래 막힘. 조용히 실패하되 로그를 남긴다. + /// 그 상대에게 다시 말을 걸면 열리는 길이 따로 있다(간부: WorldInteractionController → TryStartBossDuel, + /// 라온: 대화창 [승부하기]). 대기열은 한 칸이다 — 대사가 이어서 둘 끝나는 일은 없고, 있으면 나중 것이 맞다. + /// + /// 꿈 안에서는 아무것도 안 한다(DreamPrologueState.Active, rules/dream-prologue.md). 샌드박스 전투는 꿈에서만 열리므로 + /// 같은 표지로 걸러진다 — InsectBattleController.IsSandbox는 다음 전투가 시작될 때까지 값이 남아 "지금 샌드박스인가"를 못 답한다. + /// + /// 싱글턴 아님 — 부트스트랩이 AutoWire로 잇는다. 이벤트 구독은 AutoWire·OnEnable이 함께 건다(UI 루트 토글로 꺼졌다 켜져도 산다). + /// + public class StoryDuelLauncher : MonoBehaviour + { + /// 막힌 채 이만큼(실제 시간) 지나면 버린다 — 영상(최장 15초)·대사·결과 화면을 다 기다리고도 남는 값. + public const float GiveUpSeconds = 120f; + + private StoryDirector storyDirector; + private NpcDuelController duelController; + private RegionManager regionManager; + private CameraFollower cameraFollower; + private PlayerMovement playerMovement; + + private string pendingNpcId = string.Empty; + private string pendingBeatId = string.Empty; + // 대기열에 넣을 때 서 있던 리전(서브에리어면 그 리전, 나의 섬이면 null) — 자리를 떠나면 그 장면은 끝난 것이다. + private string pendingRegionId; + private float pendingSeconds; + + /// 지금 열리기를 기다리는 대결이 있는가. + public bool HasPendingDuel => !string.IsNullOrEmpty(pendingNpcId); + + /// + /// 기다리는 대결의 상대(storyNpcId). 없으면 빈 문자열. 대화창이 선택지 결과 대사의 마지막 버튼을 「승부!」로 바꿀 때 + /// 읽는다 — 결과 비트 자신은 가 비어 있고(선택지를 단 비트가 들고 있다), 그 비트가 + /// 끝난 순간 이미 여기 대기열에 들어와 있다. + /// + public string PendingNpcId => pendingNpcId; + + /// 마지막으로 대기열에 넣은 상대 — 검수 도구(StoryBeatWalkthrough)가 "대사 직후 대결이 걸렸나"를 본다. + public string LastQueuedNpcId { get; private set; } = string.Empty; + + /// + /// 검수 도구(배치 걸음)가 켠다 — 켜진 동안 대기열에 넣기만 하고 실제 전투는 열지 않는다. 걸음은 전투를 시뮬레이션하므로 + /// 진짜 전투가 열리면 전투 화면이 걸음의 다음 행위를 막는다. 게임 코드는 이 값을 건드리지 않는다. + /// + public bool Suspended { get; set; } + + public void AutoWire(StoryDirector director, NpcDuelController duel, RegionManager region, + CameraFollower follower, PlayerMovement movement) + { + if (storyDirector == null) storyDirector = director; + if (duelController == null) duelController = duel; + if (regionManager == null) regionManager = region; + if (cameraFollower == null) cameraFollower = follower; + if (playerMovement == null) playerMovement = movement; + Subscribe(); + } + + // AutoWire와 OnEnable이 함께 부른다 — `-=` 뒤 `+=`라 중복 구독이 되지 않는다. + private void Subscribe() + { + if (storyDirector == null) return; + storyDirector.StoryBeatCompleted -= OnBeatCompleted; + storyDirector.StoryBeatCompleted += OnBeatCompleted; + } + + private void OnEnable() => Subscribe(); + + private void OnDisable() + { + if (storyDirector != null) storyDirector.StoryBeatCompleted -= OnBeatCompleted; + } + + private void OnDestroy() + { + if (storyDirector != null) storyDirector.StoryBeatCompleted -= OnBeatCompleted; + } + + private void OnBeatCompleted(StoryBeat beat) + { + if (beat == null || string.IsNullOrWhiteSpace(beat.duelAfter)) return; + if (DreamPrologueState.Active) return; // 꿈은 이야기의 대결을 열지 않는다 + + string npcId = beat.duelAfter.Trim(); + if (KindOf(npcId) == DuelKind.None) + { + // story_lint 검사 35가 막는 오타 — 런타임에도 한 번 말한다(대결이 그냥 안 열리면 화면상 티가 안 난다). + Debug.LogWarning($"[StoryDuel] {beat.beatId}: duelAfter '{npcId}'는 대결 표에 없는 상대다"); + return; + } + if (HasPendingDuel && pendingNpcId != npcId) + Debug.Log($"[StoryDuel] {pendingBeatId} 뒤 {pendingNpcId} 대결을 {beat.beatId} 뒤 {npcId} 대결로 바꾼다"); + + pendingNpcId = npcId; + pendingBeatId = beat.beatId; + pendingRegionId = regionManager != null ? regionManager.ActionRegionId : null; + pendingSeconds = 0f; + LastQueuedNpcId = npcId; + } + + private void Update() + { + if (!HasPendingDuel) return; + if (DreamPrologueState.Active) { Drop("꿈이 시작됐다"); return; } + if (Suspended) return; + + pendingSeconds += Time.unscaledDeltaTime; + + string here = regionManager != null ? regionManager.ActionRegionId : null; + if (regionManager != null && here != pendingRegionId) { Drop("그 자리를 떠났다"); return; } + + if (ShouldWait(ModalUIRegistry.IsAnyOpen(), + cameraFollower != null && cameraFollower.InBattleMode, + storyDirector != null && storyDirector.IsBusy, + playerMovement != null && playerMovement.IsFrozen)) + { + if (pendingSeconds >= GiveUpSeconds) Drop($"{GiveUpSeconds:0}초 넘게 화면이 비지 않았다"); + return; + } + + Launch(); + } + + private void Launch() + { + string npcId = pendingNpcId; + string beatId = pendingBeatId; + Clear(); + + bool started = false; + if (duelController != null) + { + switch (KindOf(npcId)) + { + case DuelKind.Boss: started = duelController.TryStartBossDuel(npcId, Time.time); break; + case DuelKind.Rival: started = duelController.TryStartRivalDuel(npcId, Time.time); break; + } + } + if (!started) + Debug.Log($"[StoryDuel] {beatId} 뒤 {npcId} 대결을 열지 못했다(재도전 대기·출전 곤충 없음 등) — 다시 말을 걸면 열린다"); + } + + private void Drop(string reason) + { + Debug.Log($"[StoryDuel] {pendingBeatId} 뒤 {pendingNpcId} 대결을 버린다 — {reason}"); + Clear(); + } + + private void Clear() + { + pendingNpcId = string.Empty; + pendingBeatId = string.Empty; + pendingRegionId = null; + pendingSeconds = 0f; + } + + // ── 순수 판정 (테스트가 씬 없이 본다) ── + + public enum DuelKind { None, Boss, Rival } + + /// + /// 이 상대는 어느 대결인가 — 명부회 간부 표()가 먼저다. 한 인물이 두 표에 함께 있을 일은 없다 + /// (NpcRivalDuelTests가 단계 ID와 간부 ID가 안 겹치는지 본다). + /// + public static DuelKind KindOf(string storyNpcId) + { + if (string.IsNullOrEmpty(storyNpcId)) return DuelKind.None; + if (NpcBossDuels.IsBoss(storyNpcId)) return DuelKind.Boss; + if (NpcRivalDuels.IsRival(storyNpcId)) return DuelKind.Rival; + return DuelKind.None; + } + + /// + /// 아직 기다려야 하는가 — 하나라도 참이면 기다린다. 모달(대화·영상·컷신·NPC 연출·배지 연출·메뉴), 전투 화면(결과 포함 — + /// 카메라의 배틀 모드가 유일한 신뢰 신호다, StoryDirector.ShouldDeferNow와 같은 이유), 스토리 큐, 조작 잠금. + /// + public static bool ShouldWait(bool modalOpen, bool battleOnScreen, bool storyBusy, bool playerFrozen) + { + return modalOpen || battleOnScreen || storyBusy || playerFrozen; + } + } +} diff --git a/Assets/Scripts/Story/StoryDuelLauncher.cs.meta b/Assets/Scripts/Story/StoryDuelLauncher.cs.meta new file mode 100644 index 0000000..1269395 --- /dev/null +++ b/Assets/Scripts/Story/StoryDuelLauncher.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d6de52e9deac50a4c81203fc3899abc7 \ No newline at end of file diff --git a/Assets/Scripts/Story/StoryObjective.cs b/Assets/Scripts/Story/StoryObjective.cs index 1ae268b..7551a89 100644 --- a/Assets/Scripts/Story/StoryObjective.cs +++ b/Assets/Scripts/Story/StoryObjective.cs @@ -98,6 +98,28 @@ public static HashSet CollectSpineBeatIds(IEnumerable beats) return spine; } + /// + /// 아직 뒤가 남은 스파인 — 자기를 선행으로 무는 비트 중 안 본 것이 하나라도 있는 비트. + /// 목표 도출(StoryDirector.RecomputeObjective)이 대신 쓴다. + /// + /// 정상 진행에서는 둘이 같다 — 선행을 보기 전엔 뒤가 안 열리므로 "안 본 스파인"의 뒤는 늘 안 봤다. + /// 갈리는 것은 선행을 나중에 끼워 넣었을 때뿐이다: 간부 승리(duel_grip_win)를 서릿길 도착(ch9_arrive)의 + /// 선행으로 바꾸자, 그 전에 서릿길을 지나온 세이브는 도착을 이미 봤는데도 간부 승리가 스파인 1순위로 떠 HUD가 + /// 모래언덕으로 되돌려 보냈다. 뒤를 다 본 스파인은 leaf와 같은 급으로 내린다(목표에서 빠지지는 않는다). + /// + public static HashSet CollectLiveSpineBeatIds(IEnumerable beats, System.Func isSeen) + { + var spine = new HashSet(); + if (beats == null) return spine; + foreach (StoryBeat beat in beats) + { + if (beat == null || string.IsNullOrEmpty(beat.prerequisiteBeatId)) continue; + if (isSeen != null && isSeen(beat.beatId)) continue; // 뒤를 이미 봤다 — 이 선행은 더 잇는 것이 없다 + spine.Add(beat.prerequisiteBeatId); + } + return spine; + } + /// /// 선택지 결과 집합(어떤 비트의 choices[].nextBeatId로 지목된 비트). /// @@ -134,7 +156,7 @@ public static int ChapterRank(string chapterId) if (string.IsNullOrEmpty(chapterId)) return int.MaxValue; if (chapterId.StartsWith("ch") && int.TryParse(chapterId.Substring(2), out int n)) return n; - if (chapterId == "fin") return 1000; // 최종장 — 본편 뒤 + if (chapterId == FinaleChapterId) return 1000; // 최종장 — 본편 뒤 return 2000; // side / npc 등 곁이야기 } @@ -143,7 +165,8 @@ public static int ChapterRank(string chapterId) /// 순위는 (스파인 우선, 챕터, order, beatId)다. /// /// 이 함수가 순서의 단일 출처다. 목표 도출()과 - /// 실제 발화(StoryDirector.EvaluateTriggers)가 같은 답을 내야 한다 — + /// 실제 발화(StoryDirector.EvaluateTriggers)가 같은 답을 내야 한다(목표 쪽은 종장 + /// 한 가지만 덧붙인다 — ) — /// 예전엔 발화 쪽이 AllBeats()(Dictionary.Values, 순서 비결정)를 훑어 첫 일치를 /// 집었다. 그래서 동시에 자격을 갖는 비트가 있으면 HUD가 가리키는 것과 실제로 뜨는 것이 /// 갈렸다: 마을 어르신에게 말을 걸었을 때 1막 개막(ch1_intro, 프롤로그 컷신)이 아니라 @@ -157,7 +180,72 @@ public static int CompareBeatPriority(StoryBeat a, StoryBeat b, HashSet int spineA = spineBeatIds != null && spineBeatIds.Contains(a.beatId) ? 0 : 1; int spineB = spineBeatIds != null && spineBeatIds.Contains(b.beatId) ? 0 : 1; if (spineA != spineB) return spineA - spineB; + return CompareChapterOrderId(a, b); + } + + /// 최종장 챕터 ID. 의 "fin"과 같은 값이다. + public const string FinaleChapterId = "fin"; + + /// + /// 목표 도출 전용 순위 — 종장(fin) leaf를 스파인과 같은 급으로 친 뒤 (챕터, order, beatId). + /// + /// 에필로그(fin_epilogue)는 아무도 prereq로 지목하지 않는 leaf라, 스파인 우선만 두면 + /// fin_seal 직후 남은 곁이야기 스파인(마을 주민 만남 12명·도감 100/140종·꽃밭 사슬)이 전부 + /// 엔딩보다 앞섰다 — HUD가 "세라에게 말 걸기" 대신 "꼬마 화가 달래에게 말 걸기"를 가리켰다. + /// 종장 leaf는 fin_seal 뒤에만 자격을 얻으므로(선택 결과는 목표에서 빠진다) 캠페인 도중의 + /// 목표는 그대로다. 본편 leaf 전체를 앞세우면 안 된다 — 놓친 1장 동굴(ch1_cave)이 에필로그를 가로챈다. + /// + /// 발화 순서()에는 넣지 않는다. 넣으면 같은 인물에게 걸린 12장 leaf와 + /// 종장 leaf가 함께 자격을 가질 때 종장 쪽(화해)이 먼저 떠 서사가 거꾸로 흐른다(엔딩 뒤 관장 하월의 도발 + /// talk_chief와 화해 ch12_clash가 그 예였다 — 도발은 2026-10-04에 대치 직후 대결로 바뀌며 지웠다). + /// 그 경우 HUD가 가리킨 비트는 같은 행동을 한 번 더 하면 뜬다(「한 걸음에 시도가 2회」 — rules/testing.md). + /// + public static int CompareObjectivePriority(StoryBeat a, StoryBeat b, HashSet spineBeatIds) + { + if (a == null) return b == null ? 0 : 1; + if (b == null) return -1; + + int tierA = ObjectiveTier(a, spineBeatIds); + int tierB = ObjectiveTier(b, spineBeatIds); + if (tierA != tierB) return tierA - tierB; + return CompareChapterOrderId(a, b); + } + + // 0 = 이야기를 잇는 비트(스파인 또는 종장), 1 = leaf. + private static int ObjectiveTier(StoryBeat beat, HashSet spineBeatIds) + => IsObjectiveThread(beat, spineBeatIds) ? 0 : 1; + /// + /// 목표 도출에서 먼저 뽑히는 급(이야기를 잇는 비트 — 스파인 또는 종장)인가. + /// 의 0급과 같은 판정이다. leaf는 이 급이 바닥난 뒤에만 뽑힌다. + /// + /// HUD 목표 이유()를 꼭 써야 하는 비트가 이 급이다 — story_lint 검사 33과 + /// 실제 데이터 테스트가 같은 기준으로 센다(선택지 결과·Immediate는 목표 행에 머무르지 않아 뺀다). + /// + public static bool IsObjectiveThread(StoryBeat beat, HashSet spineBeatIds) + { + if (beat == null) return false; + if (spineBeatIds != null && spineBeatIds.Contains(beat.beatId)) return true; + return beat.chapterId == FinaleChapterId; + } + + /// + /// HUD 목표 행 두 번째 줄의 "왜" 한 줄. 순수 함수다 — 비트 조회는 호출부가 한다. + /// + /// 목표가 잠긴 리전의 열쇠(앞 리전 수문장)로 바꿔 쳐졌으면 빈 문자열이다. 이유는 원래 비트의 것이라 + /// "숲으로 가려면 초원 수문장 격파" 아래에 "상자에 갇힌 곤충이 있대"가 붙으면 수문장 얘기처럼 읽힌다. + /// 비트가 없거나 why가 비어 있어도 빈 문자열이다 — null을 흘리지 않는다(HUD가 그대로 그린다). + /// + /// 트래커가 목표를 앞 리전 수문장으로 바꿔 쳤는가. + public static string WhyFor(StoryBeat beat, bool redirectedToGatekeeper) + { + if (redirectedToGatekeeper || beat == null || string.IsNullOrWhiteSpace(beat.why)) return string.Empty; + return beat.why.Trim(); + } + + // 급이 같을 때의 나머지 순서 — 두 비교가 공유한다. + private static int CompareChapterOrderId(StoryBeat a, StoryBeat b) + { int chapterA = ChapterRank(a.chapterId); int chapterB = ChapterRank(b.chapterId); if (chapterA != chapterB) return chapterA - chapterB; @@ -312,6 +400,18 @@ public static string DescribeRegionObjective( return $"{regionName}(으)로"; } + /// + /// 목적지가 이야기 대결로 잠겨 있을 때의 문구 — "서릿길(으)로 가려면 집게에게 이기기". + /// 앞 리전 수문장은 이미 쓰러뜨렸고 남은 열쇠가 명부회 간부다(RegionManager의 이야기 잠금). + /// 수문장 문구()로 떨어지면 이미 이긴 수문장을 다시 가리킨다. + /// 조사는 "…에게"로 잇는다 — 이름에 따라 을/를이 갈리는 것을 피한다(필드 차단 문구와 같은 이유). + /// + public static string DescribeDuelLockObjective(string regionName, string opponentName) + { + if (string.IsNullOrEmpty(opponentName)) return $"{regionName}(으)로"; + return $"{regionName}(으)로 가려면 {opponentName}에게 이기기"; + } + /// /// 지금 기다리고 있는 비트 하나를 결정적으로 고른다. /// @@ -359,12 +459,177 @@ public static StoryBeat SelectObjectiveBeat( if (!string.IsNullOrEmpty(beat.requiredBeatId) && !isSeen(beat.requiredBeatId)) continue; - // 순위 비교는 CompareBeatPriority 하나만 쓴다 — 발화 쪽과 답이 갈리지 않게. - if (best == null || CompareBeatPriority(beat, best, spineBeatIds) < 0) + // 발화 순서(CompareBeatPriority)에 종장 한 가지만 덧붙인 순위 — 까닭은 CompareObjectivePriority. + if (best == null || CompareObjectivePriority(beat, best, spineBeatIds) < 0) best = beat; } return best; } } + + /// 마을 이야기 한 편(주민 한 명)에서 지금 해야 할 일. + public enum TaleStepKind + { + /// 이 NPC에게 걸린 대화 비트가 아예 없다. + None, + /// 말을 걸면 새 이야기가 나온다 — 지도·머리 위 !. + Talk, + /// 의뢰를 끝냈으니 알리러 가면 된다 — ?. 퀘스트 게이트가 걸린 비트가 열린 상태다. + Report, + /// 다음 대화가 의뢰 완료만 기다린다 — 따라가기는 의뢰(리전·진행)를 가리킨다. + Errand, + /// 다음 대화가 본편 진행(requiredBeatId)을 기다린다 — 지금은 할 일이 없다. + Waiting, + /// 이 NPC의 대화를 전부 들었다. + Done, + } + + /// + /// 마을 이야기 따라가기·표식의 순수 판정부. 와 같은 이유로 + /// MonoBehaviour와 떼어 둔다 — 여기 게이트 판정은 발화 쪽(StoryDirector.EvaluateTriggers)과 + /// 같은 답을 내야 한다. 한쪽만 다르면 !를 띄워 놓고 가서 말을 걸면 아무 일도 없다. + /// + /// 이야기 한 편은 한 NPC에게 거는 NpcTalk 비트의 사슬이다. 중간의 포획 징후 비트는 + /// 곁들임이라 따라가지 않는다 — 할 일은 의뢰(퀘스트)가 들고, 말할 차례는 대화 비트가 든다. + /// + public static class StoryTaleResolver + { + /// 마을 이야기 챕터 ID. 저널 탭(StoryJournalUI)과 같은 값이다. + public const string TownChapterId = "town"; + + /// + /// 이 NPC의 이야기에서 지금 할 일. 는 Talk/Report/Errand일 때 그 대화 비트, + /// 는 Errand일 때 기다리는 의뢰다. + /// + public static TaleStepKind ResolveStep( + IEnumerable beats, string npcId, + System.Func isSeen, System.Func isQuestDone, + out StoryBeat beat, out string questId) + { + beat = null; + questId = null; + if (beats == null || isSeen == null || string.IsNullOrEmpty(npcId)) return TaleStepKind.None; + + bool any = false, anyUnseen = false; + StoryBeat talk = null, errand = null; + + foreach (StoryBeat b in beats) + { + if (b == null || b.trigger == null || string.IsNullOrEmpty(b.beatId)) continue; + if (b.trigger.type != StoryDirector.TriggerNpcTalk || b.trigger.param != npcId) continue; + + any = true; + if (isSeen(b.beatId)) continue; + anyUnseen = true; + + // 순서(prereq)·단계(requiredBeatId)가 막혀 있으면 아직 차례가 아니다. + if (!string.IsNullOrEmpty(b.prerequisiteBeatId) && !isSeen(b.prerequisiteBeatId)) continue; + if (!string.IsNullOrEmpty(b.requiredBeatId) && !isSeen(b.requiredBeatId)) continue; + + bool questOpen = string.IsNullOrEmpty(b.requiredQuestId) + || (isQuestDone != null && isQuestDone(b.requiredQuestId)); + if (questOpen) + { + if (talk == null || StoryObjectiveResolver.CompareBeatPriority(b, talk, null) < 0) talk = b; + } + else if (errand == null || StoryObjectiveResolver.CompareBeatPriority(b, errand, null) < 0) + { + errand = b; + } + } + + if (talk != null) + { + beat = talk; + return string.IsNullOrEmpty(talk.requiredQuestId) ? TaleStepKind.Talk : TaleStepKind.Report; + } + if (errand != null) + { + beat = errand; + questId = errand.requiredQuestId; + return TaleStepKind.Errand; + } + if (anyUnseen) return TaleStepKind.Waiting; + return any ? TaleStepKind.Done : TaleStepKind.None; + } + + /// + /// 마을 주민 한 명에게 달 표식. 의뢰가 본편을 이긴다 — 말을 걸면 어차피 둘 중 하나가 나오고, + /// 의뢰 표식은 눌러서 따라갈 수 있어 잃으면 안 된다. 할 의뢰가 없는 주민이 본편 대상이면 본편 표식이다. + /// + public static InsectGame.NPC.QuestMark MarkFor(TaleStepKind step, bool isMainTarget) + { + if (step == TaleStepKind.Talk) return InsectGame.NPC.QuestMark.New; + if (step == TaleStepKind.Report) return InsectGame.NPC.QuestMark.Report; + return isMainTarget ? InsectGame.NPC.QuestMark.Main : InsectGame.NPC.QuestMark.None; + } + + /// + /// 마을 이야기 주민 목록 — 챕터의 NpcTalk 대상. + /// 인물 목록을 코드에 박지 않는다(주민을 늘려도 여기는 그대로다). 순서는 처음 등장한 비트의 + /// order 순이라 저작 순서(초원 → 연못 → …)와 같다. + /// + public static List CollectTaleNpcIds(IEnumerable beats) + { + var firstOrder = new Dictionary(); + if (beats != null) + { + foreach (StoryBeat b in beats) + { + if (b == null || b.trigger == null || b.chapterId != TownChapterId) continue; + if (b.trigger.type != StoryDirector.TriggerNpcTalk || string.IsNullOrEmpty(b.trigger.param)) continue; + if (!firstOrder.TryGetValue(b.trigger.param, out int o) || b.order < o) + firstOrder[b.trigger.param] = b.order; + } + } + var ids = new List(firstOrder.Keys); + ids.Sort((a, c) => firstOrder[a] != firstOrder[c] + ? firstOrder[a].CompareTo(firstOrder[c]) : string.CompareOrdinal(a, c)); + return ids; + } + + /// + /// 이 의뢰(questId)를 기다리는 대화 비트의 NPC — 퀘스트 목록의 [따라가기]가 쓴다. 없으면 null. + /// 마을 이야기(town) 비트만 본다. 본편도 퀘스트 게이트를 쓴다 — ch1_intro가 + /// q_move를 물어서, 거르지 않으면 튜토리얼 "첫 걸음!" 행에 [따라가기]가 떠 마을 어르신을 + /// "의뢰"로 따라갔다. + /// + public static string FindNpcForQuest(IEnumerable beats, string questId) + { + if (beats == null || string.IsNullOrEmpty(questId)) return null; + foreach (StoryBeat b in beats) + { + if (b == null || b.trigger == null || b.chapterId != TownChapterId || b.requiredQuestId != questId) continue; + if (b.trigger.type == StoryDirector.TriggerNpcTalk && !string.IsNullOrEmpty(b.trigger.param)) + return b.trigger.param; + } + return null; + } + + /// + /// 의뢰 진행 한 줄("연못에서 곤충 포획 3/5"). 순수 함수 — 이름·진행은 호출부가 읽어 넘긴다. + /// + /// 이미 그 리전 안이면 지명을 뺀다(스토리 목표 문구와 같은 규칙). + public static string DescribeErrand(InsectGame.Core.QuestType type, string regionName, + bool inRegion, int current, int target) + { + string verb; + switch (type) + { + case InsectGame.Core.QuestType.Capture: + case InsectGame.Core.QuestType.CaptureRarity: verb = "곤충 포획"; break; + case InsectGame.Core.QuestType.CaptureRare: verb = "희귀 곤충 포획"; break; + case InsectGame.Core.QuestType.Battle: verb = "전투 승리"; break; + case InsectGame.Core.QuestType.RaidBattle: verb = "레이드 승리"; break; + case InsectGame.Core.QuestType.VisitSubArea: verb = "숨은 장소 방문"; break; + case InsectGame.Core.QuestType.NpcDuel: verb = "대결 승리"; break; + default: verb = "의뢰 진행"; break; + } + string progress = target > 0 ? $" {UnityEngine.Mathf.Clamp(current, 0, target)}/{target}" : string.Empty; + return !inRegion && !string.IsNullOrEmpty(regionName) + ? $"{regionName}에서 {verb}{progress}" + : $"{verb}{progress}"; + } + } } diff --git a/Assets/Scripts/Story/StoryObjectiveTracker.cs b/Assets/Scripts/Story/StoryObjectiveTracker.cs index 87a4414..81d891c 100644 --- a/Assets/Scripts/Story/StoryObjectiveTracker.cs +++ b/Assets/Scripts/Story/StoryObjectiveTracker.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using InsectGame.Core; using InsectGame.Data; using InsectGame.NPC; @@ -39,6 +40,10 @@ public class StoryObjectiveTracker : MonoBehaviour private bool hasObjective; private StoryObjective objective; private string label = string.Empty; + // 목표 행 두 번째 줄의 이유 — 목표 비트의 why. 수문장으로 바꿔 쳤거나 의뢰를 따라가면 빈 문자열. + private string why = string.Empty; + // 이번 Refresh에서 TryRedirectLockedRegion이 목표를 앞 리전 수문장으로 바꿔 쳤는가. + private bool redirectedToGatekeeper; private bool hasWorldTarget; private Vector3 targetPosition; private string targetRegionId = string.Empty; @@ -48,12 +53,81 @@ public class StoryObjectiveTracker : MonoBehaviour private string statusMessage = string.Empty; private float statusTimer; + // ── 마을 이야기 따라가기 ── + // 비어 있으면 본편 목표를 안내한다. 채워져 있으면 그 주민의 이야기가 목표 행·미니맵 쐐기· + // 지도 마커·원터치 이동을 전부 가져간다(사용자 결정: 의뢰가 본편을 대신한다, 2026-09-28). + private string trackedTaleNpcId = string.Empty; + // 불러온 계정 스코프 키 — 로그인·계정 전환으로 키가 바뀌면 다시 읽는다. + private string trackedTalePrefsKey; + // 이번 Refresh의 목표가 따라가기에서 왔는가, 그리고 그게 "말 걸기"인가(자동 주행 도착 반경). + private bool trackedObjective; + private bool trackedTalkTarget; + private readonly List taleMarkers = new List(); + + /// 지도·미니맵이 그릴 의뢰 주민 표식 한 건. + public readonly struct TaleMarker + { + public readonly VillagerNpc Npc; + public readonly QuestMark Mark; + public TaleMarker(VillagerNpc npc, QuestMark mark) { Npc = npc; Mark = mark; } + } + // ── 읽기 전용 표면 (HUD가 소비) ── + /// + /// 지금 말을 걸면 이야기가 나오는 마을 주민(! 새 이야기 / ? 의뢰 보고). + /// 0.5초마다 다시 만든다 — 지도·미니맵은 그리기만 한다. + /// + public IReadOnlyList TaleMarkers => taleMarkers; + + /// + /// 이 주민의 표식(없으면 None). 지도·미니맵이 같은 탐색을 각자 들고 있던 것을 여기로 모았다 — + /// 목록이 주민 수(12명) 안팎이라 선형 탐색이다. 판정은 가 0.5초마다 만든다. + /// + public QuestMark TaleMarkOf(VillagerNpc npc) + { + if (npc == null) return QuestMark.None; + for (int i = 0; i < taleMarkers.Count; i++) + if (taleMarkers[i].Npc == npc) return taleMarkers[i].Mark; + return QuestMark.None; + } + + // ── 본편 표식 ── + // 본편이 "이 사람에게 말 걸기"를 가리킬 때 그 개체 하나에만 단다. 동행자는 리전마다 서 있어서 + // storyNpcId로 달면 같은 사람의 표식이 열 개씩 뜬다(2026-09-28 결정) — 그래서 목표 행이 고르는 것과 + // 같은 한 개체(현재 리전 우선 → 최근접)에만 붙인다. 의뢰를 따라가는 중에도 남는다 — 목표 행이 + // 의뢰로 넘어가도 "본편은 저기서 이어진다"가 머리 위·지도에 보여야 돌아올 수 있다. + private VillagerNpc mainMarkNpc; + + /// 본편이 지금 말을 걸라고 가리키는 개체. 본편 목표가 대화가 아니면 null. + public VillagerNpc MainMarkNpc => mainMarkNpc; + + /// + /// 이 주민에게 그릴 표식 — 마을 의뢰(!·?)가 먼저고, 없으면 본편 대상인지 본다. + /// 미니맵처럼 "무슨 표식이든 그리기만" 하는 쪽이 쓴다. 눌러서 따라가는 쪽은 . + /// + public QuestMark QuestMarkOf(VillagerNpc npc) + { + QuestMark tale = TaleMarkOf(npc); + if (tale != QuestMark.None) return tale; + return npc != null && npc == mainMarkNpc ? QuestMark.Main : QuestMark.None; + } + + /// 지금 목표 행이 의뢰 따라가기인가(HUD가 ✕ 해제 버튼을 붙인다). + public bool IsTrackingTale => trackedObjective; + public bool HasObjective => hasObjective; /// "세라에게 말 걸기" 같은 한 줄. 목표가 없으면 빈 문자열. public string Label => label; /// + /// 목표 아래에 붙는 짧은 이유 한 줄("상자에 갇힌 곤충이 있대") — 목표 비트의 . + /// null이 아니라 빈 문자열이 기본이다. 비는 경우: 목표가 없음 · 비트에 이유가 없음 · + /// 잠긴 리전이라 앞 리전 수문장으로 바꿔 쳤음(이유가 그 비트 것이라 수문장 안내에 붙으면 엉뚱하다) · + /// 마을 의뢰를 따라가는 중(목표 행이 본편 비트가 아니다). 과 같은 Refresh에서 갱신되고, + /// 값이 같으면 참조를 유지한다(HUD의 ReferenceEquals 캐시). + /// + public string Why => why; + /// /// 지금 목표가 가리키는 스토리 NPC 개체. 목표가 TalkToNpc가 아니거나 그 NPC가 /// 월드에 없으면 null. 의 조우 접근이 읽는다 — /// 개체 선택 규칙(현재 리전 우선 → 최근접)을 저쪽에 복제하지 않기 위해서다. @@ -62,6 +136,8 @@ public class StoryObjectiveTracker : MonoBehaviour /// 갈 곳이 정해진 목표인가 — false면 자동 주행 버튼을 띄우지 않는다. public bool HasWorldTarget => hasWorldTarget; public Vector3 TargetPosition => targetPosition; + /// 목표가 있는 리전 ID(없으면 빈 문자열). 지도가 마커를 누르면 그 리전을 고른다. + public string TargetRegionId => targetRegionId; public bool IsRunning => playerMovement != null && playerMovement.IsAutoRunning; /// /// 걸어서 갈 수 있는 목표인가 — 아니면 지도(텔레포트) 경로로 보낸다. @@ -178,10 +254,16 @@ private void Update() private void Refresh() { + EnsureTrackedTaleLoaded(); + RefreshQuestMarks(); + // 의뢰 따라가기는 본편 비트가 아니라 이유가 없다 — 본편 목표의 이유가 [의뢰] 줄 밑에 남지 않게 비운다. + if (TryResolveTrackedTale()) { SetWhy(string.Empty); return; } // false면 trackedObjective도 이미 내려가 있다 + hasObjective = storyDirector != null && storyDirector.TryGetCurrentObjective(out objective); if (!hasObjective) { label = string.Empty; + SetWhy(string.Empty); hasWorldTarget = false; targetRegionId = string.Empty; targetNpc = null; @@ -190,6 +272,8 @@ private void Refresh() // 아래 switch에서 ResolveNpcTarget만 다시 채운다 — 목표 종류가 바뀌면 자동으로 비워진다. targetNpc = null; + // 아래 해석 중 TryRedirectLockedRegion이 세운다 — 이유를 붙일지가 여기서 갈린다. + redirectedToGatekeeper = false; switch (objective.Kind) { @@ -201,9 +285,301 @@ private void Refresh() default: ResolveFreeform(); break; } + StoryService.TryGetBeat(objective.BeatId, out StoryBeat objectiveBeat); + SetWhy(StoryObjectiveResolver.WhyFor(objectiveBeat, redirectedToGatekeeper)); + TryAutoStartFirstObjective(); } + // ------------------------------------------------------------------ + // 마을 이야기 — 따라가기 + 표식 + // ------------------------------------------------------------------ + + /// + /// 지금 따라갈 거리가 있는가 — 그 의뢰 주민의 이야기가 말 걸기·보고·의뢰 진행 중 하나다. + /// 리전에 아직 못 갔거나(만남 전 단계) 본편을 기다리는 중이면 버튼을 띄우지 않는다 — + /// 눌러 봐야 "본편이 더 진행되면…"만 뜬다. 의뢰 단계인데 의뢰가 아직 잠겼으면(선행 퀘스트 + /// 미완료) 목록 행이 🔒인데 버튼만 뜨는 셈이라 띄우지 않는다. + /// + public bool CanTrackQuestNow(string questId) + { + if (storyDirector == null) return false; + string npcId = storyDirector.FindTaleNpcForQuest(questId); + if (string.IsNullOrEmpty(npcId)) return false; + TaleStepKind step = storyDirector.GetTaleStep(npcId, out string errandQuestId); + if (step == TaleStepKind.Errand) return !IsErrandLocked(errandQuestId); + return step == TaleStepKind.Talk || step == TaleStepKind.Report; + } + + // 의뢰가 아직 안 세어지는가 — 완료 전인데 선행 퀘스트·리전 해금이 안 됐다(목록의 🔒와 같은 판정). + private bool IsErrandLocked(string questId) + { + if (questManager == null) return false; + TutorialQuest quest = questManager.FindQuest(questId); + return quest != null && !questManager.IsQuestCompleted(quest.questId) && !questManager.IsSideUnlocked(quest); + } + + public bool IsQuestTracked(string questId) + { + if (string.IsNullOrEmpty(trackedTaleNpcId) || storyDirector == null) return false; + return storyDirector.FindTaleNpcForQuest(questId) == trackedTaleNpcId; + } + + public void TrackQuest(string questId) + { + if (storyDirector == null) return; + TrackTale(storyDirector.FindTaleNpcForQuest(questId)); + } + + public bool IsTaleTracked(string npcId) + => !string.IsNullOrEmpty(npcId) && npcId == trackedTaleNpcId; + + /// + /// 이 주민의 이야기를 따라간다. 지금 할 일이 없으면(본편을 기다리거나 다 들었으면) + /// 따라가지 않고 이유만 알린다 — 목표 행이 빈 안내를 붙들고 있지 않게. + /// + public void TrackTale(string npcId) + { + if (string.IsNullOrEmpty(npcId) || storyDirector == null) return; + EnsureTrackedTaleLoaded(); + // 본편 목표로 달리던 중이면 멈춘다 — 목표가 바뀌었는데 옛 목적지로 계속 가면 안 된다. + if (playerMovement != null && playerMovement.IsAutoRunning) playerMovement.CancelAutoRun(); + SetTrackedTale(npcId); + trackedTaleUserAction = true; // 이번 판정은 사용자가 막 누른 것 — 대기면 이유를 말하고 푼다 + Refresh(); + trackedTaleUserAction = false; + if (trackedObjective) ShowStatus($"{NpcDialogueDatabase.StorySpeakerName(npcId)}의 의뢰를 따라갑니다"); + } + + /// 따라가기 해제 — 목표 행이 본편으로 돌아간다. + public void StopTrackingTale() + { + EnsureTrackedTaleLoaded(); + if (string.IsNullOrEmpty(trackedTaleNpcId)) return; + if (playerMovement != null && playerMovement.IsAutoRunning) playerMovement.CancelAutoRun(); + SetTrackedTale(string.Empty); + Refresh(); + } + + // 이번 세션에서 따라가는 이야기가 **실제로 할 일을 가진 적이 있는가**. 아래 대기 판정의 근거다. + private bool trackedTaleActiveThisSession; + // TrackTale 안에서 부른 Refresh인가(사용자가 방금 누른 것). + private bool trackedTaleUserAction; + + private void SetTrackedTale(string npcId) + { + trackedTaleNpcId = npcId ?? string.Empty; + trackedTaleActiveThisSession = false; + if (string.IsNullOrEmpty(trackedTalePrefsKey)) return; + PlayerPrefs.SetString(trackedTalePrefsKey, trackedTaleNpcId); + PlayerPrefs.Save(); + } + + // 계정마다 따로 기억한다. 로그인이 트래커보다 늦게 끝나므로 키가 바뀔 때마다 다시 읽는다. + private void EnsureTrackedTaleLoaded() + { + string key = AuthManager.ScopedKey(GameConstants.PrefsKeys.TrackedTale); + if (key == trackedTalePrefsKey) return; + trackedTalePrefsKey = key; + trackedTaleNpcId = PlayerPrefs.GetString(key, string.Empty); + trackedTaleActiveThisSession = false; + } + + /// + /// 따라가는 이야기를 목표로 푼다. 풀었으면 true(본편 목표를 건너뛴다). + /// 할 일이 없는 단계면 따라가기를 스스로 풀고 false — 목표 행이 본편으로 돌아간다. + /// + private bool TryResolveTrackedTale() + { + trackedObjective = false; + if (string.IsNullOrEmpty(trackedTaleNpcId) || storyDirector == null) return false; + + string name = NpcDialogueDatabase.StorySpeakerName(trackedTaleNpcId); + TaleStepKind step = storyDirector.GetTaleStep(trackedTaleNpcId, out string questId); + switch (step) + { + case TaleStepKind.Talk: + case TaleStepKind.Report: + trackedTaleActiveThisSession = true; + return ResolveTaleTalk(step == TaleStepKind.Report); + case TaleStepKind.Errand: + if (ResolveTaleErrand(questId)) { trackedTaleActiveThisSession = true; return true; } + break; + case TaleStepKind.Waiting: + // **이번 세션에 할 일이 있던 이야기거나 방금 누른 것일 때만 푼다.** 세션 시작 직후엔 + // 스토리 진행이 아직 안 읽혔을 수 있다(로그인·클라우드 적재가 트래커보다 늦다) — 그때는 + // 열람 기록이 비어 모든 이야기가 "대기"로 보이고, 여기서 풀면 저장해 둔 따라가기가 + // 재시작할 때마다 지워진다. 그 경우엔 조용히 본편을 안내하고 다음 Refresh를 기다린다. + if (!trackedTaleActiveThisSession && !trackedTaleUserAction) return false; + ShowStatus($"{name}의 이야기는 본편이 더 진행되면 이어집니다"); + break; + case TaleStepKind.Done: + ShowStatus($"{name}의 이야기를 모두 들었습니다"); + break; + } + SetTrackedTale(string.Empty); + return false; + } + + private bool ResolveTaleTalk(bool report) + { + VillagerNpc npc = FindStoryNpc(trackedTaleNpcId); + string name = npc != null ? npc.DisplayName : NpcDialogueDatabase.StorySpeakerName(trackedTaleNpcId); + + hasObjective = true; + trackedObjective = true; + trackedTalkTarget = true; + SetLabel(report ? $"[의뢰] {name}에게 알리기" : $"[의뢰] {name}에게 말 걸기"); + + targetNpc = npc; + if (npc == null) + { + // 스폰 전이거나 컬링 중 — 안내 문구만 두고 다음 Refresh에서 다시 찾는다. + hasWorldTarget = false; + targetRegionId = string.Empty; + return true; + } + targetPosition = npc.transform.position; + targetRegionId = npc.RegionId ?? string.Empty; + hasWorldTarget = true; + return true; + } + + private bool ResolveTaleErrand(string questId) + { + TutorialQuest quest = questManager != null ? questManager.FindQuest(questId) : null; + if (quest == null) return false; + + // 만남 뒤에도 선행 퀘스트가 안 끝났으면 의뢰가 세어지지 않는다(초원은 q_capture3). 진행 0/6을 + // 띄우면 잡아도 숫자가 안 움직이니, 무엇을 먼저 끝내야 하는지를 말한다. + if (IsErrandLocked(quest.questId)) + { + hasObjective = true; + trackedObjective = true; + trackedTalkTarget = false; + targetNpc = null; + // 선행이 끝났는데도 잠겼다면 리전 쪽이다(마스터 특권 해제 등) — 끝난 퀘스트를 가리키지 않는다. + string prereq = !string.IsNullOrEmpty(quest.prerequisiteQuestId) && !questManager.IsQuestCompleted(quest.prerequisiteQuestId) + ? questManager.GetQuestTitle(quest.prerequisiteQuestId) : null; + SetLabel(string.IsNullOrEmpty(prereq) ? "[의뢰] 아직 열리지 않은 의뢰" : $"[의뢰] '{prereq}' 완료 후 시작"); + hasWorldTarget = false; + targetRegionId = string.Empty; + return true; + } + + RegionData region = regionManager != null && !string.IsNullOrEmpty(quest.requiredRegionId) + ? regionManager.GetRegionById(quest.requiredRegionId) : null; + bool inside = region != null && InTargetRegion(region.regionId); + int target = questManager.EffectiveTarget(quest); + int current = questManager.IsQuestCompleted(quest.questId) ? target : questManager.GetSideProgress(quest.questId); + + hasObjective = true; + trackedObjective = true; + trackedTalkTarget = false; + targetNpc = null; + SetLabel("[의뢰] " + StoryTaleResolver.DescribeErrand( + quest.type, region != null ? region.displayName : null, inside, current, target)); + + // 리전 안이면 갈 곳이 없다(ActInRegion과 같은 규칙). 밖이면 리전 중심으로 — 다른 리전이면 + // Toggle이 지도를 연다. 의뢰 리전은 만남 비트가 이미 그 리전에서 열렸으므로 잠겨 있지 않다. + if (region == null || inside) + { + hasWorldTarget = false; + targetRegionId = string.Empty; + return true; + } + targetPosition = region.centerPosition; + targetRegionId = region.regionId; + hasWorldTarget = true; + return true; + } + + // 같은 storyNpcId가 여러 리전에 설 수 있다 — 현재 리전 개체 우선, 없으면 첫 개체. + // 마을 주민은 한 명씩이라 사실상 첫 일치다. + private VillagerNpc FindStoryNpc(string storyNpcId) + { + if (npcManager == null || string.IsNullOrEmpty(storyNpcId)) return null; + string current = regionManager != null && regionManager.CurrentRegion != null + ? regionManager.CurrentRegion.regionId : null; + VillagerNpc first = null; + var list = npcManager.StoryNpcs; + for (int i = 0; i < list.Count; i++) + { + VillagerNpc npc = list[i]; + if (npc == null || npc.StoryNpcId != storyNpcId) continue; + if (current != null && npc.RegionId == current) return npc; + if (first == null) first = npc; + } + return first; + } + + /// + /// 마을 주민마다 지금 할 일을 물어 머리 위 표식과 지도·미니맵 표식을 정한다. + /// 동행자에게는 붙이지 않는다 — 세라·라온은 리전마다 서 있어 지도에 같은 사람의 표식이 + /// 열 개씩 뜬다(사용자 결정 2026-09-28). 대상은 저작 데이터의 town 챕터 주민뿐이다. + /// + private void RefreshTaleMarkers(VillagerNpc mainTarget) + { + taleMarkers.Clear(); + if (storyDirector == null || npcManager == null) return; + + IReadOnlyList taleIds = storyDirector.TaleNpcIds; + if (taleIds == null || taleIds.Count == 0) return; + + var list = npcManager.StoryNpcs; + for (int i = 0; i < list.Count; i++) + { + VillagerNpc npc = list[i]; + if (npc == null || !ContainsId(taleIds, npc.StoryNpcId)) continue; + + TaleStepKind step = storyDirector.GetTaleStep(npc.StoryNpcId, out _); + QuestMark mark = StoryTaleResolver.MarkFor(step, npc == mainTarget); + npc.SetQuestMark(mark); + if (mark == QuestMark.New || mark == QuestMark.Report) taleMarkers.Add(new TaleMarker(npc, mark)); + } + } + + /// + /// 머리 위·지도·미니맵 표식을 한 번에 정한다. 본편 대상을 먼저 고르고 마을 주민 루프에 넘긴다 — + /// 따로 달면 본편 대상이 마을 주민일 때 두 판정이 0.5초마다 표식을 껐다 켠다. + /// + private void RefreshQuestMarks() + { + VillagerNpc main = ResolveMainTalkNpc(); + RefreshTaleMarkers(main); + + // 마을 주민이 아닌 본편 대상(어르신·동행자)은 위 루프가 건드리지 않는다 — 여기서 달고 뗀다. + if (mainMarkNpc != null && mainMarkNpc != main && !IsTaleNpc(mainMarkNpc)) + mainMarkNpc.SetQuestMark(QuestMark.None); + if (main != null && !IsTaleNpc(main)) main.SetQuestMark(QuestMark.Main); + mainMarkNpc = main; + } + + // 본편이 지금 말을 걸라고 하는 개체. 따라가기와 무관하게 본편 목표를 직접 묻는다 — + // objective 필드는 따라가는 동안 갱신되지 않는다(Refresh가 먼저 돌아 나간다). + private VillagerNpc ResolveMainTalkNpc() + { + if (storyDirector == null || npcManager == null) return null; + if (!storyDirector.TryGetCurrentObjective(out StoryObjective main)) return null; + if (main.Kind != StoryObjectiveKind.TalkToNpc) return null; + return FindNearestStoryNpc(main.TargetId); + } + + private bool IsTaleNpc(VillagerNpc npc) + { + if (npc == null || storyDirector == null) return false; + IReadOnlyList taleIds = storyDirector.TaleNpcIds; + return taleIds != null && ContainsId(taleIds, npc.StoryNpcId); + } + + private static bool ContainsId(IReadOnlyList ids, string id) + { + if (string.IsNullOrEmpty(id)) return false; + for (int i = 0; i < ids.Count; i++) + if (ids[i] == id) return true; + return false; + } + // 캠페인 첫 목표를 한 번 자동으로 태워 보냈는가. private bool firstObjectiveAutoStarted; @@ -221,6 +597,7 @@ private void Refresh() private void TryAutoStartFirstObjective() { if (firstObjectiveAutoStarted) return; + if (DreamPrologueState.Active) return; // 꿈속에서 본 마을 어르신을 향해 달려가면 안 된다 if (!hasObjective || !hasWorldTarget) return; if (storyDirector == null || storyDirector.SeenCount > 0) return; if (playerMovement == null || playerMovement.IsFrozen || playerMovement.IsAutoRunning) return; @@ -240,7 +617,23 @@ private void TryAutoStartFirstObjective() private void ResolveNpcTarget() { hasWorldTarget = false; - if (npcManager == null || string.IsNullOrEmpty(objective.TargetId)) { ResolveFreeform(); return; } + VillagerNpc best = FindNearestStoryNpc(objective.TargetId); + if (best == null) { ResolveFreeform(); return; } + + SetLabel(objective.TriggerType == StoryDirector.TriggerDuelWin + ? $"{best.DisplayName}에게 대결 신청" + : $"{best.DisplayName}에게 말 걸기"); + targetPosition = best.transform.position; + targetRegionId = best.RegionId ?? string.Empty; + targetNpc = best; + hasWorldTarget = true; + } + + // 목표 행과 본편 표식이 **같은 개체**를 골라야 한다 — 따로 고르면 "저기로 가라"는 줄과 + // 머리 위 !가 서로 다른 리전의 같은 사람을 가리킨다. + private VillagerNpc FindNearestStoryNpc(string storyNpcId) + { + if (npcManager == null || string.IsNullOrEmpty(storyNpcId)) return null; string currentRegion = regionManager != null && regionManager.CurrentRegion != null ? regionManager.CurrentRegion.regionId : null; @@ -253,7 +646,7 @@ private void ResolveNpcTarget() for (int i = 0; i < list.Count; i++) { VillagerNpc npc = list[i]; - if (npc == null || npc.StoryNpcId != objective.TargetId) continue; + if (npc == null || npc.StoryNpcId != storyNpcId) continue; bool inRegion = currentRegion != null && npc.RegionId == currentRegion; float dist = playerTransform != null @@ -268,16 +661,7 @@ private void ResolveNpcTarget() bestDist = dist; } } - - if (best == null) { ResolveFreeform(); return; } - - SetLabel(objective.TriggerType == StoryDirector.TriggerDuelWin - ? $"{best.DisplayName}에게 대결 신청" - : $"{best.DisplayName}에게 말 걸기"); - targetPosition = best.transform.position; - targetRegionId = best.RegionId ?? string.Empty; - targetNpc = best; - hasWorldTarget = true; + return best; } private void ResolveRegionTarget() @@ -311,17 +695,54 @@ private bool TryRedirectLockedRegion(RegionData region) if (region == null || regionManager == null) return false; if (regionManager.IsRegionAccessible(region)) return false; - RegionData gate = regionManager.GetGatekeeperRegion(region.regionId); + // 잠긴 리전부터 거슬러 올라가며 **지금 손이 닿는 열쇠**를 찾는다. 열쇠는 둘이다 — 앞 리전 수문장, 그리고 + // 수문장을 이긴 뒤에도 남은 이야기 대결(서릿길 ← 집게, 잿불 골짜기 ← 저울). 수문장만 보던 시절의 로직으로는 + // 잿불 골짜기를 가리키는 세이브가 "모래언덕 수문장 격파"(이미 이긴 것)로 안내됐다. + // 판정은 RegionManager.GetLockKind 하나다 — 필드 차단 문구·지도와 같은 답을 낸다. // 최대 리전 수만큼만 거슬러 간다 — 체인은 유한하지만 데이터 오류로 순환하면 여기서 멈춘다. + RegionData locked = region; int hops = regionManager.Regions != null ? regionManager.Regions.Length : 16; - while (gate != null && !regionManager.IsRegionAccessible(gate) && hops-- > 0) - gate = regionManager.GetGatekeeperRegion(gate.regionId); - if (gate == null || !regionManager.IsRegionAccessible(gate)) return false; // 순환·상한 소진 — 잠긴 곳을 가리키지 않는다 + while (locked != null && hops-- > 0) + { + RegionManager.LockKind kind = regionManager.GetLockKind( + locked, out RegionData gate, out RegionManager.StoryLock storyLock); + + if (kind == RegionManager.LockKind.StoryDuel) + return RedirectToDuelOpponent(region, storyLock); + + if (kind != RegionManager.LockKind.Guardian || gate == null) return false; // 열쇠를 모른다 — 지어내지 않는다 + if (!regionManager.IsRegionAccessible(gate)) { locked = gate; continue; } // 앞 리전도 잠겼다 — 한 칸 더 + + SetLabel(StoryObjectiveResolver.DescribeRegionObjective( + region.displayName, false, 0, gate.displayName, gate.guardianLevel)); + targetPosition = regionManager.GetGuardianPosition(gate); + targetRegionId = gate.regionId; + hasWorldTarget = true; + redirectedToGatekeeper = true; // 이유는 원래 비트의 것 — 수문장 안내 밑에 붙이지 않는다(Refresh가 Why를 비운다) + return true; + } + return false; // 순환·상한 소진 — 잠긴 곳을 가리키지 않는다 + } - SetLabel(StoryObjectiveResolver.DescribeRegionObjective( - region.displayName, false, 0, gate.displayName, gate.guardianLevel)); - targetPosition = regionManager.GetGuardianPosition(gate); - targetRegionId = gate.regionId; + /// + /// 이야기 대결이 남은 잠금 — 그 간부에게 안내한다("서릿길(으)로 가려면 집게에게 이기기"). + /// 대개는 본편 목표가 이미 그 간부의 승리 비트(duel_grip_win, 스파인)라 같은 사람을 가리킨다 — + /// 여기는 다른 비트(마을 이야기·곁가지)가 잠긴 리전을 가리킬 때의 안내다. 간부가 월드에 없으면(스폰 전·컬링) + /// 문구만 남기고 화살표는 띄우지 않는다 — 다음 Refresh에서 다시 찾는다. + /// + private bool RedirectToDuelOpponent(RegionData region, RegionManager.StoryLock storyLock) + { + SetLabel(StoryObjectiveResolver.DescribeDuelLockObjective(region.displayName, storyLock.displayName)); + redirectedToGatekeeper = true; // 원래 비트의 이유는 이 안내와 무관하다 + VillagerNpc opponent = FindNearestStoryNpc(storyLock.storyNpcId); + if (opponent == null) + { + hasWorldTarget = false; + targetRegionId = string.Empty; + return true; + } + targetPosition = opponent.transform.position; + targetRegionId = opponent.RegionId ?? string.Empty; hasWorldTarget = true; return true; } @@ -410,6 +831,13 @@ private void SetLabel(string value) if (!string.Equals(label, value, System.StringComparison.Ordinal)) label = value; } + // SetLabel과 같은 이유로 값이 같으면 참조를 유지한다. + private void SetWhy(string value) + { + if (value == null) value = string.Empty; + if (!string.Equals(why, value, System.StringComparison.Ordinal)) why = value; + } + private bool InTargetRegion(string regionId) { if (string.IsNullOrEmpty(regionId) || regionManager == null @@ -468,9 +896,11 @@ public void Toggle() return; } + // 따라가기 목표는 objective를 거치지 않는다 — 종류를 따로 들고 있다. + bool talkTarget = trackedObjective ? trackedTalkTarget : objective.Kind == StoryObjectiveKind.TalkToNpc; playerMovement.BeginAutoRun( targetPosition, - objective.Kind == StoryObjectiveKind.TalkToNpc + talkTarget ? Mathf.Max(0.5f, WorldInteractionController.VillagerTalkRadius - TalkArriveMargin) : 2f); } diff --git a/Assets/Scripts/Story/StoryPreludeChain.cs b/Assets/Scripts/Story/StoryPreludeChain.cs new file mode 100644 index 0000000..29a3008 --- /dev/null +++ b/Assets/Scripts/Story/StoryPreludeChain.cs @@ -0,0 +1,118 @@ +using UnityEngine; + +namespace InsectGame.Story +{ + /// + /// 대사 앞 연출 여럿을 차례로 잇는 고리 — 대화창()의 연출 게이트가 + /// 하나(AutoWire(IStoryStagePrelude))라서 둔다. 부트스트랩이 영상() → + /// NPC 등장 연출() 순서로 넘긴다: 영상이 무슨 일인지 설명하고, 그다음 인물이 걸어 들어와 말한다. + /// 장을 여는 비트면 그 뒤에 「지난 이야기」 카드, 그리고 대사다(카드는 대화창이 연다 — 드라마의 콜드 오픈과 같은 순서). + /// + /// 계약()을 그대로 지킨다 — true면 onDone을 나중에(또는 이미) 정확히 한 번, + /// false면 절대 안 부른다. 못 지키면 그 비트가 pendingBeatId에 갇혀 캠페인이 멈추거나 대사가 두 번 열린다. + /// + /// 고리가 false면 다음 고리로. 전부 false면 false — 대화창이 곧바로 대사를 띄운다. + /// 고리가 true면 그 고리의 콜백에서 다음 고리부터 이어 간다. 남은 고리가 다 false면 그때 onDone. + /// 고리가 던지면 연출만 버리고 진행은 살린다 — 대화창의 try/catch는 첫 고리(동기 호출)만 감싼다. 영상이 끝난 뒤 + /// 콜백 안에서 등장 연출이 던지면 그 예외는 여기서만 받을 수 있다(안 받으면 대사가 영영 안 열린다). + /// 던진 고리가 콜백을 나중에 부르면 무시한다(이미 다음으로 넘어갔다 — 두 번 열지 않는다). 같은 콜백이 두 번 와도 한 번만 잇는다. + /// + /// 순수 C# 클래스다(MonoBehaviour 아님) — 고리 자체의 수명 검사(꺼짐·파괴)는 각 고리가 한다. + /// 파괴된 Unity 객체 고리는 여기서도 건너뛴다(인터페이스 참조의 null 비교는 파괴 검사를 못 한다). + /// + public sealed class StoryPreludeChain : IStoryStagePrelude + { + private readonly IStoryStagePrelude[] links; + + public StoryPreludeChain(params IStoryStagePrelude[] links) + { + this.links = links ?? new IStoryStagePrelude[0]; + } + + /// 고리 수(테스트·진단용). + public int Count => links.Length; + + public bool TryPlayPrelude(StoryBeat beat, System.Action onDone) + { + if (beat == null || onDone == null) return false; + return RunFrom(0, beat, onDone); + } + + /// + /// 번째 고리부터 연다. 하나라도 시작했으면 true(그 뒤는 콜백이 잇는다), 아무것도 안 열렸으면 false. + /// + private bool RunFrom(int start, StoryBeat beat, System.Action onDone) + { + for (int i = start; i < links.Length; i++) + { + IStoryStagePrelude link = links[i]; + if (IsGone(link)) continue; + + var hop = new Hop(this, i + 1, beat, onDone); + bool started; + try + { + started = link.TryPlayPrelude(beat, hop.Fire); + } + catch (System.Exception e) + { + Debug.LogError(hop.Fired + ? $"[StoryPrelude] {link.GetType().Name} 뒤의 연출·대사 열기에서 예외: {e}" + : $"[StoryPrelude] {link.GetType().Name} 연출 실패 — 다음으로 넘어간다: {e}"); + started = false; + } + + // 콜백이 이미 왔다(동기로 끝났거나, false를 돌려주면서 계약을 어기고 불렀다) — 그 콜백이 뒤를 이었다. + if (hop.Fired) return true; + if (started) return true; + + // 시작하지 않았다(또는 던졌다) — 이 고리의 늦은 콜백은 버린다. 이미 다음 고리로 넘어가므로. + hop.Disarm(); + } + return false; + } + + /// 고리 하나가 끝났다 — 다음 고리부터 이어 열고, 남은 게 없으면 대사를 연다. + private void Continue(int next, StoryBeat beat, System.Action onDone) + { + if (RunFrom(next, beat, onDone)) return; + onDone(); + } + + private static bool IsGone(IStoryStagePrelude link) + { + if (link == null) return true; + // 인터페이스 참조의 `== null`은 C# 참조 비교라 파괴된 MonoBehaviour를 못 거른다 — Unity의 `==`로 다시 본다. + return link is UnityEngine.Object unityObject && unityObject == null; + } + + /// 고리 하나에 건 콜백 — 한 번만 잇고, 버린 고리의 늦은 콜백은 무시한다. + private sealed class Hop + { + private readonly StoryPreludeChain chain; + private readonly int next; + private readonly StoryBeat beat; + private readonly System.Action onDone; + private bool disarmed; + + public bool Fired { get; private set; } + + public Hop(StoryPreludeChain chain, int next, StoryBeat beat, System.Action onDone) + { + this.chain = chain; + this.next = next; + this.beat = beat; + this.onDone = onDone; + } + + public void Disarm() => disarmed = true; + + public void Fire() + { + if (Fired || disarmed) return; + Fired = true; + chain.Continue(next, beat, onDone); + } + } + } +} diff --git a/Assets/Scripts/Story/StoryPreludeChain.cs.meta b/Assets/Scripts/Story/StoryPreludeChain.cs.meta new file mode 100644 index 0000000..c66f292 --- /dev/null +++ b/Assets/Scripts/Story/StoryPreludeChain.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 47440f50617e0434d89f8bbb09bca5ac \ No newline at end of file diff --git a/Assets/Scripts/Story/StoryService.cs b/Assets/Scripts/Story/StoryService.cs index 15c7639..4391675 100644 --- a/Assets/Scripts/Story/StoryService.cs +++ b/Assets/Scripts/Story/StoryService.cs @@ -8,6 +8,8 @@ namespace InsectGame.Story public static class StoryService { private static Dictionary cache; + // 장별 「지난 이야기」 — JSON 배열 순서 그대로(장 순서가 의미를 가진다). + private static List chapters; private const string ResourceName = "Story"; public static bool TryGetBeat(string beatId, out StoryBeat beat) @@ -38,6 +40,39 @@ public static IEnumerable AllBeats() return cache.Values; } + /// 장 「지난 이야기」 — JSON 순서(장 순서). 없으면 빈 목록. + public static IReadOnlyList AllChapters() + { + EnsureCache(); + return chapters ?? (IReadOnlyList)System.Array.Empty(); + } + + public static bool TryGetChapter(string chapterId, out StoryChapter chapter) + { + chapter = null; + if (string.IsNullOrEmpty(chapterId)) return false; + foreach (StoryChapter c in AllChapters()) + { + if (c != null && c.chapterId == chapterId) { chapter = c; return true; } + } + return false; + } + + /// + /// 이 비트가 여는 장 — 그 장의 가 이 비트면 true. + /// 카드는 지난 이야기()가 있을 때만 뜬다. + /// + public static bool TryGetChapterOpenedBy(string beatId, out StoryChapter chapter) + { + chapter = null; + if (string.IsNullOrEmpty(beatId)) return false; + foreach (StoryChapter c in AllChapters()) + { + if (c != null && c.openingBeatId == beatId) { chapter = c; return true; } + } + return false; + } + private static void EnsureCache() { if (cache != null) @@ -53,6 +88,7 @@ private static void EnsureCache() StoryList list = JsonUtility.FromJson(asset.text); cache = new Dictionary(); + chapters = list != null && list.chapters != null ? list.chapters : new List(); if (list == null || list.beats == null) { return; diff --git a/Assets/Scripts/Story/StoryStageDirector.cs b/Assets/Scripts/Story/StoryStageDirector.cs index dacf6ed..957cddd 100644 --- a/Assets/Scripts/Story/StoryStageDirector.cs +++ b/Assets/Scripts/Story/StoryStageDirector.cs @@ -147,11 +147,11 @@ private void OnBeatCompleted(StoryBeat beat) // 것으로 보인다. 플레이어가 멀어질 때까지 기다렸다가 조용히 되돌린다(Update). if (string.IsNullOrEmpty(beat.stageExitId)) { restorePending = warpedActors.Count > 0; return; } - // cutsceneId가 함께 있으면 CutsceneDirector와 조작·카메라를 다툰다. story_lint가 + // cutsceneId/videoId가 함께 있으면 CutsceneDirector/StoryVideoDirector와 조작·모달을 다툰다. story_lint가 // 그 조합을 금지하지만, 런타임에서도 컷신 쪽에 양보한다(카메라를 뺏는 쪽이 더 크다). - if (!string.IsNullOrEmpty(beat.cutsceneId)) + if (!string.IsNullOrEmpty(beat.cutsceneId) || !string.IsNullOrEmpty(beat.videoId)) { - Debug.LogWarning($"[Stage] {beat.beatId}: cutsceneId와 stageExitId가 함께 있어 연출을 건너뛴다"); + Debug.LogWarning($"[Stage] {beat.beatId}: cutsceneId/videoId와 stageExitId가 함께 있어 연출을 건너뛴다"); restorePending = warpedActors.Count > 0; // 연출은 건너뛰어도 배우는 제자리로 return; } diff --git a/Assets/Scripts/Story/StoryVideoData.cs b/Assets/Scripts/Story/StoryVideoData.cs new file mode 100644 index 0000000..61dfe98 --- /dev/null +++ b/Assets/Scripts/Story/StoryVideoData.cs @@ -0,0 +1,84 @@ +using UnityEngine; + +namespace InsectGame.Story +{ + /// + /// 영상 위에 얹는 자막 한 줄. 자막은 영상에 굽지 않는다 — 영상을 다시 그리지 않고 문구를 고치고, + /// 「무명」 금칙(StoryBible 2장)·현지화를 코드 한 곳에서 하기 위해서다(나중에 AI 영상으로 바꿔도 같다). + /// + public struct StoryVideoCue + { + /// 영상 시작 기준 자막이 뜨는 시각(초). + public float at; + /// 자막이 떠 있는 길이(초). 앞뒤 페이드는 가 준다. + public float duration; + public string text; + + public StoryVideoCue(float at, float duration, string text) + { + this.at = at; + this.duration = duration; + this.text = text; + } + + public float End => at + duration; + } + + /// + /// 영상 한 편의 정의. 파일은 Assets/StreamingAssets/Video/에 두고 VideoPlayer.url로 + /// 스트리밍한다(임포터를 타지 않아 Unity 트랜스코딩·메모리 상주가 없다). + /// + public sealed class StoryVideoDefinition + { + public string videoId; + /// 파일명(확장자 포함). 경로는 가 붙인다. + public string fileName; + /// + /// 저작 기준 길이(초). 실제 파일과 어긋나도 재생은 파일이 끝날 때 끝나지만, 프리즈 상한 + /// 판정(테스트)과 디코더가 종료 이벤트를 안 주는 기기의 안전망(+3s)이 이 값을 쓴다. + /// + public float expectedDuration; + public StoryVideoCue[] cues; + + public StoryVideoDefinition(string videoId, string fileName, float expectedDuration, StoryVideoCue[] cues) + { + this.videoId = videoId; + this.fileName = fileName; + this.expectedDuration = expectedDuration; + this.cues = cues ?? new StoryVideoCue[0]; + } + } + + /// + /// 영상 자막 타임라인의 순수 계산 — MonoBehaviour와 떼어 테스트로 고정한다 + /// (과 같은 성격). + /// + public static class StoryVideoTimeline + { + /// + /// 경과 시각에 떠 있어야 할 자막 인덱스. 없으면 false. 큐가 겹치면 앞의 것이 이긴다 + /// (겹침 자체는 테스트가 금지한다). + /// + public static bool TryGetCue(StoryVideoCue[] cues, float elapsed, out int index) + { + index = -1; + if (cues == null || cues.Length == 0) return false; + for (int i = 0; i < cues.Length; i++) + { + if (elapsed >= cues[i].at && elapsed < cues[i].End) + { + index = i; + return true; + } + } + return false; + } + + /// 그 큐 안의 0~1 진행도 — 페이드 계산용. + public static float CueProgress(StoryVideoCue cue, float elapsed) + { + if (cue.duration <= 0f) return 1f; + return Mathf.Clamp01((elapsed - cue.at) / cue.duration); + } + } +} diff --git a/Assets/Scripts/Story/StoryVideoData.cs.meta b/Assets/Scripts/Story/StoryVideoData.cs.meta new file mode 100644 index 0000000..3ed809e --- /dev/null +++ b/Assets/Scripts/Story/StoryVideoData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8cc64f8847a96054887164a5552e91b2 \ No newline at end of file diff --git a/Assets/Scripts/Story/StoryVideoDirector.cs b/Assets/Scripts/Story/StoryVideoDirector.cs new file mode 100644 index 0000000..3875b45 --- /dev/null +++ b/Assets/Scripts/Story/StoryVideoDirector.cs @@ -0,0 +1,569 @@ +using InsectGame.Core; +using InsectGame.UI; +using UnityEngine; +using UnityEngine.Video; + +namespace InsectGame.Story +{ + /// + /// 스토리 영상(mp4) 재생기 — 의 영상판이다. 저쪽이 카메라를 + /// 뺏는다면 여기는 화면을 통째로 덮는다(카메라는 건드리지 않는다). + /// + /// 세 길로 튼다: + /// + /// 대사 뒤(StoryBeat.videoId) — 대사를 닫은 뒤(StoryBeatCompleted). 전투 화면이 떠 있으면 미룬다. + /// 대사 앞(StoryBeat.introVideoId) — 로 대화창이 열리기 전에 + /// 튼다(). 설명은 영상이 맡고 대사는 짧게 간다. 대화창의 연출 게이트는 하나라 + /// 이 이 영상 → NPC 등장 연출 차례로 잇는다. + /// 다시보기() — 저널의 「▶ 영상」. 스토리 부수효과가 없다. + /// + /// 파일은 Assets/StreamingAssets/Video/에서 VideoPlayer.url로 스트리밍한다 — + /// 임포터를 타지 않아 트랜스코딩·메모리 상주가 없고, Android의 jar:file:// 경로도 + /// URL 재생은 그대로 지원한다. 소리는 mp4 안의 AAC를 AudioSource로 보낸다. + /// + /// 복귀 보장이 급소다. 재생 중 조작을 막고 모달 스택을 잡으므로 어떤 경로로 끝나든 + /// (정상 종료·건너뛰기·디코더 오류·파일 없음·준비 시간 초과·재생 초과·비활성·재진입) 하나로 되돌린다. + /// 파일이 없거나 디코더가 실패해도 진행은 잃지 않는다 — 대사 뒤 영상은 비트가 이미 완료됐고, + /// 대사 앞 영상은 이 마지막에 대사를 연다. 여기서 멈추면 DrainPendingTriggers가 + /// 모달 가드에 막혀 다음 비트가 영영 안 오고, 대사 앞 영상이면 그 비트가 pendingBeatId에 갇힌다. + /// + public class StoryVideoDirector : MonoBehaviour, IModalUI, IStoryStagePrelude + { + private const string VideoFolder = "Video"; + /// 준비(디코더 열기)가 이 시간을 넘기면 포기한다. 저사양 기기의 첫 프레임 지연보다 넉넉하게. + private const float PrepareTimeoutSeconds = 5f; + /// 디코더가 loopPointReached를 안 주는 기기 대비 — 저작 길이보다 이만큼 지나면 끝낸다. + private const float OverrunGraceSeconds = 3f; + /// 재생 중 월드 BGM·환경음 배율. + private const float BgmDuckFactor = 0.2f; + + private StoryDirector storyDirector; + private CameraFollower cameraFollower; + private PlayerMovement playerMovement; + + private VideoPlayer videoPlayer; + private AudioSource audioSource; + private RenderTexture renderTexture; + + private StoryVideoDefinition current; + private float elapsed; + private float prepareSeconds; + private bool prepared; + private bool playing; + private bool restoreFrozen; + + private StoryVideoDefinition pending; + // 포기 시계 — 전투 카메라가 결과 화면 밖에서 안 풀린 시간만 센다(StoryBattleWait, CutsceneDirector와 같다). + // 결과 화면(이제 눌러야 닫힌다)과 모달(대사·메뉴)은 플레이어가 닫는 화면이라 세지 않는다. + private float pendingSeconds; + // 1대1·레이드 결과 화면이 떠 있는가 — 부트스트랩이 넘긴다(Story가 UI를 모르게 함수 하나). + private System.Func battleResultShowing; + + // 대사 앞 영상이 끝나면 부를 것 — 대사를 연다(StoryPreludeChain이 그 사이에 NPC 등장 연출을 끼운다). + // **재생이 실제로 시작된 뒤에만 건다**(PlayIntro). 그래서 Play 안의 재진입 Stop이 새 콜백을 부르는 일이 없고, + // 시작 전에 끝난 영상(파일 없음)은 콜백 없이 false를 돌려 호출부가 곧바로 대사를 띄운다. + // 건 뒤에는 Stop이 상태를 다 비운 **다음** 정확히 한 번 부른다(FinishPrelude). + private System.Action onPreludeDone; + + /// + /// 테스트 전용 — false면 재생 상태(모달·조작 잠금·콜백)만 세우고 파일 확인·디코더를 건너뛴다. + /// PlayMode·배치 러너엔 디코더가 없어서(rules/testing.md) 종료 경로를 디코더 없이 두드리려고 둔다. + /// 게임 코드는 건드리지 않는다(IslandManager.PersistenceEnabled와 같은 성격). + /// + internal bool DecoderEnabled = true; + + private GUIStyle subtitleStyle; + private GUIStyle skipStyle; + private bool stylesReady; + + // ── IModalUI ── ESC(Back키)로 건너뛴다. 등록하지 않으면 StoryDirector.ShouldDeferNow가 + // "화면에 아무것도 없다"고 판정해 영상 위로 다음 대사가 겹쳐 뜬다. + public bool IsOpen => playing; + public void CloseModal() => Stop(); + + public bool IsPlaying => playing; + + /// 지금 틀고 있는 영상 ID(안 틀면 null) — 배치 걸음(StoryBeatWalkthrough)의 보고서용. + public string CurrentVideoId => playing && current != null ? current.videoId : null; + + /// 대사 앞 영상을 틀고 있다 — 끝나면 대사가 열린다. + public bool IsPlayingPrelude => playing && onPreludeDone != null; + + public void AutoWire(StoryDirector director, CameraFollower follower, PlayerMovement movement) + { + if (storyDirector == null) storyDirector = director; + if (cameraFollower == null) cameraFollower = follower; + if (playerMovement == null) playerMovement = movement; + Subscribe(); + } + + /// + /// 결과 화면 탐침. 결과 화면은 눌러야 닫혀서(BattleResultRules) 그 시간을 "굳은 전투 화면"으로 세면 + /// 보상을 오래 본 사람의 영상이 사라진다(). 없으면 예전처럼 결과 화면도 센다. + /// + public void AutoWire(System.Func resultShowing) + { + if (battleResultShowing == null) battleResultShowing = resultShowing; + } + + private void Awake() + { + // 컴포넌트를 코드로 붙인다 — 이 프로젝트엔 프리팹이 없고 Bootstrap이 전부 EnsureComponent로 만든다. + videoPlayer = gameObject.GetComponent(); + if (videoPlayer == null) videoPlayer = gameObject.AddComponent(); + audioSource = gameObject.GetComponent(); + if (audioSource == null) audioSource = gameObject.AddComponent(); + + audioSource.playOnAwake = false; + // 오프닝과 달리 리스너 일시정지를 뚫지 않는다 — PlayScene에서 리스너를 멈추는 주체는 오프닝 + // 다시보기뿐이고, 그동안 이 영상이 들려서는 안 된다. + audioSource.ignoreListenerPause = false; + + videoPlayer.playOnAwake = false; + videoPlayer.isLooping = false; + videoPlayer.source = VideoSource.Url; + // RenderTexture 모드에선 aspectRatio가 무시된다 — 화면 맞춤은 OnGUI의 CalculateCoverUv가 한다. + videoPlayer.renderMode = VideoRenderMode.RenderTexture; + videoPlayer.audioOutputMode = VideoAudioOutputMode.AudioSource; + videoPlayer.skipOnDrop = true; + videoPlayer.waitForFirstFrame = true; + + videoPlayer.prepareCompleted += OnPrepared; + videoPlayer.loopPointReached += OnReachedEnd; + videoPlayer.errorReceived += OnVideoError; + } + + // AutoWire와 OnEnable이 함께 부른다 — `-=` 뒤 `+=`라 중복 구독이 되지 않는다. + private void Subscribe() + { + if (storyDirector == null) return; + storyDirector.StoryBeatCompleted -= OnBeatCompleted; + storyDirector.StoryBeatCompleted += OnBeatCompleted; + } + + private void OnEnable() => Subscribe(); + + private void OnDisable() + { + if (storyDirector != null) storyDirector.StoryBeatCompleted -= OnBeatCompleted; + pending = null; // Update가 안 도는 동안 대기 시각도 안 흐른다 — 뒤늦게 튀어나오지 않게 버린다 + // 대사 앞 영상 중이었다면 Stop이 대사를 연다 — 꺼진 채 기다리면 시간 초과가 영영 안 와 그 비트가 갇힌다. + Stop(); + } + + private void OnDestroy() + { + if (videoPlayer != null) + { + videoPlayer.prepareCompleted -= OnPrepared; + videoPlayer.loopPointReached -= OnReachedEnd; + videoPlayer.errorReceived -= OnVideoError; + } + ReleaseRenderTexture(); + } + + private void OnApplicationPause(bool paused) + { + if (!playing || videoPlayer == null || !prepared) return; + if (paused) videoPlayer.Pause(); + else videoPlayer.Play(); + } + + private void OnBeatCompleted(StoryBeat beat) + { + if (beat == null || string.IsNullOrEmpty(beat.videoId)) return; + if (!StoryVideoLibrary.TryGet(beat.videoId, out StoryVideoDefinition def)) + { + // 오타를 조용히 넘기지 않는다 — 영상이 안 나오는 건 화면상 티가 안 난다. + Debug.LogWarning($"[StoryVideo] 알 수 없는 videoId: '{beat.videoId}' (beat {beat.beatId})"); + return; + } + + // 전투 화면이 아직 떠 있으면 미룬다(BattleWin 비트). 카메라의 배틀 모드가 + // "전투가 아직 화면에 있다"의 유일한 신뢰 신호다(BattleScreenUI는 IModalUI가 아니다). + if (cameraFollower != null && cameraFollower.InBattleMode) + { + pending = def; + pendingSeconds = 0f; + return; + } + + Play(def); + } + + /// 미뤄 둔 영상의 한 프레임. 무엇을 했는지 돌려준다(테스트가 본다). + private StoryBattleWait.SceneStep TickPending(float deltaSeconds) + { + if (pending == null) return StoryBattleWait.SceneStep.None; + + // 전투 화면이 닫혔고 다른 모달(대사·컷신)도 없을 때만 튼다 — 모달 위로 시작하면 서로의 복구를 덮는다. + // 상한은 결과 화면 밖에서 전투 카메라가 굳은 시간에만 걸린다 — 결과 화면을 오래 보거나 이어진 대사를 오래 읽어도 안 버린다. + StoryBattleWait.SceneStep step = StoryBattleWait.TickScene(ref pendingSeconds, deltaSeconds, + cameraFollower != null && cameraFollower.InBattleMode, + StoryBattleWait.ReadProbe(battleResultShowing), + ModalUIRegistry.IsAnyOpen()); + + if (step == StoryBattleWait.SceneStep.Play) + { + StoryVideoDefinition queued = pending; + pending = null; + Play(queued); + } + else if (step == StoryBattleWait.SceneStep.GiveUp) + { + Debug.LogWarning("[StoryVideo] 전투 화면(결과 화면 밖)이 닫히지 않아 영상을 건너뛴다"); + pending = null; + } + return step; + } + + // ==================== 대사 앞 영상 ==================== + + /// + /// 대사 앞 영상(StoryBeat.introVideoId). 가 비트를 렌더하기 전에 + /// (을 거쳐) 부른다. + /// + /// 계약: true면 을 나중에 정확히 한 번 부른다(끝·건너뛰기·ESC·디코더 오류·준비 시간 초과· + /// 재생 초과·비활성·재진입 — 전부 을 지난다). false면 절대 안 부른다 — 호출부가 곧바로 대사를 띄운다. + /// 영상이 없거나·모르는 ID거나·지휘자가 꺼져 있거나·시작도 못 하고 끝났으면(에디터·데스크톱의 파일 없음) false다. + /// Android는 파일 유무를 미리 못 봐서(jar 경로) 파일 없음이 디코더 오류로 오고, 그건 true 쪽(나중에 한 번)이다. + /// + public bool TryPlayPrelude(StoryBeat beat, System.Action onDone) + { + if (beat == null || onDone == null) return false; + if (string.IsNullOrEmpty(beat.introVideoId)) return false; + + // **죽었거나 꺼져 있으면 게이트를 걸지 않는다** — StoryStageDirector.TryPlayPrelude와 같은 함정이다. + // 호출부의 null 검사는 인터페이스 참조 비교라 Unity의 파괴 검사가 안 걸리고, 이 컴포넌트는 World/ 아래라 + // 대화창(UI/)과 다른 루트다. 꺼진 채 true를 돌려주면 Update가 안 돌아 시간 초과가 영영 안 오고 + // onDone 미호출 → 그 비트가 pendingBeatId에 갇혀 캠페인이 멈춘다. Awake 전이면 VideoPlayer도 없다. + if (!isActiveAndEnabled || videoPlayer == null) return false; + + if (!StoryVideoLibrary.TryGet(beat.introVideoId, out StoryVideoDefinition def)) + { + // 오타를 조용히 넘기지 않는다 — 영상이 안 나오는 건 화면상 티가 안 난다(story_lint 검사 25가 먼저 잡는다). + Debug.LogWarning($"[StoryVideo] 알 수 없는 introVideoId: '{beat.introVideoId}' (beat {beat.beatId}) — 대사로 넘어간다"); + return false; + } + return PlayIntro(def, onDone); + } + + /// + /// 대사 앞 영상을 틀고 콜백을 건다. 재생이 실제로 시작된 뒤에만 건다 — 먼저 걸면 안의 + /// 재진입 이 방금 건 콜백을 불러 버린다. 테스트는 정의를 직접 넘겨 이 경로를 두드린다. + /// + internal bool PlayIntro(StoryVideoDefinition definition, System.Action onDone) + { + if (definition == null || onDone == null) return false; + try + { + Play(definition); + } + catch (System.Exception e) + { + // 재생 상태를 세우다 던졌다 — 콜백은 아직 안 걸었다. 화면·조작을 되돌리고 대사로 넘긴다. + // (호출부가 예외를 받아 대사를 띄운 뒤 남은 재생이 시간 초과로 끝나며 콜백을 또 부르면 대사가 두 번 열린다.) + Debug.LogError($"[StoryVideo] 대사 앞 영상 시작 실패({definition.videoId}) — 대사로 넘어간다: {e}"); + Stop(); + return false; + } + + // 시작도 못 하고 끝났다(파일 없음 등) — 콜백 없이 false. 호출부가 곧바로 대사를 띄운다. + if (!playing) return false; + onPreludeDone = onDone; + return true; + } + + /// + /// 대사 앞 영상의 콜백을 상태를 다 비운 뒤 한 번 부른다. 콜백(대사 열기·NPC 등장 연출)이 다시 조작을 잠그고 + /// 모달을 걸 수 있으므로 우리 복구가 먼저 끝나 있어야 한다. 콜백이 던져도 지휘자 상태는 이미 깨끗하고, + /// 부른 쪽(OnGUI의 건너뛰기·재진입 Play)이 중간에 끊기지 않게 여기서 받는다. + /// + private void FinishPrelude() + { + System.Action done = onPreludeDone; + onPreludeDone = null; + if (done == null) return; + try + { + done(); + } + catch (System.Exception e) + { + Debug.LogError($"[StoryVideo] 대사 앞 영상 뒤 대사 열기 실패: {e}"); + } + } + + // ==================== 다시보기 ==================== + + /// + /// 저널의 「▶ 영상」 — 이미 본 비트의 영상을 다시 튼다. 스토리 부수효과가 없다(콜백·대결·완료 처리 없음). + /// 건너뛰기·ESC는 평소와 같다. 재생을 시작했으면 true. + /// false: 라이브러리에 없는 ID · 다른 영상이 재생 중(대사 앞 영상을 끊으면 그 대사가 다시보기 밑에서 열린다) · + /// 지휘자가 꺼져 있음 · 전투 화면 · 시작도 못 하고 끝남(에디터·데스크톱의 파일 없음). + /// + public bool PlayReplay(string videoId) + { + if (string.IsNullOrEmpty(videoId) || !StoryVideoLibrary.TryGet(videoId, out StoryVideoDefinition def)) + return false; + if (playing) return false; + if (!isActiveAndEnabled || videoPlayer == null) return false; + if (cameraFollower != null && cameraFollower.InBattleMode) return false; + + Play(def); + return playing; + } + + // ==================== 재생 ==================== + + public void Play(StoryVideoDefinition definition) + { + if (definition == null || string.IsNullOrEmpty(definition.fileName)) return; + if (videoPlayer == null) return; + + // 재진입 가드 — 없으면 restoreFrozen이 자기가 만든 frozen=true를 읽어 복구를 건너뛴다(영구 먹통). + // 대사 앞 영상이 걸려 있었다면 이 Stop이 그 대사를 연다(콜백은 한 번 — 재진입도 종료 경로다). + if (playing) Stop(); + + current = definition; + elapsed = 0f; + prepareSeconds = 0f; + prepared = false; + playing = true; + + restoreFrozen = playerMovement != null && playerMovement.IsFrozen; + if (playerMovement != null) + { + playerMovement.CancelAutoRun(); + playerMovement.SetFrozen(true); + } + ModalUIRegistry.Register(this); + // 월드 BGM·환경음을 낮춘다 — 영상 음성과 12~15초 내내 겹치지 않게. Stop()이 원복한다. + if (AudioManager.Instance != null) AudioManager.Instance.SetBgmDuck(BgmDuckFactor); + + // 재생 중엔 설정 UI가 닫혀 있으니 시작 때 한 번만 읽는다(OpeningSceneController와 같다). + audioSource.volume = Mathf.Clamp01(PlayerPrefs.GetFloat( + GameConstants.PrefsKeys.MasterVolume, GameConstants.Defaults.MasterVolume)); + + if (!DecoderEnabled) return; // 테스트 — 재생 상태만 세운다(디코더 없음) + + string url = BuildUrl(definition.fileName); +#if UNITY_EDITOR || UNITY_STANDALONE + // 에디터·데스크톱에서는 파일 유무를 미리 알 수 있다 — 디코더 오류보다 읽기 쉬운 경고를 남긴다. + // Android는 StreamingAssets가 jar 안에 있어 File.Exists가 항상 false라 여기서 보지 않는다. + if (!System.IO.File.Exists(url)) + { + Debug.LogWarning($"[StoryVideo] 파일이 없다: {url} ({definition.videoId}) — 영상을 건너뛴다"); + Stop(); + return; + } +#endif + videoPlayer.url = url; + // URL 소스는 준비 전엔 트랙 수를 모른다 — 1로 못 박아야 SetTargetAudioSource가 받는다. + videoPlayer.controlledAudioTrackCount = 1; + videoPlayer.SetTargetAudioSource(0, audioSource); + videoPlayer.Prepare(); + } + + private static string BuildUrl(string fileName) + { + // Path.Combine은 Windows에서 역슬래시를 넣는데 Android jar 경로는 '/'만 받는다. + return Application.streamingAssetsPath + "/" + VideoFolder + "/" + fileName; + } + + /// + /// 재생 종료 — 모든 종료 경로가 여기로 모인다. 두 번 불려도 안전하다. + /// 대사 앞 영상이면 복구를 다 마친 뒤 마지막에 대사를 연다( — 한 번만). + /// + public void Stop() + { + if (!playing) + { + ModalUIRegistry.Unregister(this); + // 방어 — 정상 경로에선 늘 비어 있다(재생이 시작된 뒤에만 건다). 그래도 걸려 있으면 지금 풀어 대사를 연다. + FinishPrelude(); + return; + } + + playing = false; + prepared = false; + current = null; + + // 준비 중이어도 부른다 — 진행 중인 Prepare를 취소해 디코더를 닫는다(idle이면 no-op). + if (videoPlayer != null) videoPlayer.Stop(); + + if (AudioManager.Instance != null) AudioManager.Instance.SetBgmDuck(1f); + if (playerMovement != null && !restoreFrozen) playerMovement.SetFrozen(false); + ModalUIRegistry.Unregister(this); + + // **마지막에** — 콜백(대사 열기)이 다시 조작을 잠그고 모달을 건다. 우리 복구가 먼저 끝나 있어야 한다. + FinishPrelude(); + } + + private void OnPrepared(VideoPlayer source) + { + if (!playing) return; + EnsureRenderTexture((int)source.width, (int)source.height); + source.targetTexture = renderTexture; + prepared = true; + elapsed = 0f; + // 프리즈 타이머를 **실제 재생 시작** 기준으로 다시 감는다. Play()의 SetFrozen은 Prepare 시작 + // 시각이라, 준비 5초 + 영상 15초 + 여유 3초 = 23초가 AutoUnfreezeTime(20)을 넘어 검은 화면 + // 뒤에서 조작이 살아날 수 있었다. 재장전하면 최악 15 + 3 = 18초다. + if (playerMovement != null) playerMovement.SetFrozen(true); + source.Play(); + } + + private void OnReachedEnd(VideoPlayer source) + { + if (playing) Stop(); + } + + private void OnVideoError(VideoPlayer source, string message) + { + Debug.LogWarning($"[StoryVideo] 재생 실패({current?.videoId}): {message} — 영상을 건너뛴다"); + Stop(); + } + + private void EnsureRenderTexture(int width, int height) + { + if (width <= 0 || height <= 0) { width = 1280; height = 720; } + if (renderTexture != null && renderTexture.width == width && renderTexture.height == height) + return; + ReleaseRenderTexture(); + renderTexture = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32); + renderTexture.name = "StoryVideoRT"; + renderTexture.Create(); + } + + private void ReleaseRenderTexture() + { + if (renderTexture == null) return; + if (videoPlayer != null) videoPlayer.targetTexture = null; + renderTexture.Release(); + Destroy(renderTexture); + renderTexture = null; + } + + private void Update() + { + // 연출은 timeScale에 끌려다니면 안 된다(전투 슬로모션 직후 재생될 수 있다). + float dt = Time.unscaledDeltaTime; + if (!playing) + { + TickPending(dt); + return; + } + TickPlayback(dt); + } + + /// 재생 중의 한 프레임 — 준비 시간 초과·재생 초과 안전망(테스트는 시간을 넣어 직접 부른다). + private void TickPlayback(float dt) + { + if (!playing) return; + + if (!prepared) + { + prepareSeconds += dt; + if (prepareSeconds > PrepareTimeoutSeconds) + { + Debug.LogWarning($"[StoryVideo] 준비 시간 초과({current?.videoId}) — 영상을 건너뛴다"); + Stop(); + } + return; + } + + elapsed += dt; + if (current != null && elapsed > current.expectedDuration + OverrunGraceSeconds) + Stop(); + } + + private void OnGUI() + { + if (!playing) return; + + EnsureStyles(); + UIScale.Begin(); + // 모든 HUD 위에 덮는다 — 오프닝(-10000)과 같은 계열. + GUI.depth = -9000; + + Rect full = new Rect(0f, 0f, UIScale.VirtualScreenWidth, UIScale.VirtualScreenHeight); + UISurface.Flat(full, Color.black); + // 자막 띠·건너뛰기 자리는 순수 계산(StoryVideoScreenLayout) — 세로 화면에서 둘이 겹치지 않는 것을 테스트가 본다. + HudFrame frame = HudFrame.Current; + + if (prepared && renderTexture != null) + { + // 16:9 마스터 하나로 세로 화면까지 덮는다 — 가운데를 잘라 채운다(cover). + Rect uv = UIHelper.CalculateCoverUv(full, renderTexture.width, renderTexture.height); + GUI.DrawTextureWithTexCoords(full, renderTexture, uv, false); + DrawSubtitle(frame); + } + else + { + // 첫 프레임 전 — 검정 위에 작은 점 하나. 멈춘 것처럼 보이지 않게만. + float pulse = 0.35f + 0.35f * Mathf.Sin(Time.unscaledTime * 4f); + UISurface.Flat(new Rect(full.width * 0.5f - 5f, full.height * 0.5f - 5f, 10f, 10f), + new Color(1f, 1f, 1f, pulse)); + } + + // 건너뛰기 — ESC(ModalUIRegistry)와 이 탭. 모바일엔 Back키뿐이라 이 버튼이 실질 유일 수단이다. + // 가로는 오른쪽 아래, 세로는 오른쪽 위(세로에선 자막 띠가 폭을 거의 다 써서 아래에 두면 겹친다). + // 바탕은 어둡게 — 그림책 화풍의 밝은 장면 위에서는 흰 반투명 바탕(예전 0.22)이 밝은 글자와 함께 묻힌다. + Rect skip = StoryVideoScreenLayout.Skip(frame); + if (UISurface.Button(skip, "건너뛰기 ▶", new Color(0f, 0f, 0f, 0.55f), skipStyle)) + Stop(); + + UIScale.End(); + } + + private void DrawSubtitle(HudFrame frame) + { + if (current == null || !StoryVideoTimeline.TryGetCue(current.cues, elapsed, out int index)) return; + + StoryVideoCue cue = current.cues[index]; + float alpha = CutsceneTimeline.SubtitleAlpha(cue.duration, StoryVideoTimeline.CueProgress(cue, elapsed)); + + // 띠는 둥근 판(높이 120px대라 각지게 두지 않는다). 페이드는 GUI.color 알파로 곱한다 — 판 색을 알파마다 새로 구우면 + // 둥근 텍스처 캐시가 프레임마다 는다. + Rect band = StoryVideoScreenLayout.SubtitleBand(frame); + Color previous = GUI.color; + GUI.color = new Color(1f, 1f, 1f, alpha); + UISurface.Rounded(band, new Color(0f, 0f, 0f, StoryVideoScreenLayout.SubtitleBandAlpha)); + GUI.color = previous; + + // 글자 — 두 줄 칸. 길면 LabelFit이 줄인다. 밝은 장면에서 띠가 옅어지는 페이드 구간에도 읽히게 그림자 한 겹을 먼저 깐다. + Rect text = StoryVideoScreenLayout.SubtitleText(band); + Color c = subtitleStyle.normal.textColor; + subtitleStyle.normal.textColor = new Color(0f, 0f, 0f, 0.8f * alpha); + UIHelper.LabelFit(new Rect(text.x + 2f, text.y + 2f, text.width, text.height), cue.text, subtitleStyle); + subtitleStyle.normal.textColor = new Color(c.r, c.g, c.b, alpha); + UIHelper.LabelFit(text, cue.text, subtitleStyle); + subtitleStyle.normal.textColor = c; + } + + private void EnsureStyles() + { + if (stylesReady) return; + stylesReady = true; + // 아이가 읽는 자막 — 36pt 굵게, 두 줄까지(StoryVideoScreenLayout이 칸을 잡는다). + subtitleStyle = new GUIStyle(GUI.skin.label) + { + fontSize = StoryVideoScreenLayout.SubtitleFontSize, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter, + wordWrap = true + }; + subtitleStyle.normal.textColor = new Color(0.96f, 0.95f, 0.90f); + // 건너뛰기는 자막과 따로 — 자막을 키워도 버튼(240×56) 안에 남는다. + skipStyle = new GUIStyle(GUI.skin.label) + { + fontSize = StoryVideoScreenLayout.SkipFontSize, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter, + wordWrap = false + }; + skipStyle.normal.textColor = new Color(0.96f, 0.95f, 0.90f); + } + } +} diff --git a/Assets/Scripts/Story/StoryVideoDirector.cs.meta b/Assets/Scripts/Story/StoryVideoDirector.cs.meta new file mode 100644 index 0000000..2bbc2fd --- /dev/null +++ b/Assets/Scripts/Story/StoryVideoDirector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e03dfcb5b70792c46b0901849e6990aa \ No newline at end of file diff --git a/Assets/Scripts/Story/StoryVideoLibrary.cs b/Assets/Scripts/Story/StoryVideoLibrary.cs new file mode 100644 index 0000000..6d7b9e1 --- /dev/null +++ b/Assets/Scripts/Story/StoryVideoLibrary.cs @@ -0,0 +1,321 @@ +namespace InsectGame.Story +{ + /// + /// 스토리 영상 저작 — 실제 영상 파일(mp4)의 파일명·길이·자막 큐. 프로시저럴 컷신()과 + /// 의도적으로 같은 모양이다 — 저쪽은 카메라 워크를, 여기는 파일명과 자막 큐를 정의한다. + /// + /// 영상은 그림책 화풍 렌더러 Tools/Video/storybook/render.py가 그린다(편마다 v_<파일명>.py, 그림은 kit.py, + /// 소리는 sound.py). 소리는 mp4 안(AAC)에 들어 있다 — 가 AudioSource로 보내고 + /// 재생 중 월드 BGM을 낮춘다. 파일은 Assets/StreamingAssets/Video/에 둔다. 자막은 영상에 굽지 않고 여기서 얹는다. + /// + /// 렌더러가 이 파일을 정규식으로 읽는다 — 미리보기에 자막을 얹고, 샷 길이 합(DURS)이 여기 길이와 같은지 본다 + /// (story_lint 검사 25도 같은 모양을 읽는다). 그래서 정의는 아래 빌더들과 똑같은 한 문장 모양으로 적는다 — + /// 생성자에 상수·"파일.mp4"·길이f·큐 배열(StoryVideoCue(시각f, 길이f, "자막")을 new로 나열)을 차례로 넘기고 + /// 세미콜론으로 끝낸다. 길이·시각은 f 접미 숫자 리터럴만 쓴다(상수·계산식은 정규식이 못 읽는다). + /// (이 주석에 그 문장을 그대로 적지 않는 것도 같은 이유다 — 추출기가 주석을 정의로 센다.) + /// 샷 리스트·자막 원문의 단일 출처는 Docs/StoryVideos.md다 — 여기 자막을 고치면 그쪽도 함께 고친다. + /// + /// 두 자리에 붙는다: + /// + /// 대사 앞(StoryBeat.introVideoId) — 설명은 영상이 맡는다. 대상이 초등 고학년이라 "영상이 설명하고, 대사는 + /// 짧게"다. 자막은 그 장면의 사실을 한 줄씩 말하고, 뒤에 오는 대사(6줄 이하 — story_lint 검사 36)는 인물의 반응만 맡는다. + /// 이름 벽(ch6)·울타리(ch7)·그림자(종장)·되찾은 이름(결말) 네 편이다. + /// 대사 뒤(StoryBeat.videoId) — 마무리. 자막은 "그 대사가 끝난 다음 화면에 무엇이 남아야 하는가"로 쓰고 + /// 방금 들은 대사를 되풀이하지 않는다(StoryBible 7-1의 프롤로그 컷신 사고 참조). + /// + /// + /// 지킬 것: + /// - 총 길이는 15초 이하. PlayerMovement.AutoUnfreezeTime(20s)보다 4초 이상 짧아야 + /// 재생 중 조작이 살아나지 않는다(StoryVideoLibraryTests가 강제). + /// - 자막 한 줄은 24자 이하(공백·문장부호 포함) — 아이가 2~3초 안에 한 번에 읽는 길이다(같은 테스트). + /// - 자막에 「무명」을 쓰지 않는다 — 이름을 부르지 않는 것이 이 이야기의 규칙이다(StoryBible 2장). + /// 대상은 「그림자」라 부른다. + /// - 상수 선언과 TryGet의 case를 반드시 함께 넣는다. 하나만 있으면 런타임에 + /// LogWarning만 찍고 조용히 안 나온다. story_lint 검사 25가 본다. + /// - 파일명은 ID에서 vid_를 뺀 것이다(vid_ch6_wall → ch6_wall.mp4). + /// + public static class StoryVideoLibrary + { + /// 1막 개막 — 어르신에게 첫 파트너를 받은 직후(대사 뒤). cs_story_prologue를 대체한다. + public const string Ch1Prologue = "vid_ch1_prologue"; + /// ch2 — 연못에서 수첩에 숫자만 적는 사내가 처음 나타난다(대사 뒤). + public const string Ch2Watchers = "vid_ch2_watchers"; + /// ch3 — 세라 합류. 숲 바위의 이름 무늬와 「도감에 적으면 이름이 빛난다」(대사 뒤). + public const string Ch3Scholar = "vid_ch3_scholar"; + /// ch4 — 습지의 상자들. 상자 속 곤충을 처음 본다(대사 뒤). + public const string Ch4Crates = "vid_ch4_crates"; + /// ch5 — 산 정상에서 처음으로 유적이 보인다(대사 뒤). + public const string Ch5Summit = "vid_ch5_summit"; + /// + /// ch6 — 「이름 벽」. 대사 앞(ch6_secret의 introVideoId): 옛사람이 이름을 새겨 곤충을 지켰고, 이름이 바래면 + /// 곤충이 사라지고, 도감에 적으면 다시 빛난다 — 1막의 답을 영상이 설명하고 대사는 반응만 한다. 고대의 잠자리가 벽에서 돌아온다. + /// + public const string Ch6Wall = "vid_ch6_wall"; + /// + /// ch7 — 「이름 벽은 울타리였다」. 대사 앞(ch7_opening — 7장을 여는 비트): 영상 → 「지난 이야기」 카드 → 대사. + /// 바래서 난 구멍으로 그림자가 숨어들었고, 벽이 다 차자 숨은 한 칸이 드러난다. + /// + public const string Ch7Fence = "vid_ch7_fence"; + /// ch8 — 모래언덕 창고. 명부회 첫 간부 대면 뒤 창고의 규모(대사 뒤). + public const string Ch8Vault = "vid_ch8_vault"; + /// ch9 — 얼음 서고. 세라의 과거(목록을 적던 손)(대사 뒤). + public const string Ch9Archive = "vid_ch9_archive"; + /// ch10 — 잿불 갱도 붕괴. 먹이 라온을 끌어내고 장부를 덮는다(대사 뒤). + public const string Ch10Kiln = "vid_ch10_kiln"; + /// ch11 — 우듬지의 무늬와 꽃밭 온실. 비상 울타리 둘(대사 뒤). + public const string Ch11Crown = "vid_ch11_crown"; + /// + /// ch12 — 장부의 방. 하월이 길을 비킨다. 하월을 이긴 뒤(duel_chief_win)에 튼다 — 대치(ch12_confront)는 + /// "이 아이를 넘어 보게"로 끝나고 곧바로 대결이 열리므로, 비켜서는 그림이 대결 앞에 오면 이야기가 거꾸로 간다(2026-10-04). + /// + public const string Ch12Ledger = "vid_ch12_ledger"; + /// + /// 종장 — 그림자. 대사 앞(fin_unnamed — 종장을 여는 비트, 선택지가 뒤에 온다): 빼앗은 모습으로 바뀌며 + /// "이름을 줘"라고 묻는다. 이름을 주면 무슨 일이 생기는지("만약" 장면)를 보여 주어 선택의 무게를 영상이 설명한다. + /// + public const string FinShadow = "vid_fin_shadow"; + /// + /// 결말 — 이름이 돌아간다. 대사 앞(fin_seal — 그림자를 이긴 BattleWin): 전투 결과 화면이 닫힌 뒤에 + /// 뜬다(StoryDirector가 BattleWin을 결과 화면 뒤로 미룬다). 마지막 샷(사마귀·해돋이)에는 자막을 두지 않는다. + /// + public const string FinReturn = "vid_fin_return"; + /// 에필로그 — 후일담 몽타주. 1편과 수미상관(대사 뒤). + public const string FinEpilogue = "vid_fin_epilogue"; + + public static bool TryGet(string videoId, out StoryVideoDefinition def) + { + switch (videoId) + { + case Ch1Prologue: def = BuildCh1Prologue(); return true; + case Ch2Watchers: def = BuildCh2Watchers(); return true; + case Ch3Scholar: def = BuildCh3Scholar(); return true; + case Ch4Crates: def = BuildCh4Crates(); return true; + case Ch5Summit: def = BuildCh5Summit(); return true; + case Ch6Wall: def = BuildCh6Wall(); return true; + case Ch7Fence: def = BuildCh7Fence(); return true; + case Ch8Vault: def = BuildCh8Vault(); return true; + case Ch9Archive: def = BuildCh9Archive(); return true; + case Ch10Kiln: def = BuildCh10Kiln(); return true; + case Ch11Crown: def = BuildCh11Crown(); return true; + case Ch12Ledger: def = BuildCh12Ledger(); return true; + case FinShadow: def = BuildFinShadow(); return true; + case FinReturn: def = BuildFinReturn(); return true; + case FinEpilogue: def = BuildFinEpilogue(); return true; + default: def = null; return false; + } + } + + /// + /// 새벽 초원(곤충이 하나도 없다) → 풀잎 위 곤충이 하나씩 흐려져 사라짐 → 은빛 채집망을 쥔 손 → 손등에 내려앉는 작은 곤충. + /// 어르신 대사("곤충들이 하나둘 사라지고 있어")를 받아 그다음을 보여준다 — 그래서 그물을 받았고, 이 아이와 찾으러 간다. + /// + private static StoryVideoDefinition BuildCh1Prologue() + { + return new StoryVideoDefinition(Ch1Prologue, "ch1_prologue.mp4", 12f, new[] + { + new StoryVideoCue(0.8f, 2.6f, "어제 있던 곤충이 오늘은 안 보인다."), + new StoryVideoCue(3.8f, 2.6f, "하나둘, 흐려지듯 사라지고 있다."), + new StoryVideoCue(6.4f, 2.2f, "그래서 이 그물을 받았다."), + new StoryVideoCue(9.0f, 2.6f, "이 아이와 함께 찾으러 가자."), + }); + } + + /// 연못 수면 → 갈대 너머 명부회 사내가 수첩에 눈금을 그어 셈 → 돌아서 안개 속으로 사라짐. + private static StoryVideoDefinition BuildCh2Watchers() + { + return new StoryVideoDefinition(Ch2Watchers, "ch2_watchers.mp4", 10f, new[] + { + new StoryVideoCue(3.0f, 3.0f, "수첩에는 곤충 숫자만 가득했다."), + new StoryVideoCue(6.8f, 2.6f, "그는 안개 속으로 사라졌다."), + }); + } + + /// 숲 바위의 이름 무늬 → 바랜 칸을 세라의 손끝이 훑음 → 닿은 칸이 금빛으로 반짝임 → 숲 너머 유적. + private static StoryVideoDefinition BuildCh3Scholar() + { + return new StoryVideoDefinition(Ch3Scholar, "ch3_scholar.mp4", 12f, new[] + { + new StoryVideoCue(0.8f, 2.2f, "바위에 곤충 이름이 새겨져 있다."), + new StoryVideoCue(3.2f, 2.2f, "바랜 이름도 군데군데 보였다."), + new StoryVideoCue(5.8f, 2.6f, "손끝이 닿자, 이름이 반짝였다."), + new StoryVideoCue(8.9f, 2.6f, "답은 저 너머 유적에 있다."), + }); + } + + /// 안개 습지 → 반쯤 잠긴 나무 상자 줄 → 상자 틈의 큰 눈과 더듬이 → 뚜껑의 이름표와 물방울. + private static StoryVideoDefinition BuildCh4Crates() + { + return new StoryVideoDefinition(Ch4Crates, "ch4_crates.mp4", 12f, new[] + { + new StoryVideoCue(1.0f, 2.6f, "습지 곳곳에 상자가 숨겨져 있었다."), + new StoryVideoCue(6.2f, 2.6f, "틈 사이로 더듬이가 움직였다."), + new StoryVideoCue(9.2f, 2.4f, "모두 살아 있는 곤충이었다."), + }); + } + + /// 능선 위 세 사람의 뒷모습 → 발아래 구름이 갈라짐 → 안개 아래 금빛 유적 → 유적으로 느린 줌. + private static StoryVideoDefinition BuildCh5Summit() + { + return new StoryVideoDefinition(Ch5Summit, "ch5_summit.mp4", 12f, new[] + { + new StoryVideoCue(1.0f, 2.6f, "지나온 길이 모두 내려다보였다."), + new StoryVideoCue(5.6f, 2.8f, "안개 너머로 고대 유적이 빛난다."), + new StoryVideoCue(9.0f, 2.6f, "곤충 이름의 비밀이 저기 있다."), + }); + } + + /// + /// 대사 앞. 신전의 거대한 이름 벽(아래에 세 사람이 작게) → 회상: 옛사람이 칸에 이름을 새기면 나비가 날아오름 → + /// 한 칸이 바래고 풀잎 위 딱정벌레가 사라짐 → 도감의 펜에서 금빛 실이 날아가 빈칸이 다시 켜짐 → 고대의 잠자리가 벽에서 돌아옴. + /// 뒤 대사는 "보세요! 사라졌던 고대의 잠자리예요!"로 이 마지막 샷을 받는다. + /// + private static StoryVideoDefinition BuildCh6Wall() + { + return new StoryVideoDefinition(Ch6Wall, "ch6_wall.mp4", 15f, new[] + { + new StoryVideoCue(0.6f, 2.2f, "벽 가득, 곤충 이름이 새겨져 있다."), + new StoryVideoCue(3.0f, 2.8f, "옛사람들은 이름을 새겨 곤충을 지켰다."), + new StoryVideoCue(6.1f, 2.6f, "이름이 바래면, 그 곤충이 사라졌다."), + new StoryVideoCue(9.1f, 2.6f, "도감에 적으면, 이름이 다시 빛난다."), + new StoryVideoCue(12.1f, 2.6f, "그리고 사라졌던 곤충이 돌아온다!"), + }); + } + + /// + /// 대사 앞(7장을 여는 비트 — 영상 → 「지난 이야기」 카드 → 대사). 이름 벽이 울타리로 보이고, 바깥의 그림자를 막는다 → + /// 이름이 바래면 말뚝 자리에 구멍 → 그림자가 그 구멍으로 숨어듦 → 벽이 다 차자 숨은 한 칸이 드러남. + /// 뒤 대사는 "방금 봤어? 빈칸이 움직였어!"로 받는다. + /// + private static StoryVideoDefinition BuildCh7Fence() + { + return new StoryVideoDefinition(Ch7Fence, "ch7_fence.mp4", 14f, new[] + { + new StoryVideoCue(0.3f, 2.3f, "이름 벽은 사실 울타리였다."), + new StoryVideoCue(2.8f, 2.5f, "바깥의 그림자를 막는 울타리."), + new StoryVideoCue(5.4f, 2.5f, "이름이 바래면, 구멍이 난다."), + new StoryVideoCue(8.2f, 2.6f, "그림자는 그 구멍으로 숨어들었다."), + new StoryVideoCue(11.2f, 2.6f, "벽이 다 차자, 숨은 한 칸이 드러났다."), + }); + } + + /// + /// 모래에 반쯤 묻힌 창고 입구 → 천장까지 쌓인 상자 → 뚜껑이 열린 빈 상자들 → 상자 하나가 조용히 놓여 있음. + /// 둘째 큐는 사양(2.6초)보다 0.2초 짧다 — 사양대로면 셋째 큐(6.6초)와 겹쳐 앞 자막이 뒤 자막을 가린다(겹침은 테스트가 금지). + /// + private static StoryVideoDefinition BuildCh8Vault() + { + return new StoryVideoDefinition(Ch8Vault, "ch8_vault.mp4", 12f, new[] + { + new StoryVideoCue(1.0f, 2.6f, "창고 안은 끝이 보이지 않았다."), + new StoryVideoCue(4.2f, 2.4f, "천장까지 상자가 쌓여 있었다."), + new StoryVideoCue(6.6f, 2.4f, "어떤 상자는 텅 비어 있었다."), + new StoryVideoCue(9.4f, 2.2f, "이 상자는 너무 조용했다."), + }); + } + + /// + /// 얼음 속에 멈춘 곤충들 → 세라와 저울이 마주 섬 → 얼음에 비친 회상: 어린 세라의 손이 목록을 적음 → + /// 지금의 손이 그 반영에 닿다가 멈춤. 세라의 대답은 아직 없다(대결 뒤로 남긴다). + /// + private static StoryVideoDefinition BuildCh9Archive() + { + return new StoryVideoDefinition(Ch9Archive, "ch9_archive.mp4", 14f, new[] + { + new StoryVideoCue(1.0f, 2.6f, "얼음 속에 곤충들이 멈춰 있다."), + new StoryVideoCue(3.6f, 2.2f, "세라와 저울이 마주 섰다."), + new StoryVideoCue(6.0f, 3.0f, "예전에 이 목록을 적은 건 세라였다."), + new StoryVideoCue(10.2f, 3.0f, "세라는 아직 대답하지 못했다."), + }); + } + + /// + /// 잿불 갱도 → 들보가 무너짐 → 먹의 검은 소매가 돌무더기 속 라온의 팔을 끌어냄 → 재 위의 상자 하나 → 먹이 장부를 덮음. + /// + private static StoryVideoDefinition BuildCh10Kiln() + { + return new StoryVideoDefinition(Ch10Kiln, "ch10_kiln.mp4", 15f, new[] + { + new StoryVideoCue(2.8f, 2.6f, "천장이 무너져 내렸다."), + new StoryVideoCue(6.2f, 3.0f, "먹이 라온을 끌어냈다."), + new StoryVideoCue(9.8f, 2.4f, "꺼낸 상자는 하나뿐이었다."), + new StoryVideoCue(12.2f, 2.4f, "먹은 장부를 덮었다."), + }); + } + + /// + /// 거대한 나무 꼭대기 → 나무껍질의 이름 무늬(ch3 바위와 같은 무늬) → 멀리 꽃밭 온실 → 둘이 한 화면에서 금빛으로. + /// 둘째 큐는 사양(2.4초)보다 0.2초 짧다 — 사양대로면 셋째 큐(6.0초)와 겹친다. 줄인 끝(6.0초)이 둘째 샷의 끝과 같다. + /// + private static StoryVideoDefinition BuildCh11Crown() + { + return new StoryVideoDefinition(Ch11Crown, "ch11_crown.mp4", 12f, new[] + { + new StoryVideoCue(1.0f, 2.4f, "나무 꼭대기에도 이름이 있었다."), + new StoryVideoCue(3.8f, 2.2f, "숲 바위와 같은 무늬였다."), + new StoryVideoCue(6.0f, 2.6f, "저 멀리 꽃밭 온실에도."), + new StoryVideoCue(9.0f, 2.6f, "둘 다 비상 울타리였다."), + }); + } + + /// 빈 석판이 둘러선 방 → 벽면 가득한 장부 선반 → 절반이 먼지에 바램 → 하월이 비켜서고 안쪽 어둠에 노란 눈 둘. + private static StoryVideoDefinition BuildCh12Ledger() + { + return new StoryVideoDefinition(Ch12Ledger, "ch12_ledger.mp4", 14f, new[] + { + new StoryVideoCue(1.0f, 2.6f, "삼십 년 동안 쓴 장부들."), + new StoryVideoCue(4.2f, 2.6f, "그 속 곤충의 절반이 죽었다."), + new StoryVideoCue(7.4f, 2.6f, "지키려다, 오히려 잃었다."), + // 직전 대사가 "길을 비켜 주마. 안쪽에서 그림자가 기다린다."라 같은 말을 되풀이하지 않는다. + new StoryVideoCue(10.6f, 2.6f, "이제 안쪽으로 가는 길이 열렸다."), + }); + } + + /// + /// 대사 앞(종장을 여는 비트 — 영상 → 「지난 이야기」 카드 → 대사 → 선택지). 울타리 맨 끝 빈칸의 그림자 → + /// 사마귀·나비·반딧불이로 모습을 바꿈 → 다가와 빈 이름표를 내밂 → "만약" 장면: 이름을 받은 그림자가 울타리 안을 덮음 → + /// 다시 지금: 도감의 곤충들이 줄지어 빛나고 마지막 한 칸만 비어 있다. + /// + private static StoryVideoDefinition BuildFinShadow() + { + return new StoryVideoDefinition(FinShadow, "fin_shadow.mp4", 14.4f, new[] + { + new StoryVideoCue(0.5f, 2.2f, "울타리의 맨 끝, 마지막 빈칸."), + new StoryVideoCue(2.8f, 2.6f, "그림자는 빼앗은 모습으로 바뀐다."), + new StoryVideoCue(5.6f, 2.4f, "그리고 묻는다. '이름을 줘.'"), + new StoryVideoCue(8.4f, 2.8f, "이름을 받으면, 울타리 안에 자리를 얻는다."), + new StoryVideoCue(11.4f, 2.6f, "그러니 절대, 이름을 주지 않는다."), + }); + } + + /// + /// 대사 앞(그림자를 이긴 뒤 — 전투 결과 화면이 닫힌 다음). 빌린 모습들이 빛으로 빠져나옴 → 빛이 들판으로 흩어지고 + /// 말뚝 이름표가 하나씩 켜짐 → 작아진 그림자가 울타리 밖 안개로 밀려남 → 마지막 구멍에 새 말뚝, 들판에 색이 돌아옴 → + /// 풀잎 위 초록 사마귀와 해돋이(자막 없음). 뒤 대사는 "마지막 칸이 메워졌어요!"로 받는다. + /// + private static StoryVideoDefinition BuildFinReturn() + { + return new StoryVideoDefinition(FinReturn, "fin_return.mp4", 14.6f, new[] + { + new StoryVideoCue(0.5f, 2.2f, "빼앗긴 이름들이 빠져나온다."), + new StoryVideoCue(2.9f, 2.6f, "이름은 하나씩 주인에게 돌아갔다."), + new StoryVideoCue(5.9f, 2.6f, "그림자는 울타리 밖으로 밀려났다."), + new StoryVideoCue(8.9f, 2.6f, "마지막 구멍까지, 이제 다 메워졌다."), + }); + } + + /// + /// 후일담 몽타주 — 하월이 들판에서 딱정벌레를 놓아줌 / 라온과 어르신이 초원을 걸음 / 숫자 칸 없는 새 장부에 펜 / + /// 꽃밭 속 빈 석판 하나 / 새벽 초원, 그물 없이 뻗은 손에 작은 곤충. 마지막 컷에는 자막을 두지 않는다. + /// + private static StoryVideoDefinition BuildFinEpilogue() + { + return new StoryVideoDefinition(FinEpilogue, "fin_epilogue.mp4", 15f, new[] + { + new StoryVideoCue(1.0f, 2.6f, "하월은 곤충을 하나씩 놓아주었다."), + new StoryVideoCue(4.0f, 2.6f, "라온은 어르신 곁으로 돌아갔다."), + new StoryVideoCue(7.0f, 2.6f, "새 장부에는 숫자 칸이 없다."), + new StoryVideoCue(10.0f, 2.6f, "빈칸은 또 생길지도 모른다."), + }); + } + } +} diff --git a/Assets/Scripts/Story/StoryVideoLibrary.cs.meta b/Assets/Scripts/Story/StoryVideoLibrary.cs.meta new file mode 100644 index 0000000..933b9bd --- /dev/null +++ b/Assets/Scripts/Story/StoryVideoLibrary.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3a332a3d9bd07ca42a4a3cac24258f58 \ No newline at end of file diff --git a/Assets/Scripts/UI/AccountSettingsUI.cs b/Assets/Scripts/UI/AccountSettingsUI.cs index f528e97..7b9ce16 100644 --- a/Assets/Scripts/UI/AccountSettingsUI.cs +++ b/Assets/Scripts/UI/AccountSettingsUI.cs @@ -20,12 +20,27 @@ public class AccountSettingsUI : MonoBehaviour, IModalUI private bool confirmLogout; private bool processing; private IOpeningReplayService openingReplayService; + private InsectGame.Story.DreamPrologueDirector dreamPrologue; public void AutoWire(IOpeningReplayService replayService) { openingReplayService = replayService; } + public void AutoWire(InsectGame.Story.DreamPrologueDirector prologue) + { + dreamPrologue = prologue; + } + + public void OpenSettings() + { + if (ModalUIRegistry.IsAnyOpenExcept(typeof(AccountSettingsUI))) return; + confirmDelete = false; + confirmLogout = false; + processing = false; + message = ""; + SetOpen(true); + } public bool IsOpen => open; public void CloseModal() { @@ -62,12 +77,40 @@ private void OnDisable() { if (AuthManager.Instance != null) AuthManager.Instance.AccountDeleted -= OnAccountDeleted; - ModalUIRegistry.Unregister(this); + CloseModal(); } private void Update() { - if (messageTimer > 0f) messageTimer -= Time.deltaTime; + if (messageTimer <= 0f) return; + // 필드 위에 뜰 때는 가운데 무대(HudStage)의 차례를 기다린다 — 기다리는 동안은 시간도 멈춘다. + if (MessageOnField() && !HudStage.Request(HudStageItem.AccountMessage)) return; + messageTimer -= Time.deltaTime; + } + + /// + /// 알림이 필드 HUD 위에 뜨는가 — 설정 창이 닫혀 있고 다른 창도 없고 꿈도 아닐 때. 창 위에서는 예전 자리(가운데보다 조금 위)에 뜬다. + /// + private bool MessageOnField() + { + return LoggedIn() && !open && !ModalUIRegistry.IsAnyOpen() && !InsectGame.Core.DreamPrologueState.Active; + } + + public const float OpenButtonWidth = 116f; + public const float OpenButtonHeight = 46f; + public const float MessageWidth = 560f; + public const float MessageHeight = 54f; + + /// 우하단 "설정" 버튼의 자리 — 순수 계산(제스처바 + 세로 마진 위). 잡기 버튼이 이 위로 비켜 선다. + public static Rect OpenButtonRect(HudFrame f) + { + return f.BottomPanel(OpenButtonWidth, OpenButtonHeight, UISafeLayout.HAlign.Right); + } + + /// 필드 위 계정 알림의 자리 — 가운데 무대의 차례 항목(). + public static Rect FieldMessageRect(HudFrame f) + { + return HudStage.Place(f, HudStageItem.AccountMessage, MessageWidth, MessageHeight); } private void OnAccountDeleted(bool success, string error) @@ -124,6 +167,8 @@ private void DrawScaledGUI() DrawMessage(); return; } + // 「챔피언의 꿈」 동안은 설정 버튼도 숨긴다 — 꿈속에서 열면 이동이 묶이고 꿈 밖 메뉴가 비친다. + if (!open && InsectGame.Core.DreamPrologueState.Active) return; EnsureStyles(); if (open) DrawPanel(); else DrawOpenButton(); @@ -133,8 +178,9 @@ private void DrawScaledGUI() private void DrawOpenButton() { // 우측 하단 앵커 — 제스처바 + 세로 마진 위로. - Rect btn = UISafeLayout.BottomPanel(116f, 46f, UISafeLayout.HAlign.Right); - if (GUI.Button(btn, "계정", openBtnStyle)) + Rect btn = OpenButtonRect(HudFrame.Current); + FieldHudInput.RegisterBlockingRect(btn); + if (GUI.Button(btn, "설정", openBtnStyle)) { confirmDelete = false; confirmLogout = false; @@ -155,14 +201,14 @@ private void DrawPanel() GUI.DrawTexture(new Rect(0, 0, screenWidth, screenHeight), Texture2D.whiteTexture); GUI.color = Color.white; - Rect panel = UISafeLayout.CenteredPanel(660f, (confirmDelete || confirmLogout) ? 440f : 448f); + Rect panel = UISafeLayout.CenteredPanel(660f, (confirmDelete || confirmLogout) ? 440f : 744f); float pw = panel.width; float ph = panel.height; float px = panel.x; float py = panel.y; - GUI.Box(new Rect(px, py, pw, ph), "", panelStyle); + UISurface.Card(panel); - GUI.Label(new Rect(px, py + 24f, pw, 46f), "계정", titleStyle); + GUI.Label(new Rect(px, py + 24f, pw, 46f), "설정 · 계정", titleStyle); GUI.Label(new Rect(px + 40f, py + 80f, pw - 80f, 64f), AccountLabel(), infoStyle); float cx = px + 40f; @@ -171,6 +217,15 @@ private void DrawPanel() if (!confirmDelete && !confirmLogout) { + if (UISurface.Button(new Rect(cx, y, cw, 56f), $"전투 속도 {BattlePresentation.Speed:0}배", UITheme.Instance.surfaceRaised, btnGrayStyle)) + BattlePresentation.Speed = BattlePresentation.Speed < 1.5f ? 2f : 1f; + y += 68f; + if (UISurface.Button(new Rect(cx, y, cw, 56f), "화면 흔들림 줄이기 " + (BattlePresentation.ReducedMotion ? "켜짐" : "꺼짐"), UITheme.Instance.surfaceRaised, btnGrayStyle)) + BattlePresentation.ReducedMotion = !BattlePresentation.ReducedMotion; + y += 68f; + if (UISurface.Button(new Rect(cx, y, cw, 56f), "섬광 줄이기 " + (BattlePresentation.ReducedFlashes ? "켜짐" : "꺼짐"), UITheme.Instance.surfaceRaised, btnGrayStyle)) + BattlePresentation.ReducedFlashes = !BattlePresentation.ReducedFlashes; + y += 68f; bool wasEnabled = GUI.enabled; GUI.enabled = wasEnabled && openingReplayService != null && openingReplayService.CanReplay; if (GUI.Button(new Rect(cx, y, cw, 56f), "오프닝 다시 보기", btnGrayStyle)) @@ -182,6 +237,21 @@ private void DrawPanel() GUI.enabled = wasEnabled; y += 68f; + GUI.enabled = wasEnabled && dreamPrologue != null && dreamPrologue.CanReplayIgnoring(typeof(AccountSettingsUI)); + if (GUI.Button(new Rect(cx, y, cw, 56f), "챔피언의 꿈 다시 보기", btnGrayStyle)) + { + GUI.enabled = wasEnabled; + SetOpen(false); // 설정이 닫혀야 꿈이 시작할 수 있다(모달이 떠 있으면 조건이 막힌다) + if (dreamPrologue.TryReplay()) return; + SetOpen(true); + message = "지금은 다시 볼 수 없습니다. 잠시 후 다시 시도해주세요."; + messageError = true; + messageTimer = 4f; + return; + } + GUI.enabled = wasEnabled; + y += 68f; + if (GUI.Button(new Rect(cx, y, cw, 56f), "로그아웃", btnGrayStyle)) { // 게스트는 refresh token이 유일한 재진입 수단이다. 로그아웃하면 그 토큰이 @@ -324,17 +394,28 @@ private void DrawMessage() { if (messageTimer <= 0f || string.IsNullOrEmpty(message)) return; EnsureStyles(); - float w = Mathf.Min(560f, UIScale.ContentWidth() * 0.7f); - float h = 54f; - float x = UIScale.VirtualSafeLeft + (UIScale.ContentWidth(0f) - w) * 0.5f; - // 안전 영역 중앙보다 200 위 — 마진 위로는 넘지 않는다. - float y = Mathf.Max(UISafeLayout.ContentTop, UISafeLayout.CenteredY(h) - 200f); + float w, h, x, y; + if (MessageOnField()) + { + // 필드 위 — 가운데 무대에 선다(예전 자리 가운데−200은 데스크톱 퀘스트 알림·포획 결과 카드와 한 자리였다). + Rect r = FieldMessageRect(HudFrame.Current); + if (!HudStage.Request(HudStageItem.AccountMessage, r)) return; + w = r.width; h = r.height; x = r.x; y = r.y; + } + else + { + w = Mathf.Min(MessageWidth, UIScale.ContentWidth() * 0.7f); + h = MessageHeight; + x = UIScale.VirtualSafeLeft + (UIScale.ContentWidth(0f) - w) * 0.5f; + // 안전 영역 중앙보다 200 위 — 마진 위로는 넘지 않는다. + y = Mathf.Max(UISafeLayout.ContentTop, UISafeLayout.CenteredY(h) - 200f); + } GUI.color = messageError ? new Color(0.35f, 0.08f, 0.08f, 0.92f) : new Color(0.08f, 0.25f, 0.12f, 0.92f); GUI.DrawTexture(new Rect(x, y, w, h), Texture2D.whiteTexture); GUI.color = Color.white; msgStyle.normal.textColor = messageError ? new Color(1f, 0.6f, 0.6f) : new Color(0.7f, 1f, 0.75f); - GUI.Label(new Rect(x + 12f, y, w - 24f, h), message, msgStyle); + UIHelper.LabelFit(new Rect(x + 12f, y, w - 24f, h), message, msgStyle); } private static void ReloadScene() diff --git a/Assets/Scripts/UI/BadgeArt.cs b/Assets/Scripts/UI/BadgeArt.cs new file mode 100644 index 0000000..7a02ffe --- /dev/null +++ b/Assets/Scripts/UI/BadgeArt.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 수문장 배지 그림 — Resources/UI/Badges/의 PNG를 한 번 읽어 캐시하고 그린다. + /// 획득 연출()과 배지 케이스()가 함께 쓴다. + /// + /// 그림은 Tools/Badges/guardian_badges.py가 굽는다(금속 테두리·법랑·양각 문양을 4배 슈퍼샘플로). + /// IMGUI 도형으로 그려 보는 것보다 훨씬 낫다 — 곡면 조명과 양각은 사각형·원판 조합으로 흉내가 안 난다. + /// + /// 그림이 없으면(빌드에서 빠짐 등) 원판으로 대신 그려 빈칸이 되지 않게 한다 — 예외도 경고도 없이 + /// 사라지면 "배지를 얻었는데 아무것도 안 보인다"가 된다. + /// + public static class BadgeArt + { + private static readonly Dictionary cache = new Dictionary(); + private static Texture2D rays; + private static Texture2D glow; + private static Texture2D sparkle; + private static bool fxLoaded; + + public static Texture2D Get(string regionId) + { + if (string.IsNullOrEmpty(regionId)) return null; + if (cache.TryGetValue(regionId, out Texture2D tex)) return tex; + tex = Resources.Load(GuardianBadges.ArtPath(regionId)); + if (tex == null) Debug.LogWarning($"[Badge] 배지 그림 없음: {GuardianBadges.ArtPath(regionId)}"); + cache[regionId] = tex; // null도 캐시한다 — 매 프레임 Resources.Load를 두드리지 않게 + return tex; + } + + public static Texture2D Rays { get { EnsureFx(); return rays; } } + public static Texture2D Glow { get { EnsureFx(); return glow; } } + public static Texture2D Sparkle { get { EnsureFx(); return sparkle; } } + + private static void EnsureFx() + { + if (fxLoaded) return; + fxLoaded = true; + rays = Resources.Load(GuardianBadges.ArtFolder + "badge_rays"); + glow = Resources.Load(GuardianBadges.ArtFolder + "badge_glow"); + sparkle = Resources.Load(GuardianBadges.ArtFolder + "badge_sparkle"); + } + + /// + /// 아직 못 얻은 배지의 틴트 — 모양과 문양의 윤곽만 비친다("저 자리에 무언가 있다"). + /// 색을 새로 박지 않고 테마 테두리색에서 파생한다(rules/ui-layout.md). + /// + public static Color LockedTint + { + get + { + Color b = UITheme.Instance.surfaceBorder; + return new Color(b.r * 0.42f, b.g * 0.42f, b.b * 0.44f, 0.92f); + } + } + + /// 배지를 그린다. 가 아니면 어둡게. + public static void Draw(Rect rect, string regionId, bool earned, float alpha = 1f) + { + Texture2D tex = Get(regionId); + Color prev = GUI.color; + Color tint = earned ? Color.white : LockedTint; + GUI.color = new Color(tint.r * prev.r, tint.g * prev.g, tint.b * prev.b, tint.a * prev.a * alpha); + if (tex != null) + { + GUI.DrawTexture(rect, tex, ScaleMode.ScaleToFit, true); + } + else + { + float inset = rect.width * 0.1f; + UIShapes.Ellipse(new Rect(rect.x + inset, rect.y + inset, rect.width - inset * 2f, rect.height - inset * 2f), + GUI.color * UITheme.Instance.accentAmber); + } + GUI.color = prev; + } + + /// + /// 가운데를 축으로 돌려 그린다. GUIUtility.RotateAroundPivot를 쓰지 않는 이유: + /// 그건 피벗을 행렬 적용 전 좌표로 받아 회전을 행렬 뒤에 곱하므로, UIScale의 + /// 배율이 1이 아닌 화면에서 피벗이 어긋나 빛살이 배지 둘레를 떠돈다. 피벗을 화면 좌표로 옮겨 곱한다. + /// + public static void DrawRotated(Rect rect, Texture tex, float degrees, Color color) + { + if (tex == null) return; + Matrix4x4 saved = GUI.matrix; + Vector3 pivot = saved.MultiplyPoint3x4(new Vector3(rect.center.x, rect.center.y, 0f)); + GUI.matrix = Matrix4x4.TRS(pivot, Quaternion.Euler(0f, 0f, degrees), Vector3.one) + * Matrix4x4.TRS(-pivot, Quaternion.identity, Vector3.one) + * saved; + Color prev = GUI.color; + GUI.color = color; + GUI.DrawTexture(rect, tex, ScaleMode.StretchToFill, true); + GUI.color = prev; + GUI.matrix = saved; + } + + /// 텍스처를 가운데 정렬로 그린다(빛·반짝임 — 흰 그림에 색을 곱한다). + public static void DrawCentered(Texture tex, Vector2 center, float size, Color color) + { + if (tex == null || size <= 0f) return; + Color prev = GUI.color; + GUI.color = color; + GUI.DrawTexture(new Rect(center.x - size * 0.5f, center.y - size * 0.5f, size, size), tex, + ScaleMode.StretchToFill, true); + GUI.color = prev; + } + } +} diff --git a/Assets/Scripts/UI/BadgeArt.cs.meta b/Assets/Scripts/UI/BadgeArt.cs.meta new file mode 100644 index 0000000..416dccf --- /dev/null +++ b/Assets/Scripts/UI/BadgeArt.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9674c3b6577086f4796239d46b2ef141 \ No newline at end of file diff --git a/Assets/Scripts/UI/BadgeCaseUI.cs b/Assets/Scripts/UI/BadgeCaseUI.cs new file mode 100644 index 0000000..2c6b4d4 --- /dev/null +++ b/Assets/Scripts/UI/BadgeCaseUI.cs @@ -0,0 +1,397 @@ +using System.Text; +using InsectGame.Core; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 배지 케이스 — 수문장 배지 13개의 진열판, 고른 배지의 상세, 그리고 4·8·13개 이정표 보상. + /// 퀵메뉴 [배지](K)로 연다. + /// + /// 못 얻은 배지도 자리는 보인다(어둡게) — 앞으로 무엇이 남았는지가 모으는 동기다. 다만 아직 못 가 본 + /// 땅의 이름은 가린다("???") — 2막 리전 이름("이름 없는 자리")이 1막에서 스포일러가 된다. + /// + /// 이정표는 대개 획득 연출이 바로 지급하지만, 이 기능 전에 배지를 모아 둔 세이브는 새 배지를 더 얻지 않으므로 + /// 여기 [받기]가 유일한 수령 경로다(). + /// + public class BadgeCaseUI : MonoBehaviour, IModalUI + { + private GuardianBadgeService badges; + private RegionManager regionManager; + private ItemDatabase itemDatabase; + + private bool isOpen; + private int selected = -1; + + private GuardianBadges.Badge[] table; + private GuardianBadges.Milestone[] milestones; + // 열 때 한 번 굽는 표시값(OnGUI 할당 방지). 수령·클라우드 반영으로 바뀌면 Refresh가 다시 굽는다. + private readonly bool[] earned = new bool[GuardianBadges.Total]; + private readonly bool[] visited = new bool[GuardianBadges.Total]; + private readonly string[] cellLabel = new string[GuardianBadges.Total]; + private string[] milestoneHeader; + private string[] milestoneReward; + private string[] milestoneProgress; + private int earnedCount; + private string subtitle; + private string detailName; + private string detailGuardian; + private string detailFlavor; + private string toast; + private float toastUntil; + + private bool stylesReady; + private GUIStyle cellStyle; + private GUIStyle detailNameStyle; + private GUIStyle detailSubStyle; + private GUIStyle detailFlavorStyle; + private GUIStyle msHeadStyle; + private GUIStyle msRewardStyle; + private GUIStyle msProgressStyle; + private GUIStyle buttonStyle; + private GUIStyle toastStyle; + + public bool IsOpen => isOpen; + + /// 받을 수 있는 이정표 수 — 퀵메뉴가 알림 점을 찍는다. + public int ClaimableCount => badges != null ? badges.ClaimableCount : 0; + + public void AutoWire(GuardianBadgeService service, RegionManager region, ItemDatabase items) + { + if (badges == null) badges = service; + if (regionManager == null) regionManager = region; + if (itemDatabase == null) itemDatabase = items; + } + + public void Toggle() + { + if (isOpen) + { + CloseModal(); + return; + } + isOpen = true; + Refresh(); + // 처음엔 가장 최근에 얻은(표 순서로 가장 뒤) 배지를 고른다 — 방금 얻은 것을 보러 오는 게 보통이다. + selected = 0; + for (int i = 0; i < earned.Length; i++) if (earned[i]) selected = i; + BuildDetail(); + toast = null; + ModalUIRegistry.Register(this); + } + + public void CloseModal() + { + isOpen = false; + ModalUIRegistry.Unregister(this); + } + + private void OnDisable() + { + // isOpen을 남겨 두면 레지스트리엔 없는데 열린 것으로 아는 상태가 된다(StoryJournalUI와 같은 이유). + isOpen = false; + ModalUIRegistry.Unregister(this); + } + + // ── 표시값 ── + + private void Refresh() + { + if (table == null) table = GuardianBadges.All(); + if (milestones == null) + { + milestones = GuardianBadges.AllMilestones(); + milestoneHeader = new string[milestones.Length]; + milestoneReward = new string[milestones.Length]; + milestoneProgress = new string[milestones.Length]; + for (int i = 0; i < milestones.Length; i++) + { + milestoneHeader[i] = $"배지 {milestones[i].count}개 · {milestones[i].title}"; + milestoneReward[i] = RewardText(milestones[i]); + } + } + + earnedCount = 0; + for (int i = 0; i < table.Length; i++) + { + earned[i] = badges != null && badges.IsEarned(table[i].regionId); + RegionData region = regionManager != null ? regionManager.GetRegionById(table[i].regionId) : null; + visited[i] = earned[i] || (regionManager != null && regionManager.IsRegionAccessible(region)); + if (earned[i]) earnedCount++; + cellLabel[i] = earned[i] ? table[i].name : (visited[i] && region != null ? region.displayName : "???"); + } + for (int i = 0; i < milestones.Length; i++) + milestoneProgress[i] = $"{Mathf.Min(earnedCount, milestones[i].count)} / {milestones[i].count}"; + subtitle = $"수문장 배지 {earnedCount} / {GuardianBadges.Total}"; + } + + private void BuildDetail() + { + if (table == null || selected < 0 || selected >= table.Length) return; + GuardianBadges.Badge b = table[selected]; + RegionData region = regionManager != null ? regionManager.GetRegionById(b.regionId) : null; + if (earned[selected]) + { + detailName = b.name; + detailGuardian = region != null ? $"{region.displayName} · {region.guardianDisplayName} · Lv.{region.guardianLevel}" : string.Empty; + detailFlavor = b.flavor; + } + else if (visited[selected] && region != null) + { + detailName = "아직 얻지 못한 배지"; + detailGuardian = $"{region.displayName} · 수문장 Lv.{region.guardianLevel}"; + detailFlavor = "이 땅의 수문장이 길을 내주면 얻는다."; + } + else + { + detailName = "아직 얻지 못한 배지"; + detailGuardian = "아직 가 보지 못한 땅"; + detailFlavor = "길이 열리면 이 자리에 무엇이 오는지 알게 된다."; + } + } + + private string RewardText(GuardianBadges.Milestone m) + { + if (m.rewards == null) return string.Empty; + StringBuilder sb = new StringBuilder(); + foreach (GuardianBadges.Reward r in m.rewards) + { + ItemData item = itemDatabase != null ? itemDatabase.FindById(r.itemId) : null; + if (item == null || string.IsNullOrEmpty(item.displayName)) continue; // 원문 ID는 내보내지 않는다 + if (sb.Length > 0) sb.Append(" · "); + sb.Append(item.displayName).Append(" ×").Append(r.count); + } + return sb.ToString(); + } + + private void Claim(int index) + { + if (badges == null || index < 0 || index >= milestones.Length) return; + if (!badges.TryClaim(milestones[index].count)) return; + if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.LevelUp); + toast = milestoneReward[index].Length > 0 ? "받았다! " + milestoneReward[index] : "보상을 받았다!"; + toastUntil = Time.unscaledTime + 3f; + Refresh(); + } + + // ── 그리기 ── + + private void OnGUI() + { + if (!isOpen) return; + int prevDepth = GUI.depth; + GUI.depth = -10; + UIScale.Begin(); + EnsureStyles(); + DrawPanel(); + UIScale.End(); + GUI.depth = prevDepth; + } + + private void EnsureStyles() + { + if (stylesReady) return; + stylesReady = true; + cellStyle = new GUIStyle(GUI.skin.label) { fontSize = 24, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + detailNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 46, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + detailSubStyle = new GUIStyle(GUI.skin.label) { fontSize = 26, alignment = TextAnchor.MiddleLeft, wordWrap = true }; + detailFlavorStyle = new GUIStyle(GUI.skin.label) { fontSize = 27, fontStyle = FontStyle.Italic, alignment = TextAnchor.UpperLeft, wordWrap = true }; + msHeadStyle = new GUIStyle(GUI.skin.label) { fontSize = 26, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + msRewardStyle = new GUIStyle(GUI.skin.label) { fontSize = 23, alignment = TextAnchor.MiddleLeft, wordWrap = true }; + msProgressStyle = new GUIStyle(GUI.skin.label) { fontSize = 26, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + buttonStyle = new GUIStyle(GUI.skin.label) { fontSize = 26, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + toastStyle = new GUIStyle(GUI.skin.label) { fontSize = 24, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, wordWrap = true }; + } + + private void DrawPanel() + { + UITheme theme = UITheme.Instance; + bool portrait = UIScale.IsPortrait; + UISurface.Dim(0.65f); + Rect panel = portrait ? UISafeLayout.CenteredPanel(1020f, 1780f) : UISafeLayout.CenteredPanel(1600f, 960f); + UISurface.Card(panel, theme.panelBg, theme.surfaceBorder); + if (UISurface.Header(new Rect(panel.x + 3f, panel.y + 3f, panel.width - 6f, 84f), "배지 케이스", subtitle)) + { + CloseModal(); + return; + } + + const float pad = 24f, gap = 16f; + Rect body = new Rect(panel.x + pad, panel.y + 104f, panel.width - pad * 2f, Mathf.Max(1f, panel.height - 104f - pad)); + + if (portrait) + { + float detailH = 330f; + float msH = Mathf.Min(340f, body.height * 0.22f); + Rect detail = new Rect(body.x, body.y, body.width, detailH); + Rect ms = new Rect(body.x, body.yMax - msH, body.width, msH); + Rect grid = new Rect(body.x, detail.yMax + gap, body.width, Mathf.Max(1f, ms.y - gap - detail.yMax - gap)); + DrawDetail(detail, true); + DrawGrid(grid, 4); + DrawMilestones(ms, true); + } + else + { + float detailW = Mathf.Min(560f, body.width * 0.36f); + float msH = 176f; + Rect detail = new Rect(body.xMax - detailW, body.y, detailW, body.height); + float leftW = body.width - detailW - gap; + Rect ms = new Rect(body.x, body.yMax - msH, leftW, msH); + Rect grid = new Rect(body.x, body.y, leftW, Mathf.Max(1f, ms.y - gap - body.y)); + DrawGrid(grid, 5); + DrawMilestones(ms, false); + DrawDetail(detail, false); + } + + if (!string.IsNullOrEmpty(toast) && Time.unscaledTime < toastUntil) + { + float w = Mathf.Min(900f, panel.width - 80f); + Rect tr = new Rect(panel.center.x - w * 0.5f, panel.yMax - 96f, w, 72f); + UISurface.Card(tr, theme.surfaceRaised, theme.accentMint); + toastStyle.normal.textColor = theme.textPrimary; + UIHelper.LabelFit(new Rect(tr.x + 16f, tr.y + 6f, tr.width - 32f, tr.height - 12f), toast, toastStyle); + } + } + + private void DrawGrid(Rect area, int cols) + { + UITheme theme = UITheme.Instance; + int rows = (GuardianBadges.Total + cols - 1) / cols; + const float gap = 12f; + float cellW = (area.width - gap * (cols - 1)) / cols; + float cellH = Mathf.Max(UIScale.MinTouchHeight, (area.height - gap * (rows - 1)) / rows); + for (int i = 0; i < table.Length; i++) + { + Rect cell = new Rect(area.x + (i % cols) * (cellW + gap), area.y + (i / cols) * (cellH + gap), cellW, cellH); + bool sel = i == selected; + UISurface.Card(cell, sel ? theme.surfaceRaised : theme.surfaceCard, sel ? theme.accentAmber : theme.surfaceBorder); + + float labelH = Mathf.Min(40f, cellH * 0.22f); + float art = Mathf.Max(8f, Mathf.Min(cellW - 24f, cellH - labelH - 20f)); + Rect artRect = new Rect(cell.center.x - art * 0.5f, cell.y + 8f + (cellH - labelH - 16f - art) * 0.5f, art, art); + if (earned[i]) + { + Color tc = RegionTheme(i); + BadgeArt.DrawCentered(BadgeArt.Glow, artRect.center, art * 1.5f, new Color(tc.r, tc.g, tc.b, sel ? 0.45f : 0.25f)); + } + BadgeArt.Draw(artRect, table[i].regionId, earned[i]); + + cellStyle.normal.textColor = earned[i] ? theme.textPrimary : theme.textMuted; + UIHelper.LabelFit(new Rect(cell.x + 6f, cell.yMax - labelH - 8f, cell.width - 12f, labelH), cellLabel[i], cellStyle); + + if (GUI.Button(cell, GUIContent.none, GUIStyle.none) && selected != i) + { + selected = i; + BuildDetail(); + if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.ButtonClick); + } + } + } + + private void DrawDetail(Rect area, bool horizontal) + { + if (selected < 0 || selected >= table.Length) return; + UITheme theme = UITheme.Instance; + UISurface.Card(area, theme.surfaceCard, theme.surfaceBorder); + bool got = earned[selected]; + Color tc = RegionTheme(selected); + + Rect art; + Rect text; + if (horizontal) + { + float s = Mathf.Min(area.height - 40f, 280f); + art = new Rect(area.x + 24f, area.center.y - s * 0.5f, s, s); + text = new Rect(art.xMax + 28f, area.y + 24f, area.xMax - art.xMax - 52f, area.height - 48f); + } + else + { + float s = Mathf.Min(Mathf.Min(area.width - 140f, area.height * 0.46f), 380f); + art = new Rect(area.center.x - s * 0.5f, area.y + 36f, s, s); + text = new Rect(area.x + 28f, art.yMax + 28f, area.width - 56f, area.yMax - art.yMax - 52f); + } + + if (got) + { + float pulse = 0.9f + 0.1f * Mathf.Sin(Time.unscaledTime * 2f); + BadgeArt.DrawCentered(BadgeArt.Glow, art.center, art.width * 1.7f * pulse, new Color(tc.r, tc.g, tc.b, 0.4f)); + BadgeArt.DrawRotated(new Rect(art.center.x - art.width, art.center.y - art.height, art.width * 2f, art.height * 2f), + BadgeArt.Rays, Time.unscaledTime * 8f, new Color(1f, 0.95f, 0.8f, 0.16f)); + } + BadgeArt.Draw(art, table[selected].regionId, got); + + float y = text.y; + detailNameStyle.normal.textColor = got ? theme.textPrimary : theme.textSecondary; + UIHelper.LabelFit(new Rect(text.x, y, text.width, 64f), detailName, detailNameStyle); + y += 68f; + detailSubStyle.normal.textColor = got ? Color.Lerp(theme.textSecondary, tc, 0.3f) : theme.textMuted; + UIHelper.LabelFit(new Rect(text.x, y, text.width, 72f), detailGuardian, detailSubStyle); + y += 80f; + UISurface.Flat(new Rect(text.x, y, Mathf.Min(text.width, 320f), 3f), got ? theme.accentAmber : theme.surfaceBorder); + y += 16f; + detailFlavorStyle.normal.textColor = got ? theme.textPrimary : theme.textMuted; + UIHelper.LabelFit(new Rect(text.x, y, text.width, Mathf.Max(40f, text.yMax - y)), detailFlavor, detailFlavorStyle); + } + + private void DrawMilestones(Rect area, bool stacked) + { + UITheme theme = UITheme.Instance; + int n = milestones.Length; + const float gap = 12f; + for (int i = 0; i < n; i++) + { + Rect card = stacked + ? new Rect(area.x, area.y + i * ((area.height - gap * (n - 1)) / n + gap), area.width, (area.height - gap * (n - 1)) / n) + : new Rect(area.x + i * ((area.width - gap * (n - 1)) / n + gap), area.y, (area.width - gap * (n - 1)) / n, area.height); + int need = milestones[i].count; + bool claimed = badges != null && badges.IsClaimed(need); + bool ready = !claimed && earnedCount >= need; + UISurface.Card(card, ready ? theme.surfaceRaised : theme.surfaceCard, + ready ? theme.accentAmber : (claimed ? theme.accentMint : theme.surfaceBorder)); + + float actionW = stacked ? 180f : card.width - 32f; + float actionH = Mathf.Max(UIScale.MinTouchHeight, 56f); + Rect action = stacked + ? new Rect(card.xMax - actionW - 16f, card.center.y - actionH * 0.5f, actionW, actionH) + : new Rect(card.x + 16f, card.yMax - actionH - 12f, actionW, actionH); + float textW = stacked ? action.x - card.x - 32f : card.width - 32f; + + msHeadStyle.normal.textColor = claimed ? theme.textSecondary : theme.accentAmber; + UIHelper.LabelFit(new Rect(card.x + 16f, card.y + 8f, textW, 38f), milestoneHeader[i], msHeadStyle); + msRewardStyle.normal.textColor = claimed ? theme.textMuted : theme.textPrimary; + float rewardBottom = stacked ? card.yMax - 8f : action.y - 4f; + UIHelper.LabelFit(new Rect(card.x + 16f, card.y + 46f, textW, Mathf.Max(30f, rewardBottom - card.y - 46f)), + milestoneReward[i], msRewardStyle); + + if (claimed) + { + UISurface.Chip(new Rect(action.center.x - 60f, action.center.y - 20f, 120f, 40f), "받음", + Color.Lerp(theme.accentMint, theme.surfaceCard, 0.45f), theme.textPrimary); + } + else if (ready) + { + buttonStyle.normal.textColor = theme.surfaceBase; + if (UISurface.Button(action, "받기", theme.accentAmber, buttonStyle)) Claim(i); + } + else + { + float barW = action.width; + Rect bar = new Rect(action.x, action.center.y + 14f, barW, 8f); + UISurface.Flat(bar, theme.surfaceBorder); + UISurface.Flat(new Rect(bar.x, bar.y, barW * Mathf.Clamp01(earnedCount / (float)need), bar.height), theme.accentMint); + msProgressStyle.normal.textColor = theme.textSecondary; + GUI.Label(new Rect(action.x, action.center.y - 26f, action.width, 36f), milestoneProgress[i], msProgressStyle); + } + } + } + + private Color RegionTheme(int index) + { + RegionData region = regionManager != null && table != null && index >= 0 && index < table.Length + ? regionManager.GetRegionById(table[index].regionId) + : null; + return region != null ? region.themeColor : UITheme.Instance.accentAmber; + } + } +} diff --git a/Assets/Scripts/UI/BadgeCaseUI.cs.meta b/Assets/Scripts/UI/BadgeCaseUI.cs.meta new file mode 100644 index 0000000..41a2844 --- /dev/null +++ b/Assets/Scripts/UI/BadgeCaseUI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3cce8cba53e7c094eafb7c176a9b19f5 \ No newline at end of file diff --git a/Assets/Scripts/UI/BadgeCeremonyUI.cs b/Assets/Scripts/UI/BadgeCeremonyUI.cs new file mode 100644 index 0000000..16d4861 --- /dev/null +++ b/Assets/Scripts/UI/BadgeCeremonyUI.cs @@ -0,0 +1,504 @@ +using System.Collections.Generic; +using System.Text; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Story; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 수문장 배지 획득 연출 — 수문장전 결과 화면이 닫히면 곧바로, 스토리 대사보다 먼저 뜬다. + /// + /// 순서를 보장하는 장치: 전투 화면이 닫힐 때 StoryDirector.NotifyBattlePresentationClosed가 + /// 미뤄 둔 gd_* 대사를 흘리기 직전에 BattlePresentationClosed를 울린다. 여기서 그걸 듣고 + /// 모달을 먼저 열면, 스토리 쪽 드레인이 모달 가드에 막혀 이 연출이 닫힐 때까지 기다린다 + /// (StoryDirector.Update가 이어서 흘린다). 폴링만 하면 대사가 먼저 열려 배지가 대사 뒤로 밀린다. + /// + /// 연출(실제 초): 딤 → 배지가 뒤집히며 내려앉음 → 충격(빛 번짐·반짝이 흩어짐·효과음) → 이름·수문장·새김글 → + /// 13칸 진열줄에서 새 칸이 켜짐 → (이정표면) 보상 카드. 이르게 누르면 끝 장면으로 건너뛰고, 다시 누르면 닫힌다. + /// + /// 모달이다() — 등록하지 않으면 스토리가 "화면에 아무것도 없다"고 보고 위로 대사를 띄운다. + /// 닫힐 때 프리즈를 푸는 쪽도 여기다(BattleScreenUI.EndBattle은 모달이 떠 있으면 풀지 않는다). + /// + public class BadgeCeremonyUI : MonoBehaviour, IModalUI + { + // ── 타임라인(실제 초) ── + private const float DimIn = 0.3f; + private const float DropStart = 0.1f; + private const float DropEnd = 0.85f; + private const float TextAt = 0.95f; + private const float StripAt = 1.3f; + private const float RewardAt = 1.75f; + /// 이보다 이르게 누르면 닫지 않고 끝 장면으로 건너뛴다 — 연타로 배지를 못 보고 지나가지 않게. + private const float SettleAt = 2.1f; + private const float SkipTo = 2.4f; + private const int SparkleCount = 10; + + private GuardianBadgeService badges; + private RegionManager regionManager; + private ItemDatabase itemDatabase; + private StoryDirector storyDirector; + private CameraFollower cameraFollower; + private PlayerMovement playerMovement; + + private readonly List queue = new List(); + private GuardianBadgeService.Award current; + private bool open; + private float t; + private bool restoreFrozen; + private bool impactPlayed; + private bool rewardPlayed; + + // 열 때 한 번 만든다(OnGUI는 프레임당 여러 번 돈다). + private string nameText; + private string guardianText; + private string flavorText; + private string countText; + private string rewardTitle; + private string rewardText; + private string hintText; + private Color themeColor; + private int newIndex; + private readonly bool[] earnedMask = new bool[GuardianBadges.Total]; + private GuardianBadges.Badge[] table; + + private bool stylesReady; + private GUIStyle titleStyle; + private GUIStyle nameStyle; + private GUIStyle subStyle; + private GUIStyle flavorStyle; + private GUIStyle countStyle; + private GUIStyle rewardTitleStyle; + private GUIStyle rewardStyle; + private GUIStyle hintStyle; + + public bool IsOpen => open; + /// 지금 보여 주는 배지의 리전 — 검증 도구가 기록한다(StoryBeatWalkthrough). + public string CurrentRegionId => open ? current.regionId : null; + private bool HasReward => !string.IsNullOrEmpty(rewardText); + + /// ESC·뒤로 — 이 배지를 닫고 다음 배지(있으면)로. + public void CloseModal() => Finish(); + + public void AutoWire(GuardianBadgeService service, RegionManager region, ItemDatabase items) + { + if (badges == null) badges = service; + if (regionManager == null) regionManager = region; + if (itemDatabase == null) itemDatabase = items; + Subscribe(); + } + + public void AutoWire(StoryDirector director, CameraFollower cam, PlayerMovement movement) + { + if (storyDirector == null) storyDirector = director; + if (cameraFollower == null) cameraFollower = cam; + if (playerMovement == null) playerMovement = movement; + Subscribe(); + } + + private void OnEnable() + { + Subscribe(); + } + + private void OnDisable() + { + if (badges != null) badges.BadgeAwarded -= OnAwarded; + if (storyDirector != null) storyDirector.BattlePresentationClosed -= TryOpen; + // 오프닝 다시보기가 UI 루트를 통째로 끈다 — 보던 배지는 줄 맨 앞으로 되돌려 다시 켜질 때 이어 보인다. + if (open) + { + queue.Insert(0, current); + Close(); + } + } + + private void Subscribe() + { + if (!isActiveAndEnabled) return; + if (badges != null) + { + badges.BadgeAwarded -= OnAwarded; + badges.BadgeAwarded += OnAwarded; + } + if (storyDirector != null) + { + storyDirector.BattlePresentationClosed -= TryOpen; + storyDirector.BattlePresentationClosed += TryOpen; + } + } + + private void OnAwarded(GuardianBadgeService.Award award) + { + queue.Add(award); + TryOpen(); // 전투 중이면 여기서 막히고, 결과 화면이 닫힐 때 BattlePresentationClosed가 다시 부른다 + } + + private void TryOpen() + { + if (open || queue.Count == 0) return; + if (cameraFollower != null && cameraFollower.InBattleMode) return; // 전투 화면(결과 포함)이 아직 있다 + if (ModalUIRegistry.IsAnyOpen()) return; // 대사·컷신·영상이 먼저 떠 있다 + GuardianBadgeService.Award next = queue[0]; + queue.RemoveAt(0); + Open(next); + } + + private void Open(GuardianBadgeService.Award award) + { + current = award; + open = true; + t = 0f; + impactPlayed = false; + rewardPlayed = false; + BuildTexts(); + + restoreFrozen = playerMovement != null && playerMovement.IsFrozen; + if (playerMovement != null) + { + playerMovement.CancelAutoRun(); + playerMovement.SetFrozen(true); + } + ModalUIRegistry.Register(this); + } + + private void Finish() + { + if (!open) + { + ModalUIRegistry.Unregister(this); + return; + } + Close(); + TryOpen(); // 줄 선 배지가 더 있으면 이어서(클라우드 병합 뒤 격파가 겹친 경우 등) + } + + private void Close() + { + open = false; + ModalUIRegistry.Unregister(this); + if (playerMovement != null && !restoreFrozen) playerMovement.SetFrozen(false); + } + + private void Advance() + { + if (!open) return; + if (t < SettleAt) + { + t = Mathf.Max(t, SkipTo); + return; + } + Finish(); + } + + private void Update() + { + if (!open) + { + if (queue.Count > 0) TryOpen(); // 통지 없이 전투가 끝난 경로의 안전망 + return; + } + + // 한 프레임에 쌓는 시간을 제한한다 — 전투 화면을 걷는 프레임에 멈칫(GC·텍스처 로드)이 있으면 + // 그 몇백 ms가 한꺼번에 들어와 배지가 내려앉는 장면을 통째로 건너뛴다(QA 캡처에서 실제로 났다). + t += Mathf.Min(Time.unscaledDeltaTime, 0.05f); + if (!impactPlayed && t >= DropEnd) + { + impactPlayed = true; + if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.SetComplete); + } + if (!rewardPlayed && HasReward && t >= RewardAt) + { + rewardPlayed = true; + if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.LevelUp); + } + if (Input.GetKeyDown(KeyCode.Space) || Input.GetKeyDown(KeyCode.Return) || Input.GetKeyDown(KeyCode.KeypadEnter)) + Advance(); + } + + private void BuildTexts() + { + if (table == null) table = GuardianBadges.All(); + GuardianBadges.TryGet(current.regionId, out GuardianBadges.Badge badge); + RegionData region = regionManager != null ? regionManager.GetRegionById(current.regionId) : null; + + nameText = !string.IsNullOrEmpty(badge.name) ? badge.name : current.regionId; + guardianText = region != null && !string.IsNullOrEmpty(region.guardianDisplayName) + ? $"{region.guardianDisplayName} · Lv.{region.guardianLevel}" + : string.Empty; + flavorText = badge.flavor ?? string.Empty; + countText = $"모은 배지 {current.earned} / {GuardianBadges.Total}"; + themeColor = region != null ? region.themeColor : UITheme.Instance.accentAmber; + newIndex = GuardianBadges.IndexOf(current.regionId); + hintText = UIScale.IsMobileLayout ? "화면을 눌러 계속" : "클릭하거나 Space로 계속"; + + for (int i = 0; i < table.Length && i < earnedMask.Length; i++) + earnedMask[i] = badges != null && badges.IsEarned(table[i].regionId); + if (newIndex >= 0 && newIndex < earnedMask.Length) earnedMask[newIndex] = true; + + rewardTitle = null; + rewardText = null; + if (current.milestones == null || current.milestones.Length == 0) return; + + StringBuilder title = new StringBuilder("배지 "); + StringBuilder items = new StringBuilder(); + string lastTitle = null; + for (int i = 0; i < current.milestones.Length; i++) + { + if (!GuardianBadges.TryGetMilestone(current.milestones[i], out GuardianBadges.Milestone m)) continue; + if (i > 0) title.Append('·'); + title.Append(m.count); + lastTitle = m.title; + if (m.rewards == null) continue; + foreach (GuardianBadges.Reward r in m.rewards) + { + string name = ItemName(r.itemId); + if (string.IsNullOrEmpty(name)) continue; // 이름을 모르면 원문 ID를 내보내지 않고 뺀다 + if (items.Length > 0) items.Append(" · "); + items.Append(name).Append(" ×").Append(r.count); + } + } + title.Append("개 달성"); + if (!string.IsNullOrEmpty(lastTitle)) title.Append(" — 「").Append(lastTitle).Append('」'); + rewardTitle = title.ToString(); + rewardText = items.Length > 0 ? items.ToString() : "보상을 받았다"; + } + + private string ItemName(string itemId) + { + ItemData item = itemDatabase != null ? itemDatabase.FindById(itemId) : null; + return item != null && !string.IsNullOrEmpty(item.displayName) ? item.displayName : string.Empty; + } + + // ── 그리기 ── + + private void OnGUI() + { + if (!open) return; + int prevDepth = GUI.depth; + GUI.depth = -30; // HUD·도감(-10) 위, 계정 모달(-20)과는 동시에 뜨지 않는다 + UIScale.Begin(); + EnsureStyles(); + Draw(); + UIScale.End(); + GUI.depth = prevDepth; + } + + private void EnsureStyles() + { + if (stylesReady) return; + stylesReady = true; + titleStyle = new GUIStyle(GUI.skin.label) { fontSize = 40, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + nameStyle = new GUIStyle(GUI.skin.label) { fontSize = 64, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + subStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, alignment = TextAnchor.MiddleCenter }; + flavorStyle = new GUIStyle(GUI.skin.label) { fontSize = 28, fontStyle = FontStyle.Italic, alignment = TextAnchor.MiddleCenter }; + countStyle = new GUIStyle(GUI.skin.label) { fontSize = 26, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + rewardTitleStyle = new GUIStyle(GUI.skin.label) { fontSize = 28, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + rewardStyle = new GUIStyle(GUI.skin.label) { fontSize = 28, alignment = TextAnchor.MiddleCenter, wordWrap = true }; + hintStyle = new GUIStyle(GUI.skin.label) { fontSize = 24, alignment = TextAnchor.MiddleCenter }; + } + + private void Draw() + { + UITheme theme = UITheme.Instance; + float W = UIScale.VirtualScreenWidth; + bool portrait = UIScale.IsPortrait; + + // 딤 — 화면 전체. 누르면 진행(투명 버튼이라 터치 합성 클릭도 받는다). + float dim = Mathf.Clamp01(t / DimIn); + UISurface.Dim(0.84f * dim); + + // ── 세로 배치: 제목 · 배지 · 이름 · 수문장 · 새김글 · 진열줄 · 보상 · 안내 ── + float badgeSize = portrait ? 440f : 340f; + const float titleH = 58f, nameH = 90f, subH = 44f, flavorH = 42f, countH = 38f, rewardH = 120f, hintH = 36f; + float slot = Mathf.Min(64f, (W - 96f - 12f * 8f) / GuardianBadges.Total); + float stripH = slot + 8f; + float blockH = titleH + 16f + badgeSize + 18f + nameH + subH + flavorH + 26f + countH + stripH + + (HasReward ? 22f + rewardH : 0f) + 18f + hintH; + float y = UISafeLayout.CenteredY(blockH); + float cx = W * 0.5f; + + // 제목 + float textIn = Mathf.Clamp01((t - (TextAt - 0.25f)) / 0.35f); + Color amber = theme.accentAmber; + GUI.color = new Color(amber.r, amber.g, amber.b, textIn); + GUI.Label(new Rect(cx - 400f, y + (1f - textIn) * 16f, 800f, titleH), "배지 획득!", titleStyle); + GUI.color = Color.white; + y += titleH + 16f; + + Vector2 center = new Vector2(cx, y + badgeSize * 0.5f); + DrawBadgeMoment(center, badgeSize); + y += badgeSize + 18f; + + // 이름 · 수문장 · 새김글 + float nameIn = Mathf.Clamp01((t - TextAt) / 0.3f); + GUI.color = new Color(1f, 1f, 1f, nameIn); + nameStyle.normal.textColor = theme.textPrimary; + UIHelper.LabelFit(new Rect(cx - 520f, y + (1f - nameIn) * 20f, 1040f, nameH), nameText, nameStyle); + y += nameH; + float subIn = Mathf.Clamp01((t - TextAt - 0.12f) / 0.3f); + GUI.color = new Color(1f, 1f, 1f, subIn); + subStyle.normal.textColor = theme.textSecondary; + if (!string.IsNullOrEmpty(guardianText)) + UIHelper.LabelFit(new Rect(cx - 520f, y, 1040f, subH), guardianText, subStyle); + y += subH; + flavorStyle.normal.textColor = Color.Lerp(theme.textSecondary, themeColor, 0.35f); + UIHelper.LabelFit(new Rect(cx - 520f, y, 1040f, flavorH), flavorText, flavorStyle); + y += flavorH + 26f; + GUI.color = Color.white; + + // 진열줄 — 13칸. 새 칸은 늦게 켜지며 튄다. + float stripIn = Mathf.Clamp01((t - StripAt) / 0.3f); + GUI.color = new Color(1f, 1f, 1f, stripIn); + countStyle.normal.textColor = theme.textSecondary; + GUI.Label(new Rect(cx - 300f, y, 600f, countH), countText, countStyle); + GUI.color = Color.white; // 칸은 알파를 인자로 받는다 — 여기 남기면 두 번 곱해진다 + y += countH; + DrawStrip(new Rect(cx - (slot * GuardianBadges.Total + 8f * (GuardianBadges.Total - 1)) * 0.5f, y + 4f, 0f, slot), slot, stripIn); + GUI.color = Color.white; + y += stripH; + + // 이정표 보상 + if (HasReward) + { + y += 22f; + float rIn = Mathf.Clamp01((t - RewardAt) / 0.35f); + if (rIn > 0f) + { + float cardW = Mathf.Min(860f, W - 96f); + Rect card = new Rect(cx - cardW * 0.5f, y + (1f - rIn) * 30f, cardW, rewardH); + GUI.color = new Color(1f, 1f, 1f, rIn); + UISurface.Card(card, theme.surfaceRaised, theme.accentAmber); + rewardTitleStyle.normal.textColor = theme.accentAmber; + UIHelper.LabelFit(new Rect(card.x + 20f, card.y + 10f, card.width - 40f, 42f), rewardTitle, rewardTitleStyle); + rewardStyle.normal.textColor = theme.textPrimary; + UIHelper.LabelFit(new Rect(card.x + 20f, card.y + 54f, card.width - 40f, 56f), rewardText, rewardStyle); + GUI.color = Color.white; + } + y += rewardH; + } + + // 안내 — 다 보인 뒤에 깜빡인다. + y += 18f; + if (t >= SettleAt) + { + float blink = 0.55f + 0.45f * Mathf.Sin((t - SettleAt) * 3.2f); + Color muted = theme.textMuted; + GUI.color = new Color(muted.r, muted.g, muted.b, blink); + hintStyle.normal.textColor = Color.white; + GUI.Label(new Rect(cx - 300f, y, 600f, hintH), hintText, hintStyle); + GUI.color = Color.white; + } + + // 화면 전체가 버튼 — 맨 마지막에 둬야 위에서 그린 것에 가리지 않고 입력을 받는다. + if (GUI.Button(new Rect(0f, 0f, W, UIScale.VirtualScreenHeight), GUIContent.none, GUIStyle.none)) + Advance(); + } + + /// 배지가 떨어져 내려앉는 순간 — 빛살·번짐·반짝이·뒤집힘. + private void DrawBadgeMoment(Vector2 center, float size) + { + float impact = t - DropEnd; // 음수면 아직 내려오는 중 + + // 뒤 빛: 리전 색 번짐(늘 은은하게) + 충격 뒤 도는 빛살 + float pulse = 0.85f + 0.15f * Mathf.Sin(t * 2.4f); + float glowIn = Mathf.Clamp01(t / 0.6f); + BadgeArt.DrawCentered(BadgeArt.Glow, center, size * 2.3f * pulse, + new Color(themeColor.r, themeColor.g, themeColor.b, 0.42f * glowIn)); + if (impact > 0f) + { + float raysIn = Mathf.Clamp01(impact / 0.4f); + float raySize = size * (2.2f + 0.25f * Mathf.Sin(t * 1.3f)); + Color warm = Color.Lerp(UITheme.Instance.accentAmber, Color.white, 0.45f); + BadgeArt.DrawRotated(new Rect(center.x - raySize * 0.5f, center.y - raySize * 0.5f, raySize, raySize), + BadgeArt.Rays, t * 14f, new Color(warm.r, warm.g, warm.b, 0.5f * raysIn)); + } + + // 배지 — 위에서 내려오며 두 번 뒤집히고, 살짝 눌렸다 제자리로(EaseOutBack). + float p = Mathf.Clamp01((t - DropStart) / (DropEnd - DropStart)); + float scale = Mathf.LerpUnclamped(1.7f, 1f, EaseOutBack(p)); + float flip = Mathf.Abs(Mathf.Cos((1f - EaseOutCubic(p)) * Mathf.PI * 2f)); + float drop = (1f - EaseOutCubic(p)) * -120f; + float bob = impact > 0f ? Mathf.Sin(impact * 2.1f) * 5f : 0f; + float w = size * scale * Mathf.Max(0.05f, flip); + float h = size * scale; + Rect badgeRect = new Rect(center.x - w * 0.5f, center.y - h * 0.5f + drop + bob, w, h); + float shade = 0.55f + 0.45f * flip; // 옆으로 설 때 어두워진다 — 두께가 있는 것처럼 + GUI.color = new Color(shade, shade, shade, Mathf.Clamp01(p * 4f)); + BadgeArt.Draw(badgeRect, current.regionId, true); + GUI.color = Color.white; + + if (impact <= 0f) return; + + // 충격 번짐 + float flash = Mathf.Clamp01(1f - impact / 0.35f); + if (flash > 0f) + BadgeArt.DrawCentered(BadgeArt.Glow, center, size * (1.4f + impact * 4f), new Color(1f, 1f, 1f, 0.9f * flash)); + + // 반짝이 — 고정 각도(난수 없이)로 흩어졌다 사라진다. + float burst = Mathf.Clamp01(impact / 0.9f); + if (burst < 1f) + { + for (int i = 0; i < SparkleCount; i++) + { + float ang = (i / (float)SparkleCount) * Mathf.PI * 2f + (i % 2 == 0 ? 0.18f : -0.12f); + float reach = size * (0.52f + 0.5f * EaseOutCubic(burst) + (i % 3) * 0.06f); + Vector2 pos = center + new Vector2(Mathf.Cos(ang), Mathf.Sin(ang)) * reach; + float s = Mathf.Lerp(46f, 14f, burst) * (i % 2 == 0 ? 1f : 0.7f); + BadgeArt.DrawCentered(BadgeArt.Sparkle, pos, s, new Color(1f, 0.96f, 0.82f, 1f - burst)); + } + } + + // 광택 — 배지 위 두 점이 번갈아 반짝인다. + float glintA = Mathf.Clamp01(Mathf.Sin(impact * 2.6f) * 1.4f - 0.4f); + float glintB = Mathf.Clamp01(Mathf.Sin(impact * 2.6f + 2.2f) * 1.4f - 0.4f); + BadgeArt.DrawCentered(BadgeArt.Sparkle, center + new Vector2(-size * 0.2f, -size * 0.26f + bob), 38f, + new Color(1f, 1f, 1f, glintA)); + BadgeArt.DrawCentered(BadgeArt.Sparkle, center + new Vector2(size * 0.24f, size * 0.12f + bob), 26f, + new Color(1f, 1f, 1f, glintB)); + } + + private void DrawStrip(Rect start, float slot, float alpha) + { + if (table == null) return; + float x = start.x; + for (int i = 0; i < table.Length; i++) + { + Rect r = new Rect(x, start.y, slot, slot); + bool earned = i < earnedMask.Length && earnedMask[i]; + if (i == newIndex) + { + // 새 칸 — 진열줄이 켜진 뒤 한 박자 늦게 튀어 오른다. + float pop = Mathf.Clamp01((t - StripAt - 0.25f) / 0.35f); + float sc = pop <= 0f ? 1f : Mathf.LerpUnclamped(1.6f, 1f, EaseOutBack(pop)); + Rect pr = new Rect(r.center.x - slot * sc * 0.5f, r.center.y - slot * sc * 0.5f, slot * sc, slot * sc); + if (pop > 0f) + BadgeArt.DrawCentered(BadgeArt.Glow, r.center, slot * 2.2f, + new Color(themeColor.r, themeColor.g, themeColor.b, 0.6f * alpha * (1f - 0.5f * pop))); + BadgeArt.Draw(pr, table[i].regionId, pop > 0f, alpha); + } + else + { + BadgeArt.Draw(r, table[i].regionId, earned, alpha); + } + x += slot + 8f; + } + } + + private static float EaseOutCubic(float x) + { + float u = 1f - Mathf.Clamp01(x); + return 1f - u * u * u; + } + + private static float EaseOutBack(float x) + { + x = Mathf.Clamp01(x); + const float c1 = 1.70158f; + const float c3 = c1 + 1f; + float u = x - 1f; + return 1f + c3 * u * u * u + c1 * u * u; + } + } +} diff --git a/Assets/Scripts/UI/BadgeCeremonyUI.cs.meta b/Assets/Scripts/UI/BadgeCeremonyUI.cs.meta new file mode 100644 index 0000000..9a8f0f0 --- /dev/null +++ b/Assets/Scripts/UI/BadgeCeremonyUI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e58d92fab629f1e40a4c2e2f5aba0e09 \ No newline at end of file diff --git a/Assets/Scripts/UI/BadgeVisualCapture.cs b/Assets/Scripts/UI/BadgeVisualCapture.cs new file mode 100644 index 0000000..a74f7bd --- /dev/null +++ b/Assets/Scripts/UI/BadgeVisualCapture.cs @@ -0,0 +1,121 @@ +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using InsectGame.Core; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 배지 획득 연출·배지 케이스의 실제 IMGUI 촬영 fixture(-battleScenario badge). + /// + /// 저장을 건드리지 않는다 — 격파 기록과 수령 상태를 리플렉션으로 메모리에만 넣는다 + /// (RegionManager.DefeatGuardian·GuardianBadgeService.TryClaim은 PlayerPrefs에 쓴다). + /// 연출은 서비스 이벤트 대신 핸들러를 직접 불러 연다 — 순서(대사보다 먼저)는 걸음 검증이 본다. + /// + public static class BadgeVisualCapture + { + private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + + public static IEnumerator Run(string output, Camera camera) + { + RegionData[] regions = RegionDefinitions.CreateAll(); + new GameObject("VillageQA").AddComponent().Build(regions); + GameObject village = GameObject.Find("MainVillage"); + Vector3 center = village != null ? village.transform.position : Vector3.zero; + camera.transform.position = center + new Vector3(0, 14, 23); + camera.transform.LookAt(center + Vector3.up); + Light light = new GameObject("BadgeQAKey").AddComponent(); + light.type = LightType.Directional; + light.intensity = 1.1f; + light.transform.rotation = Quaternion.Euler(48, -30, 0); + GameObject ground = GameObject.CreatePrimitive(PrimitiveType.Plane); + ground.transform.position = center; + ground.transform.localScale = Vector3.one * 20; + ground.GetComponent().material.color = new Color(.30f, .40f, .29f); + + var host = new GameObject("BadgeQA"); + RegionManager regionManager = host.AddComponent(); + regionManager.Initialize(regions); + SetDefeated(regionManager, "meadow", "pond", "forest", "garden"); + + GuardianBadgeService service = host.AddComponent(); + service.AutoWire(regionManager, null); + typeof(GuardianBadgeService).GetField("claimed", Private).SetValue(service, new HashSet()); + typeof(GuardianBadgeService).GetField("loaded", Private).SetValue(service, true); + + ItemDatabase items = ItemDatabase.CreateRuntimeDefault(); + BadgeCeremonyUI ceremony = new GameObject("BadgeCeremonyQA").AddComponent(); + ceremony.AutoWire(service, regionManager, items); + MethodInfo onAwarded = typeof(BadgeCeremonyUI).GetMethod("OnAwarded", Private); + + int shots = 0; + // 1) 네 번째 배지 + 이정표 보상 카드 — 연출 타임라인을 따라 찍는다. + onAwarded.Invoke(ceremony, new object[] { new GuardianBadgeService.Award + { regionId = "garden", earned = 4, milestones = new[] { 4 } } }); + float opened = Time.realtimeSinceStartup; + foreach (float at in new[] { 0.3f, 0.6f, 0.95f, 1.2f, 1.55f, 2.0f, 2.7f }) + { + while (Time.realtimeSinceStartup - opened < at) yield return null; + shots++; + yield return Capture(output, $"ceremony-garden-{at:0.00}"); + } + ceremony.CloseModal(); + + // 2) 2막 은빛 배지(보상 없음) — 끝 장면만. + SetDefeated(regionManager, "meadow", "pond", "forest", "garden", "swamp", "mountain", "ruins", + "hollow", "dunes", "frostline"); + onAwarded.Invoke(ceremony, new object[] { new GuardianBadgeService.Award + { regionId = "frostline", earned = 10, milestones = new int[0] } }); + opened = Time.realtimeSinceStartup; + foreach (float at in new[] { 0.9f, 2.7f }) + { + while (Time.realtimeSinceStartup - opened < at) yield return null; + shots++; + yield return Capture(output, $"ceremony-frostline-{at:0.00}"); + } + ceremony.CloseModal(); + + // 3) 케이스 — 배지 4개, 이정표 4 받을 수 있음. 얻은 배지·안 가 본 땅 두 가지 상세. + SetDefeated(regionManager, "meadow", "pond", "forest", "garden"); + BadgeCaseUI badgeCase = new GameObject("BadgeCaseQA").AddComponent(); + badgeCase.AutoWire(service, regionManager, items); + badgeCase.Toggle(); + yield return new WaitForSecondsRealtime(0.6f); + shots++; + yield return Capture(output, "case-earned"); + typeof(BadgeCaseUI).GetField("selected", Private).SetValue(badgeCase, 11); + typeof(BadgeCaseUI).GetMethod("BuildDetail", Private).Invoke(badgeCase, null); + yield return new WaitForSecondsRealtime(0.4f); + shots++; + yield return Capture(output, "case-locked"); + badgeCase.CloseModal(); + + File.WriteAllText(Path.Combine(output, "README.txt"), + $"Actual standalone IMGUI. {shots} shots. Guardian records and milestone claims injected in memory " + + "(no PlayerPrefs writes). Ceremony opened by calling its award handler directly; ordering against " + + "story dialogue is validated by StoryBeatWalkthrough, not here. Village backdrop is a staging fixture."); + Object.Destroy(items); + Application.Quit(shots > 0 ? 0 : 3); + } + + private static void SetDefeated(RegionManager manager, params string[] ids) + { + typeof(RegionManager).GetField("defeatedGuardians", Private).SetValue(manager, new HashSet(ids)); + typeof(RegionManager).GetField("unlockedRegions", Private).SetValue(manager, + new HashSet(new[] { "meadow", "pond", "forest", "garden", "swamp" })); + } + + private static IEnumerator Capture(string output, string name) + { + yield return new WaitForEndOfFrame(); + Texture2D shot = ScreenCapture.CaptureScreenshotAsTexture(); + File.WriteAllBytes(Path.Combine(output, name + ".png"), shot.EncodeToPNG()); + Object.Destroy(shot); + } + } +} +#endif diff --git a/Assets/Scripts/UI/BadgeVisualCapture.cs.meta b/Assets/Scripts/UI/BadgeVisualCapture.cs.meta new file mode 100644 index 0000000..6472fe4 --- /dev/null +++ b/Assets/Scripts/UI/BadgeVisualCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 821dcbcc0acf9634f94d4334b987111b \ No newline at end of file diff --git a/Assets/Scripts/UI/BattleEffectTextOverlay.cs b/Assets/Scripts/UI/BattleEffectTextOverlay.cs index 687e687..b146be7 100644 --- a/Assets/Scripts/UI/BattleEffectTextOverlay.cs +++ b/Assets/Scripts/UI/BattleEffectTextOverlay.cs @@ -35,6 +35,18 @@ public static class BattleEffectTextOverlay /// 떠오르는 거리(가상 px). 사라지는 동안 이만큼 위로 올라간다. private const float RiseDistance = 40f; + /// + /// 번째 줄이 떠오르기 전 자리(가상 좌표) — 순수 계산. 화면 가운데, 높이 25%부터 아래로 쌓인다. + /// 25%인 이유: 35%였을 때 시네마틱 카메라가 대상을 화면 가운데로 당기면 이 문구가 + /// 대상 머리 위 비명·피해 숫자와 한 자리에 겹쳤다(속성 10종 QA 캡처). + /// 전투 HUD 겹침 검사(DuelHudLayoutTests)가 같은 함수를 부른다. + /// + internal static Rect RowRect(HudFrame f, int index) + { + float width = Mathf.Min(MaxWidth, f.ContentWidth); + return new Rect(f.Width * 0.5f - width * 0.5f, f.Height * 0.25f + index * RowSpacing, width, RowHeight); + } + /// /// 호출부는 UIScale.Begin()과 UIScale.End() 사이에서 불러야 한다 — /// 좌표가 가상 캔버스 기준이다. @@ -58,9 +70,7 @@ public static void Draw(BattleArenaController arena) } float now = Time.time; - float centerX = UIScale.VirtualScreenWidth * 0.5f; - float baseY = UIScale.VirtualScreenHeight * 0.35f; - float width = Mathf.Min(MaxWidth, UIScale.ContentWidth()); + HudFrame frame = HudFrame.Current; Color previous = GUI.color; for (int i = 0; i < texts.Count; i++) @@ -69,8 +79,8 @@ public static void Draw(BattleArenaController arena) if (entry == null) continue; float alpha = 1f - Mathf.Clamp01((now - entry.startTime) / Mathf.Max(0.0001f, entry.duration)); - float y = baseY + i * RowSpacing - RiseDistance * (1f - alpha); - Rect rect = new Rect(centerX - width * 0.5f, y, width, RowHeight); + Rect rect = RowRect(frame, i); + rect.y -= RiseDistance * (1f - alpha); // 스킬 이름이 그대로 들어오므로(`{skill.displayName}!`) 길이를 데이터가 정한다 — // 가운데 정렬이라 넘치면 앞뒤가 같이 잘린다. LabelFit이 폭·높이를 함께 본다. diff --git a/Assets/Scripts/UI/BattleFeelHud.cs b/Assets/Scripts/UI/BattleFeelHud.cs new file mode 100644 index 0000000..f95fa79 --- /dev/null +++ b/Assets/Scripts/UI/BattleFeelHud.cs @@ -0,0 +1,1079 @@ +using System.Collections.Generic; +using InsectGame.Battle; +using UnityEngine; + +namespace InsectGame.UI +{ + // 4단계 「전투 체감」의 화면 — 진입 문구·화면 쓸기, 타격 순간의 상성 표시·스킬 카드 상성 칩, 전용기 컷인 띠, 승리 화면. + // 이 파일은 둘로 나뉜다: 위쪽은 순수 계산(문구·시각표·자리 — BattleFeelHudTests가 씬 없이 본다), 맨 아래 BattleFeelDraw는 + // 1대1(BattleScreenUI.Feel)과 레이드(RaidBattleUI.Feel)가 함께 쓰는 IMGUI 그리기다. 대상은 초등 고학년 — 글자는 크게, 문장은 짧게. + + /// + /// 전투 진입 큰 문구 — 순수. 종류()마다 한 줄. 조사는 받침으로 고른다(). + /// 꿈 챔피언전()은 꿈이 자기 카드(「챔피언 결정전」)를 먼저 띄우므로 null(그리지 않는다). + /// + public static class BattleEntryText + { + /// 라온 대결 — 한마디 표에 이름이 없을 때. + public const string RivalName = "라온"; + + /// 곤충잡이 아이 대결 — 아이는 이름 표가 없다(DuelBanter는 간부·라온만 든다). + public const string KidName = "곤충잡이 아이"; + + /// 일반 레이드 진입의 둘째 줄. + public const string RaidSubLine = "모두 힘을 합쳐 쓰러뜨리자!"; + + /// 상대 곤충 이름. + /// 상대 사람 이름(간부·라온 — 한마디 표의 name). 없으면 빈 값. + /// 수문장 리전 이름(「초원」). 없으면 빈 값. + public static string For(BattleKind kind, string insect, string person, string regionName) + { + string bug = string.IsNullOrEmpty(insect) ? "곤충" : insect; + switch (kind) + { + case BattleKind.Wild: + return $"야생 {bug}{KoreanJosa.IGa(bug)} 나타났다!"; + case BattleKind.Guardian: + return string.IsNullOrEmpty(regionName) ? $"수문장 {bug}!" : $"{regionName}의 수문장 {bug}!"; + case BattleKind.KidDuel: + return Challenge(KidName); + case BattleKind.RivalDuel: + return Challenge(string.IsNullOrEmpty(person) ? RivalName : person); + case BattleKind.BossDuel: + return Challenge(string.IsNullOrEmpty(person) ? "상대" : person); + default: + return null; + } + } + + /// 「집게가 승부를 걸어왔다!」. + public static string Challenge(string who) + { + string w = string.IsNullOrEmpty(who) ? "상대" : who; + return $"{w}{KoreanJosa.IGa(w)} 승부를 걸어왔다!"; + } + + /// 일반 레이드(수문장 아님) — 「레이드 보스 왕사슴벌레 출현!」. + public static string Raid(string boss) + { + string b = string.IsNullOrEmpty(boss) ? "곤충" : boss; + return $"레이드 보스 {b} 출현!"; + } + } + + /// + /// 전투 진입 구간의 시각표 — 순수. 1대1은 BattleScreenUI.EntryIntroProgress(실제 1.4초, 0~1)를 넘긴다. + /// ① 0 ~ : 화면을 덮은 어두운 막이 기울어진 모서리(호박색 띠)를 앞세워 오른쪽으로 쓸려 나가며 아레나가 열린다. + /// ② 부터: 큰 문구가 살짝 튀어나왔다 자리 잡는다. 컷인이 이어지는 전투(간부·라온·수문장)는 진입 구간 끝에 + /// 사라지고(컷인은 그 뒤에 시작한다 — BattleReadPacing.IntroSeconds), 아니면 인트로 끝 0.25초에 사라진다. + /// + public static class BattleEntryStaging + { + /// 화면 쓸기가 끝나는 진입 진행률. + public const float WipeEnd = 0.35f; + /// 막의 모서리가 기운 각(도) — 위가 오른쪽으로 기운다(「/」). + public const float WipeAngle = 16f; + /// 모서리를 앞세우는 호박색 띠의 폭. + public const float StripeWidth = 26f; + /// 문구가 뜨기 시작하는 진입 진행률 — 막이 화면 가운데를 막 지날 즈음. + public const float TitleIn = 0.2f; + /// 문구가 뜨고(진행률) 컷인 앞에서 사라지는(진행률) 길이. + public const float TitleFade = 0.12f; + /// 컷인이 없는 전투에서 인트로 끝에 사라지는 초(인트로 시계). + public const float TitleOutSeconds = 0.25f; + + public const float PopStart = 0.7f; + public const float PopPeak = 1.12f; + public const float PopPeakAt = 0.14f; + public const float PopSettleAt = 0.3f; + + /// 열린 정도 0~1(0 = 화면을 다 덮음, 1 = 다 열림). 끝으로 갈수록 느려진다. + public static float WipeOpen(float entryProgress) + { + if (entryProgress >= WipeEnd) return 1f; + return Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(entryProgress / WipeEnd)); + } + + /// 모서리가 화면 위·아래 끝에서 가운데보다 옆으로 비켜 있는 거리. + public static float HalfSlant(float screenHeight) => screenHeight * 0.5f * Mathf.Tan(WipeAngle * Mathf.Deg2Rad); + + /// + /// 모서리가 화면 가운데 높이에서 지나는 x(가상 좌표). 막은 이 모서리의 오른쪽이다. 열림 0이면 띠까지 화면 왼쪽 밖이라 + /// 화면 전체가 막이고, 1이면 모서리의 가장 왼쪽(화면 아래)도 화면 오른쪽 밖이라 다 열렸다. + /// + public static float WipeEdgeX(float open, float screenWidth, float screenHeight) + { + float slant = HalfSlant(screenHeight); + float from = -slant - StripeWidth * 2f; + float to = screenWidth + slant; + return Mathf.Lerp(from, to, Mathf.Clamp01(open)); + } + + /// 모서리 선이 높이 (아래로 증가)에서 지나는 x. + public static float EdgeXAt(float edgeX, float y, float screenHeight) + => edgeX + (screenHeight * 0.5f - y) * Mathf.Tan(WipeAngle * Mathf.Deg2Rad); + + /// 큰 문구의 불투명도. + /// 인트로 시계(초). + /// 인트로 전체 길이(초). + /// 진입 구간 뒤에 대결·수문장 컷인이 이어지는가. + public static float TitleAlpha(float entryProgress, float introElapsed, float introSeconds, bool cutInFollows) + { + float fadeIn = Mathf.Clamp01((entryProgress - TitleIn) / TitleFade); + float fadeOut = cutInFollows + ? Mathf.Clamp01((1f - entryProgress) / TitleFade) + : Mathf.Clamp01((introSeconds - introElapsed) / TitleOutSeconds); + return fadeIn * fadeOut; + } + + /// 문구가 뜬 뒤 초의 크기 — 작게 시작해 살짝 넘쳤다가 1로. 줄인 움직임이면 늘 1. + public static float Pop(float sinceShown, bool reducedMotion) + { + if (reducedMotion) return 1f; + if (sinceShown <= 0f) return PopStart; + if (sinceShown < PopPeakAt) return Mathf.Lerp(PopStart, PopPeak, Mathf.SmoothStep(0f, 1f, sinceShown / PopPeakAt)); + if (sinceShown < PopSettleAt) + return Mathf.Lerp(PopPeak, 1f, Mathf.SmoothStep(0f, 1f, (sinceShown - PopPeakAt) / (PopSettleAt - PopPeakAt))); + return 1f; + } + } + + /// + /// 진입 문구의 자리 — 순수. 1대1은 위쪽 가운데: 가로 화면이면 양쪽 HP 카드 사이(아레나 진입 샷은 그 아래에서 돈다), + /// 세로처럼 카드 사이가 좁으면 카드 밑 쌓기(장부 게이지·보정 칩)가 다 서도 닿지 않는 그 아래. 레이드는 아래 무대 + /// () 가운데 — 위쪽은 보스 자리라 옛 "RAID BOSS"(화면 30%)가 보스와 겹쳤다. + /// + public static class BattleEntryLayout + { + public const int TitleFont = 60; + public const float TitleHeight = 132f; + public const float TitleMaxWidth = 1100f; + public const float TitlePadX = 28f; + /// HP 카드 사이 틈이 이보다 좁으면(세로 화면) 카드 밑으로 내린다. + public const float MinGapWidth = 720f; + + public const int RaidTitleFont = 54; + public const int RaidSubFont = 28; + public const float RaidTitleHeight = 170f; + + public static Rect DuelTitle(HudFrame f) + { + Rect safe = DuelHudLayout.Safe(f); + Rect player = DuelHudLayout.HpCard(safe, true); + Rect enemy = DuelHudLayout.HpCard(safe, false); + float gap = enemy.x - player.xMax - UITheme.Space.M * 2f; + // 폭은 튀어나올 때(최대 BattleEntryStaging.PopPeak배)에도 자리 밖으로 나가지 않게 줄여 잡는다 — 가로 화면이면 HP 카드에 닿지 않게. + if (gap >= MinGapWidth) + { + float w = Mathf.Min(TitleMaxWidth, gap / BattleEntryStaging.PopPeak); + float cx = (player.xMax + enemy.x) * 0.5f; + float h = Mathf.Min(TitleHeight, DuelHudLayout.HpCardHeight); + return new Rect(cx - w * 0.5f, player.y + (DuelHudLayout.HpCardHeight - h) * 0.5f, w, h); + } + float width = Mathf.Min(TitleMaxWidth, f.ContentWidth / BattleEntryStaging.PopPeak); + float top = BattleHudStack.BubbleTop(enemy, true, true); + return new Rect(safe.center.x - width * 0.5f, top, width, TitleHeight); + } + + public static Rect RaidTitle(HudFrame f) + { + Rect stage = RaidStageLayout.LowerStage(f); + float w = Mathf.Min(TitleMaxWidth, f.ContentWidth / BattleEntryStaging.PopPeak); + float h = Mathf.Min(RaidTitleHeight, stage.height); + return new Rect(stage.x + (stage.width - w) * 0.5f, stage.y + (stage.height - h) * 0.5f, w, h); + } + } + + /// 상성 표시의 색 갈래 — 잘 통함(민트)·안 통함(코랄)·거의 안 통함(회색). + public enum MatchupTone { None, Good, Bad, Faint } + + /// + /// 상성을 아이 말로 — 순수. 타격 순간 피해 숫자 위의 큰 표시(화살표 + 「아주 잘 통했다!」)와 스킬 카드 칩(「잘 통해요」 + 화살표). + /// 등급은 (배수 경계 2.0 / 1.05 / 0.95 / 0.5)가 정한다 — 1대1·레이드·칩이 같은 등급을 읽는다. + /// 예전엔 같은 타격에 컨트롤러가 위쪽 효과 문구(「효과가 굉장했다!」)도 띄워 한 번 맞는데 상성 문구가 두 번 보일 뻔했다 — 지금은 여기 하나다. + /// + public static class MatchupHud + { + public const int CaptionFont = 34; + public const float CaptionHeight = 52f; + public const float CaptionArrowWidth = 22f; + public const float CaptionArrowHeight = 30f; + public const float ArrowGap = 4f; + public const float IconTextGap = 10f; + public const float CaptionPadX = 18f; + /// 숫자 위 「치명타!」 줄이 숫자 윗변 위로 차지하는 높이(가운데 = 윗변 − 22, 높이 40). + public const float CritRowHeight = 42f; + /// 숫자(또는 「치명타!」)와 상성 표시 사이. + public const float CaptionGap = 4f; + public const float PopPeak = 1.3f; + public const float PopSeconds = 0.2f; + + public const int ChipFont = 20; + public const float ChipHeight = 28f; + public const float ChipArrowWidth = 14f; + public const float ChipPadX = 12f; + + /// 타격 순간 문구. 보통이면 null(아무것도 띄우지 않는다). + public static string ImpactText(Matchup m) + { + switch (m) + { + case Matchup.Super: return "아주 잘 통했다!"; + case Matchup.Good: return "잘 통했다!"; + case Matchup.Weak: return "별로 안 통했다…"; + case Matchup.Resisted: return "거의 안 통했다…"; + default: return null; + } + } + + /// 스킬 카드 칩 문구(화살표는 도형으로 따로 그린다). 보통이면 null. + public static string ChipText(Matchup m) + { + switch (m) + { + case Matchup.Super: return "아주 잘 통해요"; + case Matchup.Good: return "잘 통해요"; + case Matchup.Weak: return "안 통해요"; + case Matchup.Resisted: return "거의 안 통해요"; + default: return null; + } + } + + /// 화살표 개수 — 아주(2)·잘(1)·안(1)·거의 안(2), 보통 0. + public static int ArrowCount(Matchup m) + { + switch (m) + { + case Matchup.Super: + case Matchup.Resisted: + return 2; + case Matchup.Good: + case Matchup.Weak: + return 1; + default: + return 0; + } + } + + /// 화살표가 위를 가리키는가(잘 통함). + public static bool PointsUp(Matchup m) => ElementMatchup.IsFavorable(m); + + public static MatchupTone Tone(Matchup m) + { + switch (m) + { + case Matchup.Super: + case Matchup.Good: + return MatchupTone.Good; + case Matchup.Weak: + return MatchupTone.Bad; + case Matchup.Resisted: + return MatchupTone.Faint; + default: + return MatchupTone.None; + } + } + + /// 화살표 묶음의 폭. + public static float IconWidth(int arrows, float arrowWidth, float gap) + => arrows <= 0 ? 0f : arrows * arrowWidth + (arrows - 1) * gap; + + /// 타격 표시 알약의 폭 — 좌우 여백 + 화살표 + 간격 + 글자. + public static float CaptionWidth(Matchup m, float textWidth) + { + float icon = IconWidth(ArrowCount(m), CaptionArrowWidth, ArrowGap); + return CaptionPadX * 2f + icon + (icon > 0f ? IconTextGap : 0f) + Mathf.Max(0f, textWidth); + } + + /// + /// 타격 표시의 세로 가운데 — 피해 숫자 윗변(1대1·레이드의 「치명타!」와 같은 어림: 가운데 − 글자 × 0.55) 위, 치명타면 「치명타!」 줄 위. + /// + public static float CaptionCenterY(float numberCenterY, int numberFontSize, bool crit) + { + float top = numberCenterY - numberFontSize * 0.55f; + if (crit) top -= CritRowHeight; + return CaptionCenterAbove(top); + } + + /// 윗변 바로 위에 서는 타격 표시의 세로 가운데. + public static float CaptionCenterAbove(float topY) => topY - CaptionGap - CaptionHeight * 0.5f; + + /// 뜬 뒤 초의 크기 — 1.3에서 0.2초에 걸쳐 1로. 줄인 움직임이면 1. + public static float CaptionScale(float age, bool reducedMotion) + { + if (reducedMotion || age >= PopSeconds) return 1f; + if (age <= 0f) return PopPeak; + return Mathf.Lerp(PopPeak, 1f, Mathf.SmoothStep(0f, 1f, age / PopSeconds)); + } + + /// 폭 ×2의 표시가 안전 영역 밖으로 나가지 않는 가운데 x. + public static float ClampCenterX(float cx, float halfWidth, HudFrame f) + { + float lo = f.ContentLeft + halfWidth; + float hi = f.ContentRight - halfWidth; + return lo > hi ? (f.ContentLeft + f.ContentRight) * 0.5f : Mathf.Clamp(cx, lo, hi); + } + + /// 스킬 카드 칩의 폭 — 줄 폭을 넘지 않는다. + public static float ChipWidth(Matchup m, float textWidth, float rowWidth) + { + float icon = IconWidth(ArrowCount(m), ChipArrowWidth, ArrowGap); + return Mathf.Min(rowWidth, ChipPadX * 2f + Mathf.Max(0f, textWidth) + (icon > 0f ? UITheme.Space.XS + icon : 0f)); + } + } + + /// + /// 1대1 전용기 컷인 띠의 자리·움직임 — 순수. 아레나가 시전자를 클로즈업하는 동안(BattleArenaController.IsSignatureCutIn, + /// 0~0.5초) 화면 가로를 가로지르는 띠가 내 쪽이면 왼쪽에서, 상대 쪽이면 오른쪽에서 밀려 들어온다. 띠는 아래쪽 행동 문구 띠 바로 위 — + /// HP 카드 쌓기·전투 문구 줄(화면 25%)·공격 배너와 겹치지 않는다. 컷인이 끝나면 에 걸쳐 사라진다. + /// + public static class SignatureCutInLayout + { + public const float BandHeight = 210f; + public const int SkillFont = 72; + public const int CasterFont = 28; + public const int BadgeFont = 26; + public const float BadgeWidth = 150f; + public const float BadgeHeight = 46f; + public const float PadTop = 18f; + public const float PadBottom = 14f; + public const float RowGap = 6f; + /// 컷인 진행률 중 밀려 들어오는 몫 — 나머지 동안은 제자리에서 읽힌다. + public const float SlideShare = 0.45f; + /// 컷인이 끝난 뒤 사라지는 초(연출 시계). + public const float TailSeconds = 0.18f; + public const string Badge = "전용기!"; + + public struct Plan + { + public Rect Band; + public Rect Badge; + public Rect Caster; + public Rect Skill; + } + + /// 띠 한 장 — 화면 가로 전체(가장자리까지), 글자는 안전 영역 안. 내 쪽이면 왼쪽 맞춤, 상대 쪽이면 오른쪽 맞춤. + public static Plan For(HudFrame f, bool fromPlayer) + { + Rect bar = DuelHudLayout.ActionBar(f); + float bottom = bar.y - UITheme.Space.M; + var plan = new Plan { Band = new Rect(0f, bottom - BandHeight, f.Width, BandHeight) }; + float left = f.ContentLeft + UITheme.Space.L; + float right = f.ContentRight - UITheme.Space.L; + float w = Mathf.Max(1f, right - left); + float rowY = plan.Band.y + PadTop; + float casterW = Mathf.Max(1f, w - BadgeWidth - UITheme.Space.S); + if (fromPlayer) + { + plan.Badge = new Rect(left, rowY, BadgeWidth, BadgeHeight); + plan.Caster = new Rect(plan.Badge.xMax + UITheme.Space.S, rowY, casterW, BadgeHeight); + } + else + { + plan.Badge = new Rect(right - BadgeWidth, rowY, BadgeWidth, BadgeHeight); + plan.Caster = new Rect(left, rowY, casterW, BadgeHeight); + } + float skillTop = rowY + BadgeHeight + RowGap; + plan.Skill = new Rect(left, skillTop, w, Mathf.Max(1f, plan.Band.yMax - PadBottom - skillTop)); + return plan; + } + + /// 밀려 들어오는 정도 — 1이면 화면 폭만큼 바깥, 0이면 제자리. 줄인 움직임이면 늘 0. + public static float Slide(float cutInProgress, bool reducedMotion) + { + if (reducedMotion) return 0f; + float u = Mathf.Clamp01(cutInProgress / SlideShare); + float eased = 1f - Mathf.Pow(1f - u, 3f); + return 1f - eased; + } + + /// 가로 밀림(가상 px) — 내 쪽은 왼쪽(음수)에서, 상대 쪽은 오른쪽(양수)에서. + public static float Offset(float slide, bool fromPlayer, float screenWidth) + => slide * screenWidth * (fromPlayer ? -1f : 1f); + + /// + /// 불투명도 — 컷인 동안 1(첫 프레임부터 — 짧은 구간이라 처음부터 읽혀야 한다), 끝난 뒤 에 걸쳐 0. + /// 는 컷인 끝 뒤 지난 초(컷인 중이면 음수). + /// + public static float Alpha(float sinceCutInEnd) + { + if (sinceCutInEnd <= 0f) return 1f; + return Mathf.Clamp01(1f - sinceCutInEnd / TailSeconds); + } + } + + /// 승리 화면 보상 줄의 색 갈래. + public enum RewardTone { Candy, Exp, Coin, Item, Caught, Missed, Bonus, Cheer } + + /// 승리 화면 보상 한 줄. 면 두 칸을 다 쓴다(곤충 포획·응원 한 줄 — 맨 끝에 온다). + public struct RewardLine + { + public string Text; + public RewardTone Tone; + public bool Wide; + + public RewardLine(string text, RewardTone tone, bool wide = false) + { + Text = text; + Tone = tone; + Wide = wide; + } + } + + /// + /// 승리 화면 보상 줄 짓기 — 순수. 순서는 캔디 → 경험치 → 코인 → 아이템 → 곤충(가장 큰 소식이 마지막에 뜬다). + /// 0인 보상은 줄을 만들지 않는다. 좁은 줄이 먼저, 넓은 줄이 뒤다(가 이 순서를 믿는다). + /// + public static class BattleRewardLines + { + public const string DuelEmptyCheer = "파트너와 함께 한 뼘 자랐다!"; + public const string DreamCheer = "관중석에서 함성이 쏟아진다!"; + /// 이름표가 없는 재료(mat_*) — 아이에게 ID를 보이지 않는다. + public const string MaterialName = "재료"; + + /// 아이템 데이터베이스에 이름이 없을 때의 표시 — 재료(mat_로 시작)는 「재료」, 그 밖은 ID 그대로(표시 누락 방지). + public static string FallbackItemName(string itemId) + { + if (string.IsNullOrEmpty(itemId)) return null; + return itemId.StartsWith("mat_", System.StringComparison.Ordinal) ? MaterialName : itemId; + } + + /// 이번 승리로 받은 코인(모르면 0 — 줄을 만들지 않는다). + /// 아이템 이름(없으면 빈 값). + /// 전투 포획 기회가 있었다(야생 승리). + public static List Duel(int candy, int exp, int coins, string itemName, int itemCount, + bool captureTried, bool captured, string insectName) + { + var lines = new List(6); + if (candy > 0) lines.Add(new RewardLine($"캔디 +{candy}", RewardTone.Candy)); + if (exp > 0) lines.Add(new RewardLine($"경험치 +{exp}", RewardTone.Exp)); + if (coins > 0) lines.Add(new RewardLine($"코인 +{coins}", RewardTone.Coin)); + if (!string.IsNullOrEmpty(itemName) && itemCount > 0) + lines.Add(new RewardLine($"{itemName} ×{itemCount}", RewardTone.Item)); + if (captureTried) + { + string bug = string.IsNullOrEmpty(insectName) ? "곤충" : insectName; + lines.Add(captured + ? new RewardLine($"{bug}{KoreanJosa.EulReul(bug)} 잡았다!", RewardTone.Caught, true) + : new RewardLine($"{bug}{KoreanJosa.EunNeun(bug)} 달아났다", RewardTone.Missed, true)); + } + if (lines.Count == 0) lines.Add(new RewardLine(DuelEmptyCheer, RewardTone.Cheer, true)); + return lines; + } + + /// 레이드 승리 — 캔디·경험치(×3이 이미 곱해진 값)·보너스 표시·보스 포획(레이드는 이기면 반드시 잡는다). + public static List Raid(int candy, int exp, string bossName) + { + var lines = new List(4); + if (candy > 0) lines.Add(new RewardLine($"캔디 +{candy}", RewardTone.Candy)); + if (exp > 0) lines.Add(new RewardLine($"경험치 +{exp}", RewardTone.Exp)); + lines.Add(new RewardLine("레이드 보너스 ×3", RewardTone.Bonus)); + string boss = string.IsNullOrEmpty(bossName) ? "보스" : bossName; + lines.Add(new RewardLine($"{boss}{KoreanJosa.EulReul(boss)} 잡았다!", RewardTone.Caught, true)); + return lines; + } + + /// 보상이 없는 승리(꿈 챔피언전) — 응원 한 줄. + public static List Cheer(string text) + => new List { new RewardLine(string.IsNullOrEmpty(text) ? DreamCheer : text, RewardTone.Cheer, true) }; + } + + /// + /// 승리 화면 — 순수. 위에서부터 큰 「승리!」(살짝 튀어나온다) → [대결 상대의 한마디] → (가운데는 비운다 — 아레나 승리 연출 + /// 2초가 거기서 돈다) → 보상 판(0.8초부터 한 줄씩) → 맨 아래 깜빡이는 「눌러서 계속」(닫을 수 있을 때부터). + /// 보상 판은 좁은 줄을 두 칸씩, 넓은 줄(곤충 포획)은 한 줄 통째로 — 줄 수를 줄여 가운데를 넓게 남긴다. + /// 예전 결과 창은 화면 가운데 판 하나라 승리 포즈를 덮었고, 저절로 돌아간다는 안내가 적혀 있었다(이제 눌러야 닫힌다 — BattleResultRules). + /// + public static class BattleVictoryLayout + { + public const string Title = "승리!"; + public const string ChampionTitle = "챔피언 승리!"; + public const string RaidTitle = "레이드 승리!"; + public const string ContinueHint = "눌러서 계속"; + + public const int TitleFont = 96; + public const float TitleHeight = 150f; + public const float TitleMaxWidth = 760f; + public const float QuoteMaxWidth = 900f; + + public const int RowFont = 30; + public const int WideRowFont = 34; + public const float RowHeight = 56f; + public const float RowGap = 8f; + public const float PanelPad = 14f; + public const float PanelMaxWidth = 940f; + + public const int HintFont = 30; + public const float HintHeight = 60f; + public const float HintMaxWidth = 420f; + + /// 보상 첫 줄이 뜨는 초 — 「승리!」가 자리 잡고 아레나 포즈가 한창일 때. + public const float RewardStart = 0.8f; + public const float RewardStep = 0.3f; + public const float RewardFade = 0.2f; + /// 뜰 때 아래에서 올라오는 거리. + public const float RewardRise = 10f; + + public const float TitlePopStart = 0.6f; + public const float TitlePopPeak = 1.15f; + public const float TitlePopPeakAt = 0.18f; + public const float TitlePopSettleAt = 0.34f; + + public const float HintFadeSeconds = 0.25f; + public const float HintBlinkPeriod = 1.1f; + + public static Rect TitleRect(HudFrame f) => f.TopPanel(Mathf.Min(TitleMaxWidth, f.ContentWidth), TitleHeight); + + /// 대결 상대의 한마디 — 「승리!」 바로 아래(말풍선 높이). 「승리!」가 튀어나올 때 커지는 몫만큼 더 띄운다. + public static Rect QuoteRect(HudFrame f) + { + Rect title = TitleRect(f); + float w = Mathf.Min(QuoteMaxWidth, f.ContentWidth); + float popOverflow = title.height * (TitlePopPeak - 1f) * 0.5f; + return new Rect(f.ContentLeft + (f.ContentWidth - w) * 0.5f, title.yMax + popOverflow + UITheme.Space.S, w, + BattleHudStack.BubbleHeight); + } + + public static Rect HintRect(HudFrame f) => f.BottomPanel(Mathf.Min(HintMaxWidth, f.ContentWidth), HintHeight); + + public struct Grid + { + public Rect Panel; + public int Columns; + public int Narrow; + public int Rows; + } + + /// 좁은 줄이 둘 이상이면 두 칸. + public static int Columns(int narrow) => narrow >= 2 ? 2 : 1; + + /// 보상 판 — 「눌러서 계속」 바로 위, 가로 가운데. + public static Grid Rewards(HudFrame f, IReadOnlyList lines) + { + int narrow = 0, wide = 0; + if (lines != null) + for (int i = 0; i < lines.Count; i++) + if (lines[i].Wide) wide++; + else narrow++; + int cols = Columns(narrow); + int rows = (narrow + cols - 1) / cols + wide; + float h = PanelPad * 2f + rows * RowHeight + Mathf.Max(0, rows - 1) * RowGap; + float w = Mathf.Min(PanelMaxWidth, f.ContentWidth); + Rect hint = HintRect(f); + float bottom = hint.y - UITheme.Space.S; + return new Grid + { + Panel = new Rect(f.ContentLeft + (f.ContentWidth - w) * 0.5f, bottom - h, w, h), + Columns = cols, + Narrow = narrow, + Rows = rows + }; + } + + /// 번째 줄의 칸. 좁은 줄은 왼쪽→오른쪽·위→아래, 넓은 줄은 그 아래 한 줄씩. + public static Rect Cell(Grid g, int index, IReadOnlyList lines) + { + float innerW = g.Panel.width - PanelPad * 2f; + float x0 = g.Panel.x + PanelPad; + float y0 = g.Panel.y + PanelPad; + int narrowBefore = 0, wideBefore = 0; + for (int i = 0; i < index && lines != null && i < lines.Count; i++) + if (lines[i].Wide) wideBefore++; + else narrowBefore++; + bool wideRow = lines != null && index < lines.Count && lines[index].Wide; + int narrowRows = (g.Narrow + g.Columns - 1) / g.Columns; + if (wideRow) + { + int row = narrowRows + wideBefore; + return new Rect(x0, y0 + row * (RowHeight + RowGap), innerW, RowHeight); + } + int col = narrowBefore % g.Columns; + int r = narrowBefore / g.Columns; + float cellW = (innerW - (g.Columns - 1) * RowGap) / g.Columns; + return new Rect(x0 + col * (cellW + RowGap), y0 + r * (RowHeight + RowGap), cellW, RowHeight); + } + + public static float RewardAppearAt(int index) => RewardStart + Mathf.Max(0, index) * RewardStep; + + public static float RewardAlpha(float shownSeconds, int index) + => Mathf.Clamp01((shownSeconds - RewardAppearAt(index)) / RewardFade); + + /// 「승리!」 크기 — 작게 시작해 살짝 넘쳤다가(1.15) 1로. 줄인 움직임이면 1. + public static float TitleScale(float shownSeconds, bool reducedMotion) + { + if (reducedMotion) return 1f; + float s = shownSeconds; + if (s <= 0f) return TitlePopStart; + if (s < TitlePopPeakAt) return Mathf.Lerp(TitlePopStart, TitlePopPeak, Mathf.SmoothStep(0f, 1f, s / TitlePopPeakAt)); + if (s < TitlePopSettleAt) + return Mathf.Lerp(TitlePopPeak, 1f, Mathf.SmoothStep(0f, 1f, (s - TitlePopPeakAt) / (TitlePopSettleAt - TitlePopPeakAt))); + return 1f; + } + + public static float TitleAlpha(float shownSeconds) => Mathf.Clamp01(shownSeconds / 0.12f); + + /// + /// 「눌러서 계속」 불투명도 — 닫을 수 없으면 0, 닫을 수 있게 된 뒤 0.25초에 떠올라 천천히 깜빡인다(0.55~1). 줄인 움직임이면 깜빡이지 않는다. + /// + /// 닫을 수 있게 된 뒤 지난 초. + public static float HintAlpha(bool canClose, float sinceCanClose, bool reducedMotion) + { + if (!canClose) return 0f; + float fade = Mathf.Clamp01(sinceCanClose / HintFadeSeconds); + if (reducedMotion) return fade; + float blink = 0.775f + 0.225f * Mathf.Cos(Mathf.Max(0f, sinceCanClose) / HintBlinkPeriod * Mathf.PI * 2f); + return fade * blink; + } + } + + /// + /// 레이드 팀 패널 줄의 움직임 — 순수. 3D 연출(팀원 공격·보스 예고·보스 공격·합체공격) 동안 화면 아래로 비켜서고, + /// 수문장 등장·그림자 변신·결과 동안은 숨는다(그때 줄이 화면 53%에 떠서 보스 아랫부분과 변신 연기를 가렸다 — 3단계 QA). + /// 내려갈 때는 곧바로 — 기술을 고른 순간 스킬 패널이 사라지는데 줄이 0.25초 동안 화면 가운데를 지나 내려가서, + /// 행동 문구 띠(줄에 붙어 있다)와 함께 "아래 패널이 가운데 떴다 내려가는" 것처럼 보였다(1단계 QA). 올라올 때는 미끄러진다. + /// + public static class RaidTeamStrip + { + public const float SlideSeconds = 0.25f; + public const float FadeSeconds = 0.2f; + + /// 다음 내려감 값(0 = 쉬는 자리, 1 = 화면 아래). + public static float NextDrop(float current, bool acting, float dt) + => acting ? 1f : Mathf.MoveTowards(current, 0f, Mathf.Max(0f, dt) / SlideSeconds); + + /// 줄을 숨기는 단계인가. + public static bool Hidden(bool guardianIntro, bool transforming, bool result) => guardianIntro || transforming || result; + + /// 다음 숨김 값(0 = 보임, 1 = 숨음). 이면 곧바로 숨는다(전투 첫 장면부터 숨어야 할 때). + public static float NextHide(float current, bool hidden, bool snap, float dt) + { + if (hidden) return snap ? 1f : Mathf.MoveTowards(current, 1f, Mathf.Max(0f, dt) / FadeSeconds); + return Mathf.MoveTowards(current, 0f, Mathf.Max(0f, dt) / FadeSeconds); + } + } + + /// + /// 1대1·레이드가 함께 쓰는 그리기 — 진입 막·큰 문구, 상성 표시·칩, 전용기 띠, 승리 화면 조각. 자리·시각은 위의 순수 계산이 정하고 + /// 여기는 칠하기만 한다. 크기 변화(튀어나오기)는 글자 상자를 바꾸지 않고 GUI 행렬로 키운다 — 상자가 매 프레임 바뀌면 LabelFit 캐시가 + /// 프레임마다 새 항목으로 찬다. 회전·확대는 GUIUtility.RotateAroundPivot 대신 행렬을 곱한다(UIScale≠1이면 피벗이 어긋난다). + /// + internal static class BattleFeelDraw + { + private static GUIStyle titleStyle; + private static GUIStyle subStyle; + private static GUIStyle captionStyle; + private static GUIStyle chipStyle; + private static GUIStyle badgeStyle; + private static GUIStyle casterStyle; + private static GUIStyle skillStyle; + private static GUIStyle victoryStyle; + private static GUIStyle rowStyle; + private static GUIStyle hintStyle; + private static GUIContent measure; + private static readonly float[] captionWidths = new float[5]; + private static readonly float[] chipWidths = new float[5]; + + private static void EnsureStyles() + { + if (titleStyle != null) return; + titleStyle = new GUIStyle(GUI.skin.label) + { fontSize = BattleEntryLayout.TitleFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, wordWrap = false }; + subStyle = new GUIStyle(GUI.skin.label) + { fontSize = BattleEntryLayout.RaidSubFont, alignment = TextAnchor.MiddleCenter, wordWrap = false }; + captionStyle = new GUIStyle(GUI.skin.label) + { fontSize = MatchupHud.CaptionFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft, wordWrap = false }; + chipStyle = new GUIStyle(GUI.skin.label) + { fontSize = MatchupHud.ChipFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft, wordWrap = false }; + badgeStyle = new GUIStyle(GUI.skin.label) + { fontSize = SignatureCutInLayout.BadgeFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, wordWrap = false }; + casterStyle = new GUIStyle(GUI.skin.label) + { fontSize = SignatureCutInLayout.CasterFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft, wordWrap = false }; + skillStyle = new GUIStyle(GUI.skin.label) + { fontSize = SignatureCutInLayout.SkillFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft, wordWrap = false }; + victoryStyle = new GUIStyle(GUI.skin.label) + { fontSize = BattleVictoryLayout.TitleFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, wordWrap = false }; + rowStyle = new GUIStyle(GUI.skin.label) + { fontSize = BattleVictoryLayout.RowFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft, wordWrap = false }; + hintStyle = new GUIStyle(GUI.skin.label) + { fontSize = BattleVictoryLayout.HintFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, wordWrap = false }; + measure = new GUIContent(); + for (int i = 0; i < captionWidths.Length; i++) + { + captionWidths[i] = -1f; + chipWidths[i] = -1f; + } + } + + private static Color WithAlpha(Color c, float a) => new Color(c.r, c.g, c.b, c.a * a); + + /// 가운데 를 축으로 배 — 돌려줄 원래 행렬을 준다. + private static Matrix4x4 PushScale(Vector2 pivot, float scale) + { + Matrix4x4 saved = GUI.matrix; + if (Mathf.Abs(scale - 1f) > 0.0001f) + GUI.matrix = saved + * Matrix4x4.TRS(new Vector3(pivot.x, pivot.y, 0f), Quaternion.identity, new Vector3(scale, scale, 1f)) + * Matrix4x4.TRS(new Vector3(-pivot.x, -pivot.y, 0f), Quaternion.identity, Vector3.one); + return saved; + } + + internal static Color ToneColor(MatchupTone tone) + { + UITheme t = UITheme.Instance; + switch (tone) + { + case MatchupTone.Good: return t.accentMint; + case MatchupTone.Bad: return t.accentCoral; + case MatchupTone.Faint: return t.textSecondary; + default: return t.textPrimary; + } + } + + internal static Color RewardColor(RewardTone tone) + { + UITheme t = UITheme.Instance; + switch (tone) + { + case RewardTone.Candy: return t.accentCoral; + case RewardTone.Exp: return t.skillDefense; + case RewardTone.Coin: return t.coinColor; + case RewardTone.Item: return t.accentAmber; + case RewardTone.Caught: return t.accentMint; + case RewardTone.Bonus: return t.accentAmber; + case RewardTone.Missed: return t.textSecondary; + default: return t.textPrimary; + } + } + + /// 화살표 개를 나란히. + internal static void Arrows(Rect area, int count, bool up, float arrowWidth, float gap, Color color) + { + for (int i = 0; i < count; i++) + UIShapes.Arrow(new Rect(area.x + i * (arrowWidth + gap), area.y, arrowWidth, area.height), up, color); + } + + // ── 진입 ── + + /// + /// 화면 쓸기 — 어두운 막(호박색 띠를 앞세운 기울어진 모서리)이 오른쪽으로 쓸려 나간다. 줄인 움직임이면 막이 그 자리에서 옅어진다. + /// 전투 화면의 다른 조각(HP 카드 등)보다 나중에 불러야 덮는다. + /// + internal static void EntryWipe(HudFrame f, float entryProgress, bool reducedMotion) + { + float open = BattleEntryStaging.WipeOpen(entryProgress); + if (open >= 1f) return; + UITheme theme = UITheme.Instance; + Rect screen = new Rect(0f, 0f, f.Width, f.Height); + if (reducedMotion) + { + UISurface.Flat(screen, WithAlpha(theme.surfaceBase, 1f - open)); + return; + } + float edge = BattleEntryStaging.WipeEdgeX(open, f.Width, f.Height); + Vector2 pivot = new Vector2(edge, f.Height * 0.5f); + float reach = (f.Width + f.Height) * 1.5f; + Matrix4x4 saved = GUI.matrix; + GUI.matrix = saved + * Matrix4x4.TRS(new Vector3(pivot.x, pivot.y, 0f), Quaternion.Euler(0f, 0f, BattleEntryStaging.WipeAngle), Vector3.one) + * Matrix4x4.TRS(new Vector3(-pivot.x, -pivot.y, 0f), Quaternion.identity, Vector3.one); + float top = pivot.y - reach; + UISurface.Flat(new Rect(edge, top, reach, reach * 2f), theme.surfaceBase); + UISurface.Flat(new Rect(edge, top, BattleEntryStaging.StripeWidth, reach * 2f), theme.accentAmber); + UISurface.Flat(new Rect(edge + BattleEntryStaging.StripeWidth + 10f, top, 8f, reach * 2f), theme.accentCoral); + GUI.matrix = saved; + } + + /// 진입 큰 문구 — 어두운 카드 + 강조색 띠 + 큰 글자(둘째 줄은 있을 때만). 크기는 행렬로 키운다. + internal static void EntryTitle(Rect rect, string title, string sub, int titleFont, float alpha, float scale, Color accent) + { + if (string.IsNullOrEmpty(title) || alpha <= 0.001f) return; + EnsureStyles(); + UITheme theme = UITheme.Instance; + Color prev = GUI.color; + GUI.color = WithAlpha(prev, alpha); + Matrix4x4 saved = PushScale(rect.center, scale); + UISurface.Card(rect, theme.surfaceBase, accent); + UISurface.Flat(new Rect(rect.x + UITheme.Radius.Card, rect.y + 3f, rect.width - UITheme.Radius.Card * 2f, 6f), accent); + float pad = BattleEntryLayout.TitlePadX; + bool hasSub = !string.IsNullOrEmpty(sub); + float titleH = RaidStageLayout.LineHeight(titleFont) + 8f; + float subH = RaidStageLayout.LineHeight(BattleEntryLayout.RaidSubFont); + float blockH = hasSub ? titleH + subH : titleH; + float y = rect.y + (rect.height - blockH) * 0.5f + 2f; + Rect titleRect = new Rect(rect.x + pad, y, rect.width - pad * 2f, Mathf.Min(titleH, rect.height - 12f)); + titleStyle.fontSize = titleFont; + titleStyle.normal.textColor = new Color(0f, 0f, 0f, 0.7f); + UIHelper.LabelFit(new Rect(titleRect.x + 3f, titleRect.y + 3f, titleRect.width, titleRect.height), title, titleStyle); + titleStyle.normal.textColor = theme.textPrimary; + // 상대 이름을 데이터가 정한다 — 넘치면 글자를 줄여 맞춘다. + UIHelper.LabelFit(titleRect, title, titleStyle); + if (hasSub) + { + subStyle.normal.textColor = theme.textSecondary; + UIHelper.LabelFit(new Rect(rect.x + pad, titleRect.yMax, rect.width - pad * 2f, subH), sub, subStyle); + } + GUI.matrix = saved; + GUI.color = prev; + } + + // ── 상성 ── + + private static float CaptionTextWidth(Matchup m, string text) + { + int i = Mathf.Clamp((int)m, 0, captionWidths.Length - 1); + if (captionWidths[i] < 0f) + { + captionStyle.fontSize = MatchupHud.CaptionFont; + measure.text = text; + captionWidths[i] = captionStyle.CalcSize(measure).x; + } + return captionWidths[i]; + } + + private static float ChipTextWidth(Matchup m, string text) + { + int i = Mathf.Clamp((int)m, 0, chipWidths.Length - 1); + if (chipWidths[i] < 0f) + { + chipStyle.fontSize = MatchupHud.ChipFont; + measure.text = text; + chipWidths[i] = chipStyle.CalcSize(measure).x; + } + return chipWidths[i]; + } + + /// + /// 타격 순간의 상성 표시 — 가운데 (, )에 알약 하나(화살표 + 「아주 잘 통했다!」). + /// 보통이면 아무것도 안 그린다. 안전 영역 밖으로 나가지 않게 가로를 당긴다. + /// + internal static void MatchupCaption(HudFrame f, float cx, float cy, Matchup m, float alpha, float scale) + { + string text = MatchupHud.ImpactText(m); + if (text == null || alpha <= 0.001f) return; + EnsureStyles(); + UITheme theme = UITheme.Instance; + float textW = CaptionTextWidth(m, text); + float w = MatchupHud.CaptionWidth(m, textW); + float h = MatchupHud.CaptionHeight; + cx = MatchupHud.ClampCenterX(cx, w * 0.5f, f); + Rect pill = new Rect(cx - w * 0.5f, cy - h * 0.5f, w, h); + Color tone = ToneColor(MatchupHud.Tone(m)); + + Color prev = GUI.color; + GUI.color = WithAlpha(prev, alpha); + Matrix4x4 saved = PushScale(pill.center, scale); + UISurface.Card(pill, theme.surfaceBase, tone); + int arrows = MatchupHud.ArrowCount(m); + float iconW = MatchupHud.IconWidth(arrows, MatchupHud.CaptionArrowWidth, MatchupHud.ArrowGap); + float x = pill.x + MatchupHud.CaptionPadX; + Arrows(new Rect(x, pill.y + (h - MatchupHud.CaptionArrowHeight) * 0.5f, iconW, MatchupHud.CaptionArrowHeight), + arrows, MatchupHud.PointsUp(m), MatchupHud.CaptionArrowWidth, MatchupHud.ArrowGap, tone); + if (iconW > 0f) x += iconW + MatchupHud.IconTextGap; + captionStyle.fontSize = MatchupHud.CaptionFont; + captionStyle.normal.textColor = Color.Lerp(tone, theme.textPrimary, 0.25f); + UIHelper.LabelFit(new Rect(x, pill.y, Mathf.Max(1f, pill.xMax - MatchupHud.CaptionPadX - x + 4f), h), text, captionStyle); + GUI.matrix = saved; + GUI.color = prev; + } + + /// + /// 스킬 카드의 상성 칩 — 「잘 통해요」 + 화살표. 안에서 글자 폭에 맞춰 왼쪽(또는 오른쪽) 맞춤. 그린 자리를 돌려준다 + /// (보통이면 아무것도 그리지 않고 폭 0). 이 거짓(쿨다운)이면 회색. + /// + internal static Rect MatchupChip(Rect row, Matchup m, bool alignRight, bool usable) + { + string text = MatchupHud.ChipText(m); + if (text == null) return new Rect(alignRight ? row.xMax : row.x, row.y, 0f, row.height); + EnsureStyles(); + UITheme theme = UITheme.Instance; + float textW = ChipTextWidth(m, text); + float w = MatchupHud.ChipWidth(m, textW, row.width); + Rect chip = new Rect(alignRight ? row.xMax - w : row.x, row.y, w, row.height); + Color tone = usable ? ToneColor(MatchupHud.Tone(m)) : theme.textSecondary; + UISurface.Chip(chip, string.Empty, Color.Lerp(theme.surfaceBase, tone, 0.22f), tone); + int arrows = MatchupHud.ArrowCount(m); + float iconW = MatchupHud.IconWidth(arrows, MatchupHud.ChipArrowWidth, MatchupHud.ArrowGap); + float arrowH = Mathf.Round(chip.height * 0.62f); + Rect icon = new Rect(chip.xMax - MatchupHud.ChipPadX - iconW, chip.y + (chip.height - arrowH) * 0.5f, iconW, arrowH); + Arrows(icon, arrows, MatchupHud.PointsUp(m), MatchupHud.ChipArrowWidth, MatchupHud.ArrowGap, tone); + chipStyle.fontSize = MatchupHud.ChipFont; + chipStyle.normal.textColor = Color.Lerp(tone, theme.textPrimary, 0.25f); + float textRight = iconW > 0f ? icon.x - UITheme.Space.XS : chip.xMax - MatchupHud.ChipPadX; + UIHelper.LabelFit(new Rect(chip.x + MatchupHud.ChipPadX, chip.y, Mathf.Max(1f, textRight - chip.x - MatchupHud.ChipPadX), chip.height), + text, chipStyle); + return chip; + } + + // ── 전용기 ── + + /// + /// 전용기 컷인 띠 — 어두운 띠에 속성색 위아래 테두리·글자 뒤 속성색 물결, 「전용기!」 배지, 시전자 이름(작게), 기술 이름(아주 크게). + /// + internal static void SignatureBand(HudFrame f, bool fromPlayer, string caster, string skill, Color element, + float cutInProgress, float alpha, bool reducedMotion) + { + if (string.IsNullOrEmpty(skill) || alpha <= 0.001f) return; + EnsureStyles(); + UITheme theme = UITheme.Instance; + SignatureCutInLayout.Plan plan = SignatureCutInLayout.For(f, fromPlayer); + float dx = SignatureCutInLayout.Offset(SignatureCutInLayout.Slide(cutInProgress, reducedMotion), fromPlayer, f.Width); + Rect band = Shift(plan.Band, dx); + Color readable = SkillUILayout.GetReadableAccent(element); + + Color prev = GUI.color; + GUI.color = WithAlpha(prev, alpha); + UISurface.Flat(band, WithAlpha(theme.surfaceBase, 0.9f)); + // 글자 쪽으로 짙어지는 속성색 물결 — 세 겹으로 계단을 지어 그라데이션처럼. + for (int i = 0; i < 3; i++) + { + float share = 0.62f - i * 0.17f; + float w = band.width * share; + Rect wash = fromPlayer + ? new Rect(band.x, band.y + 6f, w, band.height - 12f) + : new Rect(band.xMax - w, band.y + 6f, w, band.height - 12f); + UISurface.Flat(wash, WithAlpha(element, 0.12f)); + } + UISurface.Flat(new Rect(band.x, band.y, band.width, 6f), element); + UISurface.Flat(new Rect(band.x, band.yMax - 6f, band.width, 6f), element); + // 속도선 — 밀려 들어오는 반대쪽으로 흐른다(줄인 움직임이면 멈춘다). + float flow = reducedMotion ? 0f : Mathf.Repeat(Time.unscaledTime * 900f, band.width); + for (int i = 0; i < 4; i++) + { + float ly = band.y + 30f + i * (band.height - 60f) / 3f; + float lx = Mathf.Repeat(i * 0.37f * band.width + (fromPlayer ? -flow : flow), band.width); + UISurface.Flat(new Rect(band.x + lx, ly, band.width * 0.18f, 2f), WithAlpha(readable, 0.45f)); + } + + Rect badge = Shift(plan.Badge, dx); + UISurface.Chip(badge, string.Empty, theme.accentAmber, theme.surfaceBase); + badgeStyle.normal.textColor = theme.surfaceBase; + UIHelper.LabelFit(badge, SignatureCutInLayout.Badge, badgeStyle); + + TextAnchor anchor = fromPlayer ? TextAnchor.MiddleLeft : TextAnchor.MiddleRight; + if (!string.IsNullOrEmpty(caster)) + { + casterStyle.alignment = anchor; + casterStyle.normal.textColor = theme.textPrimary; + UIHelper.LabelFit(Shift(plan.Caster, dx), caster, casterStyle); + } + Rect skillRect = Shift(plan.Skill, dx); + skillStyle.alignment = anchor; + skillStyle.fontSize = SignatureCutInLayout.SkillFont; + skillStyle.normal.textColor = new Color(0f, 0f, 0f, 0.75f); + UIHelper.LabelFit(new Rect(skillRect.x + 4f, skillRect.y + 4f, skillRect.width, skillRect.height), skill, skillStyle); + skillStyle.normal.textColor = readable; + // 기술 이름을 데이터가 정한다 — 넘치면 글자를 줄여 맞춘다. + UIHelper.LabelFit(skillRect, skill, skillStyle); + GUI.color = prev; + } + + private static Rect Shift(Rect r, float dx) => new Rect(r.x + dx, r.y, r.width, r.height); + + /// 「전용기!」 배지 하나(호박색 알약) — 레이드 행동 문구 띠 위에 얹는다. + internal static void SignatureBadge(Rect rect, float alpha) + { + if (alpha <= 0.001f) return; + EnsureStyles(); + UITheme theme = UITheme.Instance; + Color prev = GUI.color; + GUI.color = WithAlpha(prev, alpha); + UISurface.Chip(rect, string.Empty, theme.accentAmber, theme.surfaceBase); + badgeStyle.normal.textColor = theme.surfaceBase; + UIHelper.LabelFit(rect, SignatureCutInLayout.Badge, badgeStyle); + GUI.color = prev; + } + + // ── 승리 ── + + /// 큰 「승리!」 — 반투명 판 위 호박색 글자, 처음에 살짝 튀어나온다. + internal static void VictoryTitle(HudFrame f, string text, float shownSeconds, bool reducedMotion) + { + if (string.IsNullOrEmpty(text)) return; + EnsureStyles(); + UITheme theme = UITheme.Instance; + float alpha = BattleVictoryLayout.TitleAlpha(shownSeconds); + if (alpha <= 0.001f) return; + Rect rect = BattleVictoryLayout.TitleRect(f); + Color prev = GUI.color; + GUI.color = WithAlpha(prev, alpha); + Matrix4x4 saved = PushScale(rect.center, BattleVictoryLayout.TitleScale(shownSeconds, reducedMotion)); + UISurface.Card(rect, WithAlpha(theme.surfaceBase, 0.72f), theme.accentAmber); + victoryStyle.fontSize = BattleVictoryLayout.TitleFont; + victoryStyle.normal.textColor = new Color(0f, 0f, 0f, 0.75f); + Rect text0 = new Rect(rect.x + 16f, rect.y + 6f, rect.width - 32f, rect.height - 12f); + UIHelper.LabelFit(new Rect(text0.x + 4f, text0.y + 5f, text0.width, text0.height), text, victoryStyle); + victoryStyle.normal.textColor = theme.accentAmber; + UIHelper.LabelFit(text0, text, victoryStyle); + GUI.matrix = saved; + GUI.color = prev; + } + + /// 보상 판 — 첫 줄이 뜰 때 판도 함께 떠오르고, 줄마다 정해진 시각(0.8초부터 0.3초 간격)에 아래에서 올라온다. + internal static void RewardPanel(HudFrame f, IReadOnlyList lines, float shownSeconds) + { + if (lines == null || lines.Count == 0) return; + float panelAlpha = BattleVictoryLayout.RewardAlpha(shownSeconds, 0); + if (panelAlpha <= 0.001f) return; + EnsureStyles(); + UITheme theme = UITheme.Instance; + BattleVictoryLayout.Grid grid = BattleVictoryLayout.Rewards(f, lines); + Color prev = GUI.color; + GUI.color = WithAlpha(prev, panelAlpha); + UISurface.Card(grid.Panel, WithAlpha(theme.surfaceBase, 0.88f), theme.surfaceBorder); + for (int i = 0; i < lines.Count; i++) + { + float a = BattleVictoryLayout.RewardAlpha(shownSeconds, i); + if (a <= 0.001f) continue; + RewardLine line = lines[i]; + Rect cell = BattleVictoryLayout.Cell(grid, i, lines); + cell.y += (1f - a) * BattleVictoryLayout.RewardRise; + Color tone = RewardColor(line.Tone); + GUI.color = WithAlpha(prev, a); + UISurface.Rounded(cell, line.Wide ? Color.Lerp(theme.surfaceCard, tone, 0.18f) : theme.surfaceCard); + float dot = 18f; + UIShapes.Ellipse(new Rect(cell.x + 16f, cell.center.y - dot * 0.5f, dot, dot), tone); + rowStyle.fontSize = line.Wide ? BattleVictoryLayout.WideRowFont : BattleVictoryLayout.RowFont; + rowStyle.alignment = line.Wide ? TextAnchor.MiddleCenter : TextAnchor.MiddleLeft; + rowStyle.normal.textColor = line.Wide ? Color.Lerp(tone, theme.textPrimary, 0.2f) : theme.textPrimary; + Rect textRect = line.Wide + ? new Rect(cell.x + 44f, cell.y, cell.width - 88f, cell.height) + : new Rect(cell.x + 44f, cell.y, cell.width - 56f, cell.height); + // 곤충·아이템 이름을 데이터가 정한다 — 넘치면 글자를 줄여 맞춘다. + UIHelper.LabelFit(textRect, line.Text, rowStyle); + } + GUI.color = prev; + } + + /// 맨 아래 「눌러서 계속」 알약 — 닫을 수 있을 때부터 천천히 깜빡인다. + internal static void ContinueHint(HudFrame f, bool canClose, float sinceCanClose, bool reducedMotion) + { + float alpha = BattleVictoryLayout.HintAlpha(canClose, sinceCanClose, reducedMotion); + if (alpha <= 0.001f) return; + EnsureStyles(); + UITheme theme = UITheme.Instance; + Rect rect = BattleVictoryLayout.HintRect(f); + Color prev = GUI.color; + GUI.color = WithAlpha(prev, alpha); + UISurface.Card(rect, WithAlpha(theme.surfaceBase, 0.85f), theme.accentMint); + hintStyle.fontSize = BattleVictoryLayout.HintFont; + hintStyle.normal.textColor = theme.textPrimary; + UIHelper.LabelFit(new Rect(rect.x + 12f, rect.y + 4f, rect.width - 24f, rect.height - 8f), BattleVictoryLayout.ContinueHint, hintStyle); + GUI.color = prev; + } + + /// 패배·도주 창 안의 「눌러서 계속」 한 줄(판 없이 글자만) — 창의 예전 안내 자리에. + internal static void ContinueHintLine(Rect rect, bool canClose, float sinceCanClose, bool reducedMotion) + { + float alpha = BattleVictoryLayout.HintAlpha(canClose, sinceCanClose, reducedMotion); + if (alpha <= 0.001f) return; + EnsureStyles(); + Color prev = GUI.color; + GUI.color = WithAlpha(prev, alpha); + hintStyle.fontSize = 24; + hintStyle.normal.textColor = UITheme.Instance.textPrimary; + UIHelper.LabelFit(rect, BattleVictoryLayout.ContinueHint, hintStyle); + hintStyle.fontSize = BattleVictoryLayout.HintFont; + GUI.color = prev; + } + } +} diff --git a/Assets/Scripts/UI/BattleFeelHud.cs.meta b/Assets/Scripts/UI/BattleFeelHud.cs.meta new file mode 100644 index 0000000..feafb78 --- /dev/null +++ b/Assets/Scripts/UI/BattleFeelHud.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a769d5f0b63be26429e70bc3e906ef4a \ No newline at end of file diff --git a/Assets/Scripts/UI/BattleScreenUI.Duel.cs b/Assets/Scripts/UI/BattleScreenUI.Duel.cs new file mode 100644 index 0000000..3b10847 --- /dev/null +++ b/Assets/Scripts/UI/BattleScreenUI.Duel.cs @@ -0,0 +1,622 @@ +using InsectGame.Battle; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.NPC; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 1v1 전투의 상대가 누구인가를 보여 주는 연출 — 대결 컷인, 전투 중 한마디, 결과 한마디, + /// 그리고 수문장 컷인. + /// + /// 예전엔 관장과의 마지막 대결도, 초원의 수호자도, 필드의 잡곤충과 똑같이 "○○와 마주쳤다" + /// 한 줄로 시작하고 끝까지 아무 말이 없었다. 대사는 가 들고, 누구와 싸우는지는 + /// 컨트롤러의 DuelOpponentId·EnemyGuardianRegionId(시작 시점 스냅샷)를 읽는다. + /// + /// 모놀리스 본체를 키우지 않으려고 partial로 떼었다. 본체는 네 군데서 부른다 — + /// 시작(), 인트로 길이(), + /// Update(), OnGUI(컷인·말풍선·결과 한마디). + /// + public partial class BattleScreenUI + { + private const float PlainIntroSeconds = 2.0f; + /// + /// 컷인 길이(인트로 시계 초 = 1배속 실제 초). 진입 구간() 뒤에 시작하고, + /// 2배속이어도 실제 밑으로는 줄지 않는다(BattleScreenUI.Flow). + /// + private const float CutInSeconds = 3.2f; + /// 전투 중 말풍선 — 읽는 시간이라 배속과 무관하게 실제 초로 센다. + private const float BubbleSeconds = 2.8f; + private const float TauntDelay = 0.7f; + + private string duelKey = string.Empty; + private bool hasDuelLines; + private DuelBanter.Lines duelLines; + private DuelBanter.Tracker duelTracker; + private string guardianKey = string.Empty; + private RegionData guardianRegion; + private string bubbleText; + private float bubbleShownAt = -99f; + + // 컷인의 "나" — 3D 마네킹(의상 창과 같은 렌더러). 없으면 2D 도트. + private CharacterModelPreviewRenderer characterPreview; + + /// + /// 결투 컷인의 "나"를 지금 장착한 3D로 그린다. 2D 도트는 모자·겉옷·도구 대부분을 못 그려 + /// 공들여 맞춘 옷이 대결 장면에서 사라졌다. 모놀리스 본체를 키우지 않게 이 partial에 둔다. + /// + public void AutoWire(CharacterModelPreviewRenderer preview) + { + if (characterPreview == null) characterPreview = preview; + } + + /// 컷인 속 내 모습 — 오른쪽(상대)을 3/4으로 바라본다. 발끝을 feet에 맞춘다. + private void DrawMyCutInFigure(float centerX, float feet, float scale) + { + // 2D 기준: 몸 가운데 = feet − 74.5 × scale, 키 ≈ 2 × 74.5 × scale. + float h = 2f * 74.5f * scale; + Texture me = characterPreview != null ? characterPreview.GetEquippedPreview(140f) : null; + if (me != null) + { + float w = h / CharacterModelPreviewRenderer.PreviewHeightPerWidth; + GUI.DrawTexture(new Rect(centerX - w * 0.5f, feet - h, w, h), me, ScaleMode.ScaleToFit, true); + return; + } + CharacterPortraitRenderer.DrawWithOutfit(centerX, feet - 74.5f * scale, scale); + } + + private bool duelStylesReady; + private GUIStyle cutInNameStyle; + private GUIStyle cutInTitleStyle; + private GUIStyle cutInSubStyle; + private GUIStyle cutInVsStyle; + private GUIStyle cutInLineStyle; + private GUIStyle bubbleNameStyle; + private GUIStyle bubbleLineStyle; + + private bool HasCutIn => hasDuelLines || guardianRegion != null; + // 진입 구간(진입 샷·화면 쓸기·나타났다 문구) + [컷인 | 평범한 인트로의 나머지] — BattleReadPacing.IntroSeconds. + private float IntroSeconds => BattleReadPacing.IntroSeconds(IntroCutInSeconds, PlainIntroSeconds); + + /// 새 전투 — 앞 전투의 상대·말풍선·순간 기록을 버린다. + private void ResetDuelPresentation() + { + duelKey = string.Empty; + hasDuelLines = false; + duelLines = default; + duelTracker = default; + guardianKey = string.Empty; + guardianRegion = null; + bubbleText = null; + bubbleShownAt = -99f; + ResetEnemySendOut(); + } + + /// + /// 컨트롤러의 상대 정보를 따라간다. 시작 콜백에서 한 번 읽으면 안 된다 — 간부전은 + /// StartDuel(여기서 인트로가 선다) 다음에 SetDuelOpponent가 불리므로 + /// 그 시점엔 아직 빈 값이다. 매 프레임 사본과 비교해 바뀌었을 때만 표를 다시 찾는다. + /// + private void SyncDuelContext() + { + if (battleController == null) return; + string key = battleController.DuelOpponentId ?? string.Empty; + if (key != duelKey) + { + duelKey = key; + hasDuelLines = DuelBanter.TryGet(key, out duelLines); + duelTracker = default; + } + string region = battleController.EnemyGuardianRegionId ?? string.Empty; + if (region != guardianKey) + { + guardianKey = region; + guardianRegion = null; + if (region.Length > 0) + { + if (cachedRegionMgr == null) cachedRegionMgr = FindFirstObjectByType(); + if (cachedRegionMgr != null) guardianRegion = cachedRegionMgr.GetRegionById(region); + // 리전 관리자가 없는 씬(QA 캡처 빌드)에서는 정의표에서 찾는다 — 수문장 이름은 정의에 박혀 있다. + if (guardianRegion == null) guardianRegion = FindRegionDefinition(region); + } + } + } + + private static RegionData FindRegionDefinition(string regionId) + { + foreach (RegionData r in RegionDefinitions.CreateAll()) + if (r != null && r.regionId == regionId) return r; + return null; + } + + /// + /// HP가 선을 넘는 순간 상대가 한마디 한다. 보이는 막대(display)를 기준으로 — 맞는 순간과 맞춘다. + /// 팀 대결이면 상대 HP 순간(흔들림·위기)은 에이스(마지막 곤충)에서만 — 첫 곤충이 절반 아래로 떨어졌다고 + /// 무너지는 소리를 하지 않게(). 순번은 화면에 서 있는 곤충의 것이다 + /// ( — 쓰러지는 곤충의 막대가 줄어드는 동안 컨트롤러는 이미 다음 순번이다). + /// 교체 문구가 떠 있는 동안은 말하지 않는다 — 가운데 한마디와 HP 카드 밑 말풍선이 겹친다. + /// + private void TickDuelBanter() + { + SyncDuelContext(); + TickEnemySendOut(); + if (sendOutShowing) return; + if (!hasDuelLines || resultShown || playerStats == null || enemyStats == null) return; + if (phase == Phase.None || phase == Phase.Intro || phase == Phase.EnemySwitch) return; + // 앞 말풍선이 떠 있는 동안은 다음 순간을 미룬다 — 겹치면 둘 다 못 읽는다. + if (bubbleText != null && Time.unscaledTime - bubbleShownAt < BubbleSeconds) return; + float enemyRatio = enemyStats.MaxHp > 0 ? displayEnemyHp / enemyStats.MaxHp : 1f; + float playerRatio = playerStats.MaxHp > 0 ? displayPlayerHp / playerStats.MaxHp : 1f; + int teamSize = battleController != null ? battleController.EnemyTeamSize : 1; + string line = DuelBanter.LineFor(duelLines, + DuelBanter.Next(ref duelTracker, enemyRatio, playerRatio, teamSize, ShownEnemyTeamIndex)); + if (string.IsNullOrEmpty(line)) return; + bubbleText = line; + bubbleShownAt = Time.unscaledTime; + } + + // ── 팀 대결 — 남은 곤충 공과 교체 문구 ── + // 상대가 다음 곤충을 내보내는 교체 단계(Phase.EnemySwitch, 입력 없음) 동안 아래쪽 띠에 「집게가 지네를 내보냈다!」와 + // 그 밑에 상대의 교체 한마디(DuelBanter.SendOutLine)를 띄운다. 단계는 배속 시간 1.2초라 짧다 — 이어지는 「당신의 턴」 + // 배너(아래쪽 다른 자리)가 떠 있는 동안까지 남겨 읽을 시간을 번다. 자리는 DuelTeamHud.SendOut(순수 계산). + + private InsectBattleStats sendOutFor; + private bool sendOutShowing; + private float sendOutShownAt; + private string sendOutTitle; + private string sendOutLine; + private GUIStyle sendOutTitleStyle; + + /// + /// 화면에 서 있는 상대가 팀의 몇 번째인가. 컨트롤러는 쓰러지는 그 행동 안에서 이미 다음 순번으로 넘어갔지만(EnemySwitched) + /// 화면은 교체 단계까지 쓰러지는 곤충을 그린다 — 그 사이는 한 칸 앞이다. + /// + private int ShownEnemyTeamIndex => battleController != null + ? DuelTeamHud.ShownIndex(battleController.EnemyTeamIndex, pendingEnemySwitch) + : 0; + + /// 교체 단계에 들어서면 문구를 한 번 짓는다. 단계와 이어지는 턴 배너가 끝나면 내린다. + private void TickEnemySendOut() + { + if (phase == Phase.EnemySwitch) + { + ComposeEnemySendOut(); + return; + } + if (sendOutShowing && (phase != Phase.TurnAnnounce || resultShown)) sendOutShowing = false; + } + + // 들어온 곤충마다 한 번 — 문자열은 여기서만 만든다(OnGUI 매 패스 할당 방지). + private void ComposeEnemySendOut() + { + if (enemyStats == null || ReferenceEquals(sendOutFor, enemyStats)) return; + sendOutFor = enemyStats; + string who = hasDuelLines && !string.IsNullOrEmpty(duelLines.name) ? duelLines.name : "상대"; + string insect = enemyStats.Data != null ? enemyStats.Data.displayName : "곤충"; + sendOutTitle = DuelTeamHud.SendOutText(who, insect); + int incoming = battleController != null ? battleController.EnemyTeamIndex : 0; + sendOutLine = hasDuelLines ? DuelBanter.SendOutLine(duelLines, incoming) : null; + sendOutShowing = true; + sendOutShownAt = Time.unscaledTime; + } + + private void ResetEnemySendOut() + { + sendOutFor = null; + sendOutShowing = false; + sendOutTitle = null; + sendOutLine = null; + } + + /// + /// 교체 문구 — 아래쪽 띠(행동 문구 자리 바로 위)에 큰 글자 한 줄, 그 밑에 상대의 한마디 카드. 3D 아레나 가운데(곤충이 들어서는 + /// 자리)는 비워 둔다. 0.15초에 걸쳐 떠오르고, 이어지는 「당신의 턴」 배너가 끝나 갈 때 함께 사라진다. + /// + private void DrawEnemySendOut() + { + if (phase == Phase.EnemySwitch) ComposeEnemySendOut(); + if (!sendOutShowing || resultShown || string.IsNullOrEmpty(sendOutTitle)) return; + if (phase != Phase.EnemySwitch && phase != Phase.TurnAnnounce) return; + EnsureDuelStyles(); + + UITheme theme = UITheme.Instance; + float age = Time.unscaledTime - sendOutShownAt; + // 첫 프레임부터 0.35로 보인다(곧바로 진해진다) — 단계가 짧아(배속 1.2초) 첫 장면부터 읽혀야 한다. + float alpha = Mathf.Lerp(DuelTeamHud.AppearAlpha, 1f, Mathf.Clamp01(age / 0.15f)); + if (phase == Phase.TurnAnnounce) alpha *= Mathf.Clamp01(announceTimer / 0.25f); + if (alpha <= 0.001f) return; + float rise = BattlePresentation.ReducedMotion ? 0f + : (1f - Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(age / 0.3f))) * DuelTeamHud.SendOutRise; + + bool hasLine = hasDuelLines && !string.IsNullOrEmpty(sendOutLine); + DuelTeamHud.SendOutPlan plan = DuelTeamHud.SendOut(HudFrame.Current, hasLine); + + Color prev = GUI.color; + GUI.color = new Color(prev.r, prev.g, prev.b, prev.a * alpha); + Rect title = plan.Title; + title.y += rise; + UISurface.Card(title, theme.surfaceBase, theme.accentCoral); + UISurface.Flat(new Rect(title.x + UITheme.Radius.Card, title.y + 3f, title.width - UITheme.Radius.Card * 2f, 5f), + theme.accentCoral); + sendOutTitleStyle.normal.textColor = theme.textPrimary; + // 이름 둘을 데이터가 정한다("관장 하월이 이름 잃은 나방을 내보냈다!") — 넘치면 글자를 줄여 맞춘다. + UIHelper.LabelFit(new Rect(title.x + 28f, title.y + 10f, title.width - 56f, title.height - 20f), + sendOutTitle, sendOutTitleStyle); + GUI.color = prev; + + if (hasLine) + { + Rect line = plan.Line; + line.y += rise; + // 한마디는 제목이 자리 잡은 뒤 타자로 시작한다. + DrawSpeechCard(line, duelLines, sendOutLine, Mathf.Max(0f, age - 0.2f), alpha); + } + } + + /// + /// 상대 HP 카드의 「상대 곤충」 글자 폭 — 팀 대결이면 오른쪽에 공 줄이 서므로 그 앞에서 멈춘다. + /// + private float EnemyCardLabelWidth(Rect card, float plainWidth) + { + if (battleController == null || !battleController.IsEnemyTeamBattle) return plainWidth; + Rect row = DuelTeamHud.BallRow(card, battleController.EnemyTeamSize); + return Mathf.Min(plainWidth, Mathf.Max(1f, row.x - (card.x + DuelTeamHud.CardTextInset) - UITheme.Space.S)); + } + + /// + /// 상대 팀의 남은 곤충 — HP 카드 윗줄 오른쪽(레벨 앞)에 공 하나씩(●●○). 쓰러진 곤충은 흐린 회색, 지금 나와 있는 곤충은 + /// 호박색 고리를 두른다. 한 마리 대결이면 아무것도 그리지 않는다(카드가 예전과 같다). + /// + private void DrawEnemyTeamBalls(Rect card) + { + if (battleController == null || !battleController.IsEnemyTeamBattle) return; + int count = battleController.EnemyTeamSize; + Rect row = DuelTeamHud.BallRow(card, count); + int shown = ShownEnemyTeamIndex; + bool shownFainted = enemyStats != null && displayEnemyHp <= 0.01f; + UITheme theme = UITheme.Instance; + Color fainted = new Color(theme.textSecondary.r, theme.textSecondary.g, theme.textSecondary.b, 0.35f); + for (int i = 0; i < count; i++) + { + Rect ball = DuelTeamHud.Ball(row, i, count); + switch (DuelTeamHud.BallState(i, shown, shownFainted)) + { + case DuelTeamHud.TeamBall.Fainted: + UIShapes.Ellipse(ball, fainted); + break; + case DuelTeamHud.TeamBall.Current: + float ring = DuelTeamHud.CurrentRing; + UIShapes.Ellipse(new Rect(ball.x - ring, ball.y - ring, ball.width + ring * 2f, ball.height + ring * 2f), + theme.accentAmber); + UIShapes.Ellipse(ball, theme.accentCoral); + break; + default: + UIShapes.Ellipse(ball, theme.accentCoral); + break; + } + } + } + + private void EnsureDuelStyles() + { + if (duelStylesReady) return; + duelStylesReady = true; + cutInNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 58, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + cutInTitleStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + cutInSubStyle = new GUIStyle(GUI.skin.label) { fontSize = 28, alignment = TextAnchor.MiddleLeft }; + cutInVsStyle = new GUIStyle(GUI.skin.label) { fontSize = 110, fontStyle = FontStyle.BoldAndItalic, alignment = TextAnchor.MiddleCenter }; + cutInLineStyle = new GUIStyle(GUI.skin.label) { fontSize = 34, alignment = TextAnchor.MiddleCenter, wordWrap = true, richText = true }; + bubbleNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 22, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + bubbleLineStyle = new GUIStyle(GUI.skin.label) { fontSize = 27, alignment = TextAnchor.MiddleLeft, wordWrap = true, richText = true }; + sendOutTitleStyle = new GUIStyle(GUI.skin.label) { fontSize = DuelTeamHud.SendOutFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + } + + /// + /// 대결·수문장 컷인. 그렸으면 true(평범한 인트로를 건너뛴다). + /// 가운데 띠를 반으로 갈라 왼쪽은 나, 오른쪽은 상대 — 양쪽이 밀려 들어와 VS에서 부딪히고, + /// 상대의 첫마디가 타자로 뜬다. 수문장은 사람이 아니라 곤충끼리 선다. + /// + private bool DrawDuelCutIn() + { + SyncDuelContext(); + if (!HasCutIn || playerStats == null || enemyStats == null) return false; + EnsureDuelStyles(); + + // 컷인은 진입 구간(진입 샷·화면 쓸기·「○○이(가) 승부를 걸어왔다!」 — BattleScreenUI.Feel의 DrawEntry) 뒤에 시작한다. + // 그동안은 아무것도 그리지 않는다(진입 문구는 진입 구간 끝에 사라져 이 컷인과 겹치지 않는다). + float t = CutInClock; + if (t < 0f) return true; + UITheme theme = UITheme.Instance; + bool portrait = UIScale.IsPortrait; + bool reduced = BattlePresentation.ReducedMotion; + float sw = UIScale.VirtualScreenWidth; + float cx = sw * 0.5f; + float fade = Mathf.Clamp01(t / 0.18f) * Mathf.Clamp01((CutInSeconds - t) / 0.3f); + float slide = reduced ? 1f : 1f - Mathf.Pow(1f - Mathf.Clamp01(t / 0.38f), 3f); + float offset = (1f - slide) * sw * 0.55f; + + Color prev = GUI.color; + Color faded = new Color(prev.r, prev.g, prev.b, prev.a * fade); + GUI.color = faded; + UISurface.Dim(0.68f); + + // 수문장은 사람이 아니라 띠에 글자만 선다(곤충은 뒤의 아레나에 이미 서 있다) — 띠를 낮춘다. + bool guardian = !hasDuelLines; + float bandH = portrait ? (guardian ? 520f : 760f) : (guardian ? 380f : 470f); + Rect band = new Rect(0f, UISafeLayout.CenteredY(bandH), sw, bandH); + UISurface.Flat(band, theme.surfaceBase); + Color mint = theme.accentMint, coral = theme.accentCoral; + UISurface.Flat(new Rect(-offset, band.y, cx, band.height), new Color(mint.r, mint.g, mint.b, 0.16f)); + UISurface.Flat(new Rect(cx + offset, band.y, cx, band.height), new Color(coral.r, coral.g, coral.b, 0.18f)); + UISurface.Flat(new Rect(0f, band.y, sw, 6f), theme.accentAmber); + UISurface.Flat(new Rect(0f, band.yMax - 6f, sw, 6f), theme.accentAmber); + // 가운데 사선 — 두 진영이 부딪히는 선. DrawRotatedLine은 GUI.color를 덮어쓰고 안 되돌린다 — + // 알파를 실어 넘기고 바로 복원한다(안 그러면 뒤에 그리는 초상이 전부 호박색으로 물든다). + Color amber = theme.accentAmber; + DrawRotatedLine(cx + 70f, band.y + 6f, cx - 70f, band.yMax - 6f, 8f, + new Color(amber.r, amber.g, amber.b, amber.a * faded.a)); + GUI.color = faded; + + float feet = band.yMax - 14f; + float figX = portrait ? sw * 0.25f : sw * 0.16f; + float midY = band.y + band.height * 0.5f - 90f; + + // ── 나 ── + float px = figX - offset; + if (!guardian) DrawMyCutInFigure(px, feet, 2.5f); + // 그림이 없으면 글자 묶음을 바깥쪽으로 벌린다 — 가운데로 모으면 긴 수문장 이름이 VS에 붙는다. + Rect myText = portrait + ? new Rect(40f - offset, band.y + 36f, cx - 80f, 150f) + : guardian + ? new Rect(160f - offset, midY, cx - 400f, 180f) + : new Rect(px + 150f, midY, cx - px - 250f, 180f); + DrawCutInBlock(myText, "나의 파트너", playerStats.Data.displayName, $"Lv.{playerStats.Level}", theme.accentMint, false); + + // ── 상대 ── + float ex = sw - figX + offset; + string title, name, sub; + if (guardian) + { + title = $"{guardianRegion.displayName}의 수문장"; + name = string.IsNullOrEmpty(guardianRegion.guardianDisplayName) ? enemyStats.Data.displayName : guardianRegion.guardianDisplayName; + sub = $"Lv.{enemyStats.Level}"; + } + else + { + // 초상은 DuelBanterTests가 전원 보장한다(없으면 얼굴 없이 글자만 선다). + NpcDialogueUI.TryDrawStoryPortrait(duelLines.npcId, ex, feet - 74.5f * 2.5f, 2.5f); + title = duelLines.title; + name = duelLines.name; + sub = $"{enemyStats.Data.displayName} Lv.{enemyStats.Level}"; + } + // 세로는 가운데 사선이 윗부분에서 오른쪽(cx+70)으로 기운다 — 글자 묶음을 그 오른쪽에서 시작해야 + // 긴 이름("초원의 수호자 사마귀")의 첫 글자를 사선이 관통하지 않는다. + Rect foeText = portrait + ? new Rect(cx + 90f + offset, band.y + 36f, cx - 130f, 150f) + : guardian + ? new Rect(cx + 240f + offset, midY, cx - 400f, 180f) + : new Rect(cx + 100f + offset, midY, ex - cx - 250f, 180f); + DrawCutInBlock(foeText, title, name, sub, theme.accentCoral, true); + + // ── VS — 양쪽이 닿는 순간 크게 찍혔다가 자리를 잡는다. ── + float punch = reduced ? 0f : Mathf.Clamp01(1f - Mathf.Abs(t - 0.45f) / 0.18f); + cutInVsStyle.fontSize = Mathf.RoundToInt(110f + 60f * punch); + cutInVsStyle.normal.textColor = theme.accentAmber; + // 세로 수문장은 띠 위쪽이 글자 묶음이라 VS를 그 아래에 둔다. + float vsY = !portrait ? band.y + band.height * 0.5f + : guardian ? band.y + 370f : band.y + band.height * 0.5f + 40f; + GUI.Label(new Rect(cx - 200f, vsY - 110f, 400f, 220f), "VS", cutInVsStyle); + + // ── 첫마디 ── + string line = guardian ? "이 땅의 길을 지키는 자가 앞을 막아선다." : duelLines.intro; + float lineT = t - TauntDelay; + if (!string.IsNullOrEmpty(line) && lineT > 0f) + { + Rect plate = new Rect(cx - Mathf.Min(560f, sw * 0.5f - 32f), band.yMax + 22f, Mathf.Min(1120f, sw - 64f), 104f); + UISurface.Card(plate, theme.surfaceRaised, guardian ? theme.surfaceBorder : theme.accentCoral); + cutInLineStyle.fontStyle = guardian ? FontStyle.Italic : FontStyle.Normal; + cutInLineStyle.normal.textColor = guardian ? theme.textSecondary : theme.textPrimary; + string shown = guardian ? line : $"“{line}”"; + int visible = reduced ? shown.Length + : StoryDialogueStaging.VisibleChars(shown.Length, lineT * 1.4f, StoryDialogueStaging.LineFx.None); + UIHelper.LabelFitReveal(new Rect(plate.x + 24f, plate.y + 8f, plate.width - 48f, plate.height - 16f), + shown, visible, cutInLineStyle); + } + GUI.color = prev; + return true; + } + + /// 컷인 한쪽의 글자 묶음 — 칭호(작게·강조색) / 이름(크게) / 곤충·레벨. + private void DrawCutInBlock(Rect r, string title, string name, string sub, Color accent, bool right) + { + TextAnchor anchor = right ? TextAnchor.MiddleRight : TextAnchor.MiddleLeft; + cutInTitleStyle.alignment = anchor; + cutInNameStyle.alignment = anchor; + cutInSubStyle.alignment = anchor; + cutInTitleStyle.normal.textColor = accent; + cutInNameStyle.normal.textColor = UITheme.Instance.textPrimary; + cutInSubStyle.normal.textColor = UITheme.Instance.textSecondary; + UIHelper.LabelFit(new Rect(r.x, r.y, r.width, 42f), title, cutInTitleStyle); + UIHelper.LabelFit(new Rect(r.x, r.y + 42f, r.width, 80f), name, cutInNameStyle); + UIHelper.LabelFit(new Rect(r.x, r.y + 122f, r.width, 40f), sub, cutInSubStyle); + } + + /// 전투 중 한마디 — 상대 HP 카드 바로 아래(장부 게이지·낮밤날씨 칩이 있으면 그 아래)에 잠깐 뜬다. + private void DrawDuelBubble() + { + if (!hasDuelLines || string.IsNullOrEmpty(bubbleText) || resultShown) return; + if (phase == Phase.Intro || phase == Phase.None || phase == Phase.EnemySwitch || sendOutShowing) return; + float age = Time.unscaledTime - bubbleShownAt; + if (age >= BubbleSeconds) return; + EnsureDuelStyles(); + + Rect card = DuelHudLayout.HpCard(UISafeLayout.Content, false); + bool ledger = battleController != null && LedgerPressure.IsActive(battleController.LedgerThreshold); + // 대결은 보정이 없어 칩이 서지 않는다 — 그래도 쌓는 순서는 한 곳(BattleHudStack)에서 정한다. + bool environmentChip = battleController != null && battleController.EnemyEnvironment.HasEffect; + DrawSpeechCard(BattleHudStack.Bubble(card, ledger, environmentChip), duelLines, bubbleText, age, + Mathf.Clamp01(age / 0.15f) * Mathf.Clamp01((BubbleSeconds - age) / 0.35f)); + } + + /// 결과 창(패배) 위 한마디 — 상대의 승리 대사. 도주는 말이 없다. + private void DrawDuelResultQuote(Rect panel) + { + float y = Mathf.Max(UISafeLayout.ContentTop, panel.y - 138f); + DrawDuelResultQuoteAt(new Rect(panel.x, y, panel.width, BattleHudStack.BubbleHeight)); + } + + /// + /// 결과 한마디를 에 — 이기면 상대의 패배 대사, 지면 승리 대사. 도주는 말이 없다. + /// 승리 화면은 「승리!」 바로 아래(), 패배 창은 창 위에 세운다. + /// + private void DrawDuelResultQuoteAt(Rect r) + { + if (!hasDuelLines) return; + if (battleController != null && battleController.DidEscape) return; + string line = lastWon ? duelLines.defeat : duelLines.victory; + if (string.IsNullOrEmpty(line)) return; + EnsureDuelStyles(); + DrawSpeechCard(r, duelLines, line, resultTimer, Mathf.Clamp01(resultTimer / 0.2f)); + } + + /// 말풍선 카드 — 왼쪽에 작은 전신 초상, 오른쪽에 이름과 대사(타자). + private void DrawSpeechCard(Rect r, DuelBanter.Lines who, string text, float age, float alpha) + { + UITheme theme = UITheme.Instance; + Color prev = GUI.color; + GUI.color = new Color(prev.r, prev.g, prev.b, prev.a * alpha); + UISurface.Card(r, theme.surfaceRaised, theme.accentCoral); + const float figScale = 0.72f; + bool drewFace = NpcDialogueUI.TryDrawStoryPortrait(who.npcId, r.x + 58f, r.yMax - 8f - 74.5f * figScale, figScale); + float textX = r.x + (drewFace ? 112f : 20f); + float textW = r.xMax - textX - 16f; + bubbleNameStyle.normal.textColor = theme.accentAmber; + UIHelper.LabelFit(new Rect(textX, r.y + 8f, textW, 30f), who.name, bubbleNameStyle); + bubbleLineStyle.normal.textColor = theme.textPrimary; + int visible = BattlePresentation.ReducedMotion ? text.Length + : StoryDialogueStaging.VisibleChars(text.Length, age, StoryDialogueStaging.LineFx.None); + UIHelper.LabelFitReveal(new Rect(textX, r.y + 38f, textW, r.height - 46f), text, visible, bubbleLineStyle); + GUI.color = prev; + } + } + + /// + /// 1대1 팀 대결의 화면 자리와 문구 — 순수 계산(테스트가 씬 없이 본다). 그리기는 BattleScreenUI.Duel. + /// + /// 남은 곤충 공: 상대 HP 카드 윗줄 오른쪽, 레벨 글자 바로 앞에 오른쪽 맞춤으로 선다(지름 20 · 간격 8 — 다섯 마리 132px). + /// 카드가 좁아 「상대 곤충」 글자 자리(100px)를 침범하면 공을 같은 비율로 줄인다. 카드 높이는 그대로라 아래 쌓기 + /// (장부 게이지·보정 칩·말풍선 — BattleHudStack)는 바뀌지 않는다. + /// + /// 교체 문구: 아래쪽 행동 문구 띠(DuelHudLayout.ActionBar) 바로 위에 아래부터 한마디 카드(말풍선 높이 122) → + /// 큰 글자 판(높이 96). 가로(1920×1080)면 판이 y≈672~768·폭 1100, 세로(1080×1920)면 y≈1486~1582·폭 = 안전 폭(1032). + /// 3D 아레나의 가운데(새 곤충이 들어서는 자리)와 위쪽 HP 카드 쌓기·전투 문구 줄을 비켜 서고, 「당신의 턴」 배너 + /// (DuelHudLayout.TurnBanner — 바닥)와도 겹치지 않는다 — 그 배너가 떠 있는 동안까지 남아 있기 때문이다. + /// + internal static class DuelTeamHud + { + /// HP 카드 안 글자의 왼쪽 여백(DrawHpBox의 x + 18). + internal const float CardTextInset = 18f; + /// 카드 오른쪽 위 「Lv. N」 자리(x + w − 102부터) + 간격 — 공 줄은 그 앞에서 끝난다. + internal const float LevelReserve = 110f; + /// 윗줄(「상대 곤충」, y + 10 ~ 34)의 세로 가운데. + internal const float RowCenterOffset = 22f; + internal const float BallSize = 20f; + internal const float BallGap = 8f; + /// 지금 나와 있는 곤충의 호박색 고리 두께. + internal const float CurrentRing = 3f; + /// 「상대 곤충」 글자가 남겨 둘 최소 폭. + internal const float MinLabelWidth = 100f; + + internal enum TeamBall { Waiting, Current, Fainted } + + /// + /// 화면에 서 있는 상대의 순번. 컨트롤러는 쓰러진 그 행동 안에서 이미 다음 곤충으로 넘어갔고(), + /// 화면이 교체 단계에 들어가기 전()에는 아직 쓰러지는 곤충을 그린다 — 그때는 한 칸 앞이다. + /// + internal static int ShownIndex(int controllerIndex, bool switchPending) => + Mathf.Max(0, switchPending ? controllerIndex - 1 : controllerIndex); + + /// + /// 공 하나의 상태 — 지금 서 있는 곤충보다 앞은 쓰러짐, 지금 곤충은 막대가 0이 되면 쓰러짐(막대와 같이 꺼진다), 뒤는 대기. + /// + internal static TeamBall BallState(int slot, int shownIndex, bool shownFainted) + { + if (slot < shownIndex) return TeamBall.Fainted; + if (slot == shownIndex) return shownFainted ? TeamBall.Fainted : TeamBall.Current; + return TeamBall.Waiting; + } + + /// 공 줄 전체 — 카드 윗줄 오른쪽(레벨 앞)에 오른쪽 맞춤. 높이 = 공 지름. + internal static Rect BallRow(Rect card, int count) + { + int n = Mathf.Max(0, count); + float right = card.xMax - LevelReserve; + float minLeft = card.x + CardTextInset + MinLabelWidth; + float size = BallSize, gap = BallGap; + float need = n > 0 ? n * size + (n - 1) * gap : 0f; + float room = Mathf.Max(0f, right - minLeft); + if (need > room && need > 0f) + { + float k = room / need; + size *= k; + gap *= k; + need = room; + } + return new Rect(right - need, card.y + RowCenterOffset - size * 0.5f, need, size); + } + + /// 줄 안의 번째 공(0부터, 왼쪽이 첫 곤충). + internal static Rect Ball(Rect row, int index, int count) + { + float size = row.height; + float gap = count > 1 ? (row.width - count * size) / (count - 1) : 0f; + return new Rect(row.x + index * (size + gap), row.y, size, size); + } + + // ── 교체 문구 ── + + internal const int SendOutFont = 46; + internal const float SendOutTitleHeight = 96f; + internal const float SendOutTitleMaxWidth = 1100f; + internal const float SendOutLineMaxWidth = 900f; + /// 떠오를 때 아래에서 올라오는 거리 — 아래 행동 문구 띠와의 간격(16)보다 작게. + internal const float SendOutRise = 12f; + /// 뜨는 첫 프레임의 불투명도 — 0에서 시작하면 단계의 첫 장면(QA 상태 캡처)에 글자가 없다. + internal const float AppearAlpha = 0.35f; + + internal struct SendOutPlan + { + public Rect Title; + /// 한마디 카드(일 때만). + public Rect Line; + public bool HasLine; + } + + /// 교체 문구 자리 — 행동 문구 띠 바로 위에서 아래부터 한마디 카드 → 큰 글자 판. 가로 가운데. + internal static SendOutPlan SendOut(HudFrame f, bool withLine) + { + Rect bar = DuelHudLayout.ActionBar(f); + float bottom = bar.y - UITheme.Space.M; + float cx = f.ContentLeft + f.ContentWidth * 0.5f; + var plan = new SendOutPlan { HasLine = withLine }; + if (withLine) + { + float lw = Mathf.Min(SendOutLineMaxWidth, f.ContentWidth); + plan.Line = new Rect(cx - lw * 0.5f, bottom - BattleHudStack.BubbleHeight, lw, BattleHudStack.BubbleHeight); + bottom = plan.Line.y - UITheme.Space.S; + } + float tw = Mathf.Min(SendOutTitleMaxWidth, f.ContentWidth); + plan.Title = new Rect(cx - tw * 0.5f, bottom - SendOutTitleHeight, tw, SendOutTitleHeight); + return plan; + } + + /// 「집게가 지네를 내보냈다!」 — 조사는 받침으로 고른다(). + internal static string SendOutText(string trainer, string insect) + { + string who = string.IsNullOrEmpty(trainer) ? "상대" : trainer; + string bug = string.IsNullOrEmpty(insect) ? "곤충" : insect; + return $"{who}{KoreanJosa.IGa(who)} {bug}{KoreanJosa.EulReul(bug)} 내보냈다!"; + } + } +} diff --git a/Assets/Scripts/UI/BattleScreenUI.Duel.cs.meta b/Assets/Scripts/UI/BattleScreenUI.Duel.cs.meta new file mode 100644 index 0000000..2beeaf9 --- /dev/null +++ b/Assets/Scripts/UI/BattleScreenUI.Duel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f4839ae641eced940a359381c9dc2758 \ No newline at end of file diff --git a/Assets/Scripts/UI/BattleScreenUI.Environment.cs b/Assets/Scripts/UI/BattleScreenUI.Environment.cs new file mode 100644 index 0000000..826e983 --- /dev/null +++ b/Assets/Scripts/UI/BattleScreenUI.Environment.cs @@ -0,0 +1,205 @@ +using InsectGame.Battle; +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 야생 전투의 낮·밤·날씨 보정 칩 — 양쪽 HP 카드 바로 아래에 작게, 유리하면 민트 "밤 · 야행성 +10%", + /// 불리하면 코랄 "비 · 비를 싫어함 −10%". + /// + /// 배수와 문구는 컨트롤러가 든다(·, + /// 규칙은 ). 여기는 그리기만 한다. 보정이 없으면( 거짓 — + /// 대결·수문장·샌드박스·레이드·실내) 아무것도 그리지 않고 자리도 비우지 않는다 — 아래에 붙는 대결 말풍선은 + /// 칩이 있을 때만 한 칸 내려간다(). + /// + /// 처음 뜰 때 한 번 살짝 커졌다 돌아온다(전투 시작·교체 — 처음 보는 표식이라 눈길을 한 번 끈다). 줄인 움직임 설정이면 하지 않는다. + /// 모놀리스 본체를 키우지 않으려고 partial로 뗐다. 본체는 DrawHpBars 끝에서 한 번 부른다. + /// + public partial class BattleScreenUI + { + private const int EnvironmentChipFont = 20; + + /// 한쪽 칩의 사본 — 문구가 바뀔 때(새 전투·교체)만 문자열을 잇고 폭을 잰다. + private sealed class EnvironmentChipCache + { + public InsectBattleStats Owner; + public string Reason; + public int Percent; + public string Label; + public float TextWidth; + public float ShownAt; + } + + private readonly EnvironmentChipCache playerEnvChip = new EnvironmentChipCache(); + private readonly EnvironmentChipCache enemyEnvChip = new EnvironmentChipCache(); + private GUIStyle envChipStyle; + private GUIContent envChipMeasure; + + /// HP 카드 아래의 보정 칩 둘. 상대 쪽은 장부 게이지가 있으면 그 아래. + private void DrawEnvironmentChips(Rect playerCard, Rect enemyCard) + { + if (battleController == null) return; + BattleEnvironmentNote player = battleController.PlayerEnvironment; + BattleEnvironmentNote enemy = battleController.EnemyEnvironment; + if (!player.HasEffect && !enemy.HasEffect) return; + + EnsureEnvironmentChipStyle(); + bool ledger = LedgerPressure.IsActive(battleController.LedgerThreshold); + if (player.HasEffect) DrawEnvironmentChip(playerCard, true, false, player, playerStats, playerEnvChip); + if (enemy.HasEffect) DrawEnvironmentChip(enemyCard, false, ledger, enemy, enemyStats, enemyEnvChip); + } + + private void EnsureEnvironmentChipStyle() + { + if (envChipStyle != null) return; + envChipStyle = new GUIStyle(GUI.skin.label) + { + fontSize = EnvironmentChipFont, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter, + wordWrap = false, + clipping = TextClipping.Clip + }; + envChipMeasure = new GUIContent(); + } + + private void DrawEnvironmentChip(Rect card, bool isPlayer, bool ledgerBelow, BattleEnvironmentNote note, + InsectBattleStats owner, EnvironmentChipCache cache) + { + bool favorable = note.Multiplier > 1f; + int percent = note.Percent; + // 값으로 비교한다 — 컨트롤러가 문구를 매번 새로 이어 돌려줘도 칩이 매 프레임 다시 뜨지 않게. + if (!ReferenceEquals(cache.Owner, owner) || cache.Percent != percent || !string.Equals(cache.Reason, note.Reason)) + { + cache.Owner = owner; + cache.Reason = note.Reason; + cache.Percent = percent; + cache.Label = BattleHudStack.EnvironmentLabel(note.Reason, percent, favorable); + envChipStyle.fontSize = EnvironmentChipFont; + envChipMeasure.text = cache.Label; + cache.TextWidth = envChipStyle.CalcSize(envChipMeasure).x; + cache.ShownAt = Time.unscaledTime; + } + + Rect chip = BattleHudStack.EnvironmentChip(card, isPlayer, ledgerBelow, cache.TextWidth); + float pad = BattleHudStack.EnvironmentChipPadding; + // 글자 상자는 크기를 고정한다 — 펄스 동안 상자 크기가 매 프레임 달라지면 LabelFit 캐시가 프레임마다 새 항목으로 찬다. + Rect text = new Rect(chip.x + pad, chip.y, chip.width - pad * 2f, chip.height); + Rect surface = chip; + float scale = BattleHudStack.EnvironmentPulse(Time.unscaledTime - cache.ShownAt, BattlePresentation.ReducedMotion); + if (scale > 1.0001f) + { + // 바깥 윗모서리를 축으로 표면만 키운다 — 위의 카드·장부 게이지를 덮지 않고, 카드 폭 밖으로 나가지 않는다. + float w = Mathf.Min(chip.width * scale, card.width); + surface = new Rect(isPlayer ? chip.x : chip.xMax - w, chip.y, w, chip.height * scale); + text.center = surface.center; + } + + UITheme theme = UITheme.Instance; + Color accent = favorable ? theme.accentMint : theme.accentCoral; + UISurface.Chip(surface, string.Empty, Color.Lerp(theme.surfaceBase, accent, 0.2f), accent); + envChipStyle.fontSize = EnvironmentChipFont; + envChipStyle.normal.textColor = Color.Lerp(accent, theme.textPrimary, 0.2f); + // 문구 길이는 데이터(성향·시간대·날씨)가 정한다 — 칩이 카드 폭에 걸리면 글자를 줄여 맞춘다. + UIHelper.LabelFit(text, cache.Label, envChipStyle); + } + } + + /// + /// 전투 HP 카드 아래에 붙는 것들의 세로 쌓기 — 순수 계산. 위에서부터 장부 게이지(상대 쪽, 걸렸을 때) → + /// 낮·밤·날씨 칩(보정이 있을 때) → 대결 말풍선(상대 쪽, 대결일 때). 없는 것은 자리를 차지하지 않는다. + /// + internal static class BattleHudStack + { + internal const float LedgerGaugeHeight = 26f; + internal const float EnvironmentChipHeight = 40f; + internal const float EnvironmentChipPadding = 14f; + internal const float EnvironmentChipMinWidth = 120f; + + internal const float PulseDelay = 0.25f; + internal const float PulseSeconds = 0.8f; + internal const float PulseAmount = 0.1f; + + /// 환경 칩의 윗변 — 카드 바로 아래, 장부 게이지가 있으면 그 아래. + internal static float EnvironmentChipTop(Rect card, bool ledgerBelow) + { + float top = card.yMax + UITheme.Space.S; + return ledgerBelow ? top + LedgerGaugeHeight + UITheme.Space.S : top; + } + + /// + /// 환경 칩 — 글자 폭에 맞춘 작은 칩을 카드의 바깥 모서리 쪽에 붙인다(내 곤충은 왼쪽, 상대는 오른쪽 끝 맞춤). + /// 폭은 카드 폭을 넘지 않는다 — 넘칠 문구는 그리는 쪽이 UIHelper.LabelFit으로 줄인다. + /// + internal static Rect EnvironmentChip(Rect card, bool player, bool ledgerBelow, float textWidth) + { + float w = Mathf.Clamp(textWidth + EnvironmentChipPadding * 2f + 4f, EnvironmentChipMinWidth, Mathf.Max(1f, card.width)); + return new Rect(player ? card.x : card.xMax - w, EnvironmentChipTop(card, ledgerBelow), w, EnvironmentChipHeight); + } + + /// 대결 말풍선의 윗변 — 장부 게이지·환경 칩이 있으면 그 아래. + internal static float BubbleTop(Rect card, bool ledgerBelow, bool environmentChip) + { + float top = EnvironmentChipTop(card, ledgerBelow); + return environmentChip ? top + EnvironmentChipHeight + UITheme.Space.S : top; + } + + /// 대결 말풍선 높이(상대 쪽, BattleScreenUI.DrawDuelBubble). + internal const float BubbleHeight = 122f; + + /// 장부 게이지 — 상대 HP 카드 바로 아래 한 줄. + internal static Rect LedgerGauge(Rect card) => new Rect(card.x, card.yMax + UITheme.Space.S, card.width, LedgerGaugeHeight); + + /// 대결 말풍선 — 상대 HP 카드 폭 그대로, 장부 게이지·환경 칩 아래. + internal static Rect Bubble(Rect card, bool ledgerBelow, bool environmentChip) => + new Rect(card.x, BubbleTop(card, ledgerBelow, environmentChip), card.width, BubbleHeight); + + internal const float ComboBadgeWidth = 180f; + internal const float ComboBadgeHeight = 60f; + internal const float ComboPulseSeconds = 0.2f; + /// + /// 연속 배지가 처음 뜰 때의 크기 배율(가운데 축). 1.15를 넘기면 세로 화면의 꿈 챔피언전 안내 알약 + /// (DreamPrologueDirector.HintPillRect, 720×1280에서 x 220부터)에 커진 배지가 닿는다. + /// + internal const float ComboPulsePeak = 1.15f; + + /// + /// 연속 공격 배지 — 내 HP 카드 아래, 낮·밤·날씨 칩이 있으면 그 아래. 카드 바깥(왼쪽) 모서리에 붙인다. + /// 내 쪽에는 장부 게이지·대결 말풍선이 없다(둘 다 상대 쪽). 상대 쪽 쌓기와 좌우로 갈려 서로 닿지 않는다. + /// + internal static Rect ComboBadge(Rect playerCard, bool environmentChip) + { + float w = Mathf.Min(ComboBadgeWidth, Mathf.Max(1f, playerCard.width)); + return new Rect(playerCard.x, BubbleTop(playerCard, false, environmentChip), w, ComboBadgeHeight); + } + + /// 배지가 뜬 뒤 초의 크기 배율 — 처음 0.2초 동안 1.15에서 1로 줄어들고, 그 뒤 1. + internal static float ComboPulse(float sinceShown, bool reducedMotion) + { + if (reducedMotion || sinceShown >= ComboPulseSeconds) return 1f; + return Mathf.Lerp(ComboPulsePeak, 1f, Mathf.Clamp01(sinceShown / ComboPulseSeconds)); + } + + /// + /// 처음 뜬 뒤 초의 크기 배율. 잠깐 기다렸다가 한 번 살짝(최대 +10%) 커졌다 돌아오고, 그 뒤로는 1이다. + /// + internal static float EnvironmentPulse(float sinceShown, bool reducedMotion) + { + if (reducedMotion) return 1f; + float u = (sinceShown - PulseDelay) / PulseSeconds; + if (u <= 0f || u >= 1f) return 1f; + return 1f + PulseAmount * Mathf.Sin(u * Mathf.PI); + } + + /// + /// "밤 · 야행성 +10%" / "비 · 비를 싫어함 −10%". 부호는 배수의 방향에서 정한다(퍼센트가 반올림으로 0이 돼도 방향은 남는다). + /// 빼기는 하이픈이 아니라 마이너스 기호(U+2212)다 — 더하기와 폭·높이가 맞는다. + /// + internal static string EnvironmentLabel(string reason, int percent, bool favorable) + { + string amount = (favorable ? "+" : "−") + Mathf.Abs(percent) + "%"; + return string.IsNullOrEmpty(reason) ? amount : reason + " " + amount; + } + } +} diff --git a/Assets/Scripts/UI/BattleScreenUI.Environment.cs.meta b/Assets/Scripts/UI/BattleScreenUI.Environment.cs.meta new file mode 100644 index 0000000..2f8edc6 --- /dev/null +++ b/Assets/Scripts/UI/BattleScreenUI.Environment.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9fa3407cb8145a34dbaad33c6f4f35cd \ No newline at end of file diff --git a/Assets/Scripts/UI/BattleScreenUI.Feel.cs b/Assets/Scripts/UI/BattleScreenUI.Feel.cs new file mode 100644 index 0000000..cd44053 --- /dev/null +++ b/Assets/Scripts/UI/BattleScreenUI.Feel.cs @@ -0,0 +1,188 @@ +using System.Collections.Generic; +using InsectGame.Battle; +using InsectGame.Core; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 1대1 전투의 체감 화면(4단계) — 진입(화면 쓸기 + 「야생 ○○이(가) 나타났다!」), 타격 순간의 상성 표시, 전용기 컷인 띠, 승리 화면. + /// 자리·문구·시각은 순수 계산(··· + /// )이, 칠하기는 가 한다. 여기는 전투 상태를 거기에 잇기만 한다. + /// + /// 모놀리스 본체를 키우지 않으려고 partial로 뗐다. 본체는 OnGUI(진입·전용기 띠·아레나 머리 위 이름 끄기), DrawAttackAnimation(상성 표시), + /// DrawSkillPanel(상성 칩), DrawResult(승리 화면·「눌러서 계속」)에서 부른다. + /// + public partial class BattleScreenUI + { + // ── 진입 ── + + private bool entryReady; + private BattleKind entryKind; + private string entryInsect; + private string entryPerson; + private string entryRegion; + private string entryTitle; + + /// + /// 진입 문구 — 종류·상대 곤충·사람 이름·리전이 바뀔 때만 다시 짓는다. 간부·라온은 상대 표지가 시작 함수 뒤에 서므로 + /// ( 주석) 첫 프레임에 아이 문구였다가 곧바로 바뀔 수 있다 — 그래서 매 프레임 비교한다(할당은 바뀔 때만). + /// + private string EntryTitle() + { + if (enemyStats == null || enemyStats.Data == null) return null; + SyncDuelContext(); + BattleKind kind = CurrentBattleKind; + string insect = enemyStats.Data.displayName; + string person = hasDuelLines ? duelLines.name : null; + string region = guardianRegion != null ? guardianRegion.displayName : null; + if (!entryReady || kind != entryKind || !string.Equals(insect, entryInsect) + || !string.Equals(person, entryPerson) || !string.Equals(region, entryRegion)) + { + entryReady = true; + entryKind = kind; + entryInsect = insect; + entryPerson = person; + entryRegion = region; + entryTitle = BattleEntryText.For(kind, insect, person, region); + } + return entryTitle; + } + + /// 진입 카드의 강조색 — 야생 민트, 수문장 호박, 사람과의 대결 코랄. + private static Color EntryAccent(BattleKind kind) + { + UITheme theme = UITheme.Instance; + switch (kind) + { + case BattleKind.Wild: return theme.accentMint; + case BattleKind.Guardian: + case BattleKind.Sandbox: return theme.accentAmber; + default: return theme.accentCoral; + } + } + + /// + /// 진입 구간(, 실제 1.4초) — 화면 쓸기가 HP 카드까지 덮고 열리며, 큰 문구가 위쪽 가운데에 튀어나온다. + /// 대결·수문장 컷인이 이어지면 문구는 진입 구간 끝에 사라지고(컷인은 DrawDuelCutIn이 그 뒤에 그린다), 아니면 인트로 끝까지 남는다. + /// 꿈 챔피언전은 문구가 없다(꿈이 「챔피언 결정전」 카드를 먼저 띄운다) — 화면 쓸기만. + /// + private void DrawEntry() + { + if (playerStats == null || enemyStats == null) return; + HudFrame f = HudFrame.Current; + bool reduced = BattlePresentation.ReducedMotion; + float p = EntryIntroProgress; + BattleFeelDraw.EntryWipe(f, p, reduced); + string title = EntryTitle(); + if (title == null) return; + float alpha = BattleEntryStaging.TitleAlpha(p, introTimer, IntroSeconds, HasCutIn); + if (alpha <= 0.001f) return; + float since = (p - BattleEntryStaging.TitleIn) * BattleReadPacing.EntryIntroSeconds; + BattleFeelDraw.EntryTitle(BattleEntryLayout.DuelTitle(f), title, null, BattleEntryLayout.TitleFont, + alpha, BattleEntryStaging.Pop(since, reduced), EntryAccent(entryKind)); + } + + // ── 상성 ── + + /// 방금 보인 타격의 상성 등급 — 컨트롤러가 그 라운드에 남긴 값(빗나감·기본 공격·보통은 Neutral). + private Matchup ImpactMatchup(bool playerAction) + { + if (battleController == null) return Matchup.Neutral; + return playerAction ? battleController.LastPlayerHitMatchup : battleController.LastEnemyHitMatchup; + } + + /// 피해 숫자(치명타면 「치명타!」) 바로 위에 화살표 + 「아주 잘 통했다!」. 보통이면 아무것도 없다. + private void DrawImpactMatchup(bool playerAction, float cx, float numberCenterY, int numberFontSize, bool crit, float alpha) + { + Matchup m = ImpactMatchup(playerAction); + if (m == Matchup.Neutral) return; + BattleFeelDraw.MatchupCaption(HudFrame.Current, cx, MatchupHud.CaptionCenterY(numberCenterY, numberFontSize, crit), m, + alpha, MatchupHud.CaptionScale(impactTimer, BattlePresentation.ReducedMotion)); + } + + // ── 전용기 ── + + /// + /// 1대1 화면이 떠 있는 동안 아레나의 전용기 머리 위 이름(말풍선)을 끈다 — 아래 띠가 같은 이름을 크게 띄운다. 레이드 화면은 다시 켠다 + /// (레이드는 사전 컷인이 없어 말풍선이 이름을 알린다). 정적 표지라 화면이 닫힐 때(EndBattle) 되돌린다. + /// + private static void SuppressArenaSignatureCallout() => BattleArenaController.ShowSignatureCallout = false; + + /// + /// 전용기 컷인 띠 — 아레나가 시전자를 클로즈업하는 동안() 화면을 가로지른다. + /// 내 곤충이면 왼쪽에서, 상대면 오른쪽에서 밀려 들어오고, 컷인이 끝나면 잠깐 뒤 사라진다. + /// + private void DrawSignatureCutIn() + { + if (arena == null || !arena.IsActive || !arena.IsSignaturePlaying) return; + if (phase != Phase.PlayerAttack && phase != Phase.EnemyAttack) return; + float alpha = SignatureCutInLayout.Alpha(arena.SignatureElapsed - arena.SignatureCutInEndSeconds); + if (alpha <= 0.001f) return; + bool mine = arena.SignatureFromPlayer; + InsectBattleStats caster = mine ? playerStats : enemyStats; + string casterName = caster != null && caster.Data != null ? caster.Data.displayName : null; + BattleFeelDraw.SignatureBand(HudFrame.Current, mine, casterName, arena.SignatureSkillName, + BattleArenaController.GetUIElementColor(arena.SignatureElement), arena.SignatureCutInProgress, alpha, + BattlePresentation.ReducedMotion); + } + + // ── 승리 ── + + private List victoryLines; + private InsectBattleStats victoryLinesFor; + private ItemDatabase resultItemDb; + + /// + /// 승리 화면 — 위쪽 큰 「승리!」, 그 밑 상대의 한마디(대결), 가운데는 아레나 승리 연출, 아래 보상 판(0.8초부터 한 줄씩), 맨 아래 「눌러서 계속」. + /// 시계는 결과 화면의 실제 초() — 아레나 승리 연출()과 같은 시계다. + /// + private void DrawVictory(bool sandbox) + { + HudFrame f = HudFrame.Current; + bool reduced = BattlePresentation.ReducedMotion; + float shown = ResultShownSeconds; + // 승리 포즈가 보이게 아주 옅게만 — 예전 결과 창은 0.3 딤 위에 가운데 판을 세워 포즈를 덮었다. + UISurface.Dim(0.12f); + BattleFeelDraw.VictoryTitle(f, sandbox ? BattleVictoryLayout.ChampionTitle : BattleVictoryLayout.Title, shown, reduced); + if (!sandbox) DrawDuelResultQuoteAt(BattleVictoryLayout.QuoteRect(f)); + BattleFeelDraw.RewardPanel(f, VictoryLines(sandbox), shown); + BattleFeelDraw.ContinueHint(f, ResultCanClose, shown - BattleResultRules.InputLockSeconds, reduced); + } + + /// 보상 줄 — 결과마다 한 번만 짓는다(마지막 상대 기준). + private List VictoryLines(bool sandbox) + { + if (victoryLines != null && ReferenceEquals(victoryLinesFor, enemyStats)) return victoryLines; + victoryLinesFor = enemyStats; + if (sandbox || battleController == null) + { + victoryLines = BattleRewardLines.Cheer(BattleRewardLines.DreamCheer); + return victoryLines; + } + int itemCount = battleController.GetLastItemCount(); + string itemName = itemCount > 0 ? ResultItemName(battleController.GetLastItemId()) : null; + string insect = enemyStats != null && enemyStats.Data != null ? enemyStats.Data.displayName : null; + // 코인 0이면 줄을 만들지 않는다(샌드박스·패배). + victoryLines = BattleRewardLines.Duel(battleController.GetLastCandyReward(), battleController.GetLastExpReward(), + battleController.GetLastCoinReward(), + itemName, itemCount, battleController.GetLastCaptureAttempted(), battleController.GetLastCaptureSucceeded(), insect); + return victoryLines; + } + + /// + /// 아이템 ID → 표시명. 야생 승리 드랍은 재료(InsectData.itemRewardId — 기본 mat_leaf)인데 재료는 아이템 데이터베이스에 없다 — + /// 그 이름표는 도감(DexScreenUI.GetItemDisplayName, data-architect)에만 있어 여기서는 「재료」로 적는다(아이에게 ID를 보이지 않는다). + /// + private string ResultItemName(string itemId) + { + if (string.IsNullOrEmpty(itemId)) return null; + if (resultItemDb == null) resultItemDb = Resources.Load("ItemDatabase"); + if (resultItemDb == null) resultItemDb = ItemDatabase.CreateRuntimeDefault(); + ItemData data = resultItemDb != null ? resultItemDb.FindById(itemId) : null; + if (data != null && !string.IsNullOrEmpty(data.displayName)) return data.displayName; + return BattleRewardLines.FallbackItemName(itemId); + } + } +} diff --git a/Assets/Scripts/UI/BattleScreenUI.Feel.cs.meta b/Assets/Scripts/UI/BattleScreenUI.Feel.cs.meta new file mode 100644 index 0000000..fee7916 --- /dev/null +++ b/Assets/Scripts/UI/BattleScreenUI.Feel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4e8592739d367c640bd46e381839ef02 \ No newline at end of file diff --git a/Assets/Scripts/UI/BattleScreenUI.Flow.cs b/Assets/Scripts/UI/BattleScreenUI.Flow.cs new file mode 100644 index 0000000..f655b2e --- /dev/null +++ b/Assets/Scripts/UI/BattleScreenUI.Flow.cs @@ -0,0 +1,171 @@ +using InsectGame.Battle; +using InsectGame.Core; +using InsectGame.NPC; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 1대1 전투 화면의 흐름 — 결과 화면 닫기, 읽는 단계의 배속 하한, 진입 인트로의 길이, 전투 종류. + /// 규칙은 순수부(··)가 들고, 여기는 화면 상태에 + /// 잇기만 한다. 모놀리스 본체를 키우지 않으려고 partial로 뗐다 — 본체는 Update(시계 배율·결과 닫기)·OnGUI(Space/Enter)· + /// EnterResult/EndBattle(초기화) 네 군데서 부른다. + /// + /// ui-dev·visual-dev가 읽는 공개 값: ·(결과 화면 안내·승리 연출 대기), + /// ··(진입 샷·화면 쓸기·「야생 ○○이(가) 나타났다!」), + /// (문구 고르기), (교체 등장 연출 길이). + /// + public partial class BattleScreenUI + { + // ── 결과 화면 닫기(BattleResultRules) ── + + private bool wantResultClose; // OnGUI Space/Enter — 결과 화면에서만 선다 + private bool resultCloseArmed; // 받아들인 누름 — 손을 떼면 닫는다 + private float resultArmedAt; + + /// + /// 결과 화면이 지금 누름을 받는가(처음 초가 지났다). ui-dev가 「눌러서 계속」 안내를 + /// 이때부터 보인다. 결과 화면이 아니면 false. 꿈 챔피언전도 누르면 닫힌다(그리고 4초 뒤 저절로 닫힌다). + /// + public bool ResultCanClose => phase == Phase.Result && BattleResultRules.CanClose(resultTimer); + + /// 결과 화면이 뜬 뒤 지난 실제 초(배속·히트스톱·일시정지와 무관). 결과 화면이 아니면 0. 승리 연출 대기에 쓴다. + public float ResultShownSeconds => phase == Phase.Result ? resultTimer : 0f; + + /// 결과 화면을 띄운 쪽의 공통 초기화 — EnterResult와 EndBattle가 부른다. + private void ResetResultClose() + { + wantResultClose = false; + resultCloseArmed = false; + resultArmedAt = 0f; + } + + /// + /// 결과 화면의 한 프레임 — 탭·클릭·Space/Enter를 받아(잠금 뒤) 손을 뗄 때 닫는다. 자동으로는 꿈 챔피언전만 닫힌다. + /// wantMouseClick은 Update 말미가 비우므로 여기서 먼저 읽는다. + /// + private void TickResultClose() + { + // 결과 화면은 이제 플레이어가 닫을 때까지 서 있다 — 그동안 플레이어를 묶어 둔다. PlayerMovement는 20초 넘게 묶이면 + // 스스로 풀고(AutoUnfreezeTime) ESC로도 풀리는데, 풀린 채로 이 화면을 탭하면 그 탭이 필드의 클릭-이동으로 샌다. + if (playerMovement != null && !playerMovement.IsFrozen) playerMovement.SetFrozen(true); + + bool pressed = wantResultClose || wantMouseClick || BattleResultInput.PressedThisFrame(); + wantResultClose = false; + if (!resultCloseArmed && BattleResultRules.AcceptsPress(resultTimer, pressed)) + { + resultCloseArmed = true; + resultArmedAt = resultTimer; + } + + bool sandbox = battleController != null && battleController.IsSandbox; + bool released = resultCloseArmed + && BattleResultRules.ReleaseComplete(BattleResultInput.Held(), resultTimer - resultArmedAt); + if (released || BattleResultRules.ShouldAutoClose(resultTimer, sandbox)) + EndBattle(); + } + + /// OnGUI의 KeyDown — 결과 화면에서 Space/Enter를 받는다(다른 단계의 키는 건드리지 않는다). + private void CaptureResultKey(Event evt) + { + if (phase != Phase.Result || evt == null || evt.type != EventType.KeyDown) return; + if (!BattleResultInput.IsCloseKey(evt.keyCode)) return; + wantResultClose = true; + evt.Use(); + } + + // ── 읽는 단계의 배속 하한(BattleReadPacing) ── + + /// 단계 시계(phaseTimer) 배율 — 상대 교체 단계만 하한을 둔다. + private float PhaseClockScale => phase == Phase.EnemySwitch + ? BattleReadPacing.ClockScale(EnemySwitchSeconds, BattleReadPacing.EnemySwitchMinSeconds, BattlePresentation.Speed) + : 1f; + + /// 인트로 시계(introTimer) 배율 — 진입 구간은 실제 시간, 컷인은 하한. + private float IntroClockScale => phase == Phase.Intro + ? BattleReadPacing.IntroClockScale(introTimer, IntroCutInSeconds, BattlePresentation.Speed) + : 1f; + + /// + /// 상대 교체 등장 연출(아레나 PlayEnemySwitchIn)에 넘길 길이(연출 시계 초) — 교체 단계와 같은 실제 시간에 끝난다. + /// 1배속 1.2초, 2배속 2.0초(실제 1.0초). 를 넘기면 2배속에서 연출이 0.6초에 끝나고 0.4초 서 있는다. + /// + public float EnemySwitchStagingSeconds => + BattleReadPacing.StagingSeconds(EnemySwitchSeconds, BattleReadPacing.EnemySwitchMinSeconds, BattlePresentation.Speed); + + // ── 진입 인트로 ── + + /// 이 전투의 컷인 길이 — 대결(한마디 표가 있는 상대)·수문장이면 CutInSeconds, 아니면 0. + private float IntroCutInSeconds => HasCutIn ? CutInSeconds : 0f; + + /// 인트로 단계인가(입력 없음 — 배속 버튼도 숨는다). + public bool IsIntroPlaying => phase == Phase.Intro; + + /// + /// 인트로 전체 진행률 0~1 — 진입 구간() + [컷인 | 평범한 인트로의 나머지]. + /// 전투 밖이면 0, 인트로가 끝난 뒤면 1. 구간마다 시계 배율이 달라 실제 시간에 정비례하지는 않는다 — 진입 연출은 . + /// + public float IntroProgress => phase == Phase.None ? 0f + : phase == Phase.Intro ? Mathf.Clamp01(introTimer / IntroSeconds) : 1f; + + /// + /// 진입 구간 진행률 0~1 — 진입 샷·화면 쓸기·「야생 ○○이(가) 나타났다!」가 이 값으로 그린다. 이 구간은 배속과 무관하게 실제 시간으로 + /// (1.4초) 동안 흐른다. 컷인이 있는 전투는 이 구간이 끝난 뒤 컷인이 시작한다. + /// + public float EntryIntroProgress => phase == Phase.None ? 0f + : phase == Phase.Intro ? BattleReadPacing.EntryProgress(introTimer) : 1f; + + /// 컷인 시계 — 진입 구간 뒤부터 0(그 전엔 음수). DrawDuelCutIn이 읽는다. + private float CutInClock => BattleReadPacing.CutInElapsed(introTimer); + + // ── 전투 종류 ── + + /// + /// 지금(또는 방금 끝난) 전투의 종류 — 야생·수문장·아이 대결·간부 대결·라온 대결·꿈 챔피언전. 문구 고르기용(ui-dev). + /// 상대 표지가 시작 함수 다음에 서므로() 시작 콜백 안에서는 대결이 아이로 보일 수 있다 — 그리는 쪽은 OnGUI에서 읽는다. + /// 사람 이름(「집게」)은 한마디 표(DuelBanter — duelLines.name)에 있다. 아이 대결은 이름 표가 없다. + /// + public BattleKind CurrentBattleKind + { + get + { + if (battleController == null) return BattleKind.Wild; + string opponent = battleController.DuelOpponentId ?? string.Empty; + return BattleKinds.Classify( + battleController.IsSandbox, + !string.IsNullOrEmpty(battleController.EnemyGuardianRegionId), + battleController.IsDuel, + opponent.Length > 0, + opponent.Length > 0 && NpcRivalDuels.TryGetStage(opponent, out _)); + } + } + } + + /// 결과 화면을 닫는 입력 — 1대1·레이드가 함께 쓴다(Unity Input을 읽는 자리라 순수부와 나눴다). + internal static class BattleResultInput + { + internal static bool IsCloseKey(KeyCode key) + => key == KeyCode.Space || key == KeyCode.Return || key == KeyCode.KeypadEnter; + + /// 이번 프레임에 눌렀다 — 탭·클릭·Space·Enter. + internal static bool PressedThisFrame() + { + return Input.GetMouseButtonDown(0) + || Input.GetKeyDown(KeyCode.Space) + || Input.GetKeyDown(KeyCode.Return) + || Input.GetKeyDown(KeyCode.KeypadEnter); + } + + /// + /// 아직 누르고 있다 — 손을 뗄 때 닫아야 그 탭이 필드의 클릭-이동으로 새지 않는다( 주석). + /// + internal static bool Held() + { + return Input.GetMouseButton(0) + || Input.touchCount > 0 + || Input.GetKey(KeyCode.Space) + || Input.GetKey(KeyCode.Return) + || Input.GetKey(KeyCode.KeypadEnter); + } + } +} diff --git a/Assets/Scripts/UI/BattleScreenUI.Flow.cs.meta b/Assets/Scripts/UI/BattleScreenUI.Flow.cs.meta new file mode 100644 index 0000000..fddfa6f --- /dev/null +++ b/Assets/Scripts/UI/BattleScreenUI.Flow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 48832c86f37920f4aa12a48c39f0c5a1 \ No newline at end of file diff --git a/Assets/Scripts/UI/BattleScreenUI.Impact.cs b/Assets/Scripts/UI/BattleScreenUI.Impact.cs new file mode 100644 index 0000000..673db5e --- /dev/null +++ b/Assets/Scripts/UI/BattleScreenUI.Impact.cs @@ -0,0 +1,35 @@ +using InsectGame.Battle; +using InsectGame.Data; + +namespace InsectGame.UI +{ + /// + /// 1v1 연출에 넘길 타격 정보()를 만든다. + /// 전투는 스킬 버튼을 누른 그 프레임에 동기로 해결되고 OnBattleUpdated가 피해량을 먼저 + /// 적어 두므로, 연출을 시작하는 시점엔 결과가 이미 나와 있다 — 연출은 그걸 얼마나 세게 + /// 보여줄지만 고른다. + /// + public partial class BattleScreenUI + { + private BattleArenaController.HitCue BuildHitCue(bool playerAction, string skillName, SkillEffectType effectType) + { + InsectBattleStats target = playerAction ? enemyStats : playerStats; + int damage = playerAction ? lastDamageToEnemy : lastDamageToPlayer; + int maxHp = target != null ? target.MaxHp : 0; + // 컨트롤러가 굴린 진짜 치명타(LastPlayerHitCritical/LastEnemyHitCritical) — CRITICAL 글자와 같은 값이라 + // 화면 글자와 연출 세기가 따로 놀지 않는다. 예전엔 "최대 HP의 25% 이상"을 치명타로 쳐서 거의 매 타격이 무거웠다. + bool critical = damage > 0 && (playerAction ? lastWasCritical : lastEnemyWasCritical); + int hpAfter = battleController == null ? 1 + : playerAction ? battleController.EnemyHpAfterPlayerAction : battleController.PlayerHpAfterEnemyAction; + bool finisher = damage > 0 && hpAfter <= 0; + bool missed = effectType == SkillEffectType.Damage && damage <= 0; + // 치명타 여부는 세기(평타 0.25~0.5 / 치명타 0.85~1)와 별개로 함께 넘긴다 — 아레나가 치명타만의 연출 + // (별 빛살·CriticalHit 소리·큰 비명)을 세기 문턱이 아니라 이 판정으로 고른다. + // 전용기도 컨트롤러가 적어 둔 표지(SignatureSkills — 레이드와 같은 기준)를 그대로 옮긴다. 빗나가도 켜진다. + bool signature = battleController != null + && (playerAction ? battleController.LastPlayerSkillIsSignature : battleController.LastEnemySkillIsSignature); + return new BattleArenaController.HitCue(skillName, + BattleArenaController.HitCue.WeightFor(damage, maxHp, critical), finisher, missed, critical, signature); + } + } +} diff --git a/Assets/Scripts/UI/BattleScreenUI.Impact.cs.meta b/Assets/Scripts/UI/BattleScreenUI.Impact.cs.meta new file mode 100644 index 0000000..f2e474e --- /dev/null +++ b/Assets/Scripts/UI/BattleScreenUI.Impact.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0751760094258fd4d89b1fb7fc69e2e1 \ No newline at end of file diff --git a/Assets/Scripts/UI/BattleScreenUI.cs b/Assets/Scripts/UI/BattleScreenUI.cs index 2ef477f..ada3f30 100644 --- a/Assets/Scripts/UI/BattleScreenUI.cs +++ b/Assets/Scripts/UI/BattleScreenUI.cs @@ -7,7 +7,7 @@ namespace InsectGame.UI { - public class BattleScreenUI : MonoBehaviour + public partial class BattleScreenUI : MonoBehaviour { [SerializeField] private InsectBattleController battleController; [SerializeField] private CameraFollower cameraFollower; @@ -17,7 +17,8 @@ public class BattleScreenUI : MonoBehaviour [SerializeField] private TrainingManager trainingManager; [SerializeField] private BattleArenaController arena; - private enum Phase { None, Intro, PlayerTurn, PlayerAttack, EnemyAttack, TurnAnnounce, SwapSelect, Result } + // EnemySwitch — 팀 대결에서 상대가 쓰러진 뒤 다음 곤충이 나오는 짧은 단계(입력 없음, EnemySwitchSeconds). EnterEnemySwitch 참조. + private enum Phase { None, Intro, PlayerTurn, PlayerAttack, EnemyAttack, TurnAnnounce, SwapSelect, Result, EnemySwitch } private Phase phase = Phase.None; public bool IsBattleActive => phase != Phase.None; @@ -27,7 +28,94 @@ private enum Phase { None, Intro, PlayerTurn, PlayerAttack, EnemyAttack, TurnAnn private InsectBattleStats prevPlayerStats; private InsectBattleStats prevEnemyStats; + private Vector3 battlePlayerOrigin; + private Vector3 battleEnemyOrigin; + private bool battleEnemyShiny; + private bool hasArenaSnapshot; + + private void RestoreArenaAfterEnable() + { + if (!hasArenaSnapshot || phase == Phase.None || phase == Phase.Result || arena == null + || !arena.isActiveAndEnabled || arena.IsActive || playerStats == null || enemyStats == null) return; + arena.SetupNormalBattle(playerStats.Data, playerStats.Level, enemyStats.Data, enemyStats.Level, + battleEnemyShiny, battlePlayerOrigin, battleEnemyOrigin); + } + private float phaseTimer; + private float attackDuration = 1.8f; + private bool impactRevealed; + private float impactTimer; + private bool faintStarted; + private float faintTimer; + + private void RevealImpact(bool playerAction) + { + if (impactRevealed) return; + impactRevealed = true; + impactTimer = 0f; + if (playerAction && battleController != null) + { + revealPlayerHp = battleController.PlayerHpAfterPlayerAction; + revealEnemyHp = battleController.EnemyHpAfterPlayerAction; + } + else + { + if (battleController != null) + { + revealPlayerHp = battleController.PlayerHpAfterEnemyAction; + revealEnemyHp = battleController.EnemyHpAfterEnemyAction; + } + } + battleController?.PresentResolvedAction(playerAction); + // 콤보 — 내 타격이 보이는 순간 띄우고, 반격이 나를 때리는 순간 끊는다(판정은 OnBattleUpdated가 이미 했다). + if (playerAction && lastDamageToEnemy > 0 && comboCount >= 2) comboDisplayTimer = ComboDisplaySeconds; + if (!playerAction && comboResetPending) + { + comboCount = 0; + comboDisplayTimer = 0f; + comboResetPending = false; + } + int hitDamage = playerAction ? lastDamageToEnemy : lastDamageToPlayer; + bool criticalHit = hitDamage > 0 && (playerAction ? lastWasCritical : lastEnemyWasCritical); + // 타격음은 아레나(BattleArenaController.Impact)가 타격 세기에 맞춰 한 번 낸다 — 여기서도 내면 + // 한 프레임에 두 번 겹친다. 아레나 없이 2D 카드로만 그릴 때만 여기서 낸다. + bool arenaPresents = arena != null && arena.IsActive; + if (hitDamage > 0 && !arenaPresents && AudioManager.Instance != null) + AudioManager.Instance.PlaySFX(criticalHit ? SfxType.CriticalHit : SfxType.Hit); + if (criticalHit && !BattlePresentation.ReducedFlashes) + { + // 내 치명타는 금빛, 맞은 치명타는 붉은빛 — 누가 크게 맞았는지 색으로 갈린다. + screenFlashTimer = 0.18f; + screenFlashColor = playerAction ? new Color(1f, 0.95f, 0.3f) : new Color(1f, 0.35f, 0.3f); + } + } + + private bool FinishFaintPresentation() + { + // Residual damage/healing is a round-end step, distinct from either attack's impact. + if (playerStats != null) revealPlayerHp = playerStats.CurrentHp; + if (enemyStats != null) revealEnemyHp = enemyStats.CurrentHp; + if (!faintStarted) BeginPoisonPopups(); // 독 숫자 — 위 HP 리빌과 같은 순간. 기절 연출 중 재호출은 faintStarted가 거른다 + if (!pendingResult && !pendingSwap && !pendingEnemySwitch) return true; + bool playerFainted = playerStats != null && playerStats.CurrentHp <= 0; + bool enemyFainted = enemyStats != null && enemyStats.CurrentHp <= 0; + if (!playerFainted && !enemyFainted) return true; + if (!faintStarted) + { + faintStarted = true; + faintTimer = 0f; + if (arena != null && arena.IsActive) + { + GameObject model = playerFainted ? arena.PlayerModel : arena.EnemyModel; + if (model != null) StartCoroutine(arena.PlayFaintCoroutine(model)); + // 팀 대결에서 둘이 함께 쓰러졌으면 상대도 눕힌다 — 다음 곤충이 그 자리에 선다(교체 단계). + // 한 마리 전투의 동시 쓰러짐은 예전대로 내 곤충만 눕는다. + if (playerFainted && enemyFainted && pendingEnemySwitch && arena.EnemyModel != null) + StartCoroutine(arena.PlayFaintCoroutine(arena.EnemyModel)); + } + } + return faintTimer >= 0.7f; + } private float introTimer; private string actionText; private float actionTimer; @@ -39,18 +127,42 @@ private enum Phase { None, Intro, PlayerTurn, PlayerAttack, EnemyAttack, TurnAnn private Phase announceNextPhase; // 배너 종료 후 전이할 페이즈 private bool announceTriggerEnemy; // 종료 후 EnemyAttack 연출을 발동할지 private int lastDamageToEnemy; + // 치명타 표시는 컨트롤러의 진짜 판정을 그대로 옮긴다(InsectBattleController.LastPlayerHitCritical/LastEnemyHitCritical). private bool lastWasCritical; + private bool lastEnemyWasCritical; private int comboCount; private float comboDisplayTimer; - // timeScale 단일 아비터(P3) — 크리티컬 슬로모/히트스톱이 각자 timeScale에 직접 쓰면 서로의 복원이 - // 상대를 취소(조기취소/영구 슬로모). 두 unscaled 만료 시각만 갱신하고 매 프레임 우선순위로 timeScale 결정. - private float hitstopUntil; // 히트스톱(0.05) 만료 unscaledTime - private float critUntil; // 크리티컬 슬로모(0.4) 만료 unscaledTime + // 콤보 상자(DrawComboCounter)·"연속 N" 표시 길이 — 그리기 쪽 펄스가 이 길이(2.5초)를 기준으로 짜여 있다. + private const float ComboDisplaySeconds = 2.5f; + // 이번 라운드 상대 반격이 나를 때렸다 — 콤보는 그 반격이 **보이는 순간**(RevealImpact(false))에 끊는다. + // 판정 순간(OnBattleUpdated)에 끊으면 같은 호출 안에서 올린 콤보가 화면에 한 번도 못 뜬다. + private bool comboResetPending; private float screenFlashTimer; private Color screenFlashColor; private GUIStyle cachedComboNumStyle; - private GUIStyle cachedComboLblStyle; + // "연속 N!" — 숫자가 바뀔 때만 잇는다(OnGUI는 한 프레임에 여러 패스가 돈다). + private int comboLabelCount = -1; + private string comboLabel; + + // 라운드 끝 독 숫자 "-N 독" — FinishFaintPresentation(두 공격 연출이 끝나 HP를 마저 드러내는 순간)이 연다. + // 문구는 열 때 한 번 잇고, 시각(Time.unscaledTime)으로 수명을 잰다 — 페이즈가 바뀌어도(턴 배너·교체창) 끝까지 떠오른다. + private const float PoisonPopupSeconds = 1.2f; + private float poisonPopupStartedAt = -1f; + private string playerPoisonPopup; + private string enemyPoisonPopup; + + // HP 카드 상태 줄 — 내용(기절·독·버프 남은 턴)이 바뀔 때만 다시 잇고 잰다. + private sealed class StatusLineCache + { + public long Key = long.MinValue; + public float Width; + public string Text; + } + private readonly StatusLineCache playerStatusLine = new StatusLineCache(); + private readonly StatusLineCache enemyStatusLine = new StatusLineCache(); + private GUIContent statusMeasureContent; + private System.Func statusMeasure; // OnGUI 매 프레임 호출되는 GUIStyle 캐싱 (PlayerStatusHUD 패턴) private bool stylesInitialized; @@ -70,12 +182,10 @@ private enum Phase { None, Intro, PlayerTurn, PlayerAttack, EnemyAttack, TurnAnn private GUIStyle introPNameStyleCache; private GUIStyle introENameStyleCache; private GUIStyle introFightStyleCache; - private GUIStyle introEncounterStyleCache; private GUIStyle skillHeaderCache; private GUIStyle skillKeyNumCache; private GUIStyle skillNameStyleCache; private GUIStyle skillTypeLabelCache; - private GUIStyle skillEffLabelCache; // 상성 배지(효과적/비효과) private GUIStyle skillInfoStyleCache; private GUIStyle skillCdStyleCache; private GUIStyle skillCdInfoCache; @@ -87,7 +197,6 @@ private enum Phase { None, Intro, PlayerTurn, PlayerAttack, EnemyAttack, TurnAnn private GUIStyle critLblCache; private GUIStyle skillStyle3dCache; private GUIStyle skillNameAtkStyleCache; - private GUIStyle dmgStyleAtkCache; private GUIStyle effStyleAtkCache; private GUIStyle buffDebuffSkillStyleCache; private GUIStyle upStyleCache; @@ -95,10 +204,8 @@ private enum Phase { None, Intro, PlayerTurn, PlayerAttack, EnemyAttack, TurnAnn private GUIStyle actionTextStyleCache; private GUIStyle victoryStyleCache; private GUIStyle rewardStyleCache; - private GUIStyle rewardValStyleCache; private GUIStyle defeatStyleCache; private GUIStyle defeatGuideStyleCache; - private GUIStyle defeatHintStyleCache; private GUIStyle phaseIndicatorStyleCache; private GUIStyle swapHeaderCache; private GUIStyle swapKeyStyleCache; @@ -167,16 +274,18 @@ private void OnEnable() private void OnDisable() { + faintStarted = false; + wantMouseClick = false; + wantSkill0 = wantSkill1 = wantSkill2 = wantSkill3 = false; + wantBasicAtk = wantEscape = false; if (battleController != null) { + battleController.DeferPresentation = false; battleController.BattleUpdated -= OnBattleUpdated; battleController.BattleEnded -= OnBattleEnded; battleController.PlayerFainted -= OnPlayerFainted; + battleController.EnemySwitched -= OnEnemySwitched; } - // 슬로우모션 안전 복구 (예외/씬 전환 중 timeScale 잔존 방지) - if (Time.timeScale < 0.99f) Time.timeScale = 1f; - hitstopUntil = 0f; - critUntil = 0f; } private void OnPlayerFainted() @@ -231,14 +340,19 @@ private void OnBattleUpdated(InsectBattleStats player, InsectBattleStats enemy) revealEnemyHp = enemy.CurrentHp; pendingResult = false; pendingSwap = false; + pendingEnemySwitch = false; + incomingEnemyStats = null; announceTimer = 0f; turnNumber = 0; phase = Phase.Intro; introTimer = 0f; resultShown = false; faintedInsectIds.Clear(); + ResetDuelPresentation(); if (player.PlayerData != null) currentInsectId = player.PlayerData.instanceId; - if (AudioManager.Instance != null) AudioManager.Instance.PlayBGM(BgmType.Battle); + // 수문장은 1대1이어도 수문장 곡이다(레이드 수문장과 같은 곡) — 간부·라온 대결은 NpcDuelController가 같은 프레임에 덮는다. + if (AudioManager.Instance != null) + AudioManager.Instance.PlayBGM(BattleMusic.OneVsOne(battleController != null ? battleController.EnemyGuardianRegionId : null)); DisableCanvasBattleUI(); @@ -250,9 +364,14 @@ private void OnBattleUpdated(InsectBattleStats player, InsectBattleStats enemy) if (arena != null) { bool enemyShiny3d = enemyEntity != null && enemyEntity.IsShiny; + battlePlayerOrigin = pPos; + battleEnemyOrigin = ePos; + battleEnemyShiny = enemyShiny3d; + hasArenaSnapshot = true; arena.SetupNormalBattle( player.Data, player.Level, enemy.Data, enemy.Level, enemyShiny3d, pPos, ePos); + arena.PlayBattleOpening(); // 진입 샷(visual-dev) — 상대 옆에서 휘돌아 구도로, BattleFlourish.OpeningShotSeconds } else if (cameraFollower != null && enemyEntity != null) { @@ -282,41 +401,34 @@ private void OnBattleUpdated(InsectBattleStats player, InsectBattleStats enemy) prevEnemyStats = enemyStats; int oldEnemyHp = hpSnapshotTaken ? savedEnemyHp : (enemyStats != null ? enemyStats.CurrentHp : 0); - int oldPlayerHp = hpSnapshotTaken ? savedPlayerHp : (playerStats != null ? playerStats.CurrentHp : 0); hpSnapshotTaken = false; playerStats = player; enemyStats = enemy; - lastDamageToEnemy = Mathf.Max(0, oldEnemyHp - enemy.CurrentHp); - lastDamageToPlayer = Mathf.Max(0, oldPlayerHp - player.CurrentHp); + lastDamageToEnemy = Mathf.Max(0, oldEnemyHp - battleController.EnemyHpAfterPlayerAction); + lastDamageToPlayer = Mathf.Max(0, battleController.PlayerHpAfterPlayerAction - battleController.PlayerHpAfterEnemyAction); - // 크리티컬 판정: 적 MaxHp의 25% 이상 데미지 - lastWasCritical = lastDamageToEnemy > 0 && enemy.MaxHp > 0 && lastDamageToEnemy >= enemy.MaxHp * 0.25f; + // 치명타는 컨트롤러가 굴린 판정이다. 예전엔 "적 MaxHp의 25% 이상"을 치명타로 **표시만** 했는데, + // 전투가 3~4라운드라 거의 매 타격이 그 선을 넘어 CRITICAL이 상시 떴다. + lastWasCritical = lastDamageToEnemy > 0 && battleController.LastPlayerHitCritical; + lastEnemyWasCritical = lastDamageToPlayer > 0 && battleController.LastEnemyHitCritical; - if (lastDamageToEnemy > 0) - { - enemyShake = lastWasCritical ? 0.7f : 0.4f; - comboCount++; - comboDisplayTimer = 2.5f; + // 지난 라운드 반격의 콤보 끊기가 연출로 처리되지 못했으면(연출 중단 등) 여기서 마저 끊는다. + if (comboResetPending) { comboCount = 0; comboResetPending = false; } + if (lastDamageToEnemy > 0) comboCount++; + comboResetPending = lastDamageToPlayer > 0; - // 슬로우모션 + 화면 플래시 (크리티컬만) - if (lastWasCritical) - { - critUntil = Time.unscaledTime + 0.25f; // 아비터가 timeScale 적용 - screenFlashTimer = 0.3f; - screenFlashColor = new Color(1f, 0.95f, 0.3f); - if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.CriticalHit); - } - } - if (lastDamageToPlayer > 0) + phase = Phase.PlayerAttack; + if (!battleController.PlayerActedThisRound) { - comboCount = 0; // 피격 시 콤보 리셋 - comboDisplayTimer = 0f; + attackDuration = 0.8f; + actionText = "행동을 마쳤다"; + lastSkillName = string.Empty; } - - phase = Phase.PlayerAttack; phaseTimer = 0f; + impactRevealed = false; + faintStarted = false; } private void OnBattleEnded(bool playerWon) @@ -347,12 +459,11 @@ private void OnBattleEnded(bool playerWon) /// 것만으로 리전이 열렸다 — 유적에서 야생 파라오풍뎅이 Lv40+를 이기면 2막 6리전이 도미노로. /// /// 좌표로도 판정할 수 없다. 그 다음 시도가 "수문장 자리 15m 안에서 싸웠나"였는데, - /// 그 반경엔 야생이 그대로 들어온다: InsectSpawner.RelocateSpawnPoints가 현재 리전 - /// 스폰포인트를 플레이어로부터 10~43m 나선 위로 끌어오고 거기서 다시 - /// SpawnPoint.radius(5m)만큼 흩어지므로 최근접 스폰이 플레이어에서 5m다. + /// 그 반경엔 야생이 그대로 들어왔다: 당시 스포너는 현재 리전 스폰포인트를 플레이어로부터 10~43m + /// 나선 위로 끌어오고 거기서 다시 5m만큼 흩어서 최근접 스폰이 플레이어에서 5m였다. + /// 지금은 야생이 리전 원판 전체에 기록돼 흩어지지만 수문장 앞을 비켜 두지 않는다 — /// 수문장과 싸우려면 그 앞에 서야 하니 야생이 반경에 들어오는 건 우연이 아니라 구조이고, - /// 위 9개 리전에서는 종·레벨 조건까지 동시에 맞는다. 반경을 좁혀도 스폰 링이 플레이어를 - /// 따라오는 한 같은 결함이 남는다. + /// 위 9개 리전에서는 종·레벨 조건까지 동시에 맞는다. 반경을 좁혀도 같은 결함이 남는다. /// /// 그래서 "바로 그 개체였나"만 묻는다. 수문장은 /// PlaySceneBootstrap.SpawnGuardianInsect가 new GameObject로 세우는 단 @@ -373,14 +484,11 @@ private void CheckGuardianDefeat() TutorialQuestManager.Instance.NotifyGuardianDefeated(); } - // 공격 페이즈 종료 판정 — 아레나(3D)면 연출 코루틴 완료 + 최소 바닥(0.3s), 상한 2s 안전망. - // 아레나 없으면(2D 폴백) 기존 고정 0.8s 유지(2D 애니가 phaseTimer/0.8 기준이라 잘리면 안 됨). + // Both 2D and 3D honor the same action duration; a bounded grace period avoids a stuck coroutine. private bool PhaseAnimDone() { - if (arena == null || !arena.IsActive) return phaseTimer > 0.8f; - if (phaseTimer > 2f) return true; - if (phaseTimer < 0.5f) return false; // 연출이 짧아도 최소 노출 보장(턴 인터벌 확보) - return !arena.IsPlayingSkill; + if (phaseTimer < attackDuration) return false; + return arena == null || !arena.IsActive || !arena.IsPlayingSkill || phaseTimer > attackDuration + 1f; } // EnemyAttack 페이즈 진입 시 적 공격 연출 발동 — 적이 쓴 스킬 속성/근접여부로 러시·투사체·쉐이크. @@ -391,10 +499,10 @@ private void TriggerEnemyAttackEffect() InsectElement elem = es != null ? es.element : (enemyStats != null && enemyStats.Data != null ? enemyStats.Data.primaryType : InsectElement.Bug); SkillEffectType eff = es != null ? es.effectType : SkillEffectType.Damage; - bool dealsDmg = eff == SkillEffectType.Damage; arena.PlaySkillEffect(false, elem, eff, - () => { if (playerStats != null) revealPlayerHp = playerStats.CurrentHp; if (dealsDmg) hitstopUntil = Time.unscaledTime + 0.06f; }, - BattleArenaController.IsMeleeElement(elem)); + () => { if (isActiveAndEnabled && phase == Phase.EnemyAttack) RevealImpact(false); }, + BattleArenaController.IsMeleeElement(elem), attackDuration, + BuildHitCue(false, es != null ? es.displayName : null, eff)); } // 적 치명타 연출이 끝난 뒤 교체창 진입. @@ -406,6 +514,76 @@ private void EnterSwapSelect() pendingSwap = false; } + // ── 상대 교체(팀 대결) ── + // 컨트롤러는 상대가 쓰러지는 그 행동 안에서 다음 곤충을 이미 내보냈다(EnemySwitched). 화면은 마지막 일격과 + // 쓰러짐을 다 보여 준 뒤에야 들어오는 곤충으로 바꾼다 — 그때까지 enemyStats(HP 카드)는 쓰러진 곤충 그대로다. + + /// + /// 교체 단계 길이(배속 시간 — 1배속 실제 초). 이 동안 입력을 받지 않는다. 문구를 읽어야 하는 단계라 2배속이어도 실제 + /// (1.0초) 밑으로는 줄지 않는다(단계 시계가 덜 빨라진다 — BattleScreenUI.Flow). + /// 같은 실제 시간에 끝나는 등장 연출 길이는 . + /// + public const float EnemySwitchSeconds = 1.2f; + + /// 지금 상대가 다음 곤충을 내보내는 중인가(교체 단계). ui-dev가 「○○은(는) △△을(를) 내보냈다!」를 이 동안 그린다. + public bool IsEnemySwitching => phase == Phase.EnemySwitch; + + /// 교체 단계 진행률 0~1(단계 밖이면 0) — 배속 하한이 걸린 길이 전체에 걸쳐 0→1. 아레나 모델은 단계 시작에 이미 새 곤충으로 바뀌어 있다. + public float EnemySwitchProgress => phase == Phase.EnemySwitch ? Mathf.Clamp01(phaseTimer / EnemySwitchSeconds) : 0f; + + /// 교체 단계에서 들어온 상대(단계 밖이면 null). 단계가 시작되면 HP 카드도 이 곤충을 그린다. + public InsectBattleStats SwitchingInEnemy => phase == Phase.EnemySwitch ? enemyStats : null; + + private bool pendingEnemySwitch; + private InsectBattleStats incomingEnemyStats; + + private void OnEnemySwitched(InsectBattleStats outgoing, InsectBattleStats incoming) + { + if (phase == Phase.None || incoming == null) return; + incomingEnemyStats = incoming; + pendingEnemySwitch = true; + } + + /// + /// 라운드 연출(공격·쓰러짐)이 끝난 자리의 다음 단계 — 상대 교체 → 내 교체창 → 결과 → 「당신의 턴」 순서. + /// 결과가 이미 정해졌으면(내가 졌다) 다음 상대를 세우지 않는다. 교체 단계가 끝나면 다시 여기로 온다. + /// + private void ContinueAfterRound() + { + if (pendingEnemySwitch && !pendingResult) + { + EnterEnemySwitch(); + return; + } + pendingEnemySwitch = false; + incomingEnemyStats = null; + if (pendingSwap) EnterSwapSelect(); + else if (pendingResult) EnterResult(); + else BeginTurnAnnounce("당신의 턴", true, Phase.PlayerTurn, triggerEnemy: false); + } + + // 들어오는 곤충으로 화면 상태를 바꾸고 아레나 모델을 다시 세운다(기능만 — 등장 연출은 visual-dev). + private void EnterEnemySwitch() + { + pendingEnemySwitch = false; + InsectBattleStats incoming = incomingEnemyStats; + incomingEnemyStats = null; + phase = Phase.EnemySwitch; + phaseTimer = 0f; + faintStarted = false; + if (incoming == null) return; + + prevEnemyStats = enemyStats; + enemyStats = incoming; + displayEnemyHp = incoming.CurrentHp; + chipEnemyHp = incoming.CurrentHp; + revealEnemyHp = incoming.CurrentHp; + enemyShake = 0f; + battleEnemyShiny = false; // 대결 상대는 이로치가 아니다(RestoreArenaAfterEnable이 이 값으로 다시 세운다) + if (arena != null && arena.IsActive) + arena.PlayEnemySwitchIn(incoming.Data, incoming.Level, false, EnemySwitchStagingSeconds); // 다시 세우기 + 등장 연출(visual-dev) — 단계와 같은 실제 시간에 끝나는 길이 + } + // 마지막 공격 연출이 끝난 뒤 결과화면 진입 — 승패 SFX/BGM도 여기서(연출 도중 스포일 방지). private void EnterResult() { @@ -419,6 +597,7 @@ private void EnterResult() phase = Phase.Result; resultShown = true; resultTimer = 0f; + ResetResultClose(); pendingResult = false; // 교체할 팀원이 없어 여기로 온 패배는 **컨트롤러가 아직 종료 처리를 못 했다** — @@ -428,11 +607,13 @@ private void EnterResult() // 승리·도주로 온 경우엔 이미 종료돼 있어 멱등하게 무시된다. if (!lastWon && battleController != null) battleController.ConcludeDefeatWithoutSwap(); - if (AudioManager.Instance != null) + if (AudioManager.Instance != null && (battleController == null || !battleController.DidEscape)) { AudioManager.Instance.PlaySFX(lastWon ? SfxType.Victory : SfxType.Defeat); AudioManager.Instance.PlayBGM(lastWon ? BgmType.Victory : BgmType.Defeat); } + // 승리 연출(visual-dev) — 내 곤충 포즈 + 카메라 반 바퀴, BattleFlourish.VictorySeconds. 패배·도망은 그대로. + if (lastWon && arena != null && arena.IsActive && (battleController == null || !battleController.DidEscape)) arena.PlayVictory(); } private void Update() @@ -441,55 +622,55 @@ private void Update() if (comboDisplayTimer > 0f) comboDisplayTimer -= Time.unscaledDeltaTime; if (phase == Phase.None) return; - - // timeScale 아비터 (배틀 중에만 실행 — 비배틀 프레임에 외부 timeScale 미간섭). - // 히트스톱(0.05) > 크리티컬 슬로모(0.4) > 정상(1). 우리가 늦춘 범위(0.001~0.99)일 때만 복원. - float uNow = Time.unscaledTime; - if (uNow < hitstopUntil) Time.timeScale = 0.05f; - else if (uNow < critUntil) Time.timeScale = 0.4f; - else if (Time.timeScale < 0.99f && Time.timeScale > 0.001f) Time.timeScale = 1f; - - phaseTimer += Time.deltaTime; - introTimer += Time.deltaTime; - - if (actionTimer > 0) actionTimer -= Time.deltaTime; - if (playerShake > 0) playerShake -= Time.deltaTime; - if (enemyShake > 0) enemyShake -= Time.deltaTime; - // **unscaled다.** 443행의 timeScale 복원은 `> 0.001f`라 정확히 0은 구제하지 못하는데, - // 그 상태로 남으면 결과 화면이 4초 조건에 영영 도달하지 못해 전투가 안 끝난다. - // 결과 화면 중에는 timeScale이 1이라 연출 타이밍은 그대로다. + RestoreArenaAfterEnable(); + // 전투 화면이 떠 있는 동안 플레이어를 계속 묶어 둔다 — SetFrozen(true)는 PlayerMovement의 자동 해제 시계(AutoUnfreezeTime 20초)를 + // 0으로 되돌린다. 안 그러면 20초 넘는 전투나 ESC 뒤에 고정이 풀려, 기술 버튼을 누른 탭이 숨은 플레이어의 클릭-이동으로 샌다 + // (결과 화면만 막던 것을 전투 전체로 넓혔다 — 2026-10-04). + if (playerMovement != null) playerMovement.SetFrozen(true); + + // 읽는 단계(상대 교체·진입 인트로·컷인)는 배속을 덜 탄다 — 배율은 BattleScreenUI.Flow(BattleReadPacing). 1배속이면 1이다. + phaseTimer += BattlePresentation.DeltaTime * PhaseClockScale; + introTimer += BattlePresentation.DeltaTime * IntroClockScale; + + if (actionTimer > 0) actionTimer -= BattlePresentation.DeltaTime; + if (playerShake > 0) playerShake -= BattlePresentation.DeltaTime; + if (enemyShake > 0) enemyShake -= BattlePresentation.DeltaTime; + // Results can still close when another system pauses the simulation. if (resultShown) resultTimer += Time.unscaledDeltaTime; - // 아레나(3D) 없으면 즉시 리빌(기존 2D 동작 보존). 아레나 있으면 연출 임팩트 onImpact에서 리빌. - // 단 턴 배너 중엔 2D도 리빌 보류 — 배너가 "상대의 턴"인데 HP가 미리 깎여 보이는 것 방지. - if ((arena == null || !arena.IsActive) && phase != Phase.TurnAnnounce) - { - if (playerStats != null) revealPlayerHp = playerStats.CurrentHp; - if (enemyStats != null) revealEnemyHp = enemyStats.CurrentHp; - } + if (faintStarted) faintTimer += BattlePresentation.DeltaTime; + if (impactRevealed) impactTimer += BattlePresentation.DeltaTime; + if ((phase == Phase.PlayerAttack || phase == Phase.EnemyAttack) && !impactRevealed + && (arena == null || !arena.IsActive || !arena.IsPlayingSkill) && phaseTimer >= attackDuration * 0.4f) + RevealImpact(phase == Phase.PlayerAttack); - float hpSpeed = 60f * Time.deltaTime; if (playerStats != null) - displayPlayerHp = Mathf.MoveTowards(displayPlayerHp, revealPlayerHp, hpSpeed); + displayPlayerHp = Mathf.MoveTowards(displayPlayerHp, revealPlayerHp, + Mathf.Max(60f, playerStats.MaxHp / 0.5f) * BattlePresentation.DeltaTime); if (enemyStats != null) - displayEnemyHp = Mathf.MoveTowards(displayEnemyHp, revealEnemyHp, hpSpeed); + displayEnemyHp = Mathf.MoveTowards(displayEnemyHp, revealEnemyHp, + Mathf.Max(60f, enemyStats.MaxHp / 0.5f) * BattlePresentation.DeltaTime); // 칩바 — 실제 fill(displayHp)보다 느리게 감소해 최근 피해 잔상. 회복 시엔 스냅. - float chipSpeed = 22f * Time.deltaTime; + float playerChipSpeed = Mathf.Max(22f, playerStats != null ? playerStats.MaxHp / 1.2f : 22f) * BattlePresentation.DeltaTime; + float enemyChipSpeed = Mathf.Max(22f, enemyStats != null ? enemyStats.MaxHp / 1.2f : 22f) * BattlePresentation.DeltaTime; chipPlayerHp = chipPlayerHp > displayPlayerHp - ? Mathf.MoveTowards(chipPlayerHp, displayPlayerHp, chipSpeed) : displayPlayerHp; + ? Mathf.MoveTowards(chipPlayerHp, displayPlayerHp, playerChipSpeed) : displayPlayerHp; chipEnemyHp = chipEnemyHp > displayEnemyHp - ? Mathf.MoveTowards(chipEnemyHp, displayEnemyHp, chipSpeed) : displayEnemyHp; + ? Mathf.MoveTowards(chipEnemyHp, displayEnemyHp, enemyChipSpeed) : displayEnemyHp; // BGM 인텐시티: HP 30% 이하부터 가파르게 상승 if (AudioManager.Instance != null && playerStats != null && playerStats.MaxHp > 0) { - float hpRatio = (float)playerStats.CurrentHp / playerStats.MaxHp; + float hpRatio = displayPlayerHp / playerStats.MaxHp; float intensity = Mathf.Clamp01((0.5f - hpRatio) * 2f); AudioManager.Instance.SetBattleIntensity(intensity); } - if (phase == Phase.Intro && introTimer > 2.0f) + TickDuelBanter(); + + // 인트로 길이는 상대에 따라 다르다 — 간부·수문장이면 컷인 길이(BattleScreenUI.Duel). + if (phase == Phase.Intro && introTimer > IntroSeconds) { phase = Phase.PlayerTurn; phaseTimer = 0f; @@ -497,7 +678,7 @@ private void Update() if (phase == Phase.SwapSelect) { - swapMessageTimer += Time.deltaTime; + swapMessageTimer += BattlePresentation.DeltaTime; int swapIndex = -1; if (wantSwap0 || Input.GetKeyDown(KeyCode.Alpha1)) swapIndex = 0; else if (wantSwap1 || Input.GetKeyDown(KeyCode.Alpha2)) swapIndex = 1; @@ -507,7 +688,7 @@ private void Update() wantSwap0 = wantSwap1 = wantSwap2 = wantSwap3 = wantSwap4 = false; if (swapIndex >= 0) TrySwapToSlot(swapIndex); - if (wantMouseClick || Input.GetMouseButtonDown(0)) + if ((wantMouseClick || Input.GetMouseButtonDown(0)) && !IsSpeedControlPointerHit) { Vector2 mousePos = wantMouseClick ? guiMousePos : UIScale.VirtualMousePosition; @@ -534,7 +715,7 @@ private void Update() wantSkill0 = wantSkill1 = wantSkill2 = wantSkill3 = false; wantBasicAtk = wantEscape = false; - if (wantMouseClick || Input.GetMouseButtonDown(0)) + if ((wantMouseClick || Input.GetMouseButtonDown(0)) && !IsSpeedControlPointerHit) { Vector2 mousePos = wantMouseClick ? guiMousePos : UIScale.VirtualMousePosition; @@ -556,37 +737,37 @@ private void Update() if (phase == Phase.PlayerAttack && PhaseAnimDone()) { - if (enemyStats != null) revealEnemyHp = enemyStats.CurrentHp; // 연출 종료 시 최종 동기(보증) - // 종료/교체는 배너 없이 즉시(연출 스포일 방지 우선). 그 외엔 다음 턴을 배너로 안내. - if (pendingSwap) EnterSwapSelect(); - else if (pendingResult) EnterResult(); - else if (lastDamageToPlayer > 0) + RevealImpact(true); + // The round resolves synchronously, but a fatal enemy reply must still be shown. + if (battleController != null && battleController.EnemyActedThisRound) BeginTurnAnnounce("상대의 턴", false, Phase.EnemyAttack, triggerEnemy: true); - else - BeginTurnAnnounce("당신의 턴", true, Phase.PlayerTurn, triggerEnemy: false); + else if (FinishFaintPresentation()) + { + if (enemyStats != null) revealEnemyHp = enemyStats.CurrentHp; + if (playerStats != null) revealPlayerHp = playerStats.CurrentHp; + ContinueAfterRound(); + } } if (phase == Phase.EnemyAttack && PhaseAnimDone()) { - if (playerStats != null) revealPlayerHp = playerStats.CurrentHp; // 연출 종료 시 최종 동기(보증) - if (pendingSwap) EnterSwapSelect(); - else if (pendingResult) EnterResult(); - else - BeginTurnAnnounce("당신의 턴", true, Phase.PlayerTurn, triggerEnemy: false); + RevealImpact(false); + if (FinishFaintPresentation()) ContinueAfterRound(); } + if (phase == Phase.EnemySwitch && phaseTimer >= EnemySwitchSeconds) + ContinueAfterRound(); + if (phase == Phase.TurnAnnounce) { - announceTimer -= Time.deltaTime; + announceTimer -= BattlePresentation.DeltaTime; // 탭/키 입력 시 즉시 스킵(반복 전투 배려) — 추가 상태 없이 기존 입력 플래그 재사용. if (wantMouseClick) { wantMouseClick = false; announceTimer = 0f; } if (announceTimer <= 0f) FinishTurnAnnounce(); } - if (phase == Phase.Result && resultTimer > 4f) - { - EndBattle(); - } + // 결과 화면은 탭·클릭·Space/Enter로만 닫힌다(처음 0.6초는 안 받는다, 꿈 챔피언전만 4초 뒤 저절로) — BattleScreenUI.Flow. + if (phase == Phase.Result) TickResultClose(); // 탭 래치 차단 — `Input.GetMouseButtonDown`과 같은 한 프레임 수명으로 맞춘다. // `wantMouseClick`은 OnGUI(:MouseDown)가 세우는데 소거는 SwapSelect/PlayerTurn/TurnAnnounce @@ -617,7 +798,8 @@ private void FinishTurnAnnounce() phaseTimer = 0f; if (announceTriggerEnemy) { - playerShake = 0.4f; + impactRevealed = false; + attackDuration = battleController != null && battleController.LastEnemySkill != null ? 2.5f : 1.8f; SetEnemyActionText(); // 적 행동 텍스트(플레이어와 대칭) TriggerEnemyAttackEffect(); // 적 공격 연출 발동 } @@ -642,7 +824,7 @@ private void SnapshotHp() private void TryUseSkill(int index) { - if (battleController == null || !battleController.CanUseSkill(index)) return; + if (phase != Phase.PlayerTurn || battleController == null || !battleController.CanUseSkill(index)) return; if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.SkillUse); turnNumber++; @@ -652,41 +834,45 @@ private void TryUseSkill(int index) lastSkillElement = skill != null ? skill.element : playerStats.Data.primaryType; actionText = $"{playerStats.Data.displayName}의 {lastSkillName}!"; actionTimer = 1.5f; + attackDuration = 2.5f; SnapshotHp(); - if (arena != null && arena.IsActive) + battleController.UseSkill(index); + if (battleController.PlayerActedThisRound && arena != null && arena.IsActive) { InsectElement elem = lastSkillElement; SkillEffectType effectType = (skill != null) ? skill.effectType : SkillEffectType.Damage; - bool dealsDmg = effectType == SkillEffectType.Damage; arena.PlaySkillEffect(true, elem, effectType, - () => { if (enemyStats != null) revealEnemyHp = enemyStats.CurrentHp; if (dealsDmg) hitstopUntil = Time.unscaledTime + 0.06f; }, - BattleArenaController.IsMeleeElement(elem)); + () => { if (isActiveAndEnabled && phase == Phase.PlayerAttack) RevealImpact(true); }, + BattleArenaController.IsMeleeElement(elem), attackDuration, + BuildHitCue(true, skill != null ? skill.displayName : null, effectType)); } - battleController.UseSkill(index); } private void TryBasicAttack() { - if (battleController == null) return; + if (phase != Phase.PlayerTurn || battleController == null) return; if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.Attack); turnNumber++; lastSkillName = "기본 공격"; lastSkillElement = (playerStats != null && playerStats.Data != null) ? playerStats.Data.primaryType : InsectElement.Bug; actionText = $"{playerStats.Data.displayName}의 기본 공격!"; actionTimer = 1.5f; + attackDuration = 1.8f; SnapshotHp(); - if (arena != null && arena.IsActive) + battleController.UseBasicAttack(); + if (battleController.PlayerActedThisRound && arena != null && arena.IsActive) { InsectElement elem = (playerStats != null && playerStats.Data != null) ? playerStats.Data.primaryType : InsectElement.Bug; + // 기본 공격은 외치지 않는다(이름이 없다) — 울음과 타격감만. arena.PlaySkillEffect(true, elem, SkillEffectType.Damage, - () => { if (enemyStats != null) revealEnemyHp = enemyStats.CurrentHp; hitstopUntil = Time.unscaledTime + 0.06f; }, - BattleArenaController.IsMeleeElement(elem)); + () => { if (isActiveAndEnabled && phase == Phase.PlayerAttack) RevealImpact(true); }, + BattleArenaController.IsMeleeElement(elem), attackDuration, + BuildHitCue(true, null, SkillEffectType.Damage)); } - battleController.UseBasicAttack(); } private void TrySwapToSlot(int slotIndex) @@ -714,7 +900,9 @@ private void TrySwapToSlot(int slotIndex) private void TryEscape() { - if (battleController == null) return; + if (phase != Phase.PlayerTurn || battleController == null) return; + if (battleController.IsSandbox) return; // 챔피언전은 도망칠 수 없다 — 턴도 문구도 소모하지 않는다 + attackDuration = 1.8f; SnapshotHp(); bool escaped = battleController.TryEscape(); if (escaped) @@ -788,8 +976,6 @@ private void InitStyles() { fontSize = 28, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; introFightStyleCache = new GUIStyle(GUI.skin.label) { fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - introEncounterStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 32, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; // DrawSkillPanel // 아래 라벨들은 전부 세로 가운데 정렬이다. IMGUI 기본값(Upper*)은 글자를 상자 맨 위에 @@ -810,9 +996,7 @@ private void InitStyles() }; skillTypeLabelCache = new GUIStyle(GUI.skin.label) { fontSize = 20, alignment = TextAnchor.MiddleLeft }; - // 상성 배지는 위력과 같은 줄의 오른쪽 칸이라 우측 정렬이다. - skillEffLabelCache = new GUIStyle(GUI.skin.label) - { fontSize = 19, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleRight }; + // 상성 칩은 BattleFeelDraw.MatchupChip이 자기 스타일로 그린다. skillInfoStyleCache = new GUIStyle(GUI.skin.label) { fontSize = 20, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; skillCdStyleCache = new GUIStyle(GUI.skin.label) @@ -848,8 +1032,6 @@ private void InitStyles() skillStyle3dCache.normal.textColor = Color.white; skillNameAtkStyleCache = new GUIStyle(GUI.skin.label) { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - dmgStyleAtkCache = new GUIStyle(GUI.skin.label) - { fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; effStyleAtkCache = new GUIStyle(GUI.skin.label) { fontSize = 20, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; @@ -870,14 +1052,10 @@ private void InitStyles() { fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; rewardStyleCache = new GUIStyle(GUI.skin.label) { fontSize = 24, alignment = TextAnchor.MiddleCenter }; - rewardValStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 24, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; defeatStyleCache = new GUIStyle(GUI.skin.label) { fontSize = 50, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; defeatGuideStyleCache = new GUIStyle(GUI.skin.label) { fontSize = 22, alignment = TextAnchor.MiddleCenter }; - defeatHintStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 18, alignment = TextAnchor.MiddleCenter }; // DrawPhaseIndicator phaseIndicatorStyleCache = new GUIStyle(GUI.skin.label) @@ -905,14 +1083,30 @@ private void InitStyles() swapCurStyleCache.normal.textColor = new Color(1f, 0.3f, 0.3f, 0.9f); } + private GUIStyle speedControlStyle; + private bool ShowSpeedControl => phase != Phase.None && phase != Phase.Intro && !resultShown; + private Rect SpeedControlRect => DuelHudLayout.SpeedControl(HudFrame.Current); + private bool IsSpeedControlPointerHit => ShowSpeedControl && SpeedControlRect.Contains(UIScale.VirtualMousePosition); + + private void DrawSpeedControl() + { + if (!ShowSpeedControl) return; + if (speedControlStyle == null) + speedControlStyle = new GUIStyle(GUI.skin.label) { fontSize = 24, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + speedControlStyle.normal.textColor = UITheme.Instance.textPrimary; + if (UISurface.Button(SpeedControlRect, $"속도 {BattlePresentation.Speed:0}×", UITheme.Instance.surfaceRaised, speedControlStyle)) + BattlePresentation.Speed = BattlePresentation.Speed < 1.5f ? 2f : 1f; + } + private void OnGUI() { if (phase == Phase.None) return; InitStyles(); UIScale.Begin(); + // 전용기 이름은 아래 컷인 띠(BattleScreenUI.Feel)가 크게 띄운다 — 아레나 머리 위 말풍선과 겹치지 않게 끈다(EndBattle이 되돌린다). + SuppressArenaSignatureCallout(); DrawScreenFlash(); - DrawComboCounter(); Event evt = Event.current; if (evt != null && evt.type == EventType.KeyDown) @@ -946,8 +1140,9 @@ private void OnGUI() evt.Use(); break; } } + CaptureResultKey(evt); // 결과 화면의 Space/Enter(BattleScreenUI.Flow) - if (evt != null && evt.type == EventType.MouseDown && evt.button == 0) + if (evt != null && evt.type == EventType.MouseDown && evt.button == 0 && !IsSpeedControlPointerHit) { wantMouseClick = true; guiMousePos = new Vector2(evt.mousePosition.x, evt.mousePosition.y); @@ -956,9 +1151,19 @@ private void OnGUI() DrawBattleOverlay(); DrawBattleField(); DrawHpBars(); + // 연속 공격 배지 — 내 HP 카드 아래 쌓기(BattleHudStack.ComboBadge). 2D 폴백에서만 그리던 것을 + // 아레나 화면에도 그린다(오른쪽 위에 박혀 상대 HP 카드·배속 버튼과 겹쳐서 아레나에서 꺼 두었었다). + DrawComboCounter(); + // 라운드 끝 독 숫자 — 페이즈와 무관하게 수명 동안 떠오른다(열리는 순간 페이즈가 턴 배너·교체창으로 넘어간다). + // 곤충 위에 붙는 숫자라 아래 패널(행동 패널·교체창·턴 배너)보다 먼저 그려 패널이 덮게 한다. + DrawPoisonPopups(); if (phase == Phase.Intro) - DrawIntro(); + { + // 진입 구간(화면 쓸기 + 「야생 ○○이(가) 나타났다!」 — BattleScreenUI.Feel) → 대결·수문장 컷인(진입 구간 뒤에만 그린다). + DrawEntry(); + DrawDuelCutIn(); + } else if (phase == Phase.PlayerTurn) DrawSkillPanel(); else if (phase == Phase.SwapSelect) @@ -966,7 +1171,7 @@ private void OnGUI() else if (phase == Phase.PlayerAttack) { DrawAttackAnimation(true); - DrawPhaseIndicator("내 곤충의 공격!"); + DrawPhaseIndicator(battleController != null && !battleController.PlayerActedThisRound ? "행동 확인" : "내 곤충의 공격!"); } else if (phase == Phase.EnemyAttack) { @@ -976,9 +1181,21 @@ private void OnGUI() else if (phase == Phase.TurnAnnounce) DrawTurnAnnounce(); + // 전용기 컷인 띠 — 시전자 클로즈업 동안 화면을 가로지른다(BattleScreenUI.Feel). + DrawSignatureCutIn(); + + // 팀 대결 교체 문구 — 교체 단계와 이어지는 턴 배너 동안(BattleScreenUI.Duel). + DrawEnemySendOut(); + if (actionTimer > 0) DrawActionText(); + // 기술 이름 외치기·비명·타격 의성어 — 결과 패널보다 먼저(아래에). 마지막 "털썩…"이 + // 결과 패널 위에 찍히던 자리다. + BattleShoutOverlay.Draw(arena); + + DrawDuelBubble(); + if (resultShown) DrawResult(); @@ -986,6 +1203,8 @@ private void OnGUI() // 가상 캔버스 안으로 들여왔다(BattleEffectTextOverlay 주석 참고). BattleEffectTextOverlay.Draw(arena); + DrawSpeedControl(); + UIScale.End(); } @@ -997,11 +1216,13 @@ private void DrawBattleOverlay() // 3D 아레나 활성 시: 상단 턴 표시 바만 그리고 2D 배경 스킵 if (arena != null && arena.IsActive) { - float barTop = UISafeLayout.ContentTop; // 노치/상태바 + 세로 마진 아래로 - GUI.color = new Color(0.02f, 0.03f, 0.06f, 0.7f); - GUI.DrawTexture(new Rect(0, 0, sw, barTop + 38f), Texture2D.whiteTexture); - GUI.color = Color.white; - GUI.Label(new Rect(0, barTop, sw, 34), $"BATTLE - Turn {turnNumber + 1}", turnStyle3dCache); + // 결과 화면은 위쪽을 「승리!」가 쓴다(BattleVictoryLayout) — 행동 수 띠를 걷는다. + if (resultShown) return; + Rect heading = DuelHudLayout.Heading(HudFrame.Current); + UISurface.Card(heading, UITheme.Instance.surfaceBase, UITheme.Instance.surfaceBorder); + turnStyle3dCache.normal.textColor = UITheme.Instance.textSecondary; + // 연속 공격은 따로 배지로 뜬다(DrawComboCounter) — 여기에도 붙이면 같은 숫자가 두 번 보인다. + UIHelper.LabelFit(heading, $"탐험 전투 · {turnNumber + 1}번째 행동", turnStyle3dCache); return; } @@ -1723,23 +1944,18 @@ private void DrawFly(float cx, float cy, float s, float dir, Color body, Color d private void DrawHpBars() { - if (playerStats == null || enemyStats == null) return; - - float arenaBot = UIScale.VirtualScreenHeight * 0.60f; - // 장부 게이지를 적 상자 **위**에 올린다 — HP와 나란히 두면 어느 쪽이 위험한지 - // 한눈에 안 갈린다. 명부회 보스전이 아니면 아무것도 안 그린다. - DrawLedgerGauge(UIScale.VirtualScreenWidth - 440, arenaBot - 26f, 420); - DrawHpBox(20, arenaBot + 8, 420, playerStats, displayPlayerHp, true); - DrawHpBox(UIScale.VirtualScreenWidth - 440, arenaBot + 8, 420, enemyStats, displayEnemyHp, false); + // 결과 화면에서는 HP 카드 쌓기를 걷는다 — 위쪽 「승리!」·한마디와 겹치고(세로 화면은 카드가 폭을 다 쓴다), 결과가 이미 말해 준다. + if (playerStats == null || enemyStats == null || resultShown) return; + Rect safe = UISafeLayout.Content; + Rect player = DuelHudLayout.HpCard(safe, true); + Rect enemy = DuelHudLayout.HpCard(safe, false); + DrawHpBox(player.x, player.y, player.width, playerStats, displayPlayerHp, true); + DrawHpBox(enemy.x, enemy.y, enemy.width, enemyStats, displayEnemyHp, false); + Rect ledger = BattleHudStack.LedgerGauge(enemy); + DrawLedgerGauge(ledger.x, ledger.y, ledger.width); + DrawEnvironmentChips(player, enemy); // 낮·밤·날씨 보정 칩 — BattleScreenUI.Environment.cs } - /// - /// 「장부」 게이지 — 명부회 보스전에만 뜬다(LedgerThreshold가 0이면 그리지 않는다). - /// - /// 예고가 이 요소의 전부다. 같은 행동을 되풀이하면 차오르는 걸 눈으로 보고, - /// 임계 두 칸 전부터 붉어진다. 그걸 보고도 못 바꿨을 때만 맞아야 긴장이지, - /// 아무 신호 없이 큰 게 들어오면 그냥 사고다. - /// private void DrawLedgerGauge(float x, float y, float w) { if (battleController == null) return; @@ -1751,7 +1967,7 @@ private void DrawLedgerGauge(float x, float y, float w) // 단계 판정은 순수부가 든다 — 경고 여부만 보고 색을 고르면 **가득 찬 순간** // 평상색으로 되돌아간다(IsWarning은 정의상 IsFull일 때 false다). LedgerAlert alert = LedgerPressure.AlertOf(tally, threshold); - const float h = 26f; + const float h = BattleHudStack.LedgerGaugeHeight; UISurface.Flat(new Rect(x, y, w, h), new Color(0.09f, 0.09f, 0.14f, 0.94f)); if (fill > 0.001f) @@ -1784,471 +2000,161 @@ private void DrawLedgerGauge(float x, float y, float w) private void DrawHpBox(float x, float y, float w, InsectBattleStats stats, float dispHp, bool isPlayer) { - float h = 130f; - Color rarityCol = UITheme.Instance.GetInsectRarityColor(stats.Data.rarity); - - GUI.color = new Color(0.05f, 0.06f, 0.12f, 0.94f); - GUI.DrawTexture(new Rect(x, y, w, h), Texture2D.whiteTexture); - GUI.color = rarityCol; - GUI.DrawTexture(new Rect(x, y, w, 4), Texture2D.whiteTexture); - GUI.color = new Color(rarityCol.r, rarityCol.g, rarityCol.b, 0.3f); - GUI.DrawTexture(new Rect(x, y + h - 2, w, 2), Texture2D.whiteTexture); - - hpNameStyleCache.normal.textColor = rarityCol; - GUI.color = Color.white; - UIHelper.LabelFit(new Rect(x + 14, y + 10, w - 110, 32), stats.Data.displayName, hpNameStyleCache); - - GUI.Label(new Rect(x + w - 100, y + 10, 86, 28), $"Lv.{stats.Level}", hpLvStyleCache); - - UIHelper.LabelFit(new Rect(x + 14, y + 42, w - 28, 22), - $"ATK {stats.Attack} DEF {stats.Defense}", hpMiniStatCache); - - float barX = x + 14; - float barY = y + 70; - float barW = w - 28; - float barH = 26f; - - GUI.color = new Color(0.12f, 0.12f, 0.18f); - GUI.DrawTexture(new Rect(barX, barY, barW, barH), Texture2D.whiteTexture); - - float hpRatio = stats.MaxHp > 0 ? Mathf.Clamp01(dispHp / stats.MaxHp) : 0; - - // 칩바(ghost) — chipHp까지 흐릿한 적색 잔상으로 최근 피해량 시각화(main fill 뒤에 먼저 그림). - float chipHp = isPlayer ? chipPlayerHp : chipEnemyHp; - float chipRatio = stats.MaxHp > 0 ? Mathf.Clamp01(chipHp / stats.MaxHp) : 0f; - if (chipRatio > hpRatio + 0.001f) - { - GUI.color = new Color(0.9f, 0.3f, 0.25f, 0.7f); - GUI.DrawTexture(new Rect(barX, barY, barW * chipRatio, barH), Texture2D.whiteTexture); - } - - Color hpColor = hpRatio > 0.5f ? new Color(0.3f, 0.85f, 0.35f) : - hpRatio > 0.2f ? new Color(0.95f, 0.8f, 0.2f) : - new Color(0.95f, 0.25f, 0.2f); - GUI.color = hpColor; - GUI.DrawTexture(new Rect(barX, barY, barW * hpRatio, barH), Texture2D.whiteTexture); - - GUI.color = new Color(hpColor.r + 0.15f, hpColor.g + 0.15f, hpColor.b + 0.15f, 0.4f); - GUI.DrawTexture(new Rect(barX, barY, barW * hpRatio, barH / 3f), Texture2D.whiteTexture); - - // 피격 플래시 — 최근 피해 시 흰 오버레이(shake 타이머 재사용). 무피해/회복 시 0. - float hitFlash = isPlayer ? playerShake : enemyShake; - if (hitFlash > 0f) - { - GUI.color = new Color(1f, 1f, 1f, Mathf.Clamp01(hitFlash) * 0.5f); - GUI.DrawTexture(new Rect(barX, barY, barW * Mathf.Max(chipRatio, hpRatio), barH), Texture2D.whiteTexture); - } - - GUI.color = Color.white; - GUI.Label(new Rect(barX, barY, barW, barH), $"{Mathf.CeilToInt(dispHp)} / {stats.MaxHp}", hpTextCache); - + UITheme theme = UITheme.Instance; + Color affiliation = isPlayer ? theme.accentMint : theme.accentCoral; + Rect card = new Rect(x, y, w, DuelHudLayout.HpCardHeight); + UISurface.Card(card, theme.surfaceBase, theme.surfaceBorder); + UISurface.Flat(new Rect(x + UITheme.Radius.Card, y + 3f, w - UITheme.Radius.Card * 2f, 3f), affiliation); + hpMiniStatCache.normal.textColor = affiliation; + // 팀 대결이면 상대 카드 윗줄 오른쪽에 남은 곤충 공(●●○)이 선다 — 글자는 그 앞에서 멈춘다(BattleScreenUI.Duel). + float tagW = isPlayer ? w - 120f : EnemyCardLabelWidth(card, w - 120f); + UIHelper.LabelFit(new Rect(x + 18f, y + 10f, tagW, 24f), isPlayer ? "내 파트너" : "상대 곤충", hpMiniStatCache); + if (!isPlayer) DrawEnemyTeamBalls(card); + hpLvStyleCache.normal.textColor = theme.textSecondary; + GUI.Label(new Rect(x + w - 102f, y + 12f, 84f, 30f), $"Lv. {stats.Level}", hpLvStyleCache); + hpNameStyleCache.normal.textColor = theme.textPrimary; + hpNameStyleCache.fontSize = 28; + UIHelper.LabelFit(new Rect(x + 18f, y + 36f, w - 36f, 38f), stats.Data.displayName, hpNameStyleCache); + + float ratio = stats.MaxHp > 0 ? Mathf.Clamp01(dispHp / stats.MaxHp) : 0f; + float chip = isPlayer ? chipPlayerHp : chipEnemyHp; + float chipRatio = stats.MaxHp > 0 ? Mathf.Clamp01(chip / stats.MaxHp) : 0f; + Rect track = new Rect(x + 18f, y + 80f, w - 36f, 10f); + UISurface.Flat(track, theme.surfaceRaised); + if (chipRatio > ratio) + UISurface.Flat(new Rect(track.x, track.y, track.width * chipRatio, track.height), theme.accentAmber); + Color hpColor = ratio > 0.2f ? affiliation : theme.accentAmber; + UISurface.Flat(new Rect(track.x, track.y, track.width * ratio, track.height), hpColor); + hpTextCache.normal.textColor = theme.textPrimary; + hpTextCache.alignment = TextAnchor.MiddleRight; + GUI.Label(new Rect(x + w - 186f, y + 96f, 168f, 28f), $"{Mathf.CeilToInt(dispHp)} / {stats.MaxHp}", hpTextCache); + + // 상태 줄 — 기절·중독·공격/방어 증감의 남은 턴. 같은 종류는 한 꼬리표로 모으고(가장 긴 남은 턴), + // 줄에 다 안 들어가면 앞쪽(기절·중독이 먼저)만 남기고 "외 N"으로 접는다(BattleStatusLine). + // 폭은 오른쪽 HP 숫자 자리(BattleStatusLine.HpNumberReserve)만 비우고 다 쓴다. + bool lowHp = ratio <= 0.2f; + int stunTurns = battleController == null ? 0 + : isPlayer ? battleController.PlayerStunTurns : battleController.EnemyStunTurns; InsectBattleController.EffectSnapshot[] effects = battleController != null ? battleController.GetActiveEffects() : null; - if (effects != null) - { - string effectStr = ""; - foreach (var eff in effects) - { - if (eff.targetIsPlayer != isPlayer) continue; - string tag; - switch (eff.kind) - { - case InsectBattleController.EffectKind.DefBuff: - tag = $"DEF+({eff.remainingTurns})"; break; - case InsectBattleController.EffectKind.Dot: - tag = $"독({eff.remainingTurns})"; break; - default: - tag = eff.value >= 0 ? $"ATK+({eff.remainingTurns})" : $"ATK-({eff.remainingTurns})"; break; - } - effectStr += (effectStr.Length > 0 ? " " : "") + tag; - } - if (effectStr.Length > 0) - { - GUI.Label(new Rect(barX, barY + barH + 4, barW, 22), effectStr, hpEffStyleCache); - } - } + BattleStatusLine.Turns turns = BattleStatusLine.Tally(stunTurns, effects, isPlayer); + Rect statusRect = new Rect(x + 18f, y + 96f, Mathf.Max(1f, w - 36f - BattleStatusLine.HpNumberReserve), 30f); + hpEffStyleCache.fontSize = 18; + hpEffStyleCache.normal.textColor = lowHp ? theme.accentAmber + : turns.Stun > 0 ? theme.skillStun : theme.textSecondary; + UIHelper.LabelFit(statusRect, StatusLineFor(isPlayer, lowHp, turns, statusRect.width), hpEffStyleCache); } - private void DrawIntro() + /// 상태 줄 문구 — 내용·폭이 그대로면 지난 문자열을 그대로 쓴다. + private string StatusLineFor(bool isPlayer, bool lowHp, BattleStatusLine.Turns turns, float width) { - if (playerStats == null || enemyStats == null) return; - - float cx = UIScale.VirtualScreenWidth / 2f; - float cy = UIScale.VirtualScreenHeight * 0.32f; - float sw = UIScale.VirtualScreenWidth; - - Color pc = UITheme.Instance.GetInsectRarityColor(playerStats.Data.rarity); - Color ec = UITheme.Instance.GetInsectRarityColor(enemyStats.Data.rarity); - bool isEpicOrHigher = (int)enemyStats.Data.rarity >= 3 || (int)playerStats.Data.rarity >= 3; - - // Epic/Legendary background effect - if (isEpicOrHigher) + StatusLineCache cache = isPlayer ? playerStatusLine : enemyStatusLine; + long key = turns.Key(lowHp); + if (cache.Text != null && cache.Key == key && Mathf.Approximately(cache.Width, width)) return cache.Text; + if (statusMeasure == null) { - float bgPulse = 0.03f + Mathf.Sin(introTimer * 4f) * 0.02f; - Color bgCol = (int)enemyStats.Data.rarity >= 4 - ? new Color(1f, 0.85f, 0.2f, bgPulse) - : new Color(0.6f, 0.3f, 0.9f, bgPulse); - GUI.color = bgCol; - GUI.DrawTexture(new Rect(0, 0, sw, UIScale.VirtualScreenHeight), Texture2D.whiteTexture); - - // Radial rays for legendary - if ((int)enemyStats.Data.rarity >= 4) + statusMeasureContent = new GUIContent(); + statusMeasure = s => { - float rayAlpha = 0.04f + Mathf.Sin(introTimer * 3f) * 0.02f; - GUI.color = new Color(1f, 0.9f, 0.4f, rayAlpha); - for (int i = 0; i < 8; i++) - { - float angle = i * 45f * Mathf.Deg2Rad + introTimer * 0.5f; - float rx = cx + Mathf.Cos(angle) * 300f; - float ry = cy + Mathf.Sin(angle) * 200f; - GUI.DrawTexture(new Rect(Mathf.Min(cx, rx), Mathf.Min(cy, ry), - Mathf.Abs(rx - cx) + 4, Mathf.Abs(ry - cy) + 4), Texture2D.whiteTexture); - } - } - } - - // Phase 1 (0 ~ 0.6s): "VS" text grows from center - if (introTimer < 0.6f) - { - float vsT = Mathf.Clamp01(introTimer / 0.5f); - float vsScale = 0.3f + vsT * 0.7f; - float vsAlpha = vsT; - int vsFontSize = (int)(72 * vsScale); - - // Dark backdrop - GUI.color = new Color(0, 0, 0, 0.6f * vsAlpha); - GUI.DrawTexture(new Rect(cx - 200, cy - 30, 400, 100), Texture2D.whiteTexture); - - introVsStyleCache.fontSize = vsFontSize; - introVsStyleCache.normal.textColor = new Color(1f, 0.9f, 0.3f, vsAlpha); - GUI.color = Color.white; - GUI.Label(new Rect(cx - 200, cy - 10, 400, 80), "VS", introVsStyleCache); - - // Player name slides in from left - float slideP = Mathf.Clamp01((introTimer - 0.1f) / 0.4f); - float pNameX = Mathf.Lerp(-400, cx - 350, slideP * slideP * (3f - 2f * slideP)); - introPNameStyleCache.normal.textColor = new Color(pc.r, pc.g, pc.b, slideP); - UIHelper.LabelFit(new Rect(pNameX, cy - 50, 300, 36), - $"{playerStats.Data.displayName} Lv.{playerStats.Level}", introPNameStyleCache); - - // Enemy name slides in from right - float slideE = Mathf.Clamp01((introTimer - 0.15f) / 0.4f); - float eNameX = Mathf.Lerp(sw + 100, cx + 50, slideE * slideE * (3f - 2f * slideE)); - introENameStyleCache.normal.textColor = new Color(ec.r, ec.g, ec.b, slideE); - UIHelper.LabelFit(new Rect(eNameX, cy + 56, 300, 36), - $"{enemyStats.Data.displayName} Lv.{enemyStats.Level}", introENameStyleCache); - - // Rarity color bars under names - if (slideP > 0.5f) - { - float barAlpha = (slideP - 0.5f) * 2f; - GUI.color = new Color(pc.r, pc.g, pc.b, 0.5f * barAlpha); - GUI.DrawTexture(new Rect(pNameX + 50, cy - 14, 250 * barAlpha, 3), Texture2D.whiteTexture); - } - if (slideE > 0.5f) - { - float barAlpha = (slideE - 0.5f) * 2f; - GUI.color = new Color(ec.r, ec.g, ec.b, 0.5f * barAlpha); - GUI.DrawTexture(new Rect(eNameX, cy + 92, 250 * barAlpha, 3), Texture2D.whiteTexture); - } - } - // Phase 2 (0.6 ~ 1.1s): "FIGHT!" flashes briefly - else if (introTimer < 1.1f) - { - float fightT = (introTimer - 0.6f) / 0.5f; - float fightScale = 0.6f + Mathf.Sin(fightT * Mathf.PI * 0.5f) * 0.4f; - float fightAlpha = fightT < 0.3f ? fightT / 0.3f : Mathf.Clamp01(1f - (fightT - 0.6f) / 0.4f); - int fightFontSize = (int)(64 * fightScale); - - // Flash backdrop - GUI.color = new Color(1f, 0.9f, 0.3f, 0.08f * fightAlpha); - GUI.DrawTexture(new Rect(0, cy - 40, sw, 120), Texture2D.whiteTexture); - - GUI.color = new Color(0, 0, 0, 0.7f * fightAlpha); - GUI.DrawTexture(new Rect(cx - 220, cy - 10, 440, 80), Texture2D.whiteTexture); - GUI.color = new Color(1f, 0.3f, 0.2f, 0.6f * fightAlpha); - GUI.DrawTexture(new Rect(cx - 220, cy - 10, 440, 4), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(cx - 220, cy + 66, 440, 4), Texture2D.whiteTexture); - - introFightStyleCache.fontSize = fightFontSize; - introFightStyleCache.normal.textColor = new Color(1f, 0.3f, 0.15f, fightAlpha); - GUI.color = Color.white; - GUI.Label(new Rect(cx - 200, cy, 400, 60), "FIGHT!", introFightStyleCache); + statusMeasureContent.text = s; + return hpEffStyleCache.CalcSize(statusMeasureContent).x; + }; } - // Phase 3 (1.1s+): Enemy encounter text - else - { - float showAlpha = Mathf.Clamp01((introTimer - 1.1f) / 0.3f); - - GUI.color = new Color(0, 0, 0, 0.6f * showAlpha); - GUI.DrawTexture(new Rect(cx - 320, cy - 10, 640, 60), Texture2D.whiteTexture); - GUI.color = new Color(ec.r, ec.g, ec.b, 0.5f * showAlpha); - GUI.DrawTexture(new Rect(cx - 320, cy - 10, 640, 3), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(cx - 320, cy + 47, 640, 3), Texture2D.whiteTexture); - GUI.color = Color.white; - - introEncounterStyleCache.normal.textColor = new Color(ec.r, ec.g, ec.b, showAlpha); - UIHelper.LabelFit(new Rect(cx - 320, cy, 640, 44), - $"야생 {enemyStats.Data.displayName} Lv.{enemyStats.Level} 등장!", introEncounterStyleCache); - } - - GUI.color = Color.white; + cache.Key = key; + cache.Width = width; + cache.Text = BattleStatusLine.Compose(lowHp, turns, statusMeasure, width); + return cache.Text; } + // 인트로 그리기(옛 아래쪽 「○○와 마주쳤다」 판)는 진입 구간의 큰 문구로 바뀌었다 — BattleScreenUI.Feel의 DrawEntry. + private void DrawSkillPanel() { if (playerStats == null || playerStats.Data == null) return; - - float sw = UIScale.VirtualScreenWidth; - bool mobile = UIScale.IsMobileLayout; - // 레이아웃 형태는 방향(IsPortrait) 기준 — IsMobileLayout은 '가로 모바일'에서도 true라 610px 세로 패널이 - // 높이 1080의 56%를 덮어 곤충을 가렸음. 세로=키큰 2×2 / 가로=납작 1행. 터치 라벨 문구는 mobile 유지. + UITheme theme = UITheme.Instance; bool portrait = UIScale.IsPortrait; - float panelH = UISafeLayout.ClampHeight(portrait ? 610f : 320f); - // 제스처바(하단 세이프 인셋) + 세로 마진 위로 버튼을 올림. 배경은 바닥까지 채워 빈틈 방지. - float panelY = UISafeLayout.ContentBottom - panelH; - float panelBgH = UIScale.VirtualScreenHeight - panelY; - - GUI.color = new Color(0.03f, 0.04f, 0.09f, 0.97f); - GUI.DrawTexture(new Rect(0, panelY, sw, panelBgH), Texture2D.whiteTexture); - - GUI.color = new Color(0.3f, 0.5f, 0.9f); - GUI.DrawTexture(new Rect(0, panelY, sw, 4), Texture2D.whiteTexture); - GUI.color = new Color(0.15f, 0.25f, 0.45f, 0.3f); - GUI.DrawTexture(new Rect(0, panelY + 4, sw, 2), Texture2D.whiteTexture); - - GUI.color = Color.white; - // 가로 여백은 세이프에어리어(노치·펀치홀) 안쪽으로 잡는다 — 화면 가장자리 기준으로 - // 30을 쓰면 인셋이 있는 기기에서 헤더와 스킬 카드가 노치 아래로 들어가 잘린다. - float contentX = UIScale.VirtualSafeLeft + 30f; - float contentW = Mathf.Max(1f, - sw - UIScale.VirtualSafeLeft - UIScale.VirtualSafeRight - 60f); - // 상자 42 — 28px 글자의 한글 세로 폭(약 38)에 위아래 여백까지. 36이던 자리라 받침이 잘렸다. - GUI.Label(new Rect(contentX, panelY + 10, contentW, 42), - mobile ? "사용할 기술을 선택하세요" : "스킬을 선택하세요 (숫자키 1~4 또는 클릭)", skillHeaderCache); + bool mobile = UIScale.IsMobileLayout; + Rect panel = DuelHudLayout.SkillDeck(HudFrame.Current); + UISurface.Card(panel, theme.surfaceBase, theme.surfaceBorder); + skillHeaderCache.normal.textColor = theme.textPrimary; + skillHeaderCache.fontSize = 26; + UIHelper.LabelFit(new Rect(panel.x + 20f, panel.y + 12f, panel.width * 0.58f, 38f), + "파트너의 행동을 선택하세요", skillHeaderCache); + skillCdInfoCache.normal.textColor = theme.textSecondary; + UIHelper.LabelFit(new Rect(panel.x + panel.width * 0.6f, panel.y + 16f, panel.width * 0.4f - 20f, 30f), + mobile ? "기술을 눌러 사용" : "1–4 기술 · F 기본 공격", skillCdInfoCache); InsectSkill[] skills = battleController != null ? battleController.GetPlayerSkills() : playerStats.Data.skills; - int[] cooldowns = battleController != null ? battleController.GetPlayerCooldowns() : new int[0]; + int[] cooldowns = battleController != null ? battleController.GetPlayerCooldowns() : null; int count = skills != null ? Mathf.Min(skills.Length, 4) : 0; skillBtnCount = count; - - float gap = 16f; - // 세로는 2열, 가로는 우측에 상세 패널(extraW) 자리를 비워 둔다. 둘 다 contentW에서 나눈다. - float extraW = portrait ? (contentW - gap) * 0.5f : 200f; - float availW = contentW - extraW - 30f; - float btnW = portrait - ? (contentW - gap) * 0.5f - : Mathf.Max(220, Mathf.Min(320, (availW - gap * Mathf.Max(count - 1, 0)) / Mathf.Max(count, 1))); - float btnH = 216f; - float baseBtnY = panelY + 58f; - float btnY = baseBtnY; - float startX = contentX; - - float pulse = 0.5f + Mathf.Sin(Time.time * 3f) * 0.15f; + for (int i = 0; i < skillBtnRects.Length; i++) + { skillBtnRects[i] = Rect.zero; skillBtnUsable[i] = false; } for (int i = 0; i < count; i++) { InsectSkill skill = skills[i]; if (skill == null) continue; - - float bx = portrait - ? startX + (i % 2) * (btnW + gap) - : startX + i * (btnW + gap); - if (portrait) btnY = baseBtnY + (i / 2) * (btnH + gap); - int cd = i < cooldowns.Length ? cooldowns[i] : 0; - bool canUse = cd <= 0; - - skillBtnRects[i] = new Rect(bx, btnY, btnW, btnH); + Rect card = DuelHudLayout.SkillCard(panel, portrait, i, count); + int cd = cooldowns != null && i < cooldowns.Length ? cooldowns[i] : 0; + bool canUse = battleController != null ? battleController.CanUseSkill(i) : cd <= 0; + skillBtnRects[i] = card; skillBtnUsable[i] = canUse; - - bool isHovered = false; - if (canUse) - { - Vector2 mouseGui = UIScale.VirtualMousePosition; - isHovered = skillBtnRects[i].Contains(mouseGui); - } - - Color bgCol; - if (isHovered) - bgCol = new Color(0.18f, 0.22f, 0.38f); - else if (canUse) - bgCol = new Color(0.08f, 0.10f, 0.20f); - else - bgCol = new Color(0.05f, 0.05f, 0.07f); - - GUI.color = bgCol; - GUI.DrawTexture(new Rect(bx, btnY, btnW, btnH), Texture2D.whiteTexture); - - Color borderCol = GetElementColor(skill.element); - if (canUse) - { - GUI.color = isHovered ? Color.white : borderCol; - GUI.DrawTexture(new Rect(bx, btnY, btnW, 5), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(bx, btnY + btnH - 3, btnW, 3), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(bx, btnY, 2, btnH), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(bx + btnW - 2, btnY, 2, btnH), Texture2D.whiteTexture); - } - else - { - GUI.color = new Color(0.25f, 0.25f, 0.25f); - GUI.DrawTexture(new Rect(bx, btnY, btnW, 2), Texture2D.whiteTexture); - } - - Color skillIconCol = GetElementColor(skill.element); - float iconSize = 36f; - float iconX = bx + 14; - float iconY = btnY + 14; - GUI.color = new Color(skillIconCol.r, skillIconCol.g, skillIconCol.b, 0.15f); - GUI.DrawTexture(new Rect(iconX - 4, iconY - 4, iconSize + 8, iconSize + 8), Texture2D.whiteTexture); - GUI.color = new Color(skillIconCol.r, skillIconCol.g, skillIconCol.b, canUse ? 0.9f : 0.3f); - GUI.DrawTexture(new Rect(iconX, iconY, iconSize, iconSize), Texture2D.whiteTexture); - - if (skill.effectType == SkillEffectType.Damage) - { - GUI.color = new Color(0, 0, 0, 0.6f); - GUI.DrawTexture(new Rect(iconX + 8, iconY + 4, 4, iconSize - 8), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(iconX + 16, iconY + 8, 4, iconSize - 16), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(iconX + 24, iconY + 2, 4, iconSize - 4), Texture2D.whiteTexture); - } - else - { - GUI.color = new Color(1, 1, 1, 0.3f); - GUI.DrawTexture(new Rect(iconX + 10, iconY + 10, iconSize - 20, iconSize - 20), Texture2D.whiteTexture); - } - - // 쿨다운 붉은 딤은 글자보다 **먼저** 깐다. 예전엔 라벨을 다 그린 뒤에 덮어서 - // 글자까지 같이 물들었고 명암비가 8.77→6.79(스킬명) · 6.08→4.94(타입·위력)로 내려갔다. - // 배경만 물들이면 대비가 그대로 살아 "쿨다운이라 흐린 것"과 "안 보이는 것"이 갈린다. - if (cd > 0) - { - GUI.color = new Color(1f, 0.3f, 0.2f, 0.15f); - GUI.DrawTexture(new Rect(bx, btnY, btnW, btnH), Texture2D.whiteTexture); - GUI.color = Color.white; - } - + bool hover = canUse && card.Contains(UIScale.VirtualMousePosition); + UISurface.Card(card, hover ? theme.surfaceRaised : theme.surfaceCard, + hover ? theme.accentMint : theme.surfaceBorder); + Color element = SkillUILayout.GetReadableAccent(GetElementColor(skill.element)); + UISurface.Chip(new Rect(card.x + 14f, card.y + 12f, Mathf.Min(132f, card.width - 70f), 30f), + InsectTypeChart.GetDisplayName(skill.element), theme.surfaceBase, canUse ? element : theme.textSecondary); if (!mobile) - { - // 쿨다운 번호가 (0.35,0.35,0.35) 회색이라 거의 검은 배지(0.08) 위에서 명암비 2.63이었다 - // — "회색이라 안 보인다"고 지목된 자리. 이미 있는 비활성 토큰을 쓴다(8.29). - skillKeyNumCache.normal.textColor = canUse - ? new Color(1f, 0.85f, 0.3f, pulse + 0.5f) - : SkillUILayout.DisabledTextColor; - GUI.color = canUse ? new Color(0.15f, 0.12f, 0.05f) : new Color(0.08f, 0.08f, 0.08f); - GUI.DrawTexture(new Rect(bx + btnW - 48, btnY + 10, 38, 38), Texture2D.whiteTexture); - GUI.color = Color.white; - GUI.Label(new Rect(bx + btnW - 48, btnY + 10, 38, 38), $"{i + 1}", skillKeyNumCache); - } - - // **글자를 그리기 전에 GUI.color를 반드시 흰색으로 되돌린다.** - // `GUI.color`는 스타일의 textColor에 곱해진다. 바로 위 아이콘 그리기가 색을 남겨 두는데, - // 그걸 흰색으로 되돌리는 유일한 자리가 `if (!mobile)` 안의 키 배지 블록이었다 — - // 즉 **모바일에서는 되돌지 않았다.** 그래서 기기에서는 데미지 스킬의 카드 글자가 - // 전부 (0,0,0,0.6)이 곱해져 **검은색 60% = 사실상 안 보이고**, 그 외 스킬은 - // (1,1,1,0.3)이 곱해져 30% 알파로 흐릿하게 떴다. 쿨다운 중인 카드만 위 딤 블록이 - // 흰색을 복구해 멀쩡히 보였다 — "쓸 수 있는 스킬만 안 보인다"의 정체다. - // 색 토큰을 아무리 밝혀도 여기서 곱해져 사라지므로, 이 한 줄이 실제 원인이다. - GUI.color = Color.white; - - // 카드(216) 세로 배분: 아이콘 14~50 / 이름 54~104 / 타입 110~138 / 정보 2행 144~206. - // 마지막 행 아래로 10px가 남아 카드 테두리(213)에 닿지 않는다. - Rect skillCardRect = new Rect(bx, btnY, btnW, btnH); - SkillCardDetailRows detailRows = SkillUILayout.GetDetailRows( - skillCardRect, 144f, 14f, SkillUILayout.MinimumDetailRowHeight, 6f, 10f); - skillNameStyleCache.fontSize = mobile ? 30 : 27; - skillNameStyleCache.normal.textColor = canUse ? Color.white : SkillUILayout.DisabledTextColor; - // 스킬명 길이는 데이터가 정하는데 상자는 고정이다 — 두 줄이 되면 아랫줄이 통째로 잘린다. - UIHelper.LabelFit(SkillUILayout.GetNameRect(skillCardRect, 54f, 14f, 50f), - skill.displayName, skillNameStyleCache); - - skillTypeLabelCache.normal.textColor = canUse - ? SkillUILayout.GetReadableAccent(skillIconCol) - : SkillUILayout.DisabledSecondaryTextColor; - string typeStr = $"{InsectTypeChart.GetDisplayName(skill.element)} 타입 · {SkillActionLabel(skill.effectType)}"; - // "다크 타입 · 공격 디버프"는 가로 카드(폭 220)에서 상자를 넘긴다. wordWrap이 꺼져 있어 - // 세로가 아니라 가로로 잘리는 쪽이고, LabelFit이 두 방향을 함께 본다. - UIHelper.LabelFit(new Rect(bx + 14, btnY + 110, btnW - 28, 28), typeStr, skillTypeLabelCache); - - // 상성 배지 — 지금 적에게 강/약(데미지 스킬만, 버프·디버프는 상성 무관). 스킬 선택 전 판단 제공. + UISurface.Chip(new Rect(card.xMax - 44f, card.y + 12f, 30f, 30f), + (i + 1).ToString(), theme.surfaceBase, theme.textSecondary); + skillNameStyleCache.fontSize = mobile ? 28 : 26; + skillNameStyleCache.normal.textColor = canUse ? theme.textPrimary : SkillUILayout.DisabledTextColor; + UIHelper.LabelFit(new Rect(card.x + 14f, card.y + 48f, card.width - 28f, 44f), skill.displayName, skillNameStyleCache); + skillTypeLabelCache.normal.textColor = theme.textSecondary; + bool self = DuelHudLayout.TargetsSelf(skill.effectType); + string target = self ? "자신" : enemyStats != null && enemyStats.Data != null ? enemyStats.Data.displayName : "상대"; + UIHelper.LabelFit(new Rect(card.x + 14f, card.y + 96f, card.width - 28f, 28f), + $"{SkillActionLabel(skill.effectType)} · 대상 {target}", skillTypeLabelCache); + skillInfoStyleCache.normal.textColor = theme.textPrimary; + UIHelper.LabelFit(new Rect(card.x + 14f, card.y + 128f, card.width - 28f, 28f), SkillPowerLabel(skill), skillInfoStyleCache); + string availability = cd > 0 ? $"재사용까지 {cd}턴" : !canUse ? "지금 사용할 수 없음" + : skill.cooldownTurns > 0 ? $"사용 후 {skill.cooldownTurns}턴 대기" : "매 턴 사용 가능"; + skillCdStyleCache.alignment = TextAnchor.MiddleLeft; + skillCdStyleCache.normal.textColor = cd > 0 ? theme.accentAmber : theme.textSecondary; + UIHelper.LabelFit(new Rect(card.x + 14f, card.y + 162f, card.width - 28f, 28f), availability, skillCdStyleCache); + // 상성 칩 — 「아주 잘 통해요 ⬆⬆」·「잘 통해요 ⬆」·「안 통해요 ⬇」·「거의 안 통해요 ⬇⬇」(화살표는 도형, BattleFeelDraw). + // 등급은 타격 순간 표시와 같은 ElementMatchup — 칩과 「잘 통했다!」가 갈리지 않는다. 보통이면 칩이 없다. if (skill.effectType == SkillEffectType.Damage && enemyStats != null && enemyStats.Data != null) - { - float eff = InsectTypeChart.GetEffectiveness(skill.element, - enemyStats.Data.primaryType, enemyStats.Data.secondaryType); - if (eff > 1.05f || eff < 0.95f) - { - bool strong = eff > 1.05f; - // 약점 붉은색은 호버 배경(0.18/0.22/0.38)에서 4.25로 떨어졌다 → 0.58/0.52로 올려 5.29. - skillEffLabelCache.normal.textColor = canUse - ? (strong ? new Color(0.4f, 1f, 0.5f) : new Color(1f, 0.58f, 0.52f)) - : SkillUILayout.DisabledSecondaryTextColor; - GUI.Label(detailRows.Effectiveness, - strong ? "효과적 ▲" : "비효과 ▼", skillEffLabelCache); - } - } - - skillInfoStyleCache.normal.textColor = canUse - ? new Color(0.96f, 0.91f, 0.72f) - : SkillUILayout.DisabledSecondaryTextColor; - UIHelper.LabelFit(detailRows.Power, SkillPowerLabel(skill), skillInfoStyleCache); - - if (cd > 0) - { - GUI.Label(detailRows.Cooldown, $"쿨다운 {cd}턴", skillCdStyleCache); - } - else if (skill.cooldownTurns > 0) - { - GUI.Label(detailRows.Cooldown, - $"쿨다운: {skill.cooldownTurns}턴", skillCdInfoCache); - } - - // 예전엔 여기서 흰색 6%를 카드 전체에 한 번 더 덮었다. 호버 신호는 이미 둘이다 — - // 배경이 1.52배 밝아지고 테두리가 통째로 흰색이 된다. 세 번째 덧칠은 눈에 거의 안 - // 띄면서 **글자와 배경을 같이 밝혀** 타입 줄 명암비를 4.67 → 4.14로 되돌렸다(AA 미만). - // 배경에만 얹어도 3.90으로 더 나쁘다(글자는 그대로인데 바닥만 밝아진다). 그래서 뺐다. + BattleFeelDraw.MatchupChip(new Rect(card.x + 14f, card.y + 192f, card.width - 28f, MatchupHud.ChipHeight), + ElementMatchup.Of(skill.element, enemyStats.Data), false, canUse); } - - btnY = portrait ? baseBtnY + 2f * (btnH + gap) + 2f : baseBtnY; - float extraX = portrait ? startX : startX + count * (btnW + gap) + 24; - float extraBtnH = 80f; - + basicAtkRect = DuelHudLayout.UtilityCard(panel, portrait, false); + DrawUtilityAction(basicAtkRect, mobile ? "기본 공격" : "[F] 기본 공격", "매 턴 사용 가능", false); + // 챔피언전(샌드박스)에서는 도망가지 않는다 — 그리지 않으면 누를 수도 없다(escapeRect 폭 0이 입력 판정을 끈다). + if (battleController != null && battleController.IsSandbox) { - basicAtkRect = new Rect(extraX, btnY, extraW, extraBtnH); - Vector2 mouseGui = UIScale.VirtualMousePosition; - bool hov = basicAtkRect.Contains(mouseGui); - - GUI.color = hov ? new Color(0.22f, 0.18f, 0.10f) : new Color(0.14f, 0.12f, 0.08f); - GUI.DrawTexture(basicAtkRect, Texture2D.whiteTexture); - GUI.color = new Color(0.9f, 0.6f, 0.2f); - GUI.DrawTexture(new Rect(extraX, btnY, extraW, 4), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(extraX, btnY, 2, extraBtnH), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(extraX + extraW - 2, btnY, 2, extraBtnH), Texture2D.whiteTexture); - GUI.color = Color.white; - - // 26px 글자에 상자 32, 20px 글자에 상자 26이던 자리 — 둘 다 한글 세로 폭(1.35배)에 못 미쳐 - // 받침이 잘렸다. 버튼 높이(80)는 그대로 두고 두 줄의 상자만 36/28로 키운다(6+36+4+28+6=80). - GUI.Label(new Rect(extraX, btnY + 6, extraW, 36), mobile ? "기본 공격" : "[F] 기본 공격", skillFKeyCache); - GUI.Label(new Rect(extraX, btnY + 46, extraW, 28), "쿨다운 없음", skillFInfoCache); + escapeRect = new Rect(0, 0, 0, 0); + return; } + escapeRect = DuelHudLayout.UtilityCard(panel, portrait, true); + DrawUtilityAction(escapeRect, mobile ? "도망가기" : "[ESC] 도망가기", "확률에 따라 성공", true); + } - { - float escapeX = portrait ? extraX + extraW + gap : extraX; - float escY = portrait ? btnY : btnY + extraBtnH + 12; - escapeRect = new Rect(escapeX, escY, extraW, extraBtnH); - Vector2 mouseGui2 = UIScale.VirtualMousePosition; - bool hov2 = escapeRect.Contains(mouseGui2); - - GUI.color = hov2 ? new Color(0.22f, 0.10f, 0.10f) : new Color(0.12f, 0.08f, 0.08f); - GUI.DrawTexture(escapeRect, Texture2D.whiteTexture); - GUI.color = new Color(0.7f, 0.3f, 0.3f); - GUI.DrawTexture(new Rect(escapeX, escY, extraW, 4), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(escapeX, escY, 2, extraBtnH), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(escapeX + extraW - 2, escY, 2, extraBtnH), Texture2D.whiteTexture); - GUI.color = Color.white; - - GUI.Label(new Rect(escapeX, escY + 6, extraW, 36), mobile ? "도망가기" : "[ESC] 도망가기", skillEscStyleCache); - GUI.Label(new Rect(escapeX, escY + 46, extraW, 28), "확률적 성공", skillEscInfoCache); - } + private void DrawUtilityAction(Rect card, string title, string subtitle, bool escape) + { + UITheme theme = UITheme.Instance; + UISurface.Card(card, card.Contains(UIScale.VirtualMousePosition) ? theme.surfaceRaised : theme.surfaceCard, theme.surfaceBorder); + GUIStyle titleStyle = escape ? skillEscStyleCache : skillFKeyCache; + titleStyle.fontSize = 23; + titleStyle.normal.textColor = escape ? theme.textSecondary : theme.accentMint; + GUIStyle subtitleStyle = escape ? skillEscInfoCache : skillFInfoCache; + subtitleStyle.wordWrap = false; + subtitleStyle.normal.textColor = theme.textSecondary; + UIHelper.LabelFit(new Rect(card.x + 12f, card.y + 14f, card.width - 24f, 34f), title, titleStyle); + UIHelper.LabelFit(new Rect(card.x + 12f, card.y + 52f, card.width - 24f, 28f), subtitle, subtitleStyle); } - // 스킬 효과 타입 라벨(신규 타입 포함) — 스킬 패널 표시용. private static string SkillActionLabel(SkillEffectType t) { switch (t) @@ -2314,59 +2220,46 @@ private void DrawRotatedLine(float x1, float y1, float x2, float y2, float thick private void DrawAttackAnimation(bool isPlayerAttack) { + if (isPlayerAttack && battleController != null && !battleController.PlayerActedThisRound) return; // 3D 모드: 2D 이펙트 대신 3D 공격 (BattleArenaController의 코루틴이 처리) // 여기서는 데미지 숫자 + 스킬 이름만 OnGUI로 표시 if (arena != null && arena.IsActive) { - float t3d = Mathf.Clamp01(phaseTimer / 0.8f); + float t3d = 0.3f + Mathf.Clamp01(impactTimer / Mathf.Max(0.1f, attackDuration * 0.6f)) * 0.7f; int dmg3d = isPlayerAttack ? lastDamageToEnemy : lastDamageToPlayer; - if (dmg3d > 0 && t3d >= 0.3f) + // 맞은 쪽 위에 — 내 공격이면 상대, 반격이면 내 곤충. + if (dmg3d > 0 && impactRevealed && TryDamageAnchor(!isPlayerAttack, out Vector2 anchor)) { - float sw3d = UIScale.VirtualScreenWidth; - float sh3d = UIScale.VirtualScreenHeight; - float dmgY = sh3d * 0.25f - (t3d - 0.3f) * 60f; + float rise = (t3d - 0.3f) * 60f; // 등장 팝(초반 확대 후 정착) + 후반 페이드아웃 — 데미지 숫자 저즈(2D 폴백 패턴 이식). float dmgP = Mathf.Clamp01((t3d - 0.3f) / 0.7f); float dmgAlpha = Mathf.Clamp01(1.15f - dmgP); float popScale = 1f + Mathf.Sin(Mathf.Clamp01(dmgP * 2f) * Mathf.PI) * 0.3f; - // 크리티컬이면 더 큰 폰트 + 펄스 + CRITICAL! 라벨 - bool isCrit = isPlayerAttack && lastWasCritical; - float critPulse = isCrit ? 1f + Mathf.Abs(Mathf.Sin(Time.unscaledTime * 20f)) * 0.15f : 1f; + // 치명타면 더 큰 숫자 + 펄스 + 「치명타!」 — 내 치명타는 금빛, 맞은 치명타는 붉은빛(화면 섬광과 같은 규칙). + bool isCrit = isPlayerAttack ? lastWasCritical : lastEnemyWasCritical; + float critPulse = isCrit && !BattlePresentation.ReducedMotion + ? 1f + Mathf.Abs(Mathf.Sin(Time.unscaledTime * 20f)) * 0.15f : 1f; int dmgFontSize = Mathf.RoundToInt((isCrit ? 64f * critPulse : 38f) * popScale); - - dmgStyle3dCache.fontSize = dmgFontSize; - Color dmgTextCol = isCrit - ? new Color(1f, 0.5f, 0.1f) + Color dmgTextCol = isCrit ? CritNumberColor(isPlayerAttack) : (isPlayerAttack ? new Color(1f, 0.9f, 0.3f) : new Color(1f, 0.3f, 0.3f)); - dmgTextCol.a = dmgAlpha; - dmgStyle3dCache.normal.textColor = dmgTextCol; - // 크리티컬 그림자 효과 - if (isCrit) - { - GUI.color = new Color(0f, 0f, 0f, 0.8f); - GUI.Label(new Rect(3, dmgY + 3, sw3d, 80), $"-{dmg3d}", dmgStyle3dCache); - GUI.color = Color.white; - } - GUI.Label(new Rect(0, dmgY, sw3d, 80), $"-{dmg3d}", dmgStyle3dCache); - - if (isCrit) - { - GUI.Label(new Rect(0, dmgY - 36, sw3d, 36), "★ CRITICAL! ★", critLblCache); - } + float numberY = anchor.y - rise; + DrawDamageNumber(anchor.x, numberY, $"-{dmg3d}", dmgTextCol, dmgAlpha, dmgFontSize, isCrit); + if (isCrit) DrawCritCaption(anchor.x, numberY, dmgFontSize, isPlayerAttack, dmgAlpha); + // 상성 — 숫자(치명타면 「치명타!」) 바로 위에 화살표 + 「아주 잘 통했다!」(BattleScreenUI.Feel). 위쪽 효과 문구로는 안 띄운다. + DrawImpactMatchup(isPlayerAttack, anchor.x, numberY, dmgFontSize, isCrit, dmgAlpha); - if (!string.IsNullOrEmpty(lastSkillName)) - { - float skillY = isCrit ? dmgY + 72 : dmgY + 45; - GUI.Label(new Rect(0, skillY, sw3d, 30), lastSkillName, skillStyle3dCache); - } + if (isPlayerAttack && !string.IsNullOrEmpty(lastSkillName)) + UIHelper.LabelFit(new Rect(anchor.x - 150f, numberY + dmgFontSize * 0.5f + 4f, 300f, 30f), lastSkillName, skillStyle3dCache); } return; } - float t = Mathf.Clamp01(phaseTimer / 0.8f); + float progress = Mathf.Clamp01(phaseTimer / attackDuration); + // Legacy 2D effects strike at t=.3; the shared presentation strikes at 40%. + float t = progress < 0.4f ? progress * 0.75f : 0.3f + (progress - 0.4f) * (0.7f / 0.6f); float arenaTop = UIScale.VirtualScreenHeight * 0.08f; float arenaH = UIScale.VirtualScreenHeight * 0.52f; @@ -2435,35 +2328,41 @@ private void DrawAttackAnimation(bool isPlayerAttack) } // Phase 2: Element-specific impact effect - if (t >= 0.3f && t < 0.7f && dmg > 0) + if (impactRevealed && t < 0.8f && dmg > 0) { - if (AudioManager.Instance != null && phaseTimer >= 0.24f && phaseTimer < 0.26f) - AudioManager.Instance.PlaySFX(SfxType.Hit); float impactT = (t - 0.3f) / 0.4f; DrawElementImpact(tgtX, tgtY, impactT, element, elemCol); } // Phase 3: Damage numbers and skill name - if (dmg > 0 && t >= 0.3f) + if (dmg > 0 && impactRevealed) { float dmgT = (t - 0.3f) / 0.7f; + bool isCrit = isPlayerAttack ? lastWasCritical : lastEnemyWasCritical; + float dmgAlpha = Mathf.Clamp01(1f - dmgT * 0.8f); + float dmgScale = 1f + Mathf.Sin(dmgT * Mathf.PI * 0.5f) * 0.35f; + int dmgFontSize = Mathf.RoundToInt((isCrit ? 60f : 44f) * dmgScale); + float numberY = tgtY + DamageAnchorLift2D - dmgT * 55f; if (!string.IsNullOrEmpty(lastSkillName) && isPlayerAttack) { float skillAlpha = Mathf.Clamp01(1f - dmgT * 1.5f); skillNameAtkStyleCache.normal.textColor = new Color(elemCol.r, elemCol.g, elemCol.b, skillAlpha); GUI.color = Color.white; - GUI.Label(new Rect(tgtX - 160, tgtY - 130 - dmgT * 25f, 320, 40), lastSkillName, skillNameAtkStyleCache); + // 치명타면 숫자 위에 「치명타!」(40px 줄)가, 상성이 있으면 그 위에 상성 표시가 서므로 기술 이름은 그 위로 비켜선다. + float skillTop = tgtY - 130f - dmgT * 25f; + if (isCrit) skillTop = Mathf.Min(skillTop, CritCaptionCenterY(numberY, dmgFontSize) - 20f - 42f); + if (ImpactMatchup(true) != Matchup.Neutral) + skillTop = Mathf.Min(skillTop, MatchupHud.CaptionCenterY(numberY, dmgFontSize, isCrit) + - MatchupHud.CaptionHeight * 0.5f - MatchupHud.CaptionGap - 40f); + GUI.Label(new Rect(tgtX - 160, skillTop, 320, 40), lastSkillName, skillNameAtkStyleCache); } - float dmgAlpha = Mathf.Clamp01(1f - dmgT * 0.8f); - float dmgScale = 1f + Mathf.Sin(dmgT * Mathf.PI * 0.5f) * 0.35f; - int dmgFontSize = (int)(44 * dmgScale); - dmgStyleAtkCache.fontSize = dmgFontSize; - Color dmgCol = isPlayerAttack ? new Color(1, 1, 0.3f, dmgAlpha) : new Color(1, 0.3f, 0.3f, dmgAlpha); - dmgStyleAtkCache.normal.textColor = dmgCol; - GUI.color = Color.white; - GUI.Label(new Rect(tgtX - 70, tgtY - 90 - dmgT * 55f, 140, 55), $"-{dmg}", dmgStyleAtkCache); + Color dmgCol = isCrit ? CritNumberColor(isPlayerAttack) + : isPlayerAttack ? new Color(1f, 1f, 0.3f) : new Color(1f, 0.3f, 0.3f); + DrawDamageNumber(tgtX, numberY, $"-{dmg}", dmgCol, dmgAlpha, dmgFontSize, isCrit); + if (isCrit) DrawCritCaption(tgtX, numberY, dmgFontSize, isPlayerAttack, dmgAlpha); + DrawImpactMatchup(isPlayerAttack, tgtX, numberY, dmgFontSize, isCrit, dmgAlpha); // Effectiveness text based on element if (isPlayerAttack && dmgT < 0.5f) @@ -3137,234 +3036,70 @@ private string GetElementName(InsectElement element) private void DrawActionText() { - if (string.IsNullOrEmpty(actionText)) return; - - float alpha = Mathf.Clamp01(actionTimer / 0.3f); - float cx = UIScale.VirtualScreenWidth / 2f; - float cy = UIScale.VirtualScreenHeight * 0.62f; - - GUI.color = new Color(0, 0, 0, 0.8f * alpha); - GUI.DrawTexture(new Rect(cx - 320, cy - 8, 640, 56), Texture2D.whiteTexture); - GUI.color = new Color(0.3f, 0.5f, 0.9f, 0.5f * alpha); - GUI.DrawTexture(new Rect(cx - 320, cy - 8, 640, 3), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(cx - 320, cy + 45, 640, 3), Texture2D.whiteTexture); - - actionTextStyleCache.normal.textColor = new Color(1, 1, 1, alpha); - GUI.color = Color.white; - GUI.Label(new Rect(cx - 320, cy - 2, 640, 48), actionText, actionTextStyleCache); + // 인트로 동안은 진입 문구가 화면을 쓴다 — 지난 전투에서 남은 행동 문구가 그 밑에 비치지 않게. + if (string.IsNullOrEmpty(actionText) || phase == Phase.PlayerTurn || phase == Phase.Intro || resultShown) return; + Rect panel = DuelHudLayout.ActionBar(HudFrame.Current); + UISurface.Card(panel, UITheme.Instance.surfaceBase, UITheme.Instance.surfaceBorder); + actionTextStyleCache.fontSize = 24; + actionTextStyleCache.normal.textColor = UITheme.Instance.textPrimary; + UIHelper.LabelFit(new Rect(panel.x + 16f, panel.y + 8f, panel.width - 32f, 38f), actionText, actionTextStyleCache); } internal static string GetCaptureResultMessage(bool attempted, bool succeeded) { - if (!attempted) - return string.Empty; - - return succeeded - ? "곤충을 포획했습니다!" - : "곤충을 잡지 못했습니다."; + return !attempted ? string.Empty : succeeded ? "곤충을 포획했습니다!" : "곤충을 잡지 못했습니다."; } private void DrawResult() { - float alpha = Mathf.Clamp01(resultTimer / 0.5f); - float cx = UIScale.VirtualScreenWidth / 2f; - float cy = UIScale.VirtualScreenHeight * 0.3f; - float sw = UIScale.VirtualScreenWidth; - float sh = UIScale.VirtualScreenHeight; - - if (lastWon) + UITheme theme = UITheme.Instance; + bool escaped = battleController != null && battleController.DidEscape; + bool sandbox = battleController != null && battleController.IsSandbox; + // 이겼으면 승리 화면 — 위쪽 큰 「승리!」, 아래 보상 판, 가운데는 아레나 승리 연출(BattleScreenUI.Feel). + if (lastWon && !escaped) { - // Victory: golden background glow - float bgGlow = 0.06f * alpha + Mathf.Sin(resultTimer * 2f) * 0.02f; - GUI.color = new Color(1f, 0.85f, 0.2f, bgGlow); - GUI.DrawTexture(new Rect(0, 0, sw, sh), Texture2D.whiteTexture); - - // Star/radial line burst effect - if (resultTimer < 3f) - { - float starT = Mathf.Clamp01(resultTimer / 2f); - int starCount = 12; - for (int i = 0; i < starCount; i++) - { - float angle = i * (360f / starCount) * Mathf.Deg2Rad + resultTimer * 0.8f; - float lineStart = 50f + starT * 30f; - float lineEnd = 100f + starT * 180f; - float lineAlpha = (1f - starT * 0.5f) * 0.4f * alpha; - float lineW = 2.5f * (1f - starT * 0.4f); - - float x1 = cx + Mathf.Cos(angle) * lineStart; - float y1 = cy + 30 + Mathf.Sin(angle) * lineStart; - float x2 = cx + Mathf.Cos(angle) * lineEnd; - float y2 = cy + 30 + Mathf.Sin(angle) * lineEnd; - - GUI.color = new Color(1f, 0.9f, 0.3f, lineAlpha); - GUI.DrawTexture(new Rect( - Mathf.Min(x1, x2), Mathf.Min(y1, y2), - Mathf.Max(Mathf.Abs(x2 - x1), lineW), Mathf.Max(Mathf.Abs(y2 - y1), lineW)), - Texture2D.whiteTexture); - } - - // Sparkle particles - for (int i = 0; i < 6; i++) - { - float sparkAngle = i * 60f * Mathf.Deg2Rad + resultTimer * 1.5f; - float sparkDist = 80f + Mathf.Sin(resultTimer * 3f + i) * 40f; - float sparkX = cx + Mathf.Cos(sparkAngle) * sparkDist; - float sparkY = cy + 30 + Mathf.Sin(sparkAngle) * sparkDist; - float sparkAlpha = 0.5f + Mathf.Sin(resultTimer * 5f + i * 1.2f) * 0.3f; - float sparkSize = 4f + Mathf.Sin(resultTimer * 4f + i) * 2f; - GUI.color = new Color(1f, 1f, 0.6f, sparkAlpha * alpha); - GUI.DrawTexture(new Rect(sparkX - sparkSize, sparkY - sparkSize, sparkSize * 2, sparkSize * 2), Texture2D.whiteTexture); - } - } - - // Main panel - GUI.color = new Color(0.05f, 0.04f, 0.02f, 0.8f * alpha); - GUI.DrawTexture(new Rect(cx - 320, cy - 20, 640, 200), Texture2D.whiteTexture); - // Gold borders - Color gold = new Color(1f, 0.85f, 0.2f); - GUI.color = new Color(gold.r, gold.g, gold.b, 0.8f * alpha); - GUI.DrawTexture(new Rect(cx - 320, cy - 20, 640, 4), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(cx - 320, cy + 176, 640, 4), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(cx - 320, cy - 20, 3, 200), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(cx + 317, cy - 20, 3, 200), Texture2D.whiteTexture); - - // "VICTORY!" text with scale animation - float victoryScale = 1f + Mathf.Sin(resultTimer * 2.5f) * 0.05f; - int victoryFontSize = (int)(52 * victoryScale); - victoryStyleCache.fontSize = victoryFontSize; - victoryStyleCache.normal.textColor = new Color(gold.r, gold.g, gold.b, alpha); - GUI.color = Color.white; - GUI.Label(new Rect(cx - 280, cy, 560, 60), "VICTORY!", victoryStyleCache); - - // Reward info with slide-in animation - if (battleController != null) - { - int candy = battleController.GetLastCandyReward(); - int exp = battleController.GetLastExpReward(); - - float rewardAlpha = Mathf.Clamp01((resultTimer - 0.5f) / 0.5f); - float rewardSlide = Mathf.Lerp(30f, 0f, Mathf.Clamp01((resultTimer - 0.5f) / 0.4f)); - - bool captureAttempted = battleController.GetLastCaptureAttempted(); - bool captureSucceeded = battleController.GetLastCaptureSucceeded(); - string captureMessage = GetCaptureResultMessage(captureAttempted, captureSucceeded); - if (!string.IsNullOrEmpty(captureMessage)) - { - rewardStyleCache.normal.textColor = captureSucceeded - ? new Color(0.9f, 0.9f, 0.9f, rewardAlpha) - : new Color(1f, 0.55f, 0.35f, rewardAlpha); - GUI.Label( - new Rect(cx - 260, cy + 68 + rewardSlide, 520, 30), - captureMessage, - rewardStyleCache); - } - - float valAlpha = Mathf.Clamp01((resultTimer - 0.8f) / 0.4f); - float valSlide = Mathf.Lerp(20f, 0f, Mathf.Clamp01((resultTimer - 0.8f) / 0.3f)); - - rewardValStyleCache.normal.textColor = new Color(1f, 0.5f, 0.8f, valAlpha); - GUI.Label(new Rect(cx - 200, cy + 110 + valSlide, 190, 30), $"+{candy} Candy", rewardValStyleCache); - rewardValStyleCache.normal.textColor = new Color(0.4f, 0.85f, 1f, valAlpha); - GUI.Label(new Rect(cx + 10, cy + 110 + valSlide, 190, 30), $"+{exp} XP", rewardValStyleCache); - - // Subtle candy/exp glow - if (valAlpha > 0.5f) - { - GUI.color = new Color(1f, 0.5f, 0.8f, 0.05f * valAlpha); - GUI.DrawTexture(new Rect(cx - 200, cy + 108 + valSlide, 190, 34), Texture2D.whiteTexture); - GUI.color = new Color(0.4f, 0.85f, 1f, 0.05f * valAlpha); - GUI.DrawTexture(new Rect(cx + 10, cy + 108 + valSlide, 190, 34), Texture2D.whiteTexture); - } - } + DrawVictory(sandbox); + return; } + // 패배·도주는 예전 창 그대로 — 안내만 「눌러서 계속」(이제 눌러야 닫힌다 — BattleResultRules). + UISurface.Dim(0.3f); + Rect panel = UISafeLayout.CenteredPanel(680f, 320f); + UISurface.Card(panel, theme.surfaceBase, theme.surfaceBorder); + Color accent = escaped ? theme.accentMint : theme.accentCoral; + UISurface.Flat(new Rect(panel.x + 16f, panel.y + 3f, panel.width - 32f, 4f), accent); + victoryStyleCache.fontSize = 42; + victoryStyleCache.normal.textColor = accent; + UIHelper.LabelFit(new Rect(panel.x + 24f, panel.y + 26f, panel.width - 48f, 62f), + escaped ? "무사히 이탈" : "다음 탐험을 준비해요", victoryStyleCache); + rewardStyleCache.normal.textColor = theme.textSecondary; + if (escaped) + UIHelper.LabelFit(new Rect(panel.x + 32f, panel.y + 116f, panel.width - 64f, 64f), + "전투를 벗어났습니다.\n탐험을 계속할 수 있습니다.", rewardStyleCache); else - { - // Defeat: darkening background - float darkOverlay = Mathf.Clamp01(resultTimer / 1.5f) * 0.5f; - GUI.color = new Color(0, 0, 0, darkOverlay); - GUI.DrawTexture(new Rect(0, 0, sw, sh), Texture2D.whiteTexture); - - // Red vignette on edges - float vignetteAlpha = 0.15f * alpha; - GUI.color = new Color(0.5f, 0, 0, vignetteAlpha); - GUI.DrawTexture(new Rect(0, 0, sw * 0.1f, sh), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(sw * 0.9f, 0, sw * 0.1f, sh), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(0, 0, sw, sh * 0.08f), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(0, sh * 0.92f, sw, sh * 0.08f), Texture2D.whiteTexture); - - // Main panel - GUI.color = new Color(0.08f, 0.02f, 0.02f, 0.85f * alpha); - GUI.DrawTexture(new Rect(cx - 320, cy - 20, 640, 180), Texture2D.whiteTexture); - // Red borders - Color red = new Color(0.9f, 0.25f, 0.2f); - GUI.color = new Color(red.r, red.g, red.b, 0.7f * alpha); - GUI.DrawTexture(new Rect(cx - 320, cy - 20, 640, 4), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(cx - 320, cy + 156, 640, 4), Texture2D.whiteTexture); - - // "DEFEAT..." text - defeatStyleCache.normal.textColor = new Color(red.r, red.g, red.b, alpha); - GUI.color = Color.white; - UIHelper.LabelFit(new Rect(cx - 280, cy + 4, 560, 60), "DEFEAT...", defeatStyleCache); - - // Retry guidance with fade-in - float guideAlpha = Mathf.Clamp01((resultTimer - 1f) / 0.5f); - defeatGuideStyleCache.normal.textColor = new Color(0.7f, 0.5f, 0.5f, guideAlpha); - GUI.Label(new Rect(cx - 260, cy + 80, 520, 28), "곤충을 강화하고 다시 도전하세요!", defeatGuideStyleCache); - - // Subtle pulsing hint - float hintPulse = 0.4f + Mathf.Sin(resultTimer * 3f) * 0.2f; - defeatHintStyleCache.normal.textColor = new Color(0.5f, 0.4f, 0.4f, hintPulse * guideAlpha); - GUI.Label(new Rect(cx - 200, cy + 116, 400, 24), "훈련소에서 레벨업 가능", defeatHintStyleCache); - } - - GUI.color = Color.white; + UIHelper.LabelFit(new Rect(panel.x + 32f, panel.y + 116f, panel.width - 64f, 64f), + "보유 곤충에서 팀과 기술을 확인하고\n훈련으로 파트너를 성장시켜 보세요", rewardStyleCache); + BattleFeelDraw.ContinueHintLine(new Rect(panel.x + 24f, panel.y + 246f, panel.width - 48f, 40f), ResultCanClose, + ResultShownSeconds - BattleResultRules.InputLockSeconds, BattlePresentation.ReducedMotion); + DrawDuelResultQuote(panel); } private void DrawPhaseIndicator(string text) { - float panelH = 70f; - // 텍스트는 제스처바 + 세로 마진 위로, 배경 띠는 화면 바닥까지. - float panelY = UISafeLayout.ContentBottom - panelH; - GUI.color = new Color(0.04f, 0.05f, 0.10f, 0.92f); - GUI.DrawTexture(new Rect(0, panelY, UIScale.VirtualScreenWidth, UIScale.VirtualScreenHeight - panelY), - Texture2D.whiteTexture); - GUI.color = new Color(0.5f, 0.7f, 1f, 0.4f); - GUI.DrawTexture(new Rect(0, panelY, UIScale.VirtualScreenWidth, 3), Texture2D.whiteTexture); - GUI.color = Color.white; - - float pulse = 0.6f + Mathf.Sin(Time.time * 5f) * 0.2f; - phaseIndicatorStyleCache.normal.textColor = new Color(1f, 0.9f, 0.5f, pulse); - GUI.Label(new Rect(0, panelY + 14, UIScale.VirtualScreenWidth, 40), text, phaseIndicatorStyleCache); + Rect panel = DuelHudLayout.PhaseBanner(HudFrame.Current); + UISurface.Card(panel, UITheme.Instance.surfaceBase, UITheme.Instance.surfaceBorder); + phaseIndicatorStyleCache.normal.textColor = phase == Phase.EnemyAttack ? UITheme.Instance.accentCoral : UITheme.Instance.accentMint; + UIHelper.LabelFit(new Rect(panel.x + 18f, panel.y + 10f, panel.width - 36f, 42f), text, phaseIndicatorStyleCache); } - // 인터-턴 중앙 배너 — "당신의 턴"(청록)/"상대의 턴"(적색). 페이드 인·아웃 + 슬라이드. introVsStyle 재사용. private void DrawTurnAnnounce() { - float sw = UIScale.VirtualScreenWidth; - float cy = UIScale.VirtualScreenHeight * 0.30f; - float t = 1f - Mathf.Clamp01(announceTimer / TurnAnnounceDuration); // 0→1 진행 - float alpha = Mathf.Clamp01(Mathf.Min(t * 4f, (1f - t) * 4f)) + 0.15f; // 양끝 페이드 - alpha = Mathf.Clamp01(alpha); - float slide = (1f - Mathf.Clamp01(t * 3f)) * 40f; // 진입 시 살짝 아래→제자리 - - Color accent = announceIsPlayer ? new Color(0.4f, 0.9f, 1f) : new Color(1f, 0.4f, 0.35f); - - // 반투명 밴드 배경 - GUI.color = new Color(0.03f, 0.04f, 0.09f, 0.72f * alpha); - GUI.DrawTexture(new Rect(0, cy - 12f, sw, 92f), Texture2D.whiteTexture); - GUI.color = new Color(accent.r, accent.g, accent.b, 0.85f * alpha); - GUI.DrawTexture(new Rect(0, cy - 12f, sw, 4f), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(0, cy + 76f, sw, 4f), Texture2D.whiteTexture); - - introVsStyleCache.fontSize = 58; - introVsStyleCache.normal.textColor = new Color(accent.r, accent.g, accent.b, alpha); - GUI.color = Color.white; - GUI.Label(new Rect(0, cy + slide, sw, 80f), announceText, introVsStyleCache); + Rect panel = DuelHudLayout.TurnBanner(HudFrame.Current); + UISurface.Card(panel, UITheme.Instance.surfaceBase, UITheme.Instance.surfaceBorder); + introVsStyleCache.fontSize = 34; + introVsStyleCache.normal.textColor = announceIsPlayer ? UITheme.Instance.accentMint : UITheme.Instance.accentCoral; + UIHelper.LabelFit(new Rect(panel.x + 20f, panel.y + 20f, panel.width - 40f, 56f), announceText, introVsStyleCache); } - // FindFirstObjectByType 캐싱 — 배틀 시작/종료마다 재조회 회귀 차단. - // ForceHidePanel은 InsectBattleUIController가 영구 객체라 1회 조회 후 재사용 안전. private InsectBattleUIController cachedCanvasBattleUI; private RegionManager cachedRegionMgr; @@ -3380,140 +3115,73 @@ private void DisableCanvasBattleUI() private void DrawSwapSelect() { - float panelW = UIScale.VirtualScreenWidth; - float panelH = UISafeLayout.ClampHeight(340f); - float panelY = UISafeLayout.ContentBottom - panelH; - - GUI.color = new Color(0.04f, 0.03f, 0.08f, 0.97f); - GUI.DrawTexture(new Rect(0, panelY, panelW, UIScale.VirtualScreenHeight - panelY), Texture2D.whiteTexture); - GUI.color = new Color(0.9f, 0.35f, 0.3f); - GUI.DrawTexture(new Rect(0, panelY, panelW, 4), Texture2D.whiteTexture); - - float pulse = 0.7f + Mathf.Sin(Time.time * 3f) * 0.3f; - // SwapHeader 색 static readonly + alpha만 갱신 (DrawCombo의 ComboCol 패턴과 동일) - Color hdr = SwapHeaderBase; - hdr.a = pulse; - swapHeaderCache.normal.textColor = hdr; - GUI.color = Color.white; - string faintedName = playerStats != null && playerStats.Data != null ? playerStats.Data.displayName : "곤충"; - GUI.Label(new Rect(0, panelY + 10, panelW, 38), - UIScale.IsMobileLayout - ? $"{faintedName}이(가) 쓰러졌다! 다음 곤충을 선택하세요" - : $"{faintedName}이(가) 쓰러졌다! 다음 곤충을 선택하세요 (숫자키 1~5 또는 클릭)", - swapHeaderCache); - + UITheme theme = UITheme.Instance; + bool portrait = UIScale.IsPortrait; bool mobile = UIScale.IsMobileLayout; - float btnW = mobile ? (panelW - 80f) / BattleTeamManager.MaxSlots : 240f; - float btnH = 240f; - float btnY = panelY + 56; - float totalW = BattleTeamManager.MaxSlots * (btnW + 14) - 14; - float startX = (panelW - totalW) / 2f; - + Rect panel = UISafeLayout.BottomPanel(Mathf.Min(UISafeLayout.ContentWidth, 1480f), portrait ? 610f : 340f); + UISurface.Card(panel, theme.surfaceBase, theme.surfaceBorder); + swapHeaderCache.normal.textColor = theme.textPrimary; + swapHeaderCache.alignment = TextAnchor.MiddleLeft; + string faintedName = playerStats != null && playerStats.Data != null ? playerStats.Data.displayName : "파트너"; + UIHelper.LabelFit(new Rect(panel.x + 20f, panel.y + 14f, panel.width - 40f, 40f), + $"{faintedName} 전투 불능 · 다음 파트너를 선택하세요", swapHeaderCache); + + int columns = portrait ? 3 : BattleTeamManager.MaxSlots; + const float gap = 12f; + float cardW = (panel.width - 40f - (columns - 1) * gap) / columns; + const float cardH = 240f; for (int i = 0; i < swapBtnRects.Length; i++) { - swapBtnRects[i] = new Rect(0, 0, 0, 0); + swapBtnRects[i] = Rect.zero; swapBtnAvail[i] = false; } - if (teamManager == null || collection == null) return; for (int i = 0; i < BattleTeamManager.MaxSlots; i++) { - float bx = startX + i * (btnW + 14); + Rect card = new Rect(panel.x + 20f + (i % columns) * (cardW + gap), + panel.y + 66f + (i / columns) * (cardH + gap), cardW, cardH); string slotId = teamManager.GetSlot(i); bool isEmpty = string.IsNullOrEmpty(slotId); PlayerInsectData pid = isEmpty ? null : collection.GetByInstanceId(slotId); - // 전투 내 기절(faintedInsectIds) 또는 지속 기절(currentHp==0) — 둘 다 교체 불가로 표시. bool isFainted = !isEmpty && (faintedInsectIds.Contains(slotId) || (pid != null && pid.IsFainted)); bool isCurrent = !isEmpty && slotId == currentInsectId; InsectData data = pid != null ? collection.GetInsectData(pid.insectId) : null; bool available = !isEmpty && !isFainted && !isCurrent && data != null; - - swapBtnRects[i] = new Rect(bx, btnY, btnW, btnH); + swapBtnRects[i] = card; swapBtnAvail[i] = available; - - Vector2 mouseGui = UIScale.VirtualMousePosition; - bool hovered = available && swapBtnRects[i].Contains(mouseGui); - - Color bgCol; - if (isFainted || isCurrent) - bgCol = new Color(0.08f, 0.05f, 0.05f, 0.9f); - else if (hovered) - bgCol = new Color(0.15f, 0.20f, 0.35f); - else if (available) - bgCol = new Color(0.10f, 0.12f, 0.20f); - else - bgCol = new Color(0.06f, 0.06f, 0.08f); - - GUI.color = bgCol; - GUI.DrawTexture(new Rect(bx, btnY, btnW, btnH), Texture2D.whiteTexture); - - if (available) - { - Color borderCol2 = hovered ? Color.white : new Color(0.3f, 0.6f, 0.9f); - GUI.color = borderCol2; - GUI.DrawTexture(new Rect(bx, btnY, btnW, 4), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(bx, btnY + btnH - 3, btnW, 3), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(bx, btnY, 2, btnH), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(bx + btnW - 2, btnY, 2, btnH), Texture2D.whiteTexture); - } - - if (!mobile) - { - swapKeyStyleCache.normal.textColor = available ? new Color(1f, 0.85f, 0.3f) : new Color(0.3f, 0.3f, 0.3f); - GUI.color = available ? new Color(0.15f, 0.12f, 0.05f) : new Color(0.06f, 0.06f, 0.06f); - GUI.DrawTexture(new Rect(bx + 8, btnY + 8, 36, 36), Texture2D.whiteTexture); - GUI.color = Color.white; - GUI.Label(new Rect(bx + 8, btnY + 8, 36, 36), $"{i + 1}", swapKeyStyleCache); - } - + bool hovered = available && card.Contains(UIScale.VirtualMousePosition); + UISurface.Card(card, hovered ? theme.surfaceRaised : theme.surfaceCard, + available ? theme.accentMint : theme.surfaceBorder); + UISurface.Chip(new Rect(card.x + 12f, card.y + 10f, 40f, 28f), + mobile ? (i + 1).ToString() : $"[{i + 1}]", theme.surfaceBase, theme.textSecondary); + + string status = available ? "교체 가능" : isFainted ? "쓰러짐" + : isCurrent ? "출전 중" : isEmpty ? "비어 있음" : "정보 없음"; + Color statusColor = available ? theme.accentMint : isFainted ? theme.accentCoral : theme.textSecondary; + UISurface.Chip(new Rect(card.x + 12f, card.yMax - 36f, card.width - 24f, 28f), + status, theme.surfaceBase, statusColor); if (isEmpty || data == null) { - GUI.Label(new Rect(bx, btnY + 90, btnW, 30), "빈 슬롯", swapEmptyStyleCache); + swapEmptyStyleCache.normal.textColor = theme.textSecondary; + UIHelper.LabelFit(new Rect(card.x + 12f, card.y + 88f, card.width - 24f, 40f), + isEmpty ? "빈 슬롯" : "곤충 정보 없음", swapEmptyStyleCache); continue; } - int level = pid != null ? pid.level : 1; - int cp = PlayerInsectCombatPower.Calculate(data, pid); - - Color rarityCol = UITheme.Instance.GetInsectRarityColor(data.rarity); - Color insectCol = UITheme.Instance.GetInsectColor(data.insectId, data.rarity); - - GUI.color = new Color(insectCol.r, insectCol.g, insectCol.b, 0.15f); - GUI.DrawTexture(new Rect(bx + btnW / 2f - 35, btnY + 48, 70, 70), Texture2D.whiteTexture); - GUI.color = insectCol; - GUI.DrawTexture(new Rect(bx + btnW / 2f - 26, btnY + 57, 52, 52), Texture2D.whiteTexture); - - swapNameStyleCache.normal.textColor = available ? rarityCol : new Color(rarityCol.r * 0.4f, rarityCol.g * 0.4f, rarityCol.b * 0.4f); + // Use the same model thumbnail / species silhouette as collection and team screens. + InsectVisual.Draw(new Rect(card.center.x - 42f, card.y + 38f, 84f, 76f), data, pid.isShiny, 1f); GUI.color = Color.white; - UIHelper.LabelFit(new Rect(bx, btnY + 126, btnW, 28), data.displayName, swapNameStyleCache); - - swapInfoStyleCache.normal.textColor = available ? new Color(0.6f, 0.6f, 0.65f) : new Color(0.3f, 0.3f, 0.3f); - GUI.Label(new Rect(bx, btnY + 156, btnW, 24), $"Lv.{level} | CP {cp}", swapInfoStyleCache); - - if (pid != null) - { - int hp = pid.GetTotalHp(data.baseHp); - int atk = pid.GetTotalAtk(data.baseAtk); - swapStatStyleCache.normal.textColor = available ? new Color(0.5f, 0.5f, 0.55f) : new Color(0.25f, 0.25f, 0.25f); - GUI.Label(new Rect(bx, btnY + 182, btnW, 22), $"HP {hp} ATK {atk}", swapStatStyleCache); - } - - if (isFainted) - { - UIHelper.LabelFit(new Rect(bx, btnY + 208, btnW, 28), "쓰러짐", swapFaintStyleCache); - } - else if (isCurrent) - { - GUI.Label(new Rect(bx, btnY + 208, btnW, 28), "현재 (쓰러짐)", swapCurStyleCache); - } - - if (hovered) - { - GUI.color = new Color(1f, 1f, 1f, 0.06f); - GUI.DrawTexture(new Rect(bx, btnY, btnW, btnH), Texture2D.whiteTexture); - GUI.color = Color.white; - } + swapNameStyleCache.normal.textColor = theme.textPrimary; + UIHelper.LabelFit(new Rect(card.x + 12f, card.y + 116f, card.width - 24f, 32f), data.displayName, swapNameStyleCache); + swapInfoStyleCache.normal.textColor = theme.textSecondary; + UIHelper.LabelFit(new Rect(card.x + 12f, card.y + 150f, card.width - 24f, 26f), + $"Lv. {pid.level} · CP {PlayerInsectCombatPower.Calculate(data, pid)}", swapInfoStyleCache); + int maxHp = pid.GetTotalHp(data.baseHp); + int hp = isFainted ? 0 : pid.GetEffectiveHp(maxHp); + swapStatStyleCache.normal.textColor = theme.textSecondary; + UIHelper.LabelFit(new Rect(card.x + 12f, card.y + 178f, card.width - 24f, 26f), + $"HP {hp} / {maxHp}", swapStatStyleCache); } } @@ -3523,12 +3191,17 @@ private void EndBattle() // (영구 동결 회귀 방지). try { + // 1대1 화면이 끈 아레나의 전용기 머리 위 이름을 되돌린다(BattleScreenUI.Feel — 정적 표지). + BattleArenaController.ShowSignatureCallout = true; if (arena != null) arena.CleanupArena(); - Time.timeScale = 1f; hitstopUntil = 0f; critUntil = 0f; comboCount = 0; comboDisplayTimer = 0f; + comboResetPending = false; + lastWasCritical = false; + lastEnemyWasCritical = false; + ResetResultClose(); if (AudioManager.Instance != null) { @@ -3536,6 +3209,7 @@ private void EndBattle() AudioManager.Instance.ClearBattleIntensity(); } phase = Phase.None; + hasArenaSnapshot = false; playerStats = null; enemyStats = null; skillBtnCount = 0; @@ -3552,15 +3226,15 @@ private void EndBattle() wantMouseClick = false; faintedInsectIds.Clear(); currentInsectId = null; + pendingEnemySwitch = false; + incomingEnemyStats = null; } finally { - if (Time.timeScale != 1f) Time.timeScale = 1f; - hitstopUntil = 0f; critUntil = 0f; if (cameraFollower != null) cameraFollower.ExitBattleMode(); // **모달이 떠 있으면 풀지 않는다.** 프리즈는 bool 하나라 주인이 여럿이면 // 마지막에 쓴 쪽이 이긴다. 전투 승리로 스토리 비트가 뜨면 대화 모달이 먼저 - // 프리즈를 걸어 두는데, 결과 화면은 그와 무관하게 4초 뒤 스스로 닫히며 여기서 + // 프리즈를 걸어 두는데, 결과 화면은 그와 무관하게 닫히며(탭 — 꿈 챔피언전은 4초 뒤 저절로) 여기서 // 프리즈를 푼다 — **대사를 읽는 동안 캐릭터가 걸어다녔다**(BattleWin 비트 12개 // 전부 해당). 모달 쪽이 닫힐 때 자기가 푼다(NpcDialogueUI.CloseModal). // 그쪽이 끝내 안 풀어도 PlayerMovement의 AutoUnfreezeTime(20s)이 받아낸다. @@ -3575,99 +3249,210 @@ private void EndBattle() } } + // 치명타 화면 섬광 — 시작 값은 RevealImpact가 넣는 screenFlashTimer(0.18초)와 같다. + private const float CritFlashSeconds = 0.18f; + // 3D 아레나 위에서는 아주 옅게만 — 모델 번쩍임·충격파가 이미 타격을 보여 주고, 화면이 하얗게 뜨면 곤충이 안 보인다. + private const float ArenaFlashPeak = 0.1f; + + /// + /// 치명타 순간 화면 전체에 깔리는 틴트(내 치명타 금빛, 맞은 치명타 붉은빛 — 색은 RevealImpact가 고른다). + /// 예전엔 아레나가 켜져 있으면 그리지 않아, 실제 게임(늘 아레나)에선 치명타 섬광이 한 번도 안 보였다. + /// 밝은 섬광 줄이기()면 그리지 않는다. + /// private void DrawScreenFlash() { - if (screenFlashTimer <= 0f) return; - float alpha = Mathf.Clamp01(screenFlashTimer / 0.3f) * 0.4f; + if (BattlePresentation.ReducedFlashes || screenFlashTimer <= 0f) return; + bool arenaActive = arena != null && arena.IsActive; + float alpha = arenaActive + ? Mathf.Clamp01(screenFlashTimer / CritFlashSeconds) * ArenaFlashPeak + : Mathf.Clamp01(screenFlashTimer / 0.3f) * 0.4f; + Color prev = GUI.color; GUI.color = new Color(screenFlashColor.r, screenFlashColor.g, screenFlashColor.b, alpha); GUI.DrawTexture(new Rect(0, 0, UIScale.VirtualScreenWidth, UIScale.VirtualScreenHeight), Texture2D.whiteTexture); - GUI.color = Color.white; + GUI.color = prev; } + /// + /// 연속 공격 배지 "연속 N!" — 내 HP 카드 아래(낮·밤·날씨 칩이 있으면 그 아래, ). + /// 예전엔 화면 오른쪽 위 고정 좌표(y 100)에 박혀 상대 HP 카드(가로·세로 모두)와 배속 버튼(세로)을 덮었고, + /// 그래서 아레나 화면에서는 꺼 두어 실제 게임에서 거의 보이지 않았다. + /// 뜰 때 잠깐 컸다가() 마지막 0.5초에 사라진다. + /// private void DrawComboCounter() { - if (comboCount < 2 || comboDisplayTimer <= 0f) return; + if (comboCount < 2 || comboDisplayTimer <= 0f || playerStats == null || resultShown) return; + UITheme theme = UITheme.Instance; + Rect playerCard = DuelHudLayout.HpCard(UISafeLayout.Content, true); + bool environmentChip = battleController != null && battleController.PlayerEnvironment.HasEffect; + Rect badge = BattleHudStack.ComboBadge(playerCard, environmentChip); float alpha = Mathf.Clamp01(comboDisplayTimer / 0.5f); - float scale = 1f + Mathf.Max(0f, (2.5f - comboDisplayTimer) * 2f) * 0.0f; - // 콤보 시작 직후 큰 펄스 - float justAppeared = Mathf.Clamp01(2.5f - comboDisplayTimer) / 0.2f; - float pulse = justAppeared < 1f ? Mathf.Lerp(1.4f, 1f, justAppeared) : 1f; - - float sw = UIScale.VirtualScreenWidth; - float boxW = 180f; - float boxH = 70f; - float bx = sw - boxW - 30f; - float by = 100f; - - // 배경 - GUI.color = new Color(0f, 0f, 0f, 0.55f * alpha); - GUI.DrawTexture(new Rect(bx, by, boxW, boxH), Texture2D.whiteTexture); - - // 강조 라인 — 3개 정적 색 (combo 등급별). 매 프레임 new Color 회귀 차단을 위해 static. - Color comboCol = comboCount >= 5 ? ComboColHot : - comboCount >= 3 ? ComboColWarm : - ComboColCool; - // Color는 struct(value)지만 alpha만 다르면 매번 new 대신 .a 갱신 - Color tinted = comboCol; - tinted.a = alpha; - GUI.color = tinted; - GUI.DrawTexture(new Rect(bx, by, boxW, 3), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(bx, by + boxH - 3, boxW, 3), Texture2D.whiteTexture); - - // 숫자 (펄스) — GUIStyle 캐싱 + float pulse = BattleHudStack.ComboPulse(ComboDisplaySeconds - comboDisplayTimer, BattlePresentation.ReducedMotion); + if (comboLabelCount != comboCount) + { + comboLabelCount = comboCount; + comboLabel = $"연속 {comboCount}!"; + } if (cachedComboNumStyle == null) cachedComboNumStyle = new GUIStyle(GUI.skin.label) - { fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - cachedComboNumStyle.fontSize = Mathf.RoundToInt(40f * pulse); - cachedComboNumStyle.normal.textColor = tinted; + { fontSize = 32, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, wordWrap = false }; + + // 세 번째부터는 금빛으로 한 단 더 눈에 띄게. + Color accent = comboCount >= 3 ? theme.accentAmber : theme.accentMint; + Matrix4x4 prevMatrix = GUI.matrix; + Color prevColor = GUI.color; + if (pulse > 1.0001f) + { + // 배지 가운데를 축으로 키운다 — RotateAroundPivot류는 UIScale≠1이면 피벗이 어긋나 행렬을 직접 곱한다. + Vector2 pivot = badge.center; + GUI.matrix = prevMatrix + * Matrix4x4.TRS(new Vector3(pivot.x, pivot.y, 0f), Quaternion.identity, new Vector3(pulse, pulse, 1f)) + * Matrix4x4.TRS(new Vector3(-pivot.x, -pivot.y, 0f), Quaternion.identity, Vector3.one); + } + // 페이드는 GUI.color 알파로 — 카드 색에 알파를 섞으면 둥근 텍스처가 알파마다 새로 구워진다. + GUI.color = new Color(1f, 1f, 1f, prevColor.a * alpha); + UISurface.Card(badge, theme.surfaceBase, accent); + cachedComboNumStyle.fontSize = 32; + cachedComboNumStyle.normal.textColor = accent; + UIHelper.LabelFit(new Rect(badge.x + 12f, badge.y + 6f, badge.width - 24f, badge.height - 12f), + comboLabel, cachedComboNumStyle); + GUI.color = prevColor; + GUI.matrix = prevMatrix; + } + + // ── 피해 숫자 — 타격(내 공격·반격·치명타)과 라운드 끝 독이 같은 모양을 쓴다 ── + + private const string CritLabel = "치명타!"; + /// 아레나: 피해 숫자의 가운데가 모델 화면 중심보다 이만큼 위(옛 80px 상자 — 중심 − 70의 가운데). + private const float DamageAnchorLift3D = -30f; + /// 2D 폴백: 피해 숫자의 가운데가 곤충 그림 중심보다 이만큼 위(옛 55px 상자 — 중심 − 90의 가운데). + private const float DamageAnchorLift2D = -62.5f; + + /// + /// 한쪽 곤충 위 피해 숫자 자리(떠오르기 전, 숫자의 가운데). 아레나면 모델의 화면 위치, 2D 폴백이면 그림 위치. + /// 카메라 뒤(또는 카메라·모델이 없음)면 false — 예전엔 그대로 (0,0) 근처에 그렸다. + /// + private bool TryDamageAnchor(bool playerSide, out Vector2 anchor) + { + float sw = UIScale.VirtualScreenWidth; + float sh = UIScale.VirtualScreenHeight; + if (arena != null && arena.IsActive) + { + Vector3 screen = arena.GetCombatantScreenPosition(playerSide); + if (screen.z <= 0f) + { + anchor = Vector2.zero; + return false; + } + anchor = new Vector2(screen.x / Mathf.Max(1, Screen.width) * sw, + (1f - screen.y / Mathf.Max(1, Screen.height)) * sh + DamageAnchorLift3D); + return true; + } + float arenaTop = sh * 0.08f; + float arenaH = sh * 0.52f; + anchor = new Vector2(sw * (playerSide ? 0.22f : 0.72f), + arenaTop + arenaH * (playerSide ? 0.72f : 0.38f) + DamageAnchorLift2D); + return true; + } + + /// 치명타 숫자 색 — 내 치명타 금빛, 맞은 치명타 붉은빛(화면 섬광과 같은 규칙). + private static Color CritNumberColor(bool mine) => mine ? UITheme.Instance.accentAmber : UITheme.Instance.accentCoral; + + /// + /// 피해 숫자 한 개. 상자는 가장 큰 글자(치명타 64pt × 펄스 × 팝 ≈ 96pt)의 한글 줄높이까지 잡아 위아래가 잘리지 않게 한다. + /// 글자 크기가 매 프레임 바뀌므로 LabelFit이 아니라 GUI.Label이다(LabelFit 캐시가 프레임마다 새 항목으로 찬다). + /// + private void DrawDamageNumber(float cx, float cy, string text, Color color, float alpha, int fontSize, bool shadow) + { + Rect r = new Rect(cx - 150f, cy - 65f, 300f, 130f); + dmgStyle3dCache.fontSize = Mathf.Max(1, fontSize); + Color prev = GUI.color; GUI.color = Color.white; - GUI.Label(new Rect(bx, by - 4, boxW, boxH * 0.7f), $"{comboCount}", cachedComboNumStyle); - - // "COMBO" 라벨 — GUIStyle 캐싱 - if (cachedComboLblStyle == null) - cachedComboLblStyle = new GUIStyle(GUI.skin.label) - { fontSize = 16, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - Color lblCol = ComboLblBase; - lblCol.a = alpha * 0.9f; - cachedComboLblStyle.normal.textColor = lblCol; - UIHelper.LabelFit(new Rect(bx, by + boxH - 22, boxW, 18), "COMBO", cachedComboLblStyle); - } - - // 콤보 표시 정적 색 — 매 OnGUI new Color 회귀 차단 - private static readonly Color ComboColHot = new Color(1f, 0.4f, 0.2f); - private static readonly Color ComboColWarm = new Color(1f, 0.85f, 0.3f); - private static readonly Color ComboColCool = new Color(0.9f, 0.95f, 1f); - private static readonly Color ComboLblBase = new Color(1f, 1f, 1f); - // DrawSwapSelect 헤더 펄스 색 — alpha만 동적, RGB는 static - private static readonly Color SwapHeaderBase = new Color(1f, 0.4f, 0.3f); + if (shadow) + { + dmgStyle3dCache.normal.textColor = new Color(0f, 0f, 0f, 0.8f * alpha); + GUI.Label(new Rect(r.x + 3f, r.y + 3f, r.width, r.height), text, dmgStyle3dCache); + } + color.a = alpha; + dmgStyle3dCache.normal.textColor = color; + GUI.Label(r, text, dmgStyle3dCache); + GUI.color = prev; + } + + /// 「치명타!」 줄(40px)의 가운데 — 숫자의 윗변(글자 크기의 절반 남짓) 바로 위. + private static float CritCaptionCenterY(float numberCenterY, int numberFontSize) => + numberCenterY - numberFontSize * 0.55f - 22f; + + /// 숫자 바로 위 「치명타!」 — 숫자 크기(펄스·팝)를 따라 위로 비켜선다. + private void DrawCritCaption(float cx, float numberCenterY, int numberFontSize, bool mine, float alpha) + { + float cy = CritCaptionCenterY(numberCenterY, numberFontSize); + Rect r = new Rect(cx - 150f, cy - 20f, 300f, 40f); + Color prev = GUI.color; + GUI.color = Color.white; + critLblCache.fontSize = 28; + critLblCache.normal.textColor = new Color(0f, 0f, 0f, 0.75f * alpha); + UIHelper.LabelFit(new Rect(r.x + 2f, r.y + 2f, r.width, r.height), CritLabel, critLblCache); + Color c = CritNumberColor(mine); + c.a = alpha; + critLblCache.normal.textColor = c; + UIHelper.LabelFit(r, CritLabel, critLblCache); + GUI.color = prev; + } + + /// + /// 라운드 끝 독 숫자를 연다 — 가 HP를 마저 드러내는 순간에 한 번. + /// 값은 컨트롤러가 그 라운드 독으로 실제로 깎은 HP(0에서 잘린 몫 제외)다. + /// + private void BeginPoisonPopups() + { + if (battleController == null) return; + int mine = battleController.PlayerPoisonDamageThisRound; + int theirs = battleController.EnemyPoisonDamageThisRound; + playerPoisonPopup = mine > 0 ? $"-{mine} 독" : null; + enemyPoisonPopup = theirs > 0 ? $"-{theirs} 독" : null; + poisonPopupStartedAt = mine > 0 || theirs > 0 ? Time.unscaledTime : -1f; + } - private Color GetSkillColor(SkillEffectType type) + /// 독 숫자 — 중독된 곤충 위에 보라색 "-N 독"이 떠오르며 사라진다. 배속이면 그만큼 빨리 지나간다. + private void DrawPoisonPopups() { - switch (type) + if (poisonPopupStartedAt < 0f) return; + float p = (Time.unscaledTime - poisonPopupStartedAt) * Mathf.Max(1f, BattlePresentation.Speed) / PoisonPopupSeconds; + if (p < 0f || p >= 1f) { - case SkillEffectType.Damage: return new Color(0.9f, 0.35f, 0.3f); - case SkillEffectType.BuffAttack: return new Color(0.3f, 0.8f, 0.4f); - case SkillEffectType.DebuffAttack: return new Color(0.7f, 0.4f, 0.9f); - // 아래 4종은 d3d90cf가 SkillEffectType에 추가했는데 여기만 빠져 전부 회색으로 떨어졌다. - // 같은 커밋이 라벨 switch 2개(SkillTypeLabel/SkillPowerLabel)는 7종 전부 채웠고, - // 레이드(`RaidBattleUI.Draw.GetSkillColor`)도 7종을 갖고 있다 — 1v1 색만 3종에 멈춰 있었다. - // 값은 그 레이드 팔레트와 맞춘다(같은 스킬이 화면마다 다른 색이면 그게 더 나쁘다). - case SkillEffectType.Heal: return new Color(0.35f, 0.92f, 0.62f); - case SkillEffectType.DefenseBuff: return new Color(0.35f, 0.68f, 1f); - case SkillEffectType.Stun: return new Color(1f, 0.86f, 0.25f); - case SkillEffectType.PoisonDot: return new Color(0.68f, 0.35f, 0.88f); - default: return Color.gray; + poisonPopupStartedAt = -1f; + return; } + if (playerPoisonPopup != null) DrawPoisonPopup(true, playerPoisonPopup, p); + if (enemyPoisonPopup != null) DrawPoisonPopup(false, enemyPoisonPopup, p); } + private void DrawPoisonPopup(bool playerSide, string text, float p) + { + if (!TryDamageAnchor(playerSide, out Vector2 anchor)) return; + float pop = BattlePresentation.ReducedMotion ? 1f : 1f + Mathf.Sin(Mathf.Clamp01(p * 3f) * Mathf.PI) * 0.25f; + float alpha = Mathf.Clamp01((1f - p) / 0.35f); + DrawDamageNumber(anchor.x, anchor.y - p * 50f, text, UITheme.Instance.skillPoison, alpha, + Mathf.RoundToInt(40f * pop), true); + } + + // DrawSwapSelect 헤더 펄스 색 — alpha만 동적, RGB는 static + private static readonly Color SwapHeaderBase = new Color(1f, 0.4f, 0.3f); + + // 팔레트는 UITheme이 단일 출처다. 여기 사본이 있을 땐 d3d90cf가 추가한 4종(회복·기절·독·방어)이 + // 이 화면에서만 빠져 회색으로 떨어졌다 — 레이드·훈련소와 같은 표를 읽는다. + private Color GetSkillColor(SkillEffectType type) => UITheme.Instance.GetSkillColor(type); + public void AutoWire(InsectBattleController bc, CameraFollower cam, PlayerMovement pm = null) { if (battleController != null && battleController != bc) { + battleController.DeferPresentation = false; battleController.BattleUpdated -= OnBattleUpdated; battleController.BattleEnded -= OnBattleEnded; battleController.PlayerFainted -= OnPlayerFainted; + battleController.EnemySwitched -= OnEnemySwitched; } if (battleController == null || battleController != bc) @@ -3694,9 +3479,12 @@ private void SubscribeBattleController() battleController.BattleUpdated -= OnBattleUpdated; battleController.BattleEnded -= OnBattleEnded; battleController.PlayerFainted -= OnPlayerFainted; + battleController.EnemySwitched -= OnEnemySwitched; + battleController.DeferPresentation = true; battleController.BattleUpdated += OnBattleUpdated; battleController.BattleEnded += OnBattleEnded; battleController.PlayerFainted += OnPlayerFainted; + battleController.EnemySwitched += OnEnemySwitched; } public void AutoWire(BattleTeamManager team, PlayerInsectCollection col, TrainingManager training) @@ -3739,6 +3527,59 @@ internal SkillCardDetailRows(Rect power, Rect effectiveness, Rect cooldown) } } + internal static class DuelHudLayout + { + internal const float HpCardHeight = 140f; + internal static Rect HpCard(Rect safe, bool player) + { + float width = Mathf.Min(460f, (safe.width - 32f) * 0.5f); + return new Rect(player ? safe.x : safe.xMax - width, safe.y + 68f, width, HpCardHeight); + } + + // ── 화면 한 장(HudFrame) 위의 고정 자리 — 그리는 쪽은 HudFrame.Current, 겹침 테스트는 HudFrame.ForScreen을 넘긴다 ── + + /// 안전 영역 콘텐츠(UISafeLayout.Content와 같은 값) — HP 카드의 기준. + internal static Rect Safe(HudFrame f) => new Rect(f.ContentLeft, f.ContentTop, f.ContentWidth, f.ContentHeight); + + /// 아레나 화면 맨 위 "탐험 전투 · N번째 행동". + internal static Rect Heading(HudFrame f) => f.TopPanel(340f, 50f); + + /// 오른쪽 위 배속 버튼. + internal static Rect SpeedControl(HudFrame f) => f.TopPanel(132f, 56f, UISafeLayout.HAlign.Right); + + /// 내 차례의 행동 패널(기술 카드 + 기본 공격·도망). + internal static Rect SkillDeck(HudFrame f) => f.BottomPanel(f.ContentWidth, f.Portrait ? 610f : 300f); + + /// "당신의 턴 / 상대의 턴" 배너. + internal static Rect TurnBanner(HudFrame f) => f.BottomPanel(620f, 98f); + + /// 공격 연출 중 "내 곤충의 공격! / 적 곤충의 반격!". + internal static Rect PhaseBanner(HudFrame f) => f.BottomPanel(720f, 62f); + + /// 행동 문구 띠("○○의 △△!") — 아래 132px 자리의 윗줄. + internal static Rect ActionBar(HudFrame f) + { + Rect footer = f.BottomPanel(720f, 132f); + return new Rect(footer.x, footer.y, footer.width, 54f); + } + internal static Rect SkillCard(Rect panel, bool portrait, int index, int count) + { + float contentW = panel.width - 40f; + float width = portrait ? (contentW - 12f) * 0.5f + : (contentW - 200f - 16f - 12f * Mathf.Max(0, count - 1)) / Mathf.Max(1, count); + return new Rect(panel.x + 20f + (portrait ? index % 2 : index) * (width + 12f), + panel.y + 62f + (portrait ? index / 2 : 0) * 228f, width, 224f); + } + internal static Rect UtilityCard(Rect panel, bool portrait, bool escape) + { + float width = portrait ? (panel.width - 52f) * 0.5f : 200f; + return new Rect(portrait ? panel.x + 20f + (escape ? width + 12f : 0f) : panel.xMax - width - 20f, + portrait ? panel.yMax - 94f : panel.y + 62f + (escape ? 116f : 0f), width, portrait ? 80f : 108f); + } + internal static bool TargetsSelf(SkillEffectType type) => type == SkillEffectType.Heal + || type == SkillEffectType.BuffAttack || type == SkillEffectType.DefenseBuff; + } + internal static class SkillUILayout { internal const float MinimumSkillNameHeight = 44f; @@ -3857,4 +3698,126 @@ internal static Color GetReadableAccent(Color accent) return readable; } } + + /// + /// HP 카드 상태 줄 — 순수 계산. "체력 위험 기절 1턴 중독 3턴 공격↓ 2턴"처럼 남은 턴을 꼬리표로 잇는다. + /// + /// 같은 종류(공격↑ 둘 등)는 꼬리표 하나로 모아 가장 긴 남은 턴을 적는다 — 효과 목록은 겹쳐 건 만큼 항목이 따로 있어 + /// 예전엔 "공격↑ 2턴 공격↑ 3턴"이 나란히 떴다. 순서는 기절 → 중독 → 하락 → 상승(지금 못 움직이는 것·HP가 새는 것이 먼저). + /// 줄에 다 안 들어가면 앞에서부터 들어가는 만큼만 남기고 "외 N"으로 접는다 — 글자를 줄이는 쪽(LabelFit)은 + /// 18pt 하한에서 멈춰 그 뒤로는 꼬리가 그냥 잘렸다. + /// + internal static class BattleStatusLine + { + /// 상태 줄 오른쪽에 비워 두는 HP 숫자("999 / 999", 20pt 굵게 ≈ 110px) 자리. + internal const float HpNumberReserve = 132f; + internal const string Gap = " "; + internal const string LowHpLabel = "체력 위험"; + internal const string IdleLabel = "HP"; + + /// 종류별 남은 턴(0이면 없음). + internal struct Turns + { + public int Stun; + public int Poison; + public int AttackDown; + public int DefenseDown; + public int AttackUp; + public int DefenseUp; + + public bool Any => Stun > 0 || Poison > 0 || AttackDown > 0 || DefenseDown > 0 || AttackUp > 0 || DefenseUp > 0; + + /// 내용이 같으면 같은 값 — 그리는 쪽이 이걸로 문구를 다시 잇고 잴지 고른다(각 칸 0~127턴). + public long Key(bool lowHp) + { + long k = lowHp ? 1L : 0L; + k = (k << 7) | (long)Mathf.Clamp(Stun, 0, 127); + k = (k << 7) | (long)Mathf.Clamp(Poison, 0, 127); + k = (k << 7) | (long)Mathf.Clamp(AttackDown, 0, 127); + k = (k << 7) | (long)Mathf.Clamp(DefenseDown, 0, 127); + k = (k << 7) | (long)Mathf.Clamp(AttackUp, 0, 127); + k = (k << 7) | (long)Mathf.Clamp(DefenseUp, 0, 127); + return k; + } + } + + /// 한쪽 곤충의 상태를 종류별로 모은다. 기절은 효과 목록이 아니라 컨트롤러의 따로 센 값이다. + internal static Turns Tally(int stunTurns, InsectBattleController.EffectSnapshot[] effects, bool forPlayer) + { + Turns t = new Turns { Stun = Mathf.Max(0, stunTurns) }; + if (effects == null) return t; + for (int i = 0; i < effects.Length; i++) + { + InsectBattleController.EffectSnapshot e = effects[i]; + if (e.targetIsPlayer != forPlayer || e.remainingTurns <= 0) continue; + switch (e.kind) + { + case InsectBattleController.EffectKind.Dot: + t.Poison = Mathf.Max(t.Poison, e.remainingTurns); + break; + case InsectBattleController.EffectKind.DefBuff: + if (e.value >= 0f) t.DefenseUp = Mathf.Max(t.DefenseUp, e.remainingTurns); + else t.DefenseDown = Mathf.Max(t.DefenseDown, e.remainingTurns); + break; + default: + if (e.value >= 0f) t.AttackUp = Mathf.Max(t.AttackUp, e.remainingTurns); + else t.AttackDown = Mathf.Max(t.AttackDown, e.remainingTurns); + break; + } + } + return t; + } + + /// + /// 상태 줄 문구. 는 그 문자열이 그려질 폭(같은 스타일의 CalcSize) — null이면 접지 않는다. + /// 꼬리표가 없으면 "HP"(체력 위험이면 그것만). 다 안 들어가면 ①머리말을 둔 채 뒤 꼬리표부터 "외 N"으로 접고, + /// ②그래도 안 되면 머리말("체력 위험" — HP 막대 색이 이미 말해 준다)을 빼고 다시 접는다. + /// + internal static string Compose(bool lowHp, Turns t, System.Func measure, float maxWidth) + { + string head = lowHp ? LowHpLabel : null; + if (!t.Any) return head ?? IdleLabel; + + var tags = new List(6); + if (t.Stun > 0) tags.Add($"기절 {t.Stun}턴"); + if (t.Poison > 0) tags.Add($"중독 {t.Poison}턴"); + if (t.AttackDown > 0) tags.Add($"공격↓ {t.AttackDown}턴"); + if (t.DefenseDown > 0) tags.Add($"방어↓ {t.DefenseDown}턴"); + if (t.AttackUp > 0) tags.Add($"공격↑ {t.AttackUp}턴"); + if (t.DefenseUp > 0) tags.Add($"방어↑ {t.DefenseUp}턴"); + + string full = Join(head, tags, tags.Count); + if (measure == null || measure(full) <= maxWidth) return full; + if (TryFold(head, tags, measure, maxWidth, out string folded)) return folded; + if (head != null && TryFold(null, tags, measure, maxWidth, out folded)) return folded; + return Join(null, tags, 1); // 꼬리표 하나도 안 들어가는 폭 — 그리는 쪽 LabelFit에 맡긴다 + } + + private static bool TryFold(string head, List tags, System.Func measure, float maxWidth, out string text) + { + for (int keep = head == null ? tags.Count : tags.Count - 1; keep >= 1; keep--) + { + text = Join(head, tags, keep); + if (measure(text) <= maxWidth) return true; + } + text = null; + return false; + } + + /// 머리말 + 앞에서 개 꼬리표, 남은 게 있으면 " 외 N". + private static string Join(string head, List tags, int keep) + { + var sb = new System.Text.StringBuilder(48); + if (head != null) sb.Append(head); + int shown = Mathf.Clamp(keep, 0, tags.Count); + for (int i = 0; i < shown; i++) + { + if (sb.Length > 0) sb.Append(Gap); + sb.Append(tags[i]); + } + int rest = tags.Count - shown; + if (rest > 0) sb.Append(" 외 ").Append(rest); + return sb.ToString(); + } + } } diff --git a/Assets/Scripts/UI/BattleShoutOverlay.cs b/Assets/Scripts/UI/BattleShoutOverlay.cs new file mode 100644 index 0000000..5cabb73 --- /dev/null +++ b/Assets/Scripts/UI/BattleShoutOverlay.cs @@ -0,0 +1,197 @@ +using System.Collections.Generic; +using InsectGame.Battle; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 전투 외침 그리기 — 기술 이름 외치기(말풍선), 맞은 쪽 비명, 타격 의성어("콰광!!"). + /// 목록과 타이밍의 주인은 이고 여기선 그리기만 한다 + /// (와 같은 분업 — 아레나는 UI를 부를 수 없다). + /// + /// 위치는 외친 순간의 월드 점을 매 프레임 다시 투영한다. 연출 카메라가 움직여도 말풍선이 + /// 그 곤충 머리 위에 붙어 있고, 돌진하는 시전자를 따라 화면을 가로지르지는 않는다. + /// 시계는 실제 시간이다 — 히트스톱 중에도 의성어가 튀어나오는 게 보여야 한다. + /// + public static class BattleShoutOverlay + { + private static GUIStyle calloutStyle; + private static GUIStyle hurtStyle; + private static GUIStyle soundStyle; + private static readonly GUIContent measure = new GUIContent(); + + private const int CalloutFont = 40; + private const int HurtFont = 32; + private const int SoundFont = 62; + private const int HeavySoundFont = 80; + private const float CalloutMaxWidth = 760f; + /// 상단 HP 상자 높이만큼 비운다(가상 px). + private const float HudClearance = 140f; + + /// + /// 호출부는 UIScale.Begin()과 UIScale.End() 사이에서 부른다 — 가상 캔버스 좌표다. + /// + public static void Draw(BattleArenaController arena) + { + if (arena == null || !arena.IsActive) return; + IReadOnlyList shouts = arena.GetActiveShouts(); + if (shouts == null || shouts.Count == 0) return; + Camera cam = Camera.main; + if (cam == null) return; + EnsureStyles(); + + float now = Time.unscaledTime; + Color prevColor = GUI.color; + Matrix4x4 prevMatrix = GUI.matrix; + // 의성어를 먼저(아래) 그린다 — 큰 의성어가 위에 오면 말풍선의 기술 이름·비명이 가려진다. + for (int pass = 0; pass < 2; pass++) + { + for (int i = 0; i < shouts.Count; i++) + { + BattleShout.Entry e = shouts[i]; + if (e == null) continue; + bool soundPass = e.Kind == BattleShout.Kind.Sound; + if (soundPass != (pass == 0)) continue; + float age = now - e.StartTime; + if (age < 0f || age >= e.Duration) continue; + Vector3 sp = cam.WorldToScreenPoint(e.WorldPoint); + if (sp.z <= 0f) continue; + Vector2 p = new Vector2( + sp.x / Mathf.Max(1, Screen.width) * UIScale.VirtualScreenWidth, + (1f - sp.y / Mathf.Max(1, Screen.height)) * UIScale.VirtualScreenHeight); + float fade = Mathf.Clamp01((e.Duration - age) / 0.25f); + switch (e.Kind) + { + case BattleShout.Kind.Callout: DrawCallout(p, e, age, fade); break; + case BattleShout.Kind.Hurt: DrawHurt(p, e, age, fade); break; + default: DrawSound(p, e, age, fade); break; + } + GUI.matrix = prevMatrix; + } + } + GUI.color = prevColor; + } + + /// 기술 이름 외치기 — 머리 위 말풍선이 튀어나와(0.12초 과장 후 정착) 꼬리로 곤충을 가리킨다. + private static void DrawCallout(Vector2 anchor, BattleShout.Entry e, float age, float fade) + { + UITheme theme = UITheme.Instance; + measure.text = e.Text; + Vector2 size = calloutStyle.CalcSize(measure); + float w = Mathf.Min(CalloutMaxWidth, size.x + 56f); + const float h = 72f; + Rect content = UISafeLayout.Content; + float x = Mathf.Clamp(anchor.x - w * 0.5f, content.xMin, content.xMax - w); + // 위쪽은 HP 상자 아래까지만 — 카메라가 움직이면 머리 위 점이 화면 위로 밀려 올라간다. + float y = Mathf.Clamp(anchor.y - 34f - h, content.yMin + HudClearance, content.yMax - h); + Rect box = new Rect(x, y, w, h); + + float pop = age < 0.12f ? Mathf.Lerp(0.45f, 1.12f, age / 0.12f) + : age < 0.2f ? Mathf.Lerp(1.12f, 1f, (age - 0.12f) / 0.08f) : 1f; + TransformAround(new Vector2(box.center.x, box.yMax), 0f, pop); + + Color accent = e.Color; + Color bg = theme.surfaceBase; + bg.a = 0.94f * fade; + accent.a = fade; + // 꼬리 먼저 — 카드가 뿌리를 덮는다. 곤충 쪽으로 좁아지는 쐐기. + Vector2 tailFrom = new Vector2(Mathf.Clamp(anchor.x, box.xMin + 28f, box.xMax - 28f), box.yMax - 4f); + Vector2 tailTo = new Vector2(anchor.x, Mathf.Clamp(anchor.y - 10f, box.yMax + 10f, box.yMax + 46f)); + const int Steps = 6; + float stepH = (tailTo.y - tailFrom.y) / Steps; + for (int k = 0; k < Steps; k++) + { + float t = (float)k / Steps; + float cx = Mathf.Lerp(tailFrom.x, tailTo.x, t); + float tw = Mathf.Lerp(22f, 5f, t); + UISurface.Flat(new Rect(cx - tw * 0.5f, tailFrom.y + stepH * k, tw, stepH + 1f), accent); + } + GUI.color = new Color(1f, 1f, 1f, fade); + UISurface.Card(box, bg, accent); + UISurface.Flat(new Rect(box.x + UITheme.Radius.Card, box.y + 4f, box.width - UITheme.Radius.Card * 2f, 4f), accent); + + Color text = Color.Lerp(e.Color, Color.white, 0.55f); + text.a = fade; + calloutStyle.normal.textColor = text; + GUI.color = Color.white; + UIHelper.LabelFit(new Rect(box.x + 18f, box.y + 4f, box.width - 36f, box.height - 6f), e.Text, calloutStyle); + } + + /// 비명 — 머리 위에서 부르르 떨다가 떠오르며 사라진다. + private static void DrawHurt(Vector2 anchor, BattleShout.Entry e, float age, float fade) + { + float jitter = age < 0.22f ? Mathf.Sin(age * 90f) * 5f * (1f - age / 0.22f) : 0f; + float rise = Mathf.Min(1f, age / e.Duration) * 26f; + Rect content = UISafeLayout.Content; + float x = Mathf.Clamp(anchor.x - 160f + jitter, content.xMin, content.xMax - 320f); + float y = Mathf.Clamp(anchor.y - 30f - 48f - rise, content.yMin + HudClearance, content.yMax - 48f); + float pop = age < 0.08f ? Mathf.Lerp(1.5f, 1f, age / 0.08f) : 1f; + Rect r = new Rect(x, y, 320f, 48f); + TransformAround(r.center, 0f, pop); + DrawOutlined(r, e.Text, hurtStyle, new Color(1f, 1f, 1f, fade), new Color(0.05f, 0.04f, 0.08f, 0.9f * fade), 3f); + } + + /// + /// 타격 의성어 — 크게 찍혔다가(과장 1.8배 → 1배, 0.09초) 기울어진 채 남는다. 굵은 어두운 외곽선이 + /// 섬광·임팩트 위에서도 글자를 세운다. + /// + private static void DrawSound(Vector2 anchor, BattleShout.Entry e, float age, float fade) + { + bool heavy = e.Text.EndsWith("!!"); + soundStyle.fontSize = heavy ? HeavySoundFont : SoundFont; + float pop = age < 0.09f ? Mathf.Lerp(1.8f, 1f, age / 0.09f) : 1f + 0.04f * Mathf.Sin(age * 20f) * Mathf.Exp(-age * 6f); + Rect r = new Rect(anchor.x - 260f, anchor.y + 6f, 520f, heavy ? 110f : 90f); + TransformAround(r.center, e.Tilt, pop); + Color fill = e.Color; + fill.a = fade; + DrawOutlined(r, e.Text, soundStyle, fill, new Color(0.06f, 0.04f, 0.09f, 0.95f * fade), heavy ? 5f : 4f); + } + + /// + /// 가상 캔버스 좌표의 한 점을 중심으로 돌리고 키운다. GUIUtility.RotateAroundPivot은 피벗을 + /// 스케일 전 화면 좌표로 해석해서, UIScale이 1이 아닌 화면에선 도형이 엉뚱한 자리로 + /// 날아갔다(말풍선 꼬리가 말풍선에서 떨어진 노란 막대로 그려졌다 — 1280×720 실측). + /// 현재 행렬 **뒤에** 곱하면 변환이 가상 좌표에서 일어난다. + /// + private static void TransformAround(Vector2 pivot, float angle, float scale) + { + GUI.matrix = GUI.matrix + * Matrix4x4.TRS(new Vector3(pivot.x, pivot.y, 0f), Quaternion.Euler(0f, 0f, angle), new Vector3(scale, scale, 1f)) + * Matrix4x4.TRS(new Vector3(-pivot.x, -pivot.y, 0f), Quaternion.identity, Vector3.one); + } + + private static readonly Vector2[] OutlineDirs = + { + new Vector2(1f, 0f), new Vector2(-1f, 0f), new Vector2(0f, 1f), new Vector2(0f, -1f), + new Vector2(0.71f, 0.71f), new Vector2(-0.71f, 0.71f), new Vector2(0.71f, -0.71f), new Vector2(-0.71f, -0.71f) + }; + + private static void DrawOutlined(Rect r, string text, GUIStyle style, Color fill, Color outline, float thickness) + { + GUI.color = Color.white; + style.normal.textColor = outline; + for (int i = 0; i < OutlineDirs.Length; i++) + { + Vector2 d = OutlineDirs[i] * thickness; + GUI.Label(new Rect(r.x + d.x, r.y + d.y, r.width, r.height), text, style); + } + style.normal.textColor = fill; + GUI.Label(r, text, style); + } + + private static void EnsureStyles() + { + if (calloutStyle != null) return; + calloutStyle = new GUIStyle(GUI.skin.label) + { + fontSize = CalloutFont, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter, + wordWrap = false, + clipping = TextClipping.Overflow + }; + hurtStyle = new GUIStyle(calloutStyle) { fontSize = HurtFont }; + soundStyle = new GUIStyle(calloutStyle) { fontSize = SoundFont, fontStyle = FontStyle.BoldAndItalic }; + } + } +} diff --git a/Assets/Scripts/UI/BattleShoutOverlay.cs.meta b/Assets/Scripts/UI/BattleShoutOverlay.cs.meta new file mode 100644 index 0000000..dcd367a --- /dev/null +++ b/Assets/Scripts/UI/BattleShoutOverlay.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 43916b43db3c8004a8649a1c3789f820 \ No newline at end of file diff --git a/Assets/Scripts/UI/BattleTeamUI.cs b/Assets/Scripts/UI/BattleTeamUI.cs index fbfcb0a..6538503 100644 --- a/Assets/Scripts/UI/BattleTeamUI.cs +++ b/Assets/Scripts/UI/BattleTeamUI.cs @@ -29,57 +29,33 @@ public class BattleTeamUI : MonoBehaviour, IModalUI // OnGUI에서 IndexOutOfRange가 날 자리였다. 히트 테스트는 `GUI.Button`이 직접 하므로 필요 없다. private int pickerSortedCount = -1; - // OnGUI 매 프레임 new GUIStyle 회귀 차단 — 11개 캐시 필드 + InitStyles 1회 초기화. + // 헤더 부제("4/5 · 팀 전투력 1272")는 값이 바뀔 때만 다시 만든다 — OnGUI는 프레임당 여러 패스다. + private string headerSubtitle = string.Empty; + private int headerFilled = -1; + private int headerCp = -1; + + // OnGUI 매 프레임 new GUIStyle 회귀 차단 — 캐시 필드 + InitTeamStyles 1회 초기화. // 동적 textColor는 매 호출 갱신 (BattleScreenUI ComboCol 패턴). - private GUIStyle teamTitleCache, teamCloseCache, teamSubCache; - private GUIStyle slotNumCache, slotNameCache, slotInfoCache, slotRemoveCache, slotEmptyCache, slotAddCache, slotHintCache; - private GUIStyle pickerTitleCache, pickerBackCache, pickerNameCache, pickerInfoCache, pickerTagCache, pickerBtnCache; + // 색은 전부 UITheme 토큰에서 받는다(rules/ui-layout.md) — 한때 이 파일에만 자기 색이 23개 있었다. + private GUIStyle teamSubCache, slotNumCache, slotNameCache, slotInfoCache, slotHpCache; + private GUIStyle buttonCache, chipButtonCache, emptyPlusCache; private bool teamStylesInit; - // 정적 색 — new Color 매 프레임 회귀 차단 - private static readonly Color PanelBg = new Color(0.05f, 0.06f, 0.12f, 0.95f); - private static readonly Color HeaderBg = new Color(0.15f, 0.18f, 0.28f); - private static readonly Color HeaderLine = new Color(1f, 0.6f, 0.2f); - private static readonly Color TitleCol = new Color(1f, 0.8f, 0.3f); - private static readonly Color SubCol = new Color(0.78f, 0.78f, 0.82f); - private static readonly Color SlotBg = new Color(0.1f, 0.12f, 0.18f, 0.85f); - private static readonly Color NumCol = new Color(0.58f, 0.62f, 0.76f); - private static readonly Color InfoCol = new Color(0.76f, 0.76f, 0.8f); - private static readonly Color RemoveBg = new Color(0.5f, 0.2f, 0.2f); - private static readonly Color ChangeBg = new Color(0.25f, 0.35f, 0.55f); - private static readonly Color HealBg = new Color(0.22f, 0.55f, 0.38f); - private static readonly Color HurtCol = new Color(1f, 0.5f, 0.42f); - private static readonly Color EmptyBoxCol = new Color(0.25f, 0.25f, 0.3f, 0.5f); - private static readonly Color EmptyCol = new Color(0.68f, 0.68f, 0.74f); - private static readonly Color HintCol = new Color(0.66f, 0.66f, 0.72f); - private static readonly Color AddBg = new Color(0.2f, 0.4f, 0.3f); - private static readonly Color PickerHeaderBg = new Color(0.15f, 0.2f, 0.3f); - private static readonly Color PickerItemActiveBg = new Color(0.1f, 0.12f, 0.18f, 0.85f); - private static readonly Color PickerItemDisabledBg = new Color(0.08f, 0.08f, 0.12f, 0.6f); - private static readonly Color PickerInfoCol = new Color(0.72f, 0.72f, 0.76f); - private static readonly Color TagCol = new Color(0.86f, 0.84f, 0.48f); - private static readonly Color BtnBg = new Color(0.2f, 0.45f, 0.3f); - private static readonly Color DimNameCol = new Color(0.64f, 0.64f, 0.68f); - private void InitTeamStyles() { if (teamStylesInit) return; teamStylesInit = true; + UITheme t = UITheme.Instance; - teamTitleCache = new GUIStyle(GUI.skin.label) { fontSize = 44, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - teamTitleCache.normal.textColor = TitleCol; - teamCloseCache = new GUIStyle(GUI.skin.button) { fontSize = 36, fontStyle = FontStyle.Bold }; teamSubCache = new GUIStyle(GUI.skin.label) { - fontSize = 32, - alignment = TextAnchor.MiddleCenter, + fontSize = 30, + alignment = TextAnchor.MiddleLeft, wordWrap = true, clipping = TextClipping.Clip }; - teamSubCache.normal.textColor = SubCol; - - slotNumCache = new GUIStyle(GUI.skin.label) { fontSize = 47, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - slotNumCache.normal.textColor = NumCol; + slotNumCache = new GUIStyle(GUI.skin.label) { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + slotNumCache.normal.textColor = t.textSecondary; slotNameCache = new GUIStyle(GUI.skin.label) { fontSize = 34, @@ -88,31 +64,14 @@ private void InitTeamStyles() wordWrap = true, clipping = TextClipping.Clip }; - slotInfoCache = new GUIStyle(GUI.skin.label) { fontSize = 31 }; - slotInfoCache.normal.textColor = InfoCol; - slotRemoveCache = new GUIStyle(GUI.skin.button) { fontSize = 31 }; - slotEmptyCache = new GUIStyle(GUI.skin.label) { fontSize = 52, alignment = TextAnchor.MiddleCenter }; - slotEmptyCache.normal.textColor = EmptyCol; - slotAddCache = new GUIStyle(GUI.skin.button) { fontSize = 34 }; - slotHintCache = new GUIStyle(GUI.skin.label) { fontSize = 34 }; - slotHintCache.normal.textColor = HintCol; - - pickerTitleCache = new GUIStyle(GUI.skin.label) { fontSize = 39, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - pickerTitleCache.normal.textColor = Color.white; - pickerBackCache = new GUIStyle(GUI.skin.button) { fontSize = 31, fontStyle = FontStyle.Bold }; - pickerNameCache = new GUIStyle(GUI.skin.label) - { - fontSize = 31, - fontStyle = FontStyle.Bold, - alignment = TextAnchor.MiddleLeft, - wordWrap = true, - clipping = TextClipping.Clip - }; - pickerInfoCache = new GUIStyle(GUI.skin.label) { fontSize = 31 }; - pickerInfoCache.normal.textColor = PickerInfoCol; - pickerTagCache = new GUIStyle(GUI.skin.label) { fontSize = 31, alignment = TextAnchor.MiddleRight }; - pickerTagCache.normal.textColor = TagCol; - pickerBtnCache = new GUIStyle(GUI.skin.button) { fontSize = 31 }; + slotInfoCache = new GUIStyle(GUI.skin.label) { fontSize = 26, alignment = TextAnchor.MiddleLeft }; + slotInfoCache.normal.textColor = t.textSecondary; + slotHpCache = new GUIStyle(GUI.skin.label) { fontSize = 22, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + buttonCache = new GUIStyle(GUI.skin.label) { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + buttonCache.normal.textColor = t.textPrimary; + chipButtonCache = new GUIStyle(buttonCache) { fontSize = 26 }; + emptyPlusCache = new GUIStyle(GUI.skin.label) { fontSize = 48, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + emptyPlusCache.normal.textColor = t.textMuted; } public bool IsOpen => isOpen; @@ -156,46 +115,51 @@ private void OnGUI() UIScale.End(); } + /// + /// 창 자리. 세로 화면은 높이를 크게 잡는다 — 960×940 고정일 때 세로 화면 가운데 절반만 쓰고 + /// 위아래가 비었다(의상 창·캐시샵이 1560으로 고친 것과 같은 결함). 안전 영역을 넘으면 하네스가 줄인다. + /// + private static Rect PanelRect() + { + bool mobile = UIScale.IsMobileLayout; + return UISafeLayout.CenteredPanel(mobile ? 1000f : 1040f, mobile ? 1560f : 960f); + } + private void DrawTeamPanel() { - Rect panel = UISafeLayout.CenteredPanel(960f, 940f); + UITheme t = UITheme.Instance; + Rect panel = PanelRect(); float panelW = panel.width; float panelH = panel.height; float px = panel.x; float py = panel.y; - UISurface.Card(new Rect(px, py, panelW, panelH), PanelBg, UITheme.Instance.surfaceBorder); - UISurface.Rounded(new Rect(px + 3f, py + 3f, panelW - 6f, 84f), HeaderBg); - GUI.color = HeaderLine; - GUI.DrawTexture(new Rect(px + 3f, py + 84, panelW - 6f, 5), Texture2D.whiteTexture); - - GUI.color = Color.white; - // 화면 전체가 한국어인데 이 세 곳(제목·피커 제목·뒤로)만 영어로 남아 있었다. - // 퀵바·HUD가 이 화면을 "배틀팀"으로 부르므로 같은 말을 쓴다. - GUI.Label(new Rect(px, py + 14, panelW - 72, 58), "배틀팀", teamTitleCache); - - float closeH = SkillUILayout.GetTouchHeight(UIScale.IsMobileLayout, 52f, 64f); - if (GUI.Button(new Rect(px + panelW - 80f, py + 10f, 70f, closeH), "X", teamCloseCache)) + UISurface.Card(panel); + // 도감과 같은 액센트 헤더 — 제목·부제·닫기를 한 줄에 둔다. + // 화면 전체가 한국어라 퀵바·HUD가 이 화면을 부르는 "배틀팀"을 그대로 쓴다. + if (UISurface.Header(new Rect(px + 3f, py + 3f, panelW - 6f, 88f), "배틀팀", HeaderSubtitle())) { CloseModal(); + return; } // 부상 안내 + 회복 진입 — 팀을 짜는 자리에서 바로 상태를 알고 고칠 수 있어야 한다. // 치료 자체는 병원이 한다(재화·결제·환불 로직을 여기서 복제하면 곧 어긋난다). int injured = InjuredTeamCount(); - GUI.Label(new Rect(px + 24f, py + 92, panelW - (injured > 0 ? 272f : 48f), 52), + bool canHeal = injured > 0 && hospitalUi != null; + teamSubCache.normal.textColor = injured > 0 ? t.accentCoral : t.textSecondary; + UIHelper.LabelFit(new Rect(px + 28f, py + 104f, panelW - (canHeal ? 330f : 56f), 52f), injured > 0 ? $"부상 {injured}마리 — 회복하고 나가세요" : $"배틀용 곤충을 최대 {BattleTeamManager.MaxSlots}마리 선택하세요", teamSubCache); - if (injured > 0 && hospitalUi != null) + if (canHeal) { - float healH = SkillUILayout.GetTouchHeight(UIScale.IsMobileLayout, 48f, 56f); - GUI.backgroundColor = HealBg; - // 폭 224 — "회복하러 가기"는 31px 글자 6자라 안쪽 여백을 빼면 190px쯤 든다. - // 172로 잡았더니 마지막 글자가 잘렸다(wordWrap이 없어 가로로 잘린다). - if (GUI.Button(new Rect(px + panelW - 248f, py + 94f, 224f, healH), "회복하러 가기", slotRemoveCache)) + float healH = SkillUILayout.GetTouchHeight(UIScale.IsMobileLayout, 52f, 60f); + // 폭 250 — "회복하러 가기"(30px 굵은 글자 6자)가 안쪽 여백을 빼고도 들어가는 값. + if (UISurface.Button(new Rect(px + panelW - 278f, py + 104f + (52f - healH) / 2f, 250f, healH), + "회복하러 가기", t.btnPrimary, chipButtonCache)) { // 배틀팀을 닫고 병원을 연다 — 모달 둘이 겹치면 뒤쪽 버튼이 클릭을 가로챈다. CloseModal(); @@ -203,45 +167,74 @@ private void DrawTeamPanel() // 두 창이 다 사라진다. 지금은 퀵바의 IsAnyOpen 게이트 덕에 도달하지 않지만 // 게이트 하나에 기대는 대신 여기서 "열기"로 못박는다. if (!hospitalUi.IsOpen) hospitalUi.Toggle(); - GUI.backgroundColor = Color.white; return; } - GUI.backgroundColor = Color.white; } - float slotY = py + 150; - const float slotGap = 6f; + float slotY = py + 170f; + const float slotGap = 10f; // 패널이 안전 영역에 맞춰 줄면 슬롯 높이도 함께 줄여 마지막 슬롯이 잘리지 않게 한다. - float slotAvail = panelH - (slotY - py) - 12f; + float slotAvail = panelH - (slotY - py) - 20f; float slotH = Mathf.Clamp( (slotAvail - (BattleTeamManager.MaxSlots - 1) * slotGap) / BattleTeamManager.MaxSlots, UIScale.MinTouchHeight, - UIScale.IsMobileLayout ? 148f : 128f); + UIScale.IsMobileLayout ? 300f : 142f); for (int i = 0; i < BattleTeamManager.MaxSlots; i++) { - DrawSlot(px + 18, slotY + i * (slotH + slotGap), panelW - 36, slotH, i); + DrawSlot(px + 20f, slotY + i * (slotH + slotGap), panelW - 40f, slotH, i); } } + /// "4/5 · 팀 전투력 1272" — 합이 바뀔 때만 문자열을 다시 만든다. + private string HeaderSubtitle() + { + int filled = 0; + int cp = 0; + if (teamManager != null && collection != null) + { + for (int i = 0; i < BattleTeamManager.MaxSlots; i++) + { + PlayerInsectData pid = collection.GetByInstanceId(teamManager.GetSlot(i)); + if (pid == null) continue; + InsectData data = collection.GetInsectData(pid.insectId); + if (data == null) continue; + filled++; + cp += PlayerInsectCombatPower.Calculate(data, pid); + } + } + if (filled != headerFilled || cp != headerCp) + { + headerFilled = filled; + headerCp = cp; + headerSubtitle = $"{filled}/{BattleTeamManager.MaxSlots} · 팀 전투력 {cp}"; + } + return headerSubtitle; + } + private void DrawSlot(float x, float y, float w, float h, int index) { + UITheme t = UITheme.Instance; string instanceId = teamManager != null ? teamManager.GetSlot(index) : null; + PlayerInsectData pid = !string.IsNullOrEmpty(instanceId) && collection != null + ? collection.GetByInstanceId(instanceId) : null; + InsectData data = pid != null ? collection.GetInsectData(pid.insectId) : null; bool hasInsect = !string.IsNullOrEmpty(instanceId); - UISurface.Card(new Rect(x, y, w, h), SlotBg, UITheme.Instance.surfaceBorder); + UISurface.Card(new Rect(x, y, w, h), hasInsect ? t.surfaceRaised : t.surfaceBase, t.surfaceBorder); + DrawSlotNumber(x, y, h, index); - GUI.color = Color.white; - GUI.Label(new Rect(x, y, 64, h), $"{index + 1}", slotNumCache); + float thumb = Mathf.Min(h - 20f, UIScale.IsMobileLayout ? 200f : 120f); + Rect frame = new Rect(x + 70f, y + (h - thumb) / 2f, thumb, thumb); + float textX = frame.xMax + 20f; + float textW = x + w - 190f - textX; + float actionH = SkillUILayout.GetTouchHeight(UIScale.IsMobileLayout, 52f, 60f); if (hasInsect) { - PlayerInsectData pid = collection != null ? collection.GetByInstanceId(instanceId) : null; - InsectData data = pid != null && collection != null ? collection.GetInsectData(pid.insectId) : null; - if (data != null) { - Color rarityCol = UITheme.Instance.GetInsectRarityColor(data.rarity); + Color rarityCol = t.GetInsectRarityColor(data.rarity); // 6px 레일 — 각진 채로 두고(ui-layout.md) 세로를 카드 반경만큼 물려 // 둥근 모서리를 뚫지 않게 한다. UISurface.Flat( @@ -249,86 +242,103 @@ private void DrawSlot(float x, float y, float w, float h, int index) Mathf.Max(4f, h - 6f - UITheme.Radius.Card * 2f)), rarityCol); - InsectVisual.Draw(x + 90, y + h / 2f, 96f, data, pid != null && pid.isShiny, 1f); - - slotNameCache.normal.textColor = rarityCol; - GUI.color = Color.white; - // 46px는 이 폰트로 한 줄이라, 긴 이름이 줄바꿈되면 둘째 줄이 잘렸다. - UIHelper.LabelFit( - new Rect(x + 150, y + 16, w - 330, 46), GetOwnedDisplayName(pid, data), slotNameCache); - - int lv = pid != null ? pid.level : 1; - int cp = PlayerInsectCombatPower.Calculate(data, pid); - // 부상이면 그 자리에서 알린다 — 헤더의 "회복하러 가기"만으로는 **누가** 다쳤는지 모른다. - bool hurt = NeedsHeal(pid); - slotInfoCache.normal.textColor = hurt ? HurtCol : InfoCol; - GUI.Label(new Rect(x + 150, y + 64, w - 330, 42), - hurt - ? $"Lv.{lv} | CP {cp} | {HurtLabel(pid, data)}" - : $"Lv.{lv} | {data.rarity} | CP {cp}", - slotInfoCache); - slotInfoCache.normal.textColor = InfoCol; - } + UISurface.Rounded(frame, Color.Lerp(t.surfaceBase, rarityCol, 0.14f)); + InsectVisual.Draw(frame.center.x, frame.center.y, thumb * 0.94f, data, pid.isShiny, + pid.IsFainted ? 0.45f : 1f); - GUI.backgroundColor = RemoveBg; - float actionH = SkillUILayout.GetTouchHeight(UIScale.IsMobileLayout, 50f, 60f); - if (GUI.Button(new Rect(x + w - 168, y + 10f, 150, actionH), "제거", slotRemoveCache)) - teamManager?.RemoveSlot(index); + DrawInsectLines(textX, y + h * 0.5f, textW, pid, data, slotNameCache); + } - GUI.backgroundColor = ChangeBg; - if (GUI.Button(new Rect(x + w - 168, y + h - actionH - 10f, 150, actionH), "변경", slotRemoveCache)) + // 두 행동 버튼 — 세로로 쌓되 슬롯이 낮으면 가운데로 모은다. + float stackH = actionH * 2f + 10f; + float by = y + Mathf.Max(8f, (h - stackH) / 2f); + // "변경"은 테두리색 — btnSecondary는 surfaceRaised로 동기화돼 슬롯 카드와 같은 색이라 버튼이 사라져 보였다. + if (UISurface.Button(new Rect(x + w - 170f, by, 150f, actionH), "변경", t.surfaceBorder, buttonCache)) { - selectingSlot = index; - listScroll = Vector2.zero; - directScroll.Reset(); - pickerSortDirty = true; // 열 때마다 최신 보유 목록으로 다시 정렬 + OpenPicker(index); + return; } - GUI.backgroundColor = Color.white; + if (UISurface.Button(new Rect(x + w - 170f, by + actionH + 10f, 150f, actionH), "제거", + Color.Lerp(t.btnDanger, t.surfaceRaised, 0.35f), buttonCache)) + teamManager?.RemoveSlot(index); } else { - GUI.color = EmptyBoxCol; - GUI.DrawTexture(new Rect(x + 64, y + h / 2f - 32, 64, 64), Texture2D.whiteTexture); + UISurface.Rounded(frame, t.surfaceCard); + GUI.Label(frame, "+", emptyPlusCache); - GUI.color = Color.white; - UIHelper.LabelFit(new Rect(x + 64, y + h / 2f - 32, 64, 64), "+", slotEmptyCache); + slotInfoCache.normal.textColor = t.textMuted; + UIHelper.LabelFit(new Rect(textX, y + h / 2f - 22f, textW, 44f), "빈 슬롯 — 곤충을 넣어 주세요", slotInfoCache); + slotInfoCache.normal.textColor = t.textSecondary; - GUI.backgroundColor = AddBg; - float selectH = SkillUILayout.GetTouchHeight(UIScale.IsMobileLayout, 52f, 64f); - if (GUI.Button(new Rect(x + w - 170, y + h / 2f - selectH * 0.5f, 150, selectH), "선택", slotAddCache)) - { - selectingSlot = index; - listScroll = Vector2.zero; - directScroll.Reset(); - pickerSortDirty = true; // 열 때마다 최신 보유 목록으로 다시 정렬 - } - GUI.backgroundColor = Color.white; - - GUI.Label(new Rect(x + 150, y + h / 2f - 21, w - 330, 42), "빈 슬롯", slotHintCache); + float selectH = SkillUILayout.GetTouchHeight(UIScale.IsMobileLayout, 56f, 64f); + if (UISurface.Button(new Rect(x + w - 170f, y + h / 2f - selectH * 0.5f, 150f, selectH), "선택", + t.btnPrimary, buttonCache)) + OpenPicker(index); } } + /// 좌측 번호 원 — 슬롯 순서가 곧 레이드 출전 순서다. + private void DrawSlotNumber(float x, float y, float h, int index) + { + Rect badge = new Rect(x + 16f, y + h / 2f - 22f, 44f, 44f); + UISurface.Rounded(badge, UITheme.Instance.surfaceBase, UITheme.Radius.Chip); + GUI.Label(badge, (index + 1).ToString(), slotNumCache); + } + + /// + /// 이름 · (Lv·등급·CP) · HP 막대 세 줄을 기준 세로 가운데로 그린다. + /// 배틀팀 슬롯과 피커 행이 같은 모양을 쓴다(두 곳이 따로 놀던 때는 한쪽만 HP를 보였다). + /// + private void DrawInsectLines(float x, float midY, float w, PlayerInsectData pid, InsectData data, GUIStyle nameStyle) + { + UITheme t = UITheme.Instance; + Color rarityCol = t.GetInsectRarityColor(data.rarity); + nameStyle.normal.textColor = rarityCol; + // 46px는 이 폰트로 한 줄이라, 긴 이름이 줄바꿈되면 둘째 줄이 잘렸다. + UIHelper.LabelFit(new Rect(x, midY - 56f, w, 46f), GetOwnedDisplayName(pid, data), nameStyle); + + int cp = PlayerInsectCombatPower.Calculate(data, pid); + GUI.Label(new Rect(x, midY - 10f, w, 34f), + $"Lv.{pid.level} · {data.rarity.Korean()} · CP {cp}", slotInfoCache); + + // 부상이면 그 자리에서 알린다 — 헤더의 "회복하러 가기"만으로는 **누가** 다쳤는지 모른다. + int maxHp = pid.GetTotalHp(data.baseHp); + int curHp = pid.currentHp < 0 ? maxHp : pid.currentHp; // -1은 구세이브 미초기화 = 풀피 + float ratio = maxHp > 0 ? (float)curHp / maxHp : 0f; + float barW = Mathf.Min(w * 0.55f, 300f); + UISurface.Meter(new Rect(x, midY + 34f, barW, 12f), ratio, t.GetHpColor(ratio)); + bool hurt = NeedsHeal(pid); + slotHpCache.normal.textColor = hurt ? t.accentCoral : t.textSecondary; + UIHelper.LabelFit(new Rect(x + barW + 12f, midY + 24f, Mathf.Max(1f, w - barW - 12f), 32f), + hurt ? HurtLabel(pid, data) : $"HP {curHp}/{maxHp}", slotHpCache); + } + + private void OpenPicker(int index) + { + selectingSlot = index; + listScroll = Vector2.zero; + directScroll.Reset(); + pickerSortDirty = true; // 열 때마다 최신 보유 목록으로 다시 정렬 + } + private void DrawInsectPicker() { - Rect panel = UISafeLayout.CenteredPanel(960f, 940f); + UITheme t = UITheme.Instance; + Rect panel = PanelRect(); float panelW = panel.width; float panelH = panel.height; float px = panel.x; float py = panel.y; - UISurface.Card(new Rect(px, py, panelW, panelH), PanelBg, UITheme.Instance.surfaceBorder); - UISurface.Rounded(new Rect(px + 3f, py + 3f, panelW - 6f, 84f), PickerHeaderBg); - - GUI.color = Color.white; - GUI.Label(new Rect(px + 130, py + 14, panelW - 260, 58), - $"{selectingSlot + 1}번 슬롯에 넣을 곤충 선택", pickerTitleCache); - - float backH = SkillUILayout.GetTouchHeight(UIScale.IsMobileLayout, 52f, 64f); - // 화살표는 ASCII '<' 그대로 둔다 — 이 라벨에서 이미 렌더되던 글자라 폰트 아틀라스가 확실하다. - if (GUI.Button(new Rect(px + 14, py + 10f, 150f, backH), "< 뒤로", pickerBackCache)) + UISurface.Card(panel); + // 헤더의 오른쪽 버튼이 곧 "뒤로"다 — 창을 닫지 않고 슬롯 화면으로 돌아간다. + if (UISurface.Header(new Rect(px + 3f, py + 3f, panelW - 6f, 88f), + $"{selectingSlot + 1}번 슬롯", "넣을 곤충 선택", "‹ 뒤로")) { selectingSlot = -1; directScroll.Reset(); + return; } if (collection == null) return; @@ -337,15 +347,16 @@ private void DrawInsectPicker() // 팀에 넣을 곤충을 고를 때 필요한 건 "무엇이 강한가"다. 기본은 등급 → CP 순이고, // 레벨·전투력·최근 획득으로 바꿀 수 있다. 순서 규칙은 InsectBrowseSort가 단일 출처이며 // 보유 곤충 화면도 같은 것을 쓴다(두 화면이 다르게 정렬하면 방금 본 개체를 다시 찾아야 한다). - float chipY = py + 92f; - float chipH = SkillUILayout.GetTouchHeight(UIScale.IsMobileLayout, 44f, 52f); - float chipW = (panelW - 40f - 18f) / InsectBrowseSort.Order.Length; + float chipY = py + 106f; + float chipH = SkillUILayout.GetTouchHeight(UIScale.IsMobileLayout, 48f, 56f); + float chipGap = 8f; + float chipW = (panelW - 48f - chipGap * (InsectBrowseSort.Order.Length - 1)) / InsectBrowseSort.Order.Length; for (int i = 0; i < InsectBrowseSort.Order.Length; i++) { InsectSortMode mode = InsectBrowseSort.Order[i]; - Rect chip = new Rect(px + 20f + i * (chipW + 6f), chipY, chipW, chipH); - GUI.backgroundColor = pickerSort == mode ? ChangeBg : SlotBg; - if (GUI.Button(chip, InsectBrowseSort.Label(mode), pickerBtnCache) && pickerSort != mode) + Rect chip = new Rect(px + 24f + i * (chipW + chipGap), chipY, chipW, chipH); + if (UISurface.Button(chip, InsectBrowseSort.Label(mode), t.surfaceRaised, chipButtonCache, pickerSort == mode) + && pickerSort != mode) { pickerSort = mode; pickerSortDirty = true; @@ -353,16 +364,15 @@ private void DrawInsectPicker() directScroll.Reset(); } } - GUI.backgroundColor = Color.white; EnsurePickerSorted(); List owned = pickerSorted; - float listY = chipY + chipH + 8f; - float listH = panelH - (listY - py) - 10f; - float itemH = UIScale.IsMobileLayout ? 132f : 116f; + float listY = chipY + chipH + 14f; + float listH = Mathf.Max(1f, panelH - (listY - py) - 16f); + float itemH = UIScale.IsMobileLayout ? 168f : 130f; float totalH = owned.Count * itemH; - Rect listArea = new Rect(px + 10, listY, panelW - 20, listH); - Rect viewRect = new Rect(0, 0, listArea.width, totalH); + Rect listArea = new Rect(px + 16f, listY, panelW - 32f, listH); + Rect viewRect = new Rect(0, 0, listArea.width - 12f, totalH); directScroll.Handle(ref listScroll, listArea, totalH, itemH * 0.35f); listScroll = GUI.BeginScrollView( @@ -375,7 +385,7 @@ private void DrawInsectPicker() // 캐시가 한 뷰포트 분량이라, 전 개체를 매 패스 훑으면 LRU가 안정되지 않아 렌더러가 // 프레임마다 곤충 모델을 만들었다 부순다(2026-08-06 audit, 도감·훈련과 같은 결함). DexBrowseLayout.GetVisibleRowRange( - listScroll.y, listArea.height, itemH - 3f, 3f, owned.Count, + listScroll.y, listArea.height, itemH - 8f, 8f, owned.Count, out int firstVisible, out int lastVisible); for (int i = firstVisible; i <= lastVisible; i++) @@ -383,51 +393,49 @@ private void DrawInsectPicker() PlayerInsectData pid = owned[i]; InsectData data = collection.GetInsectData(pid.insectId); bool alreadyInTeam = teamManager != null && teamManager.IsInTeam(pid.instanceId); - DrawPickerItem(new Rect(0, i * itemH, viewRect.width, itemH - 3), pid, data, alreadyInTeam); + DrawPickerItem(new Rect(4f, i * itemH, viewRect.width - 4f, itemH - 8f), pid, data, alreadyInTeam); } GUI.EndScrollView(); + UISurface.ScrollAffordance(listArea, listScroll, totalH, t.accentCoral); } private void DrawPickerItem(Rect rect, PlayerInsectData pid, InsectData data, bool alreadyInTeam) { - UISurface.Card( - rect, - alreadyInTeam ? PickerItemDisabledBg : PickerItemActiveBg, - UITheme.Instance.surfaceBorder); + UITheme t = UITheme.Instance; + UISurface.Card(rect, alreadyInTeam ? t.surfaceBase : t.surfaceRaised, t.surfaceBorder); + + float thumb = Mathf.Min(rect.height - 20f, UIScale.IsMobileLayout ? 140f : 104f); + Rect frame = new Rect(rect.x + 20f, rect.y + (rect.height - thumb) / 2f, thumb, thumb); + float textX = frame.xMax + 20f; + float textW = rect.xMax - 200f - textX; if (data != null) { - Color rarityCol = UITheme.Instance.GetInsectRarityColor(data.rarity); - // alreadyInTeam이면 alpha/채도 낮춤 (struct copy + 필드 갱신) - Color railCol = rarityCol; - if (alreadyInTeam) { railCol.r *= 0.4f; railCol.g *= 0.4f; railCol.b *= 0.4f; } + Color rarityCol = t.GetInsectRarityColor(data.rarity); + // 이미 팀에 있으면 레일을 어둡게 — 고를 수 없는 줄이 한눈에 가라앉는다. UISurface.Flat( new Rect(rect.x + 3f, rect.y + 3f + UITheme.Radius.Card, 5f, Mathf.Max(4f, rect.height - 6f - UITheme.Radius.Card * 2f)), - railCol); - - InsectVisual.Draw(rect.x + 48, rect.y + rect.height / 2f, 96f, data, pid != null && pid.isShiny, alreadyInTeam ? 0.4f : 1f); + alreadyInTeam ? Color.Lerp(rarityCol, t.surfaceBase, 0.6f) : rarityCol); - pickerNameCache.normal.textColor = alreadyInTeam ? DimNameCol : rarityCol; - GUI.color = Color.white; - UIHelper.LabelFit( - new Rect(rect.x + 100, rect.y + 14, rect.width - 280, 42), - GetOwnedDisplayName(pid, data), pickerNameCache); + UISurface.Rounded(frame, Color.Lerp(t.surfaceBase, rarityCol, alreadyInTeam ? 0.05f : 0.14f)); + InsectVisual.Draw(frame.center.x, frame.center.y, thumb * 0.94f, data, pid != null && pid.isShiny, + alreadyInTeam ? 0.4f : 1f); - int cp = PlayerInsectCombatPower.Calculate(data, pid); - GUI.Label(new Rect(rect.x + 100, rect.y + 62, rect.width - 280, 40), - $"Lv.{pid.level} | CP {cp}", pickerInfoCache); + DrawInsectLines(textX, rect.y + rect.height * 0.5f, textW, pid, data, slotNameCache); } if (alreadyInTeam) { - GUI.Label(new Rect(rect.x + rect.width - 200, rect.y + rect.height / 2f - 21, 190, 42), "팀에 있음", pickerTagCache); + UISurface.Chip(new Rect(rect.xMax - 180f, rect.y + rect.height / 2f - 20f, 160f, 40f), "팀에 있음", + t.surfaceRaised, t.textSecondary); } else { - GUI.backgroundColor = BtnBg; - float selectH = SkillUILayout.GetTouchHeight(UIScale.IsMobileLayout, 52f, 64f); - if (GUI.Button(new Rect(rect.x + rect.width - 170, rect.y + rect.height / 2f - selectH * 0.5f, 150, selectH), "선택", pickerBtnCache)) + float selectH = SkillUILayout.GetTouchHeight(UIScale.IsMobileLayout, 56f, 64f); + if (UISurface.Button(new Rect(rect.xMax - 180f, rect.y + rect.height / 2f - selectH * 0.5f, 160f, selectH), + "선택", t.btnPrimary, buttonCache) + && !directScroll.IsDragging) { if (teamManager != null) { @@ -436,7 +444,6 @@ private void DrawPickerItem(Rect rect, PlayerInsectData pid, InsectData data, bo directScroll.Reset(); } } - GUI.backgroundColor = Color.white; } } diff --git a/Assets/Scripts/UI/CaptureChoiceUI.cs b/Assets/Scripts/UI/CaptureChoiceUI.cs index 24ecad0..ea7a0f3 100644 --- a/Assets/Scripts/UI/CaptureChoiceUI.cs +++ b/Assets/Scripts/UI/CaptureChoiceUI.cs @@ -26,7 +26,12 @@ public class CaptureChoiceUI : MonoBehaviour, IModalUI private bool isOpen; public bool IsOpen => isOpen; - public void CloseModal() { Hide(); } + + /// + /// 모달 닫기(ESC — PlayerMovement·ModalUIRegistry.HandleEscape). 습격 창에서는 그냥 닫지 않고 도망 판정으로 보낸다 + /// () — 닫기가 도망을 우회하면 습격이 무의미해진다. + /// + public void CloseModal() { RequestClose(); } private InsectEntity targetInsect; #pragma warning disable 0414 private int selectedTeamSlot = -1; @@ -47,6 +52,10 @@ public void ShowChoice(InsectEntity target) targetInsect = target; target.SetEngaged(true); // 포획 상호작용 중 — 곤충 도주 방지 isOpen = true; + ambushMode = false; // 습격 창은 ShowAmbush가 이 위에 켠다 + openedFrame = Time.frameCount; + openKeyHeld = Input.GetKey(KeyCode.E); + EnsureLastNetLoaded(); // 창을 열 때 한 번 — 그리는 쪽(OnGUI 패스마다)에서 키 문자열을 만들지 않는다 selectedTeamSlot = -1; showTeamSelect = false; showItemSelect = false; @@ -61,10 +70,26 @@ public void Hide() targetInsect = null; showTeamSelect = false; showItemSelect = false; + ambushMode = false; ModalUIRegistry.Unregister(this); if (playerMovement != null) playerMovement.SetFrozen(false); } + /// + /// 닫기 요청 — ESC가 오는 길(이 창의 키 처리 · CaptureInputController · PlayerMovement의 모달 ESC)이 전부 여기로 모인다. + /// 보통 창은 닫고, 습격 창은 도망 판정(팀 고르기 중이면 습격 창으로 돌아가기)으로 보낸다. + /// + public void RequestClose() + { + if (!isOpen) return; + if (!ambushMode) + { + Hide(); + return; + } + HandleAmbushEscapeKey(); + } + private void OnDisable() { ModalUIRegistry.Unregister(this); } private void Update() @@ -77,12 +102,40 @@ private void Update() return; } + // 도망에 실패했다 — 「도망치지 못했다!」를 잠깐 보여 준 뒤 싸움으로 넘어간다(한 번만. 팀 고르기에서 ‹ 뒤로를 누르면 [싸우기]만 남은 창이다). + if (ambushMode && ambushEscapeFailed && !ambushAutoFought && !showTeamSelect + && Time.unscaledTime - ambushFailAt >= AmbushRules.EscapeFailPauseSeconds) + { + ambushAutoFought = true; + BeginAmbushFight(); + if (!isOpen) return; + } + HandleInputUpdate(); } + // 이 창을 연 프레임. 필드에서 [E]로 곤충에게 말을 걸면 **그 같은 누름**이 이 창의 [E](미니게임 포획)로도 + // 읽힌다 — 예전엔 채집망 선택 창이 한 겹 더 있어 티가 안 났지만, 이제 [E]가 곧바로 채집망을 쓰고 + // 미니게임을 열므로 포획/배틀을 고를 틈도 없이 넘어가 버린다. 연 직후의 키는 받지 않는다. + private int openedFrame = -1; + // 창을 연 [E]를 아직 누르고 있는가. IMGUI는 누르고 있는 키의 KeyDown을 되풀이해 보내므로(키 반복) + // 프레임 수만으로는 못 막는다 — 손을 뗄 때까지 [E]는 받지 않는다. + private bool openKeyHeld; + private void HandleInput(KeyCode key) { if (!isOpen) return; + if (Time.frameCount <= openedFrame + 1) return; + if (ambushMode) + { + HandleAmbushInput(key); + return; + } + if (openKeyHeld) + { + if (Input.GetKey(KeyCode.E)) { if (key == KeyCode.E) return; } + else openKeyHeld = false; + } if (showTeamSelect) { @@ -102,7 +155,7 @@ private void HandleInput(KeyCode key) { // 버튼과 같은 조건 — 수문장 포획은 키로도 우회할 수 없다(잡히면 리전 영구 잠김). if (HasAnyCaptureItem() && !isGuardian) - showItemSelect = true; + BeginQuickCapture(); } if (!isRaid && (key == KeyCode.B || key == KeyCode.Alpha2)) { @@ -122,14 +175,17 @@ private void HandleInput(KeyCode key) } } + // 매 프레임 새 배열을 만들지 않는다. F는 습격 창의 [도망치기]다(보통 창에서는 아무 일도 하지 않는다). + private static readonly KeyCode[] InputKeys = { KeyCode.E, KeyCode.B, KeyCode.R, KeyCode.F, KeyCode.Escape, + KeyCode.Backspace, KeyCode.Alpha1, KeyCode.Alpha2, KeyCode.Alpha3 }; + private void HandleInputUpdate() { - KeyCode[] keys = { KeyCode.E, KeyCode.B, KeyCode.R, KeyCode.Escape, KeyCode.Backspace, - KeyCode.Alpha1, KeyCode.Alpha2, KeyCode.Alpha3 }; - foreach (var k in keys) + for (int i = 0; i < InputKeys.Length; i++) { - if (Input.GetKeyDown(k)) - HandleInput(k); + if (!isOpen) return; // 앞 키가 창을 닫았다(싸우기·도망 성공) + if (Input.GetKeyDown(InputKeys[i])) + HandleInput(InputKeys[i]); } } @@ -144,91 +200,156 @@ private void OnGUI() evt.Use(); } + // 키 처리가 창을 닫았을 수 있다(싸우기 → 전투, 도망 성공) — 닫힌 창을 이번 패스에 그리지 않는다. + if (!isOpen || targetInsect == null) return; + UIScale.Begin(); if (showTeamSelect) DrawTeamSelect(); else if (showItemSelect) DrawItemSelect(); + else if (ambushMode) + DrawAmbush(); else DrawChoice(); UIScale.End(); } + /// + /// 레이드로만 맞설 수 있는 등급(영웅·전설) — 포획 선택 창의 분기와 습격 판정(CaptureInputController의 「싸울 수 있나」)이 + /// 같은 답을 읽는다. 두 곳이 따로 적으면 습격 창이 막힌 버튼으로 뜬다. + /// + public static bool IsRaidRarity(InsectRarity rarity) + { + return rarity == InsectRarity.Epic || rarity == InsectRarity.Legendary; + } + private bool IsRaidTarget() { - return targetInsect != null && targetInsect.Data != null && - (targetInsect.Data.rarity == InsectRarity.Epic || targetInsect.Data.rarity == InsectRarity.Legendary); + return targetInsect != null && targetInsect.Data != null && IsRaidRarity(targetInsect.Data.rarity); + } + + // ── 스타일 캐시 ── + // 예전엔 세 화면이 OnGUI 패스마다 GUIStyle을 20개 가까이 새로 만들었다(패스는 프레임당 여러 번이다). + // 한 번만 만들고 동적인 것(글자색)만 매번 바꾼다(BattleScreenUI·BattleTeamUI와 같은 패턴). + private GUIStyle titleStyle, subStyle, nameStyle, buttonStyle, cancelStyle, hintStyle; + private GUIStyle raidHintStyle, raidDescStyle, itemNameStyle, itemDescStyle, countStyle; + private GUIStyle slotNumStyle, slotNameStyle, slotInfoStyle, netRowStyle; + private GUIStyle ambushTitleStyle, ambushFailStyle; + private bool stylesReady; + + private void InitStyles() + { + if (stylesReady) return; + stylesReady = true; + UITheme t = UITheme.Instance; + + titleStyle = new GUIStyle(GUI.skin.label) { fontSize = 36, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + titleStyle.normal.textColor = t.textPrimary; + subStyle = new GUIStyle(GUI.skin.label) { fontSize = 26, alignment = TextAnchor.MiddleCenter }; + subStyle.normal.textColor = t.textSecondary; + nameStyle = new GUIStyle(GUI.skin.label) { fontSize = 40, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + buttonStyle = new GUIStyle(GUI.skin.label) { fontSize = 32, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, richText = true }; + buttonStyle.normal.textColor = t.textPrimary; + cancelStyle = new GUIStyle(buttonStyle) { fontSize = 26 }; + netRowStyle = new GUIStyle(GUI.skin.label) { fontSize = 24, alignment = TextAnchor.MiddleCenter }; + netRowStyle.normal.textColor = t.textSecondary; + hintStyle = new GUIStyle(GUI.skin.label) { fontSize = 24, alignment = TextAnchor.MiddleCenter, wordWrap = true }; + raidHintStyle = new GUIStyle(GUI.skin.label) { fontSize = 28, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + raidHintStyle.normal.textColor = t.accentCoral; + raidDescStyle = new GUIStyle(GUI.skin.label) { fontSize = 23, alignment = TextAnchor.MiddleCenter }; + raidDescStyle.normal.textColor = t.textSecondary; + itemNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 32, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft, richText = true }; + itemDescStyle = new GUIStyle(GUI.skin.label) { fontSize = 23, alignment = TextAnchor.MiddleLeft }; + itemDescStyle.normal.textColor = t.textSecondary; + countStyle = new GUIStyle(GUI.skin.label) { fontSize = 32, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleRight }; + slotNumStyle = new GUIStyle(GUI.skin.label) { fontSize = 32, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + slotNumStyle.normal.textColor = t.textMuted; + slotNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + slotInfoStyle = new GUIStyle(GUI.skin.label) { fontSize = 23, alignment = TextAnchor.MiddleLeft }; + slotInfoStyle.normal.textColor = t.textSecondary; + // 습격 창 — 머리말과 도망 실패 줄은 경고색(헤더·활성 탭에 쓰는 코랄 토큰)이다. + ambushTitleStyle = new GUIStyle(GUI.skin.label) { fontSize = 44, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + ambushTitleStyle.normal.textColor = t.accentCoral; + ambushFailStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + ambushFailStyle.normal.textColor = t.accentCoral; + } + + /// 데스크톱에서만 단축키를 작게 붙인다 — 모바일엔 키보드가 없다. + private static string WithKey(string label, string key) + { + return UIScale.IsMobileLayout ? label : $"{label} [{key}]"; + } + + /// 모달 공통 바탕 — 가벼운 딤 + 등급색이 섞인 테두리 카드 + 윗줄 액센트. + private static void DrawPanel(Rect panel, Color accent) + { + UITheme t = UITheme.Instance; + UISurface.Dim(0.35f); + UISurface.Card(panel, t.surfaceCard, Color.Lerp(t.surfaceBorder, accent, 0.55f)); + UISurface.Flat(new Rect(panel.x + UITheme.Radius.Card, panel.y + 3f, + panel.width - UITheme.Radius.Card * 2f, 4f), accent); } private void DrawChoice() { if (targetInsect == null || targetInsect.Data == null) return; + InitStyles(); + UITheme t = UITheme.Instance; bool isRaid = IsRaidTarget(); - Rect panel = UISafeLayout.CenteredPanel(760f, isRaid ? 560f : 480f); + bool mobile = UIScale.IsMobileLayout; + // 레벨 차 경고 — 포획 공식(TrainerLevelGap)과 같은 캐릭터 레벨·같은 판정으로 띄운다. + // 레이드는 승리 시 확정 포획이라, 수문장은 애초에 포획 대상이 아니라 제외한다. + int trainerLevel = captureController != null ? captureController.TrainerLevel : 1; + bool levelWarn = !isRaid && !targetInsect.IsGuardian + && TrainerLevelGap.IsCaptureRestricted(trainerLevel, targetInsect.Level); + const float levelWarnH = 44f; + // 포획 버튼 아래의 채집망 한 줄(NetRowH)만큼 일반 창이 길다 — 레이드 창에는 그 줄이 없다. + Rect panel = UISafeLayout.CenteredPanel(820f, + isRaid ? 730f : 620f + NetRowH + 8f + (levelWarn ? levelWarnH : 0f)); float panelW = panel.width; float panelH = panel.height; float px = panel.x; float py = panel.y; - GUI.color = new Color(0.04f, 0.06f, 0.1f, 0.95f); - GUI.DrawTexture(new Rect(px, py, panelW, panelH), Texture2D.whiteTexture); - - Color rarityCol = UITheme.Instance.GetInsectRarityColor(targetInsect.Data.rarity); - GUI.color = rarityCol; - GUI.DrawTexture(new Rect(px, py, panelW, 4), Texture2D.whiteTexture); - - GUIStyle titleStyle = new GUIStyle(GUI.skin.label) - { fontSize = 36, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - titleStyle.normal.textColor = Color.white; + Color rarityCol = t.GetInsectRarityColor(targetInsect.Data.rarity); + DrawPanel(panel, rarityCol); GUI.color = Color.white; string titleText = isRaid ? "레이드 보스 발견!" : "어떻게 포획할까요?"; - GUI.Label(new Rect(px, py + 20, panelW, 48), titleText, titleStyle); + GUI.Label(new Rect(px, py + 20f, panelW, 50f), titleText, titleStyle); - InsectVisual.Draw(px + panelW / 2f, py + 120, 96f, targetInsect.Data, targetInsect.IsShiny, 1f); + // 미리보기 — 예전엔 96px 썸네일이라 무엇을 만났는지 알아보기 어려웠다. + Rect frame = new Rect(px + panelW / 2f - 100f, py + 80f, 200f, 200f); + UISurface.Rounded(frame, Color.Lerp(t.surfaceBase, rarityCol, 0.14f)); + InsectVisual.Draw(frame.center.x, frame.center.y, 188f, targetInsect.Data, targetInsect.IsShiny, 1f); - GUIStyle nameStyle = new GUIStyle(GUI.skin.label) - { fontSize = 38, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; nameStyle.normal.textColor = rarityCol; // 「지워진 개체」면 본명 대신 "???" — 월드에서 실루엣·"???"로 감춰 놓고 이 창이 // 바로 알려주면 연출이 무의미해진다(`InsectEntity.DisplayNameForPlayer`가 단일 출처). - UIHelper.LabelFit(new Rect(px, py + 160, panelW, 48), - $"{targetInsect.DisplayNameForPlayer} Lv.{targetInsect.Level}", nameStyle); + UIHelper.LabelFit(new Rect(px + 20f, py + 288f, panelW - 40f, 50f), + $"{targetInsect.DisplayNameForPlayer} Lv.{targetInsect.Level}", nameStyle); - if (isRaid) - { - GUIStyle raidHint = new GUIStyle(GUI.skin.label) - { fontSize = 28, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - raidHint.normal.textColor = new Color(1f, 0.4f, 0.15f); - GUI.Label(new Rect(px, py + 210, panelW, 36), - "RAID BOSS - 레이드로만 포획 가능!", raidHint); - - GUIStyle raidDesc = new GUIStyle(GUI.skin.label) - { fontSize = 22, alignment = TextAnchor.MiddleCenter }; - raidDesc.normal.textColor = new Color(0.6f, 0.6f, 0.6f); - GUI.Label(new Rect(px, py + 244, panelW, 30), - "5마리 팀 전체가 협력하여 보스에 도전합니다", raidDesc); - } - - GUIStyle btnStyle = new GUIStyle(GUI.skin.button) - { fontSize = 32, fontStyle = FontStyle.Bold }; + // 배지 — 등급(한글) · 색다름 · 수문장. 가운데 정렬로 늘어놓는다. + DrawChoiceChips(px + panelW / 2f, py + 344f, rarityCol); if (isRaid) { - float raidBtnW = 340f; - float raidBtnH = 90f; - float raidBtnX = px + (panelW - raidBtnW) / 2f; - float raidBtnY = py + 290; + GUI.Label(new Rect(px, py + 392f, panelW, 36f), "레이드로만 포획할 수 있어요", raidHintStyle); + GUI.Label(new Rect(px, py + 428f, panelW, 32f), "5마리 팀 전체가 힘을 합쳐 보스에 도전합니다", raidDescStyle); + + float raidBtnW = 380f; + float raidBtnH = 88f; + float raidBtnY = py + 474f; + Rect raidBtn = new Rect(px + (panelW - raidBtnW) / 2f, raidBtnY, raidBtnW, raidBtnH); bool hasFullTeam = teamManager != null && teamManager.FilledSlots >= 5; // 전원 기절이면 시작해도 아무도 행동할 수 없어 레이드가 잠긴다(StartRaidBattle 주석 참조). int readyCount = CountBattleReadyTeamMembers(); bool canRaid = hasFullTeam && readyCount > 0; - GUI.backgroundColor = canRaid ? new Color(0.7f, 0.2f, 0.05f) : new Color(0.3f, 0.3f, 0.3f); GUI.enabled = canRaid; - string raidButtonText = UIScale.IsMobileLayout ? "레이드 시작" : "레이드 시작 [R]"; - if (GUI.Button(new Rect(raidBtnX, raidBtnY, raidBtnW, raidBtnH), raidButtonText, btnStyle)) + if (UISurface.Button(raidBtn, WithKey("레이드 시작", "R"), canRaid ? t.btnDanger : t.btnDisabled, buttonStyle)) { StartRaidBattle(); } @@ -236,13 +357,13 @@ private void DrawChoice() // 안내는 한 줄만 — 편성 부족 > 전원 기절 > 일부 기절 순으로 더 급한 것을 보여준다. string raidNotice = null; - Color raidNoticeCol = new Color(1f, 0.4f, 0.15f); + Color raidNoticeCol = t.accentCoral; if (!hasFullTeam) { int filled = teamManager != null ? teamManager.FilledSlots : 0; - raidNotice = UIScale.IsMobileLayout + raidNotice = mobile ? $"팀 편성 필요 ({filled}/5) · 메뉴에서 배틀팀 편성" - : $"팀 편성 필요 ({filled}/5) T키로 편성"; + : $"팀 편성 필요 ({filled}/5) · T키로 편성"; } else if (readyCount <= 0) { @@ -251,26 +372,33 @@ private void DrawChoice() else if (readyCount < BattleTeamManager.MaxSlots) { raidNotice = $"기절 {BattleTeamManager.MaxSlots - readyCount}마리 · 전투 가능 {readyCount}/{BattleTeamManager.MaxSlots}"; - raidNoticeCol = new Color(1f, 0.75f, 0.25f); + raidNoticeCol = t.accentAmber; } if (raidNotice != null) { - GUIStyle raidReq = new GUIStyle(GUI.skin.label) - { fontSize = 26, alignment = TextAnchor.UpperCenter, wordWrap = true }; - raidReq.normal.textColor = raidNoticeCol; - // 버튼 폭(340)이 아니라 패널 폭을 쓴다 — "팀 전원이 기절했습니다 …" 같은 한 줄은 - // 340px에 못 들어가고, 한국어 폰트가 커지는 모바일에서 특히 잘렸다. + hintStyle.normal.textColor = raidNoticeCol; + // 버튼 폭이 아니라 패널 폭을 쓴다 — "팀 전원이 기절했습니다 …" 같은 한 줄은 + // 버튼 폭에 못 들어가고, 한국어 폰트가 커지는 모바일에서 특히 잘렸다. // 2줄 높이를 주고 그래도 넘치면 LabelFit이 글자를 줄여 맞춘다(ui-layout.md). - UIHelper.LabelFit(new Rect(px + 20f, raidBtnY + raidBtnH + 10f, panelW - 40f, 68f), - raidNotice, raidReq); + UIHelper.LabelFit(new Rect(px + 24f, raidBtnY + raidBtnH + 8f, panelW - 48f, 60f), + raidNotice, hintStyle); } } else { - float btnY = py + 230; - float btnW = 280f; - float btnH = 80f; + float btnY = py + 400f; + if (levelWarn) + { + // 버튼 위 한 줄 — 패널을 그만큼 키웠으므로 버튼만 내리면 아래(힌트·취소)가 겹치지 않는다. + hintStyle.normal.textColor = t.accentCoral; + int gapLv = TrainerLevelGap.Gap(trainerLevel, targetInsect.Level); + UIHelper.LabelFit(new Rect(px + 24f, btnY - 6f, panelW - 48f, 40f), + $"캐릭터보다 {gapLv}레벨 높아 포획이 매우 어려워요", hintStyle); + btnY += levelWarnH; + } + float btnW = 330f; + float btnH = 88f; float gap = 24f; float leftX = px + panelW / 2f - btnW - gap / 2f; @@ -279,30 +407,42 @@ private void DrawChoice() bool isGuardian = targetInsect.IsGuardian; bool hasAnyNet = HasAnyCaptureItem(); bool canCapture = hasAnyNet && !isGuardian; - GUI.backgroundColor = canCapture ? new Color(0.2f, 0.5f, 0.3f) : new Color(0.3f, 0.3f, 0.3f); GUI.enabled = canCapture; - string minigameText = UIScale.IsMobileLayout ? "미니게임 포획" : "미니게임 [E]"; - if (GUI.Button(new Rect(leftX, btnY, btnW, btnH), minigameText, btnStyle)) + if (UISurface.Button(new Rect(leftX, btnY, btnW, btnH), WithKey("미니게임 포획", "E"), + canCapture ? t.btnPrimary : t.btnDisabled, buttonStyle)) { - showItemSelect = true; + BeginQuickCapture(); } GUI.enabled = true; if (!canCapture) { - GUIStyle noNet = new GUIStyle(GUI.skin.label) - { fontSize = 24, alignment = TextAnchor.MiddleCenter }; - noNet.normal.textColor = new Color(1f, 0.4f, 0.3f); + hintStyle.normal.textColor = t.accentCoral; // 고정 상자에 리터럴을 그리므로 LabelFit — 수문장 문구가 더 길어 잘릴 수 있다. - UIHelper.LabelFit(new Rect(leftX, btnY + btnH + 6, btnW, 30), - isGuardian ? "수문장은 쓰러뜨려야 한다" : "포획 아이템 없음!", noNet); + UIHelper.LabelFit(new Rect(leftX, btnY + btnH + 6f, btnW, 32f), + isGuardian ? "수문장은 쓰러뜨려야 한다" : "포획 아이템 없음!", hintStyle); + } + else + { + // 이번에 쓸 채집망 — 누르면 고르는 창이 뜬다. 예전엔 포획할 때마다 그 창을 거쳤다. + CaptureItemData quick = ResolveQuickNet(); + if (quick != null) + { + Rect netRow = new Rect(leftX, btnY + btnH + 8f, btnW, NetRowH); + if (UISurface.Button(netRow, string.Empty, t.surfaceRaised, netRowStyle)) + showItemSelect = true; + Rect swatch = new Rect(netRow.x + 14f, netRow.y + (NetRowH - 24f) * 0.5f, 24f, 24f); + UISurface.Rounded(swatch, quick.themeColor, UITheme.Radius.Chip); + UIHelper.LabelFit(new Rect(swatch.xMax + 8f, netRow.y, netRow.xMax - swatch.xMax - 20f, NetRowH), + NetRowLabel(quick), netRowStyle); + } } bool hasTeam = teamManager != null && teamManager.HasAnyInsect(); - GUI.backgroundColor = hasTeam ? new Color(0.5f, 0.2f, 0.2f) : new Color(0.3f, 0.3f, 0.3f); + float rightX = leftX + btnW + gap; GUI.enabled = hasTeam; - string battleText = UIScale.IsMobileLayout ? "배틀" : "배틀 [B]"; - if (GUI.Button(new Rect(leftX + btnW + gap, btnY, btnW, btnH), battleText, btnStyle)) + if (UISurface.Button(new Rect(rightX, btnY, btnW, btnH), WithKey("배틀", "B"), + hasTeam ? t.btnDanger : t.btnDisabled, buttonStyle)) { showTeamSelect = true; } @@ -310,112 +450,335 @@ private void DrawChoice() if (!hasTeam) { - GUIStyle hintStyle = new GUIStyle(GUI.skin.label) - { fontSize = 24, alignment = TextAnchor.MiddleCenter }; - hintStyle.normal.textColor = new Color(0.5f, 0.4f, 0.3f); - GUI.Label(new Rect(leftX + btnW + gap, btnY + btnH + 6, btnW, 30), - UIScale.IsMobileLayout ? "메뉴에서 배틀팀 편성" : "T키로 팀 편성", hintStyle); + hintStyle.normal.textColor = t.textMuted; + UIHelper.LabelFit(new Rect(rightX, btnY + btnH + 6f, btnW, 32f), + mobile ? "메뉴에서 배틀팀 편성" : "T키로 팀 편성", hintStyle); } } - GUI.backgroundColor = new Color(0.3f, 0.3f, 0.35f); - GUIStyle cancelStyle = new GUIStyle(GUI.skin.button) { fontSize = 26, fontStyle = FontStyle.Bold }; - float cancelW = UIScale.IsMobileLayout ? 190f : 140f; - float cancelH = UIScale.IsMobileLayout ? 60f : 50f; - string cancelText = UIScale.IsMobileLayout ? "취소" : "취소 [ESC]"; - if (GUI.Button(new Rect(px + panelW / 2f - cancelW / 2f, py + panelH - cancelH - 18f, - cancelW, cancelH), cancelText, cancelStyle)) + float cancelW = mobile ? 220f : 200f; + float cancelH = mobile ? 64f : 56f; + if (UISurface.Button(new Rect(px + panelW / 2f - cancelW / 2f, py + panelH - cancelH - 18f, cancelW, cancelH), + WithKey("취소", "ESC"), t.surfaceRaised, cancelStyle)) Hide(); - GUI.backgroundColor = Color.white; + } + + /// 등급(한글)·색다름·수문장 배지를 가운데 정렬로 한 줄에 놓는다. + private void DrawChoiceChips(float centerX, float y, Color rarityCol) + { + UITheme t = UITheme.Instance; + const float h = 36f; + const float gap = 10f; + const float rarityW = 96f, shinyW = 150f, guardianW = 104f; + bool shiny = targetInsect.IsShiny; + bool guardian = targetInsect.IsGuardian; + float total = rarityW + (shiny ? gap + shinyW : 0f) + (guardian ? gap + guardianW : 0f); + float x = centerX - total / 2f; + + UISurface.Chip(new Rect(x, y, rarityW, h), targetInsect.Data.rarity.Korean(), + Color.Lerp(rarityCol, Color.black, 0.35f), t.textPrimary); + x += rarityW + gap; + if (shiny) + { + UISurface.Chip(new Rect(x, y, shinyW, h), "★ 색다른 개체", t.accentAmber, t.surfaceBase); + x += shinyW + gap; + } + if (guardian) + UISurface.Chip(new Rect(x, y, guardianW, h), "수문장", t.accentCoral, t.textPrimary); + } + + // ── 습격 창 ───────────────────────────────────────────────────────── + // 깨어 있는 습격형 곤충이 닿으면 CaptureInputController가 연다(규칙은 AmbushRules). 고를 것은 [싸우기]와 [도망치기]뿐이다. + // · 포획 버튼이 없다 — 덤벼드는 곤충을 미니게임으로 잡는 건 이상하다. 이기면 전투의 포획 기회가 있다. + // · 그냥 닫는 길이 없다 — ESC는 도망치기다(RequestClose). 닫기가 도망 판정을 우회하면 습격이 무의미해진다. + // · 도망은 전투의 도주 확률(BattleEscapeRules — 선두 출전 곤충 레벨 대 상대 레벨)을 한 번만 굴리고, 실패하면 싸운다. + // · 싸우기는 기존 [B] 배틀 경로(출전할 곤충 고르기)다. 영웅·전설은 기존처럼 레이드로 맞선다(IsRaidRarity). + + private bool ambushMode; + private string ambushReason; + private string ambushNameLabel; + private string ambushFightLabel; + private string ambushEscapeLabel; + private string ambushChanceLabel; + private float ambushOpenedAt; + private bool ambushEscapeTried; + private bool ambushEscapeFailed; + private bool ambushAutoFought; + private float ambushFailAt; + private int ambushLeadLevel = 1; + private int ambushActionFrame = -1; + private const float AmbushPanelH = 640f; + + /// 습격 창이 열려 있는가(팀 고르기로 넘어간 동안도 참이다). + public bool IsAmbushOpen => isOpen && ambushMode; + + /// + /// 「습격!」 창을 연다. 싸울 수 없으면 열지 않는다(false) — 이 창엔 그냥 닫는 길이 없어서 싸울 수 없는 창은 갇힌 창이 된다 + /// (판정 쪽 AmbushRules.CanFight도 같은 조건으로 습격 자체를 막는다). 이미 다른 창이 열려 있어도 false. + /// + /// "왜 덤벼들었나" 한 줄(AmbushRules.ReasonLine). 비면 기본 문구. + public bool ShowAmbush(InsectEntity target, string reason) + { + if (isOpen || target == null || target.Data == null) return false; + if (!CanFight(target)) return false; + + ShowChoice(target); + if (!isOpen) return false; + ambushMode = true; + ambushReason = string.IsNullOrEmpty(reason) ? AmbushRules.FallbackReason : reason; + ambushOpenedAt = Time.unscaledTime; + ambushEscapeTried = false; + ambushEscapeFailed = false; + ambushAutoFought = false; + ambushFailAt = 0f; + ambushActionFrame = -1; + ambushLeadLevel = LeadFighterLevel(); + // 문구는 창을 열 때 한 번 만든다 — OnGUI 패스마다 이어 붙이지 않는다. + ambushNameLabel = $"{target.DisplayNameForPlayer} Lv.{target.Level}"; + ambushFightLabel = WithKey(IsRaidTarget() ? "레이드로 맞서기" : "싸우기", "B"); + ambushEscapeLabel = WithKey("도망치기", "F/ESC"); + int percent = Mathf.RoundToInt(BattleEscapeRules.Chance(ambushLeadLevel, target.Level) * 100f); + ambushChanceLabel = $"도망칠 확률 {percent}%"; + return true; + } + + /// + /// 이 곤충과 지금 싸울 수 있는가 — 1v1이면 기절 안 한 출전 곤충, 레이드 대상이면 5마리 팀(그중 하나라도 싸울 수 있게). + /// 습격 판정(CaptureInputController)과 같은 술어(AmbushRules.CanFight)다. + /// + public bool CanFight(InsectEntity target) + { + if (target == null || target.Data == null) return false; + GetFightReadiness(out int filled, out int ready); + return AmbushRules.CanFight(IsRaidRarity(target.Data.rarity), filled, ready); + } + + /// 배틀팀 상태 — 채운 칸 수와 그중 지금 싸울 수 있는(기절 안 한) 곤충 수. 습격 판정이 프레임마다 한 번 읽는다. + public void GetFightReadiness(out int filledSlots, out int readySlots) + { + filledSlots = teamManager != null ? teamManager.FilledSlots : 0; + readySlots = CountBattleReadyTeamMembers(); + } + + /// 창이 뜬 직후에는 입력을 받지 않는다 — 걷던 손가락·누르던 키가 그대로 버튼을 누르지 않게. + private bool AmbushInputReady => Time.unscaledTime - ambushOpenedAt >= AmbushRules.InputDelaySeconds; + + /// + /// 한 프레임에 한 동작만. 같은 누름이 여러 길로 온다(이 창의 Update·OnGUI, CaptureInputController, PlayerMovement의 모달 ESC) — + /// 안 막으면 팀 고르기에서 ESC 한 번이 ‹ 뒤로와 도망치기를 함께 일으킨다. + /// + private bool ConsumeAmbushAction() + { + if (ambushActionFrame == Time.frameCount) return false; + ambushActionFrame = Time.frameCount; + return true; + } + + private void HandleAmbushInput(KeyCode key) + { + if (key == KeyCode.Escape || key == KeyCode.Backspace) + { + HandleAmbushEscapeKey(); + return; + } + if (showTeamSelect) return; // 출전 곤충은 버튼으로 고른다(보통 창과 같다) + + bool isRaid = IsRaidTarget(); + if (key == KeyCode.B || key == KeyCode.Alpha1 || (isRaid && key == KeyCode.R)) + { + if (AmbushInputReady && ConsumeAmbushAction()) BeginAmbushFight(); + } + else if (key == KeyCode.F || key == KeyCode.Alpha2) + { + if (AmbushInputReady && ConsumeAmbushAction()) TryAmbushEscape(); + } + } + + /// ESC — 팀 고르기 중이면 습격 창으로 돌아가고, 아니면 도망치기다. 그냥 닫는 길은 없다. + private void HandleAmbushEscapeKey() + { + if (!ConsumeAmbushAction()) return; + if (showTeamSelect) + { + showTeamSelect = false; + return; + } + if (!AmbushInputReady) return; + TryAmbushEscape(); + } + + /// + /// 도망치기 — 한 번만 굴린다. 성공하면 창을 닫고(교전이 풀리며 그 몸에 쿨다운이 걸린다) 곤충은 물러난다(놓침 도주와 같은 길 — + /// 스포너가 같은 개체를 눈 밖 다른 자리로 옮긴다). 실패하면 「도망치지 못했다!」 뒤 싸움으로 넘어간다(Update). + /// + private void TryAmbushEscape() + { + if (!ambushMode || ambushEscapeTried || targetInsect == null) return; + ambushEscapeTried = true; + InsectEntity target = targetInsect; + // 안전망 — 열 때는 싸울 수 있었는데 그 수단이 사라졌다면, 갇힌 창이 되지 않게 도망은 늘 성공한다. + bool escaped = !CanFight(target) + || Random.value < BattleEscapeRules.Chance(ambushLeadLevel, target.Level); + if (escaped) + { + Hide(); + target.ScareAway(); + return; + } + ambushEscapeFailed = true; + ambushFailAt = Time.unscaledTime; + } + + /// 싸우기 — 영웅·전설은 기존 레이드 진입, 그 밖에는 기존 [B] 배틀 경로(출전할 곤충 고르기)를 그대로 탄다. + private void BeginAmbushFight() + { + if (!ambushMode || targetInsect == null) return; + if (IsRaidTarget()) StartRaidBattle(); + else showTeamSelect = true; + } + + /// 선두 출전 곤충의 레벨 — 배틀팀 앞 칸부터 기절하지 않은 첫 곤충. 도망 확률이 이 레벨과 상대 레벨을 견준다. + private int LeadFighterLevel() + { + if (teamManager == null || collection == null) return 1; + for (int i = 0; i < BattleTeamManager.MaxSlots; i++) + { + string instanceId = teamManager.GetSlot(i); + if (string.IsNullOrEmpty(instanceId)) continue; + PlayerInsectData pid = collection.GetByInstanceId(instanceId); + if (pid != null && !pid.IsFainted) return Mathf.Max(1, pid.level); + } + return 1; + } + + private void DrawAmbush() + { + if (targetInsect == null || targetInsect.Data == null) return; + + InitStyles(); + UITheme t = UITheme.Instance; + bool isRaid = IsRaidTarget(); + Rect panel = UISafeLayout.CenteredPanel(820f, AmbushPanelH); + float panelW = panel.width; + float px = panel.x; + float py = panel.y; + + Color rarityCol = t.GetInsectRarityColor(targetInsect.Data.rarity); + DrawPanel(panel, t.accentCoral); + GUI.color = Color.white; + + UIHelper.LabelFit(new Rect(px + 24f, py + 14f, panelW - 48f, 60f), "습격!", ambushTitleStyle); + UIHelper.LabelFit(new Rect(px + 24f, py + 74f, panelW - 48f, 36f), ambushReason, subStyle); + + // 미리보기 — 포획 선택 창과 같은 자리·크기. 바탕만 경고색이 섞인다. + Rect frame = new Rect(px + panelW / 2f - 100f, py + 118f, 200f, 200f); + UISurface.Rounded(frame, Color.Lerp(t.surfaceBase, t.accentCoral, 0.16f)); + InsectVisual.Draw(frame.center.x, frame.center.y, 188f, targetInsect.Data, targetInsect.IsShiny, 1f); + + nameStyle.normal.textColor = rarityCol; + UIHelper.LabelFit(new Rect(px + 20f, py + 326f, panelW - 40f, 50f), ambushNameLabel, nameStyle); + DrawChoiceChips(px + panelW / 2f, py + 382f, rarityCol); + + // 버튼 둘 — 크기는 포획 선택 창(330×88)과 같다. 창이 뜬 직후 잠깐은 눌리지 않는다(InputDelaySeconds). + const float btnW = 330f; + const float btnH = 88f; + const float gap = 24f; + float btnY = py + 440f; + float leftX = px + panelW / 2f - btnW - gap / 2f; + float rightX = leftX + btnW + gap; + bool ready = AmbushInputReady; + + GUI.enabled = ready; + if (UISurface.Button(new Rect(leftX, btnY, btnW, btnH), ambushFightLabel, + ready ? t.btnDanger : t.btnDisabled, buttonStyle) && ConsumeAmbushAction()) + BeginAmbushFight(); + + bool canEscape = ready && !ambushEscapeTried; + GUI.enabled = canEscape; + if (UISurface.Button(new Rect(rightX, btnY, btnW, btnH), ambushEscapeLabel, + canEscape ? t.btnSecondary : t.btnDisabled, buttonStyle) && ConsumeAmbushAction()) + TryAmbushEscape(); + GUI.enabled = true; + if (!isOpen || showTeamSelect) return; // 도망에 성공해 닫혔거나 출전 곤충 고르기로 넘어갔다 + + hintStyle.normal.textColor = t.textMuted; + UIHelper.LabelFit(new Rect(leftX, btnY + btnH + 6f, btnW, 32f), + isRaid ? "5마리 팀 레이드" : "출전할 곤충을 고른다", hintStyle); + hintStyle.normal.textColor = ambushEscapeTried ? t.accentCoral : t.textSecondary; + UIHelper.LabelFit(new Rect(rightX, btnY + btnH + 6f, btnW, 32f), + ambushEscapeTried ? "도망 실패" : ambushChanceLabel, hintStyle); + + if (ambushEscapeFailed) + UIHelper.LabelFit(new Rect(px + 24f, btnY + btnH + 46f, panelW - 48f, 48f), "도망치지 못했다!", ambushFailStyle); } private void DrawItemSelect() { if (captureItems == null || captureItems.Length == 0) { showItemSelect = false; return; } - // 아이템 수에 비례해 무한히 자라는 높이 — 안전 영역을 넘으면 clamp된다(넘치는 목록은 스크롤로 본다). - Rect panel = UISafeLayout.CenteredPanel(800f, 120 + captureItems.Length * 160); + InitStyles(); + UITheme t = UITheme.Instance; + bool mobile = UIScale.IsMobileLayout; + const float rowH = 148f; + const float rowGap = 12f; + float backH = mobile ? 64f : 56f; + // 아이템 수에 비례해 자라는 높이 — 안전 영역을 넘으면 clamp된다. + Rect panel = UISafeLayout.CenteredPanel(820f, + 96f + captureItems.Length * (rowH + rowGap) + backH + 28f); float panelW = panel.width; float panelH = panel.height; float px = panel.x; float py = panel.y; - GUI.color = new Color(0.04f, 0.06f, 0.1f, 0.95f); - GUI.DrawTexture(new Rect(px, py, panelW, panelH), Texture2D.whiteTexture); - GUI.color = new Color(0.3f, 0.8f, 0.4f); - GUI.DrawTexture(new Rect(px, py, panelW, 4), Texture2D.whiteTexture); - - GUIStyle title = new GUIStyle(GUI.skin.label) - { fontSize = 36, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - title.normal.textColor = new Color(0.3f, 1f, 0.5f); + DrawPanel(panel, t.accentMint); GUI.color = Color.white; - GUI.Label(new Rect(px, py + 16, panelW, 48), "포획 아이템 선택", title); - - float startY = py + 76; + GUI.Label(new Rect(px, py + 18f, panelW, 50f), "채집망 선택", titleStyle); + float startY = py + 84f; for (int i = 0; i < captureItems.Length; i++) { CaptureItemData item = captureItems[i]; int count = itemInventory != null ? itemInventory.GetCount(item.itemId) : 0; - float cy = startY + i * 160; bool hasItem = count > 0; + Rect row = new Rect(px + 24f, startY + i * (rowH + rowGap), panelW - 48f, rowH); - GUI.color = new Color(0.1f, 0.12f, 0.18f, 0.85f); - GUI.DrawTexture(new Rect(px + 20, cy, panelW - 40, 148), Texture2D.whiteTexture); - GUI.color = item.themeColor; - GUI.DrawTexture(new Rect(px + 20, cy, 6, 148), Texture2D.whiteTexture); - - float diamondX = px + 56; - float diamondY = cy + 74; - float ds = 24f; - GUI.DrawTexture(new Rect(diamondX - ds / 2, diamondY - ds, ds, ds * 2), Texture2D.whiteTexture); - - GUI.color = Color.white; - GUIStyle ns = new GUIStyle(GUI.skin.label) { fontSize = 32, fontStyle = FontStyle.Bold }; - ns.normal.textColor = hasItem ? item.themeColor : new Color(0.4f, 0.4f, 0.4f); - UIHelper.LabelFit(new Rect(px + 90, cy + 10, panelW - 280, 42), $"[{i + 1}] {item.displayName}", ns); + UISurface.Card(row, hasItem ? t.surfaceRaised : t.surfaceBase, t.surfaceBorder); + // 그물 색 견본 — 없는 아이템은 흐리게. + Rect swatch = new Rect(row.x + 20f, row.y + (rowH - 64f) / 2f, 64f, 64f); + UISurface.Rounded(swatch, hasItem ? item.themeColor : Color.Lerp(item.themeColor, t.surfaceBase, 0.7f)); + UISurface.Rounded(new Rect(swatch.x + 14f, swatch.y + 14f, 36f, 36f), t.surfaceShadow); - GUIStyle ds2 = new GUIStyle(GUI.skin.label) { fontSize = 24 }; - ds2.normal.textColor = new Color(0.55f, 0.55f, 0.55f); - UIHelper.LabelFit(new Rect(px + 90, cy + 52, panelW - 280, 32), item.description, ds2); + float textX = swatch.xMax + 20f; + float textW = row.xMax - 200f - textX; + itemNameStyle.normal.textColor = hasItem ? t.textPrimary : t.textMuted; + string name = mobile ? item.displayName : $"{item.displayName} [{i + 1}]"; + UIHelper.LabelFit(new Rect(textX, row.y + 14f, textW, 42f), name, itemNameStyle); + UIHelper.LabelFit(new Rect(textX, row.y + 58f, textW, 32f), item.description, itemDescStyle); string difficulty; - if (item.speedMultiplier <= 0.6f) difficulty = "매우 쉬움"; - else if (item.speedMultiplier <= 0.8f) difficulty = "쉬움"; - else difficulty = "보통"; - - GUIStyle diffS = new GUIStyle(GUI.skin.label) { fontSize = 24 }; - diffS.normal.textColor = item.speedMultiplier <= 0.6f ? new Color(0.3f, 1f, 0.5f) : - item.speedMultiplier <= 0.8f ? new Color(0.9f, 0.8f, 0.3f) : new Color(0.6f, 0.6f, 0.6f); - GUI.Label(new Rect(px + 90, cy + 90, panelW - 280, 32), $"난이도: {difficulty}", diffS); - - GUIStyle countS = new GUIStyle(GUI.skin.label) - { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleRight }; - countS.normal.textColor = hasItem ? new Color(1f, 0.85f, 0.3f) : new Color(0.4f, 0.3f, 0.3f); - UIHelper.LabelFit(new Rect(px + panelW - 220, cy + 14, 80, 36), $"x{count}", countS); - - GUIStyle useBtn = new GUIStyle(GUI.skin.button) { fontSize = 28, fontStyle = FontStyle.Bold }; - GUI.backgroundColor = hasItem ? new Color(0.25f, 0.5f, 0.3f) : new Color(0.2f, 0.2f, 0.2f); + Color diffCol; + if (item.speedMultiplier <= 0.6f) { difficulty = "매우 쉬움"; diffCol = t.accentMint; } + else if (item.speedMultiplier <= 0.8f) { difficulty = "쉬움"; diffCol = t.accentAmber; } + else { difficulty = "보통"; diffCol = t.surfaceBorder; } + // "보통"은 밝은 액센트가 아니라 테두리색 바탕이라 글자를 밝게 둔다(어두운 글자면 묻혔다). + Color diffText = diffCol == t.surfaceBorder ? t.textPrimary : t.surfaceBase; + UISurface.Chip(new Rect(textX, row.y + 98f, 132f, 34f), difficulty, + hasItem ? diffCol : t.surfaceRaised, hasItem ? diffText : t.textMuted); + + countStyle.normal.textColor = hasItem ? t.accentAmber : t.textMuted; + UIHelper.LabelFit(new Rect(row.xMax - 180f, row.y + 12f, 160f, 40f), $"×{count}", countStyle); + GUI.enabled = hasItem; - if (GUI.Button(new Rect(px + panelW - 160, cy + 56, 120, 56), "사용", useBtn)) + if (UISurface.Button(new Rect(row.xMax - 170f, row.y + 62f, 150f, 68f), "사용", + hasItem ? t.btnPrimary : t.btnDisabled, buttonStyle)) { - if (itemInventory != null && itemInventory.UseItem(item.itemId, 1)) - { - InsectEntity savedTarget = targetInsect; - Hide(); - if (minigame != null && savedTarget != null) - minigame.StartMinigame(savedTarget, - item.speedMultiplier, item.zoneSizeMultiplier, - item.timeLimitMultiplier, item.captureBonus); - } + StartCaptureWith(item); } GUI.enabled = true; - GUI.backgroundColor = Color.white; } - GUIStyle backBtn = new GUIStyle(GUI.skin.button) { fontSize = 24, fontStyle = FontStyle.Bold }; - float backH = UIScale.IsMobileLayout ? 60f : 46f; - if (GUI.Button(new Rect(px + panelW / 2f - 90f, py + panelH - backH - 10f, 180f, backH), "< 뒤로", backBtn)) + if (UISurface.Button(new Rect(px + panelW / 2f - 110f, py + panelH - backH - 16f, 220f, backH), + "‹ 뒤로", t.surfaceRaised, cancelStyle)) showItemSelect = false; } @@ -435,10 +798,77 @@ private void TryItemSelectByKey() } if (keyIndex < 0 || keyIndex >= captureItems.Length) return; - CaptureItemData item = captureItems[keyIndex]; + StartCaptureWith(captureItems[keyIndex]); + } + + // ── 채집망 바로 쓰기 ── + // 포획은 수백 번 반복하는 동작인데 매번 「채집망 선택」 창을 거쳤다(선택 → 그물 → 미니게임 → 결과). + // 지난번에 쓴 채집망을 기억해 버튼 하나로 미니게임까지 간다. 바꾸고 싶으면 버튼 아래 줄을 누른다. + + private const float NetRowH = 52f; + + private string lastNetItemId; + private string lastNetPrefsKey; + // 채집망 줄 문구 캐시 — OnGUI 패스마다 이어 붙이지 않는다(종류·개수가 바뀔 때만 다시 만든다). + private string netRowLabel; + private string netRowLabelItemId; + private int netRowLabelCount = -1; + private System.Func netCountProbe; + + // 계정마다 따로 기억한다. 로그인이 이 화면보다 늦게 끝나므로 키가 바뀔 때마다 다시 읽는다. + private void EnsureLastNetLoaded() + { + string key = AuthManager.ScopedKey(GameConstants.PrefsKeys.LastCaptureNet); + if (key == lastNetPrefsKey) return; + lastNetPrefsKey = key; + lastNetItemId = PlayerPrefs.GetString(key, string.Empty); + } + + /// + /// 이번 포획에 쓸 채집망 — 지난번에 쓴 것이 남아 있으면 그것, 아니면 가진 것 중 맨 앞. + /// 목록은 흔한 것부터 적혀 있어(기본 → 은빛 → 황금), 귀한 채집망을 말없이 써 버리지 않는다. + /// + private CaptureItemData ResolveQuickNet() + { + if (captureItems == null || itemInventory == null) return null; + // 대리자는 한 번만 묶는다 — 이 함수는 OnGUI 패스마다 불린다. + if (netCountProbe == null) netCountProbe = itemInventory.GetCount; + return CaptureNetChoice.Resolve(captureItems, lastNetItemId, netCountProbe); + } + + private void BeginQuickCapture() + { + CaptureItemData net = ResolveQuickNet(); + if (net != null) StartCaptureWith(net); + } + + private string NetRowLabel(CaptureItemData net) + { + int count = itemInventory != null ? itemInventory.GetCount(net.itemId) : 0; + if (netRowLabel == null || netRowLabelItemId != net.itemId || netRowLabelCount != count) + { + netRowLabelItemId = net.itemId; + netRowLabelCount = count; + netRowLabel = $"{net.displayName} ×{count} · 바꾸기"; + } + return netRowLabel; + } + + /// 채집망 하나를 쓰고 미니게임을 연다 — 버튼·숫자 키·바로 쓰기 세 경로의 단일 진입점. + private void StartCaptureWith(CaptureItemData item) + { + if (item == null || itemInventory == null) return; if (itemInventory.GetCount(item.itemId) <= 0) return; if (!itemInventory.UseItem(item.itemId, 1)) return; + EnsureLastNetLoaded(); + if (lastNetItemId != item.itemId) + { + lastNetItemId = item.itemId; + PlayerPrefs.SetString(lastNetPrefsKey, lastNetItemId); + PlayerPrefs.Save(); + } + InsectEntity savedTarget = targetInsect; Hide(); if (minigame != null && savedTarget != null) @@ -457,112 +887,95 @@ private bool HasAnyCaptureItem() private void DrawTeamSelect() { - Rect panel = UISafeLayout.CenteredPanel(820f, 720f); + InitStyles(); + UITheme t = UITheme.Instance; + bool mobile = UIScale.IsMobileLayout; + Rect panel = UISafeLayout.CenteredPanel(880f, mobile ? 1100f : 860f); float panelW = panel.width; float panelH = panel.height; float px = panel.x; float py = panel.y; - GUI.color = new Color(0.04f, 0.06f, 0.1f, 0.95f); - GUI.DrawTexture(new Rect(px, py, panelW, panelH), Texture2D.whiteTexture); - - GUI.color = new Color(1f, 0.5f, 0.2f); - GUI.DrawTexture(new Rect(px, py, panelW, 4), Texture2D.whiteTexture); - - GUIStyle titleStyle = new GUIStyle(GUI.skin.label) - { fontSize = 36, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - titleStyle.normal.textColor = new Color(1f, 0.8f, 0.3f); + DrawPanel(panel, t.accentAmber); GUI.color = Color.white; - UIHelper.LabelFit(new Rect(px, py + 18, panelW, 44), "곤충을 선택하세요", titleStyle); - - GUIStyle subStyle = new GUIStyle(GUI.skin.label) - { fontSize = 28, alignment = TextAnchor.MiddleCenter }; - subStyle.normal.textColor = new Color(0.6f, 0.6f, 0.6f); - UIHelper.LabelFit(new Rect(px, py + 62, panelW, 36), - $"vs {targetInsect.Data.displayName} Lv.{targetInsect.Level}", subStyle); + UIHelper.LabelFit(new Rect(px + 24f, py + 18f, panelW - 48f, 48f), "출전할 곤충을 고르세요", titleStyle); + UIHelper.LabelFit(new Rect(px + 24f, py + 66f, panelW - 48f, 34f), + $"vs {targetInsect.DisplayNameForPlayer} Lv.{targetInsect.Level}", subStyle); - float slotY = py + 110; - const float slotGap = 6f; - float backH = UIScale.IsMobileLayout ? 60f : 46f; + float slotY = py + 112f; + const float slotGap = 8f; + float backH = mobile ? 64f : 56f; // 패널이 안전 영역에 맞춰 줄면 슬롯도 줄여 '뒤로' 버튼과 겹치지 않게 한다. - float slotAvail = panelH - (slotY - py) - backH - 24f; + float slotAvail = panelH - (slotY - py) - backH - 32f; float slotH = Mathf.Clamp( (slotAvail - (BattleTeamManager.MaxSlots - 1) * slotGap) / BattleTeamManager.MaxSlots, UIScale.MinTouchHeight, - 100f); + mobile ? 170f : 128f); for (int i = 0; i < BattleTeamManager.MaxSlots; i++) { string instanceId = teamManager != null ? teamManager.GetSlot(i) : null; - DrawTeamSlotChoice(px + 24, slotY + i * (slotH + slotGap), panelW - 48, slotH, i, instanceId); + DrawTeamSlotChoice(px + 24f, slotY + i * (slotH + slotGap), panelW - 48f, slotH, i, instanceId); } - GUIStyle backStyle = new GUIStyle(GUI.skin.button) { fontSize = 24, fontStyle = FontStyle.Bold }; - if (GUI.Button(new Rect(px + panelW / 2f - 90f, py + panelH - backH - 10f, 180f, backH), "< 뒤로", backStyle)) + if (UISurface.Button(new Rect(px + panelW / 2f - 110f, py + panelH - backH - 16f, 220f, backH), + "‹ 뒤로", t.surfaceRaised, cancelStyle)) showTeamSelect = false; } private void DrawTeamSlotChoice(float x, float y, float w, float h, int index, string instanceId) { + UITheme t = UITheme.Instance; bool empty = string.IsNullOrEmpty(instanceId); + Rect card = new Rect(x, y, w, h); - GUI.color = empty - ? new Color(0.08f, 0.08f, 0.12f, 0.5f) - : new Color(0.1f, 0.12f, 0.18f, 0.85f); - GUI.DrawTexture(new Rect(x, y, w, h), Texture2D.whiteTexture); - - GUIStyle numStyle = new GUIStyle(GUI.skin.label) - { fontSize = 32, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - numStyle.normal.textColor = new Color(0.3f, 0.3f, 0.35f); + UISurface.Card(card, empty ? t.surfaceBase : t.surfaceRaised, t.surfaceBorder); GUI.color = Color.white; - GUI.Label(new Rect(x, y, 56, h), $"{index + 1}", numStyle); + GUI.Label(new Rect(x + 8f, y, 48f, h), $"{index + 1}", slotNumStyle); if (empty) { - GUIStyle emptyStyle = new GUIStyle(GUI.skin.label) { fontSize = 28 }; - emptyStyle.normal.textColor = new Color(0.3f, 0.3f, 0.35f); - GUI.Label(new Rect(x + 64, y + h / 2f - 18, w - 70, 36), "비어있음", emptyStyle); + slotInfoStyle.normal.textColor = t.textMuted; + GUI.Label(new Rect(x + 64f, y, w - 80f, h), "비어 있음", slotInfoStyle); + slotInfoStyle.normal.textColor = t.textSecondary; return; } PlayerInsectData pid = collection != null ? collection.GetByInstanceId(instanceId) : null; InsectData data = pid != null && collection != null ? collection.GetInsectData(pid.insectId) : null; - - if (data != null) - { - Color rarityCol = UITheme.Instance.GetInsectRarityColor(data.rarity); - GUI.color = rarityCol; - GUI.DrawTexture(new Rect(x, y, 5, h), Texture2D.whiteTexture); - - InsectVisual.Draw(x + 80, y + h / 2f, 96f, data, pid != null && pid.isShiny, 1f); - - GUIStyle ns = new GUIStyle(GUI.skin.label) { fontSize = 30, fontStyle = FontStyle.Bold }; - ns.normal.textColor = rarityCol; - GUI.color = Color.white; - int lv = pid != null ? pid.level : 1; - int cp = PlayerInsectCombatPower.Calculate(data, pid); - UIHelper.LabelFit(new Rect(x + 120, y + 12, w - 280, 38), GetOwnedDisplayName(pid, data), ns); - - GUIStyle info = new GUIStyle(GUI.skin.label) { fontSize = 24 }; - info.normal.textColor = new Color(0.55f, 0.55f, 0.55f); - GUI.Label(new Rect(x + 120, y + 52, w - 280, 32), $"Lv.{lv} | CP {cp}", info); - - GUIStyle fightBtn = new GUIStyle(GUI.skin.button) { fontSize = 28, fontStyle = FontStyle.Bold }; - // 기절(0 HP) 곤충은 출전 불가 — 병원 치료 전까지 즉사 반복 방지(지속 HP 도입에 따른 가드). - bool fainted = pid != null && pid.IsFainted; - if (fainted) - { - GUI.backgroundColor = new Color(0.3f, 0.3f, 0.32f); - GUI.Button(new Rect(x + w - 150, y + h / 2f - 28, 130, 56), "기절", fightBtn); - } - else - { - GUI.backgroundColor = new Color(0.6f, 0.2f, 0.15f); - if (GUI.Button(new Rect(x + w - 150, y + h / 2f - 28, 130, 56), "출격!", fightBtn)) - StartBattleCapture(pid, data, lv); - } - GUI.backgroundColor = Color.white; - } + if (data == null) return; + + Color rarityCol = t.GetInsectRarityColor(data.rarity); + // 6px 레일 — 각진 채로 두고 세로를 카드 반경만큼 물린다(ui-layout.md). + UISurface.Flat(new Rect(x + 3f, y + 3f + UITheme.Radius.Card, 6f, + Mathf.Max(4f, h - 6f - UITheme.Radius.Card * 2f)), rarityCol); + + float thumb = Mathf.Min(h - 16f, 104f); + InsectVisual.Draw(x + 60f + thumb / 2f, y + h / 2f, thumb, data, pid.isShiny, pid.IsFainted ? 0.45f : 1f); + + float textX = x + 72f + thumb; + float textW = w - (textX - x) - 176f; + int lv = pid.level; + int cp = PlayerInsectCombatPower.Calculate(data, pid); + slotNameStyle.normal.textColor = rarityCol; + UIHelper.LabelFit(new Rect(textX, y + h * 0.5f - 44f, textW, 40f), GetOwnedDisplayName(pid, data), slotNameStyle); + GUI.Label(new Rect(textX, y + h * 0.5f - 4f, textW, 30f), $"Lv.{lv} · {data.rarity.Korean()} · CP {cp}", slotInfoStyle); + + // HP 막대 — 출전 전에 누가 지쳤는지 보인다(예전엔 기절만 버튼으로 알렸다). + int maxHp = pid.GetTotalHp(data.baseHp); + int curHp = pid.currentHp < 0 ? maxHp : pid.currentHp; + float ratio = maxHp > 0 ? (float)curHp / maxHp : 0f; + UISurface.Meter(new Rect(textX, y + h * 0.5f + 30f, Mathf.Min(textW, 280f), 10f), ratio, t.GetHpColor(ratio)); + + // 기절(0 HP) 곤충은 출전 불가 — 병원 치료 전까지 즉사 반복 방지(지속 HP 도입에 따른 가드). + bool fainted = pid.IsFainted; + float btnH = Mathf.Min(h - 20f, 68f); + Rect btn = new Rect(x + w - 164f, y + (h - btnH) / 2f, 144f, btnH); + GUI.enabled = !fainted; + if (UISurface.Button(btn, fainted ? "기절" : "출격!", fainted ? t.btnDisabled : t.btnDanger, buttonStyle) + && !fainted) + StartBattleCapture(pid, data, lv); + GUI.enabled = true; } private void StartBattleCapture(PlayerInsectData playerPid, InsectData playerInsect, int playerLevel) @@ -675,4 +1088,28 @@ public void AutoWire(PlayerMovement pm) if (playerMovement == null) playerMovement = pm; } } + + /// + /// 「바로 쓸 채집망」 고르기의 순수 판정. 화면(IMGUI)과 떼어 테스트로 고정한다 — + /// 틀리면 귀한 채집망이 말없이 소모된다. + /// + public static class CaptureNetChoice + { + /// 채집망 목록. 흔한 것부터 적혀 있다는 것이 전제다. + /// 지난번에 쓴 채집망. 없거나 다 떨어졌으면 목록 맨 앞의 가진 것으로 물러난다. + public static CaptureItemData Resolve(CaptureItemData[] items, string lastUsedId, System.Func countOf) + { + if (items == null || countOf == null) return null; + + CaptureItemData firstOwned = null; + for (int i = 0; i < items.Length; i++) + { + CaptureItemData item = items[i]; + if (item == null || countOf(item.itemId) <= 0) continue; + if (item.itemId == lastUsedId) return item; + if (firstOwned == null) firstOwned = item; + } + return firstOwned; + } + } } diff --git a/Assets/Scripts/UI/CapturePopupUI.cs b/Assets/Scripts/UI/CapturePopupUI.cs index 82e47b7..9a5af61 100644 --- a/Assets/Scripts/UI/CapturePopupUI.cs +++ b/Assets/Scripts/UI/CapturePopupUI.cs @@ -16,6 +16,10 @@ public class CapturePopupUI : MonoBehaviour private bool wasSuccess; private string insectName; private string insectId; + // 3D 초상용 — 도감·포획 창과 같은 InsectVisual 경로로 그린다(없으면 2D 초상 폴백). + private InsectData insectData; + private bool insectShiny; + private string successTitleName; // "★ 이름" — OnGUI 패스마다 이어 붙이지 않게 결과 시점에 한 번 만든다 private InsectRarity insectRarity; private int insectLevel; private int candyReward; @@ -25,6 +29,8 @@ public class CapturePopupUI : MonoBehaviour private int capturedIvHp, capturedIvAtk, capturedIvDef; private float capturedIvPct; private string capturedInstanceId; + // 이 종을 처음 잡았는가 — 초상 모서리에 「NEW」를 붙인다(도감에 새 칸이 찼다는 표시). + private bool capturedNewSpecies; private struct Star { @@ -48,42 +54,36 @@ private struct Star private GUIStyle ivVsStyleCache; private bool popupStylesReady; - private static readonly Color SubGrayBase = new Color(0.8f, 0.8f, 0.8f); - private static readonly Color GradeTitleGrayBase = new Color(0.6f, 0.6f, 0.6f); - private static readonly Color RewardCandyBase = new Color(1f, 0.5f, 0.8f); - private static readonly Color RewardExpBase = new Color(0.4f, 0.8f, 1f); - private static readonly Color FailMsgBase = new Color(1f, 0.35f, 0.3f); - private static readonly Color FailSubBase = new Color(0.7f, 0.7f, 0.7f); - private static readonly Color IvLblBase = new Color(0.55f, 0.55f, 0.55f); private void InitPopupStyles() { if (popupStylesReady) return; popupStylesReady = true; + // 크기는 가상 좌표계(UIScale) 기준 — 다른 화면과 같은 눈금이다. headerStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 28, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + { fontSize = 40, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; nameStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 34, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + { fontSize = 44, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; subStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 22, alignment = TextAnchor.MiddleCenter }; + { fontSize = 26, alignment = TextAnchor.MiddleCenter }; gradeTitleStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 19, alignment = TextAnchor.MiddleLeft }; + { fontSize = 20, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; gradeLblStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 48, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + { fontSize = 56, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; pctLblStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 24, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + { fontSize = 28, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; rewardLabelStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 20, alignment = TextAnchor.MiddleCenter }; + { fontSize = 22, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; rewardValStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 24, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; failStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 40, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + { fontSize = 48, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; failSubStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 22, alignment = TextAnchor.MiddleCenter }; - ivLblStyleCache = new GUIStyle(GUI.skin.label) { fontSize = 19 }; + { fontSize = 26, alignment = TextAnchor.MiddleCenter }; + ivLblStyleCache = new GUIStyle(GUI.skin.label) { fontSize = 20, fontStyle = FontStyle.Bold }; ivVsStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 19, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleRight }; + { fontSize = 22, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleRight }; } private void OnEnable() @@ -117,12 +117,18 @@ private void OnCaptureResolved(InsectEntity target, bool success) if (target != null && target.Data != null) { + insectData = target.Data; + insectShiny = target.IsShiny; insectName = target.Data.displayName; + successTitleName = insectShiny ? "★ " + insectName : insectName; insectId = target.Data.insectId; insectRarity = target.Data.rarity; insectLevel = target.Level; - candyReward = InsectRewardCalculator.GetCandyReward(target.Data); - expReward = InsectRewardCalculator.GetExpReward(target.Data); + // 공식을 다시 돌리지 않고 **실제 지급값**을 읽는다. 다시 돌리면 ①EXP 부스터가 빠지고 + // ②레벨 차를 지급 뒤(방금 오른) 캐릭터 레벨로 재서, 화면 숫자와 받은 EXP가 갈린다. + candyReward = captureController != null ? captureController.LastCandyReward : 0; + expReward = captureController != null ? captureController.LastExpReward : 0; + capturedNewSpecies = success && captureController != null && captureController.LastCaptureWasNewSpecies; if (success && insectCollection != null) { @@ -140,8 +146,12 @@ private void OnCaptureResolved(InsectEntity target, bool success) } else { + insectData = null; + insectShiny = false; insectName = "???"; + successTitleName = insectName; insectId = ""; + capturedNewSpecies = false; capturedInstanceId = null; insectRarity = InsectRarity.Common; insectLevel = 1; @@ -170,140 +180,248 @@ private void Update() { if (popupTimer > 0f) { + // 카드는 가운데 무대(HudStage)에 선다 — 미니게임 결과 글자처럼 앞 칸이 서 있거나 꿈 동안은 기다리고, + // 기다리는 동안은 시간도 멈춘다(못 보고 지나가지 않게). + if (!CanShow()) return; popupTimer -= Time.deltaTime; animTime += Time.deltaTime; } } + private static bool CanShow() + { + return !DreamPrologueState.Active && HudStage.Request(HudStageItem.CaptureResult); + } + private void OnGUI() { if (popupTimer <= 0f) return; + if (!CanShow()) return; InitPopupStyles(); float alpha = popupTimer < 0.5f ? popupTimer / 0.5f : Mathf.Clamp01(animTime / 0.3f); + // 가상 좌표계 — 예전엔 이 팝업만 픽셀 좌표(Screen.width)로 그려 해상도마다 크기가 달랐다 + // (720p 창에선 화면의 3/4을 덮고, 1080×2400 폰에선 절반 폭에 글자가 작았다). + UIScale.Begin(); if (wasSuccess) DrawSuccessPopup(alpha); else DrawFailPopup(alpha); + GUI.color = Color.white; + UIScale.End(); + } + + private static Color WithAlpha(Color c, float a) => new Color(c.r, c.g, c.b, c.a * a); + + // 팝업이 뜬 직후의 누름은 닫기로 치지 않는다 — 미니게임의 마지막 조작(던지기 탭, 누르고 있던 손)이 + // 결과를 보기도 전에 닫아 버린다. + private const float DismissGuardSeconds = 0.5f; + private const float FadeOutSeconds = 0.5f; + + /// + /// 카드를 누르면 바로 닫는다. 예전엔 4.5초를 그냥 기다려야 했다 — 포획은 수백 번 반복하는데 + /// 매번 그만큼 화면 가운데가 가려졌다. + /// + /// 필드 위에 뜨는 비모달 카드라 자리를 등록한다. 안 하면 닫으려고 누른 탭이 월드 + /// 클릭-이동으로 새어 캐릭터가 카드 밑으로 걸어간다(rules/ui-layout.md). + /// + private void HandleDismiss(Rect card) + { + FieldHudInput.RegisterBlockingRect(card); + Event e = Event.current; + if (e == null || e.type != EventType.MouseDown || e.button != 0) return; + if (animTime < DismissGuardSeconds || popupTimer <= FadeOutSeconds) return; + if (!card.Contains(e.mousePosition)) return; + popupTimer = FadeOutSeconds; // 남은 시간을 사라지는 구간으로 당긴다 + e.Use(); + } + + /// 성공 카드의 설계 크기 — 안의 배치가 전부 이 640×640 기준 절대 좌표다. 무대가 작으면 통째로 줄인다. + public const float SuccessSize = 640f; + public const float FailWidth = 560f; + public const float FailHeight = 150f; + + /// + /// 포획 성공 카드의 자리 — 가운데 무대()의 차례 항목, 정사각형을 무대에 맞춰 줄인다. + /// 예전엔 화면 가운데(ContentTop+100 아래)라 퀘스트 완료 알림·소식 카드·대화 버튼과 한 자리였고, 720p에서는 높이만 + /// 잘려(ClampHeight) 아래 보상 줄이 카드 밖으로 나갔다. + /// + public static Rect SuccessRect(HudFrame f) + { + Rect area = HudStage.Area(f); + float k = HudStage.FitScale(area, SuccessSize, SuccessSize); + return HudStage.Place(area, HudStageItem.CaptureResult, SuccessSize * k, SuccessSize * k); + } + + /// 포획 실패 카드의 자리 — 가운데 무대의 차례 항목. + public static Rect FailRect(HudFrame f) + { + return HudStage.Place(f, HudStageItem.CaptureResult, FailWidth, FailHeight); } private void DrawSuccessPopup(float alpha) { - float cx = Screen.width / 2f; - float cy = Screen.height * 0.38f; - float panelW = 560f; - float panelH = UISafeLayout.Px.ClampHeight(540f); - float px = cx - panelW / 2f; - // 화면 38% 지점 중심 — 단 세이프에어리어 + 세로 마진 밖으로는 나가지 않는다. - float py = Mathf.Clamp( - cy - panelH / 2f, - UISafeLayout.Px.ContentTop, - Mathf.Max(UISafeLayout.Px.ContentTop, UISafeLayout.Px.ContentBottom - panelH)); - - float slideIn = Mathf.Clamp01(animTime / 0.25f); - py += (1f - slideIn) * 30f; - - Color rarityCol = UITheme.Instance.GetInsectRarityColor(insectRarity); - - GUI.color = new Color(0.03f, 0.05f, 0.1f, 0.94f * alpha); - GUI.DrawTexture(new Rect(px, py, panelW, panelH), Texture2D.whiteTexture); - GUI.color = Color.white; + // 무대 안의 자리를 받아, 카드는 늘 640×640 설계 좌표로 그리고 행렬로 줄인다(나타날 때 94%→100%로 살짝 커진다 — + // 예전처럼 위에서 미끄러지면 무대 밖으로 나간다). + Rect outer = SuccessRect(HudFrame.Current); + HudStage.Request(HudStageItem.CaptureResult, outer); // 무대 안의 가운데 것들이 겹치면 비켜서게 자리를 알린다 + HandleDismiss(outer); + float pop = 0.94f + 0.06f * Mathf.Clamp01(animTime / 0.25f); + float k = outer.width / SuccessSize * pop; + Matrix4x4 saved = GUI.matrix; + GUI.matrix = saved * Matrix4x4.TRS( + new Vector3(outer.center.x - SuccessSize * 0.5f * k, outer.center.y - SuccessSize * 0.5f * k, 0f), + Quaternion.identity, new Vector3(k, k, 1f)); + try + { + DrawSuccessCard(alpha); + } + finally + { + GUI.matrix = saved; + } + } + private void DrawSuccessCard(float alpha) + { + UITheme t = UITheme.Instance; + const float panelW = SuccessSize; + const float panelH = SuccessSize; + const float px = 0f; + const float py = 0f; + const float cx = SuccessSize * 0.5f; + + Color rarityCol = t.GetInsectRarityColor(insectRarity); Rect panelRect = new Rect(px, py, panelW, panelH); int rarityTier = (int)insectRarity; + // 필드 HUD·포획 창과 같은 카드 — 알파는 GUI.color로 곱한다(UISurface가 호출부 알파를 살린다). + GUI.color = new Color(1f, 1f, 1f, alpha); + UISurface.Card(panelRect, t.surfaceCard, Color.Lerp(t.surfaceBorder, rarityCol, 0.6f)); + UISurface.Flat(new Rect(px + UITheme.Radius.Card, py + 3f, panelW - UITheme.Radius.Card * 2f, 4f), rarityCol); + GUI.color = Color.white; + // Epic/Legendary 글로우 if (rarityTier >= 3) { float glowIntensity = rarityTier >= 4 ? 0.8f : 0.5f; UIHelper.DrawRarityGlow(panelRect, rarityCol, glowIntensity * alpha, animTime); } - UIHelper.DrawRarityBorder(panelRect, rarityTier, animTime); + Rect frame = new Rect(cx - 110f, py + 78f, 220f, 220f); + float starCy = frame.center.y; + + // 반짝이 — 둥근 점(예전엔 흰 사각 텍스처라 네모 조각이 흩날렸다). if (stars != null) { + Texture2D disc = UIShapes.Disc; foreach (Star s in stars) { float d = s.dist + animTime * s.speed; float rad = s.angle * Mathf.Deg2Rad + animTime * 0.5f; - float sx = cx + Mathf.Cos(rad) * d; - float sy = cy - 30 + Mathf.Sin(rad) * d * 0.6f; - float starAlpha = Mathf.Clamp01(1f - d / 220f) * alpha; - GUI.color = new Color(rarityCol.r, rarityCol.g, rarityCol.b, starAlpha); - float sz = s.size * (1f + Mathf.Sin(animTime * 5f + s.angle) * 0.3f); - GUI.DrawTexture(new Rect(sx - sz / 2, sy - sz / 2, sz, sz), Texture2D.whiteTexture); + float sx = cx + Mathf.Cos(rad) * d * 1.3f; + float sy = starCy + Mathf.Sin(rad) * d * 0.8f; + float starAlpha = Mathf.Clamp01(1f - d / 260f) * alpha; + GUI.color = WithAlpha(rarityCol, starAlpha); + float sz = s.size * 1.2f * (1f + Mathf.Sin(animTime * 5f + s.angle) * 0.3f); + GUI.DrawTexture(new Rect(sx - sz / 2, sy - sz / 2, sz, sz), disc); } + GUI.color = Color.white; } - headerStyleCache.normal.textColor = new Color(1, 1, 1, alpha); + headerStyleCache.normal.textColor = WithAlpha(t.textPrimary, alpha); + GUI.Label(new Rect(px, py + 18f, panelW, 52f), "포획 성공!", headerStyleCache); + + // 초상 — 도감·포획 창·배틀팀과 같은 3D 썸네일(InsectVisual). 이 팝업만 2D 도형 초상을 그려 + // 같은 곤충이 잡기 직전엔 3D, 잡은 순간엔 평면으로 보였다. + GUI.color = new Color(1f, 1f, 1f, alpha); + UISurface.Rounded(frame, Color.Lerp(t.surfaceBase, rarityCol, 0.16f)); GUI.color = Color.white; - GUI.Label(new Rect(px, py + 14, panelW, 36), "포획 성공!", headerStyleCache); + if (insectData != null) + InsectVisual.Draw(frame.center.x, frame.center.y, 208f, insectData, insectShiny, alpha); + else + DrawTypedInsectPortrait(frame.center.x, frame.center.y, insectId, insectRarity, alpha); - DrawTypedInsectPortrait(cx, py + 105, insectId, insectRarity, alpha); + // 처음 잡은 종 — 초상 오른쪽 위에 걸친다. 살짝 맥동해 "도감에 새로 올랐다"가 먼저 눈에 든다. + if (capturedNewSpecies) + { + float pop = 1f + Mathf.Sin(animTime * 6f) * 0.04f; + float chipW = 104f * pop, chipH = 38f * pop; + GUI.color = new Color(1f, 1f, 1f, alpha); + UISurface.Chip(new Rect(frame.xMax - chipW * 0.62f, frame.y - chipH * 0.4f, chipW, chipH), + "NEW", t.accentCoral, t.textPrimary); + GUI.color = Color.white; + } - nameStyleCache.normal.textColor = new Color(rarityCol.r, rarityCol.g, rarityCol.b, alpha); - GUI.Label(new Rect(px, py + 175, panelW, 40), insectName, nameStyleCache); + nameStyleCache.normal.textColor = WithAlpha(rarityCol, alpha); + UIHelper.LabelFit(new Rect(px + 24f, py + 306f, panelW - 48f, 56f), + successTitleName, nameStyleCache); - subStyleCache.normal.textColor = new Color(SubGrayBase.r, SubGrayBase.g, SubGrayBase.b, alpha); - GUI.Label(new Rect(px, py + 216, panelW, 28), - $"Lv.{insectLevel} | {insectRarity}", subStyleCache); + subStyleCache.normal.textColor = WithAlpha(t.textSecondary, alpha); + GUI.Label(new Rect(px, py + 360f, panelW, 36f), + $"Lv.{insectLevel} · {insectRarity.Korean()}", subStyleCache); + // ── 개체값 감정 ── Color gc = GetGradeColor(capturedGrade); - float gradeBoxY = py + 256; - - GUI.color = new Color(0.1f, 0.12f, 0.18f, 0.8f * alpha); - GUI.DrawTexture(new Rect(px + 30, gradeBoxY, panelW - 60, 120), Texture2D.whiteTexture); - GUI.color = new Color(gc.r, gc.g, gc.b, 0.6f * alpha); - GUI.DrawTexture(new Rect(px + 30, gradeBoxY, 5, 120), Texture2D.whiteTexture); + Rect gradeBox = new Rect(px + 32f, py + 408f, panelW - 64f, 128f); + GUI.color = new Color(1f, 1f, 1f, alpha); + UISurface.Rounded(gradeBox, t.surfaceRaised); + UISurface.Flat(new Rect(gradeBox.x + 4f, gradeBox.y + UITheme.Radius.Card, 5f, + gradeBox.height - UITheme.Radius.Card * 2f), gc); GUI.color = Color.white; - gradeTitleStyleCache.normal.textColor = new Color(GradeTitleGrayBase.r, GradeTitleGrayBase.g, GradeTitleGrayBase.b, alpha); - UIHelper.LabelFit(new Rect(px + 44, gradeBoxY + 6, 120, 22), "개체값 감정", gradeTitleStyleCache); - - gradeLblStyleCache.normal.textColor = new Color(gc.r, gc.g, gc.b, alpha); - GUI.Label(new Rect(px + 44, gradeBoxY + 26, 65, 56), GetGradeLabel(capturedGrade), gradeLblStyleCache); + gradeTitleStyleCache.normal.textColor = WithAlpha(t.textMuted, alpha); + UIHelper.LabelFit(new Rect(gradeBox.x + 22f, gradeBox.y + 8f, 180f, 30f), "개체값 감정", gradeTitleStyleCache); - pctLblStyleCache.normal.textColor = new Color(gc.r, gc.g, gc.b, alpha * 0.8f); - GUI.Label(new Rect(px + 102, gradeBoxY + 38, 100, 30), $"{capturedIvPct * 100:0}%", pctLblStyleCache); + gradeLblStyleCache.normal.textColor = WithAlpha(gc, alpha); + GUI.Label(new Rect(gradeBox.x + 22f, gradeBox.y + 36f, 76f, 72f), GetGradeLabel(capturedGrade), gradeLblStyleCache); - float ivX = px + 210; - float ivW = panelW - 260; - DrawMiniIVBar(ivX, gradeBoxY + 22, ivW, "HP", capturedIvHp, alpha); - DrawMiniIVBar(ivX, gradeBoxY + 52, ivW, "ATK", capturedIvAtk, alpha); - DrawMiniIVBar(ivX, gradeBoxY + 82, ivW, "DEF", capturedIvDef, alpha); + pctLblStyleCache.normal.textColor = WithAlpha(gc, alpha * 0.85f); + GUI.Label(new Rect(gradeBox.x + 98f, gradeBox.y + 52f, 110f, 40f), $"{capturedIvPct * 100:0}%", pctLblStyleCache); - float rewardY = gradeBoxY + 132; - GUI.color = new Color(0.15f, 0.17f, 0.25f, 0.7f * alpha); - GUI.DrawTexture(new Rect(px + 36, rewardY, panelW - 72, 68), Texture2D.whiteTexture); + float ivX = gradeBox.x + 226f; + float ivW = gradeBox.xMax - 20f - ivX; + DrawMiniIVBar(ivX, gradeBox.y + 14f, ivW, "HP", capturedIvHp, alpha); + DrawMiniIVBar(ivX, gradeBox.y + 50f, ivW, "ATK", capturedIvAtk, alpha); + DrawMiniIVBar(ivX, gradeBox.y + 86f, ivW, "DEF", capturedIvDef, alpha); - rewardLabelStyleCache.normal.textColor = new Color(GradeTitleGrayBase.r, GradeTitleGrayBase.g, GradeTitleGrayBase.b, alpha); + // ── 보상 ── + Rect rewardBox = new Rect(px + 32f, py + 548f, panelW - 64f, 72f); + GUI.color = new Color(1f, 1f, 1f, alpha); + UISurface.Rounded(rewardBox, t.surfaceBase); GUI.color = Color.white; - UIHelper.LabelFit(new Rect(px, rewardY + 4, panelW, 22), "보상", rewardLabelStyleCache); - rewardValStyleCache.normal.textColor = new Color(RewardCandyBase.r, RewardCandyBase.g, RewardCandyBase.b, alpha); - GUI.Label(new Rect(px, rewardY + 32, panelW / 2f, 30), $"+{candyReward} 캔디", rewardValStyleCache); + rewardLabelStyleCache.normal.textColor = WithAlpha(t.textMuted, alpha); + GUI.Label(new Rect(rewardBox.x + 20f, rewardBox.y, 90f, rewardBox.height), "보상", rewardLabelStyleCache); - rewardValStyleCache.normal.textColor = new Color(RewardExpBase.r, RewardExpBase.g, RewardExpBase.b, alpha); - GUI.Label(new Rect(px + panelW / 2f, rewardY + 32, panelW / 2f, 30), $"+{expReward} XP", rewardValStyleCache); + float valW = (rewardBox.width - 110f) / 2f; + rewardValStyleCache.normal.textColor = WithAlpha(t.accentCoral, alpha); + GUI.Label(new Rect(rewardBox.x + 110f, rewardBox.y, valW, rewardBox.height), $"+{candyReward} 캔디", rewardValStyleCache); + rewardValStyleCache.normal.textColor = WithAlpha(t.accentMint, alpha); + GUI.Label(new Rect(rewardBox.x + 110f + valW, rewardBox.y, valW, rewardBox.height), $"+{expReward} 경험치", rewardValStyleCache); } private void DrawFailPopup(float alpha) { - float cx = Screen.width / 2f; - float cy = Screen.height * 0.35f; + UITheme t = UITheme.Instance; + Rect card = FailRect(HudFrame.Current); + HudStage.Request(HudStageItem.CaptureResult, card); + float w = card.width; + HandleDismiss(card); - GUI.color = new Color(0, 0, 0, 0.6f * alpha); - GUI.DrawTexture(new Rect(cx - 240, cy - 20, 480, 120), Texture2D.whiteTexture); + GUI.color = new Color(1f, 1f, 1f, alpha); + UISurface.Card(card, t.surfaceCard, Color.Lerp(t.surfaceBorder, t.accentCoral, 0.6f)); + UISurface.Flat(new Rect(card.x + UITheme.Radius.Card, card.y + 3f, card.width - UITheme.Radius.Card * 2f, 4f), t.accentCoral); GUI.color = Color.white; - failStyleCache.normal.textColor = new Color(FailMsgBase.r, FailMsgBase.g, FailMsgBase.b, alpha); - GUI.Label(new Rect(cx - 240, cy - 10, 480, 55), "도망갔다...", failStyleCache); + failStyleCache.normal.textColor = WithAlpha(t.accentCoral, alpha); + UIHelper.LabelFit(new Rect(card.x, card.y + 14f, w, 64f), "도망갔다...", failStyleCache); - failSubStyleCache.normal.textColor = new Color(FailSubBase.r, FailSubBase.g, FailSubBase.b, alpha); - GUI.Label(new Rect(cx - 240, cy + 45, 480, 32), $"{insectName}(이)가 도망쳤습니다!", failSubStyleCache); + failSubStyleCache.normal.textColor = WithAlpha(t.textSecondary, alpha); + UIHelper.LabelFit(new Rect(card.x + 20f, card.y + 82f, w - 40f, 42f), $"{insectName}(이)가 도망쳤습니다!", failSubStyleCache); } public static void DrawTypedInsectPortrait(float cx, float cy, string id, InsectRarity rarity, float alpha) @@ -980,24 +1098,25 @@ private static void PortraitLonghorn(float cx, float cy, float s, Color body, Co private void DrawMiniIVBar(float x, float y, float w, string label, int iv, float alpha) { - ivLblStyleCache.normal.textColor = new Color(IvLblBase.r, IvLblBase.g, IvLblBase.b, alpha); - GUI.Label(new Rect(x, y, 50, 22), label, ivLblStyleCache); + UITheme t = UITheme.Instance; + ivLblStyleCache.normal.textColor = new Color(t.textSecondary.r, t.textSecondary.g, t.textSecondary.b, alpha); + GUI.Label(new Rect(x, y, 56, 28), label, ivLblStyleCache); - float barX = x + 52; - float barW = w - 90; + float barX = x + 58; + float barW = w - 100; float barH = 14f; - GUI.color = new Color(0.15f, 0.15f, 0.2f, alpha); - UIShapes.Part(new Rect(barX, y + 4, barW, barH), GUI.color); + GUI.color = new Color(t.surfaceBase.r, t.surfaceBase.g, t.surfaceBase.b, alpha); + UIShapes.Part(new Rect(barX, y + 7, barW, barH), GUI.color); float ratio = iv / (float)PlayerInsectData.MaxIV; Color bc = GetIVBarColor(iv); GUI.color = new Color(bc.r, bc.g, bc.b, alpha); - UIShapes.Part(new Rect(barX, y + 4, barW * ratio, barH), GUI.color); + UIShapes.Part(new Rect(barX, y + 7, barW * ratio, barH), GUI.color); GUI.color = Color.white; ivVsStyleCache.normal.textColor = new Color(bc.r, bc.g, bc.b, alpha); - UIHelper.LabelFit(new Rect(barX + barW + 6, y, 34, 22), $"{iv}", ivVsStyleCache); + UIHelper.LabelFit(new Rect(barX + barW + 6, y, 36, 28), $"{iv}", ivVsStyleCache); } public static void DrawInsectPortrait(float cx, float cy, InsectRarity rarity, float alpha) diff --git a/Assets/Scripts/UI/CashShopUI.cs b/Assets/Scripts/UI/CashShopUI.cs index 379f31f..4e0e115 100644 --- a/Assets/Scripts/UI/CashShopUI.cs +++ b/Assets/Scripts/UI/CashShopUI.cs @@ -33,6 +33,18 @@ public class CashShopUI : MonoBehaviour, IModalUI // OnGUI 매 호출마다 FindFirstObjectByType 비용 회피용 캐시 private OutfitBonusProvider cachedBonusProvider; private PlayerCurrencyWallet cachedWallet; + + // 왼쪽 캐릭터 칸 — 의상 창과 같은 3D 마네킹. 없으면(배선 실패) 2D 도트로 물러난다. + private CharacterModelPreviewRenderer characterPreview; + + /// + /// 3D 프리뷰 렌더러를 받는다. 예전엔 2D 도트 캐릭터였는데, 2D는 모자 11종·겉옷 형태·도구 대부분을 + /// 못 그려 **방금 산 옷이 상점 캐릭터에 안 보였다**(필드 캐릭터와 다른 사람). + /// + public void AutoWire(CharacterModelPreviewRenderer preview) + { + if (characterPreview == null) characterPreview = preview; + } private InsectDatabase cachedInsectDb; // 가챠 영역 OnGUI 매 호출 new GUIStyle 11개 회귀 제거용 캐시 (DexScreenUI/BattleTeamUI 패턴). @@ -68,7 +80,7 @@ private struct CardText public string title; // 이름(굵게) public string sub; // 수량 배지 — 없으면 null public string desc; // 설명 - public string price; // 💎 보석가 — 실결제 카드엔 없다(null, 아래 주석 참조) + public string price; // 보석가 — 실결제 카드엔 없다(null, 아래 주석 참조) } private readonly Dictionary cardTextCache = new Dictionary(); @@ -108,7 +120,7 @@ private CardText GetItemCardText(CashShopItem item) t.sub = item.rewardCount > 1 ? $"x{item.rewardCount}" : null; t.desc = $"{item.description}"; // 보석가는 품목 데이터라 불변 — 실결제 가격과 달리 캐시해도 안전하다. - t.price = $"💎 {item.gemPrice}"; + t.price = $"보석 {item.gemPrice}"; cardTextCache[item.itemId] = t; return t; } @@ -120,14 +132,38 @@ private CardText GetBoxCardText(string boxId, string title, int price) t.title = $"{title}"; t.sub = null; t.desc = null; // 확률표는 gachaRateTextCache가 따로 든다(공시 정합성 때문에 출처가 다르다) - t.price = $"💎 {price}"; + t.price = $"보석 {price}"; cardTextCache[boxId] = t; return t; } - private static readonly Color GachaPriceAffordCol = new Color(0.4f, 0.7f, 1f); - private static readonly Color GachaRateGrayCol = new Color(0.85f, 0.85f, 0.85f); - private static readonly Color GachaCandyPinkCol = new Color(1f, 0.6f, 0.8f); + // 재화 라벨 — 💎·🪙 이모지는 스탠드얼론·기기 기본 폰트에 없어 □로 깨졌다(2026-09-30 검수 빌드 캡처). + // 다른 화면(`PlayerCurrencyUIController`·의상 창)과 같은 "보석 N"·"코인 N" 표기로 맞추고, + // 값이 바뀔 때만 문자열을 새로 만든다(OnGUI 패스마다 보간하지 않게). + private int gemsLabelValue = -1, coinsLabelValue = -1, headerGemsValue = -1; + private string gemsLabel, coinsLabel, headerGemsLabel; + + private string GemsLabel(int gems) + { + if (gems != gemsLabelValue || gemsLabel == null) { gemsLabelValue = gems; gemsLabel = $"보석 {gems}"; } + return gemsLabel; + } + + private string CoinsLabel(int coins) + { + if (coins != coinsLabelValue || coinsLabel == null) { coinsLabelValue = coins; coinsLabel = $"코인 {coins}"; } + return coinsLabel; + } + + private string HeaderGemsLabel(int gems) + { + if (gems != headerGemsValue || headerGemsLabel == null) { headerGemsValue = gems; headerGemsLabel = $"보석 {gems}"; } + return headerGemsLabel; + } + + private static Color GachaPriceAffordCol => UITheme.Instance.accentMint; + private static Color GachaRateGrayCol => UITheme.Instance.textSecondary; + private static Color GachaCandyPinkCol => UITheme.Instance.accentAmber; // OnGUI 본문 + Tab 0/Tab 1 캐시 (Tab 2와 같은 처리 패턴). private bool mainStylesReady; @@ -149,15 +185,15 @@ private CardText GetBoxCardText(string boxId, string title, int price) private GUIStyle itemPriceStyle; // textColor 동적 // OnGUI 매 프레임 new Color 회귀 제거용 (alpha/구성 고정). - private static readonly Color BackdropDimCol = new Color(0f, 0f, 0f, 0.6f); - private static readonly Color PanelBgCol = new Color(0.12f, 0.12f, 0.18f, 0.95f); - private static readonly Color CharAreaBgCol = new Color(0.04f, 0.06f, 0.12f, 0.7f); - private static readonly Color ResTitleSoftCol = new Color(0.85f, 0.9f, 1f); - private static readonly Color CoinGoldCol = new Color(1f, 0.85f, 0.3f); - private static readonly Color BonusGreenBoldCol = new Color(0.4f, 0.9f, 0.4f); - private static readonly Color BonusGreenLightCol = new Color(0.7f, 0.95f, 0.7f); - private static readonly Color GemLabelLightCol = new Color(0.9f, 0.95f, 1f); - private static readonly Color BuyButtonGreenCol = new Color(0.2f, 0.8f, 0.3f); + private static Color BackdropDimCol => UITheme.Instance.dimOverlay; + private static Color PanelBgCol => UITheme.Instance.surfaceBase; + private static Color CharAreaBgCol => UITheme.Instance.surfaceCard; + private static Color ResTitleSoftCol => UITheme.Instance.textPrimary; + private static Color CoinGoldCol => UITheme.Instance.accentAmber; + private static Color BonusGreenBoldCol => UITheme.Instance.accentMint; + private static Color BonusGreenLightCol => UITheme.Instance.accentMint; + private static Color GemLabelLightCol => UITheme.Instance.textPrimary; + private static Color BuyButtonGreenCol => UITheme.Instance.accentMint; private static readonly Color GemGlowCol = new Color(0.2f, 0.4f, 0.9f, 0.3f); private static readonly Color GemBorderCol = new Color(0.4f, 0.6f, 1f, 0.9f); private static readonly Color GemGradTopCol = new Color(0.5f, 0.7f, 1f, 0.9f); @@ -166,6 +202,15 @@ private CardText GetBoxCardText(string boxId, string title, int price) private readonly string[] tabNames = { "보석 충전", "아이템 상점", "랜덤 상자" }; + // ── 데스크톱 폭 ── + // 패널 1200이던 때 오른쪽 콘텐츠(패널 − 캐릭터 칸)가 812뿐이라 상자 세 장(최소 960)이 안 들어가 + // 골드 상자가 오른쪽에 잘리고 가로 스크롤이 생겼다(2026-09-30 검수 캡처). 1400이면 콘텐츠 1012 — + // 세로 스크롤바를 빼고도 960이 들어간다. 모바일은 화면 폭으로 clamp된다. 셋의 관계는 테스트가 고정한다. + internal const float DesktopPanelWidth = 1400f; + internal const float DesktopCharColumnWidth = 388f; // 캐릭터 칸 340 + 좌 여백 16 + 사이 32 + internal const float DesktopBoxTabMinWidth = 960f; // 상자 300×3 + 간격 15×3 + 상자 여백 + internal const float DesktopCardTabMinWidth = 840f; // 카드 260×3 + 간격 + private void InitMainStyles() { if (mainStylesReady) return; @@ -178,7 +223,8 @@ private void InitMainStyles() bonusLineStyle = new GUIStyle(GUI.skin.label) { fontSize = 17, alignment = TextAnchor.MiddleLeft, wordWrap = true }; bonusLineStyle.normal.textColor = BonusGreenLightCol; headerTitleStyle = new GUIStyle(GUI.skin.label) { richText = true, normal = { textColor = Color.cyan } }; - headerGemsStyle = new GUIStyle(GUI.skin.label) { richText = true, normal = { textColor = GachaPriceAffordCol } }; + // 크기는 스타일에 둔다 — 리치텍스트는 GUILayout이 폭을 기본 글자 크기로 재서 "보석 0"의 숫자가 잘렸다. + headerGemsStyle = new GUIStyle(GUI.skin.label) { fontSize = 28, fontStyle = FontStyle.Bold, normal = { textColor = GachaPriceAffordCol } }; tabStyle = new GUIStyle(GUI.skin.button) { fontSize = mobile ? 28 : 26, fontStyle = FontStyle.Bold }; feedbackStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, richText = true, normal = { textColor = Color.green } }; infoGrayStyle = new GUIStyle(GUI.skin.label) { fontSize = mobile ? 20 : 16, alignment = TextAnchor.MiddleCenter, normal = { textColor = Color.gray } }; @@ -311,7 +357,9 @@ private void OnGUI() // 모바일 세로는 긴 화면(1920+)을 더 활용해 확대된 카드의 스크롤을 줄임. 데스크톱은 기존 유지. // 세이프에어리어 + 세로 마진은 하네스가 뺀다. - Rect panelRect = UISafeLayout.CenteredPanel(1200f, UIScale.IsMobileLayout ? 1560f : 820f); + Rect panelRect = UISafeLayout.CenteredPanel( + UIScale.IsMobileLayout ? 1200f : DesktopPanelWidth, + UIScale.IsMobileLayout ? 1560f : 820f); float panelW = panelRect.width; float panelH = panelRect.height; float px = panelRect.x; @@ -331,14 +379,25 @@ private void OnGUI() UISurface.Card(charArea, CharAreaBgCol, UITheme.Instance.surfaceBorder); GUI.color = Color.white; - float charCx = charArea.x + charAreaW * 0.5f; - float charCy = charArea.y + charAreaH * 0.40f; - float charScale = 2.2f; - float swayX = Mathf.Sin(previewRotate * Mathf.Deg2Rad) * 12f * charScale; - CharacterPortraitRenderer.DrawWithOutfit(charCx, charCy, charScale, swayX); - // 재화 정보 (캐릭터 아래) — 폰트 확대에 맞춰 시작 오프셋/행 높이 확대. float infoY = charArea.y + charAreaH - 220f; + + // 지금 장착한 모습 그대로의 3D. 각도는 고정 — 흔들면 매 프레임 카메라를 다시 찍는다. + Texture me = characterPreview != null + ? characterPreview.GetEquippedPreview(CharacterModelPreviewRenderer.FrontYaw - 20f) : null; + if (me != null) + { + Rect stage = new Rect(charArea.x + 10f, charArea.y + 10f, charAreaW - 20f, Mathf.Max(1f, infoY - charArea.y - 20f)); + GUI.DrawTexture(stage, me, ScaleMode.ScaleToFit, true); + } + else + { + float charCx = charArea.x + charAreaW * 0.5f; + float charCy = charArea.y + charAreaH * 0.40f; + float charScale = 2.2f; + float swayX = Mathf.Sin(previewRotate * Mathf.Deg2Rad) * 12f * charScale; + CharacterPortraitRenderer.DrawWithOutfit(charCx, charCy, charScale, swayX); + } int gemsLeft = CashShopManager.Instance != null ? CashShopManager.Instance.Gems : 0; int coinsLeft = 0; if (cachedWallet == null) cachedWallet = FindFirstObjectByType(); @@ -348,9 +407,9 @@ private void OnGUI() // resStyle.normal.textColor는 gems/coins 라인마다 동적 갱신. resStyle.normal.textColor = GachaPriceAffordCol; - GUI.Label(new Rect(charArea.x + 16, infoY + 38, charAreaW - 32, 36), $"💎 {gemsLeft}", resStyle); + GUI.Label(new Rect(charArea.x + 16, infoY + 38, charAreaW - 32, 36), GemsLabel(gemsLeft), resStyle); resStyle.normal.textColor = CoinGoldCol; - GUI.Label(new Rect(charArea.x + 16, infoY + 78, charAreaW - 32, 36), $"🪙 {coinsLeft}", resStyle); + GUI.Label(new Rect(charArea.x + 16, infoY + 78, charAreaW - 32, 36), CoinsLabel(coinsLeft), resStyle); // 장비 보너스 요약 if (cachedBonusProvider == null) cachedBonusProvider = FindFirstObjectByType(); @@ -376,8 +435,8 @@ private void OnGUI() } // 우측 콘텐츠 영역 - float rightX = showingGachaResult || mobile ? px : px + 372f; - float rightW = showingGachaResult || mobile ? panelW : panelW - 388f; + float rightX = showingGachaResult || mobile ? px : px + DesktopCharColumnWidth - 16f; + float rightW = showingGachaResult || mobile ? panelW : panelW - DesktopCharColumnWidth; Rect contentArea = new Rect(rightX, py, rightW, panelH); GUILayout.BeginArea(contentArea); @@ -386,7 +445,7 @@ private void OnGUI() GUILayout.Label(" 보석 상점", headerTitleStyle); GUILayout.FlexibleSpace(); int gems = CashShopManager.Instance != null ? CashShopManager.Instance.Gems : 0; - GUILayout.Label($"💎 {gems}", headerGemsStyle); + GUILayout.Label(HeaderGemsLabel(gems), headerGemsStyle); GUILayout.Space(14); if (GUILayout.Button("X", GUILayout.Width(mobile ? 62 : 52), GUILayout.Height(mobile ? 60 : 48))) Toggle(); @@ -501,7 +560,7 @@ private void DrawScrollableTab(Rect contentArea) // 안 주면 레이아웃이 카드를 뷰포트에 욱여넣어 **찌그러뜨린다**(상자가 안 보인다는 증상). // 옛 `GUI.BeginScrollView` 경로는 contentWidth를 960/840으로 직접 줘서 가로 스크롤이 // 생겼는데, 레이아웃 스크롤뷰로 바꾸며 그 지정이 빠진 것이 회귀였다. - float minContentW = UIScale.IsMobileLayout ? 0f : (tab == 2 ? 960f : 840f); + float minContentW = UIScale.IsMobileLayout ? 0f : (tab == 2 ? DesktopBoxTabMinWidth : DesktopCardTabMinWidth); if (minContentW > 0f) GUILayout.BeginVertical(GUILayout.MinWidth(minContentW)); else GUILayout.BeginVertical(); @@ -712,7 +771,7 @@ private void DrawItemCard(CashShopItem item, int currentGems) GUILayout.EndVertical(); } - private static readonly Color ItemBuyBlueCol = new Color(0.2f, 0.7f, 0.9f); + private static Color ItemBuyBlueCol => UITheme.Instance.accentMint; // 박스 테마 색상 (매 프레임 new Color 제거). private static readonly Color BoxBronzeCol = new Color(0.6f, 0.4f, 0.2f); diff --git a/Assets/Scripts/UI/CharacterOutfitUI.cs b/Assets/Scripts/UI/CharacterOutfitUI.cs index 94093c7..e247b15 100644 --- a/Assets/Scripts/UI/CharacterOutfitUI.cs +++ b/Assets/Scripts/UI/CharacterOutfitUI.cs @@ -77,10 +77,18 @@ private static string StarsFor(int equipped, int total) private readonly OutfitLoadout previewLoadout = new OutfitLoadout(); private OutfitItem tryOnItem; // 호버 중인 카드. 실장착은 건드리지 않는다 private bool hoverFoundThisPass; - - // 호버 툴팁 (ScrollView 밖에서 렌더링) - private OutfitItem hoveredItemForTooltip; - private Rect hoveredCardScreenRect; + // 슬롯별로 **클릭(탭)해서 고른** 카드 — 입어보기가 호버에만 의존하면 터치 기기에서 아예 안 됐다. + // 슬롯마다 따로 담아 두어 여러 벌을 한꺼번에 맞춰 볼 수 있다. 창을 열 때 비운다. + private readonly Dictionary trySelections = new Dictionary(); + private bool previewCloseUp; + + internal enum ItemFilter { All, Owned, NotOwned } + private ItemFilter filter = ItemFilter.All; + private static readonly string[] FilterLabels = { "전체", "보유", "미보유" }; + private readonly List visibleItems = new List(); + private OutfitSlot visibleSlot = (OutfitSlot)(-1); + private ItemFilter visibleFilter; + private int visibleVersion = -1; public bool IsOpen => isOpen; @@ -99,6 +107,7 @@ private static string StarsFor(int equipped, int total) private GUIStyle titleStyle; private GUIStyle labelStyle; private GUIStyle coinStyle; + private GUIStyle coinRightStyle; // 데스크톱: 오른쪽 카드 열 아래에 오른쪽 정렬 private GUIStyle buttonStyle; private GUIStyle closeStyle; private GUIStyle bonusStyle; @@ -106,6 +115,10 @@ private static string StarsFor(int equipped, int total) private GUIStyle setActiveStyle; private GUIStyle infoStyleCache; private GUIStyle infoNameStyleCache; + private GUIStyle detailNameStyle; + private GUIStyle detailDescStyle; + private GUIStyle detailBonusStyle; + private GUIStyle detailSetStyle; private bool stylesInitialized; // 매 OnGUI 프레임 FindFirstObjectByType 회귀 차단 — 첫 조회 1회만. @@ -116,7 +129,15 @@ private static string StarsFor(int equipped, int total) public void Toggle() { isOpen = !isOpen; - if (isOpen) scrollPos = Vector2.zero; + if (isOpen) + { + scrollPos = Vector2.zero; + // 지난번에 돌려 둔 각도·입어보기가 남아 있으면 "내 옷이 이상하다"로 보인다 — 열 때마다 정면·실장착으로. + previewYaw = CharacterModelPreviewRenderer.FrontYaw; + previewCloseUp = false; + trySelections.Clear(); + tryOnItem = null; + } // 외형(성별·머리·얼굴)은 캐릭터 생성 화면에서만 바뀌므로 이 모달 밖에서만 변한다. // 여기서 한 번 표시해 주면 렌더러가 매 프레임 PlayerPrefs를 두드리지 않아도 된다. if (modelPreview != null) modelPreview.InvalidatePreview(); @@ -160,24 +181,25 @@ public void AutoWire(CharacterModelPreviewRenderer preview) // ── 캐릭터 미리보기 ── /// - /// 3D 마네킹이 준비돼 있으면 그것을, 아직이면 2D 도트 폴백을 그린다. - /// 드래그로 돌릴 수 있고, 카드에 마우스를 올리면 사기 전에 입어볼 수 있다(실장착 불변). + /// 3D 마네킹이 준비돼 있으면 그것을, 아직이면 2D 도트 폴백을 그린다. 드래그로 돌리고, + /// 오른쪽 위 버튼으로 정면 복귀·상반신 확대·입어보기 되돌리기를 한다. 아래는 상세 패널. /// private void DrawCharacterPreview(Rect area, bool mobile) { UITheme theme = UITheme.Instance; UISurface.Card(area, theme.surfaceBase, theme.surfaceBorder); - float infoH = mobile ? 42f : 84f; - Rect stage = new Rect(area.x + 8f, area.y + 8f, - area.width - 16f, Mathf.Max(1f, area.height - 16f - infoH)); + // 아래 상세(이름·설명·보너스 전체·세트). 예전엔 "현재 모자: 이름" 한 줄이라 설명과 보너스 상세는 + // **이미 가진** 아이템의 호버 툴팁에서만 보였다 — 사기 전에 무엇이 좋은지 알 수 없었다. + float stageH = PreviewStageHeight(area.width, area.height, mobile); + float infoH = area.height - 16f - stageH; + Rect stage = new Rect(area.x + 8f, area.y + 8f, area.width - 16f, stageH); Texture preview = null; if (modelPreview != null) { SyncPreviewLoadout(); - HandlePreviewDrag(stage); - preview = modelPreview.GetPreview(previewLoadout, previewYaw); + preview = modelPreview.GetPreview(previewLoadout, previewYaw, previewCloseUp); } if (preview != null) @@ -193,36 +215,416 @@ private void DrawCharacterPreview(Rect area, bool mobile) stage.center.x, stage.y + stage.height * 0.5f, charScale, swayX); } - // 지금 보고 있는 슬롯이 무엇을 입고 있는지. 이름 길이는 데이터가 정하고 상자는 고정이라 - // LabelFit으로 줄여 맞춘다(GUI.Label을 쓰면 text_fit_lint가 막는다). - OutfitItem shown = ResolvePreviewItem(selectedSlot); - string curName = shown != null ? shown.displayName : "(없음)"; - float infoY = area.yMax - infoH - 4f; + // 버튼을 **드래그 처리보다 먼저** — 버튼이 MouseDown을 쓰면(Use) 드래그가 시작되지 않는다. + // 그림(텍스처)은 이미 위에서 그렸으니 버튼이 그 위에 얹힌다. + if (modelPreview != null) DrawPreviewControls(stage, mobile); + if (modelPreview != null) HandlePreviewDrag(stage); + + Rect detail = new Rect(area.x + 16f, area.yMax - infoH, area.width - 32f, infoH - 8f); + float used = DrawItemDetail(detail, mobile); + + // 모바일엔 탭 아래 세트 목록 칸이 없다(탭이 상단 4열이다). 상세 아래가 남으면 거기에 둔다. if (mobile) + DrawActiveSets(detail.x, used + 12f, detail.width, detail.yMax); + } + + /// + /// 미리보기 칸에서 그림(stage)이 차지할 높이. 그림은 텍스처 비율(2:3)까지만 키운다 — 세로 화면처럼 + /// 칸이 길면 그 이상은 위아래 여백일 뿐이라 남는 높이를 상세에 준다(설명·입은 세트 진행이 들어간다). + /// 상세는 최소 150(모바일)·200(데스크톱)을 보장한다. + /// + internal static float PreviewStageHeight(float areaW, float areaH, bool mobile) + { + float infoMinH = mobile ? 150f : 200f; + float stageMaxH = (areaW - 16f) * CharacterModelPreviewRenderer.PreviewHeightPerWidth; + return Mathf.Clamp(areaH - 16f - infoMinH, 1f, Mathf.Max(1f, stageMaxH)); + } + + /// 정면 복귀 · 전신/상반신 · 입어보기 되돌리기. + private void DrawPreviewControls(Rect stage, bool mobile) + { + UITheme theme = UITheme.Instance; + float bw = mobile ? 132f : 120f, bh = mobile ? 54f : 42f, gap = 8f; // "원래대로" 네 글자가 안 잘리는 폭 + float bx = stage.xMax - bw - 6f; + float by = stage.y + 6f; + + if (UISurface.Button(new Rect(bx, by, bw, bh), "정면", theme.surfaceRaised, tabNormalStyle)) + previewYaw = CharacterModelPreviewRenderer.FrontYaw; + by += bh + gap; + if (UISurface.Button(new Rect(bx, by, bw, bh), previewCloseUp ? "전신" : "확대", theme.surfaceRaised, tabNormalStyle, previewCloseUp)) + previewCloseUp = !previewCloseUp; + by += bh + gap; + if (HasTryOn() && UISurface.Button(new Rect(bx, by, bw, bh), "원래대로", theme.accentCoral, tabNormalStyle)) + trySelections.Clear(); + } + + /// + /// 실장착을 복사한 뒤 슬롯별로 **고른 카드**(입어보기)를 덮고, 마지막으로 호버 중인 카드를 덮는다. + /// 슬롯마다 따로 고를 수 있어 "이 모자에 이 상의" 같은 조합을 사기 전에 맞춰 볼 수 있다. + /// 호버만 있던 예전 방식은 터치 기기에서 입어보기가 아예 안 됐다. + /// + private void SyncPreviewLoadout() + { + previewLoadout.CopyFrom(outfitManager); + foreach (KeyValuePair pick in trySelections) + if (pick.Value != null) previewLoadout.Set(pick.Key, pick.Value.itemId); + if (tryOnItem != null) previewLoadout.Set(tryOnItem.slot, tryOnItem.itemId); + } + + /// 입어보기가 실장착과 하나라도 다른가(되돌리기 버튼 표시용). + private bool HasTryOn() + { + foreach (KeyValuePair pick in trySelections) + if (pick.Value != null && !outfitManager.IsEquipped(pick.Value.itemId)) return true; + return false; + } + + /// 상세 패널이 보여 줄 아이템 — 호버 > 이 슬롯에서 고른 카드 > 이 슬롯의 실장착. + private OutfitItem DetailItem() + { + if (tryOnItem != null && tryOnItem.slot == selectedSlot) return tryOnItem; + if (trySelections.TryGetValue(selectedSlot, out OutfitItem picked) && picked != null) return picked; + return outfitManager.GetEquipped(selectedSlot); + } + + /// + /// 입고 있는 세트의 진행(별·발동 보너스). 를 넘는 항목은 그리지 않는다 — + /// 데스크톱 탭 열은 세트가 셋이면 아래 보너스 요약 줄에 겹쳤다. + /// + private void DrawActiveSets(float x, float setY, float w, float maxY) + { + if (bonusProvider == null) return; + ActiveSetInfo[] activeSets = bonusProvider.GetActiveSets(); + foreach (ActiveSetInfo setInfo in activeSets) { - UIHelper.LabelFit(new Rect(area.x + 16f, infoY, area.width - 32f, 38f), - $"{slotLabels[(int)selectedSlot]}: {curName}", infoNameStyleCache); + bool active = setInfo.isPartialActive || setInfo.isFullActive; + GUIStyle sStyle = active ? setActiveStyle : setStyle; + // 발동하면 보너스 줄이 붙어 3줄, 아니면 2줄 — 한글 줄높이 ≈ fontSize × 1.35 (16px이면 68·46) + float setH = sStyle.fontSize * 1.35f * (active ? 3 : 2) + 3f; + if (setY + setH > maxY) break; + + Color prevColor = sStyle.normal.textColor; + if (active) sStyle.normal.textColor = setInfo.set.setColor; + + string setLabel = ActiveSetLabel(setInfo); + Rect setRect = new Rect(x, setY, w, setH); + + // 세트 완성 글로우 + if (setCompleteFlashTimer > 0f && active) + { + float glowAlpha = Mathf.Clamp01(setCompleteFlashTimer / 1f) * 0.6f; + UIHelper.DrawRarityGlow(setRect, setInfo.set.setColor, glowAlpha, Time.time); + } + + GUI.Label(setRect, setLabel, sStyle); + sStyle.normal.textColor = prevColor; + setY += setH + 4; } - else + } + + private readonly Dictionary<(string, int), string> activeSetLabelCache = new Dictionary<(string, int), string>(); + + /// + /// "이름 (n/총)\n별\n발동 보너스" — 입은 벌 수와 발동 단계로만 바뀌므로 그 둘로 캐시한다 + /// (예전엔 OnGUI 패스마다 세트당 문자열 3~4개를 이어 붙였다). + /// + private string ActiveSetLabel(ActiveSetInfo setInfo) + { + int stage = setInfo.isFullActive ? 2 : setInfo.isPartialActive ? 1 : 0; + (string, int) key = (setInfo.set.setId, setInfo.equippedCount * 4 + stage); + if (activeSetLabelCache.TryGetValue(key, out string label)) return label; + + int total = setInfo.set.requiredItemIds.Length; + label = $"{setInfo.set.displayName} ({setInfo.equippedCount}/{total})\n{StarsFor(setInfo.equippedCount, total)}"; + if (setInfo.isFullActive) label += "\n" + setInfo.set.fullBonus.GetPrimaryBonusText(); + else if (setInfo.isPartialActive) label += "\n" + setInfo.set.partialBonus.GetPrimaryBonusText(); + activeSetLabelCache[key] = label; + return label; + } + + private string measuredDescId; + private float measuredDescW = -1f, measuredDescH; + + /// 상세 설명이 줄바꿈 후 차지할 높이 — 아이템·폭이 같으면 다시 재지 않는다. + private float DescriptionHeight(OutfitItem item, float width) + { + if (item.itemId != measuredDescId || !Mathf.Approximately(width, measuredDescW)) { - GUI.Label(new Rect(area.x + 16f, infoY, area.width - 32f, 32f), - $"현재 {slotLabels[(int)selectedSlot]}:", infoStyleCache); - UIHelper.LabelFit(new Rect(area.x + 16f, infoY + 32f, area.width - 32f, 36f), - curName, infoNameStyleCache); + measuredDescId = item.itemId; + measuredDescW = width; + measuredDescH = UIHelper.MeasureWrappedHeight(detailDescStyle, item.description, width); } + return measuredDescH; } - /// 실장착을 복사한 뒤 호버 중인 아이템만 덮어쓴다 — 이게 입어보기(try-on)다. - private void SyncPreviewLoadout() + /// 그린 내용의 아래 끝 y — 남는 자리에 세트 진행을 이어 그린다. + private float DrawItemDetail(Rect r, bool mobile) { - previewLoadout.CopyFrom(outfitManager); - if (tryOnItem != null) previewLoadout.Set(tryOnItem.slot, tryOnItem.itemId); + UITheme theme = UITheme.Instance; + OutfitItem item = DetailItem(); + if (item == null) + { + GUI.Label(new Rect(r.x, r.y, r.width, 36f), "아이템을 골라 보세요", detailDescStyle); + return r.y + 36f; + } + + bool owned = outfitManager.IsOwned(item.itemId); + bool equipped = outfitManager.IsEquipped(item.itemId); + + // 1줄: 이름 + 상태 칩 + float chipW = mobile ? 150f : 132f; + float nameH = 40f; + UIHelper.LabelFit(new Rect(r.x, r.y, r.width - chipW - 8f, nameH), item.displayName, detailNameStyle); + string status = StatusTextFor(item, owned, equipped); + if (!string.IsNullOrEmpty(status)) + { + Color chipBg = equipped ? theme.accentAmber : owned ? theme.accentMint + : item.gemPrice > 0 ? new Color(0.45f, 0.3f, 0.75f) : theme.surfaceRaised; + Color chipFg = equipped || owned ? theme.surfaceBase : theme.textPrimary; + UISurface.Chip(new Rect(r.xMax - chipW, r.y + 5f, chipW, 30f), status, chipBg, chipFg); + } + float y = r.y + nameH + 2f; + + // 2줄: 설명 — 칸이 좁으면(가로 모바일) 세트/획득 줄을 우선하고 설명은 뺀다. + // 세로 화면처럼 칸이 길면 설명을 세 줄까지 준다(데스크톱은 두 줄 52 그대로). + const float bonusAndSetH = 34f + 32f; + float descMax = Mathf.Min(r.yMax - y - bonusAndSetH - 2f, mobile ? 110f : 52f); + if (descMax >= 50f) + { + // 데스크톱은 두 줄 칸 고정(예전 그대로). 모바일 긴 칸은 글 높이만큼만 — 한 줄 설명 밑이 비지 않게. + float descH = mobile ? Mathf.Clamp(DescriptionHeight(item, r.width), 28f, descMax) : descMax; + UIHelper.LabelFit(new Rect(r.x, y, r.width, descH), item.description, detailDescStyle); + y += descH + 2f; + } + + // 3줄: 보너스 전체 + UIHelper.LabelFit(new Rect(r.x, y, r.width, 32f), FullBonusTextFor(item), detailBonusStyle); + y += 34f; + + // 4줄: 세트 진행 또는 획득 방법 + OutfitSetDefinition set = SetOf(item.itemId); + if (set != null) + { + Color prev = detailSetStyle.normal.textColor; + detailSetStyle.normal.textColor = Color.Lerp(set.setColor, Color.white, 0.35f); + UIHelper.LabelFit(new Rect(r.x, y, r.width, 32f), SetLineFor(set), detailSetStyle); + detailSetStyle.normal.textColor = prev; + y += 34f; + } + else if (!owned && item.price <= 0 && item.gemPrice <= 0 && !string.IsNullOrEmpty(item.unlockCondition)) + { + UIHelper.LabelFit(new Rect(r.x, y, r.width, 32f), DescribeUnlockCondition(item.unlockCondition), detailSetStyle); + y += 34f; + } + return y; + } + + private readonly Dictionary statusTextCache = new Dictionary(); + private readonly Dictionary fullBonusCache = new Dictionary(); + private readonly Dictionary setLineCache = new Dictionary(); + private int setLineVersion = -1; + + private string StatusTextFor(OutfitItem item, bool owned, bool equipped) + { + if (equipped) return "장착중"; + if (owned) return "보유"; + if (!string.IsNullOrEmpty(item.unlockCondition) && item.price <= 0 && item.gemPrice <= 0) return "잠김"; + if (!statusTextCache.TryGetValue(item.itemId, out string text)) + { + text = PriceLabel(item); + statusTextCache[item.itemId] = text; + } + return text; + } + + private readonly Dictionary priceLabelCache = new Dictionary(); + + /// + /// 가격 표기 — "보석 800" / "코인 300". 🪙·💎 이모지는 스탠드얼론 기본 폰트에 없어 **□로 깨졌다** + /// (2026-09-30 검수 빌드 캡처). 카드마다·패스마다 보간 문자열을 만들지 않게 캐시한다. + /// + private string PriceLabel(OutfitItem item) + { + if (!priceLabelCache.TryGetValue(item.itemId, out string text)) + { + text = item.gemPrice > 0 ? $"보석 {item.gemPrice}" : item.price > 0 ? $"코인 {item.price}" : ""; + priceLabelCache[item.itemId] = text; + } + return text; + } + + private string footerText; + private int footerCoins = -1, footerGems = -1; + + private static GUIStyle BuildSummaryStyle() + { + GUIStyle s = new GUIStyle(GUI.skin.label); + s.fontSize = 19; + s.fontStyle = FontStyle.Bold; + s.normal.textColor = new Color(0.4f, 0.9f, 0.4f); + s.alignment = TextAnchor.MiddleLeft; + return s; + } + + private static readonly GUIContent summaryProbe = new GUIContent(); + private string summaryLinesSource, summaryLines; + private float summaryLinesWidth = -1f; + + /// + /// 데스크톱 요약 줄은 미리보기 왼쪽 폭(~540)이라 보너스가 많으면 넘친다. 줄바꿈을 IMGUI에 맡기면 + /// 한글 사이 아무 데서나 끊겨 "캔디 / +2%"처럼 항목이 갈렸다(검수 캡처) — 항목 경계(공백)에서 + /// 가운데에 가장 가까운 곳을 골라 직접 두 줄로 나눈다. 한 줄에 들어가면 그대로 둔다. + /// + private string SummaryForWidth(string summary, GUIStyle style, float width) + { + if (ReferenceEquals(summary, summaryLinesSource) && Mathf.Approximately(width, summaryLinesWidth)) + return summaryLines; + summaryLinesSource = summary; + summaryLinesWidth = width; + summaryProbe.text = summary; + summaryLines = style.CalcSize(summaryProbe).x <= width ? summary : SplitAtMiddleSpace(summary); + return summaryLines; } - private OutfitItem ResolvePreviewItem(OutfitSlot slot) + /// 가운데에 가장 가까운 공백 하나를 줄바꿈으로 — 공백이 없으면 그대로. + internal static string SplitAtMiddleSpace(string text) { - if (tryOnItem != null && tryOnItem.slot == slot) return tryOnItem; - return outfitManager.GetEquipped(slot); + int mid = text.Length / 2, best = -1; + for (int i = 0; i < text.Length; i++) + if (text[i] == ' ' && (best < 0 || Mathf.Abs(i - mid) < Mathf.Abs(best - mid))) best = i; + return best > 0 ? text.Substring(0, best) + "\n" + text.Substring(best + 1) : text; + } + + private string equippedSummary; + private OutfitStatBonus equippedSummaryFor; + + /// + /// 하단 "장비 보너스: 포획+2% …" — 장착이 바뀔 때만 다시 만든다(예전엔 OnGUI 패스마다 7번 이어 붙였다). + /// + internal string EquippedSummaryText(OutfitStatBonus total) + { + if (equippedSummary != null && SameBonus(total, equippedSummaryFor)) return equippedSummary; + equippedSummaryFor = total; + string summary = "장비 보너스:"; + if (total.captureChanceBonus > 0f) summary += $" 포획+{total.captureChanceBonus * 100f:0}%"; + if (total.atkBonus > 0f) summary += $" ATK+{total.atkBonus * 100f:0}%"; + if (total.defBonus > 0f) summary += $" DEF+{total.defBonus * 100f:0}%"; + if (total.moveSpeedBonus > 0f) summary += $" 이속+{total.moveSpeedBonus * 100f:0}%"; + if (total.expMultiplier > 0f) summary += $" 경험치+{total.expMultiplier * 100f:0}%"; + if (total.candyMultiplier > 0f) summary += $" 캔디+{total.candyMultiplier * 100f:0}%"; + if (total.rareSpawnBonus > 0f) summary += $" 레어+{total.rareSpawnBonus * 100f:0}%"; + equippedSummary = summary; + return summary; + } + + // 구조체 기본 Equals는 박싱(할당)이라 필드를 직접 비교한다. + private static bool SameBonus(OutfitStatBonus a, OutfitStatBonus b) => + a.captureChanceBonus == b.captureChanceBonus && a.atkBonus == b.atkBonus && a.defBonus == b.defBonus + && a.moveSpeedBonus == b.moveSpeedBonus && a.expMultiplier == b.expMultiplier + && a.candyMultiplier == b.candyMultiplier && a.rareSpawnBonus == b.rareSpawnBonus; + + private string FooterText(int coins, int gems) + { + if (coins != footerCoins || gems != footerGems || footerText == null) + { + footerCoins = coins; + footerGems = gems; + footerText = $"보유 코인 {coins} · 보석 {gems}"; + } + return footerText; + } + + private string FullBonusTextFor(OutfitItem item) + { + if (!fullBonusCache.TryGetValue(item.itemId, out string text)) + { + text = FullBonusText(item.statBonus); + fullBonusCache[item.itemId] = text; + } + return text; + } + + /// "포획 +2% · 경험치 +1%" — 카드의 대표 1개와 달리 전부. 없으면 "보너스 없음". + internal static string FullBonusText(OutfitStatBonus b) + { + System.Text.StringBuilder sb = new System.Text.StringBuilder(); + void Add(string label, float v) + { + if (v <= 0f) return; + if (sb.Length > 0) sb.Append(" · "); + sb.Append(label).Append(" +").Append(Mathf.RoundToInt(v * 100f)).Append('%'); + } + Add("포획", b.captureChanceBonus); + Add("ATK", b.atkBonus); + Add("DEF", b.defBonus); + Add("이속", b.moveSpeedBonus); + Add("경험치", b.expMultiplier); + Add("캔디", b.candyMultiplier); + Add("레어", b.rareSpawnBonus); + return sb.Length > 0 ? sb.ToString() : "보너스 없음"; + } + + /// "◆ 서부의 총잡이 세트 2/6 보유 · 3벌부터 포획 +3%". 소유가 바뀌면 다시 만든다. + private string SetLineFor(OutfitSetDefinition set) + { + if (setLineVersion != outfitManager.OwnershipVersion) + { + setLineCache.Clear(); + setLineVersion = outfitManager.OwnershipVersion; + } + if (setLineCache.TryGetValue(set.setId, out string text)) return text; + + int owned = 0; + for (int i = 0; i < set.requiredItemIds.Length; i++) + if (outfitManager.IsOwned(set.requiredItemIds[i])) owned++; + text = $"◆ {set.displayName} 세트 {owned}/{set.requiredItemIds.Length} 보유 · {set.partialThreshold}벌부터 {set.partialBonus.GetPrimaryBonusText()}"; + setLineCache[set.setId] = text; + return text; + } + + private static Dictionary setByItem; + + /// + /// 아이템이 속한 세트(없으면 null). 예전 카드의 세트 점은 **이미 한 벌 이상 입은** 세트에만 찍혀서, + /// 시작하지 않은 세트는 어떤 옷이 한 벌인지 알 수 없었다. + /// + internal static OutfitSetDefinition SetOf(string itemId) + { + if (string.IsNullOrEmpty(itemId)) return null; + if (setByItem == null) + { + setByItem = new Dictionary(); + foreach (OutfitSetDefinition s in OutfitSetCatalog.GetAllSets()) + foreach (string id in s.requiredItemIds) + if (!setByItem.ContainsKey(id)) setByItem[id] = s; + } + return setByItem.TryGetValue(itemId, out OutfitSetDefinition set) ? set : null; + } + + /// 필터를 거친 이 슬롯의 목록. 슬롯·필터·소유 버전이 같으면 재사용한다(OnGUI마다 거르지 않는다). + private List VisibleItems() + { + int ver = outfitManager.OwnershipVersion; + if (visibleSlot == selectedSlot && visibleFilter == filter && visibleVersion == ver) return visibleItems; + visibleSlot = selectedSlot; + visibleFilter = filter; + visibleVersion = ver; + visibleItems.Clear(); + foreach (OutfitItem item in outfitManager.GetItemsForSlot(selectedSlot)) + { + if (PassesFilter(filter, outfitManager.IsOwned(item.itemId))) visibleItems.Add(item); + } + return visibleItems; + } + + internal static bool PassesFilter(ItemFilter f, bool owned) + { + switch (f) + { + case ItemFilter.Owned: return owned; + case ItemFilter.NotOwned: return !owned; + default: return true; + } } private void HandlePreviewDrag(Rect stage) @@ -318,6 +720,7 @@ private void InitStyles() coinStyle.fontStyle = FontStyle.Bold; coinStyle.normal.textColor = new Color(1f, 0.84f, 0f, 1f); coinStyle.alignment = TextAnchor.MiddleLeft; + coinRightStyle = new GUIStyle(coinStyle) { alignment = TextAnchor.MiddleRight }; buttonStyle = new GUIStyle(GUI.skin.button); buttonStyle.normal.background = btnTex; @@ -337,7 +740,8 @@ private void InitStyles() bonusStyle.alignment = TextAnchor.MiddleCenter; setStyle = new GUIStyle(GUI.skin.label); - setStyle.fontSize = 13; + // 13은 검수 빌드 캡처에서 읽히지 않았다. 모바일은 미리보기 칸(폭 ~430)에 그려서 더 크게 둔다. + setStyle.fontSize = UIScale.IsMobileLayout ? 20 : 16; setStyle.normal.textColor = new Color(0.6f, 0.6f, 0.7f); setStyle.alignment = TextAnchor.MiddleLeft; setStyle.wordWrap = true; @@ -353,6 +757,17 @@ private void InitStyles() infoNameStyleCache = new GUIStyle(infoStyleCache) { fontSize = 24, fontStyle = FontStyle.Bold }; infoNameStyleCache.normal.textColor = Color.white; + + // 상세 패널 — 한글 줄높이 ≈ fontSize × 1.35에 맞춘 상자(40·52·32)다. + UITheme theme = UITheme.Instance; + detailNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 26, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + detailNameStyle.normal.textColor = theme.textPrimary; + detailDescStyle = new GUIStyle(GUI.skin.label) { fontSize = 18, alignment = TextAnchor.UpperLeft, wordWrap = true }; + detailDescStyle.normal.textColor = theme.textSecondary; + detailBonusStyle = new GUIStyle(GUI.skin.label) { fontSize = 19, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + detailBonusStyle.normal.textColor = theme.accentMint; + detailSetStyle = new GUIStyle(GUI.skin.label) { fontSize = 18, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + detailSetStyle.normal.textColor = theme.accentAmber; } private void OnGUI() @@ -382,9 +797,12 @@ private void OnGUI() previewRotate += Time.deltaTime * 30f; // 데스크톱은 미리보기를 가운데 크게 두고 아이템을 양옆에 나눠 거는 3분할이라 폭이 더 든다 - // (탭 + 좌 카드열 + 미리보기 + 우 카드열). 모바일 세로는 기존 1열 스택 그대로다. + // (탭 + 좌 카드열 + 미리보기 + 우 카드열). + // 모바일은 높이도 길게 청한다(하네스가 안전 영역으로 자른다) — 820 고정이던 때 세로 화면(1920)에서 + // 창이 가운데 43%만 쓰고 위아래가 비었다(2026-09-30 검수 캡처). 캐시샵과 같은 1560이다. Rect panelRect = UISafeLayout.CenteredPanel( - UIScale.IsMobileLayout ? 1200f : 1560f, 820f); + UIScale.IsMobileLayout ? 1200f : 1560f, + UIScale.IsMobileLayout ? 1560f : 820f); float panelW = panelRect.width; float panelH = panelRect.height; bool mobile = UIScale.IsMobileLayout; @@ -402,6 +820,27 @@ private void OnGUI() }); GUI.Label(new Rect(x + 24, y + 14, 540, 50), "캐릭터 꾸미기", bigTitle); + // 필터(전체·보유·미보유) — 제목 줄 오른쪽. 그리드 위에 줄을 새로 내면 카드가 한 줄 줄어든다. + { + UITheme theme = UITheme.Instance; + float chipW = mobile ? 150f : 120f, chipH = mobile ? 52f : 42f; + // 닫기 버튼 왼쪽에서 끝나게 — 세로 화면은 패널이 1032로 줄어 고정 x+560이면 "미보유"가 X에 깔렸다(검수 캡처). + float chipsW = FilterLabels.Length * chipW + (FilterLabels.Length - 1) * 8f; + float closeW = mobile ? 58f : 44f; + float chipX = Mathf.Min(x + 600f, x + panelW - closeW - 24f - chipsW); + for (int f = 0; f < FilterLabels.Length; f++) + { + Rect fr = new Rect(chipX + f * (chipW + 8f), y + (mobile ? 12f : 16f), chipW, chipH); + bool on = (int)filter == f; + if (UISurface.Button(fr, FilterLabels[f], on ? theme.accentMint : theme.surfaceRaised, tabNormalStyle, on)) + { + filter = (ItemFilter)f; + scrollPos = Vector2.zero; + directScroll.Reset(); + } + } + } + // 닫기 버튼 float closeSize = mobile ? 58f : 44f; if (GUI.Button(new Rect(x + panelW - closeSize - 12f, y + 10f, closeSize, mobile ? 56f : 40f), "X", closeStyle)) @@ -432,7 +871,7 @@ private void OnGUI() // 미리보기가 그리드 영역의 정중앙을 차지한다. 세로는 폭이 1032뿐이라 조금 좁게 잡아야 // 양옆에 카드 한 열씩이 남는다(400 + 296×2). - float previewW = mobile ? 400f : 440f; + float previewW = Mathf.Min(gridW * 0.46f, mobile ? 460f : 560f); float sideW = Mathf.Max(0f, (gridW - previewW) * 0.5f); // ── 캐릭터 미리보기 ── @@ -441,9 +880,17 @@ private void OnGUI() float charAreaX = gridX + sideW; float charAreaY = mobile ? tabY + tabBlockH + 8f : y + 70f; float charAreaW = previewW; - // 세로는 화면이 길어 미리보기가 끝없이 늘어난다 — 상한을 둬 아래 카드가 계속 보이게 한다. + + // ── 하단 띠(보너스 요약 −76 · 재화 −44) ── 카드는 이 위에서 **여백을 두고** 끝난다. + // 예전 데스크톱 그리드는 요약 줄 4px 위에서 끝나 잘린 카드 모서리가 글자에 붙었다(2026-09-30 검수 캡처). + const float FooterBandH = 96f; + float contentBottom = y + panelH - FooterBandH; + + // 모바일도 카드 열과 같은 높이다(옛 720 상한은 카드가 미리보기 **아래**에 오던 때의 것 — 지금은 옆이다). + // 그림은 텍스처 비율까지만 커지고 남는 높이는 상세·세트 진행이 받는다(DrawCharacterPreview). + // 데스크톱 미리보기는 패널 바닥까지 간다 — 하단 띠는 미리보기 **왼쪽**에만 그린다(아래 요약 폭). float charAreaH = mobile - ? Mathf.Min(720f, Mathf.Max(1f, panelH - (charAreaY - y) - 84f)) + ? Mathf.Max(1f, contentBottom - charAreaY) : panelH - 90f; Rect charArea = new Rect(charAreaX, charAreaY, charAreaW, charAreaH); @@ -464,58 +911,23 @@ private void OnGUI() scrollPos = Vector2.zero; directScroll.Reset(); } + if (outfitManager.HasNewInSlot(slots[i])) + UISurface.Chip(new Rect(tabRect.xMax - 22f, tabRect.y + 4f, 18f, 18f), "", UITheme.Instance.accentMint, UITheme.Instance.accentMint); } - // ── 세트 정보 패널 ── - if (bonusProvider != null && !mobile) - { - float setY = tabY + slots.Length * (tabH + tabGap) + 8; - ActiveSetInfo[] activeSets = bonusProvider.GetActiveSets(); - foreach (ActiveSetInfo setInfo in activeSets) - { - bool active = setInfo.isPartialActive || setInfo.isFullActive; - GUIStyle sStyle = active ? setActiveStyle : setStyle; - Color prevColor = sStyle.normal.textColor; - if (active) sStyle.normal.textColor = setInfo.set.setColor; - - int total = setInfo.set.requiredItemIds.Length; - string stars = StarsFor(setInfo.equippedCount, total); - - string setLabel = $"{setInfo.set.displayName} ({setInfo.equippedCount}/{total})\n{stars}"; - if (setInfo.isFullActive) - { - setLabel += "\n" + setInfo.set.fullBonus.GetPrimaryBonusText(); - } - else if (setInfo.isPartialActive) - { - setLabel += "\n" + setInfo.set.partialBonus.GetPrimaryBonusText(); - } - - float setH = active ? 52 : 36; - Rect setRect = new Rect(tabX, setY, tabW, setH); - - // 세트 완성 글로우 - if (setCompleteFlashTimer > 0f && active) - { - float glowAlpha = Mathf.Clamp01(setCompleteFlashTimer / 1f) * 0.6f; - UIHelper.DrawRarityGlow(setRect, setInfo.set.setColor, glowAlpha, Time.time); - } - - GUI.Label(setRect, setLabel, sStyle); - sStyle.normal.textColor = prevColor; - setY += setH + 4; - } - } + // ── 세트 정보 패널 (데스크톱: 탭 아래 / 모바일: 미리보기 칸의 상세 아래 — DrawCharacterPreview) ── + if (!mobile) + DrawActiveSets(tabX, tabY + slots.Length * (tabH + tabGap) + 8, tabW, contentBottom); // ── 아이템 그리드 ── // 스크롤뷰가 미리보기까지 덮는 폭을 갖되 **가운데 구간엔 카드를 두지 않는다** — // 그래야 좌우 카드가 한 스크롤을 공유한다(스크롤뷰를 둘로 쪼개면 따로 논다). // 그리드는 미리보기와 **같은 줄에서** 시작한다: 세로에서도 카드가 미리보기 옆에 선다. - // 하단은 보너스 요약(-76)·재화(-44) 라벨과 겹치지 않게 84px 여백을 남긴다. + // 하단은 contentBottom(하단 띠 위 여백)에서 끝난다. float gridY = mobile ? charAreaY : y + 70f; - float gridH = Mathf.Max(1f, mobile ? panelH - (gridY - y) - 84f : panelH - 150f); + float gridH = Mathf.Max(1f, contentBottom - gridY); - OutfitItem[] items = outfitManager.GetItemsForSlot(selectedSlot); + List items = VisibleItems(); // 세로는 한쪽 폭이 ~296px뿐이라 카드를 데스크톱보다 조금만 크게 잡는다(한 열). float cardW = mobile ? 230f : 200f; @@ -523,7 +935,7 @@ private void OnGUI() float cardGap = 14f; // 한쪽 열 수를 세고 카드를 좌·우로 번갈아 채운다(세로·가로 공통). int cols = Mathf.Max(1, Mathf.FloorToInt((sideW - 10) / (cardW + cardGap))); - int perSide = Mathf.CeilToInt(items.Length / 2f); + int perSide = Mathf.CeilToInt(items.Count / 2f); int rows = Mathf.CeilToInt((float)perSide / cols); float contentH = rows * (cardH + cardGap) + 10; @@ -542,7 +954,7 @@ private void OnGUI() GUIStyle.none, GUIStyle.none); - for (int i = 0; i < items.Length; i++) + for (int i = 0; i < items.Count; i++) { OutfitItem item = items[i]; // 짝수는 왼쪽, 홀수는 오른쪽 — 미리보기를 사이에 두고 감싼다. @@ -572,8 +984,7 @@ private void OnGUI() { GUI.DrawTexture(cardRect, UIHelper.GetCachedTex(new Color(1f, 1f, 1f, 0.08f))); UIHelper.DrawBorder(cardRect, new Color(0.7f, 0.8f, 1f, 0.5f), 1); - hoveredItemForTooltip = item; - hoveredCardScreenRect = new Rect(gridX + cx - scrollPos.x, gridY + cy - scrollPos.y, cardW, cardH); + outfitManager.MarkSeen(item.itemId); // 입어보기 — 미보유 아이템도 포함한다. "사기 전에 어떻게 보이나"가 핵심이다. // 미리보기는 이 값을 다음 패스에서 읽으므로 한 프레임 늦는데, 체감되지 않는다. tryOnItem = item; @@ -585,6 +996,15 @@ private void OnGUI() { UIHelper.DrawBorder(cardRect, new Color(1f, 0.84f, 0f, 1f), 2); } + // 입어보기로 고른 카드 — 장착 테두리(금색)와 구별되는 민트 + if (!equipped && trySelections.TryGetValue(item.slot, out OutfitItem picked) && picked == item) + { + UIHelper.DrawBorder(cardRect, UITheme.Instance.accentMint, 3); + } + if (outfitManager.IsNew(item.itemId)) + { + UISurface.Chip(new Rect(cx + cardW - 64f, cy + 6f, 58f, 26f), "NEW", UITheme.Instance.accentMint, UITheme.Instance.surfaceBase); + } // 장착 플래시 오버레이 if (equipFlashTimer > 0f && item.itemId == lastEquippedId) @@ -647,22 +1067,12 @@ private void OnGUI() GUI.Label(bonusRect, bonusText, bigBonus); } - // 세트 도트 표시 - if (bonusProvider != null) + // 세트 표시 — **모든** 세트 옷에. 예전엔 이미 한 벌 이상 입은 세트에만 찍혀 시작하지 않은 세트의 + // 구성품을 알 수 없었고, 카드마다 세트 목록 전체를 두 겹으로 훑었다(OnGUI 패스마다). + OutfitSetDefinition cardSet = SetOf(item.itemId); + if (cardSet != null) { - ActiveSetInfo[] activeSets = bonusProvider.GetActiveSets(); - foreach (ActiveSetInfo setInfo in activeSets) - { - foreach (string reqId in setInfo.set.requiredItemIds) - { - if (reqId == item.itemId) - { - Rect dotRect = new Rect(cx + 4, cy + 4, 10, 10); - GUI.DrawTexture(dotRect, UIHelper.GetCachedTex(setInfo.set.setColor)); - break; - } - } - } + UISurface.Flat(new Rect(cx + 6f, cy + 6f, 8f, 26f), cardSet.setColor); } // 버튼 영역 @@ -681,12 +1091,7 @@ private void OnGUI() { if (GUI.Button(btnRect, "장착", buttonStyle)) { - outfitManager.Equip(item.itemId); - equipFlashTimer = 0.4f; - lastEquippedId = item.itemId; - if (InsectGame.Core.AudioManager.Instance != null) - InsectGame.Core.AudioManager.Instance.PlaySFX(InsectGame.Core.SfxType.Equip); - CheckSetCompletion(); + EquipWithFeedback(item); } } } @@ -699,11 +1104,11 @@ private void OnGUI() bool canAfford = currentGems >= item.gemPrice; GUI.backgroundColor = canAfford ? new Color(0.3f, 0.2f, 0.6f) : new Color(0.3f, 0.3f, 0.3f); GUI.enabled = canAfford; - if (GUI.Button(btnRect, $"💎{item.gemPrice}", buttonStyle)) + if (GUI.Button(btnRect, PriceLabel(item), buttonStyle)) { if (outfitManager.TryPurchaseWithGems(item.itemId)) { - outfitManager.Equip(item.itemId); + EquipWithFeedback(item); } } GUI.enabled = true; @@ -723,14 +1128,19 @@ private void OnGUI() } else if (item.price > 0) { - // 캔디 구매 - if (GUI.Button(btnRect, $"🍬{item.price}", buttonStyle)) + // 코인 구매. 라벨이 오래 🍬(캔디)였는데 TryPurchase가 실제로 빼는 건 **코인**이다 + // (CharacterOutfitManager.TryPurchase → wallet.SpendCoins). 잔액이 모자라도 눌려서 + // 아무 반응 없이 끝났다 — 보석 버튼처럼 비활성화한다. + bool canAfford = CanAffordCoins(item.price); + GUI.enabled = canAfford; + if (GUI.Button(btnRect, PriceLabel(item), buttonStyle)) { if (outfitManager.TryPurchase(item.itemId)) { - outfitManager.Equip(item.itemId); + EquipWithFeedback(item); } } + GUI.enabled = true; } else if (!string.IsNullOrEmpty(item.unlockCondition)) { @@ -754,6 +1164,14 @@ private void OnGUI() UIHelper.LabelFit(hintRect, DescribeUnlockCondition(item.unlockCondition), hintStyle); } } + + // 카드 본문을 누르면 그 슬롯의 입어보기로 고른다(터치 기기의 유일한 입어보기 경로). + // 장착/구매 버튼은 **먼저** 그렸으므로 그 버튼 위를 누르면 그쪽이 이벤트를 가져간다. + if (GUI.Button(cardRect, GUIContent.none, GUIStyle.none)) + { + trySelections[item.slot] = item; + outfitManager.MarkSeen(item.itemId); + } } GUI.EndScrollView(); @@ -761,79 +1179,34 @@ private void OnGUI() // 카드에서 마우스가 벗어나면 입어보기를 풀고 실장착으로 돌아간다. if (Event.current.type == EventType.Repaint && !hoverFoundThisPass) tryOnItem = null; - // ── 호버 툴팁 (ScrollView 밖에서 렌더) ── - if (hoveredItemForTooltip != null && hoveredItemForTooltip.statBonus.HasAnyBonus() - && outfitManager.IsOwned(hoveredItemForTooltip.itemId)) - { - var b = hoveredItemForTooltip.statBonus; - string tip = ""; - if (b.captureChanceBonus > 0f) tip += $"포획 +{b.captureChanceBonus * 100f:0}%\n"; - if (b.atkBonus > 0f) tip += $"ATK +{b.atkBonus * 100f:0}%\n"; - if (b.defBonus > 0f) tip += $"DEF +{b.defBonus * 100f:0}%\n"; - if (b.moveSpeedBonus > 0f) tip += $"이속 +{b.moveSpeedBonus * 100f:0}%\n"; - if (b.expMultiplier > 0f) tip += $"경험치 +{b.expMultiplier * 100f:0}%\n"; - if (b.candyMultiplier > 0f) tip += $"캔디 +{b.candyMultiplier * 100f:0}%\n"; - if (b.rareSpawnBonus > 0f) tip += $"레어 +{b.rareSpawnBonus * 100f:0}%\n"; - tip = tip.TrimEnd('\n'); - - int lineCount = tip.Split('\n').Length; - float tipH = lineCount * 17 + 8; - Rect tipRect = new Rect( - hoveredCardScreenRect.x, - hoveredCardScreenRect.y - tipH - 4, - hoveredCardScreenRect.width, - tipH); - GUI.DrawTexture(tipRect, UIHelper.GetCachedTex(new Color(0.05f, 0.07f, 0.15f, 0.92f))); - GUIStyle tipStyle = UIHelper.CachedStyle("outfit_tip", () => - { - GUIStyle s = new GUIStyle(GUI.skin.label); - s.fontSize = 12; - s.normal.textColor = new Color(0.7f, 0.95f, 0.7f); - s.alignment = TextAnchor.MiddleCenter; - s.wordWrap = true; - s.padding = new RectOffset(4, 4, 4, 4); - return s; - }); - GUI.Label(tipRect, tip, tipStyle); - } - hoveredItemForTooltip = null; - // ── 하단 보너스 요약 + 코인 표시 ── + // 데스크톱은 미리보기가 바닥까지 가므로 하단 띠가 미리보기 양옆으로 갈린다: 왼쪽(탭 열 + 왼쪽 카드 열)에 + // 요약을 두 줄까지, 오른쪽 카드 열 아래에 재화를 둔다. 예전엔 둘 다 패널 폭으로 잡혀 보너스가 많으면 + // 요약 줄이 미리보기 칸 위로 뻗었다. 길이는 보너스 개수가 정하니 LabelFit으로 줄여 맞춘다. + float footerW = mobile ? panelW - 48f : Mathf.Max(1f, charArea.x - (x + 24f) - 16f); if (bonusProvider != null) { OutfitStatBonus total = bonusProvider.GetTotalBonus(); if (total.HasAnyBonus()) { - string summary = "장비 보너스:"; - if (total.captureChanceBonus > 0f) summary += $" 포획+{total.captureChanceBonus * 100f:0}%"; - if (total.atkBonus > 0f) summary += $" ATK+{total.atkBonus * 100f:0}%"; - if (total.defBonus > 0f) summary += $" DEF+{total.defBonus * 100f:0}%"; - if (total.moveSpeedBonus > 0f) summary += $" 이속+{total.moveSpeedBonus * 100f:0}%"; - if (total.expMultiplier > 0f) summary += $" 경험치+{total.expMultiplier * 100f:0}%"; - if (total.candyMultiplier > 0f) summary += $" 캔디+{total.candyMultiplier * 100f:0}%"; - if (total.rareSpawnBonus > 0f) summary += $" 레어+{total.rareSpawnBonus * 100f:0}%"; - - Rect summaryRect = new Rect(x + 24, y + panelH - 76, panelW - 48, 30); - GUIStyle summaryStyle = UIHelper.CachedStyle("outfit_summary", () => - { - GUIStyle s = new GUIStyle(GUI.skin.label); - s.fontSize = 19; - s.fontStyle = FontStyle.Bold; - s.normal.textColor = new Color(0.4f, 0.9f, 0.4f); - s.alignment = TextAnchor.MiddleLeft; - return s; - }); - GUI.Label(summaryRect, summary, summaryStyle); + string summary = EquippedSummaryText(total); + Rect summaryRect = mobile + ? new Rect(x + 24, y + panelH - 76, footerW, 30) + : new Rect(x + 24, y + panelH - 86, footerW, 72); + GUIStyle summaryStyle = UIHelper.CachedStyle("outfit_summary", BuildSummaryStyle); + if (!mobile) summary = SummaryForWidth(summary, summaryStyle, footerW); + UIHelper.LabelFit(summaryRect, summary, summaryStyle); } } - Rect coinRect = new Rect(x + 24, y + panelH - 44, 820, 36); - if (walletCache == null) - walletCache = outfitManager.GetComponent() ?? - FindFirstObjectByType(); - int coinCount = (walletCache != null) ? walletCache.Coins : 0; + float coinX = charArea.xMax + 16f; + Rect coinRect = mobile + ? new Rect(x + 24, y + panelH - 44, footerW, 36) + : new Rect(coinX, y + panelH - 68, Mathf.Max(1f, x + panelW - 24f - coinX), 36); + PlayerCurrencyWallet footerWallet = ResolveWallet(); + int coinCount = (footerWallet != null) ? footerWallet.Coins : 0; int gemCount = CashShopManager.Instance != null ? CashShopManager.Instance.Gems : 0; - GUI.Label(coinRect, $"보유 코인: 🪙{coinCount} 보석: 💎{gemCount}", coinStyle); + UIHelper.LabelFit(coinRect, FooterText(coinCount, gemCount), mobile ? coinStyle : coinRightStyle); // GUI.color 복원 GUI.color = Color.white; @@ -841,6 +1214,38 @@ private void OnGUI() UIScale.End(); } + /// + /// 장착 + 피드백(번쩍임·효과음·세트 완성 판정). 구매 직후 자동 장착도 여기로 온다 — + /// 예전엔 구매 경로만 Equip을 직접 불러 연출이 없었고, 세트 기억값()이 + /// 낡아서 다음 장착에 엉뚱하게 "세트 완성"이 울렸다. + /// + private void EquipWithFeedback(OutfitItem item) + { + if (item == null) return; + outfitManager.Equip(item.itemId); + trySelections.Remove(item.slot); + equipFlashTimer = 0.4f; + lastEquippedId = item.itemId; + if (InsectGame.Core.AudioManager.Instance != null) + InsectGame.Core.AudioManager.Instance.PlaySFX(InsectGame.Core.SfxType.Equip); + CheckSetCompletion(); + } + + private PlayerCurrencyWallet ResolveWallet() + { + if (walletCache == null && outfitManager != null) + walletCache = outfitManager.GetComponent() ?? + FindFirstObjectByType(); + return walletCache; + } + + private bool CanAffordCoins(int price) + { + if (AuthManager.Instance != null && AuthManager.Instance.MasterPrivilegesActive) return true; + PlayerCurrencyWallet w = ResolveWallet(); + return w != null && w.Coins >= price; + } + private void CheckSetCompletion() { if (bonusProvider == null) return; @@ -862,22 +1267,6 @@ private void CheckSetCompletion() } } - private static string GetSlotSymbol(OutfitSlot slot) - { - switch (slot) - { - case OutfitSlot.Hat: return "^"; - case OutfitSlot.Top: return "T"; - case OutfitSlot.Bottom: return "II"; - case OutfitSlot.Outerwear: return "W"; - case OutfitSlot.Shoes: return "U"; - case OutfitSlot.Backpack: return "B"; - case OutfitSlot.Tool: return "+"; - case OutfitSlot.Accessory: return "*"; - default: return "?"; - } - } - // ── 해금 조건 문구 ── // regionId → 표시명. RegionDefinitions에서 1회만 파생한다(이름을 여기 박으면 낡는다). @@ -890,31 +1279,47 @@ private static string GetSlotSymbol(OutfitSlot slot) /// 카드에 노출됐다. 알 수 없는 형식은 토큰을 그대로 돌려주므로, 새 조건 형식을 추가해도 /// 화면이 비지는 않는다(대신 여기 분기를 늘려 문장을 붙일 것). /// - /// 해금 판정은 여기서 하지 않는다. 현재 저장소에 unlockCondition을 평가해 - /// 소유를 부여하는 코드가 없어 조건부 의상 4벌은 획득 불가 상태다 — 그 배선은 해금 시점을 - /// 정하는 게임 디자인 결정이라 별건이다. 이 메서드는 표시만 고친다. + /// 해금 판정은 여기서 하지 않는다 — 가 하고 + /// CharacterOutfitManager.EvaluateUnlocks가 소유를 준다. 형식을 늘리면 두 곳을 함께 고친다. /// internal static string DescribeUnlockCondition(string condition) { if (string.IsNullOrEmpty(condition)) return ""; + // 잠긴 카드마다·OnGUI 패스마다 불린다 — 보간 문자열을 매번 만들지 않는다. + if (UnlockTextCache.TryGetValue(condition, out string cached)) return cached; - if (condition.StartsWith("region_")) + string text; + bool cacheable = true; + if (condition.StartsWith(OutfitUnlockRules.RegionPrefix)) { - string regionId = condition.Substring("region_".Length); - return $"{RegionDisplayName(regionId)} 도달 시 해금"; + string regionId = condition.Substring(OutfitUnlockRules.RegionPrefix.Length); + text = $"{RegionDisplayName(regionId)} 도달 시 해금"; } - if (condition.StartsWith("level_")) + else if (condition.StartsWith(OutfitUnlockRules.LevelPrefix)) { - string lv = condition.Substring("level_".Length); - return int.TryParse(lv, out int n) ? $"Lv.{n} 달성 시 해금" : condition; + text = OutfitUnlockRules.TryParseLevel(condition, out int n) ? $"Lv.{n} 달성 시 해금" : condition; } - if (condition.StartsWith("quest_")) + else if (condition.StartsWith(OutfitUnlockRules.QuestPrefix)) { - return "특정 퀘스트 완료 시 해금"; + // 어느 퀘스트인지 말해 준다. 퀘스트 매니저가 아직 없으면(테스트·부팅 직후) 일반 문구로 + // 물러나고 캐시하지 않는다 — 한 번 굳으면 제목이 영영 안 뜬다. + string questId = condition.Substring(OutfitUnlockRules.QuestPrefix.Length); + string title = TutorialQuestManager.Instance != null + ? TutorialQuestManager.Instance.GetQuestTitle(questId) : null; + if (string.IsNullOrEmpty(title)) { text = "특정 퀘스트 완료 시 해금"; cacheable = false; } + else text = $"'{title}' 완료 시 해금"; } - return condition; // 미지의 형식 — 토큰이라도 보여 준다 + else + { + text = condition; // 미지의 형식 — 토큰이라도 보여 준다 + } + + if (cacheable) UnlockTextCache[condition] = text; + return text; } + private static readonly Dictionary UnlockTextCache = new Dictionary(); + private static string RegionDisplayName(string regionId) { if (string.IsNullOrEmpty(regionId)) return "특정 지역"; diff --git a/Assets/Scripts/UI/CharacterPortraitRenderer.cs b/Assets/Scripts/UI/CharacterPortraitRenderer.cs index 1e68351..68e2f3a 100644 --- a/Assets/Scripts/UI/CharacterPortraitRenderer.cs +++ b/Assets/Scripts/UI/CharacterPortraitRenderer.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using InsectGame.Core; using UnityEngine; @@ -46,14 +47,28 @@ public static Proportions CalculateProportions(float cy, float scale, int gender public static void Draw(float cx, float cy, float scale, int gender, int skinColorIdx, int hairColorIdx, int hairStyle, int faceType, Color topColor, Color bottomColor, Color shoeColor, Color hatColor, - float swayX = 0f, bool drawDefaultPack = true) + float swayX = 0f, bool drawDefaultPack = true, bool drawNet = false) + { + DrawWithColors(cx, cy, scale, gender, GetSkinColor(skinColorIdx), GetHairColor(hairColorIdx), + hairStyle, faceType, topColor, bottomColor, shoeColor, hatColor, swayX, drawDefaultPack, drawNet); + } + + /// + /// 피부·머리를 팔레트 번호가 아니라 색으로 받는 판. 플레이어 팔레트(CharacterPalette)에 없는 색 + /// — 월드 NPC의 백발 같은 — 을 초상에 그대로 옮길 때 쓴다(NpcDialogueUI의 월드 파생 초상). + /// + /// + /// 기본 잠자리채를 그릴까. 예전엔 **항상** 그렸다 — 장착 도구를 따로 그리는 에서는 + /// 도구 없음을 골라도 잠자리채가 보이고 다른 도구와 겹쳤으며, NPC 대사 초상의 어른들까지 전부 잠자리채를 들었다. + /// + public static void DrawWithColors(float cx, float cy, float scale, + int gender, Color skin, Color hair, int hairStyle, int faceType, + Color topColor, Color bottomColor, Color shoeColor, Color hatColor, + float swayX = 0f, bool drawDefaultPack = true, bool drawNet = false) { // 호출부의 색(패널 페이드 알파 등)을 잡아 둔다 — 파츠는 여기에 곱해 그린다. ambientColor = GUI.color; - Color skin = GetSkinColor(skinColorIdx); - Color hair = GetHairColor(hairColorIdx); - // 공용 비례 헬퍼 — DrawArmsAsSkin/DrawBackpackWithSlot/DrawOutfitAccessories와 자동 동기. Proportions p = CalculateProportions(cy, scale, gender); float s = p.s; @@ -147,14 +162,9 @@ public static void Draw(float cx, float cy, float scale, float noseW = headW * 0.07f; DrawCol(noseCol, cx - noseW * 0.5f + swayX, headY + headH * 0.60f, noseW, headH * 0.08f); - // 입 (표정별, headW/headH 비례 위치 — 얼굴 하단 1/4) - Color mouthCol = new Color(0.85f, 0.4f, 0.35f); - float mouthW = headW * (0.18f + faceType * 0.05f); - float mouthH = (faceType == 1) ? headH * 0.07f : headH * 0.05f; - float mouthY = headY + headH * 0.74f; - DrawCol(mouthCol, cx - mouthW * 0.5f + swayX, mouthY, mouthW, mouthH); - if (faceType == 0 || faceType == 1) - DrawCol(new Color(1f, 0.7f, 0.7f, 0.5f), cx - mouthW * 0.3f + swayX, mouthY + mouthH, mouthW * 0.6f, headH * 0.035f); + // 입 — 3D(PlayerVisualBuilder.MouthShape)와 **같은 모양·크기 순서**다: 미소(곡선) · 활짝(벌린 입 + 혀) · + // 차분(작은 곡선) · 무표정(일자). 예전 2D는 폭이 표정 번호에 비례해 무표정이 가장 컸다(3D와 반대). + DrawMouth2D(cx + swayX, headY + headH * 0.74f, headW, headH, faceType); // 볼터치 + 속눈썹 (여자) — headW/headH 비례 if (gender == 1) @@ -186,7 +196,12 @@ public static void Draw(float cx, float cy, float scale, DrawCol(hatDark, headX + 1f * s, headY - headH * 0.20f, headW - 2f * s, headH * 0.22f); } - // === 채집봉 (오른쪽 어깨 너머) === + // === 채집봉 (오른쪽 어깨 너머) — 부를 때 고른다(drawNet) === + if (!drawNet) + { + GUI.color = ambientColor; + return; + } Color netCol = new Color(0.2f, 0.12f, 0.06f); float netX = cx + bodyW * 0.5f + armW + 2f * s + swayX; float netTop = headY - 8f * s; @@ -202,6 +217,44 @@ public static void Draw(float cx, float cy, float scale, GUI.color = ambientColor; // 흰색이 아니라 호출부의 색으로 되돌린다 } + internal enum Mouth2D { Smile, Open, Line } + + /// 표정 → 2D 입(모양, 폭 비율, 높이 비율). 3D 표와 순서를 맞춘다 — 테스트가 대조한다. + internal static void MouthShape2D(int faceType, out Mouth2D kind, out float widthK, out float heightK) + { + switch (faceType) + { + case 1: kind = Mouth2D.Open; widthK = 0.24f; heightK = 0.1f; break; // 활짝 + case 2: kind = Mouth2D.Smile; widthK = 0.15f; heightK = 0.045f; break; // 차분 + case 3: kind = Mouth2D.Line; widthK = 0.13f; heightK = 0.025f; break; // 무표정 + default: kind = Mouth2D.Smile; widthK = 0.22f; heightK = 0.06f; break; // 미소 + } + } + + private static void DrawMouth2D(float cx, float top, float headW, float headH, int faceType) + { + MouthShape2D(faceType, out Mouth2D kind, out float wk, out float hk); + float w = headW * wk, h = headH * hk; + Color line = new Color(0.55f, 0.22f, 0.2f); + switch (kind) + { + case Mouth2D.Open: + DrawCol(new Color(0.6f, 0.18f, 0.18f), cx - w * 0.5f, top, w, h); + DrawCol(new Color(0.96f, 0.55f, 0.55f), cx - w * 0.3f, top + h * 0.55f, w * 0.6f, h * 0.45f); + break; + case Mouth2D.Line: + DrawCol(line, cx - w * 0.5f, top + h, w, h); + break; + default: + // 곡선을 세 토막으로 — 가운데가 낮고 양끝이 올라간다. + float t = Mathf.Max(1f, h * 0.45f); + DrawCol(line, cx - w * 0.2f, top + h, w * 0.4f, t); + DrawCol(line, cx - w * 0.5f, top + h * 0.35f, w * 0.32f, t); + DrawCol(line, cx + w * 0.18f, top + h * 0.35f, w * 0.32f, t); + break; + } + } + private static void DrawHair(float headX, float headY, float headW, float headH, float s, int gender, int style, Color hair) { switch (style) @@ -256,13 +309,9 @@ private static void DrawHair(float headX, float headY, float headW, float headH, public static void DrawForCreation(float cx, float cy, float scale, int gender, int skinColorIdx, int hairColorIdx, int hairStyle, int faceType, int outfitIdx) { - Color topCol = PresetTopColors[Mathf.Clamp(outfitIdx, 0, PresetTopColors.Length - 1)]; - Color bottomCol = new Color(0.18f, 0.22f, 0.28f); - Color shoeCol = new Color(0.2f, 0.12f, 0.06f); - Color hatCol = new Color(0f, 0f, 0f, 0f); - + PresetColors(outfitIdx, out Color topCol, out Color bottomCol, out Color shoeCol, out Color hatCol, out bool net); Draw(cx, cy, scale, gender, skinColorIdx, hairColorIdx, hairStyle, faceType, - topCol, bottomCol, shoeCol, hatCol); + topCol, bottomCol, shoeCol, hatCol, 0f, true, net); } // OutfitChanged 이벤트 기반 캐시 — 매 OnGUI 호출 시 PlayerPrefs 5회 + GetEquipped 8회를 @@ -636,14 +685,44 @@ private static void DrawOutfitAccessories(float cx, float cy, float scale, float /// 값은 각 프리셋이 입는 겉옷(없으면 상의)의 primaryColor를 따른다: /// 탐험가·밤의 채집가·직접 만들기는 outer_jacket, 관찰자는 top_shirt, 아이는 top_polo. /// - internal static readonly Color[] PresetTopColors = + private static Dictionary catalogById; + + /// + /// 생성 화면 2D 폴백의 색 — 프리셋이 **실제로 입히는 아이템**에서 뽑는다. 예전엔 대표색 배열을 따로 적어 + /// 자켓 색이 실제 탐험가 자켓과 달랐고(0.20,0.40,0.85 ↔ 0.16,0.32,0.72) 하의·신발·모자는 무시됐다. + /// 몸통은 겉옷을 입으면 겉옷, 벗으면 상의(ApplyToCharacter와 같은 규칙). + /// + internal static void PresetColors(int outfitIdx, out Color body, out Color bottom, out Color shoe, out Color hat, out bool net) { - new Color(0.20f, 0.40f, 0.85f), // 초원의 탐험가 — outer_jacket - new Color(0.98f, 0.96f, 0.92f), // 숲의 관찰자 — outer_none → top_shirt - new Color(0.53f, 0.81f, 0.98f), // 들판의 아이 — outer_none → top_polo - new Color(0.20f, 0.40f, 0.85f), // 밤의 채집가 — outer_jacket - new Color(0.20f, 0.40f, 0.85f), // 직접 만들기 — outer_jacket - }; + if (catalogById == null) + { + catalogById = new Dictionary(); + foreach (OutfitItem it in CharacterOutfitManager.BuildCatalog()) catalogById[it.itemId] = it; + } + body = new Color(0.98f, 0.96f, 0.92f); + bottom = new Color(0.18f, 0.22f, 0.28f); + shoe = new Color(0.2f, 0.12f, 0.06f); + hat = new Color(0f, 0f, 0f, 0f); + net = false; + Color top = body, outer = new Color(0f, 0f, 0f, 0f); + + string[] ids = CharacterPresetLibrary.Get(outfitIdx).OutfitItemIds; + if (ids == null) return; + foreach (string id in ids) + { + if (!catalogById.TryGetValue(id, out OutfitItem it)) continue; + switch (it.slot) + { + case OutfitSlot.Top: top = it.primaryColor; break; + case OutfitSlot.Outerwear: outer = it.primaryColor; break; + case OutfitSlot.Bottom: bottom = it.primaryColor; break; + case OutfitSlot.Shoes: shoe = it.primaryColor; break; + case OutfitSlot.Hat: hat = it.primaryColor; break; + case OutfitSlot.Tool: net = id == "tool_net"; break; + } + } + body = outer.a > 0.01f ? outer : top; + } public static Color GetSkinColor(int idx) { @@ -684,6 +763,21 @@ private static void DrawRect(float x, float y, float w, float h) /// 옛 GetSlotSymbol은 "^" "T" 같은 텍스트라 어떤 아이템인지 알 수 없었음. /// 호출자는 색 배경 + 테두리만 그린 뒤 이 메서드로 형태 오버레이. /// + /// 그 슬롯의 레시피가 아이템 모양 전체인가(모자·가방·도구·악세서리). 나머지는 몸 노드를 칠하고 일부만 덧붙인다. + internal static bool RecipeIsWholeItem(OutfitSlot slot) + { + switch (slot) + { + case OutfitSlot.Hat: + case OutfitSlot.Backpack: + case OutfitSlot.Tool: + case OutfitSlot.Accessory: + return true; + default: + return false; + } + } + public static void DrawItemPreview(Rect r, OutfitSlot slot, string itemId, Color primary, Color secondary) { if (primary.a < 0.01f) @@ -701,7 +795,10 @@ public static void DrawItemPreview(Rect r, OutfitSlot slot, string itemId, Color // 형태의 단일 출처는 OutfitShapeLibrary다 — 레시피가 있으면 그 3D 파츠를 정사영해 그린다. // 그래야 카드 그림과 실제 착용 모습이 어긋나지 않는다(예전엔 카드에만 목도리 분기가 있어 // "카드는 목도리, 캐릭터는 가슴 큐브"였다). 레시피가 없는 아이템은 아래 슬롯별 폴백. - if (OutfitShapeLibrary.TryGet(slot, id, out OutfitRecipe recipe)) + // + // 단, 레시피가 **아이템 전체**인 슬롯에서만이다. 상의·하의·겉옷·신발 레시피는 몸에 **덧붙이는 부분** + // (반바지 천·운동화 밑창·챕스·술 장식)뿐이라 그것만 그리면 운동화 카드에 밑창 한 장이 뜬다(2026-09-30). + if (RecipeIsWholeItem(slot) && OutfitShapeLibrary.TryGet(slot, id, out OutfitRecipe recipe)) { DrawRecipePreview(r, recipe, primary, secondary); GUI.color = prevCol; diff --git a/Assets/Scripts/UI/CollectionUI.cs b/Assets/Scripts/UI/CollectionUI.cs index 66f816f..543711e 100644 --- a/Assets/Scripts/UI/CollectionUI.cs +++ b/Assets/Scripts/UI/CollectionUI.cs @@ -12,6 +12,14 @@ public class CollectionUI : MonoBehaviour, IModalUI [SerializeField] private PlayerCandyInventory candyInventory; [SerializeField] private PlayerProgressController progressController; + private BattleTeamUI teamScreen; + private TrainingUI trainingScreen; + public void AutoWire(BattleTeamUI team, TrainingUI training) + { + teamScreen = team; + trainingScreen = training; + } + private bool isOpen; private Vector2 scrollPos; private readonly UIDirectScroll directScroll = new UIDirectScroll(); @@ -31,12 +39,11 @@ public class CollectionUI : MonoBehaviour, IModalUI private GUIStyle itemStatMiniStyle; private GUIStyle itemViewStyle; - private static readonly Color ItemBgCol = new Color(0.12f, 0.14f, 0.2f, 0.92f); - private static readonly Color ItemInfoGrayCol = new Color(0.65f, 0.65f, 0.65f); - private static readonly Color ItemStatGrayCol = new Color(0.55f, 0.55f, 0.55f); - private static readonly Color ItemViewBlueCol = new Color(0.25f, 0.35f, 0.55f); - private static readonly Color EmptyDataCol = new Color(0.5f, 0.5f, 0.5f); - private static readonly Color NoInsectCol = new Color(0.6f, 0.6f, 0.6f); + private static Color ItemBgCol => UITheme.Instance.surfaceCard; + private static Color ItemInfoGrayCol => UITheme.Instance.textSecondary; + private static Color ItemStatGrayCol => UITheme.Instance.textMuted; + private static Color EmptyDataCol => UITheme.Instance.textMuted; + private static Color NoInsectCol => UITheme.Instance.textSecondary; // 잔여 영역(Panel/Detail/Stats/LevelUp/StatBar) 캐시 — 매 프레임 ~30 new GUIStyle 회피. private bool detailStylesReady; @@ -72,43 +79,49 @@ public class CollectionUI : MonoBehaviour, IModalUI private GUIStyle barIvLabelStyle; private GUIStyle centeredLabelStyle; // textColor 동적 - private static readonly Color PanelBgCol = new Color(0.05f, 0.07f, 0.12f, 0.95f); - private static readonly Color PanelHeaderCol = new Color(0.15f, 0.18f, 0.25f, 1f); - private static readonly Color TabActiveBgCol = new Color(0.3f, 0.5f, 0.9f); - private static readonly Color TabInactiveBgCol = new Color(0.2f, 0.2f, 0.3f); - private static readonly Color StatBlockBgCol = new Color(0.1f, 0.12f, 0.18f, 0.8f); - private static readonly Color DescGrayCol = new Color(0.72f, 0.72f, 0.72f); - private static readonly Color HintGreenCol = new Color(0.5f, 0.65f, 0.5f); - private static readonly Color StatsLabelCol = new Color(0.85f, 0.85f, 0.85f); - private static readonly Color StatsDividerCol = new Color(0.3f, 0.3f, 0.4f); - private static readonly Color CandyValCol = new Color(1f, 0.5f, 0.8f); - private static readonly Color LuBgCol = new Color(0.08f, 0.10f, 0.16f, 0.9f); - private static readonly Color LuAccentBlueCol = new Color(0.3f, 0.7f, 1f); - private static readonly Color LuLabelBlueCol = new Color(0.5f, 0.65f, 0.9f); - private static readonly Color LuXpLabelCol = new Color(0.55f, 0.65f, 0.8f); - private static readonly Color LuBarBgCol = new Color(0.06f, 0.06f, 0.1f); - private static readonly Color LuBarFillDarkCol = new Color(0.2f, 0.5f, 0.9f); - private static readonly Color LuBarFillLightCol = new Color(0.35f, 0.65f, 1f); - private static readonly Color LuXpValCol = new Color(0.85f, 0.9f, 1f); - private static readonly Color LuMaxLvCol = new Color(0.45f, 0.5f, 0.6f); - private static readonly Color LuBtnGreenCol = new Color(0.2f, 0.5f, 0.3f); - private static readonly Color LuBtnDisabledCol = new Color(0.15f, 0.15f, 0.18f); - private static readonly Color LuCandyOkCol = new Color(1f, 0.7f, 0.85f); - private static readonly Color LuCandyLowCol = new Color(0.4f, 0.35f, 0.4f); - private static readonly Color BarBgCol = new Color(0.15f, 0.15f, 0.2f); - private static readonly Color BarLabelGrayCol = new Color(0.75f, 0.75f, 0.75f); - private static readonly Color BarTotalLightCol = new Color(0.85f, 0.85f, 0.85f); - private static readonly Color BarIvLabelGrayCol = new Color(0.5f, 0.5f, 0.5f); + private static Color PanelBgCol => UITheme.Instance.surfaceBase; + private static Color PanelHeaderCol => UITheme.Instance.surfaceRaised; + private static Color TabActiveBgCol => UITheme.Instance.accentCoral; + private static Color TabInactiveBgCol => UITheme.Instance.surfaceCard; + private static Color StatBlockBgCol => UITheme.Instance.surfaceCard; + private static Color DescGrayCol => UITheme.Instance.textSecondary; + private static Color HintGreenCol => UITheme.Instance.accentMint; + private static Color StatsLabelCol => UITheme.Instance.textPrimary; + private static Color StatsDividerCol => UITheme.Instance.surfaceBorder; + private static Color CandyValCol => UITheme.Instance.accentAmber; + private static Color LuBgCol => UITheme.Instance.surfaceCard; + private static Color LuAccentBlueCol => UITheme.Instance.accentMint; + private static Color LuLabelBlueCol => UITheme.Instance.textSecondary; + private static Color LuXpLabelCol => UITheme.Instance.textSecondary; + private static Color LuBarBgCol => UITheme.Instance.surfaceBase; + private static Color LuBarFillDarkCol => UITheme.Instance.accentMint; + private static Color LuBarFillLightCol => UITheme.Instance.accentMint; + private static Color LuXpValCol => UITheme.Instance.textPrimary; + private static Color LuMaxLvCol => UITheme.Instance.textMuted; + private static Color LuBtnGreenCol => UITheme.Instance.btnPrimary; + private static Color LuBtnDisabledCol => UITheme.Instance.btnDisabled; + private static Color LuCandyOkCol => UITheme.Instance.accentAmber; + private static Color LuCandyLowCol => UITheme.Instance.textMuted; + private static Color BarBgCol => UITheme.Instance.surfaceBase; + private static Color BarLabelGrayCol => UITheme.Instance.textSecondary; + private static Color BarTotalLightCol => UITheme.Instance.textPrimary; + private static Color BarIvLabelGrayCol => UITheme.Instance.textMuted; private void InitDetailStyles() { if (detailStylesReady) return; panelTitleStyle = new GUIStyle(GUI.skin.label) { fontSize = 48, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - panelTitleStyle.normal.textColor = Color.white; - panelCloseStyle = new GUIStyle(GUI.skin.button) { fontSize = 40, fontStyle = FontStyle.Bold }; - panelTabActiveStyle = new GUIStyle(GUI.skin.button) { fontSize = 34, fontStyle = FontStyle.Bold }; - panelTabInactiveStyle = new GUIStyle(GUI.skin.button) { fontSize = 34, fontStyle = FontStyle.Normal }; - detailBackStyle = new GUIStyle(GUI.skin.button) { fontSize = 32, fontStyle = FontStyle.Bold }; + panelTitleStyle.normal.textColor = UITheme.Instance.textPrimary; + // 버튼 글자는 전부 라벨 스타일이다 — UISurface.Button이 둥근 표면을 그리고 이 스타일로 글자만 얹는다. + // (GUI.skin.button을 넘기면 둥근 표면 위에 각진 버튼 상자가 한 겹 더 그려졌다.) + panelCloseStyle = new GUIStyle(GUI.skin.label) { fontSize = 40, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + panelCloseStyle.normal.textColor = UITheme.Instance.textPrimary; + panelTabActiveStyle = new GUIStyle(GUI.skin.label) { fontSize = 32, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + panelTabActiveStyle.normal.textColor = UITheme.Instance.textPrimary; + panelTabInactiveStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, fontStyle = FontStyle.Normal, alignment = TextAnchor.MiddleCenter }; + panelTabInactiveStyle.normal.textColor = UITheme.Instance.textSecondary; + detailBackStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + detailBackStyle.normal.textColor = UITheme.Instance.textPrimary; detailNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 48, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; detailRarityStyle = new GUIStyle(GUI.skin.label) { fontSize = 34, alignment = TextAnchor.MiddleCenter }; detailGradeDispStyle = new GUIStyle(GUI.skin.label) { fontSize = 62, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; @@ -151,7 +164,7 @@ private void InitDetailStyles() statsValueStyle.normal.textColor = Color.white; statsCandyValStyle = new GUIStyle(statsValueStyle); statsCandyValStyle.normal.textColor = CandyValCol; - luLvLabelStyle = new GUIStyle(GUI.skin.label) { fontSize = 21, fontStyle = FontStyle.Bold }; + luLvLabelStyle = new GUIStyle(GUI.skin.label) { fontSize = 22, fontStyle = FontStyle.Bold }; luLvLabelStyle.normal.textColor = LuLabelBlueCol; luLvNumStyle = new GUIStyle(GUI.skin.label) { fontSize = 42, fontStyle = FontStyle.Bold }; luLvNumStyle.normal.textColor = Color.white; @@ -161,7 +174,8 @@ private void InitDetailStyles() luXpValStyle.normal.textColor = LuXpValCol; luMaxLvStyle = new GUIStyle(GUI.skin.label) { fontSize = 18, alignment = TextAnchor.MiddleRight }; luMaxLvStyle.normal.textColor = LuMaxLvCol; - luBtnStyle = new GUIStyle(GUI.skin.button) { fontSize = 28, fontStyle = FontStyle.Bold }; + luBtnStyle = new GUIStyle(GUI.skin.label) { fontSize = 28, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, richText = true }; + luBtnStyle.normal.textColor = UITheme.Instance.textPrimary; luCandyInfoStyle = new GUIStyle(GUI.skin.label) { fontSize = 18, alignment = TextAnchor.MiddleCenter }; luMsgStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; barLabelStyle = new GUIStyle(GUI.skin.label) { fontSize = 34, fontStyle = FontStyle.Bold }; @@ -169,7 +183,7 @@ private void InitDetailStyles() barIvStyle = new GUIStyle(GUI.skin.label) { fontSize = 32, fontStyle = FontStyle.Bold }; barTotalStyle = new GUIStyle(GUI.skin.label) { fontSize = 32, alignment = TextAnchor.MiddleRight }; barTotalStyle.normal.textColor = BarTotalLightCol; - barIvLabelStyle = new GUIStyle(GUI.skin.label) { fontSize = 27 }; + barIvLabelStyle = new GUIStyle(GUI.skin.label) { fontSize = 23 }; barIvLabelStyle.normal.textColor = BarIvLabelGrayCol; centeredLabelStyle = new GUIStyle(GUI.skin.label) { fontSize = 28, alignment = TextAnchor.MiddleCenter, wordWrap = true }; detailStylesReady = true; @@ -204,7 +218,8 @@ private void InitItemStyles() itemGradeStyle = new GUIStyle(GUI.skin.label) { fontSize = 46, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleRight }; itemStatMiniStyle = new GUIStyle(GUI.skin.label) { fontSize = 27, alignment = TextAnchor.MiddleRight }; itemStatMiniStyle.normal.textColor = ItemStatGrayCol; - itemViewStyle = new GUIStyle(GUI.skin.button) { fontSize = 31 }; + itemViewStyle = new GUIStyle(GUI.skin.label) { fontSize = 29, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + itemViewStyle.normal.textColor = UITheme.Instance.textPrimary; itemStylesReady = true; } @@ -270,7 +285,7 @@ private void BuildRowTextCache() } InsectData data = insectCollection.GetInsectData(pid.insectId); - string rarityStr = data != null ? data.rarity.ToString() : "?"; + string rarityStr = data != null ? data.rarity.Korean() : "?"; // 크기는 #코드를 대신하는 개체 구분 축이라 목록 줄에 함께 보여준다. string sizeStr = data != null ? " | " + InsectSizeCalculator.SizeLabel(InsectSizeCalculator.SizeMm(data, pid)) @@ -302,6 +317,24 @@ private void OnEnable() } public bool IsOpen => isOpen; + + /// 도감의 보유 카드 등에서 특정 개체의 정보로 바로 이동한다. + public bool OpenDetailForInstance(string instanceId) + { + if (insectCollection == null || string.IsNullOrEmpty(instanceId) + || insectCollection.GetByInstanceId(instanceId) == null) + return false; + + if (!isOpen) + Toggle(); + selectedTab = 0; + selectedInstanceId = instanceId; + detailScrollPos = Vector2.zero; + detailDirectScroll.Reset(); + ModalUIRegistry.Register(this); + return true; + } + public void Toggle() { isOpen = !isOpen; @@ -365,7 +398,9 @@ private void DrawPanel() { InitDetailStyles(); // 세이프에어리어 + 세로 마진 안으로 자동 clamp — 가로 캔버스(높이 1080)에서 잘리던 자리다. - Rect panel = UISafeLayout.AnchoredPanel(1000f, 1000f, UISafeLayout.HAlign.Right); + // 세로 화면은 1560 — 1000 고정일 때 화면 가운데 절반만 쓰고 위아래가 비었다(의상 창과 같은 결함). + bool mobile = UIScale.IsMobileLayout; + Rect panel = UISafeLayout.AnchoredPanel(1000f, mobile ? 1560f : 1000f, UISafeLayout.HAlign.Right); float panelW = panel.width; float panelH = panel.height; float panelX = panel.x; @@ -375,27 +410,35 @@ private void DrawPanel() UISurface.Rounded(new Rect(panelX + 3f, panelY + 3f, panelW - 6f, 88f), PanelHeaderCol); GUI.color = Color.white; - UIHelper.LabelFit(new Rect(panelX, panelY + 16, panelW - 84, 58), "컬렉션", panelTitleStyle); + UIHelper.LabelFit(new Rect(panelX + 24f, panelY + 16, panelW - 480f, 58), "보유 곤충", panelTitleStyle); - if (GUI.Button(new Rect(panelX + panelW - 72, panelY + 16, 56, 56), "X", panelCloseStyle)) + if (UISurface.Button(new Rect(panelX + panelW - 76f, panelY + 18f, 58f, 58f), "×", UITheme.Instance.surfaceCard, panelCloseStyle)) { CloseModal(); } - float tabY = panelY + 98; + // 바로가기 — 모바일엔 키보드가 없으니 키 안내를 붙이지 않는다. 150px일 때 "배틀팀 [T"에서 잘렸다. + bool previousEnabled = GUI.enabled; + GUI.enabled = previousEnabled && teamScreen != null; + if (UISurface.Button(new Rect(panelX + panelW - 460f, panelY + 20f, 180f, 54f), mobile ? "배틀팀" : "배틀팀 [T]", UITheme.Instance.surfaceCard, panelTabInactiveStyle)) + { CloseModal(); teamScreen.Toggle(); return; } + GUI.enabled = previousEnabled && trainingScreen != null; + if (UISurface.Button(new Rect(panelX + panelW - 268f, panelY + 20f, 180f, 54f), mobile ? "훈련" : "훈련 [G]", UITheme.Instance.surfaceCard, panelTabInactiveStyle)) + { CloseModal(); trainingScreen.Toggle(); return; } + GUI.enabled = previousEnabled; + float tabY = panelY + 100; for (int i = 0; i < tabNames.Length; i++) { float tabX = panelX + i * 300 + 24; bool active = selectedTab == i; - GUI.backgroundColor = active ? TabActiveBgCol : TabInactiveBgCol; - if (GUI.Button(new Rect(tabX, tabY, 280, 64), tabNames[i], active ? panelTabActiveStyle : panelTabInactiveStyle)) + if (UISurface.Button(new Rect(tabX, tabY, 280, 64), tabNames[i], active ? TabActiveBgCol : TabInactiveBgCol, + active ? panelTabActiveStyle : panelTabInactiveStyle)) { selectedTab = i; scrollPos = Vector2.zero; directScroll.Reset(); } } - GUI.backgroundColor = Color.white; GUI.color = Color.white; float contentY = tabY + 78; @@ -423,8 +466,8 @@ private float DrawSortBar(float x, float y, float w) { InsectSortMode mode = InsectBrowseSort.Order[i]; Rect chip = new Rect(x + i * (chipW + 6f), y, chipW, h); - GUI.backgroundColor = sortMode == mode ? TabActiveBgCol : TabInactiveBgCol; - if (GUI.Button(chip, InsectBrowseSort.Label(mode), itemViewStyle) && sortMode != mode) + if (UISurface.Button(chip, InsectBrowseSort.Label(mode), sortMode == mode ? TabActiveBgCol : TabInactiveBgCol, itemViewStyle) + && sortMode != mode) { sortMode = mode; ownedCacheDirty = true; // 순서와 행 문자열을 함께 다시 굽는다 @@ -434,15 +477,14 @@ private float DrawSortBar(float x, float y, float w) } teamFirstChip = new Rect(x + w - toggleW, y, toggleW, h); - GUI.backgroundColor = teamFirst ? TabActiveBgCol : TabInactiveBgCol; - if (GUI.Button(teamFirstChip, teamFirst ? "팀 먼저 ON" : "팀 먼저 OFF", itemViewStyle)) + if (UISurface.Button(teamFirstChip, teamFirst ? "팀 먼저 ON" : "팀 먼저 OFF", + teamFirst ? UITheme.Instance.accentMint : TabInactiveBgCol, itemViewStyle)) { teamFirst = !teamFirst; ownedCacheDirty = true; scrollPos = Vector2.zero; directScroll.Reset(); } - GUI.backgroundColor = Color.white; return h + 12f; } @@ -515,15 +557,20 @@ private bool DrawInsectItem(Rect rect, PlayerInsectData pid, InsectData data, UIHelper.DrawRarityBorder(rect, rarityTier, Time.time); if (data != null) - InsectVisual.Draw(rect.x + 72, rect.y + rect.height / 2f + 2, 96f, data, pid != null && pid.isShiny, 1f); + { + // 썸네일 틀 — 도감·배틀팀과 같은 등급색 섞인 둥근 판. 틀 없이 떠 있던 모델이 카드마다 위치가 달라 보였다. + Rect thumb = new Rect(rect.x + 16f, rect.y + (rect.height - 118f) / 2f, 118f, 118f); + UISurface.Rounded(thumb, Color.Lerp(UITheme.Instance.surfaceBase, rarityColor, 0.14f)); + InsectVisual.Draw(thumb.center.x, thumb.center.y, 110f, data, pid != null && pid.isShiny, 1f); + } string displayName = GetOwnedDisplayName(pid, data); // 캐시 스타일 + textColor만 동적 갱신 (BattleScreenUI 패턴, owned.Count×5 GUIStyle/프레임 회피). itemNameStyle.normal.textColor = rarityColor; GUI.color = Color.white; - GUI.Label(new Rect(rect.x + 138, rect.y + 12, rect.width - 320, 48), displayName, itemNameStyle); + UIHelper.LabelFit(new Rect(rect.x + 150, rect.y + 12, rect.width - 332, 48), displayName, itemNameStyle); - GUI.Label(new Rect(rect.x + 138, rect.y + 66, rect.width - 320, 40), infoText, itemInfoStyle); + UIHelper.LabelFit(new Rect(rect.x + 150, rect.y + 66, rect.width - 332, 40), infoText, itemInfoStyle); string gradeStr = CapturePopupUI.GetGradeLabel(pid.Grade); Color gradeCol = UITheme.Instance.GetGradeColor(pid.Grade); @@ -532,7 +579,7 @@ private bool DrawInsectItem(Rect rect, PlayerInsectData pid, InsectData data, if (!string.IsNullOrEmpty(statsText)) { - GUI.Label(new Rect(rect.x + 138, rect.y + 112, rect.width - 320, 34), + GUI.Label(new Rect(rect.x + 150, rect.y + 112, rect.width - 332, 34), statsText, itemStatMiniStyle); } @@ -540,15 +587,19 @@ private bool DrawInsectItem(Rect rect, PlayerInsectData pid, InsectData data, // statsText는 같은 띠의 오른쪽 끝에 붙으므로 왼쪽 96px는 비어 있다. if (inTeam) { - UISurface.Chip(new Rect(rect.x + 138, rect.y + 110, 100f, 38f), + UISurface.Chip(new Rect(rect.x + 150, rect.y + 110, 100f, 38f), "배틀팀", UITheme.Instance.accentMint, Color.white); } - GUI.backgroundColor = ItemViewBlueCol; float detailButtonH = UIScale.IsMobileLayout ? 64f : 52f; - if (GUI.Button(new Rect(rect.x + rect.width - 172f, rect.y + rect.height - detailButtonH - 8f, 156f, detailButtonH), "상세", itemViewStyle)) + if (UISurface.Button(new Rect(rect.x + rect.width - 172f, rect.y + rect.height - detailButtonH - 10f, 156f, detailButtonH), + "상세", UITheme.Instance.surfaceBorder, itemViewStyle)) + clicked = true; + + // 카드 본문도 개체 정보로 이어진다. 상세 버튼을 먼저 등록해야 그 클릭을 + // 투명 카드 히트 영역이 가로채지 않는다. + if (GUI.Button(rect, string.Empty, GUIStyle.none) && !directScroll.IsDragging) clicked = true; - GUI.backgroundColor = Color.white; return clicked; } @@ -565,7 +616,8 @@ private void DrawDetailPanel() InitDetailStyles(); // 1040은 "하단에 습득 기술(learnset) 섹션까지 담고 싶은" 희망 높이 — 안전 영역이 좁으면 줄어든다. - Rect panel = UISafeLayout.AnchoredPanel(1000f, 1040f, UISafeLayout.HAlign.Right); + // 세로 화면은 1560 — 아래 스크롤 뷰포트가 패널 높이에서 파생되므로 늘어난 만큼 더 보인다. + Rect panel = UISafeLayout.AnchoredPanel(1000f, UIScale.IsMobileLayout ? 1560f : 1040f, UISafeLayout.HAlign.Right); float panelW = panel.width; float panelH = panel.height; float panelX = panel.x; @@ -582,30 +634,30 @@ private void DrawDetailPanel() if (detailRarityTier >= 3) UIHelper.DrawRarityGlow(detailRect, rarityCol, detailRarityTier >= 4 ? 0.6f : 0.3f, Time.time); - if (GUI.Button(new Rect(panelX + 16, panelY + 16, 150, 60), "< 뒤로", detailBackStyle)) + if (UISurface.Button(new Rect(panelX + 16, panelY + 16, 160, 60), "‹ 뒤로", UITheme.Instance.surfaceRaised, detailBackStyle)) { selectedInstanceId = null; detailScrollPos = Vector2.zero; detailDirectScroll.Reset(); + return; } - if (GUI.Button(new Rect(panelX + panelW - 72, panelY + 16, 56, 56), "X", panelCloseStyle)) + if (UISurface.Button(new Rect(panelX + panelW - 76f, panelY + 16f, 58f, 58f), "×", UITheme.Instance.surfaceRaised, panelCloseStyle)) { CloseModal(); + return; } float portraitCx = panelX + panelW / 2f; float portraitCy = panelY + 168; - // 동적 색상(rarityCol scaled)은 struct stack 할당, GC 영향 없음 (BattleArenaController 판단 일관). - GUI.color = new Color(rarityCol.r * 0.15f, rarityCol.g * 0.15f, rarityCol.b * 0.15f, 0.6f); - GUI.DrawTexture(new Rect(portraitCx - 90, portraitCy - 90, 180, 180), Texture2D.whiteTexture); - - GUI.color = new Color(rarityCol.r, rarityCol.g, rarityCol.b, 0.2f); - GUI.DrawTexture(new Rect(portraitCx - 84, portraitCy - 84, 168, 168), Texture2D.whiteTexture); + // 초상 틀 — 도감 상세·배틀팀과 같은 등급색 섞인 둥근 판(각진 사각형 두 겹이었다). + UISurface.Rounded(new Rect(portraitCx - 98f, portraitCy - 98f, 196f, 196f), + Color.Lerp(UITheme.Instance.surfaceBase, rarityCol, 0.18f)); + GUI.color = Color.white; // 등급·ID를 따로 뽑아 넘기던 자리 — 파사드가 InsectData 하나만 받는다. - InsectVisual.Draw(portraitCx, portraitCy, 168f, data, pid != null && pid.isShiny, 1f); + InsectVisual.Draw(portraitCx, portraitCy, 184f, data, pid != null && pid.isShiny, 1f); string displayName = GetOwnedDisplayName(pid, data); // 캐시 스타일 + textColor만 동적 갱신. @@ -620,7 +672,7 @@ private void DrawDetailPanel() : "타입 미상"; GUI.Label(new Rect(panelX, panelY + 340, panelW, 42), data != null - ? $"{data.rarity} · {elementLabel} 타입 · {InsectSizeCalculator.Summary(data, pid)}" + ? $"{data.rarity.Korean()} · {elementLabel} 타입 · {InsectSizeCalculator.Summary(data, pid)}" : "Unknown", detailRarityStyle); @@ -663,21 +715,21 @@ private void DrawDetailPanel() float statBlockY = 168f; float statBlockH = 264f; - GUI.color = StatBlockBgCol; - GUI.DrawTexture(new Rect(10f, statBlockY, lowerViewport.width - 20f, statBlockH), Texture2D.whiteTexture); + UISurface.Rounded(new Rect(10f, statBlockY, lowerViewport.width - 20f, statBlockH), StatBlockBgCol); GUI.color = Color.white; float sx = 28f; float sw = lowerViewport.width - 56f; - float barY = statBlockY + 18; + // 행 간격 84 — 78일 때 각 행의 부가줄("기본 … + Lv 보너스")이 다음 행 이름에 붙어 한 덩어리로 읽혔다. + float barY = statBlockY + 14; int bHp = data != null ? data.baseHp : 50; int bAtk = data != null ? data.baseAtk : 20; int bDef = data != null ? data.baseDef : 15; DrawStatBar(sx, barY, sw, "HP", pid.ivHp, pid.GetTotalHp(bHp), bHp); - DrawStatBar(sx, barY + 78, sw, "ATK", pid.ivAtk, pid.GetTotalAtk(bAtk), bAtk); - DrawStatBar(sx, barY + 156, sw, "DEF", pid.ivDef, pid.GetTotalDef(bDef), bDef); + DrawStatBar(sx, barY + 84, sw, "ATK", pid.ivAtk, pid.GetTotalAtk(bAtk), bAtk); + DrawStatBar(sx, barY + 168, sw, "DEF", pid.ivDef, pid.GetTotalDef(bDef), bDef); if (data != null && !string.IsNullOrEmpty(data.description)) { @@ -783,10 +835,8 @@ private void DrawStats(Rect area) DrawStatRow(area.x, ref y, rowH, lw, vw, "경험치", $"{xp}", statsLabelStyle, statsValueStyle); y += 12; - GUI.color = StatsDividerCol; - GUI.DrawTexture(new Rect(area.x, y, area.width, 1), Texture2D.whiteTexture); + UISurface.Flat(new Rect(area.x, y, area.width, 1), StatsDividerCol); y += 12; - GUI.color = Color.white; DrawStatRow(area.x, ref y, rowH, lw, vw, "포획한 곤충", $"{total}", statsLabelStyle, statsValueStyle); DrawStatRow(area.x, ref y, rowH, lw, vw, "캔디", $"{candy}", statsLabelStyle, statsCandyValStyle); @@ -806,13 +856,11 @@ private void DrawStatRow(float x, ref float y, float h, float lw, float vw, private void DrawLevelUpSection(float x, float y, float w, PlayerInsectData pid, InsectData data) { InitDetailStyles(); - GUI.color = LuBgCol; - GUI.DrawTexture(new Rect(x, y, w, 150), Texture2D.whiteTexture); - GUI.color = LuAccentBlueCol; - GUI.DrawTexture(new Rect(x, y, w, 4), Texture2D.whiteTexture); + UISurface.Rounded(new Rect(x, y, w, 150), LuBgCol); + UISurface.Flat(new Rect(x + UITheme.Radius.Card, y + 3f, w - UITheme.Radius.Card * 2f, 4f), LuAccentBlueCol); GUI.color = Color.white; - GUI.Label(new Rect(x + 16, y + 10, 130, 26), "LEVEL", luLvLabelStyle); + GUI.Label(new Rect(x + 16, y + 8, 130, 30), "레벨", luLvLabelStyle); GUI.Label(new Rect(x + 16, y + 38, 104, 54), pid.level.ToString(), luLvNumStyle); int maxLv = insectCollection != null ? insectCollection.GetMaxLevel(pid.insectId) : 50; @@ -826,19 +874,10 @@ private void DrawLevelUpSection(float x, float y, float w, PlayerInsectData pid, float barH = 24f; float barY2 = y + 46; - GUI.Label(new Rect(barX, y + 16, barW, 24), isMaxLevel ? "MAX LEVEL" : "경험치 (EXP)", luXpLabelStyle); - - GUI.color = LuBarBgCol; - GUI.DrawTexture(new Rect(barX, barY2, barW, barH), Texture2D.whiteTexture); - - if (!isMaxLevel && xpRatio > 0) - { - GUI.color = LuBarFillDarkCol; - GUI.DrawTexture(new Rect(barX, barY2 + barH / 2, barW * xpRatio, barH / 2), Texture2D.whiteTexture); - GUI.color = LuBarFillLightCol; - GUI.DrawTexture(new Rect(barX, barY2, barW * xpRatio, barH / 2), Texture2D.whiteTexture); - } + // 24px 상자에 18pt면 한글 윗부분이 잘렸다("경험치 (EXP)"의 위가 깎여 보였다) — 30px로 준다. + GUI.Label(new Rect(barX, y + 12, barW, 30), isMaxLevel ? "최대 레벨" : "경험치", luXpLabelStyle); + UISurface.Meter(new Rect(barX, barY2, barW, barH), isMaxLevel ? 1f : xpRatio, LuBarFillLightCol); GUI.color = Color.white; GUI.Label(new Rect(barX, barY2, barW, barH), isMaxLevel ? "MAX" : $"{pid.currentXp} / {xpNeeded}", luXpValStyle); @@ -853,10 +892,10 @@ private void DrawLevelUpSection(float x, float y, float w, PlayerInsectData pid, int currentCandy = candyInventory != null ? candyInventory.Candies : 0; bool canAfford = currentCandy >= candyCost && !isMaxLevel; - GUI.backgroundColor = canAfford ? LuBtnGreenCol : LuBtnDisabledCol; GUI.enabled = canAfford; - if (GUI.Button(new Rect(btnX, btnY2, btnW2, btnH2), - isMaxLevel ? "MAX" : $"레벨업\n캔디 {candyCost}", luBtnStyle)) + if (UISurface.Button(new Rect(btnX, btnY2, btnW2, btnH2), + isMaxLevel ? "최대" : $"레벨업\n캔디 {candyCost}", + canAfford ? LuBtnGreenCol : LuBtnDisabledCol, luBtnStyle)) { if (insectCollection != null && insectCollection.TryLevelUpWithCandyByInstance(pid.instanceId)) { @@ -872,7 +911,6 @@ private void DrawLevelUpSection(float x, float y, float w, PlayerInsectData pid, } } GUI.enabled = true; - GUI.backgroundColor = Color.white; // luCandyInfoStyle textColor 동적 갱신 (canAfford 따라). luCandyInfoStyle.normal.textColor = canAfford ? LuCandyOkCol : LuCandyLowCol; @@ -902,13 +940,9 @@ private void DrawStatBar(float x, float y, float w, string label, int iv, int to float barH = 28f; float barY2 = y + 6; - GUI.color = BarBgCol; - GUI.DrawTexture(new Rect(barX, barY2, barW, barH), Texture2D.whiteTexture); - float ivRatio = iv / (float)PlayerInsectData.MaxIV; Color barCol = CapturePopupUI.GetIVBarColor(iv); - GUI.color = barCol; - GUI.DrawTexture(new Rect(barX, barY2, barW * ivRatio, barH), Texture2D.whiteTexture); + UISurface.Meter(new Rect(barX, barY2, barW, barH), ivRatio, barCol); GUI.color = Color.white; // barIvStyle textColor 동적 갱신 (barCol 따라). @@ -917,7 +951,8 @@ private void DrawStatBar(float x, float y, float w, string label, int iv, int to GUI.Label(new Rect(x + w - 90, y, 90, 40), $"{total}", barTotalStyle); - GUI.Label(new Rect(x, y + 44, w, 34), + // 36px — 30일 때 한글 아랫부분이 잘려 윗절반만 보였다. + GUI.Label(new Rect(x, y + 40, w, 36), $"기본 {baseStat} + IV {iv} + Lv 보너스", barIvLabelStyle); } diff --git a/Assets/Scripts/UI/DreamVisualCapture.cs b/Assets/Scripts/UI/DreamVisualCapture.cs new file mode 100644 index 0000000..df1decf --- /dev/null +++ b/Assets/Scripts/UI/DreamVisualCapture.cs @@ -0,0 +1,195 @@ +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Dex; +using InsectGame.Spawning; +using InsectGame.Story; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 「챔피언의 꿈」의 섬·도입·깨어남 화면 촬영 fixture(-battleScenario dream-island). + /// + /// 진짜 가 꾸며진 섬을 짓고 진짜 가 안내를 그린다 + /// (IMGUI라 배치 캡처에는 안 잡힌다 — rules/testing.md 「한계 셋」). 전투 화면은 dream-battle이 따로 찍는다. + /// 저장을 부르지 않는 메모리 fixture다. + /// + public static class DreamVisualCapture + { + private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + + public static IEnumerator Run(string output, Camera camera) + { + int shots = 0; + var holder = new GameObject("DreamQAData"); + holder.SetActive(false); + + // 섬에 나올 모든 종 — ID는 저작 데이터 그대로다(오타가 나면 그 곤충이 안 찍힌다). + var database = ScriptableObject.CreateInstance(); + var added = new HashSet(); + foreach (var s in DreamPrologueData.IslandInsects) Species(database, added, s.insectId, s.insectId); + Species(database, added, DreamPrologueData.AceInsectId, "헤라클레스 천공각"); + Species(database, added, DreamPrologueData.ChallengerInsectId, "태고의 비천룡"); + + var collection = holder.AddComponent(); + Set(collection, "database", database); + Set(collection, "saveData", new PlayerInsectCollectionSave()); + var candy = holder.AddComponent(); + Set(candy, "data", new PlayerCandyData()); + var wallet = holder.AddComponent(); + Set(wallet, "data", new PlayerCurrencyData()); + var regionManager = holder.AddComponent(); + var island = holder.AddComponent(); + island.PersistenceEnabled = false; + island.AutoWire(collection, wallet, candy, database); + island.LoadForCapture(new IslandSave { starterGranted = true, guideDone = true }); + + // 섬 환경 프로필(SubAreaEnvironment의 섬 case)과 같은 값 + camera.backgroundColor = new Color(0.56f, 0.80f, 0.96f); + camera.farClipPlane = 400f; + RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat; + RenderSettings.ambientLight = new Color(0.40f, 0.44f, 0.50f); + RenderSettings.fog = true; + RenderSettings.fogMode = FogMode.Exponential; + RenderSettings.fogColor = new Color(0.66f, 0.84f, 0.95f); + RenderSettings.fogDensity = 0.006f; + var sun = new GameObject("DreamQASun").AddComponent(); + sun.type = LightType.Directional; + sun.color = new Color(1f, 0.97f, 0.88f); + sun.intensity = 1.1f; + sun.shadows = LightShadows.Soft; + sun.transform.rotation = Quaternion.Euler(52f, 35f, 0f); + + var player = GameObject.CreatePrimitive(PrimitiveType.Capsule); + player.name = "Player"; + player.GetComponent().material.color = new Color(0.3f, 0.45f, 0.85f); + PlayerMovement movement = player.AddComponent(); + movement.AutoWire(regionManager); + CameraFollower follower = camera.GetComponent(); + follower.SetTarget(player.transform); + + InsectVisual.Renderer = new GameObject("DreamQAPreview").AddComponent(); + + var world = new GameObject("DreamQAWorld").AddComponent(); + world.AutoWire(regionManager, island, follower, movement, database); + // 섬 HUD는 DreamMode에서 숨는다 — 일부러 만들어 두어 "정말 숨는가"도 같이 본다. + var hud = new GameObject("DreamQAIslandHud").AddComponent(); + hud.AutoWire(island, world, movement, null); + + var director = new GameObject("DreamQADirector").AddComponent(); + director.AutoWire(null, movement, player.transform, regionManager, world, null, null, database, null); + + // 도입 영상 — 진짜 영상을 실시간으로 틀고 영상 시각에 맞춰 찍는다(IMGUI 위에 영상이 덮이는지·가로세로 크롭). + // 디코더가 없는 환경이면 영상 대신 카드로 넘어가므로 그 경우엔 건너뛴다(README에 적는다). + director.ShowForCapture("opening-video", 0f); + float[] videoShots = { 1.2f, 2.6f, 3.5f, 4.8f, 6.15f, 6.5f }; + string[] videoNames = { "tunnel", "flash", "reveal", "duelists", "faceoff", "after" }; + bool videoPlayed = false; + for (int i = 0; i < videoShots.Length; i++) + { + float giveUp = Time.realtimeSinceStartup + 8f; + while (!director.IntroReachedCardForCapture && director.IntroClockForCapture < videoShots[i] + && Time.realtimeSinceStartup < giveUp) + yield return null; + if (director.IntroClockForCapture < 0f) break; // 영상이 못 떴다(디코더 없음) 또는 이미 끝났다 + videoPlayed = true; + shots++; yield return Capture(output, $"00-video-{i + 1}-{videoNames[i]}"); + } + + // 도입 카드 + director.ShowForCapture("opening", 1.4f); + yield return Wait(0.3f); + shots++; yield return Capture(output, "01-opening-card"); + + // 섬 도착 — 챔피언의 섬 카드 + director.ShowForCapture("island-title", 0.8f); + yield return Wait(1.6f); + shots++; yield return Capture(output, "02-island-title"); + + // 걷기 안내 — 섬 HUD가 없어야 한다(꿈 모드) + director.ShowForCapture("island-move", 2f); + yield return Wait(0.4f); + shots++; yield return Capture(output, "03-island-move"); + + // 곤충에게 다가가기 — 가장 가까운 곤충을 가리키는 표식 + director.ShowForCapture("island-approach", 2f); + yield return Wait(0.5f); + shots++; yield return Capture(output, "04-island-approach"); + + // 곤충 소개 카드 + director.ShowForCapture("island-card", 2f); + yield return Wait(0.4f); + shots++; yield return Capture(output, "05-island-card"); + + // 분수 앞으로 — 목표 표식 + director.ShowForCapture("island-fountain", 2f); + yield return Wait(0.5f); + shots++; yield return Capture(output, "06-island-fountain"); + + // 분수 가까이에서 본 광장 + Vector3 fountain = DreamPrologueData.FountainWorldPosition(); + player.transform.position = fountain + new Vector3(0f, 0.5f, -4.5f); + follower.SnapToTarget(); + yield return Wait(0.9f); + shots++; yield return Capture(output, "07-fountain-plaza"); + + // 깨어남 자막(흰 화면이 걷히는 중) + director.ShowForCapture("wake", 0.9f); + yield return Wait(0.3f); + shots++; yield return Capture(output, "08-wake-caption"); + + director.EndCapture(); + File.WriteAllText(Path.Combine(output, "README.txt"), + (videoPlayed ? "Intro video: played for real (00-video-*).\n" : "Intro video: did NOT play here (no decoder?) — card only.\n") + + $"Actual standalone IMGUI over the real IslandWorldBuilder + DreamPrologueDirector. {shots} shots at " + + $"{Screen.width}x{Screen.height} (mobile layout={UIScale.IsMobileLayout}).\n" + + "Island: DreamPrologueData.BuildIslandSnapshot via EnterVisit (DreamMode on). In-memory fixture; nothing is saved.\n" + + "Species are stand-in InsectData with the authored IDs (stats/skills are not the real database).\n"); + Application.Quit(shots > 0 ? 0 : 3); + } + + private static void Species(InsectDatabase db, HashSet added, string id, string name) + { + if (!added.Add(id)) return; + var d = ScriptableObject.CreateInstance(); + d.insectId = id; + d.displayName = name; + d.rarity = InsectRarity.Legendary; + d.description = name + " — 검수용 설명."; + d.baseHp = 150; + d.baseAtk = 40; + d.baseDef = 30; + d.baseSizeMm = 70f; + d.baseWeightG = 14f; + db.insects.Add(d); + } + + private static void Set(object target, string name, object value) + { + FieldInfo f = target.GetType().GetField(name, Private); + if (f == null) throw new MissingFieldException(target.GetType().Name, name); + f.SetValue(target, value); + } + + private static IEnumerator Wait(float seconds) + { + float until = Time.realtimeSinceStartup + seconds; + while (Time.realtimeSinceStartup < until) yield return null; + } + + private static IEnumerator Capture(string output, string name) + { + yield return new WaitForEndOfFrame(); + Texture2D shot = ScreenCapture.CaptureScreenshotAsTexture(); + File.WriteAllBytes(Path.Combine(output, name + ".png"), shot.EncodeToPNG()); + UnityEngine.Object.Destroy(shot); + } + } +} +#endif diff --git a/Assets/Scripts/UI/DreamVisualCapture.cs.meta b/Assets/Scripts/UI/DreamVisualCapture.cs.meta new file mode 100644 index 0000000..e8f3390 --- /dev/null +++ b/Assets/Scripts/UI/DreamVisualCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2d51101e15dd15840ba8b1805bb39b43 \ No newline at end of file diff --git a/Assets/Scripts/UI/FieldHudVisualCapture.cs b/Assets/Scripts/UI/FieldHudVisualCapture.cs new file mode 100644 index 0000000..44f036a --- /dev/null +++ b/Assets/Scripts/UI/FieldHudVisualCapture.cs @@ -0,0 +1,451 @@ +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Dex; +using InsectGame.Spawning; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 필드 HUD·포획 선택·배틀팀 화면의 **실제 IMGUI** 촬영 fixture(-battleScenario field-ui). + /// + /// 세 화면 다 OnGUI라 배치 캡처(LiveSceneCapture·FieldDesignTour)에는 안 찍힌다 + /// (rules/testing.md 「한계 셋」). 스탠드얼론 검수 빌드에서 ScreenCapture로 찍는다. + /// + /// 디스크 저장을 부르지 않는다 — 플레이어 상태 컴포넌트는 비활성 호스트에 붙여 Awake(로드)를 막고 + /// private 필드에 메모리 값을 넣는다(와 같은 방식). + /// 필드 배경은 무대용 평지다 — 실제 리전 지형은 FieldDesignTour가 본다. + /// + public static class FieldHudVisualCapture + { + private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + + public static IEnumerator Run(string output, Camera camera) + { + int shots = 0; + var holder = new GameObject("FieldHudQAData"); + holder.SetActive(false); + + // ── 종·보유 ── + var database = ScriptableObject.CreateInstance(); + InsectData rhino = Species(database, "rhinoceros_beetle", "장수풍뎅이", InsectRarity.Rare); + InsectData stag = Species(database, "stag_beetle", "사슴벌레", InsectRarity.Rare); + InsectData mantis = Species(database, "mantis_green", "사마귀", InsectRarity.Uncommon); + InsectData azure = Species(database, "butterfly_azure", "푸른나비", InsectRarity.Epic); + InsectData dragonfly = Species(database, "dragonfly_lake", "호수 잠자리", InsectRarity.Uncommon); + InsectData beetle = Species(database, "beetle_basic", "들판 딱정벌레", InsectRarity.Common); + InsectData hercules = Species(database, "beetle_hercules", "헤라클레스장수풍뎅이", InsectRarity.Legendary); + + var owned = new List + { + Owned("qa-rhino", rhino, 18, 14, 13, 11, false, -1), + Owned("qa-stag", stag, 16, 9, 15, 12, false, 38), // 부상 + Owned("qa-mantis", mantis, 14, 11, 12, 8, true, -1), + Owned("qa-azure", azure, 21, 15, 14, 13, false, -1), + Owned("qa-dragonfly", dragonfly, 11, 7, 9, 10, false, -1), + Owned("qa-beetle", beetle, 6, 5, 6, 4, false, -1), + Owned("qa-hercules", hercules, 25, 13, 15, 14, false, 0), // 기절 + }; + owned[1].isPoisoned = true; + + var collection = holder.AddComponent(); + Set(collection, "database", database); + Set(collection, "saveData", new PlayerInsectCollectionSave { insects = owned }); + var lookup = (Dictionary)Get(collection, "lookup"); + foreach (PlayerInsectData p in owned) lookup.Add(p.instanceId, p); + + var team = holder.AddComponent(); + team.AutoWire(collection); + Set(team, "saveData", new BattleTeamSave + { + slotIds = new List { "qa-rhino", "qa-stag", "qa-azure", "qa-mantis", string.Empty } + }); + + var progress = holder.AddComponent(); + Set(progress, "data", new PlayerProgressData { level = 12, currentXp = 140 }); + var candy = holder.AddComponent(); + Set(candy, "data", new PlayerCandyData { candies = 340 }); + var wallet = holder.AddComponent(); + Set(wallet, "data", new PlayerCurrencyData { coins = 1250, gems = 30 }); + + var items = holder.AddComponent(); + var itemSave = new PlayerItemSave(); + var itemLookup = (Dictionary)Get(items, "lookup"); + foreach (var (id, count) in new[] { ("net_basic", 12), ("net_silver", 3), ("net_gold", 0) }) + { + var rec = new PlayerItemRecord { itemId = id, count = count }; + itemSave.items.Add(rec); + itemLookup.Add(id, rec); + } + Set(items, "save", itemSave); + + var dex = holder.AddComponent(); + var dexSave = new DexSaveData(); + var dexLookup = (Dictionary)Get(dex, "lookup"); + foreach (InsectData s in database.insects) + { + var rec = new DexRecord(s.insectId) { discoveredCount = 3, capturedCount = s == hercules ? 0 : 1 }; + dexSave.records.Add(rec); + dexLookup.Add(s.insectId, rec); + } + Set(dex, "saveData", dexSave); + + var region = new RegionData + { + regionId = "meadow", + displayName = "햇살 초원", + themeColor = new Color(0.55f, 0.85f, 0.4f), + insectIds = new[] { "beetle_basic", "mantis_green", "butterfly_azure", "dragonfly_lake" } + }; + var regionManager = holder.AddComponent(); + Set(regionManager, "currentRegion", region); + + // ── 무대 — 실제 게임 카메라 구도((0,9,-6) 고각) ── + camera.backgroundColor = new Color(0.52f, 0.72f, 0.86f); + camera.transform.position = new Vector3(0f, 9f, -6f); + camera.transform.LookAt(new Vector3(0f, 0f, 1.5f)); + var ground = GameObject.CreatePrimitive(PrimitiveType.Plane); + ground.transform.localScale = Vector3.one * 10f; + ground.GetComponent().material.color = new Color(0.36f, 0.56f, 0.28f); + var sun = new GameObject("FieldHudQASun").AddComponent(); + sun.type = LightType.Directional; + sun.intensity = 1.1f; + sun.transform.rotation = Quaternion.Euler(50f, -30f, 0f); + var player = GameObject.CreatePrimitive(PrimitiveType.Capsule); + player.name = "Player"; + player.transform.position = new Vector3(0f, 1f, 0f); + player.GetComponent().material.color = new Color(0.3f, 0.45f, 0.85f); + + InsectEntity target = Entity(mantis, 9, new Vector3(1.8f, 0.3f, 2.2f), false); + Entity(beetle, 5, new Vector3(-3.5f, 0.3f, 4.5f), false); + Entity(dragonfly, 7, new Vector3(5f, 0.3f, 7f), false); + InsectEntity raidTarget = Entity(azure, 22, new Vector3(-2.2f, 0.3f, 1.6f), false); + raidTarget.gameObject.SetActive(false); + + var visual = new GameObject("FieldHudQAPreview").AddComponent(); + InsectVisual.Renderer = visual; + + // ── 필드 HUD ── + var hud = new GameObject("FieldHudQAStatus").AddComponent(); + hud.AutoWire(progress, candy, collection, items, dex, team, regionManager); + hud.AutoWire(wallet); + var minimap = new GameObject("FieldHudQAMinimap").AddComponent(); + minimap.AutoWire(regionManager); + minimap.AutoWire(hud); + var keyGuide = new GameObject("FieldHudQAKeyGuide").AddComponent(); + keyGuide.AutoWire(regionManager); + keyGuide.AutoWire(items); + + var quickTargets = new GameObject("FieldHudQAQuickTargets"); + quickTargets.SetActive(false); + var quick = new GameObject("FieldHudQAQuickBar").AddComponent(); + Set(quick, "dexScreen", quickTargets.AddComponent()); + Set(quick, "collectionUI", quickTargets.AddComponent()); + Set(quick, "inventoryScreen", quickTargets.AddComponent()); + Set(quick, "questUI", quickTargets.AddComponent()); + Set(quick, "regionMapUI", quickTargets.AddComponent()); + + var teamUi = new GameObject("FieldHudQATeam").AddComponent(); + teamUi.AutoWire(team, collection); + teamUi.AutoWire(quickTargets.AddComponent()); + Set(quick, "battleTeamUI", teamUi); + + var choice = new GameObject("FieldHudQAChoice").AddComponent(); + choice.AutoWire(null, null, null, team, collection, null, null, dex, null, items, null); + choice.SetCaptureItems(CaptureItems()); + + // 시각·날씨 칩(우측 열 맨 아래) — 밤 22시대·비로 붙잡는다. 칩을 붙이기 전에 걸어 두어 첫 장에 변화 알림이 뜨지 않게 한다 + // (rules/world-environment.md 「HUD」). 아래에서 눈으로 바꿔 알림도 한 장 찍는다. + var skyHolder = new GameObject("FieldHudQASky"); + var clock = skyHolder.AddComponent(); + var weather = skyHolder.AddComponent(); + var worldState = skyHolder.AddComponent(); + worldState.AutoWire(clock, weather); + clock.SetTime01(22.2f / 24f, true); + weather.SetWeather(WeatherType.Rain, true, true); + var clockHud = new GameObject("FieldHudQAClock").AddComponent(); + clockHud.AutoWire(worldState, regionManager, null); + + yield return Wait(1.6f); + shots++; yield return Capture(output, "01-field-hud"); + + Set(hud, "expanded", false); + yield return Wait(0.6f); + shots++; yield return Capture(output, "02-field-hud-collapsed"); + + weather.SetWeather(WeatherType.Snow, true, true); + yield return Wait(0.8f); + shots++; yield return Capture(output, "02b-clock-weather-notice"); + + choice.ShowChoice(target); + yield return Wait(0.8f); + shots++; yield return Capture(output, "03-capture-choice"); + Set(choice, "showItemSelect", true); + yield return Wait(0.5f); + shots++; yield return Capture(output, "04-capture-items"); + Set(choice, "showItemSelect", false); + Set(choice, "showTeamSelect", true); + yield return Wait(0.8f); + shots++; yield return Capture(output, "05-capture-team"); + choice.Hide(); + + raidTarget.gameObject.SetActive(true); + choice.ShowChoice(raidTarget); + yield return Wait(0.8f); + shots++; yield return Capture(output, "06-capture-raid"); + choice.Hide(); + yield return Wait(0.3f); + + // 「습격!」 창 — 저녁·밤에 깨어난 습격형이 닿으면 뜬다(rules/world-environment.md 「습격」). 1v1 대상이어야 열린다 + // (레이드 대상은 5칸 팀이 필요한데 이 팀은 4칸이다). 도망 실패 상태도 찍는다 — 자동으로 싸움에 넘어가는 분기는 + // 전투 컨트롤러가 없는 이 픽스처에서 막아 둔다(ambushAutoFought). + if (choice.ShowAmbush(target, "밤이 되자 사나워졌다")) + { + yield return Wait(0.9f); // 창이 뜬 뒤 0.45초는 입력을 받지 않는다 — 그 뒤 모습 + shots++; yield return Capture(output, "06b-ambush"); + Set(choice, "ambushAutoFought", true); + Set(choice, "ambushEscapeTried", true); + Set(choice, "ambushEscapeFailed", true); + yield return Wait(0.3f); + shots++; yield return Capture(output, "06c-ambush-escape-failed"); + choice.Hide(); + yield return Wait(0.3f); + } + else Debug.LogError("[QA] ambush window did not open — CanFight refused the fixture team"); + + // 포획 성공 팝업 — 필드 흐름의 마지막 화면. 결과 이벤트 핸들러를 직접 불러 같은 상태를 만든다. + var popup = new GameObject("FieldHudQAPopup").AddComponent(); + popup.AutoWire(collection); + typeof(CapturePopupUI).GetMethod("OnCaptureResolved", Private).Invoke(popup, new object[] { target, true }); + Set(popup, "capturedNewSpecies", true); // 그 종의 첫 포획 — 초상 모서리의 NEW + yield return Wait(0.9f); + shots++; yield return Capture(output, "09-capture-success"); + typeof(CapturePopupUI).GetMethod("OnCaptureResolved", Private).Invoke(popup, new object[] { target, false }); + yield return Wait(0.9f); + shots++; yield return Capture(output, "10-capture-fail"); + Object.Destroy(popup.gameObject); + yield return Wait(0.3f); + + // ── 포획 미니게임 3종 — 판을 원하는 순간에 세워 놓고 찍는다(조작은 가짜 입력으로 미리 돌린다) ── + var minigame = new GameObject("FieldHudQAMinigame").AddComponent(); + var board = new Vector2(InsectGame.Capture.CaptureMinigame.BoardWidth, InsectGame.Capture.CaptureMinigame.BoardHeight); + minigame.StartForCapture(target, InsectGame.Capture.CaptureMinigameKind.Sneak, 1.1f, true, Vector2.zero); + yield return Wait(0.6f); + shots++; yield return Capture(output, "19-minigame-sneak"); + minigame.EndCapture(); + minigame.StartForCapture(target, InsectGame.Capture.CaptureMinigameKind.Sneak, 9f, false, Vector2.zero, true); + yield return Wait(0.6f); + shots++; yield return Capture(output, "20-minigame-sneak-look"); + minigame.EndCapture(); + minigame.StartForCapture(target, InsectGame.Capture.CaptureMinigameKind.Track, 1.4f, true, board * 0.5f); + yield return Wait(0.6f); + shots++; yield return Capture(output, "21-minigame-track"); + minigame.EndCapture(); + minigame.StartForCapture(target, InsectGame.Capture.CaptureMinigameKind.Toss, 0.3f, true, new Vector2(board.x * 0.62f, board.y * 0.36f)); + yield return Wait(0.6f); + shots++; yield return Capture(output, "22-minigame-toss"); + minigame.EndCapture(); + Object.Destroy(minigame.gameObject); + yield return Wait(0.3f); + + // ── 필드 소식 — 라온과의 내기 점수판 + 소식 카드(레벨업·보상·색다른 조우) ── + // 내기 컨트롤러는 꺼 둔 채 상태만 넣는다(Update가 돌면 PlayerPrefs를 읽고 시계가 흐른다). + var race = new GameObject("FieldHudQARace").AddComponent(); + race.enabled = false; + Set(race, "active", true); + Set(race, "k__BackingField", 2); + Set(race, "k__BackingField", 1); + var moments = new GameObject("FieldHudQAMoments").AddComponent(); + moments.AutoWire(null, null, race, null); + moments.ShowForCapture(new FieldMoment(FieldMomentKind.LevelUp, "레벨 업! Lv.13", + "이제 Lv.18 곤충까지 레벨 차 페널티 없이 잡을 수 있습니다"), 0.45f); + yield return Wait(0.2f); + shots++; yield return Capture(output, "23-moment-levelup-race"); + moments.ShowForCapture(new FieldMoment(FieldMomentKind.Reward, "보상을 받았습니다", + "캔디 +5 · 은빛 채집망 ×2 · 호수 잠자리 Lv.6"), 1.2f); + yield return Wait(0.2f); + shots++; yield return Capture(output, "24-moment-reward"); + moments.ShowForCapture(new FieldMoment(FieldMomentKind.Rival, "라온과의 내기에서 이겼습니다!", + "라온: \"…졌다. 다음엔 안 봐줘!\" 캔디 +15 · 은빛 채집망 ×2"), 1.2f); + yield return Wait(0.2f); + shots++; yield return Capture(output, "25-moment-rival"); + Object.Destroy(moments.gameObject); + Object.Destroy(race.gameObject); + yield return Wait(0.3f); + + teamUi.Toggle(); + yield return Wait(1.2f); + shots++; yield return Capture(output, "07-team"); + Set(teamUi, "selectingSlot", 4); + Set(teamUi, "pickerSortDirty", true); + yield return Wait(1.2f); + shots++; yield return Capture(output, "08-team-picker"); + teamUi.CloseModal(); + + // ── 훈련소 — 방식·범용기·종족 learnset을 실제 부트스트랩 코드로 만든다(가격이 곧 검수 대상이다) ── + // 부트스트랩은 비활성 호스트에 붙여 Awake(월드 생성)를 막고 private 생성 함수만 부른다. + var bootstrap = holder.AddComponent(); + rhino.primaryType = InsectElement.Metal; + rhino.secondaryType = InsectElement.Bug; + azure.primaryType = InsectElement.Wind; + azure.secondaryType = InsectElement.Light; + var allSkills = new List( + (InsectSkill[])typeof(PlaySceneBootstrap).GetMethod("CreateTrainingSkills", Private).Invoke(bootstrap, null)); + MethodInfo buildLearnset = typeof(PlaySceneBootstrap).GetMethod("BuildLevelLearnset", Private); + foreach (InsectData s in database.insects) + { + s.learnset = (InsectLearnableSkill[])buildLearnset.Invoke(bootstrap, new object[] { s }); + foreach (InsectLearnableSkill l in s.learnset) + if (l != null && l.skill != null && !allSkills.Contains(l.skill)) allSkills.Add(l.skill); + } + var training = holder.AddComponent(); + training.AutoWire(collection, candy); + training.Initialize( + (TrainingMethod[])typeof(PlaySceneBootstrap).GetMethod("CreateTrainingMethods", Private).Invoke(bootstrap, null), + allSkills.ToArray()); + + // 장수풍뎅이: 종족기 셋을 배웠고 폭발·단단해지기를 훈련 중. 푸른나비: 6칸이 가득 찬 교체 상황. + PlayerInsectData rhinoPid = owned[0]; + foreach (InsectLearnableSkill l in rhino.learnset) + { + if (l.learnLevel > 9) continue; + rhinoPid.learnedSkillIds.Add(l.skillId); + rhinoPid.equippedSkillIds.Add(l.skillId); + } + rhinoPid.trainingProgress.Add("bug_burst:1"); + rhinoPid.trainingProgress.Add("tr_harden:1"); + PlayerInsectData azurePid = owned[3]; + foreach (InsectLearnableSkill l in azure.learnset) + azurePid.learnedSkillIds.Add(l.skillId); + for (int i = 0; i < PlayerInsectData.MaxEquipSlots; i++) + azurePid.equippedSkillIds.Add(azure.learnset[i].skillId); + + var trainingUi = new GameObject("FieldHudQATraining").AddComponent(); + trainingUi.AutoWire(training, collection, candy); + trainingUi.OpenForCapture(null, "select"); + yield return Wait(1.2f); + shots++; yield return Capture(output, "11-training-select"); + trainingUi.OpenForCapture("qa-rhino", "method"); + yield return Wait(1.0f); + shots++; yield return Capture(output, "12-training-method"); + trainingUi.OpenForCapture("qa-rhino", "growth"); + yield return Wait(1.0f); + shots++; yield return Capture(output, "13-training-growth"); + trainingUi.ShowFeedbackForCapture("등급 상승! A → S", true); + yield return Wait(0.4f); + shots++; yield return Capture(output, "14-training-growth-toast"); + Set(trainingUi, "feedbackTimer", 0f); + trainingUi.OpenForCapture("qa-rhino", "learn", 0); // 종족 기술 연구 + yield return Wait(1.0f); + shots++; yield return Capture(output, "15-training-learn-species"); + trainingUi.OpenForCapture("qa-rhino", "learn", 4); // 특수 훈련 — 상태기 가격 + yield return Wait(1.0f); + shots++; yield return Capture(output, "16-training-learn-special"); + trainingUi.OpenForCapture("qa-rhino", "equip"); + yield return Wait(1.0f); + shots++; yield return Capture(output, "17-training-equip"); + trainingUi.OpenForCapture("qa-azure", "replace", 4, "tr_nature_force"); + yield return Wait(1.0f); + shots++; yield return Capture(output, "18-training-replace"); + trainingUi.CloseModal(); + + File.WriteAllText(Path.Combine(output, "README.txt"), + $"Actual standalone IMGUI. {shots} shots at {Screen.width}x{Screen.height} (mobile layout={UIScale.IsMobileLayout}).\n" + + "In-memory player state on an inactive host (no Awake/Load, no save calls). Field backdrop is a flat staging plane.\n" + + "Team: 4/5 filled, slot 2 poisoned+hurt; one owned insect fainted. Capture choice/item/team states injected via private fields.\n" + + "Training: methods/general skills/species learnsets built by the real PlaySceneBootstrap generators (prices are live). Candy 340.\n"); + Application.Quit(shots > 0 ? 0 : 3); + } + + private static InsectData Species(InsectDatabase db, string id, string name, InsectRarity rarity) + { + var d = ScriptableObject.CreateInstance(); + d.insectId = id; + d.displayName = name; + d.rarity = rarity; + d.description = name + " — 검수용 설명."; + d.baseHp = 120; + d.baseAtk = 34; + d.baseDef = 28; + d.baseSizeMm = 60f; + d.baseWeightG = 12f; + db.insects.Add(d); + return d; + } + + private static PlayerInsectData Owned(string instance, InsectData species, int level, + int hp, int atk, int def, bool shiny, int currentHp) + { + var p = new PlayerInsectData + { + instanceId = instance, + insectId = species.insectId, + level = level, + ivHp = hp, + ivAtk = atk, + ivDef = def, + isShiny = shiny, + sizeRoll = 50 + }; + p.currentHp = currentHp < 0 ? p.GetTotalHp(species.baseHp) : currentHp; + return p; + } + + private static InsectEntity Entity(InsectData data, int level, Vector3 at, bool shiny) + { + var go = new GameObject("QAInsect_" + data.insectId); + go.transform.position = at; + var e = go.AddComponent(); + e.BuildForBattle(data, level, shiny); + go.transform.localScale = Vector3.one * 1.4f; + return e; + } + + private static CaptureItemData[] CaptureItems() + { + return new[] + { + new CaptureItemData { itemId = "net_basic", displayName = "기본 채집망", description = "흔한 채집망 - 보통 난이도", + themeColor = new Color(0.6f, 0.6f, 0.6f), speedMultiplier = 1f, zoneSizeMultiplier = 1f, timeLimitMultiplier = 1f }, + new CaptureItemData { itemId = "net_silver", displayName = "은빛 채집망", description = "좋은 품질 - 미니게임이 쉬워짐", + themeColor = new Color(0.75f, 0.82f, 0.95f), speedMultiplier = 0.75f, zoneSizeMultiplier = 1.3f, timeLimitMultiplier = 1.3f, captureBonus = 0.1f }, + new CaptureItemData { itemId = "net_gold", displayName = "황금 채집망", description = "최고급 - 매우 쉬운 미니게임 + 포획률 보너스", + themeColor = new Color(1f, 0.85f, 0.2f), speedMultiplier = 0.55f, zoneSizeMultiplier = 1.6f, timeLimitMultiplier = 1.6f, captureBonus = 0.2f }, + }; + } + + private static object Get(object target, string name) + { + FieldInfo f = target.GetType().GetField(name, Private); + if (f == null) throw new System.MissingFieldException(target.GetType().Name, name); + return f.GetValue(target); + } + + private static void Set(object target, string name, object value) + { + FieldInfo f = target.GetType().GetField(name, Private); + if (f == null) throw new System.MissingFieldException(target.GetType().Name, name); + f.SetValue(target, value); + } + + private static IEnumerator Wait(float seconds) + { + float until = Time.realtimeSinceStartup + seconds; + while (Time.realtimeSinceStartup < until) yield return null; + } + + private static IEnumerator Capture(string output, string name) + { + yield return new WaitForEndOfFrame(); + Texture2D shot = ScreenCapture.CaptureScreenshotAsTexture(); + File.WriteAllBytes(Path.Combine(output, name + ".png"), shot.EncodeToPNG()); + Object.Destroy(shot); + } + } +} +#endif diff --git a/Assets/Scripts/UI/FieldHudVisualCapture.cs.meta b/Assets/Scripts/UI/FieldHudVisualCapture.cs.meta new file mode 100644 index 0000000..b643b14 --- /dev/null +++ b/Assets/Scripts/UI/FieldHudVisualCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0ad5dba2d3a26bc4281fb7383ae6bc86 \ No newline at end of file diff --git a/Assets/Scripts/UI/FieldMomentsUI.cs b/Assets/Scripts/UI/FieldMomentsUI.cs new file mode 100644 index 0000000..1712feb --- /dev/null +++ b/Assets/Scripts/UI/FieldMomentsUI.cs @@ -0,0 +1,286 @@ +using InsectGame.Core; +using InsectGame.Story; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 필드 소식 — 레벨업, 이야기 보상, 라온과의 내기, 색다른 조우를 화면에 잠깐 띄운다. + /// + /// 소식은 에서 꺼내 온다(구독하지 않는다). UI 루트가 꺼졌다 켜질 때 + /// 구독이 사라지는 계열(rules/ui-layout.md)에 걸리지 않고, 대사·전투 화면이 덮은 동안 들어온 소식은 + /// 그 뒤에 차례로 뜬다. 내기 점수판도 의 상태를 읽기만 한다. + /// + /// 비모달이라 자리를 에 등록한다 — 안 하면 카드를 누른 탭이 월드 + /// 클릭-이동으로 새어 캐릭터가 그 밑으로 걸어간다. + /// + public class FieldMomentsUI : MonoBehaviour + { + private const float ShowSeconds = 3.6f; + private const float FadeInSeconds = 0.25f; + private const float FadeOutSeconds = 0.35f; + private const float ToastHeight = 108f; + private const float ToastWidth = 760f; + /// 카드 둘레 여백 — 처음 0.9초 동안 기호 둘레로 퍼지는 점(반지름 최대 64)이 카드 밖 8px까지 나간다. + private const float BurstMargin = 8f; + private const float RaceChipHeight = 58f; + // 리전 배너(ContentTop부터 80) 아래. + private const float RaceChipTop = 86f; + + private FieldMomentFeed feed; + private PlayerProgressController progress; + private RivalRaceController rivalRace; + private PlayerMovement playerMovement; + + private bool hasCurrent; + private FieldMoment current; + private float shownFor; + // 마지막으로 본 레벨업 일련번호. 음수면 아직 못 봤다 — 처음 본 값은 기준일 뿐 연출하지 않는다. + private int seenLevelUpSerial = -1; + + // 목표 수는 규칙(RivalRaceRules.Target)이 정한다 — 문구에 숫자를 따로 박아 두면 규칙을 바꿀 때 어긋난다. + private static readonly string RaceGoalText = $"먼저 {RivalRaceRules.Target}마리!"; + + private GUIStyle titleStyle, bodyStyle, glyphStyle, raceNameStyle, raceMidStyle; + private bool stylesReady; + + public void AutoWire(FieldMomentFeed momentFeed, PlayerProgressController progressController, + RivalRaceController race, PlayerMovement movement) + { + if (feed == null) feed = momentFeed; + if (progress == null) progress = progressController; + if (rivalRace == null) rivalRace = race; + if (playerMovement == null) playerMovement = movement; + } + + // 대사·전투·메뉴가 화면을 덮었거나 포획 창처럼 조작이 묶인 동안은 그리지 않고 시간도 흐르지 않는다 — + // 보지 못한 소식이 그사이에 지나가 버리지 않게. + // 「챔피언의 꿈」 동안도 숨는다(rules/dream-prologue.md — 필드 위 HUD는 꿈속에 비치지 않는다). + private bool Hidden => ModalUIRegistry.IsAnyOpen() || (playerMovement != null && playerMovement.IsFrozen) + || DreamPrologueState.Active; + + private void Update() + { + WatchLevelUp(); + if (Hidden) return; + + // 소식 카드는 가운데 무대(HudStage)에 선다 — 포획 결과·퀘스트 완료 같은 앞 차례가 서 있으면 꺼내지도, 시간을 쓰지도 않는다. + if (hasCurrent) + { + if (!HudStage.Request(HudStageItem.Moment)) return; + shownFor += Time.deltaTime; + if (shownFor >= ShowSeconds) hasCurrent = false; + } + if (!hasCurrent && feed != null && feed.PendingCount > 0 && HudStage.Request(HudStageItem.Moment) + && feed.TryDequeue(out current)) + { + hasCurrent = true; + shownFor = 0f; + if (current.Kind == FieldMomentKind.LevelUp && AudioManager.Instance != null) + AudioManager.Instance.PlaySFX(SfxType.LevelUp); + } + } + + private void WatchLevelUp() + { + if (progress == null || feed == null) return; + int serial = progress.LevelUpSerial; + if (seenLevelUpSerial < 0) { seenLevelUpSerial = serial; return; } + if (serial == seenLevelUpSerial) return; + seenLevelUpSerial = serial; + + // 캐릭터 레벨이 실제로 바꾸는 것을 한 줄로 — 곤충이 캐릭터보다 이만큼 넘게 높으면 포획이 깎인다. + int level = progress.Level; + feed.Push(FieldMomentKind.LevelUp, $"레벨 업! Lv.{level}", + $"이제 Lv.{level + GameConstants.TrainerLevel.CaptureGraceLevels} 곤충까지 레벨 차 페널티 없이 잡을 수 있습니다"); + } + + private void InitStyles() + { + if (stylesReady) return; + stylesReady = true; + UITheme t = UITheme.Instance; + titleStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + bodyStyle = new GUIStyle(GUI.skin.label) { fontSize = 23, alignment = TextAnchor.MiddleLeft }; + glyphStyle = new GUIStyle(GUI.skin.label) { fontSize = 28, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + glyphStyle.normal.textColor = t.surfaceBase; + raceNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 24, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + raceMidStyle = new GUIStyle(GUI.skin.label) { fontSize = 20, alignment = TextAnchor.MiddleCenter }; + raceMidStyle.normal.textColor = t.textSecondary; + } + + private void OnGUI() + { + bool race = rivalRace != null && rivalRace.IsActive; + if (!race && !hasCurrent) return; + if (Hidden) return; + + UIScale.Begin(); + InitStyles(); + if (race) DrawRaceChip(); + if (hasCurrent && HudStage.Request(HudStageItem.Moment)) DrawToast(); + GUI.color = Color.white; + UIScale.End(); + } + + // ── 라온과의 내기 점수판 ── + + private void DrawRaceChip() + { + UITheme t = UITheme.Instance; + HudFrame frame = HudFrame.Current; + Rect chip = RaceChipRect(frame); + // 상태 패널을 펼치면 겹치는 점수판은 비켜선다(세로 화면에서 패널 폭 480이 이 자리를 덮는다). + if (MinimapUI.LeftStackOccluded && chip.Overlaps(PlayerStatusHUD.PanelRect(frame))) return; + FieldHudInput.RegisterBlockingRect(chip); + UISurface.HudCard(chip); + + raceNameStyle.normal.textColor = t.accentMint; + GUI.Label(new Rect(chip.x + 10f, chip.y, 48f, chip.height), "나", raceNameStyle); + DrawPips(chip.x + 62f, chip.center.y, 1f, rivalRace.PlayerCount, t.accentMint); + + // 가운데 문구는 양쪽 점수(이름 + 점 셋, 한쪽 148px) 사이에 남는 폭만 쓴다. + float midW = Mathf.Max(60f, chip.width - 296f); + UIHelper.LabelFit(new Rect(chip.center.x - midW * 0.5f, chip.y, midW, chip.height), + RaceGoalText, raceMidStyle); + + DrawPips(chip.xMax - 62f, chip.center.y, -1f, rivalRace.RivalCount, t.accentCoral); + raceNameStyle.normal.textColor = t.accentCoral; + UIHelper.LabelFit(new Rect(chip.xMax - 62f, chip.y, 56f, chip.height), "라온", raceNameStyle); + } + + // 점 세 개 — 잡은 만큼 채운다. direction이 음수면 오른쪽에서 왼쪽으로 채운다(라온 쪽). + private static void DrawPips(float startX, float centerY, float direction, int filled, Color color) + { + UITheme t = UITheme.Instance; + const float size = 18f; + const float pitch = 26f; + for (int i = 0; i < RivalRaceRules.Target; i++) + { + float cx = startX + direction * (i * pitch + size * 0.5f); + UIShapes.Ellipse(new Rect(cx - size * 0.5f, centerY - size * 0.5f, size, size), + i < filled ? color : t.surfaceBorder); + } + } + + // ── 소식 한 장 ── + + /// + /// 내기 점수판의 자리 — 순수 계산. 리전 배너 아래 줄. 모바일은 좌상단 상태 탭과 우상단 단축 바 사이(리전 배너와 같은 구간) — + /// 화면 중앙에 두면 세로 화면에서 오른쪽 끝("라온")이 단축 바 밑에 깔린다(2026-10-02 검수 캡처). + /// 가운데 무대()는 이 아래에서 시작한다. + /// + public static Rect RaceChipRect(HudFrame f) + { + float left = f.ContentLeft; + float right = f.ContentRight; + if (f.Mobile) + { + left = PlayerStatusHUD.TabRect(f).xMax + UITheme.Space.S; + right = QuickAccessBarUI.ShortcutBarRectFor(f).x - UITheme.Space.S; + } + float w = Mathf.Min(460f, Mathf.Max(1f, right - left)); + return new Rect(left + (right - left - w) * 0.5f, f.ContentTop + RaceChipTop, w, RaceChipHeight); + } + + /// + /// 소식 카드가 차지하는 자리(둘레로 퍼지는 점까지) — 가운데 무대의 차례 항목(). + /// 예전엔 모바일은 화면 가운데 줄, 데스크톱은 ContentTop+262라 섬 안내 배너·대화 버튼·포획 결과 카드와 한 자리였다. + /// + public static Rect ToastFootprint(HudFrame f) + { + return HudStage.Place(f, HudStageItem.Moment, ToastWidth + BurstMargin * 2f, ToastHeight + BurstMargin * 2f); + } + + private static Rect ToastRect() + { + Rect area = ToastFootprint(HudFrame.Current); + return new Rect(area.x + BurstMargin, area.y + BurstMargin, area.width - BurstMargin * 2f, + area.height - BurstMargin * 2f); + } + + private void DrawToast() + { + UITheme t = UITheme.Instance; + float alpha = Mathf.Clamp01(shownFor / FadeInSeconds) + * Mathf.Clamp01((ShowSeconds - shownFor) / FadeOutSeconds); + HudStage.Request(HudStageItem.Moment, ToastFootprint(HudFrame.Current)); + Rect card = ToastRect(); + // 나타날 때 아래에서 살짝 올라온다(위로 미끄러지면 무대 밖 — 내기 점수판 — 으로 나간다). 무대 안에서만 움직인다. + card.y += (1f - Mathf.Clamp01(shownFor / FadeInSeconds)) * BurstMargin; + FieldHudInput.RegisterBlockingRect(card); + + Color accent = AccentOf(current.Kind); + GUI.color = new Color(1f, 1f, 1f, alpha); + UISurface.Card(card, t.surfaceCard, Color.Lerp(t.surfaceBorder, accent, 0.6f)); + UISurface.Flat(new Rect(card.x + UITheme.Radius.Card, card.y + 3f, card.width - UITheme.Radius.Card * 2f, 4f), accent); + GUI.color = Color.white; + + // 왼쪽 기호 원 — 처음 0.9초 동안 둘레로 점이 퍼진다(레벨업·색다른 조우처럼 좋은 소식일 때만). + Vector2 icon = new Vector2(card.x + 58f, card.center.y + 2f); + if (current.Kind != FieldMomentKind.Rival) DrawBurst(icon, accent, alpha); + UIShapes.Ellipse(new Rect(icon.x - 30f, icon.y - 30f, 60f, 60f), new Color(accent.r, accent.g, accent.b, alpha)); + glyphStyle.normal.textColor = new Color(t.surfaceBase.r, t.surfaceBase.g, t.surfaceBase.b, alpha); + GUI.Label(new Rect(icon.x - 30f, icon.y - 30f, 60f, 60f), GlyphOf(current.Kind), glyphStyle); + + float textX = card.x + 106f; + float textW = card.xMax - 20f - textX; + bool hasBody = !string.IsNullOrEmpty(current.Body); + titleStyle.normal.textColor = new Color(accent.r, accent.g, accent.b, alpha); + UIHelper.LabelFit(new Rect(textX, card.y + (hasBody ? 14f : 32f), textW, 44f), current.Title, titleStyle); + if (hasBody) + { + bodyStyle.normal.textColor = new Color(t.textSecondary.r, t.textSecondary.g, t.textSecondary.b, alpha); + UIHelper.LabelFit(new Rect(textX, card.y + 60f, textW, 36f), current.Body, bodyStyle); + } + } + + private void DrawBurst(Vector2 center, Color accent, float alpha) + { + const float burstSeconds = 0.9f; + if (shownFor >= burstSeconds) return; + float k = shownFor / burstSeconds; + float radius = 34f + k * 30f; + float size = 10f * (1f - k); + Color dot = new Color(accent.r, accent.g, accent.b, alpha * (1f - k)); + for (int i = 0; i < 8; i++) + { + float angle = i * Mathf.PI * 0.25f + k * 0.8f; + UIShapes.Ellipse(new Rect(center.x + Mathf.Cos(angle) * radius - size * 0.5f, + center.y + Mathf.Sin(angle) * radius - size * 0.5f, size, size), dot); + } + } + + private static Color AccentOf(FieldMomentKind kind) + { + UITheme t = UITheme.Instance; + switch (kind) + { + case FieldMomentKind.Reward: return t.accentMint; + case FieldMomentKind.Rival: return t.accentCoral; + default: return t.accentAmber; // LevelUp · Discovery + } + } + + private static string GlyphOf(FieldMomentKind kind) + { + switch (kind) + { + case FieldMomentKind.LevelUp: return "▲"; + case FieldMomentKind.Reward: return "★"; + case FieldMomentKind.Rival: return "VS"; + default: return "!"; + } + } + +#if UNITY_EDITOR || DEVELOPMENT_BUILD + /// 검수 빌드 전용 — 소식 한 장을 지정한 시점(초)에 세워 둔다. + public void ShowForCapture(FieldMoment moment, float at) + { + current = moment; + hasCurrent = true; + shownFor = at; + } +#endif + } +} diff --git a/Assets/Scripts/UI/FieldMomentsUI.cs.meta b/Assets/Scripts/UI/FieldMomentsUI.cs.meta new file mode 100644 index 0000000..f8942ad --- /dev/null +++ b/Assets/Scripts/UI/FieldMomentsUI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8a640dd4b9c39f54ea1ed139b9d89470 \ No newline at end of file diff --git a/Assets/Scripts/UI/GuidedTutorialController.cs b/Assets/Scripts/UI/GuidedTutorialController.cs index 58dbe56..e2adff3 100644 --- a/Assets/Scripts/UI/GuidedTutorialController.cs +++ b/Assets/Scripts/UI/GuidedTutorialController.cs @@ -19,7 +19,8 @@ public class GuidedTutorialController : MonoBehaviour // q_approach("곤충에게 다가가 E 키로 포획해보세요!")는 사용자 요청으로 제거 — 그 강제 배너/프리즈 미표시. // 퀘스트 진행 자체는 TutorialQuestManager가 처리하므로 안내만 빠지고 흐름은 유지된다. { "q_battle", "야생 곤충에게 B 키로 배틀을 걸어보세요!" }, - { "q_team", "T 키로 배틀 팀을 편성하세요!" }, + // q_team(팀 편성)은 「둘러보기」 서브 과제로 옮겨졌다 — 서브는 활성화 이벤트가 없어 강제 가이드에 + // 걸리지도 않고, 걸려서도 안 된다(안 해도 되는 일로 화면을 막지 않는다). }; private string activeGuidedQuestId; // 현재 가이드 중인 questId (없으면 null) @@ -121,6 +122,8 @@ private void OnGUI() { if (!IsGuiding) return; if (ModalUIRegistry.IsAnyOpen()) return; // 다른 모달 위로 안 겹치게 + if (DreamPrologueState.Active) return; // 꿈 밖의 안내가 꿈속에 비치지 않게(다시 보기는 가이드 중에도 열린다) + if (YieldsNow(HudFrame.Current)) return; UIScale.Begin(); InitStyles(); @@ -128,6 +131,23 @@ private void OnGUI() UIScale.End(); } + /// + /// 코치 배너가 지금 비켜서는가 — 가운데 무대에 선 카드와 겹치거나(), 섬 안내 배너가 같은 자리에 + /// 섰거나, 대화 버튼·동굴 입구 버튼이 이 배너와 겹치는 자리에 섰을 때. 배너는 행동 안내라 버튼·카드에 자리를 내준다 + /// (가이드는 퀘스트를 마칠 때까지 남아 있어 잠깐 비켜도 다시 뜬다). + /// + private static bool YieldsNow(HudFrame f) + { + Rect coach = CoachRect(f); + if (HudStage.OccupiedOver(coach)) return true; + if (HudPresence.IsShowing(HudPresenceItem.IslandGuide)) return true; + if (HudPresence.IsShowing(HudPresenceItem.Talk) && coach.Overlaps(WorldInteractionController.TalkRect(f))) + return true; + if (HudPresence.IsShowing(HudPresenceItem.Nearby) && coach.Overlaps(WorldFieldMultiplayerUI.NearbyRect(f))) + return true; + return HudPresence.IsShowing(HudPresenceItem.Gate) && coach.Overlaps(SubAreaWorldBuilder.GateRect(f)); + } + private void InitStyles() { if (stylesInit) return; @@ -140,14 +160,37 @@ private void InitStyles() hintStyle.normal.textColor = new Color(0.8f, 0.9f, 1f); } + /// 코치 배너의 윗변 — 안전 영역 위에서 이만큼(상단 리전 배너·알림 아래). + public const float CoachTopOffset = 150f; + public const float CoachHeight = 100f; + public const float CoachMaxWidth = 720f; + + /// + /// 코치 배너의 자리 — 순수 계산. 데스크톱은 위쪽 가운데(리전 배너·내기 점수판 아래, ContentTop+150). + /// 모바일은 섬 안내 배너와 같은 가운데 줄(세로 59%·가로 64%, 캐릭터 발밑) — 위쪽은 세로 화면에서 미니맵·단축 바가 + /// 양옆을 차지해 폭 720 배너가 둘 다 덮었다. 가로 모바일의 퀘스트 칩()은 데스크톱 띠 높이 아래에 선다. + /// + public static Rect CoachRect(HudFrame f) + { + if (!f.Mobile) + { + float availW = f.Width - f.SafeLeft - f.SafeRight; + float w = Mathf.Min(CoachMaxWidth, availW - 24f); + return new Rect(f.SafeLeft + (availW - w) * 0.5f, f.ContentTop + CoachTopOffset, w, CoachHeight); + } + float mw = Mathf.Min(CoachMaxWidth, f.ContentWidth); + float y = Mathf.Clamp(f.Height * (f.Portrait ? 0.59f : 0.64f), f.ContentTop, f.ContentBottom - CoachHeight); + return new Rect(f.ContentLeft + (f.ContentWidth - mw) * 0.5f, y, mw, CoachHeight); + } + private void DrawCoach() { - // 상단 중앙 코치 배너 — 펄스 강조. 가이드 중엔 숨길 수 없음(강제). - float availW = UIScale.VirtualScreenWidth - UIScale.VirtualSafeLeft - UIScale.VirtualSafeRight; - float w = Mathf.Min(720f, availW - 24f); - float h = 100f; - float x = UIScale.VirtualSafeLeft + (availW - w) * 0.5f; - float y = UISafeLayout.ContentTop + 150f; // 상단 리전 배너(ContentTop)·알림 아래 + // 코치 배너 — 펄스 강조. 가이드 중엔 숨길 수 없음(강제). 자리는 CoachRect(순수 계산). + Rect coach = CoachRect(HudFrame.Current); + float w = coach.width; + float h = coach.height; + float x = coach.x; + float y = coach.y; float pulse = 0.5f + 0.5f * Mathf.Sin(Time.time * 3f); GUI.color = new Color(0.1f, 0.08f, 0.02f, 0.9f); diff --git a/Assets/Scripts/UI/HudStage.cs b/Assets/Scripts/UI/HudStage.cs new file mode 100644 index 0000000..c77fd37 --- /dev/null +++ b/Assets/Scripts/UI/HudStage.cs @@ -0,0 +1,308 @@ +using InsectGame.Capture; +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 화면 가운데 에 잠깐 서는 카드·알림. 값 순서가 우선순위다(작을수록 먼저). + /// 앞의 셋은 고정 — 다른 담당 파일이 시간을 쥐고 있어 기다리게 할 수 없다. 무대 위 정해진 칸에 차례로 쌓인다. + /// 나머지는 차례 — 한 번에 하나만 서고, 고정이 서 있거나 앞 차례가 서 있으면 기다린다(시간도 멈춘다). + /// + public enum HudStageItem + { + /// 포획 미니게임의 결과(PERFECT 등, CaptureMinigameController — 1.5초). + MinigameResult = 0, + /// 서브에리어·섬에 들어선 알림(PlayerStatusHUD). + PlaceAlert = 1, + /// 동굴 출입 토스트(SubAreaWorldBuilder — 3초). + PlaceToast = 2, + /// 포획 결과 카드(성공·실패, CapturePopupUI). + CaptureResult = 3, + /// 퀘스트 완료 알림(TutorialQuestUI). + QuestDone = 4, + /// 다음 퀘스트 알림(TutorialQuestUI). + QuestNext = 5, + /// 필드 소식 카드(FieldMomentsUI — 레벨 업·이로치 등). + Moment = 6, + /// 섬 수확·좋아요 결과(IslandHudUI). + IslandToast = 7, + /// 곤충잡이 대결 결과 글자(WorldInteractionController). + DuelResult = 8, + /// 계정 처리 결과(AccountSettingsUI). + AccountMessage = 9, + /// 필드 초대(WorldFieldMultiplayerUI). + NetInvite = 10, + /// 필드 멀티 안내(WorldFieldMultiplayerUI). + NetToast = 11, + /// 잠긴 리전에 부딪혔다는 안내(PlayerHintOverlay). + RegionLock = 12, + } + + /// + /// 화면 가운데의 무대 — 잠깐 뜨는 카드·알림이 서는 한 자리와, 누가 설지 정하는 중재. + /// + /// 왜 있나. 포획 결과 카드·퀘스트 완료·다음 퀘스트·소식 카드·동굴 출입 알림·섬 토스트·대결 결과·계정 알림이 저마다 + /// 화면 위 가운데(ContentTop+0~+262)나 가운데 줄에 자리를 잡아, 한 번의 포획(카드 + 퀘스트 완료 + 레벨 업)이나 동굴 진입 + /// (알림 + 토스트 + 퀘스트 완료)마다 서로와 리전 배너·단축 바·미니맵을 덮었다(2026-10-03 전수 검사에서 204쌍). 자리를 하나로 모으고 + /// 차례를 세운다. + /// + /// 자리()는 늘 떠 있는 HUD — 상태 패널·미니맵·퀘스트 칩·단축 바·시각 칩과 알림·포획 아이템 패널·리전 배너· + /// 내기 점수판·잡기 버튼과 글자·상호작용 버튼·동굴 입구 버튼·필드 멀티 상태와 대화 기록 — 를 모두 피한 사각형이다. 섬 HUD 판(과 섬 정보 줄· + /// 섬 시각 알림)은 섬 HUD가 서 있을 때만 피한다() — 필드에서도 피하면 세로 화면의 카드가 섬 열 + /// 왼쪽, 곧 화면 왼쪽 절반으로 밀린다(2026-10-03 QA 실측, 720×1280에서 x 18~378). + /// 그 안에 서는 가운데 것들(대화 버튼·코치 배너·섬 안내 배너·근처 탐험가·세로 화면의 대화 기록)은 서 있는 카드와 겹치면 비켜선다 + /// (). + /// + /// 중재는 프레임 단위다 — 서고 싶은 쪽이 매 프레임 를 부르고, 이번 프레임과 직전 프레임에 원한 것 중 + /// 우선순위로 정한다(OnGUI 순서와 무관하게 같은 답이 나온다). 판정 자체는 (순수)다. + /// + public static class HudStage + { + /// 고정 항목 수(의 앞 셋). + public const int FixedCount = 3; + /// 무대의 최대 폭 — 넓은 화면에서 카드가 화면 끝까지 퍼지지 않게. + public const float MaxWidth = 1100f; + + // 고정 칸 — 늘 같은 자리(서 있는 것과 무관하게 칸을 비워 둔다. 동시에 서도 겹치지 않는다). + /// 미니게임 결과 칸 — 글자(72)와 그 둘레로 퍼지는 빛(최대 448×224)을 담는다. + public const float MinigameSlotHeight = 232f; + public const float MinigameSlotWidth = 448f; + public const float PlaceAlertTop = MinigameSlotHeight + UITheme.Space.S; + public const float PlaceAlertHeight = 76f; + public const float PlaceToastTop = PlaceAlertTop + PlaceAlertHeight + UITheme.Space.XS; + + private static int frame = -1; + private static int current; + private static int previous; + private const int ItemCount = (int)HudStageItem.RegionLock + 1; + private static readonly Rect[] lastRect = new Rect[ItemCount]; + private static readonly int[] rectFrame = new int[ItemCount]; + + private static void Roll() + { + int f = Time.frameCount; + if (f == frame) return; + previous = f == frame + 1 ? current : 0; + current = 0; + frame = f; + } + + public static bool IsFixed(HudStageItem item) => (int)item < FixedCount; + + /// + /// 이번 프레임에 무대에 서고 싶다 — 서 있는 동안 매 프레임(Update·OnGUI 어디서든) 부른다. 지금 서도 되면 true. + /// 고정 항목은 늘 true다. 차례 항목이 false를 받으면 그리지 말고 시간도 멈출 것(기다리는 중이다). + /// + public static bool Request(HudStageItem item) + { + Roll(); + current |= 1 << (int)item; + return Granted(item, current | previous); + } + + /// + /// 와 같고, 지금 그리는 자리()를 함께 알린다 — + /// 가운데 것들이 로 실제로 겹칠 때만 비켜서게. 그리는 자리(OnGUI)에서 부른다. + /// + public static bool Request(HudStageItem item, Rect footprint) + { + bool granted = Request(item); + int i = (int)item; + lastRect[i] = footprint; + rectFrame[i] = Time.frameCount + 1; // 0은 '한 번도 못 받았다' + return granted; + } + + /// 무대에 무언가 서 있거나 서려고 한다. + public static bool Occupied + { + get + { + Roll(); + return (current | previous) != 0; + } + } + + /// + /// 지금 무대에 서 있는(차례를 받은) 카드가 와 겹치는가 — 무대 안의 가운데 것들(대화 버튼·코치 배너· + /// 섬 안내 배너·근처 탐험가·세로 화면의 대화 기록)이 비켜설지. 기다리는 카드는 그려지지 않으니 세지 않는다. + /// 자리를 아직 모르는 카드(이번·직전 프레임에 가 없었다)는 겹친다고 본다. + /// + public static bool OccupiedOver(Rect area) + { + Roll(); + int mask = current | previous; + if (mask == 0) return false; + int stampNow = Time.frameCount + 1; + for (int i = 0; i < ItemCount; i++) + { + if ((mask & (1 << i)) == 0 || !Granted((HudStageItem)i, mask)) continue; + int age = stampNow - rectFrame[i]; + bool known = rectFrame[i] != 0 && age >= 0 && age <= 1; + if (!known || lastRect[i].Overlaps(area)) return true; + } + return false; + } + + /// + /// 순수 판정 — (서고 싶은 항목들의 비트) 가운데 이 설 차례인가. + /// 고정은 늘 선다. 차례 항목은 고정이 하나도 없고 자기보다 앞선 차례가 없을 때만 선다. + /// + public static bool Granted(HudStageItem item, int activeMask) + { + if (IsFixed(item)) return true; + int fixedMask = (1 << FixedCount) - 1; + if ((activeMask & fixedMask) != 0) return false; + int ahead = (1 << (int)item) - 1; + return (activeMask & ahead) == 0; + } + + // ── 자리 ── + + /// + /// 무대 사각형(가상 좌표) — 그 화면 기준. 섬 HUD가 서 있는지는 가 든다( — + /// 그리기가 넘기는 는 에서 읽고, 테스트는 로 세운다). + /// + public static Rect Area(HudFrame f) => Area(f, f.IslandHud); + + /// + /// 무대 사각형(가상 좌표) — 순수 계산. 늘 떠 있는 HUD를 모두 피한다 — 화면 모양마다 막는 것이 달라 셋으로 나눈다. + /// 는 섬 HUD 판()이 서 있는가 — 그때만 섬 판·섬 정보 줄·섬 시각 알림을 피한다. + /// 필드에서 그것까지 피하면 세로 화면의 카드가 섬 열 왼쪽(화면 왼쪽 절반)으로 밀린다. + /// 데스크톱: 왼쪽 상태 패널과 오른쪽 시각 알림 사이, 내기 점수판 아래 ~ 동굴 입구 버튼(섬: 섬 정보 줄) 위. + /// 가로 모바일: 미니맵 옆 퀘스트 칩 오른쪽 ~ 습격 경고·시각 알림 왼쪽, 내기 점수판과 단축 바 왼쪽 띠(필드: 필드 멀티 상태·대화 기록, + /// 섬: 섬 HUD 판) 아래 ~ 동굴 입구 버튼 위. + /// 세로 모바일: 퀘스트 목표 행(「왜」 줄이 붙은 두 줄 높이)과 오른쪽 열(단축 바 → 시각 칩·알림 → 필드 멀티 상태) 아래 ~ 동굴 입구 버튼·잡기 글자·상호작용 버튼 위. + /// 필드는 왼쪽 끝 ~ 오른쪽 끝(화면 가운데에 선다), 섬은 왼쪽 끝 ~ 섬 HUD 열(과 그 아래 섬 시각 알림) 왼쪽. + /// + public static Rect Area(HudFrame f, bool islandHud) + { + float s = UITheme.Space.S; + float x0, x1, y0, y1; + Rect gate = SubAreaWorldBuilder.GateRect(f); + Rect fieldNotice = WorldClockRules.NoticeBelow(f, WorldClockRules.FieldChip(f)); + if (!f.Mobile) + { + x0 = PlayerStatusHUD.PanelRect(f).xMax + s; + x1 = fieldNotice.x - s; + y0 = FieldMomentsUI.RaceChipRect(f).yMax + s; + y1 = gate.y; + if (islandHud) y1 = Mathf.Min(y1, IslandHudLayout.DesktopInfo(IslandHudLayout.OwnPanel(f)).y); + y1 -= s; + } + else if (!f.Portrait) + { + Rect quest = QuestChipLayout.ChipRect(f, QuestChipLayout.ExpandedHeight, QuestChipLayout.RowHeightWithWhy); + x0 = quest.xMax + s; + x1 = Mathf.Min(CatchButtonLayout.WarnRect(f).x, fieldNotice.x) - s; + // 단축 바 왼쪽 띠 — 섬에서는 섬 HUD 두 칸 판, 필드에서는 같은 자리의 필드 멀티 상태 판과 그 아래 대화 기록(가로는 늘 선다). + float band = islandHud + ? IslandHudLayout.OwnPanel(f).yMax + : Mathf.Max(WorldFieldMultiplayerUI.StatusRect(f).yMax, WorldFieldMultiplayerUI.MessagesRect(f).yMax); + y0 = Mathf.Max(FieldMomentsUI.RaceChipRect(f).yMax, band) + s; + y1 = gate.y - s; + } + else + { + // 목표 행은 「왜」 둘째 줄이 붙은 큰 쪽(RowHeightWithWhy)을 피한다 — 칩을 펼친 쪽으로 재는 것과 같은 이유다. + Rect quest = QuestChipLayout.ChipRect(f, QuestChipLayout.ExpandedHeight, QuestChipLayout.RowHeightWithWhy); + Rect row = QuestChipLayout.Row(quest, quest.width, QuestChipLayout.RowHeightWithWhy); + x0 = f.ContentLeft; + if (islandHud) + { + Rect island = IslandHudLayout.OwnPanel(f); + Rect islandNotice = WorldClockRules.NoticeBelow(f, WorldClockRules.IslandMobileChip( + QuickAccessBarUI.ShortcutBarRectFor(f), island, true)); + x1 = Mathf.Min(islandNotice.x, island.x) - s; + } + else + { + // 필드의 오른쪽 열(단축 바 → 시각 칩·알림 → 필드 멀티 상태)은 아래 y0보다 위에서 끝난다 — 가로로 피할 것이 없다. + x1 = f.ContentRight; + } + y0 = Mathf.Max(Mathf.Max(row.yMax, fieldNotice.yMax), WorldFieldMultiplayerUI.StatusRect(f).yMax) + s; + y1 = Mathf.Min(gate.y, Mathf.Min(CatchButtonLayout.WarnRect(f).y, + WorldInteractionController.InteractButtonRect(f).y)) - s; + } + float w = Mathf.Max(1f, Mathf.Min(MaxWidth, x1 - x0)); + float cx = (x0 + x1) * 0.5f; + return new Rect(cx - w * 0.5f, y0, w, Mathf.Max(1f, y1 - y0)); + } + + /// + /// 무대 위 의 자리 — 가운데 정렬, 폭·높이는 무대 안으로 줄인다. 차례 항목은 무대 맨 위에, + /// 고정 항목은 정해진 칸(미니게임 결과 → 리전 진입 알림 → 동굴 출입 토스트)에 선다. 섬 HUD를 피할지는 가 든다. + /// + public static Rect Place(HudFrame f, HudStageItem item, float width, float height) + { + return Place(Area(f), item, width, height); + } + + /// 의 순수 몸통 — 무대 사각형을 받는다. + public static Rect Place(Rect area, HudStageItem item, float width, float height) + { + float top = 0f; + switch (item) + { + case HudStageItem.PlaceAlert: top = PlaceAlertTop; break; + case HudStageItem.PlaceToast: top = PlaceToastTop; break; + } + float w = Mathf.Clamp(width, 1f, area.width); + float h = Mathf.Clamp(height, 1f, Mathf.Max(1f, area.height - top)); + return new Rect(area.x + (area.width - w) * 0.5f, area.y + top, w, h); + } + + /// 정사각형에 가까운 큰 카드(포획 성공)를 무대에 맞춰 줄일 배율 — 1 이하. + public static float FitScale(Rect area, float width, float height) + { + return Mathf.Min(1f, Mathf.Min(area.width / Mathf.Max(1f, width), area.height / Mathf.Max(1f, height))); + } + } + + /// 지금 화면에 서 있는가를 다른 HUD가 보고 비켜서는 것들 — 가운데 안내 배너가 비켜설 버튼들과, 가운데 무대가 피할 섬 HUD. + public enum HudPresenceItem + { + /// 섬 안내 배너(IslandGuideUI) — 코치 배너가 비켜선다. + IslandGuide = 0, + /// 가운데 대화 버튼(WorldInteractionController) — 안내 배너가 겹치면 비켜서고, 근처 탐험가 패널은 같은 자리라 비켜선다. + Talk = 1, + /// 근처 탐험가 패널(WorldFieldMultiplayerUI, 대화 버튼과 같은 자리) — 안내 배너가 겹치면 비켜선다. + Nearby = 2, + /// 동굴 입구·나가기 버튼(SubAreaWorldBuilder) — 안내 배너가 겹치면 비켜선다. + Gate = 3, + /// + /// 섬 HUD 판(IslandHudUI — 내 섬·남의 섬. 꿈 섬에서는 숨는다) — 가운데 무대()가 이때만 섬 HUD를 피한다. + /// 가 읽어 에 담는다. + /// + IslandHud = 4, + } + + /// + /// 지금 화면에 서 있는가 — 서 있는 쪽이 매 프레임(Update든 OnGUI든) 를 부르고, 비켜설 쪽이 으로 본다. + /// 이번 프레임이나 직전 프레임에 불렀으면 서 있는 것으로 친다(OnGUI 순서와 무관하게 같은 답). + /// + public static class HudPresence + { + private const int ItemCount = (int)HudPresenceItem.IslandHud + 1; + private static readonly int[] lastFrame = NeverMarked(); + + private static int[] NeverMarked() + { + var frames = new int[ItemCount]; + for (int i = 0; i < frames.Length; i++) frames[i] = -10; + return frames; + } + + public static void Mark(HudPresenceItem item) + { + lastFrame[(int)item] = Time.frameCount; + } + + public static bool IsShowing(HudPresenceItem item) + { + int d = Time.frameCount - lastFrame[(int)item]; + return d >= 0 && d <= 1; + } + } +} diff --git a/Assets/Scripts/UI/HudStage.cs.meta b/Assets/Scripts/UI/HudStage.cs.meta new file mode 100644 index 0000000..702df1f --- /dev/null +++ b/Assets/Scripts/UI/HudStage.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3328b60bb26c4ca4eaf1509e9c35e803 \ No newline at end of file diff --git a/Assets/Scripts/UI/InsectDetailVisualCapture.cs b/Assets/Scripts/UI/InsectDetailVisualCapture.cs new file mode 100644 index 0000000..1839ef8 --- /dev/null +++ b/Assets/Scripts/UI/InsectDetailVisualCapture.cs @@ -0,0 +1,191 @@ +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Dex; +using UnityEngine; + +namespace InsectGame.UI +{ + /// Standalone IMGUI 검수. 메모리 상태만 주입하며 게임 저장 서비스를 실행하지 않는다. + public static class InsectDetailVisualCapture + { + private const BindingFlags PrivateFields = BindingFlags.Instance | BindingFlags.NonPublic; + + private static void Set(object target, string fieldName, object value) + { + FieldInfo field = target.GetType().GetField(fieldName, PrivateFields); + if (field == null) throw new System.MissingFieldException(target.GetType().Name, fieldName); + field.SetValue(target, value); + } + + private static T AddInactive(GameObject holder) where T : Component => holder.AddComponent(); + + public static IEnumerator Run(string output, Camera camera) + { + // 플레이어 데이터와 도감 기록은 전부 메모리에 만든다. 비활성 호스트에서 + // AddComponent 하므로 Awake의 디스크 로드도 호출되지 않는다. + var dataHolder = new GameObject("InsectDetailQAData"); + dataHolder.SetActive(false); + var database = ScriptableObject.CreateInstance(); + InsectData beetle = Species("rhinoceros_beetle", "장수풍뎅이", InsectRarity.Rare, + "튼튼한 뿔을 가진 숲의 강자. 큰 나무 주변에서 발견됩니다.", "나무 그늘과 수액이 있는 숲"); + InsectData stag = Species("stag_beetle", "사슴벌레", InsectRarity.Rare, + "넓은 턱으로 상대를 밀어냅니다. 밤이 되면 활발히 움직입니다.", "오래된 참나무 숲"); + InsectData butterfly = Species("butterfly", "나비", InsectRarity.Uncommon, + "가벼운 날개로 꽃밭을 돌아다니며 꽃가루를 옮깁니다.", "햇빛이 드는 꽃밭"); + database.insects.Add(beetle); + database.insects.Add(stag); + database.insects.Add(butterfly); + + PlayerInsectData first = Owned("qa-rhino-first", beetle.insectId, 16, 11, 13, 9, false); + PlayerInsectData second = Owned("qa-rhino-second", beetle.insectId, 22, 15, 14, 13, true); + PlayerInsectData third = Owned("qa-stag", stag.insectId, 18, 12, 10, 15, false); + var collection = AddInactive(dataHolder); + Set(collection, "database", database); + Set(collection, "saveData", new PlayerInsectCollectionSave + { + insects = new List { first, second, third } + }); + var ownedLookup = (Dictionary)typeof(PlayerInsectCollection) + .GetField("lookup", PrivateFields).GetValue(collection); + ownedLookup.Add(first.instanceId, first); + ownedLookup.Add(second.instanceId, second); + ownedLookup.Add(third.instanceId, third); + + var dexController = AddInactive(dataHolder); + var dexSave = new DexSaveData(); + var dexLookup = (Dictionary)typeof(DexController) + .GetField("lookup", PrivateFields).GetValue(dexController); + foreach (InsectData species in database.insects) + { + var record = new DexRecord(species.insectId) { discoveredCount = 2, capturedCount = 1 }; + dexSave.records.Add(record); + dexLookup.Add(species.insectId, record); + } + Set(dexController, "saveData", dexSave); + + var visual = new GameObject("InsectDetailQAPreview").AddComponent(); + InsectVisual.Renderer = visual; + var collectionUI = new GameObject("InsectDetailQACollection").AddComponent(); + collectionUI.AutoWire(collection, null, null); + var dexUI = new GameObject("InsectDetailQADex").AddComponent(); + dexUI.AutoWire(database, dexController); + dexUI.AutoWire(collection, null); + dexUI.AutoWire(visual); + dexUI.AutoWire(collectionUI); + + // 추가된 하단 7개 버튼도 실 UI 컴포넌트로 캡처한다. 목적지 화면은 + // 검수용 비활성 호스트에 두므로 클릭 시 계정/저장 기능이 시작되지 않는다. + var quickTargets = new GameObject("InsectDetailQAQuickTargets"); + quickTargets.SetActive(false); + var quick = new GameObject("InsectDetailQAQuickBar").AddComponent(); + Set(quick, "dexScreen", dexUI); + Set(quick, "collectionUI", collectionUI); + Set(quick, "battleTeamUI", AddInactive(quickTargets)); + Set(quick, "inventoryScreen", AddInactive(quickTargets)); + Set(quick, "questUI", AddInactive(quickTargets)); + Set(quick, "regionMapUI", AddInactive(quickTargets)); + + camera.transform.position = new Vector3(0f, 7f, -12f); + camera.transform.LookAt(new Vector3(0f, .6f, 0f)); + var ground = GameObject.CreatePrimitive(PrimitiveType.Plane); + ground.transform.localScale = Vector3.one * 8f; + ground.GetComponent().material.color = new Color(.16f, .27f, .23f); + var light = new GameObject("InsectDetailQAKey").AddComponent(); + light.type = LightType.Directional; + light.transform.rotation = Quaternion.Euler(50f, -25f, 0f); + + yield return Capture(output, "01-quick-buttons"); + dexUI.Toggle(); + yield return Capture(output, "02-dex-grid"); + Set(dexUI, "detailModalOpen", true); + Set(dexUI, "selectedIndex", 0); + yield return Capture(output, "03-dex-detail"); + Set(dexUI, "previewAngle", 210f); + Set(dexUI, "previewShiny", true); + yield return Capture(output, "04-dex-detail-variant-angle"); + Set(dexUI, "detailModalOpen", false); + Set(dexUI, "currentTab", 1); + yield return Capture(output, "05-dex-owned-cards"); + dexUI.CloseModal(); + if (!collectionUI.OpenDetailForInstance(second.instanceId)) + { + Debug.LogError("Collection detail fixture could not select second owned insect."); + Application.Quit(3); + yield break; + } + yield return Capture(output, "06-collection-instance-detail"); + collectionUI.CloseModal(); + collectionUI.Toggle(); + yield return Capture(output, "07-collection-list"); + collectionUI.CloseModal(); + + File.WriteAllText(Path.Combine(output, "README.txt"), + "Actual standalone ScreenCapture including DexScreenUI, CollectionUI and QuickAccessBarUI IMGUI.\n" + + "Synthetic in-memory species, three owned insects (two of the same species), and captured dex records.\n" + + "The collection detail selects qa-rhino-second by unique instanceId.\n" + + "Dex tabs, detail open, angle and shiny states were set programmatically for screenshots.\n" + + "These images do not prove pointer clicks or scroll gesture routing; click interaction needs live input.\n" + + "No PlaySceneBootstrap, AuthManager, PlayerInsectCollection.Awake, DexController.Awake, or save calls.\n" + + "Screen " + Screen.width + "x" + Screen.height + ". Background is a staging fixture.\n"); + + if (System.Array.IndexOf(System.Environment.GetCommandLineArgs(), "-insectUiInteractive") >= 0) + { + File.AppendAllText(Path.Combine(output, "README.txt"), + "Interactive QA enabled: the quick bar remains visible for 180 seconds after automatic capture. " + + "Use it to click Dex and Collection controls in the standalone window; the window then closes automatically.\n"); + float interactiveUntil = Time.realtimeSinceStartup + 180f; + while (Time.realtimeSinceStartup < interactiveUntil) + yield return null; + } + Application.Quit(0); + } + + private static InsectData Species(string id, string name, InsectRarity rarity, string description, string habitat) + { + var data = ScriptableObject.CreateInstance(); + data.insectId = id; + data.displayName = name; + data.rarity = rarity; + data.description = description; + data.habitatHint = habitat; + data.baseHp = 120; + data.baseAtk = 35; + data.baseDef = 28; + data.baseSizeMm = 70f; + data.baseWeightG = 20f; + return data; + } + + private static PlayerInsectData Owned(string instance, string species, int level, + int hp, int attack, int defense, bool shiny) + { + return new PlayerInsectData + { + instanceId = instance, + insectId = species, + level = level, + ivHp = hp, + ivAtk = attack, + ivDef = defense, + isShiny = shiny, + currentHp = 120, + sizeRoll = 50 + }; + } + + private static IEnumerator Capture(string output, string name) + { + yield return new WaitForSecondsRealtime(.8f); + yield return new WaitForEndOfFrame(); + Texture2D image = ScreenCapture.CaptureScreenshotAsTexture(); + File.WriteAllBytes(Path.Combine(output, name + ".png"), image.EncodeToPNG()); + Object.Destroy(image); + } + } +} +#endif diff --git a/Assets/Scripts/UI/InsectDetailVisualCapture.cs.meta b/Assets/Scripts/UI/InsectDetailVisualCapture.cs.meta new file mode 100644 index 0000000..f7292b8 --- /dev/null +++ b/Assets/Scripts/UI/InsectDetailVisualCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c1aa25f070a49d048b862227e81661e6 \ No newline at end of file diff --git a/Assets/Scripts/UI/InsectVisual.cs b/Assets/Scripts/UI/InsectVisual.cs index b953513..0b5fc0e 100644 --- a/Assets/Scripts/UI/InsectVisual.cs +++ b/Assets/Scripts/UI/InsectVisual.cs @@ -9,8 +9,10 @@ namespace InsectGame.UI /// /// 왜 파사드인가 /// ------------- - /// 곤충을 그리는 화면이 9곳(도감 그리드·상세, 보유 곤충, 팀 편성, 병원, 훈련, 포획 선택, - /// 지역맵 도감)인데, 각자 "3D 있으면 3D"를 판단하게 두면 정책이 아홉 갈래로 흩어진다. + /// 곤충을 그리는 화면이 10곳(도감 그리드·상세, 보유 곤충, 팀 편성, 병원, 훈련, 포획 선택, + /// 포획 성공 팝업, 지역맵 도감)인데, 각자 "3D 있으면 3D"를 판단하게 두면 정책이 열 갈래로 흩어진다. + /// (포획 성공 팝업은 2026-09-30까지 이 파사드를 안 거치고 2D 초상을 직접 그렸다 — 잡기 직전엔 3D, + /// 잡은 순간엔 평면으로 보였다.) /// 여기 한 곳만 고치면 전부 따라온다. /// /// 예전엔 의 호출부가 도감 상세 **한 곳뿐**이었다. diff --git a/Assets/Scripts/UI/InventoryUI.cs b/Assets/Scripts/UI/InventoryUI.cs index a70704f..dd068ec 100644 --- a/Assets/Scripts/UI/InventoryUI.cs +++ b/Assets/Scripts/UI/InventoryUI.cs @@ -332,12 +332,12 @@ private void DrawActiveBanner(Rect rect) float remaining = effectManager.RemainingSeconds; float total = Mathf.Max(1f, active.durationSeconds); - UISurface.Card(rect, new Color(0.12f, 0.18f, 0.16f, 0.95f), UITheme.Instance.accentMint); + UISurface.Card(rect, UITheme.Instance.surfaceRaised, UITheme.Instance.accentMint); // 남은 시간 게이지 — 6px 얇은 바라 Flat(각짐)이고, 긴 축을 카드 반경만큼 물린다. float barY = rect.yMax - 9f; float barX = rect.x + UITheme.Radius.Card; float barW = rect.width - UITheme.Radius.Card * 2f; - UISurface.Flat(new Rect(barX, barY, barW, 6f), new Color(0.16f, 0.2f, 0.24f)); + UISurface.Flat(new Rect(barX, barY, barW, 6f), UITheme.Instance.surfaceBase); UISurface.Flat(new Rect(barX, barY, barW * Mathf.Clamp01(remaining / total), 6f), UITheme.Instance.accentMint); GUI.color = Color.white; @@ -354,7 +354,7 @@ private void DrawItemRow(Rect rect, Entry entry) UITheme t = UITheme.Instance; Color rarityCol = entry.data != null ? t.GetItemRarityColor(entry.rarity) : t.textMuted; - UISurface.Card(rect, new Color(0.10f, 0.12f, 0.18f, 0.9f), t.surfaceBorder); + UISurface.Card(rect, t.surfaceCard, t.surfaceBorder); // 등급 레일 — 5px라 각진 채로, 세로를 카드 반경만큼 물려 둥근 모서리 안쪽에 둔다. UISurface.Flat( new Rect(rect.x + 3f, rect.y + 3f + UITheme.Radius.Card, 5f, diff --git a/Assets/Scripts/UI/IslandEditUI.cs b/Assets/Scripts/UI/IslandEditUI.cs new file mode 100644 index 0000000..03f6390 --- /dev/null +++ b/Assets/Scripts/UI/IslandEditUI.cs @@ -0,0 +1,478 @@ +using System.Collections.Generic; +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 섬 꾸미기 — 보관함에서 골라 칸에 놓고, 놓인 것을 눌러 옮기기·돌리기·넣기. + /// + /// 모달이다. 지면을 누르는 탭이 「놓을 자리 고르기」인데, 같은 탭을 PlayerMovement가 + /// 클릭-이동으로도 읽는다(그쪽은 Update에서 마우스를 따로 폴링한다). 모달로 등록하면 이동 입력이 통째로 + /// 막히므로 두 뜻이 겹치지 않는다. 대신 캐릭터가 서 있으니 카메라를 캐릭터에서 떼어 화면을 끌어 옮긴다 + /// (). + /// + public class IslandEditUI : MonoBehaviour, IModalUI + { + /// 이만큼(가상 px) 넘게 끌면 탭이 아니라 화면 끌기다. + private const float DragThreshold = 14f; + private const float KeyPanSpeed = 9f; + private const float FeedbackSeconds = 2.2f; + + [SerializeField] private IslandManager island; + [SerializeField] private IslandWorldBuilder world; + [SerializeField] private IslandShareClient share; + + private bool isOpen; + // 들고 있는 물건 — 보관함에서 꺼낸 새 물건이면 carryIndex가 -1, 놓인 물건을 집었으면 그 인덱스. + private string carryId; + private int carryIndex = -1; + private int carryX; + private int carryZ; + private int carryRot; + private bool carryValid; + private bool ghostDirty; + + private int trayPage; + private readonly List trayItems = new List(); + private bool trayDirty = true; + private readonly List uiRects = new List(); + + private bool pressing; + private bool dragging; + private Vector2 pressVirtual; + private Vector2 lastScreen; + + private string feedback; + private float feedbackUntil; + // 보관함 칸의 "×3" 문구 — 수량이 바뀔 때만 다시 만든다. + private readonly Dictionary countLabels = new Dictionary(); + private readonly Dictionary countShown = new Dictionary(); + + public bool IsOpen => isOpen; + + public void AutoWire(IslandManager islandManager, IslandWorldBuilder worldBuilder, IslandShareClient shareClient) + { + if (island == null) island = islandManager; + if (world == null) world = worldBuilder; + if (share == null) share = shareClient; + } + + public void Open() + { + if (isOpen || island == null || world == null || world.Mode != IslandMode.Own) return; + isOpen = true; + carryId = null; + carryIndex = -1; + trayPage = 0; + trayDirty = true; + pressing = dragging = false; + ModalUIRegistry.Register(this); + world.BeginEditCamera(); + world.SetGridVisible(true); + island.ReportEditOpened(); + } + + public void CloseModal() + { + if (!isOpen) return; + isOpen = false; + DropCarry(); + ModalUIRegistry.Unregister(this); + if (world != null) + { + world.SetGridVisible(false); + world.EndEditCamera(); + } + // 꾸미기를 마친 모습을 올린다 — 방문자는 마지막으로 올라간 모습을 본다. + if (share != null) share.Publish(); + } + + /// 검수 캡처 전용 — 보관함의 물건을 지정한 칸·회전으로 든 상태를 만든다. + internal void CarryForCapture(string id, int x, int z, int rot) + { + DropCarry(); + carryId = id; + carryIndex = -1; + carryX = x; + carryZ = z; + carryRot = rot; + ghostDirty = true; + } + + /// 검수 캡처 전용 — 놓인 물건을 집은 상태를 만든다. + internal void PickPlacedForCapture(int index) => PickPlaced(index); + + // UI 루트가 통째로 꺼질 때(오프닝 다시보기)도 카메라와 격자를 되돌려야 한다. + private void OnDisable() => CloseModal(); + + private void Update() + { + if (!isOpen) return; + // 섬을 떠났으면(다른 경로로 강제 종료 포함) 닫는다. + if (world == null || world.Mode != IslandMode.Own) { CloseModal(); return; } + if (!ReferenceEquals(ModalUIRegistry.TopModal, this)) return; + + float h = (Input.GetKey(KeyCode.D) || Input.GetKey(KeyCode.RightArrow) ? 1f : 0f) + - (Input.GetKey(KeyCode.A) || Input.GetKey(KeyCode.LeftArrow) ? 1f : 0f); + float v = (Input.GetKey(KeyCode.W) || Input.GetKey(KeyCode.UpArrow) ? 1f : 0f) + - (Input.GetKey(KeyCode.S) || Input.GetKey(KeyCode.DownArrow) ? 1f : 0f); + if (h != 0f || v != 0f) + world.PanEditCamera(new Vector3(h, 0f, v) * (KeyPanSpeed * Time.unscaledDeltaTime)); + + if (carryId != null) + { + if (Input.GetKeyDown(KeyCode.R)) Rotate(); + if (Input.GetKeyDown(KeyCode.Return) || Input.GetKeyDown(KeyCode.KeypadEnter)) Confirm(); + } + } + + private void OnGUI() + { + if (!isOpen || world == null || island == null) return; + // 위에 다른 모달(상점 등)이 떠 있으면 그리기만 멈춘다 — 입력을 같이 받으면 뒤 화면이 눌린다. + if (!ReferenceEquals(ModalUIRegistry.TopModal, this)) return; + + UIScale.Begin(); + uiRects.Clear(); + DrawTopBar(); + DrawTray(); + if (carryId != null) DrawCarryActions(); + DrawFeedback(); + UIScale.End(); + + // 버튼이 먼저 이벤트를 가져간 뒤 남은 것만 섬 입력으로 읽는다. + HandleWorldInput(); + RefreshGhost(); + } + + private void DrawTopBar() + { + UITheme t = UITheme.Instance; + bool mobile = UIScale.IsMobileLayout; + Rect bar = UISafeLayout.TopPanel(Mathf.Min(1100f, UISafeLayout.ContentWidth), 76f); + uiRects.Add(bar); + UISurface.HudCard(bar); + float doneW = 180f; + IslandUiKit.Label(new Rect(bar.x + 20f, bar.y + 4f, 160f, 68f), "꾸미기", IslandUiKit.Title, t.textPrimary); + string hint = carryId != null + ? "놓을 칸을 누르세요. 화면을 끌면 섬을 둘러봅니다." + : mobile ? "보관함에서 고르거나, 놓인 물건을 누르세요." + : "보관함에서 고르거나, 놓인 물건을 누르세요. [R] 돌리기 · [Enter] 놓기 · 방향키 이동"; + IslandUiKit.Label(new Rect(bar.x + 180f, bar.y + 4f, bar.width - 180f - doneW - 24f, 68f), hint, + IslandUiKit.Small, t.textSecondary); + if (UISurface.Button(new Rect(bar.xMax - doneW - 10f, bar.y + 8f, doneW, 60f), "완료", + t.accentMint, IslandUiKit.Button)) + CloseModal(); + } + + private void RebuildTray() + { + trayItems.Clear(); + IReadOnlyList all = IslandCatalog.All; + for (int i = 0; i < all.Count; i++) + if (island.GetStorageCount(all[i].id) > 0) trayItems.Add(all[i]); + trayDirty = false; + } + + private void DrawTray() + { + if (trayDirty) RebuildTray(); + UITheme t = UITheme.Instance; + bool mobile = UIScale.IsMobileLayout; + float trayH = mobile ? 190f : 150f; + Rect tray = UISafeLayout.BottomPanel(Mathf.Min(1500f, UISafeLayout.ContentWidth), trayH); + uiRects.Add(tray); + UISurface.HudCard(tray); + + if (trayItems.Count == 0) + { + IslandUiKit.Label(new Rect(tray.x + 20f, tray.y + 10f, tray.width - 40f, trayH - 20f), + "보관함이 비었습니다. 섬 [상점]에서 물건을 사 보세요.", IslandUiKit.BodyCenter, t.textSecondary); + return; + } + + const float arrowW = 64f; + float cardW = mobile ? 200f : 190f; + const float gap = 8f; + float inner = tray.width - 16f - (arrowW + gap) * 2f; + int perPage = Mathf.Max(1, Mathf.FloorToInt((inner + gap) / (cardW + gap))); + int pages = (trayItems.Count + perPage - 1) / perPage; + trayPage = Mathf.Clamp(trayPage, 0, pages - 1); + + bool enabled = GUI.enabled; + GUI.enabled = enabled && trayPage > 0; + if (UISurface.Button(new Rect(tray.x + 8f, tray.y + 8f, arrowW, trayH - 16f), "◀", t.surfaceRaised, + IslandUiKit.Button)) + trayPage--; + GUI.enabled = enabled && trayPage < pages - 1; + if (UISurface.Button(new Rect(tray.xMax - 8f - arrowW, tray.y + 8f, arrowW, trayH - 16f), "▶", + t.surfaceRaised, IslandUiKit.Button)) + trayPage++; + GUI.enabled = enabled; + + float x = tray.x + 8f + arrowW + gap; + int start = trayPage * perPage; + for (int i = start; i < Mathf.Min(start + perPage, trayItems.Count); i++) + { + IslandObjectDef def = trayItems[i]; + Rect card = new Rect(x, tray.y + 8f, cardW, trayH - 16f); + x += cardW + gap; + bool selected = carryIndex < 0 && carryId == def.id; + UISurface.Card(card, selected ? t.surfaceRaised : t.surfaceCard, + selected ? t.accentMint : t.surfaceBorder); + UISurface.Flat(new Rect(card.x + UITheme.Radius.Card, card.y + 3f, card.width - UITheme.Radius.Card * 2f, 6f), + IslandUiKit.CategoryColor(def.category)); + IslandUiKit.Label(new Rect(card.x + 10f, card.y + 14f, card.width - 20f, 44f), def.displayName, + IslandUiKit.BodyCenter, t.textPrimary); + IslandUiKit.Label(new Rect(card.x + 10f, card.y + 58f, card.width - 20f, 32f), + CountLabel(def), IslandUiKit.SmallCenter, t.textSecondary); + if (GUI.Button(card, GUIContent.none, GUIStyle.none)) PickFromTray(def); + } + } + + private string CountLabel(IslandObjectDef def) + { + int count = island.GetStorageCount(def.id); + if (!countShown.TryGetValue(def.id, out int shown) || shown != count) + { + countShown[def.id] = count; + countLabels[def.id] = def.width + "×" + def.depth + "칸 · " + count + "개"; + } + return countLabels[def.id]; + } + + private void DrawCarryActions() + { + UITheme t = UITheme.Instance; + bool moving = carryIndex >= 0; + int count = moving ? 4 : 3; + const float w = 170f; + const float h = 68f; + const float gap = 8f; + bool mobile = UIScale.IsMobileLayout; + float trayH = mobile ? 190f : 150f; + float totalW = count * w + (count - 1) * gap + 16f; + Rect tray = UISafeLayout.BottomPanel(totalW, h + 16f); + // 보관함 바로 위 — 자리는 보관함 높이에서 파생한다. + Rect bar = new Rect(tray.x, tray.y - trayH - 10f, tray.width, tray.height); + uiRects.Add(bar); + UISurface.HudCard(bar); + + float x = bar.x + 8f; + Rect Next() { var r = new Rect(x, bar.y + 8f, w, h); x += w + gap; return r; } + + if (UISurface.Button(Next(), "돌리기", t.surfaceRaised, IslandUiKit.Button)) Rotate(); + bool enabled = GUI.enabled; + GUI.enabled = enabled && carryValid; + if (UISurface.Button(Next(), "놓기", t.accentMint, IslandUiKit.Button)) Confirm(); + GUI.enabled = enabled; + if (moving && UISurface.Button(Next(), "보관함에 넣기", t.accentAmber, IslandUiKit.ButtonSmall)) StoreCarried(); + if (UISurface.Button(Next(), "취소", t.surfaceCard, IslandUiKit.Button)) DropCarry(); + } + + private void DrawFeedback() + { + if (string.IsNullOrEmpty(feedback) || Time.unscaledTime >= feedbackUntil) return; + Rect r = UISafeLayout.TopPanel(Mathf.Min(720f, UISafeLayout.ContentWidth), 56f); + r.y += 92f; + UISurface.HudCard(r); + IslandUiKit.Label(new Rect(r.x + 16f, r.y + 6f, r.width - 32f, r.height - 12f), feedback, + IslandUiKit.BodyCenter, UITheme.Instance.accentAmber); + } + + private void ShowFeedback(string message) + { + feedback = message; + feedbackUntil = Time.unscaledTime + FeedbackSeconds; + } + + // ── 섬 입력: 탭 = 칸 고르기, 끌기 = 화면 옮기기 ── + + private void HandleWorldInput() + { + Event e = Event.current; + if (e == null) return; + Vector2 virtualPos = UIScale.VirtualMousePosition; + Vector2 screenPos = Input.mousePosition; + + switch (e.type) + { + case EventType.MouseDown: + if (e.button != 0 || OverUi(virtualPos)) return; + pressing = true; + dragging = false; + pressVirtual = virtualPos; + lastScreen = screenPos; + e.Use(); + break; + + case EventType.MouseDrag: + if (!pressing) return; + if (!dragging && (virtualPos - pressVirtual).sqrMagnitude > DragThreshold * DragThreshold) + dragging = true; + if (dragging) + { + // 손가락 밑의 땅이 손가락을 따라오게 — 앞뒤 화면 좌표가 가리키는 지면 점의 차이만큼 옮긴다. + if (GroundPoint(lastScreen, out Vector3 before) && GroundPoint(screenPos, out Vector3 after)) + world.PanEditCamera(before - after); + lastScreen = screenPos; + } + e.Use(); + break; + + case EventType.MouseUp: + if (!pressing) return; + pressing = false; + if (!dragging && !OverUi(virtualPos) && world.ScreenToCell(screenPos, out int cx, out int cz)) + OnCellTapped(cx, cz); + dragging = false; + e.Use(); + break; + } + } + + private bool OverUi(Vector2 virtualPos) + { + for (int i = 0; i < uiRects.Count; i++) + if (uiRects[i].Contains(virtualPos)) return true; + // 이 화면 위에 다른 컴포넌트가 그리는 것(가이드 배너)도 피한다. OnGUI 호출 순서는 정해져 있지 않아, + // 이쪽이 먼저 돌면 배너 버튼을 누른 탭을 여기서 먹어 버린다. 배너는 자기 자리를 FieldHudInput에 등록한다. + return FieldHudInput.IsScreenPointOverHud(Input.mousePosition); + } + + private static bool GroundPoint(Vector2 screenPos, out Vector3 point) + { + point = Vector3.zero; + Camera cam = Camera.main; + if (cam == null) return false; + Ray ray = cam.ScreenPointToRay(screenPos); + var plane = new Plane(Vector3.up, IslandWorldBuilder.Origin); + if (!plane.Raycast(ray, out float enter)) return false; + point = ray.GetPoint(enter); + return true; + } + + private void OnCellTapped(int cellX, int cellZ) + { + if (carryId == null) + { + // 아무것도 안 들었다 — 누른 칸의 물건을 집는다. + int index = IslandGrid.FindAt(island.Placed, cellX, cellZ); + if (index >= 0) PickPlaced(index); + return; + } + MoveCarryTo(cellX, cellZ); + } + + private void PickFromTray(IslandObjectDef def) + { + DropCarry(); + carryId = def.id; + carryIndex = -1; + carryRot = 0; + // 화면 가운데가 가리키는 칸에서 시작한다 — 고르자마자 어디 놓일지 보이게. + if (!world.ScreenToCell(new Vector2(Screen.width * 0.5f, Screen.height * 0.5f), out int cx, out int cz)) + cx = cz = 0; + MoveCarryTo(cx, cz); + } + + private void PickPlaced(int index) + { + IslandPlacedRecord p = island.Placed[index]; + if (IslandCatalog.Get(p.id) == null) return; + DropCarry(); + carryId = p.id; + carryIndex = index; + carryX = p.x; + carryZ = p.z; + carryRot = p.rot; + world.SetObjectHidden(index); + ghostDirty = true; + } + + // 누른 칸이 물건의 가운데쯤 오도록 최소 모서리를 잡는다(큰 건물을 누른 칸의 오른쪽 위로만 뻗게 두면 어색하다). + private void MoveCarryTo(int cellX, int cellZ) + { + IslandObjectDef def = IslandCatalog.Get(carryId); + if (def == null) return; + IslandGrid.Footprint(def, carryRot, out int w, out int d); + carryX = cellX - w / 2; + carryZ = cellZ - d / 2; + ghostDirty = true; + } + + private void Rotate() + { + if (carryId == null) return; + IslandObjectDef def = IslandCatalog.Get(carryId); + if (def == null) return; + // 중심 칸을 유지한 채 돈다 — 최소 모서리를 그대로 두면 긴 물건이 돌 때마다 옆으로 밀려난다. + IslandGrid.Footprint(def, carryRot, out int w, out int d); + int centerX = carryX + w / 2; + int centerZ = carryZ + d / 2; + carryRot = (carryRot + 1) % 4; + MoveCarryTo(centerX, centerZ); + } + + private void Confirm() + { + if (carryId == null) return; + IslandPlaceResult result = carryIndex >= 0 + ? island.TryMove(carryIndex, carryX, carryZ, carryRot) + : island.TryPlace(carryId, carryX, carryZ, carryRot); + if (result != IslandPlaceResult.Ok) + { + ShowFeedback(IslandUiKit.PlaceResultText(result)); + return; + } + if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.Equip); + + string placedId = carryId; + bool wasNew = carryIndex < 0; + // 배치가 바뀌면 월드가 물건을 다시 지어 숨김 표시도 풀린다 — 들고 있던 상태만 비운다. + carryId = null; + carryIndex = -1; + world.HideGhost(); + trayDirty = true; + + // 같은 물건이 보관함에 더 있으면 이어서 놓을 수 있게 다시 든다(울타리·화분을 여러 개 놓을 때). + if (wasNew && island.GetStorageCount(placedId) > 0) + { + carryId = placedId; + ghostDirty = true; + } + } + + private void StoreCarried() + { + if (carryIndex < 0) return; + int index = carryIndex; + carryId = null; + carryIndex = -1; + world.HideGhost(); + island.Store(index); + trayDirty = true; + ShowFeedback("보관함에 넣었습니다"); + } + + private void DropCarry() + { + carryId = null; + carryIndex = -1; + if (world != null) + { + world.SetObjectHidden(-1); + world.HideGhost(); + } + } + + private void RefreshGhost() + { + if (carryId == null || !ghostDirty) return; + ghostDirty = false; + carryValid = island.CanPlace(carryId, carryX, carryZ, carryRot, carryIndex) == IslandPlaceResult.Ok; + world.ShowGhost(carryId, carryX, carryZ, carryRot, carryValid); + } + } +} diff --git a/Assets/Scripts/UI/IslandEditUI.cs.meta b/Assets/Scripts/UI/IslandEditUI.cs.meta new file mode 100644 index 0000000..3bd05fc --- /dev/null +++ b/Assets/Scripts/UI/IslandEditUI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 86c5b44d0609b8d489c3b975ea4af6b8 \ No newline at end of file diff --git a/Assets/Scripts/UI/IslandGuideUI.cs b/Assets/Scripts/UI/IslandGuideUI.cs new file mode 100644 index 0000000..db812f6 --- /dev/null +++ b/Assets/Scripts/UI/IslandGuideUI.cs @@ -0,0 +1,269 @@ +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 섬 안내 — 첫 방문 때의 단계형 코치 배너와, 언제든 다시 여는 도움말 창. + /// + /// 배너는 실제 행동으로 넘어간다(판정은 , 진행 저장은 섬 세이브). + /// 기존 GuidedTutorialController를 안 쓰는 이유는 그쪽이 Story 퀘스트의 활성 이벤트에 묶여 있어서다 — + /// 섬 퀘스트는 Side라 그 이벤트가 울리지 않는다. + /// + /// 배너는 모달이 아니다. 「물건 놓기」 단계는 꾸미기 화면(모달) 위에서 떠야 하므로, 다른 HUD와 달리 + /// "모달이 하나라도 열렸으면 숨는다"가 아니라 맨 위 모달이 꾸미기 화면일 때는 그린다. + /// + /// 배너는 ✕로 닫거나 가 지나면 사라진다. 닫는 것은 표시만이다 — 단계는 실제 행동으로만 + /// 넘어가고, 다음 단계에 가거나 섬에 다시 들어오면 그 단계 안내가 다시 뜬다. 전부 다시 보려면 도움말의 「처음 안내 다시 보기」. + /// + public class IslandGuideUI : MonoBehaviour, IModalUI + { + [SerializeField] private IslandManager island; + [SerializeField] private IslandWorldBuilder world; + [SerializeField] private IslandEditUI editUI; + + /// 단계 안내가 떠 있는 시간(초). 지나면 스스로 사라지고, 다음 단계에 가면 그 단계 안내가 다시 뜬다. + internal const float CoachSeconds = 12f; + internal const float CoachFadeSeconds = 1f; + + // 안내 배너의 표시 상태 — 저장하지 않는다(단계 진행은 섬 세이브가 들고 있다). + private bool coachArmed; // 지금 단계의 시간을 재기 시작했는가 + private IslandGuideStep coachStep = IslandGuideStep.Done; + private bool coachReplay; + private float coachShown; // 이 단계 안내가 화면에 떠 있던 누적 시간 + private bool coachDismissed; // ✕나 시간 경과로 닫았다 + + private bool helpOpen; + private Vector2 helpScroll; + private readonly UIDirectScroll directScroll = new UIDirectScroll(); + + public bool IsOpen => helpOpen; + + public void AutoWire(IslandManager islandManager, IslandWorldBuilder worldBuilder, IslandEditUI edit) + { + if (island == null) island = islandManager; + if (world == null) world = worldBuilder; + if (editUI == null) editUI = edit; + } + + public void OpenHelp() + { + if (helpOpen) return; + helpOpen = true; + helpScroll = Vector2.zero; + directScroll.Reset(); + ModalUIRegistry.Register(this); + } + + public void CloseModal() + { + helpOpen = false; + directScroll.Reset(); + ModalUIRegistry.Unregister(this); + } + + private void OnDisable() => CloseModal(); + + private void OnGUI() + { + if (island == null || world == null) return; + + if (helpOpen) + { + if (!ReferenceEquals(ModalUIRegistry.TopModal, this)) return; + UIScale.Begin(); + DrawHelp(); + UIScale.End(); + return; + } + + if (world.Mode != IslandMode.Own || !island.GuideActive) + { + // 섬을 나갔다 다시 들어오면 지금 단계 안내를 처음부터 다시 보여 준다. + coachArmed = false; + return; + } + // 꾸미기 화면 위에서는 그린다. 그 밖의 모달(상점·곤충·방문·대화) 위에서는 숨는다(시간도 멈춘다). + IModalUI top = ModalUIRegistry.IsAnyOpen() ? ModalUIRegistry.TopModal : null; + if (top != null && !ReferenceEquals(top, editUI)) return; + + IslandGuideStep step = island.GuideStep; + bool replay = island.GuideIsReplay; + if (!coachArmed || step != coachStep || replay != coachReplay) + { + coachArmed = true; + coachStep = step; + coachReplay = replay; + coachShown = 0f; + coachDismissed = false; + } + if (coachDismissed) return; + // 가운데 무대에 선 카드(퀘스트 완료·섬 토스트 등)와 겹치면 비켜선다 — 그리지 않으므로 표시 시간도 흐르지 않는다. + if (HudStage.OccupiedOver(CoachRect(HudFrame.Current, top != null))) return; + + float remaining = CoachRemaining(coachShown, replay); + bool repaint = Event.current != null && Event.current.type == EventType.Repaint; + if (remaining <= 0f) + { + if (repaint) DismissCoach(step, replay); + return; + } + // OnGUI는 한 프레임에 여러 번 온다(Layout·입력·Repaint) — 그리는 이벤트에서만 센다. + if (repaint) coachShown += Time.unscaledDeltaTime; + + UIScale.Begin(); + DrawCoach(top != null, step, replay, remaining); + UIScale.End(); + } + + /// + /// 이 단계 안내가 더 떠 있을 시간(초). 0 이하면 사라진다. 다시 보기는 [다음]으로 직접 넘기므로 시간으로 닫지 않는다. + /// + internal static float CoachRemaining(float shownSeconds, bool replay) + => replay ? CoachSeconds : CoachSeconds - shownSeconds; + + /// 마지막 동안 서서히 옅어진다. + internal static float CoachAlpha(float remaining) + => Mathf.Clamp01(remaining / CoachFadeSeconds); + + /// + /// 안내를 닫는다(✕ 또는 시간 경과). 단계는 그대로다 — 실제 행동으로 다음 단계에 가면 그 단계 안내가 다시 뜬다. + /// 마지막 인사는 닫는 것이 곧 확인이고, 다시 보기는 거기서 끝낸다. + /// + private void DismissCoach(IslandGuideStep step, bool replay) + { + coachDismissed = true; + if (replay) island.EndGuideReplay(); + else if (step == IslandGuideStep.Finish) island.AcknowledgeGuideFinish(); + } + + /// + /// 배너 자리(가상 좌표). + /// + /// 모바일 필드에서는 화면 가운데 줄, 캐릭터 발 아래. 위쪽은 왼편에 미니맵·퀘스트 칩·목표 행이, 오른편에 퀵바와 + /// 섬 버튼 줄이 내려와 있어 빈 자리가 없다 — 예전엔 퀵바 왼쪽(좌상단)에 놓아서 미니맵·퀘스트 칩과 한 자리에 겹쳤고, + /// OnGUI 그리기 순서가 정해져 있지 않아 안내가 그 밑에 깔렸다(2026-10-02 기기 보고). + /// 꾸미기 화면 위에서는 다른 HUD가 전부 숨으므로 그 화면의 상단 바 아래에 둔다(가운데는 칸을 고르는 자리다). + /// + public static Rect CoachRect(HudFrame f, bool overEdit) + { + bool mobile = f.Mobile; + if (mobile && !overEdit) + { + bool portrait = f.Portrait; + float mh = portrait ? 210f : 170f; + float w = Mathf.Min(portrait ? 900f : 760f, f.ContentWidth); + float y = Mathf.Clamp(f.Height * (portrait ? 0.59f : 0.64f), f.ContentTop, f.ContentBottom - mh); + return new Rect(f.ContentLeft + (f.ContentWidth - w) * 0.5f, y, w, mh); + } + + float h = mobile ? 200f : 148f; + Rect banner = f.TopPanel(Mathf.Min(mobile ? 900f : 860f, f.ContentWidth), h); + // 꾸미기 화면에서는 그 화면의 상단 바·안내 문구 아래로 내린다. + banner.y += overEdit ? 160f : 150f; + return banner; + } + + private void DrawCoach(bool overEdit, IslandGuideStep step, bool replay, float remaining) + { + UITheme t = UITheme.Instance; + bool needsButton = replay || step == IslandGuideStep.Finish; + + Rect banner = CoachRect(HudFrame.Current, overEdit); + float h = banner.height; + HudPresence.Mark(HudPresenceItem.IslandGuide); // 코치 배너(GuidedTutorialController)가 같은 자리를 비켜 준다 + // 배너 위 탭이 클릭-이동(필드)이나 칸 고르기(꾸미기)로 새지 않게. + FieldHudInput.RegisterBlockingRect(banner); + + Color previous = GUI.color; + GUI.color = new Color(previous.r, previous.g, previous.b, previous.a * CoachAlpha(remaining)); + + float pulse = 0.55f + 0.45f * Mathf.Sin(Time.unscaledTime * 3.5f); + UISurface.Card(banner, new Color(t.surfaceCard.r, t.surfaceCard.g, t.surfaceCard.b, 0.96f), + Color.Lerp(t.surfaceBorder, t.accentAmber, pulse)); + + // 닫기 — 우상단. 모바일은 손가락 크기로. + float closeSize = UIScale.IsMobileLayout ? 60f : 44f; + Rect close = new Rect(banner.xMax - closeSize - 10f, banner.y + 8f, closeSize, closeSize); + + const float btnW = 150f; + const float btnH = 68f; + float bodyTop = banner.y + 58f; + float bodyH = banner.yMax - 14f - bodyTop; + float titleW = close.x - 12f - (banner.x + 20f); + float bodyW = banner.width - 40f - (needsButton ? btnW + 12f : 0f); + IslandUiKit.Label(new Rect(banner.x + 20f, banner.y + 10f, titleW, 44f), IslandGuideSteps.Title(step), + IslandUiKit.Title, t.accentAmber); + IslandUiKit.Label(new Rect(banner.x + 20f, bodyTop, bodyW, bodyH), IslandGuideSteps.Body(step), + IslandUiKit.BodyWrap, t.textPrimary); + + // 남은 시간 — 스스로 사라진다는 걸 알려 주는 얇은 줄(둥근 모서리를 뚫지 않게 긴 축을 반경만큼 물린다). + if (!replay) + { + float track = banner.width - UITheme.Radius.Card * 2f; + UISurface.Flat(new Rect(banner.x + UITheme.Radius.Card, banner.yMax - 7f, + track * Mathf.Clamp01(remaining / CoachSeconds), 4f), t.accentAmber); + } + + bool closed = UISurface.Button(close, "✕", t.surfaceRaised, IslandUiKit.Button); + bool acted = false; + if (needsButton) + { + Rect btn = new Rect(banner.xMax - btnW - 14f, banner.yMax - btnH - 16f, btnW, btnH); + acted = UISurface.Button(btn, step == IslandGuideStep.Finish ? "확인" : "다음", t.accentMint, + IslandUiKit.Button); + } + GUI.color = previous; + + if (closed) DismissCoach(step, replay); + else if (acted && replay) island.AdvanceGuideReplay(); + else if (acted) island.AcknowledgeGuideFinish(); + } + + private void DrawHelp() + { + UISurface.Dim(0.6f); + UITheme t = UITheme.Instance; + bool mobile = UIScale.IsMobileLayout; + Rect panel = UISafeLayout.CenteredPanel(mobile ? 1000f : 920f, mobile ? 1400f : 900f); + UISurface.Card(panel); + if (UISurface.Header(new Rect(panel.x + 3f, panel.y + 3f, panel.width - 6f, 84f), "섬 도움말", string.Empty)) + { + CloseModal(); + return; + } + + Rect area = new Rect(panel.x + 20f, panel.y + 100f, panel.width - 40f, + Mathf.Max(1f, panel.height - 100f - 100f)); + var topics = IslandGuideSteps.HelpTopics; + float rowH = mobile ? 190f : 136f; + const float gap = 10f; + float contentH = topics.Length * (rowH + gap); + Rect view = new Rect(0f, 0f, area.width - 16f, contentH); + directScroll.Handle(ref helpScroll, area, contentH, rowH * 0.5f); + helpScroll = GUI.BeginScrollView(area, helpScroll, view, GUIStyle.none, GUIStyle.none); + for (int i = 0; i < topics.Length; i++) + { + Rect row = new Rect(0f, i * (rowH + gap), view.width, rowH); + UISurface.Card(row, t.surfaceCard, t.surfaceBorder); + IslandUiKit.Label(new Rect(row.x + 20f, row.y + 8f, row.width - 40f, 42f), topics[i].title, + IslandUiKit.Title, t.accentAmber); + IslandUiKit.Label(new Rect(row.x + 20f, row.y + 52f, row.width - 40f, rowH - 62f), topics[i].body, + IslandUiKit.BodyWrap, t.textPrimary); + } + GUI.EndScrollView(); + UISurface.ScrollAffordance(area, helpScroll, contentH, t.accentMint); + + // 내 섬에 있을 때만 — 안내는 섬 위의 버튼을 가리키므로 다른 곳에서 다시 틀면 가리킬 것이 없다. + Rect replayBtn = new Rect(panel.x + 20f, panel.yMax - 88f, panel.width - 40f, 68f); + bool enabled = GUI.enabled; + GUI.enabled = enabled && world.Mode == IslandMode.Own; + if (UISurface.Button(replayBtn, "처음 안내 다시 보기", t.surfaceRaised, IslandUiKit.Button)) + { + island.RestartGuide(); + CloseModal(); + } + GUI.enabled = enabled; + } + } +} diff --git a/Assets/Scripts/UI/IslandGuideUI.cs.meta b/Assets/Scripts/UI/IslandGuideUI.cs.meta new file mode 100644 index 0000000..ac9ded2 --- /dev/null +++ b/Assets/Scripts/UI/IslandGuideUI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5e499aa5f93f7e649a9513f8a0417634 \ No newline at end of file diff --git a/Assets/Scripts/UI/IslandHudUI.cs b/Assets/Scripts/UI/IslandHudUI.cs new file mode 100644 index 0000000..21602bb --- /dev/null +++ b/Assets/Scripts/UI/IslandHudUI.cs @@ -0,0 +1,456 @@ +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 섬 위에서만 뜨는 버튼 줄 — 꾸미기·상점·곤충·수확·방문·도움말·나가기. 남의 섬에서는 좋아요·돌아가기만 남는다. + /// + /// 모달이 아니다(캐릭터가 걸어다니는 동안 떠 있다). 그래서 매 OnGUI마다 자기 자리를 + /// 에 등록한다 — 안 하면 버튼을 누른 탭이 클릭-이동으로 새어 + /// 캐릭터가 버튼 아래 지점으로 걸어간다(rules/ui-layout.md, 같은 결함이 네 번 났다). + /// 자리는 이 정한다(데스크톱 한 줄 · 세로 모바일 열 · 가로 모바일 두 칸 판). + /// + public class IslandHudUI : MonoBehaviour + { + private const float ToastSeconds = 3f; + + [SerializeField] private IslandManager island; + [SerializeField] private IslandWorldBuilder world; + [SerializeField] private PlayerMovement playerMovement; + [SerializeField] private IslandShareClient share; + [SerializeField] private IslandEditUI editUI; + [SerializeField] private IslandShopUI shopUI; + [SerializeField] private IslandInsectUI insectUI; + [SerializeField] private IslandVisitUI visitUI; + [SerializeField] private IslandGuideUI guideUI; + + private IslandInfo visitInfo; + private string toast; + // 남은 표시 시간 — 가운데 무대(HudStage)에서 차례를 기다리는 동안은 줄지 않는다. + private float toastRemaining; + + // 매 프레임 문자열을 새로 잇지 않게, 보이는 숫자가 바뀔 때만 다시 만든다. + private string harvestLabel = "수확"; + private int harvestCandyShown = -1; + private int harvestCoinShown = -1; + private string infoLine = string.Empty; + private int infoKey = -1; + private string visitLine = string.Empty; + private int visitKey = -1; + + public void AutoWire(IslandManager islandManager, IslandWorldBuilder worldBuilder, PlayerMovement movement, + IslandShareClient shareClient) + { + if (island == null) island = islandManager; + if (world == null) world = worldBuilder; + if (playerMovement == null) playerMovement = movement; + if (share == null) share = shareClient; + } + + public void AutoWire(IslandEditUI edit, IslandShopUI shop, IslandInsectUI insects, IslandVisitUI visit, + IslandGuideUI guide) + { + if (editUI == null) editUI = edit; + if (shopUI == null) shopUI = shop; + if (insectUI == null) insectUI = insects; + if (visitUI == null) visitUI = visit; + if (guideUI == null) guideUI = guide; + } + + /// 남의 섬에 들어올 때 방문 창이 넘겨 준다 — 주인·좋아요 수 표시와 좋아요 요청에 쓴다. + public void SetVisitInfo(IslandInfo info) + { + visitInfo = info; + visitKey = -1; + } + + public void ShowToast(string message) + { + toast = message; + toastRemaining = ToastSeconds; + } + + private void Update() + { + // 섬 HUD가 이 화면에 선다고 알린다 — 가운데 무대(HudStage)가 그때만 섬 판을 피한다(필드에서 피하면 세로 화면의 카드가 + // 왼쪽 절반으로 밀렸다). OnGUI가 아니라 여기서 부르는 이유: Update가 모든 OnGUI보다 먼저 돌아 섬에 들어선 첫 프레임부터 + // 다른 화면의 카드가 같은 답을 보고, 모달·조작 잠금으로 판이 잠깐 숨어도 카드가 옆으로 뛰지 않는다(필드의 단축 바 등과 같다). + // 꿈 섬은 판을 숨기므로 알리지 않는다. + if (world != null && island != null && world.IsOnIsland && !world.DreamMode) + HudPresence.Mark(HudPresenceItem.IslandHud); + + if (toastRemaining <= 0f) return; + // 섬을 떠났으면 거둔다(섬 이야기다). + if (world == null || !world.IsOnIsland) { toastRemaining = 0f; return; } + // 무대 차례가 오고, 실제로 보이는 동안만 시간을 쓴다(창·프리즈가 덮었으면 기다린다). + if (!HudStage.Request(HudStageItem.IslandToast)) return; + if (ModalUIRegistry.IsAnyOpen() || (playerMovement != null && playerMovement.IsFrozen)) return; + toastRemaining -= Time.unscaledDeltaTime; + } + + private void OnGUI() + { + if (world == null || island == null || !world.IsOnIsland || world.DreamMode) return; + if (ModalUIRegistry.IsAnyOpen()) return; + if (playerMovement != null && playerMovement.IsFrozen) return; + + UIScale.Begin(); + HudFrame frame = HudFrame.Current; + IslandHudForm form = IslandHudLayout.FormFor(frame); + Rect quick = QuickAccessBarUI.ShortcutBarRectFor(frame); + if (world.IsVisiting) DrawVisitBar(form, quick); + else DrawOwnBar(form, quick, frame); + DrawToast(frame); + UIScale.End(); + } + + private Rect DrawOwnBar(IslandHudForm form, Rect quick, HudFrame frame) + { + island.Peek(out float pendingCandy, out float pendingCoin, out float accrued, out float cap); + int candy = Mathf.FloorToInt(pendingCandy); + int coin = Mathf.FloorToInt(pendingCoin); + if (candy != harvestCandyShown || coin != harvestCoinShown) + { + harvestCandyShown = candy; + harvestCoinShown = coin; + harvestLabel = candy > 0 || coin > 0 ? "수확 캔디 " + candy + " · 코인 " + coin : "수확"; + } + // 쾌적도·누적 시간(0.1시간 단위)·상한이 바뀔 때만 다시 잇는다. + int key = island.Effects.comfort * 100000 + Mathf.RoundToInt(accrued * 10f) * 100 + Mathf.RoundToInt(cap); + if (key != infoKey) + { + infoKey = key; + island.GetRates(out float candyRate, out float coinRate); + infoLine = "쾌적도 " + island.Effects.comfort + + " · 시간당 캔디 " + candyRate.ToString("0.0") + " · 코인 " + coinRate.ToString("0.0") + + " · 쌓인 시간 " + accrued.ToString("0.0") + " / " + cap.ToString("0") + "시간"; + } + + bool canHarvest = candy > 0 || coin > 0; + UITheme t = UITheme.Instance; + // 모바일은 퀵바(우상단) 곁에 선다 — 세로는 그 아래 열, 가로는 그 왼쪽 두 칸 판. 좌하단 사분면은 가상 조이스틱의 + // 시작 영역이라 거기 버튼을 두면 누를 때마다 캐릭터가 움찔한다. 데스크톱은 퀵바(하단 중앙) 바로 위에 한 줄. + Rect area = IslandHudLayout.OwnPanel(form, quick, frame.ContentHeight); + FieldHudInput.RegisterBlockingRect(area); + UISurface.HudCard(area); + if (form == IslandHudForm.Strip) + { + Rect info = IslandHudLayout.DesktopInfo(area); + UISurface.HudCard(info); + IslandUiKit.Label(new Rect(info.x + 16f, info.y, info.width - 32f, info.height), infoLine, + IslandUiKit.SmallCenter, t.textSecondary); + } + DrawOwnButtons(area, form, canHarvest, t); + return area; + } + + private void DrawOwnButtons(Rect area, IslandHudForm form, bool canHarvest, UITheme t) + { + Rect Slot(int index) => IslandHudLayout.OwnButton(area, form, index); + GUIStyle style = IslandUiKit.Button; + if (UISurface.Button(Slot(0), "꾸미기", t.surfaceRaised, style) && editUI != null) editUI.Open(); + if (UISurface.Button(Slot(1), "상점", t.surfaceRaised, style) && shopUI != null) shopUI.Toggle(); + if (UISurface.Button(Slot(2), "곤충", t.surfaceRaised, style) && insectUI != null) insectUI.Toggle(); + + bool enabled = GUI.enabled; + GUI.enabled = enabled && canHarvest; + // 받을 게 있으면 민트로 눈에 띄게 — 섬에 들른 이유가 대개 이 버튼이다. + if (UISurface.Button(Slot(IslandHudLayout.HarvestIndex), harvestLabel, + canHarvest ? t.accentMint : t.surfaceCard, style)) + DoHarvest(); + GUI.enabled = enabled; + + if (UISurface.Button(Slot(4), "방문", t.surfaceRaised, style) && visitUI != null) visitUI.Toggle(); + if (UISurface.Button(Slot(5), "도움말", t.surfaceRaised, style) && guideUI != null) guideUI.OpenHelp(); + if (UISurface.Button(Slot(6), "나가기", t.surfaceCard, style)) world.ExitIsland(); + } + + private void DoHarvest() + { + if (!island.Harvest(out int candy, out int coin)) return; + if (AudioManager.Instance != null) AudioManager.Instance.PlaySFX(SfxType.ItemPickup); + ShowToast("수확! 캔디 +" + candy + " · 코인 +" + coin); + } + + private Rect DrawVisitBar(IslandHudForm form, Rect quick) + { + UITheme t = UITheme.Instance; + IslandSnapshot snapshot = world.VisitSnapshot; + int likes = visitInfo != null ? visitInfo.likes : 0; + int key = likes * 10000 + (snapshot != null ? snapshot.comfort : 0); + if (key != visitKey) + { + visitKey = key; + visitLine = (snapshot != null ? snapshot.ownerName : "누군가") + "의 섬 · 쾌적도 " + + (snapshot != null ? snapshot.comfort : 0) + " · ♥ " + likes; + } + + Rect panel = IslandHudLayout.VisitPanel(form, quick); + FieldHudInput.RegisterBlockingRect(panel); + UISurface.HudCard(panel); + IslandUiKit.Label(new Rect(panel.x + 12f, panel.y + 6f, panel.width - 24f, 40f), visitLine, + IslandUiKit.BodyCenter, t.textPrimary); + + Rect like = IslandHudLayout.VisitButton(panel, form, 0); + Rect home = IslandHudLayout.VisitButton(panel, form, 1); + Rect exit = IslandHudLayout.VisitButton(panel, form, 2); + + GUIStyle style = IslandUiKit.Button; + bool canLike = visitInfo != null && !visitInfo.likedByMe && share != null && !share.IsBusy; + bool enabled = GUI.enabled; + GUI.enabled = enabled && canLike; + string likeLabel = visitInfo != null && visitInfo.likedByMe ? "좋아요 완료" : "좋아요 ♥"; + if (UISurface.Button(like, likeLabel, t.accentCoral, style)) DoLike(); + GUI.enabled = enabled; + + if (UISurface.Button(home, "내 섬으로", t.surfaceRaised, style)) + { + visitInfo = null; + world.ReturnToOwnIsland(); + } + if (UISurface.Button(exit, "나가기", t.surfaceCard, style)) + { + visitInfo = null; + world.ExitIsland(); + } + return panel; + } + + private void DoLike() + { + if (visitInfo == null || share == null) return; + IslandInfo target = visitInfo; + share.Like(target.ownerUid, result => + { + if (result == null) + { + ShowToast(string.IsNullOrEmpty(share.LastError) ? "좋아요를 보내지 못했습니다" : share.LastError); + return; + } + // 응답이 오기 전에 다른 섬으로 옮겨 갔을 수 있다 — 그 섬의 숫자를 덮지 않는다. + if (!ReferenceEquals(visitInfo, target)) return; + visitInfo.likes = result.likes; + visitInfo.likedByMe = true; + visitKey = -1; + ShowToast("좋아요를 보냈습니다"); + }); + } + + /// + /// 수확·좋아요 결과 한 줄의 자리 — 가운데 무대(). 예전엔 화면 위 가운데(+120)라 + /// 세로에서 단축 바를, 가로에서 섬 HUD 판을 덮었고, 그 다음엔 미니맵 오른쪽 줄에 두었는데 퀘스트 완료·코치 배너와 겹쳤다. + /// + public static Rect ToastRect(HudFrame f) + { + return HudStage.Place(f, HudStageItem.IslandToast, IslandHudLayout.ToastMaxWidth, IslandHudLayout.ToastHeight); + } + + private void DrawToast(HudFrame frame) + { + if (string.IsNullOrEmpty(toast) || toastRemaining <= 0f) return; + Rect r = ToastRect(frame); + if (!HudStage.Request(HudStageItem.IslandToast, r)) return; // 다른 카드가 무대에 서 있다 — 차례를 기다린다 + // 버튼은 없지만 불투명 카드다 — 그 위 탭이 클릭-이동으로 새지 않게. + FieldHudInput.RegisterBlockingRect(r); + UISurface.HudCard(r); + IslandUiKit.Label(new Rect(r.x + 16f, r.y + 6f, r.width - 32f, r.height - 12f), toast, + IslandUiKit.BodyCenter, UITheme.Instance.accentAmber); + } + } + + /// 섬 HUD의 모양. 데스크톱 한 줄 · 세로 모바일의 세로 열 · 가로 모바일의 두 칸 판. + public enum IslandHudForm + { + /// 데스크톱 — 단축 바(하단 가운데) 바로 위 한 줄, 그 위에 정보 한 줄. + Strip, + /// 세로 모바일 — 단축 바(우상단) 바로 아래 세로 열. + Column, + /// 가로 모바일 — 단축 바 왼쪽, 윗변을 맞춘 두 칸 판(수확만 한 줄을 다 쓴다). + Grid + } + + /// + /// 섬 HUD 버튼 줄·결과 토스트의 자리 — 순수 계산. 가 이걸로 그리고, 그 자리를 피해야 하는 HUD + /// (의 시각·날씨 칩)가 같은 계산을 읽는다 — 크기를 베껴 두면 줄을 늘릴 때 조용히 겹친다. + /// 앵커는 단축 바()다. + /// 내 섬 버튼 순서는 꾸미기·상점·곤충·수확·방문·도움말·나가기(0~6), 남의 섬은 좋아요·내 섬으로·나가기(0~2). + /// + /// 가로 모바일이 두 칸 판인 이유: 세로 열(7줄, 568)을 가로 화면 단축 바 아래에 세우면 화면 85% 높이까지 내려와 + /// 우하단 잡기 버튼(CaptureInputController — 지름 192, 아래 안전 가장자리에서 92/Scale 위)과 그 위 피드백 글자를 덮는다. + /// 밤·비에 섬에도 야생 손님이 오면서 섬에서도 잡기 버튼이 뜬다. 단축 바 왼쪽은 리전 배너·내기 점수판이 단축 바 왼쪽 끝 기준 + /// 가운데에 서서 비어 있고, 줄 높이를 단축 바 버튼(68)과 맞추면 판 높이가 단축 바와 같아(312) 둘이 위쪽 한 띠에 나란히 선다. + /// 그러면 단축 바 아래(시각·날씨 칩 자리)와 화면 아래쪽 절반이 비어 잡기 버튼·조이스틱과 멀다. + /// + public static class IslandHudLayout + { + public const int OwnButtonCount = 7; + public const int VisitButtonCount = 3; + /// 내 섬 줄에서 수확 버튼의 순번 — 데스크톱 줄과 가로 판에서는 이 칸만 넓다(수확물 숫자가 들어간다). + public const int HarvestIndex = 3; + + /// 단축 바와 섬 HUD 사이. + public const float AnchorGap = 10f; + public const float MobileRowHeight = 72f; + public const float MobileRowGap = 8f; + /// 가로 판의 줄 높이 — 단축 바의 모바일 버튼 높이(68)와 같다. + public const float GridRowHeight = 68f; + /// 데스크톱 줄의 버튼, 세로 남의 섬 버튼 높이. + public const float ButtonHeight = 64f; + public const float CellGap = 6f; + public const float HarvestWidth = 340f; + public const float VisitHeaderHeight = 48f; + /// 남의 섬 판에서 머리 줄(주인·쾌적도·좋아요) 아래 첫 버튼의 윗변(모바일). + public const float VisitButtonsTop = 52f; + public const float ToastHeight = 60f; + public const float ToastMaxWidth = 760f; + + /// 세로 모바일 내 섬 열이 바라는 높이(버튼 7줄 + 위아래 여백). + public const float MobileOwnColumnHeight = 16f + OwnButtonCount * MobileRowHeight + (OwnButtonCount - 1) * MobileRowGap; + /// 가로 판의 줄 수 — 수확 한 줄 + 나머지 여섯을 둘씩 세 줄. + public const int GridOwnRows = 1 + (OwnButtonCount - 1) / 2; + /// 가로 판(내 섬) 높이 — 단축 바(2열 4줄, 312)와 같다. + public const float GridOwnHeight = 16f + GridOwnRows * GridRowHeight + (GridOwnRows - 1) * MobileRowGap; + /// 가로 판(남의 섬) 높이 — 머리 줄 + 좋아요 한 줄 + (내 섬으로·나가기) 한 줄. + public const float GridVisitHeight = VisitButtonsTop + 2f * GridRowHeight + MobileRowGap + 8f; + + public static IslandHudForm FormFor(bool mobileLayout, bool portrait) + { + if (!mobileLayout) return IslandHudForm.Strip; + return portrait ? IslandHudForm.Column : IslandHudForm.Grid; + } + + // ── 판 ── + + /// 내 섬 판. 는 안전 영역 높이(UISafeLayout.ContentHeight) — 세로 열만 쓴다. + public static Rect OwnPanel(IslandHudForm form, Rect quick, float maxHeight) + { + switch (form) + { + case IslandHudForm.Column: return MobileOwnColumn(quick, maxHeight); + case IslandHudForm.Grid: return LeftOfBar(quick, GridOwnHeight); + default: return DesktopOwnStrip(quick); + } + } + + /// 남의 섬 판. + public static Rect VisitPanel(IslandHudForm form, Rect quick) + { + switch (form) + { + case IslandHudForm.Column: return MobileVisitPanel(quick); + case IslandHudForm.Grid: return LeftOfBar(quick, GridVisitHeight); + default: return DesktopVisitPanel(quick); + } + } + + /// 지금 그려지는 판(내 섬 또는 남의 섬). + public static Rect Panel(IslandHudForm form, Rect quick, bool visiting, float maxHeight) + { + return visiting ? VisitPanel(form, quick) : OwnPanel(form, quick, maxHeight); + } + + /// 세로 모바일 내 섬 — 단축 바 바로 아래 세로 열. + public static Rect MobileOwnColumn(Rect quick, float maxHeight) + { + return new Rect(quick.x, quick.yMax + AnchorGap, quick.width, Mathf.Min(MobileOwnColumnHeight, maxHeight)); + } + + /// 세로 모바일 남의 섬 — 단축 바 아래, 머리 줄(주인·쾌적도·좋아요) + 버튼 세 줄. + public static Rect MobileVisitPanel(Rect quick) + { + return new Rect(quick.x, quick.yMax + AnchorGap, quick.width, + 16f + VisitHeaderHeight + VisitButtonCount * (ButtonHeight + 8f)); + } + + public static Rect DesktopOwnStrip(Rect bar) + { + return new Rect(bar.x, bar.y - ButtonHeight - 16f - AnchorGap, bar.width, ButtonHeight + 16f); + } + + /// 데스크톱 줄 위의 정보 한 줄(쾌적도·시간당 생산·쌓인 시간). + public static Rect DesktopInfo(Rect strip) + { + return new Rect(strip.x, strip.y - 44f, strip.width, 40f); + } + + public static Rect DesktopVisitPanel(Rect quick) + { + float h = ButtonHeight + 16f + VisitHeaderHeight; + return new Rect(quick.x, quick.y - h - AnchorGap, quick.width, h); + } + + // 가로 모바일 — 단축 바 왼쪽, 윗변을 맞춘다. 폭은 단축 바와 같다. + private static Rect LeftOfBar(Rect quick, float height) + { + return new Rect(quick.x - AnchorGap - quick.width, quick.y, quick.width, height); + } + + // ── 버튼 ── + + /// 내 섬 번째 버튼(는 ). + public static Rect OwnButton(Rect area, IslandHudForm form, int index) + { + switch (form) + { + case IslandHudForm.Column: + return new Rect(area.x + 8f, area.y + 8f + index * (MobileRowHeight + MobileRowGap), + area.width - 16f, MobileRowHeight); + case IslandHudForm.Grid: + { + // 첫 줄은 수확(넓게 — 수확물 숫자가 든다), 그 아래로 [꾸미기|상점] [곤충|방문] [도움말|나가기]. + if (index == HarvestIndex) return GridCell(area, 8f, 0, -1); + int slot = index < HarvestIndex ? index : index - 1; + return GridCell(area, 8f, 1 + slot / 2, slot % 2); + } + default: + { + int narrow = OwnButtonCount - 1; + float cellW = (area.width - 16f - HarvestWidth - CellGap * narrow) / narrow; + float x = area.x + 8f; + for (int i = 0; i < index; i++) x += (i == HarvestIndex ? HarvestWidth : cellW) + CellGap; + return new Rect(x, area.y + 8f, index == HarvestIndex ? HarvestWidth : cellW, ButtonHeight); + } + } + } + + /// 남의 섬 번째 버튼(은 ). + public static Rect VisitButton(Rect panel, IslandHudForm form, int index) + { + switch (form) + { + case IslandHudForm.Column: + return new Rect(panel.x + 8f, panel.y + VisitButtonsTop + index * (ButtonHeight + 8f), + panel.width - 16f, ButtonHeight); + case IslandHudForm.Grid: + // 좋아요는 한 줄을 다 쓰고, 내 섬으로·나가기는 그 아래 둘로. + return index == 0 ? GridCell(panel, VisitButtonsTop, 0, -1) : GridCell(panel, VisitButtonsTop, 1, index - 1); + default: + { + float w = (panel.width - 16f - 12f) / VisitButtonCount; + return new Rect(panel.x + 8f + index * (w + 6f), panel.y + VisitHeaderHeight, w, ButtonHeight); + } + } + } + + // 가로 판의 칸 — column이 음수면 그 줄을 다 쓴다. + private static Rect GridCell(Rect area, float top, int row, int column) + { + float y = area.y + top + row * (GridRowHeight + MobileRowGap); + float inner = area.width - 16f; + if (column < 0) return new Rect(area.x + 8f, y, inner, GridRowHeight); + float w = (inner - MobileRowGap) * 0.5f; + return new Rect(area.x + 8f + column * (w + MobileRowGap), y, w, GridRowHeight); + } + + // ── 화면 한 장(HudFrame)에서 ── + + public static IslandHudForm FormFor(HudFrame f) => FormFor(f.Mobile, f.Portrait); + + /// 내 섬 판(화면 한 장 기준). + public static Rect OwnPanel(HudFrame f) => OwnPanel(FormFor(f), QuickAccessBarUI.ShortcutBarRectFor(f), f.ContentHeight); + + /// 남의 섬 판(화면 한 장 기준). + public static Rect VisitPanel(HudFrame f) => VisitPanel(FormFor(f), QuickAccessBarUI.ShortcutBarRectFor(f)); + } +} diff --git a/Assets/Scripts/UI/IslandHudUI.cs.meta b/Assets/Scripts/UI/IslandHudUI.cs.meta new file mode 100644 index 0000000..b620c0d --- /dev/null +++ b/Assets/Scripts/UI/IslandHudUI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 88d161934e5c029458a24455518c9a54 \ No newline at end of file diff --git a/Assets/Scripts/UI/IslandInsectUI.cs b/Assets/Scripts/UI/IslandInsectUI.cs new file mode 100644 index 0000000..245a630 --- /dev/null +++ b/Assets/Scripts/UI/IslandInsectUI.cs @@ -0,0 +1,226 @@ +using System.Collections.Generic; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Dex; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 섬 곤충 창 — 보유 곤충을 섬에 풀어놓거나 거둔다. 풀어놓은 곤충은 보유 목록에 그대로 있고 + /// 전투·훈련에도 쓸 수 있다(배틀팀처럼 표시만 붙는다). + /// + public class IslandInsectUI : MonoBehaviour, IModalUI + { + private const float FeedbackSeconds = 2.4f; + + // 한 줄에 보여 줄 문구를 목록을 만들 때 한 번만 잇는다 — 행마다 매 프레임 이으면 목록이 길수록 GC가 는다. + private class Row + { + public PlayerInsectData insect; + public InsectData data; + public bool released; + public string title; + public string detail; + public string hearts; + } + + [SerializeField] private IslandManager island; + [SerializeField] private PlayerInsectCollection insects; + + private bool isOpen; + private Vector2 scroll; + private readonly UIDirectScroll directScroll = new UIDirectScroll(); + private readonly List rows = new List(); + private bool rowsDirty = true; + private string summary = string.Empty; + private string feedback; + private float feedbackUntil; + + public bool IsOpen => isOpen; + + public void AutoWire(IslandManager islandManager, PlayerInsectCollection collection) + { + if (island == null) island = islandManager; + if (insects == null) insects = collection; + } + + public void Toggle() + { + if (isOpen) { CloseModal(); return; } + if (island == null || insects == null) return; + isOpen = true; + scroll = Vector2.zero; + rowsDirty = true; + directScroll.Reset(); + // 창을 열 때 밀린 시간을 정산한다 — 친밀도와 시간당 생산이 지금 값으로 보인다. + island.Settle(); + ModalUIRegistry.Register(this); + } + + public void CloseModal() + { + isOpen = false; + directScroll.Reset(); + ModalUIRegistry.Unregister(this); + } + + private void OnDisable() => CloseModal(); + + private void RebuildRows() + { + rows.Clear(); + IslandEffects effects = island.Effects; + List owned = insects.GetAllOwned(); + for (int i = 0; i < owned.Count; i++) + { + PlayerInsectData p = owned[i]; + InsectData data = insects.GetInsectData(p.insectId); + if (data == null) continue; + bool released = island.IsReleased(p.instanceId); + int bond = island.GetBondLevel(p.instanceId); + string detail; + if (released) + { + detail = "시간당 캔디 " + IslandYield.CandyPerHour(data.rarity, bond, effects).ToString("0.00") + + " · 코인 " + IslandYield.CoinPerHour(bond, effects).ToString("0.00"); + } + else + { + detail = "풀어놓으면 시간당 캔디 " + + IslandYield.CandyPerHour(data.rarity, bond, effects).ToString("0.00"); + } + rows.Add(new Row + { + insect = p, + data = data, + released = released, + title = (p.isShiny ? "★ " : "") + data.displayName + " Lv." + p.level, + detail = detail, + hearts = IslandUiKit.Hearts(bond), + }); + } + // 섬에 있는 곤충을 위로, 그다음 레벨 높은 순. + rows.Sort((a, b) => + { + if (a.released != b.released) return a.released ? -1 : 1; + return b.insect.level.CompareTo(a.insect.level); + }); + + island.GetRates(out float candyRate, out float coinRate); + summary = "섬에 있는 곤충 " + island.ReleasedInsects.Count + " / " + island.InsectSlots + + " · 시간당 캔디 " + candyRate.ToString("0.0") + " · 코인 " + coinRate.ToString("0.0"); + rowsDirty = false; + } + + private void OnGUI() + { + if (!isOpen || island == null || insects == null) return; + if (!ReferenceEquals(ModalUIRegistry.TopModal, this)) return; + if (rowsDirty) RebuildRows(); + + UIScale.Begin(); + UISurface.Dim(0.6f); + UITheme t = UITheme.Instance; + bool mobile = UIScale.IsMobileLayout; + Rect panel = UISafeLayout.CenteredPanel(mobile ? 1000f : 980f, mobile ? 1500f : 940f); + UISurface.Card(panel); + if (UISurface.Header(new Rect(panel.x + 3f, panel.y + 3f, panel.width - 6f, 84f), "섬 곤충", string.Empty)) + { + CloseModal(); + UIScale.End(); + return; + } + + IslandUiKit.Label(new Rect(panel.x + 24f, panel.y + 98f, panel.width - 48f, 40f), summary, + IslandUiKit.Body, t.accentAmber); + + Rect area = new Rect(panel.x + 20f, panel.y + 148f, panel.width - 40f, + Mathf.Max(1f, panel.height - 148f - 72f)); + if (rows.Count == 0) + { + IslandUiKit.Label(area, "풀어놓을 곤충이 없습니다. 필드에서 곤충을 잡아 오세요.", + IslandUiKit.BodyCenter, t.textSecondary); + } + else + { + DrawList(area, t, mobile); + } + + if (!string.IsNullOrEmpty(feedback) && Time.unscaledTime < feedbackUntil) + IslandUiKit.Label(new Rect(panel.x + 20f, panel.yMax - 62f, panel.width - 40f, 48f), feedback, + IslandUiKit.BodyCenter, t.accentAmber); + UIScale.End(); + } + + private void DrawList(Rect area, UITheme t, bool mobile) + { + float rowH = mobile ? 132f : 112f; + const float gap = 8f; + float contentH = rows.Count * (rowH + gap); + Rect view = new Rect(0f, 0f, area.width - 16f, contentH); + directScroll.Handle(ref scroll, area, contentH, rowH * 0.5f); + scroll = GUI.BeginScrollView(area, scroll, view, GUIStyle.none, GUIStyle.none); + // 뷰포트 컬링이 꼭 필요하다 — 곤충 썸네일 캐시가 24칸이라 보이지 않는 행까지 그리면 캐시가 매 프레임 밀린다. + DexBrowseLayout.GetVisibleRowRange(scroll.y, area.height, rowH, gap, rows.Count, out int first, out int last); + for (int i = first; i <= last; i++) + DrawRow(new Rect(0f, i * (rowH + gap), view.width, rowH), rows[i], t); + GUI.EndScrollView(); + UISurface.ScrollAffordance(area, scroll, contentH, t.accentMint); + } + + private void DrawRow(Rect r, Row row, UITheme t) + { + UISurface.Card(r, row.released ? t.surfaceRaised : t.surfaceCard, + row.released ? t.accentMint : t.surfaceBorder); + UISurface.Flat(new Rect(r.x + 3f, r.y + UITheme.Radius.Card, 6f, r.height - UITheme.Radius.Card * 2f), + t.GetInsectRarityColor(row.data.rarity)); + + float portrait = r.height - 16f; + InsectVisual.Draw(new Rect(r.x + 16f, r.y + 8f, portrait, portrait), row.data, row.insect.isShiny, 1f); + + const float btnW = 190f; + float textX = r.x + 16f + portrait + 14f; + float textW = r.xMax - btnW - 24f - textX; + IslandUiKit.Label(new Rect(textX, r.y + 8f, textW * 0.68f, 40f), row.title, IslandUiKit.Title, t.textPrimary); + IslandUiKit.Label(new Rect(textX + textW * 0.68f, r.y + 8f, textW * 0.32f, 40f), row.hearts, + IslandUiKit.Body, t.accentCoral); + IslandUiKit.Label(new Rect(textX, r.y + 50f, textW, 34f), row.detail, IslandUiKit.Small, t.textSecondary); + + Rect btn = new Rect(r.xMax - btnW - 12f, r.y + (r.height - 64f) * 0.5f, btnW, 64f); + bool enabled = GUI.enabled; + GUI.enabled = enabled && !directScroll.IsDragging; + if (row.released) + { + if (UISurface.Button(btn, "거두기", t.surfaceBase, IslandUiKit.Button)) Unrelease(row); + } + else if (UISurface.Button(btn, "풀어놓기", t.accentMint, IslandUiKit.Button)) + { + Release(row); + } + GUI.enabled = enabled; + } + + private void Release(Row row) + { + IslandReleaseResult result = island.TryRelease(row.insect.instanceId); + ShowFeedback(result == IslandReleaseResult.Ok + ? row.data.displayName + " — " + IslandUiKit.ReleaseResultText(result) + : IslandUiKit.ReleaseResultText(result)); + rowsDirty = true; + } + + private void Unrelease(Row row) + { + if (island.Unrelease(row.insect.instanceId)) + ShowFeedback(row.data.displayName + " — 섬에서 거두었습니다. 친밀도는 남아 있어요."); + rowsDirty = true; + } + + private void ShowFeedback(string message) + { + feedback = message; + feedbackUntil = Time.unscaledTime + FeedbackSeconds; + } + } +} diff --git a/Assets/Scripts/UI/IslandInsectUI.cs.meta b/Assets/Scripts/UI/IslandInsectUI.cs.meta new file mode 100644 index 0000000..c36c168 --- /dev/null +++ b/Assets/Scripts/UI/IslandInsectUI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7db2ea9f9fa26104f88ec80560eaa769 \ No newline at end of file diff --git a/Assets/Scripts/UI/IslandShopUI.cs b/Assets/Scripts/UI/IslandShopUI.cs new file mode 100644 index 0000000..e6a321b --- /dev/null +++ b/Assets/Scripts/UI/IslandShopUI.cs @@ -0,0 +1,318 @@ +using System.Collections.Generic; +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 섬 상점 — 건물·가구·지형지물·도구를 사고(보관함으로 들어간다) 섬 크기와 곤충 자리를 넓힌다. + /// 되팔기는 없다: 다이아 환급은 서버 규칙이 막고, 코인만 환급하면 다이아 물건과 규칙이 갈린다. + /// + public class IslandShopUI : MonoBehaviour, IModalUI + { + private const int ExpandTab = 4; + private const float FeedbackSeconds = 2.2f; + + private static readonly string[] TabNames = { "건물", "가구", "지형지물", "도구", "확장" }; + + [SerializeField] private IslandManager island; + [SerializeField] private PlayerCurrencyWallet wallet; + + private bool isOpen; + private int tab = 1; + private Vector2 scroll; + private readonly UIDirectScroll directScroll = new UIDirectScroll(); + private readonly List rows = new List(); + private int rowsTab = -1; + + private string feedback; + private float feedbackUntil; + + // 행마다 매 프레임 문자열을 잇지 않게 물건별 문구를 한 번만 만든다. 보유 수가 바뀌면 그 줄만 다시 만든다. + private readonly Dictionary specLabels = new Dictionary(); + private readonly Dictionary ownedLabels = new Dictionary(); + private readonly Dictionary ownedShown = new Dictionary(); + private string walletLabel = string.Empty; + private int walletKey = -1; + + public bool IsOpen => isOpen; + + public void AutoWire(IslandManager islandManager, PlayerCurrencyWallet currencyWallet) + { + if (island == null) island = islandManager; + if (wallet == null) wallet = currencyWallet; + } + + public void Toggle() + { + if (isOpen) { CloseModal(); return; } + if (island == null) return; + isOpen = true; + scroll = Vector2.zero; + rowsTab = -1; + directScroll.Reset(); + ModalUIRegistry.Register(this); + island.ReportShopOpened(); + } + + public void CloseModal() + { + isOpen = false; + directScroll.Reset(); + ModalUIRegistry.Unregister(this); + } + + private void OnDisable() => CloseModal(); + + private void OnGUI() + { + if (!isOpen || island == null) return; + if (!ReferenceEquals(ModalUIRegistry.TopModal, this)) return; + + UIScale.Begin(); + UISurface.Dim(0.6f); + bool mobile = UIScale.IsMobileLayout; + Rect panel = UISafeLayout.CenteredPanel(mobile ? 1000f : 1080f, mobile ? 1500f : 940f); + UISurface.Card(panel); + if (UISurface.Header(new Rect(panel.x + 3f, panel.y + 3f, panel.width - 6f, 84f), "섬 상점", WalletLabel())) + { + CloseModal(); + UIScale.End(); + return; + } + + DrawTabs(new Rect(panel.x + 20f, panel.y + 100f, panel.width - 40f, 64f)); + Rect body = new Rect(panel.x + 20f, panel.y + 176f, panel.width - 40f, + Mathf.Max(1f, panel.height - 176f - 76f)); + if (tab == ExpandTab) DrawExpand(body); + else DrawCatalog(body); + DrawFeedback(new Rect(panel.x + 20f, panel.yMax - 66f, panel.width - 40f, 50f)); + UIScale.End(); + } + + private string WalletLabel() + { + int coins = wallet != null ? wallet.Coins : 0; + int gems = wallet != null ? wallet.Gems : 0; + int key = coins * 1000003 + gems; + if (key != walletKey) + { + walletKey = key; + walletLabel = "코인 " + coins + " · 다이아 " + gems; + } + return walletLabel; + } + + private void DrawTabs(Rect area) + { + UITheme t = UITheme.Instance; + const float gap = 8f; + float w = (area.width - gap * (TabNames.Length - 1)) / TabNames.Length; + for (int i = 0; i < TabNames.Length; i++) + { + Rect r = new Rect(area.x + i * (w + gap), area.y, w, area.height); + if (UISurface.Button(r, TabNames[i], i == tab ? t.accentCoral : t.surfaceRaised, IslandUiKit.Button, i == tab) + && tab != i) + { + tab = i; + scroll = Vector2.zero; + directScroll.Reset(); + } + } + } + + private void RebuildRows() + { + rows.Clear(); + IReadOnlyList all = IslandCatalog.All; + for (int i = 0; i < all.Count; i++) + if ((int)all[i].category == tab) rows.Add(all[i]); + rowsTab = tab; + } + + private void DrawCatalog(Rect area) + { + if (rowsTab != tab) RebuildRows(); + UITheme t = UITheme.Instance; + bool mobile = UIScale.IsMobileLayout; + float rowH = mobile ? 168f : 132f; + const float gap = 8f; + float contentH = rows.Count * (rowH + gap); + Rect view = new Rect(0f, 0f, area.width - 16f, contentH); + directScroll.Handle(ref scroll, area, contentH, rowH * 0.5f); + scroll = GUI.BeginScrollView(area, scroll, view, GUIStyle.none, GUIStyle.none); + for (int i = 0; i < rows.Count; i++) + { + float y = i * (rowH + gap); + // 뷰포트 밖 행은 건너뛴다. + if (y + rowH < scroll.y || y > scroll.y + area.height) continue; + DrawRow(new Rect(0f, y, view.width, rowH), rows[i], t, mobile); + } + GUI.EndScrollView(); + UISurface.ScrollAffordance(area, scroll, contentH, t.accentMint); + } + + private void DrawRow(Rect r, IslandObjectDef def, UITheme t, bool mobile) + { + bool locked = island.IsLockedBySize(def); + UISurface.Card(r, t.surfaceCard, t.surfaceBorder); + // 세로 등급 레일 — 폭이 좁아 둥근 9-slice를 쓰면 뭉개진다(Flat). 둥근 모서리를 뚫지 않게 위아래를 물린다. + UISurface.Flat(new Rect(r.x + 3f, r.y + UITheme.Radius.Card, 6f, r.height - UITheme.Radius.Card * 2f), + IslandUiKit.CategoryColor(def.category)); + + float buyW = mobile ? 220f : 230f; + float textW = r.width - 28f - buyW - 20f; + IslandUiKit.Label(new Rect(r.x + 22f, r.y + 8f, textW, 40f), def.displayName, IslandUiKit.Title, t.textPrimary); + IslandUiKit.Label(new Rect(r.x + 22f, r.y + 48f, textW, r.height - 48f - 40f), def.description, + IslandUiKit.BodyWrap, t.textSecondary); + IslandUiKit.Label(new Rect(r.x + 22f, r.yMax - 38f, textW * 0.62f, 32f), SpecLabel(def), + IslandUiKit.Small, t.accentAmber); + IslandUiKit.Label(new Rect(r.x + 22f + textW * 0.62f, r.yMax - 38f, textW * 0.38f, 32f), OwnedLabel(def), + IslandUiKit.Small, t.textMuted); + + Rect buy = new Rect(r.xMax - buyW - 12f, r.y + (r.height - 68f) * 0.5f, buyW, 68f); + if (locked) + { + UISurface.Rounded(buy, t.surfaceBase); + IslandUiKit.Label(buy, "섬 확장 필요", IslandUiKit.ButtonSmall, t.textMuted); + return; + } + + bool afford = CanAfford(def.coinPrice, def.gemPrice, def.IsPremium); + bool enabled = GUI.enabled; + // 스크롤하다 뗀 손가락이 구매로 읽히지 않게 — 끄는 중에는 버튼을 끈다. + GUI.enabled = enabled && afford && !directScroll.IsDragging; + if (UISurface.Button(buy, PriceLabel(def.coinPrice, def.gemPrice, def.IsPremium), + def.IsPremium ? t.insectRare : t.accentMint, IslandUiKit.Button)) + Buy(def); + GUI.enabled = enabled; + } + + private string SpecLabel(IslandObjectDef def) + { + if (specLabels.TryGetValue(def.id, out string label)) return label; + label = def.width + "×" + def.depth + "칸 · 쾌적도 +" + def.comfort; + string effect = IslandUiKit.EffectText(def); + if (!string.IsNullOrEmpty(effect)) label += " · " + effect; + specLabels[def.id] = label; + return label; + } + + private string OwnedLabel(IslandObjectDef def) + { + int owned = island.GetOwnedCount(def.id); + if (!ownedShown.TryGetValue(def.id, out int shown) || shown != owned) + { + ownedShown[def.id] = owned; + ownedLabels[def.id] = owned > 0 ? "보유 " + owned : string.Empty; + } + return ownedLabels[def.id]; + } + + // 가격 문구는 가짓수가 적어(카탈로그 가격 + 확장 단계) 값으로 캐시한다. + private readonly Dictionary priceLabels = new Dictionary(); + + private string PriceLabel(int coins, int gems, bool useGems) + { + int key = useGems ? -gems : coins; + if (priceLabels.TryGetValue(key, out string label)) return label; + label = useGems ? "다이아 " + gems : "코인 " + coins; + priceLabels[key] = label; + return label; + } + + private bool CanAfford(int coins, int gems, bool useGems) + { + if (AuthManager.Instance != null && AuthManager.Instance.MasterPrivilegesActive) return true; + if (wallet == null) return false; + return useGems ? wallet.Gems >= gems : wallet.Coins >= coins; + } + + private void Buy(IslandObjectDef def) + { + IslandBuyResult result = island.TryBuy(def.id); + if (result == IslandBuyResult.Ok && AudioManager.Instance != null) + AudioManager.Instance.PlaySFX(SfxType.Purchase); + ShowFeedback(result == IslandBuyResult.Ok + ? def.displayName + " — 보관함에 넣었습니다. [꾸미기]에서 놓아 보세요." + : IslandUiKit.BuyResultText(result)); + } + + private void DrawExpand(Rect area) + { + UITheme t = UITheme.Instance; + const float cardH = 250f; + Rect sizeCard = new Rect(area.x, area.y, area.width, cardH); + Rect slotCard = new Rect(area.x, area.y + cardH + 16f, area.width, cardH); + + bool canSize = IslandCatalog.SizePrice(island.SizeLevel, out int sizeCoins, out int sizeGems); + bool canSlot = IslandCatalog.SlotPrice(island.ExtraSlots, out int slotCoins, out int slotGems); + + // 단계가 바뀔 때만 문구를 다시 잇는다. + int key = island.SizeLevel * 100 + island.ExtraSlots; + if (key != expandKey) + { + expandKey = key; + int grid = island.GridSize; + int next = grid + GameConstants.Island.GridSizeStep; + sizeNow = "지금 " + grid + "×" + grid + "칸" + (canSize ? " → " + next + "×" + next + "칸" : " (최대)"); + slotNow = "지금 " + island.InsectSlots + "마리" + + (canSlot ? " → " + (island.InsectSlots + 1) + "마리" : " (최대)"); + } + + DrawExpandCard(sizeCard, "섬 넓히기", "섬이 사방으로 넓어집니다. 놓아둔 물건은 그대로 있습니다.", + sizeNow, canSize, sizeCoins, sizeGems, t, true); + DrawExpandCard(slotCard, "곤충 자리 늘리기", "섬에 풀어놓을 수 있는 곤충이 한 마리 늘어납니다.", + slotNow, canSlot, slotCoins, slotGems, t, false); + } + + private int expandKey = -1; + private string sizeNow = string.Empty; + private string slotNow = string.Empty; + + private void DrawExpandCard(Rect r, string title, string desc, string now, bool available, + int coins, int gems, UITheme t, bool isSize) + { + UISurface.Card(r, t.surfaceCard, t.surfaceBorder); + IslandUiKit.Label(new Rect(r.x + 22f, r.y + 12f, r.width - 44f, 44f), title, IslandUiKit.Title, t.textPrimary); + IslandUiKit.Label(new Rect(r.x + 22f, r.y + 58f, r.width - 44f, 36f), desc, IslandUiKit.Body, t.textSecondary); + IslandUiKit.Label(new Rect(r.x + 22f, r.y + 98f, r.width - 44f, 36f), now, IslandUiKit.Body, t.accentAmber); + if (!available) return; + + // 코인으로도 다이아로도 넓힐 수 있다 — 다이아가 유료 전용이라 코인 길을 막지 않는다. + float w = (r.width - 44f - 12f) * 0.5f; + Rect coinBtn = new Rect(r.x + 22f, r.yMax - 84f, w, 68f); + Rect gemBtn = new Rect(coinBtn.xMax + 12f, coinBtn.y, w, 68f); + bool enabled = GUI.enabled; + GUI.enabled = enabled && CanAfford(coins, gems, false); + if (UISurface.Button(coinBtn, PriceLabel(coins, gems, false), t.accentMint, IslandUiKit.Button)) + Expand(isSize, false); + GUI.enabled = enabled && CanAfford(coins, gems, true); + if (UISurface.Button(gemBtn, PriceLabel(coins, gems, true), t.insectRare, IslandUiKit.Button)) + Expand(isSize, true); + GUI.enabled = enabled; + } + + private void Expand(bool isSize, bool useGems) + { + IslandBuyResult result = isSize ? island.TryExpandSize(useGems) : island.TryBuySlot(useGems); + if (result == IslandBuyResult.Ok && AudioManager.Instance != null) + AudioManager.Instance.PlaySFX(SfxType.Purchase); + ShowFeedback(result == IslandBuyResult.Ok + ? (isSize ? "섬이 넓어졌습니다" : "곤충 자리가 늘었습니다") + : IslandUiKit.BuyResultText(result)); + } + + private void DrawFeedback(Rect r) + { + if (string.IsNullOrEmpty(feedback) || Time.unscaledTime >= feedbackUntil) return; + IslandUiKit.Label(r, feedback, IslandUiKit.BodyCenter, UITheme.Instance.accentAmber); + } + + private void ShowFeedback(string message) + { + feedback = message; + feedbackUntil = Time.unscaledTime + FeedbackSeconds; + } + } +} diff --git a/Assets/Scripts/UI/IslandShopUI.cs.meta b/Assets/Scripts/UI/IslandShopUI.cs.meta new file mode 100644 index 0000000..94dd039 --- /dev/null +++ b/Assets/Scripts/UI/IslandShopUI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4a830936dd75dea4492daccef1be0865 \ No newline at end of file diff --git a/Assets/Scripts/UI/IslandUiKit.cs b/Assets/Scripts/UI/IslandUiKit.cs new file mode 100644 index 0000000..c2c72ee --- /dev/null +++ b/Assets/Scripts/UI/IslandUiKit.cs @@ -0,0 +1,147 @@ +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 섬 화면 여섯 개(HUD·꾸미기·상점·곤충·방문·가이드)가 함께 쓰는 스타일과 문구. + /// 화면마다 GUIStyle과 결과 문구 switch를 따로 두면 같은 말이 화면마다 다르게 적힌다. + /// 색은 전부 토큰에서 받는다. + /// + internal static class IslandUiKit + { + // 키가 상수이고 람다가 아무것도 캡처하지 않아 적중 경로가 무할당이다(UIHelper.CachedStyle 주석 참조). + public static GUIStyle Title => UIHelper.CachedStyle("island_title", + () => Make(30, FontStyle.Bold, TextAnchor.MiddleLeft, false)); + + public static GUIStyle Body => UIHelper.CachedStyle("island_body", + () => Make(24, FontStyle.Normal, TextAnchor.MiddleLeft, false)); + + public static GUIStyle BodyWrap => UIHelper.CachedStyle("island_body_wrap", + () => Make(24, FontStyle.Normal, TextAnchor.UpperLeft, true)); + + public static GUIStyle BodyCenter => UIHelper.CachedStyle("island_body_center", + () => Make(24, FontStyle.Normal, TextAnchor.MiddleCenter, false)); + + public static GUIStyle Small => UIHelper.CachedStyle("island_small", + () => Make(20, FontStyle.Normal, TextAnchor.MiddleLeft, false)); + + public static GUIStyle SmallCenter => UIHelper.CachedStyle("island_small_center", + () => Make(20, FontStyle.Normal, TextAnchor.MiddleCenter, false)); + + public static GUIStyle Button => UIHelper.CachedStyle("island_button", + () => Make(24, FontStyle.Bold, TextAnchor.MiddleCenter, false)); + + public static GUIStyle ButtonSmall => UIHelper.CachedStyle("island_button_small", + () => Make(20, FontStyle.Bold, TextAnchor.MiddleCenter, false)); + + private static GUIStyle Make(int size, FontStyle fontStyle, TextAnchor anchor, bool wrap) + { + var style = new GUIStyle(GUI.skin.label) + { + fontSize = size, + fontStyle = fontStyle, + alignment = anchor, + wordWrap = wrap, + richText = false, + }; + style.normal.textColor = UITheme.Instance.textPrimary; + return style; + } + + /// + /// 색을 입혀 맞춰 그린다. 스타일이 공유 캐시라 색을 바꾼 채 두면 다음 호출부가 그 색으로 그린다 — 되돌린다. + /// + public static void Label(Rect rect, string text, GUIStyle style, Color color) + { + Color previous = style.normal.textColor; + style.normal.textColor = color; + UIHelper.LabelFit(rect, text, style); + style.normal.textColor = previous; + } + + public static string CategoryLabel(IslandObjectCategory category) + { + switch (category) + { + case IslandObjectCategory.Building: return "건물"; + case IslandObjectCategory.Furniture: return "가구"; + case IslandObjectCategory.Terrain: return "지형지물"; + default: return "도구"; + } + } + + public static Color CategoryColor(IslandObjectCategory category) + { + UITheme t = UITheme.Instance; + switch (category) + { + case IslandObjectCategory.Building: return t.accentCoral; + case IslandObjectCategory.Furniture: return t.accentAmber; + case IslandObjectCategory.Terrain: return t.accentMint; + default: return t.insectRare; + } + } + + /// 물건 효과 한 줄. 효과가 없으면 빈 문자열. + public static string EffectText(IslandObjectDef def) + { + if (def == null) return string.Empty; + switch (def.effect) + { + case IslandEffectKind.YieldBonus: return "생산 +" + Mathf.RoundToInt(def.effectValue * 100f) + "%"; + case IslandEffectKind.CapHours: return "누적 +" + def.effectValue.ToString("0.#") + "시간"; + case IslandEffectKind.BondSpeed: return "친밀도 속도 +" + Mathf.RoundToInt(def.effectValue * 100f) + "%"; + default: return string.Empty; + } + } + + public static string PlaceResultText(IslandPlaceResult result) + { + switch (result) + { + case IslandPlaceResult.Ok: return "놓았습니다"; + case IslandPlaceResult.OutOfBounds: return "섬 밖에는 놓을 수 없습니다"; + case IslandPlaceResult.Reserved: return "나루터 앞은 비워 두어야 합니다"; + case IslandPlaceResult.Overlap: return "다른 물건과 겹칩니다"; + case IslandPlaceResult.NotOwned: return "보관함에 남은 것이 없습니다"; + default: return "놓을 수 없는 물건입니다"; + } + } + + public static string BuyResultText(IslandBuyResult result) + { + switch (result) + { + case IslandBuyResult.Ok: return "구매했습니다"; + case IslandBuyResult.NotEnoughCoins: return "코인이 부족합니다"; + case IslandBuyResult.NotEnoughGems: return "다이아가 부족합니다"; + case IslandBuyResult.Maxed: return "더 넓힐 수 없습니다"; + case IslandBuyResult.Locked: return "섬을 더 넓혀야 살 수 있습니다"; + default: return "살 수 없는 물건입니다"; + } + } + + public static string ReleaseResultText(IslandReleaseResult result) + { + switch (result) + { + case IslandReleaseResult.Ok: return "섬에 풀어놓았습니다"; + case IslandReleaseResult.AlreadyReleased: return "이미 섬에 있습니다"; + case IslandReleaseResult.NoFreeSlot: return "빈 자리가 없습니다 — 상점의 [확장]에서 자리를 늘릴 수 있어요"; + default: return "그 곤충을 찾을 수 없습니다"; + } + } + + // 하트 문자열은 여섯 가지뿐이라 미리 만들어 둔다 — 곤충 목록이 행마다 매 프레임 이어 붙이지 않게. + private static readonly string[] HeartStrings = + { + "♡♡♡♡♡", "♥♡♡♡♡", "♥♥♡♡♡", "♥♥♥♡♡", "♥♥♥♥♡", "♥♥♥♥♥", + }; + + public static string Hearts(int bondLevel) + { + return HeartStrings[Mathf.Clamp(bondLevel, 0, HeartStrings.Length - 1)]; + } + } +} diff --git a/Assets/Scripts/UI/IslandUiKit.cs.meta b/Assets/Scripts/UI/IslandUiKit.cs.meta new file mode 100644 index 0000000..30edb49 --- /dev/null +++ b/Assets/Scripts/UI/IslandUiKit.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 12be75914ba07f34ba9aed43a39d61bc \ No newline at end of file diff --git a/Assets/Scripts/UI/IslandVisitUI.cs b/Assets/Scripts/UI/IslandVisitUI.cs new file mode 100644 index 0000000..376eeb0 --- /dev/null +++ b/Assets/Scripts/UI/IslandVisitUI.cs @@ -0,0 +1,320 @@ +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 섬 나들목 — 내 섬으로 가기/나가기, 내 섬 공개 설정과 섬 코드, 친구·섬 코드로 남의 섬 방문. + /// 탐험 메뉴의 [내 섬], 본 마을 나루터, 섬 HUD의 [방문]이 전부 이 창을 연다. + /// + public class IslandVisitUI : MonoBehaviour, IModalUI + { + private const float FeedbackSeconds = 3f; + private const int CodeLength = 8; + + [SerializeField] private IslandManager island; + [SerializeField] private IslandWorldBuilder world; + [SerializeField] private IslandShareClient share; + [SerializeField] private SocialPvpManager social; + [SerializeField] private IslandHudUI hud; + + private bool isOpen; + private string codeInput = string.Empty; + private Vector2 friendScroll; + private readonly UIDirectScroll directScroll = new UIDirectScroll(); + private string feedback; + private float feedbackUntil; + + // 좋아요·방문 수 문구 — 숫자가 바뀔 때만 다시 잇는다. + private string statsLabel = string.Empty; + private int statsKey = -1; + + public bool IsOpen => isOpen; + + /// 탐험 메뉴의 [내 섬]에 점을 찍을 일이 있는가 — 열렸는데 아직 안 가 봤거나 수확물이 가득 찼다. + public bool NeedsAttention => + island != null && island.IsUnlocked && (!island.HasEnteredOnce || island.IsHarvestFull); + + public void AutoWire(IslandManager islandManager, IslandWorldBuilder worldBuilder, + IslandShareClient shareClient, SocialPvpManager socialPvp, IslandHudUI hudUi) + { + if (island == null) island = islandManager; + if (world == null) world = worldBuilder; + if (share == null) share = shareClient; + if (social == null) social = socialPvp; + if (hud == null) hud = hudUi; + } + + public void Toggle() + { + if (isOpen) { CloseModal(); return; } + if (island == null || world == null) return; + isOpen = true; + friendScroll = Vector2.zero; + feedback = null; + directScroll.Reset(); + ModalUIRegistry.Register(this); + if (share != null) + { + share.ClearError(); + share.RefreshMyInfo(); + } + // 친구 목록은 PVP 창을 한 번도 안 열었으면 비어 있다 — 여기서도 받아 온다(서버가 없으면 조용히 실패한다). + if (social != null && FirebaseConfig.IsSocialPvpConfigured) social.RefreshAll(); + } + + public void CloseModal() + { + isOpen = false; + directScroll.Reset(); + ModalUIRegistry.Unregister(this); + } + + private void OnDisable() => CloseModal(); + + private void OnGUI() + { + if (!isOpen || island == null || world == null) return; + if (!ReferenceEquals(ModalUIRegistry.TopModal, this)) return; + + UIScale.Begin(); + UISurface.Dim(0.6f); + UITheme t = UITheme.Instance; + bool mobile = UIScale.IsMobileLayout; + Rect panel = UISafeLayout.CenteredPanel(mobile ? 1000f : 920f, mobile ? 1500f : 960f); + UISurface.Card(panel); + if (UISurface.Header(new Rect(panel.x + 3f, panel.y + 3f, panel.width - 6f, 84f), "내 섬", string.Empty)) + { + CloseModal(); + UIScale.End(); + return; + } + + float x = panel.x + 20f; + float w = panel.width - 40f; + float y = panel.y + 100f; + + Rect travel = new Rect(x, y, w, 168f); + DrawTravelCard(travel, t); + y = travel.yMax + 12f; + + Rect mine = new Rect(x, y, w, 168f); + DrawMyIslandCard(mine, t); + y = mine.yMax + 12f; + + Rect visit = new Rect(x, y, w, Mathf.Max(1f, panel.yMax - 70f - y)); + DrawVisitCard(visit, t); + + DrawStatus(new Rect(x, panel.yMax - 62f, w, 48f), t); + UIScale.End(); + } + + private void DrawTravelCard(Rect r, UITheme t) + { + UISurface.Card(r, t.surfaceCard, t.surfaceBorder); + bool unlocked = island.IsUnlocked; + string line; + if (world.Mode == IslandMode.Own) line = "지금 내 섬에 있습니다."; + else if (world.Mode == IslandMode.Visit) line = "지금 다른 사람의 섬을 구경하고 있습니다."; + else if (!unlocked) line = "「곤충 수집가」 퀘스트를 마치면 섬이 열립니다."; + else line = "곤충을 풀어놓고 꾸미는 나만의 섬입니다."; + IslandUiKit.Label(new Rect(r.x + 20f, r.y + 10f, r.width - 40f, 44f), line, IslandUiKit.Body, t.textSecondary); + + Rect left = new Rect(r.x + 20f, r.yMax - 88f, (r.width - 52f) * 0.5f, 72f); + Rect right = new Rect(left.xMax + 12f, left.y, left.width, 72f); + Rect full = new Rect(r.x + 20f, r.yMax - 88f, r.width - 40f, 72f); + bool enabled = GUI.enabled; + + switch (world.Mode) + { + case IslandMode.None: + GUI.enabled = enabled && unlocked; + if (UISurface.Button(full, "내 섬으로 가기", t.accentMint, IslandUiKit.Button)) GoToOwnIsland(); + break; + case IslandMode.Own: + if (UISurface.Button(full, "메인 월드로 나가기", t.surfaceRaised, IslandUiKit.Button)) + { + CloseModal(); + world.ExitIsland(); + } + break; + default: + if (UISurface.Button(left, "내 섬으로 돌아가기", t.accentMint, IslandUiKit.Button)) + { + CloseModal(); + if (hud != null) hud.SetVisitInfo(null); + world.ReturnToOwnIsland(); + } + if (UISurface.Button(right, "메인 월드로 나가기", t.surfaceRaised, IslandUiKit.Button)) + { + CloseModal(); + if (hud != null) hud.SetVisitInfo(null); + world.ExitIsland(); + } + break; + } + GUI.enabled = enabled; + } + + private void GoToOwnIsland() + { + if (!world.CanTravel(out string reason)) + { + ShowFeedback(reason); + return; + } + CloseModal(); + world.EnterOwnIsland(); + } + + private void DrawMyIslandCard(Rect r, UITheme t) + { + UISurface.Card(r, t.surfaceCard, t.surfaceBorder); + IslandInfo info = share != null ? share.MyInfo : null; + string code = info != null && !string.IsNullOrEmpty(info.friendCode) ? info.friendCode : "—"; + IslandUiKit.Label(new Rect(r.x + 20f, r.y + 10f, 200f, 40f), "내 섬 코드", IslandUiKit.Body, t.textSecondary); + IslandUiKit.Label(new Rect(r.x + 220f, r.y + 6f, 300f, 48f), code, IslandUiKit.Title, t.accentAmber); + + int likes = info != null ? info.likes : 0; + int visits = info != null ? info.visits : 0; + int key = likes * 100003 + visits; + if (key != statsKey) + { + statsKey = key; + statsLabel = "♥ " + likes + " · 방문 " + visits; + } + IslandUiKit.Label(new Rect(r.xMax - 320f, r.y + 10f, 300f, 40f), statsLabel, IslandUiKit.Body, t.accentCoral); + + // 공개 설정은 서버가 없어도 저장된다(다음에 올릴 때 반영). 그래서 서버 상태와 무관하게 누를 수 있다. + bool isPublic = island.IsPublic; + Rect toggle = new Rect(r.x + 20f, r.yMax - 88f, r.width - 40f, 72f); + if (UISurface.Button(toggle, isPublic ? "공개 중 — 누르면 아무도 들어올 수 없게 닫습니다" + : "비공개 — 누르면 다른 사람이 구경할 수 있게 엽니다", + isPublic ? t.surfaceRaised : t.surfaceBase, IslandUiKit.ButtonSmall)) + { + island.SetPublic(!isPublic); + if (share != null) share.Publish(); + } + } + + private void DrawVisitCard(Rect r, UITheme t) + { + UISurface.Card(r, t.surfaceCard, t.surfaceBorder); + IslandUiKit.Label(new Rect(r.x + 20f, r.y + 8f, r.width - 40f, 40f), "다른 섬 구경하기", IslandUiKit.Title, t.textPrimary); + + bool busy = share != null && share.IsBusy; + const float btnW = 170f; + Rect input = new Rect(r.x + 20f, r.y + 56f, r.width - 40f - btnW - 12f, 60f); + codeInput = GUI.TextField(input, codeInput ?? string.Empty, CodeLength, TextFieldStyle()); + if (string.IsNullOrEmpty(codeInput)) + IslandUiKit.Label(new Rect(input.x + 14f, input.y, input.width - 28f, input.height), "섬 코드 8자리", + IslandUiKit.Body, t.textMuted); + + bool enabled = GUI.enabled; + GUI.enabled = enabled && !busy && !string.IsNullOrWhiteSpace(codeInput); + if (UISurface.Button(new Rect(input.xMax + 12f, input.y, btnW, 60f), "방문", t.accentMint, IslandUiKit.Button)) + VisitByCode(codeInput); + GUI.enabled = enabled; + + Rect listArea = new Rect(r.x + 20f, r.y + 128f, r.width - 40f, Mathf.Max(1f, r.height - 140f)); + PvpProfileSnapshot[] friends = social != null && social.State != null ? social.State.friends : null; + if (friends == null || friends.Length == 0) + { + IslandUiKit.Label(listArea, "친구가 있으면 여기서 바로 섬을 구경할 수 있어요. 친구는 탐험 메뉴의 [PVP]에서 추가합니다.", + IslandUiKit.BodyWrap, t.textMuted); + return; + } + + const float rowH = 76f; + const float gap = 8f; + float contentH = friends.Length * (rowH + gap); + Rect view = new Rect(0f, 0f, listArea.width - 16f, contentH); + directScroll.Handle(ref friendScroll, listArea, contentH, rowH * 0.5f); + friendScroll = GUI.BeginScrollView(listArea, friendScroll, view, GUIStyle.none, GUIStyle.none); + for (int i = 0; i < friends.Length; i++) + { + PvpProfileSnapshot friend = friends[i]; + if (friend == null) continue; + Rect row = new Rect(0f, i * (rowH + gap), view.width, rowH); + UISurface.Rounded(row, t.surfaceRaised); + IslandUiKit.Label(new Rect(row.x + 16f, row.y + 8f, row.width - 32f - btnW - 12f, rowH - 16f), + friend.displayName, IslandUiKit.Body, t.textPrimary); + GUI.enabled = enabled && !busy && !directScroll.IsDragging; + if (UISurface.Button(new Rect(row.xMax - btnW - 8f, row.y + 8f, btnW, rowH - 16f), "섬 구경", t.accentMint, + IslandUiKit.ButtonSmall)) + VisitByUid(friend.uid); + GUI.enabled = enabled; + } + GUI.EndScrollView(); + UISurface.ScrollAffordance(listArea, friendScroll, contentH, t.accentMint); + } + + private static GUIStyle TextFieldStyle() + { + return UIHelper.CachedStyle("island_textfield", () => new GUIStyle(GUI.skin.textField) + { + fontSize = 28, + alignment = TextAnchor.MiddleLeft, + padding = new RectOffset(14, 14, 8, 8), + }); + } + + private void VisitByCode(string code) + { + if (share == null) return; + if (!world.CanTravel(out string reason)) { ShowFeedback(reason); return; } + share.FetchByCode(code, OnFetched); + } + + private void VisitByUid(string uid) + { + if (share == null) return; + if (!world.CanTravel(out string reason)) { ShowFeedback(reason); return; } + share.FetchByUid(uid, OnFetched); + } + + private void OnFetched(IslandInfo info, IslandSnapshot snapshot) + { + // 실패 문구는 share.LastError가 들고 있다(DrawStatus가 그린다). + if (info == null || snapshot == null) return; + + // 내 코드를 넣었다면 구경이 아니라 내 섬이다 — 구경 모드로 들어가면 꾸미기·수확이 다 막힌 내 섬을 보게 된다. + AuthManager auth = AuthManager.Instance; + if (auth != null && !string.IsNullOrEmpty(info.ownerUid) && info.ownerUid == auth.UserId) + { + if (island.IsUnlocked) GoToOwnIsland(); + return; + } + + // 응답을 기다리는 사이에 창을 닫았거나 다른 곳으로 갔을 수 있다. + if (!world.CanTravel(out string reason)) + { + ShowFeedback(reason); + return; + } + if (hud != null) hud.SetVisitInfo(info); + CloseModal(); + world.EnterVisit(snapshot); + } + + private void DrawStatus(Rect r, UITheme t) + { + if (!string.IsNullOrEmpty(feedback) && Time.unscaledTime < feedbackUntil) + { + IslandUiKit.Label(r, feedback, IslandUiKit.BodyCenter, t.accentAmber); + return; + } + if (share == null) return; + if (share.IsBusy) + IslandUiKit.Label(r, "불러오는 중…", IslandUiKit.BodyCenter, t.textSecondary); + else if (!string.IsNullOrEmpty(share.LastError)) + IslandUiKit.Label(r, share.LastError, IslandUiKit.BodyCenter, t.textMuted); + } + + private void ShowFeedback(string message) + { + feedback = message; + feedbackUntil = Time.unscaledTime + FeedbackSeconds; + } + } +} diff --git a/Assets/Scripts/UI/IslandVisitUI.cs.meta b/Assets/Scripts/UI/IslandVisitUI.cs.meta new file mode 100644 index 0000000..532a8df --- /dev/null +++ b/Assets/Scripts/UI/IslandVisitUI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8029f4cde85a64e44bcb00feccc76d4d \ No newline at end of file diff --git a/Assets/Scripts/UI/IslandVisualCapture.cs b/Assets/Scripts/UI/IslandVisualCapture.cs new file mode 100644 index 0000000..60fe6e7 --- /dev/null +++ b/Assets/Scripts/UI/IslandVisualCapture.cs @@ -0,0 +1,393 @@ +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Dex; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace InsectGame.UI +{ + /// + /// 나의 섬 화면의 실제 IMGUI 촬영 fixture(-battleScenario island-ui). + /// + /// 섬 화면은 전부 OnGUI라 배치 캡처에 안 찍힌다(rules/testing.md 「한계 셋」). 스탠드얼론 검수 빌드에서 + /// ScreenCapture로 찍는다. 섬 자체는 진짜 가 짓는다 — 지형·물건·곤충· + /// 꾸미기 미리보기가 실제 경로다. + /// + /// 디스크에 쓰지 않는다. 검수 빌드는 실제 게임과 같은 저장 폴더를 쓴다. 섬 매니저는 + /// 를 끄고, 재화를 건드리는 동작(구매·수확)은 부르지 않는다 — + /// 지갑·캔디 컴포넌트는 차감 즉시 파일에 쓰기 때문이다. + /// + public static class IslandVisualCapture + { + private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + + public static IEnumerator Run(string output, Camera camera) + { + int shots = 0; + var holder = new GameObject("IslandQAData"); + holder.SetActive(false); + + // ── 종·보유 ── + var database = ScriptableObject.CreateInstance(); + InsectData rhino = Species(database, "rhinoceros_beetle", "장수풍뎅이", InsectRarity.Rare); + InsectData stag = Species(database, "stag_beetle", "사슴벌레", InsectRarity.Rare); + InsectData mantis = Species(database, "mantis_green", "사마귀", InsectRarity.Uncommon); + InsectData azure = Species(database, "butterfly_azure", "푸른나비", InsectRarity.Epic); + InsectData dragonfly = Species(database, "dragonfly_lake", "호수 잠자리", InsectRarity.Uncommon); + InsectData beetle = Species(database, "beetle_basic", "들판 딱정벌레", InsectRarity.Common); + InsectData hercules = Species(database, "beetle_hercules", "헤라클레스장수풍뎅이", InsectRarity.Legendary); + + var owned = new List + { + Owned("qa-rhino", rhino, 18, false), + Owned("qa-stag", stag, 16, false), + Owned("qa-mantis", mantis, 14, true), + Owned("qa-azure", azure, 21, false), + Owned("qa-dragonfly", dragonfly, 11, false), + Owned("qa-beetle", beetle, 6, false), + Owned("qa-hercules", hercules, 25, false), + }; + var collection = holder.AddComponent(); + Set(collection, "database", database); + Set(collection, "saveData", new PlayerInsectCollectionSave { insects = owned }); + var lookup = (Dictionary)Get(collection, "lookup"); + foreach (PlayerInsectData p in owned) lookup.Add(p.instanceId, p); + + var candy = holder.AddComponent(); + Set(candy, "data", new PlayerCandyData { candies = 340 }); + var wallet = holder.AddComponent(); + Set(wallet, "data", new PlayerCurrencyData { coins = 860, gems = 120 }); + var regionManager = holder.AddComponent(); + + // ── 섬 세이브(메모리) — 14칸 섬, 물건 14개, 곤충 4마리, 다섯 시간치 수확물 ── + long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var save = new IslandSave + { + sizeLevel = 1, extraSlots = 1, starterGranted = true, guideDone = true, + lastSettleUnix = now - 5 * 3600, harvestCount = 3, + }; + foreach (var (id, x, z, rot) in new[] + { + ("b_cabin", -6, -2, 0), ("t_oak", 3, -1, 0), ("f_bench", -2, -3, 0), ("f_lantern", 1, -3, 0), + ("t_pond", 2, 2, 0), ("f_flowerpot", -3, -5, 0), ("f_flowerpot", 2, -5, 0), + ("t_flowerbed", -3, 0, 0), ("o_feeder", 0, -1, 0), ("f_fence", -6, -4, 0), + ("f_fence", -4, -4, 0), ("t_rock", 5, -4, 0), ("f_campfire", -1, 3, 0), ("b_storage", -6, 3, 0), + }) + save.placed.Add(new IslandPlacedRecord { id = id, x = x, z = z, rot = rot }); + foreach (var (id, count) in new[] + { + ("f_campfire", 2), ("f_table", 1), ("t_bush", 3), ("b_windmill", 1), ("f_sign", 1), + ("f_mailbox", 1), ("o_water", 1), ("t_sapling", 2), + }) + save.owned.Add(new IslandOwnedRecord { id = id, count = count }); + save.released.AddRange(new[] { "qa-rhino", "qa-azure", "qa-mantis", "qa-beetle" }); + save.bonds.Add(new IslandBondRecord { instanceId = "qa-rhino", hours = 40f }); + save.bonds.Add(new IslandBondRecord { instanceId = "qa-azure", hours = 95f }); + save.bonds.Add(new IslandBondRecord { instanceId = "qa-mantis", hours = 7f }); + + var island = holder.AddComponent(); + island.PersistenceEnabled = false; + island.AutoWire(collection, wallet, candy, database); + island.LoadForCapture(save); + + // ── 무대 — 섬 환경 프로필(SubAreaEnvironment의 섬 case)과 같은 값 ── + camera.backgroundColor = new Color(0.56f, 0.80f, 0.96f); + camera.farClipPlane = 400f; + RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat; + RenderSettings.ambientLight = new Color(0.40f, 0.44f, 0.50f); + RenderSettings.fog = true; + RenderSettings.fogMode = FogMode.Exponential; + RenderSettings.fogColor = new Color(0.66f, 0.84f, 0.95f); + RenderSettings.fogDensity = 0.006f; + var sun = new GameObject("IslandQASun").AddComponent(); + sun.type = LightType.Directional; + sun.color = new Color(1f, 0.97f, 0.88f); + sun.intensity = 1.1f; + sun.shadows = LightShadows.Soft; + sun.transform.rotation = Quaternion.Euler(52f, 35f, 0f); + + var player = GameObject.CreatePrimitive(PrimitiveType.Capsule); + player.name = "Player"; + player.GetComponent().material.color = new Color(0.3f, 0.45f, 0.85f); + PlayerMovement movement = player.AddComponent(); + movement.AutoWire(regionManager); + CameraFollower follower = camera.GetComponent(); + follower.SetTarget(player.transform); + + var visual = new GameObject("IslandQAPreview").AddComponent(); + InsectVisual.Renderer = visual; + + var world = new GameObject("IslandQAWorld").AddComponent(); + world.AutoWire(regionManager, island, follower, movement, database); + var share = new GameObject("IslandQAShare").AddComponent(); + share.AutoWire(island); + + var editUi = new GameObject("IslandQAEdit").AddComponent(); + editUi.AutoWire(island, world, null); // 공유 클라이언트를 안 준다 — 닫을 때 서버로 올리지 않게 + var shopUi = new GameObject("IslandQAShop").AddComponent(); + shopUi.AutoWire(island, wallet); + var insectUi = new GameObject("IslandQAInsects").AddComponent(); + insectUi.AutoWire(island, collection); + var guideUi = new GameObject("IslandQAGuide").AddComponent(); + guideUi.AutoWire(island, world, editUi); + var hud = new GameObject("IslandQAHud").AddComponent(); + hud.AutoWire(island, world, movement, share); + var visitUi = new GameObject("IslandQAVisit").AddComponent(); + visitUi.AutoWire(island, world, share, null, hud); + hud.AutoWire(editUi, shopUi, insectUi, visitUi, guideUi); + var quick = new GameObject("IslandQAQuickBar").AddComponent(); + quick.AutoWire(visitUi); + // 좌측 스택 — 진짜 미니맵과, 퀘스트 칩·목표 행이 차지하는 자리의 대역(안내 배너가 여기에 깔렸었다). + var minimap = new GameObject("IslandQAMinimap").AddComponent(); + minimap.AutoWire(regionManager); + var questStandIn = new GameObject("IslandQAQuestStandIn").AddComponent(); + questStandIn.enabled = false; + // 시각·날씨 칩 — 섬에서도 보이고 모바일에서는 섬 HUD 판을 피해 따로 선다(WorldClockRules.IslandMobileChip). + // 밤 22시대·비로 붙잡는다. 칩을 붙이기 전에 걸어 두어 첫 장에 변화 알림이 뜨지 않게 한다(FieldHudVisualCapture와 같다). + var skyHolder = new GameObject("IslandQASky"); + var clock = skyHolder.AddComponent(); + var weather = skyHolder.AddComponent(); + var worldState = skyHolder.AddComponent(); + worldState.AutoWire(clock, weather); + clock.SetTime01(22.2f / 24f, true); + weather.SetWeather(WeatherType.Rain, true, true); + var clockHud = new GameObject("IslandQAClock").AddComponent(); + clockHud.AutoWire(worldState, regionManager, movement); + clockHud.AutoWire(world); + + if (!world.EnterOwnIsland()) + { + Debug.LogError("[IslandQA] 섬에 들어가지 못했다"); + Application.Quit(3); + yield break; + } + // 안내는 뒤에서 따로 찍는다 — 들어올 때 스타터 키트·단계 진행이 돌았으므로 끝난 상태로 되돌린다. + save.guideDone = true; + + yield return Wait(2.0f); + shots++; yield return Capture(output, "01-island-hud"); + + // 수확 토스트(가운데 무대)와 날씨 변화 알림(시각 칩 아래)이 함께 선 모습 — 수확을 부르면 지갑이 디스크에 쓰므로 토스트만 띄운다. + hud.ShowToast("수확! 캔디 +8 · 코인 +4"); + weather.SetWeather(WeatherType.Fog, true, true); + yield return Wait(0.8f); + shots++; yield return Capture(output, "01b-island-toast-notice"); + Set(hud, "toastRemaining", 0f); + yield return Wait(3.2f); // 알림(3초)이 걷힌 뒤 다음 장면 + + // ── 꾸미기 ── + editUi.Open(); + yield return Wait(1.2f); + shots++; yield return Capture(output, "02-edit"); + editUi.CarryForCapture("f_table", 0, 0, 0); + yield return Wait(0.6f); + shots++; yield return Capture(output, "03-edit-carry-ok"); + editUi.CarryForCapture("b_windmill", 2, -2, 0); // 참나무와 겹친다 + yield return Wait(0.6f); + shots++; yield return Capture(output, "04-edit-carry-blocked"); + editUi.PickPlacedForCapture(0); // 오두막을 집었다 — 옮기기·넣기 + yield return Wait(0.6f); + shots++; yield return Capture(output, "05-edit-move-placed"); + editUi.CloseModal(); + yield return Wait(0.8f); + + // ── 상점 ── + shopUi.Toggle(); + yield return Wait(0.8f); + shots++; yield return Capture(output, "06-shop-furniture"); + Set(shopUi, "tab", 0); + yield return Wait(0.5f); + shots++; yield return Capture(output, "07-shop-building"); + Set(shopUi, "tab", 3); + yield return Wait(0.5f); + shots++; yield return Capture(output, "08-shop-tool"); + Set(shopUi, "tab", 4); + yield return Wait(0.5f); + shots++; yield return Capture(output, "09-shop-expand"); + shopUi.CloseModal(); + + // ── 곤충 ── + insectUi.Toggle(); + yield return Wait(1.6f); // 썸네일이 프레임당 하나씩 렌더된다 + shots++; yield return Capture(output, "10-insects"); + insectUi.CloseModal(); + + // ── 방문 창 ── + visitUi.Toggle(); + yield return Wait(0.8f); + shots++; yield return Capture(output, "11-visit-hub"); + visitUi.CloseModal(); + + // ── 안내 — 첫 단계 배너, 꾸미기 화면 위의 배너, 도움말 ── + questStandIn.enabled = true; + save.guideDone = false; + save.guideStep = (int)IslandGuideStep.OpenEdit; + yield return Wait(0.6f); + shots++; yield return Capture(output, "12-guide-first"); + // 시간이 지나면 스스로 사라진다 — 단계는 그대로다(행동으로만 넘어간다). + yield return Wait(IslandGuideUI.CoachSeconds + 0.6f); + shots++; yield return Capture(output, "12b-guide-autohidden"); + bool stillActive = island.GuideActive && island.GuideStep == IslandGuideStep.OpenEdit; + // 다음 단계에 가면 다시 뜬다(꾸미기 화면 위). + save.guideStep = (int)IslandGuideStep.PlaceFirst; + questStandIn.enabled = false; + editUi.Open(); + yield return Wait(1.0f); + shots++; yield return Capture(output, "13-guide-over-edit"); + editUi.CloseModal(); + questStandIn.enabled = true; + save.guideStep = (int)IslandGuideStep.Finish; + yield return Wait(0.8f); + shots++; yield return Capture(output, "14-guide-finish"); + questStandIn.enabled = false; + save.guideDone = true; + guideUi.OpenHelp(); + yield return Wait(0.8f); + shots++; yield return Capture(output, "15-help"); + guideUi.CloseModal(); + + // ── 남의 섬 구경 ── + var snapshot = new IslandSnapshot { ownerName = "하늘", sizeLevel = 0, comfort = 37 }; + foreach (var (id, x, z, rot) in new[] + { + ("b_greenhouse", -4, 0, 0), ("f_fountain", 1, 0, 0), ("t_blossom", -2, -3, 0), + ("f_hammock", 1, -3, 0), ("f_lantern", 4, -4, 0), ("o_honeypot", -5, -3, 0), + }) + snapshot.placed.Add(new IslandPlacedRecord { id = id, x = x, z = z, rot = rot }); + snapshot.insects.Add(new IslandSnapshotInsect { insectId = hercules.insectId, level = 30 }); + snapshot.insects.Add(new IslandSnapshotInsect { insectId = dragonfly.insectId, level = 12, shiny = true }); + snapshot.insects.Add(new IslandSnapshotInsect { insectId = stag.insectId, level = 19 }); + IslandSaveRules.SanitizeSnapshot(snapshot); + hud.SetVisitInfo(new IslandInfo { ownerUid = "qa-owner", ownerName = "하늘", likes = 12, visits = 48 }); + world.EnterVisit(snapshot); + yield return Wait(1.6f); + shots++; yield return Capture(output, "16-visiting"); + + // ── 가장 큰 섬 — 화면 없이 지형만(22칸) ── + hud.SetVisitInfo(null); + world.ReturnToOwnIsland(); + save.sizeLevel = GameConstants.Island.MaxSizeLevel; + island.LoadForCapture(save); + world.ExitIsland(); + yield return Wait(0.3f); + world.EnterOwnIsland(); + save.guideDone = true; + editUi.Open(); // 높은 부감으로 본다 + yield return Wait(1.4f); + shots++; yield return Capture(output, "17-large-island-edit"); + editUi.CloseModal(); + + File.WriteAllText(Path.Combine(output, "README.txt"), + $"Actual standalone IMGUI over the real IslandWorldBuilder. {shots} shots at {Screen.width}x{Screen.height} " + + $"(mobile layout={UIScale.IsMobileLayout}).\n" + + "In-memory island save (IslandManager.PersistenceEnabled=false); wallet/candy on an inactive host and no " + + "purchase/harvest calls, so nothing is written to disk.\n" + + "Own island: size level 1 (14x14), 14 objects, 4 released insects, ~5h accrued. Visit: snapshot fixture.\n" + + "Guide shots: real MinimapUI + a stand-in for the quest chip/objective rows (TutorialQuestUI needs a login session).\n" + + $"Guide banner auto-hide: step kept after {IslandGuideUI.CoachSeconds:0}s = {(stillActive ? "PASS" : "FAIL")}\n"); + Application.Quit(shots > 0 ? 0 : 3); + } + + private static InsectData Species(InsectDatabase db, string id, string name, InsectRarity rarity) + { + var d = ScriptableObject.CreateInstance(); + d.insectId = id; + d.displayName = name; + d.rarity = rarity; + d.description = name + " — 검수용 설명."; + d.baseHp = 120; + d.baseAtk = 34; + d.baseDef = 28; + d.baseSizeMm = 60f; + d.baseWeightG = 12f; + db.insects.Add(d); + return d; + } + + private static PlayerInsectData Owned(string instance, InsectData species, int level, bool shiny) + { + var p = new PlayerInsectData + { + instanceId = instance, + insectId = species.insectId, + level = level, + ivHp = 10, ivAtk = 10, ivDef = 10, + isShiny = shiny, + sizeRoll = 50, + }; + p.currentHp = p.GetTotalHp(species.baseHp); + return p; + } + + private static object Get(object target, string name) + { + FieldInfo f = target.GetType().GetField(name, Private); + if (f == null) throw new MissingFieldException(target.GetType().Name, name); + return f.GetValue(target); + } + + private static void Set(object target, string name, object value) + { + FieldInfo f = target.GetType().GetField(name, Private); + if (f == null) throw new MissingFieldException(target.GetType().Name, name); + f.SetValue(target, value); + } + + private static IEnumerator Wait(float seconds) + { + float until = Time.realtimeSinceStartup + seconds; + while (Time.realtimeSinceStartup < until) yield return null; + } + + private static IEnumerator Capture(string output, string name) + { + yield return new WaitForEndOfFrame(); + Texture2D shot = ScreenCapture.CaptureScreenshotAsTexture(); + File.WriteAllBytes(Path.Combine(output, name + ".png"), shot.EncodeToPNG()); + Object.Destroy(shot); + } + } + + /// + /// 검수 전용 — 퀘스트 칩과 목표 행이 차지하는 자리만 그린다(진짜 는 로그인 세션이 있어야 + /// 칩을 그린다). 좌표는 그쪽과 같은 순수 계산()에서 받는다. + /// 섬 안내 배너가 이 자리와 겹치는지 눈으로 보려는 것이다. + /// + internal class IslandQaQuestChipStandIn : MonoBehaviour + { + private void OnGUI() + { + if (ModalUIRegistry.IsAnyOpen()) return; + UIScale.Begin(); + UITheme t = UITheme.Instance; + bool mobile = UIScale.IsMobileLayout; + const float chipH = 96f; + const float rowH = 64f; + // 진짜 칩과 같은 순수 계산(QuestChipLayout) — 데스크톱은 단축 바 왼쪽 끝까지만, 목표 행 자리를 비우고 바닥에서 위로. + Rect bar = QuickAccessBarUI.ShortcutBarRect; + QuestStackPlace place = QuestChipLayout.PlaceFor(mobile, UIScale.IsPortrait); + float w = QuestChipLayout.Width(place, UIScale.VirtualScreenWidth, UIScale.VirtualSafeLeft, UIScale.VirtualSafeRight, + MinimapUI.LeftX, bar); + Rect chip = QuestChipLayout.Chip(place, QuestChipLayout.Left(place, MinimapUI.LeftX), w, chipH, rowH, + QuestChipLayout.MobileTop(place, UISafeLayout.ContentTop), + QuestChipLayout.DesktopBottom(MinimapUI.LeftX, bar, UISafeLayout.ContentBottom)); + UISurface.HudCard(chip); + IslandUiKit.Label(new Rect(chip.x + 14f, chip.y + 8f, chip.width - 28f, 40f), "퀘스트 칩 자리(검수용)", + IslandUiKit.Body, t.accentAmber); + IslandUiKit.Label(new Rect(chip.x + 14f, chip.y + 50f, chip.width - 28f, 36f), "진행 0 / 3", + IslandUiKit.Small, t.textSecondary); + Rect row = QuestChipLayout.Row(chip, w, rowH); + UISurface.HudCard(row); + IslandUiKit.Label(new Rect(row.x + 14f, row.y + 8f, row.width - 28f, rowH - 16f), "목표 행 자리(검수용)", + IslandUiKit.Small, t.textSecondary); + UIScale.End(); + } + } +} +#endif diff --git a/Assets/Scripts/UI/IslandVisualCapture.cs.meta b/Assets/Scripts/UI/IslandVisualCapture.cs.meta new file mode 100644 index 0000000..9a34843 --- /dev/null +++ b/Assets/Scripts/UI/IslandVisualCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b4b01b1447819b341ad1fec07f9af291 \ No newline at end of file diff --git a/Assets/Scripts/UI/KeyGuideHUD.cs b/Assets/Scripts/UI/KeyGuideHUD.cs index 0fd367d..7db4bbe 100644 --- a/Assets/Scripts/UI/KeyGuideHUD.cs +++ b/Assets/Scripts/UI/KeyGuideHUD.cs @@ -78,7 +78,7 @@ private void OnGUI() // 모달이 열려 있으면 HUD를 숨긴다. depth를 안 거는 전체화면 모달(CollectionUI, // TrainingUI, RegionMapUI 등)과 렌더 순서가 미정의라 패널 위로 튀어나올 수 있다. // UIScale.Begin() 전에 return해야 Begin/End 균형이 유지된다(MinimapUI:52 관례). - if (ModalUIRegistry.IsAnyOpen()) return; + if (ModalUIRegistry.IsAnyOpen() || DreamPrologueState.Active) return; UIScale.Begin(); InitStyles(); @@ -110,7 +110,7 @@ private void DrawKeyGuide() y += lineH + 2; DrawKeyRow(x, ref y, lineH, "WASD", "이동", keyStyle, descStyle); - DrawKeyRow(x, ref y, lineH, "E", inMinigame ? "타이밍 확인" : "포획", keyStyle, descStyle); + DrawKeyRow(x, ref y, lineH, "E", inMinigame ? "미니게임 조작" : "포획", keyStyle, descStyle); if (inMinigame) DrawKeyRow(x, ref y, lineH, "ESC", "포획 취소", keyStyle, descStyle); @@ -148,69 +148,109 @@ private void DrawKeyRow(float x, ref float y, float h, string key, string desc, private void DrawCurrentRegion() { if (regionManager == null) return; + UITheme t = UITheme.Instance; RegionData current = regionManager.CurrentRegion; - string regionName = current != null ? current.displayName : "Wild"; - Color regionCol = current != null ? current.themeColor : new Color(0.5f, 0.5f, 0.5f); + string regionName = current != null ? current.displayName : "야외"; + Color regionCol = current != null ? current.themeColor : t.textSecondary; + // 섬(분리 구역)에 있는 동안은 리전 판정이 얼어 있어 CurrentRegion이 떠나온 리전에 머문다 — + // 그대로 두면 섬 위에서 배너가 "초원"이라고 말한다. + SubAreaData detached = regionManager.CurrentSubArea; + if (detached != null && detached.detached) + { + regionName = detached.displayName; + regionCol = t.accentMint; + } - float w = UIScale.IsMobileLayout ? 430f : 520f; - float h = UIScale.IsMobileLayout ? 64f : 80f; - // 진짜 화면 중앙이 아니라 '세이프 에어리어 중앙'으로 — 가로 비대칭 노치 보정. - float safeL = UIScale.VirtualSafeLeft; - float safeR = UIScale.VirtualSafeRight; - float x = safeL + (UIScale.VirtualScreenWidth - safeL - safeR - w) / 2f; - float y = UISafeLayout.ContentTop; + bool mobile = UIScale.IsMobileLayout; + HudFrame frame = HudFrame.Current; + Rect banner = RegionBannerRect(frame); + // 상태 패널을 펼치면(그 안에 리전 칸이 있다) 겹치는 배너는 비켜선다 — 세로 화면에서 패널(폭 480)이 배너 자리를 덮는다. + if (MinimapUI.LeftStackOccluded && banner.Overlaps(PlayerStatusHUD.PanelRect(frame))) return; - GUI.color = new Color(0, 0, 0, 0.6f); - GUI.DrawTexture(new Rect(x, y, w, h), Texture2D.whiteTexture); - GUI.color = regionCol; - GUI.DrawTexture(new Rect(x, y + h - 5, w, 5), Texture2D.whiteTexture); + // 미니맵·상태 패널과 같은 HUD 카드 + 리전 색 밑줄(얇아서 각진 채, 반경만큼 물린다). + UISurface.HudCard(banner); + UISurface.Flat(new Rect(banner.x + UITheme.Radius.Card, banner.yMax - 9f, + banner.width - UITheme.Radius.Card * 2f, 4f), regionCol); - hintStyle.fontSize = UIScale.IsMobileLayout ? 32 : 44; + hintStyle.fontSize = mobile ? 32 : 40; hintStyle.alignment = TextAnchor.MiddleCenter; - hintStyle.normal.textColor = regionCol; + hintStyle.normal.textColor = Color.Lerp(regionCol, t.textPrimary, 0.25f); GUI.color = Color.white; - GUI.Label(new Rect(x, y, w, h), regionName, hintStyle); + UIHelper.LabelFit(new Rect(banner.x + 16f, banner.y, banner.width - 32f, banner.height - 8f), regionName, hintStyle); + } + + public const float CaptureItemsWidth = 440f; + public const float CaptureItemsHeight = 226f; + + /// + /// 위쪽 가운데 리전 배너의 자리 — 순수 계산. 진짜 화면 중앙이 아니라 '세이프 에어리어 중앙'(가로 비대칭 노치 보정). + /// 모바일은 좌상단 상태 탭과 우상단 단축 바 사이에 둔다 — 화면 중앙에 두면 세로 화면에서 단축 바(폭 324) 밑으로 20px 넘게 + /// 파고들었다(2026-09-30 검수 캡처). + /// + public static Rect RegionBannerRect(HudFrame f) + { + if (!f.Mobile) + { + float l = f.SafeLeft; + float r = f.Width - f.SafeRight; + return new Rect(l + (r - l - 520f) * 0.5f, f.ContentTop, 520f, 80f); + } + float left = PlayerStatusHUD.TabRect(f).xMax + UITheme.Space.S; + float right = QuickAccessBarUI.ShortcutBarRectFor(f).x - UITheme.Space.S; + float w = Mathf.Min(430f, Mathf.Max(1f, right - left)); + return new Rect(left + (right - left - w) * 0.5f, f.ContentTop, w, 68f); } + /// 우상단 포획 아이템 패널(데스크톱 전용)의 자리 — 순수 계산. + public static Rect CaptureItemsRectFor(HudFrame f) + { + return new Rect(f.Width - f.SafeRight - CaptureItemsWidth - 20f, f.ContentTop, CaptureItemsWidth, CaptureItemsHeight); + } + + /// + /// 우상단 포획 아이템 패널의 자리(가상 좌표, 데스크톱 전용). 그 아래에 붙는 HUD()가 + /// 읽어 한 열로 선다 — 크기를 베껴 두면 패널을 키울 때 조용히 겹친다(와 같은 이유). + /// + public static Rect CaptureItemsRect => CaptureItemsRectFor(HudFrame.Current); + private void DrawCaptureItems() { if (itemInventory == null) return; + UITheme t = UITheme.Instance; - float w = 440f; - float h = 220f; - float x = UIScale.VirtualScreenWidth - w - 20; - float y = UISafeLayout.ContentTop; + Rect panel = CaptureItemsRect; + float w = panel.width; - GUI.color = new Color(0, 0, 0, 0.6f); - GUI.DrawTexture(new Rect(x, y, w, h), Texture2D.whiteTexture); - GUI.color = new Color(0.3f, 0.8f, 0.4f); - GUI.DrawTexture(new Rect(x, y, w, 4), Texture2D.whiteTexture); - GUI.color = Color.white; + UISurface.HudCard(panel); + UISurface.Flat(new Rect(panel.x + UITheme.Radius.Card, panel.y + 3f, + panel.width - UITheme.Radius.Card * 2f, 3f), t.accentMint); - titleStyle.normal.textColor = new Color(0.75f, 0.75f, 0.75f); - GUI.Label(new Rect(x + 16, y + 10, w, 40), "포획 아이템", titleStyle); + titleStyle.normal.textColor = t.textSecondary; + GUI.color = Color.white; + GUI.Label(new Rect(panel.x + 20f, panel.y + 10f, w - 40f, 44f), "포획 아이템", titleStyle); - float iy = y + 56; - DrawItemCount(x + 16, iy, "기본 채집망", itemInventory.GetCount("net_basic"), new Color(0.65f, 0.65f, 0.65f)); - DrawItemCount(x + 16, iy + 50, "실버 채집망", itemInventory.GetCount("net_silver"), new Color(0.75f, 0.82f, 0.95f)); - DrawItemCount(x + 16, iy + 100, "골드 채집망", itemInventory.GetCount("net_gold"), new Color(1f, 0.85f, 0.2f)); + // 이름은 아이템 DB·상점·포획 창과 같은 "은빛/황금"이다 — 여기만 "실버/골드"라 같은 그물이 둘로 보였다. + float iy = panel.y + 60f; + DrawItemCount(panel.x + 20f, iy, w - 40f, "기본 채집망", itemInventory.GetCount("net_basic"), t.itemCommon); + DrawItemCount(panel.x + 20f, iy + 52f, w - 40f, "은빛 채집망", itemInventory.GetCount("net_silver"), t.itemRare); + DrawItemCount(panel.x + 20f, iy + 104f, w - 40f, "황금 채집망", itemInventory.GetCount("net_gold"), t.itemLegendary); } - private void DrawItemCount(float x, float y, string label, int count, Color col) + private void DrawItemCount(float x, float y, float w, string label, int count, Color col) { - GUI.color = col; - GUI.DrawTexture(new Rect(x, y + 10, 22, 22), Texture2D.whiteTexture); - GUI.color = Color.white; + UITheme t = UITheme.Instance; + bool has = count > 0; + UISurface.Rounded(new Rect(x, y + 10f, 24f, 24f), has ? col : t.surfaceRaised, 6f); - itemNameStyle.normal.textColor = col; - GUI.Label(new Rect(x + 32, y, 220, 42), label, itemNameStyle); + itemNameStyle.normal.textColor = has ? t.textPrimary : t.textMuted; + GUI.Label(new Rect(x + 36f, y, w - 150f, 44f), label, itemNameStyle); itemCountStyle.fontStyle = FontStyle.Bold; itemCountStyle.fontSize = 32; itemCountStyle.alignment = TextAnchor.MiddleRight; - itemCountStyle.normal.textColor = count > 0 ? new Color(1f, 0.92f, 0.5f) : new Color(0.4f, 0.3f, 0.3f); - GUI.Label(new Rect(x + 260, y, 120, 42), $"x{count}", itemCountStyle); + itemCountStyle.normal.textColor = has ? t.accentAmber : t.textMuted; + GUI.Label(new Rect(x + w - 120f, y, 120f, 44f), $"×{count}", itemCountStyle); } public void AutoWire(CaptureMinigameController mg, InsectBattleController bc, InsectBattleUIController bui) diff --git a/Assets/Scripts/UI/KoreanJosa.cs b/Assets/Scripts/UI/KoreanJosa.cs new file mode 100644 index 0000000..bd58962 --- /dev/null +++ b/Assets/Scripts/UI/KoreanJosa.cs @@ -0,0 +1,102 @@ +namespace InsectGame.UI +{ + /// + /// 한국어 조사 고르기 — 순수 계산. 이름 끝 글자의 받침으로 이/가·을/를·은/는·와/과를 고른다. + /// + /// 화면 문구의 이름은 데이터가 정한다(상대 이름·곤충 이름). 조사를 한쪽으로 고정하면 절반은 틀리고, "이(가)"로 두면 + /// 아이가 읽다가 걸린다 — 전투 화면의 「집게가 지네를 내보냈다!」가 처음 쓰는 자리다. + /// + /// 끝의 닫는 괄호·따옴표·문장부호·공백은 건너뛰고 그 앞 글자를 본다("사마귀(이로치)" → 치). 숫자는 읽는 소리로 본다 + /// (1 일·3 삼·6 육·7 칠·8 팔·0 영은 받침, 2 이·4 사·5 오·9 구는 없음). 한글도 숫자도 아니면(영문 등) 모른다 — + /// 그때는 "이(가)"처럼 둘 다 적는다(지어내지 않는다). + /// + public static class KoreanJosa + { + private const int HangulFirst = 0xAC00; + private const int HangulLast = 0xD7A3; + private const int FinalCount = 28; + /// 종성 ㄹ의 번호(가 + 8). + private const int FinalRieul = 8; + + /// 끝 글자에 받침이 있으면 true, 없으면 false, 모르면 null. + public static bool? HasFinalConsonant(string word) + { + int final = FinalIndex(word); + if (final == Unknown) return null; + return final > 0; + } + + /// 이/가 — "집게가", "관장 하월이". + public static string IGa(string word) => Pick(word, "이", "가", "이(가)"); + + /// 을/를 — "지네를", "사슴벌레를", "장수풍뎅이를". + public static string EulReul(string word) => Pick(word, "을", "를", "을(를)"); + + /// 은/는. + public static string EunNeun(string word) => Pick(word, "은", "는", "은(는)"); + + /// 와/과 — 받침이 있으면 "과". + public static string WaGwa(string word) => Pick(word, "과", "와", "와(과)"); + + /// 단어 + 조사. 는 위 함수 중 하나. + public static string With(string word, System.Func josa) + { + string w = word ?? string.Empty; + return josa != null ? w + josa(w) : w; + } + + private const int Unknown = -1; + + private static string Pick(string word, string withFinal, string withoutFinal, string unknown) + { + int final = FinalIndex(word); + if (final == Unknown) return unknown; + return final > 0 ? withFinal : withoutFinal; + } + + /// 끝 글자의 종성 번호(0 = 받침 없음, 1~27), 모르면 . + private static int FinalIndex(string word) + { + if (string.IsNullOrEmpty(word)) return Unknown; + for (int i = word.Length - 1; i >= 0; i--) + { + char c = word[i]; + if (IsSkippable(c)) continue; + if (c >= HangulFirst && c <= HangulLast) return (c - HangulFirst) % FinalCount; + if (c >= '0' && c <= '9') return DigitFinal(c); + return Unknown; + } + return Unknown; + } + + // 숫자를 한자어 소리로 읽을 때의 받침(0 영 ㅇ, 1 일 ㄹ, 3 삼 ㅁ, 6 육 ㄱ, 7 칠 ㄹ, 8 팔 ㄹ). + private static int DigitFinal(char digit) + { + switch (digit) + { + case '0': return 21; // ㅇ + case '1': return FinalRieul; // ㄹ + case '3': return 16; // ㅁ + case '6': return 1; // ㄱ + case '7': return FinalRieul; + case '8': return FinalRieul; + default: return 0; // 2 이 · 4 사 · 5 오 · 9 구 + } + } + + private static bool IsSkippable(char c) + { + switch (c) + { + case ' ': case '\t': case '\n': + case ')': case ']': case '}': case '>': + case '」': case '』': case '》': case '〉': + case '"': case '\'': case '”': case '’': + case '!': case '?': case '.': case ',': case '~': case '…': + return true; + default: + return false; + } + } + } +} diff --git a/Assets/Scripts/UI/KoreanJosa.cs.meta b/Assets/Scripts/UI/KoreanJosa.cs.meta new file mode 100644 index 0000000..3f983d5 --- /dev/null +++ b/Assets/Scripts/UI/KoreanJosa.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: eb840765d96b95d4a8080f813a48a3e9 \ No newline at end of file diff --git a/Assets/Scripts/UI/LoginUI.cs b/Assets/Scripts/UI/LoginUI.cs index bf77d4a..1d59e58 100644 --- a/Assets/Scripts/UI/LoginUI.cs +++ b/Assets/Scripts/UI/LoginUI.cs @@ -39,7 +39,8 @@ internal enum CreateStep { Preset, Customize, Starter } // 캐릭터 생성 private CreateStep createStep = CreateStep.Preset; private int selectedStarter; - private string characterName = "탐험가"; + private const string DefaultCharacterName = "탐험가"; + private string characterName = DefaultCharacterName; private int selectedSkinColor; // 0~3 private int selectedHairStyle; // 0~3 private int selectedOutfit; // 0~2 @@ -704,10 +705,12 @@ internal static float PreviewHeightFor(CreateStep step, bool mobile, float panel /// 세부 조정 오른쪽 열의 5행 높이 — 이 값이 그 단계의 세로를 정한다. internal static float CustomizeRowsHeight(bool mobile) { - float rowH = mobile ? 58f : 45f; - return 5f * (rowH + 12f); + return 5f * (CustomizeRowH(mobile) + 12f); } + /// 세부 조정 한 행의 높이. 데스크톱 45 → 50(2026-09-30) — 색 견본을 누르기 좋게. 짧은 화면 검사는 여전히 통과한다. + internal static float CustomizeRowH(bool mobile) => mobile ? 58f : 50f; + /// /// 이 단계가 실제로 차지하는 총 세로. 패널 높이를 넘으면 하단 버튼이 밖으로 밀린다. /// @@ -729,8 +732,10 @@ internal static float TotalContentHeight(CreateStep step, bool mobile, float pan /// 스타터 곤충 카드 높이. 이름 + 설명 두 줄이 들어간다. internal const float StarterCardHeight = 210f; - private const float PreviewH = 300f; - private const float PreviewW = 200f; + // 프리뷰 상한 300×200 → 400×266(2026-09-30). 고른 얼굴·머리가 알아볼 크기로 보이게. + // 하한(MinPreviewH)과 짧은 화면 검사는 그대로라 버튼이 밀리지 않는다(CharacterCreateFlowTests). + private const float PreviewH = 400f; + private const float PreviewW = 266f; /// 프리뷰 최소 높이. 이보다 작으면 3D를 보여주는 의미가 없다. internal const float MinPreviewH = 96f; @@ -784,8 +789,9 @@ private void DrawPresetStep(float px, float py, float pw, float ph) DrawLivePreview(px + pw * 0.5f - previewW * 0.5f, cy, previewW, previewH); cy += previewH + 24f; - GUI.Label(new Rect(cx, cy, 75f, 30f), "이름:", labelStyle); // 오른쪽 필드가 cx+82f — 넓히면 겹친다 - characterName = GUI.TextField(new Rect(cx + 82f, cy, fieldW - 82f, 35f), characterName, 12, fieldStyle); + GUI.Label(new Rect(cx, cy + 7f, 75f, 30f), "이름:", labelStyle); // 오른쪽 필드가 cx+82f — 넓히면 겹친다 + // 35 → 44: 25pt 글자가 35px 칸에서 아래가 잘렸다(검수 빌드 캡처). 행 간격 50 안이라 아래가 밀리지 않는다. + characterName = GUI.TextField(new Rect(cx + 82f, cy, fieldW - 82f, 44f), characterName, 12, fieldStyle); cy += 50f; // 프리셋은 개수가 5개라 한 줄에 넣으면 라벨이 잘린다 — 두 줄로 나눈다. @@ -818,7 +824,7 @@ private float DrawPresetRow(float x, float y, float totalW, string[] names, int int index = from + i; float bx = x + i * (btnW + 8f); GUIStyle style = (index == selectedOutfit) ? radioSelectedStyle : radioStyle; - if (GUI.Button(new Rect(bx, y, btnW, h), names[index], style)) + if (FitButton(new Rect(bx, y, btnW, h), names[index], style)) { ApplyPreset(index); } @@ -866,23 +872,24 @@ private void DrawCustomizeStep(float px, float py, float pw, float ph) float colX = cx + previewW + 24f; float colW = fieldW - previewW - 24f; float labelW = 96f; - float rowH = mobile ? 58f : 45f; + float rowH = CustomizeRowH(mobile); float ry = cy; selectedGender = DrawLabeledRadio(colX, ry, colW, labelW, rowH, "성별", GenderLabels, selectedGender); ry += rowH + 12f; - selectedSkinColor = DrawLabeledRadio(colX, ry, colW, labelW, rowH, "피부색", - SkinLabels, selectedSkinColor); + // 색은 글자가 아니라 **색 견본**으로 고른다 — "어두운/진한", "보라/파랑" 글자만으로는 결과를 짐작하기 어려웠다. + selectedSkinColor = DrawLabeledSwatches(colX, ry, colW, labelW, rowH, "피부색", + InsectGame.Core.CharacterPalette.SkinCount, true, selectedSkinColor); ry += rowH + 12f; selectedHairStyle = DrawLabeledRadio(colX, ry, colW, labelW, rowH, "머리", HairStyleLabels, selectedHairStyle); ry += rowH + 12f; - selectedHairColor = DrawLabeledRadio(colX, ry, colW, labelW, rowH, "머리색", - HairColorLabels, selectedHairColor); + selectedHairColor = DrawLabeledSwatches(colX, ry, colW, labelW, rowH, "머리색", + InsectGame.Core.CharacterPalette.HairCount, false, selectedHairColor); ry += rowH + 12f; selectedFaceType = DrawLabeledRadio(colX, ry, colW, labelW, rowH, "표정", @@ -959,11 +966,34 @@ private void DrawStarterStep(float px, float py, float pw, float ph) // 라디오 라벨은 static으로 둔다 — OnGUI에서 매 프레임 배열을 새로 만들지 않기 위해서다. private static readonly string[] GenderLabels = { "남자", "여자" }; - private static readonly string[] SkinLabels = { "밝은", "보통", "어두운", "진한" }; private static readonly string[] HairStyleLabels = { "짧은", "중간", "긴", "올림" }; - private static readonly string[] HairColorLabels = { "검정", "갈색", "금발", "빨강", "보라", "파랑" }; private static readonly string[] FaceLabels = { "미소", "활짝", "차분", "무표정" }; + /// + /// 라벨 + 색 견본 한 줄. 견본은 팔레트(CharacterPalette)에서 바로 칠한다 — 3D 캐릭터가 쓰는 바로 그 색이다. + /// 고른 것은 바깥 테두리(호박색)로 표시한다. + /// + private int DrawLabeledSwatches(float x, float y, float totalW, float labelW, float rowH, + string label, int count, bool skin, int selected) + { + GUI.Label(new Rect(x, y + (rowH - 30f) * 0.5f, labelW, 30f), label, sectionLabelStyle); + if (count <= 0) return selected; + float areaW = totalW - labelW; + float gap = 10f; + float size = Mathf.Min(rowH, (areaW - gap * (count - 1)) / count); + UITheme theme = UITheme.Instance; + for (int i = 0; i < count; i++) + { + Rect r = new Rect(x + labelW + i * (size + gap), y + (rowH - size) * 0.5f, size, size); + Color c = skin ? InsectGame.Core.CharacterPalette.Skin(i) : InsectGame.Core.CharacterPalette.Hair(i); + if (i == selected) + UISurface.Rounded(new Rect(r.x - 4f, r.y - 4f, r.width + 8f, r.height + 8f), theme.accentAmber); + UISurface.Rounded(r, c); + if (GUI.Button(r, GUIContent.none, GUIStyle.none)) selected = i; + } + return selected; + } + /// 라벨 + 라디오를 한 줄에. 세로로 나누면 행마다 35px가 더 든다. private int DrawLabeledRadio(float x, float y, float totalW, float labelW, float rowH, string label, string[] options, int selected) @@ -1048,11 +1078,34 @@ private void HandlePreviewDrag(Rect box) } else if (e.type == EventType.MouseDrag && draggingPreview) { - previewYaw += e.delta.x * 0.5f; + // 오른쪽으로 끌면 캐릭터 앞면이 오른쪽으로 돈다 — 의상 창(CharacterOutfitUI)과 같은 방향. + // 예전엔 두 화면이 반대로 돌았다. + previewYaw -= e.delta.x * 0.5f; e.Use(); } } + private static readonly GUIContent fitProbe = new GUIContent(); + + /// + /// 버튼 라벨이 폭을 넘으면 글자를 줄여 그린다(끝나면 원래 크기로). 세로 화면에서 프리셋 버튼 셋이 한 줄에 + /// 서면 "초원의 탐험가"가 "초원의 탐험기"처럼 잘려 보였다(검수 빌드 720×1280 캡처). + /// + private static bool FitButton(Rect r, string text, GUIStyle style) + { + int original = style.fontSize; + fitProbe.text = text; + int size = original; + while (size > 16 && style.CalcSize(fitProbe).x > r.width - 8f) + { + size -= 2; + style.fontSize = size; + } + bool clicked = GUI.Button(r, text, style); + style.fontSize = original; + return clicked; + } + /// 0이면 기존 기본 높이를 쓴다(다른 화면의 호출부가 그대로 돌게). private int DrawRadioRow(float x, float y, float totalW, string[] labels, int selected, float rowH = 0f) { @@ -1062,7 +1115,7 @@ private int DrawRadioRow(float x, float y, float totalW, string[] labels, int se { float bx = x + i * (btnW + 8f); GUIStyle style = (i == selected) ? radioSelectedStyle : radioStyle; - if (GUI.Button(new Rect(bx, y, btnW, h), labels[i], style)) + if (FitButton(new Rect(bx, y, btnW, h), labels[i], style)) { selected = i; } @@ -1225,8 +1278,19 @@ private void EnterCharacterCreate() // ── 캐릭터 데이터 저장 ── + /// + /// 저장할 이름. 입력란을 비우거나 공백만 넣고 진행하면 **빈 이름이 저장됐다** — HUD·대사창이 + /// 이름 자리를 빈칸으로 그리고, 클라우드 복원은 빈 값을 "없음"으로 보고 건너뛰어 기기마다 달라진다. + /// + internal static string SanitizeCharacterName(string name) + { + string trimmed = name != null ? name.Trim() : ""; + return trimmed.Length > 0 ? trimmed : DefaultCharacterName; + } + private void SaveCharacterCreation() { + characterName = SanitizeCharacterName(characterName); PlayerPrefs.SetString(CharNameKey, characterName); PlayerPrefs.SetInt(CharSkinKey, selectedSkinColor); PlayerPrefs.SetInt(CharHairKey, selectedHairStyle); @@ -1299,16 +1363,16 @@ private void InitStyles() appIconTexture = Resources.Load(AppIconResourcePath); backgroundTexture = MakeGradientTex(2, 128, - new Color(0.035f, 0.055f, 0.16f, 1f), - new Color(0.025f, 0.15f, 0.095f, 1f)); + UITheme.Instance.surfaceBase, + UITheme.Instance.surfaceCard); backgroundGlowTexture = MakeRadialTex(64, new Color(0.36f, 0.95f, 0.55f, 0.72f), Color.clear); // 짙은 유리 질감 + 이중 테두리. GUIStyle.border로 모서리를 늘리지 않고 유지합니다. Texture2D panelTex = MakePanelTex(64, 11f, - new Color(0.075f, 0.115f, 0.17f, 0.97f), - new Color(0.035f, 0.07f, 0.09f, 0.97f), - new Color(0.32f, 0.82f, 0.5f, 0.92f)); + UITheme.Instance.surfaceRaised, + UITheme.Instance.surfaceCard, + UITheme.Instance.surfaceBorder); panelStyle = new GUIStyle(GUI.skin.box); panelStyle.normal.background = panelTex; panelStyle.border = new RectOffset(14, 14, 14, 14); @@ -1336,7 +1400,7 @@ private void InitStyles() titleStyle = new GUIStyle(GUI.skin.label); titleStyle.fontSize = 70; titleStyle.fontStyle = FontStyle.Bold; - titleStyle.normal.textColor = new Color(1f, 0.84f, 0f, 1f); + titleStyle.normal.textColor = UITheme.Instance.accentAmber; titleStyle.alignment = TextAnchor.MiddleCenter; // 서브타이틀 @@ -1349,30 +1413,30 @@ private void InitStyles() taglineStyle = new GUIStyle(GUI.skin.label); taglineStyle.fontSize = 29; taglineStyle.fontStyle = FontStyle.Normal; - taglineStyle.normal.textColor = new Color(0.68f, 0.82f, 0.78f, 1f); + taglineStyle.normal.textColor = UITheme.Instance.textSecondary; taglineStyle.alignment = TextAnchor.MiddleCenter; brandEyebrowStyle = new GUIStyle(GUI.skin.label); brandEyebrowStyle.fontSize = 18; brandEyebrowStyle.fontStyle = FontStyle.Bold; - brandEyebrowStyle.normal.textColor = new Color(0.44f, 0.98f, 0.6f, 1f); + brandEyebrowStyle.normal.textColor = UITheme.Instance.accentMint; brandEyebrowStyle.alignment = TextAnchor.MiddleLeft; helperStyle = new GUIStyle(GUI.skin.label); helperStyle.fontSize = 21; - helperStyle.normal.textColor = new Color(0.58f, 0.7f, 0.72f, 1f); + helperStyle.normal.textColor = UITheme.Instance.textSecondary; helperStyle.alignment = TextAnchor.MiddleCenter; versionStyle = new GUIStyle(GUI.skin.label); versionStyle.fontSize = 18; - versionStyle.normal.textColor = new Color(0.42f, 0.56f, 0.58f, 1f); + versionStyle.normal.textColor = UITheme.Instance.textMuted; versionStyle.alignment = TextAnchor.MiddleCenter; linkStyle = new GUIStyle(GUI.skin.label); linkStyle.fontSize = 20; - linkStyle.normal.textColor = new Color(0.65f, 0.8f, 0.82f, 1f); + linkStyle.normal.textColor = UITheme.Instance.textSecondary; linkStyle.hover.textColor = Color.white; - linkStyle.active.textColor = new Color(0.44f, 0.98f, 0.6f, 1f); + linkStyle.active.textColor = UITheme.Instance.accentMint; linkStyle.alignment = TextAnchor.MiddleRight; // 입력 필드 @@ -1390,12 +1454,12 @@ private void InitStyles() // 라벨 labelStyle = new GUIStyle(GUI.skin.label); labelStyle.fontSize = 28; - labelStyle.normal.textColor = new Color(0.85f, 0.85f, 0.9f, 1f); + labelStyle.normal.textColor = UITheme.Instance.textPrimary; // 에러 errorStyle = new GUIStyle(GUI.skin.label); errorStyle.fontSize = 25; - errorStyle.normal.textColor = new Color(1f, 0.3f, 0.3f, 1f); + errorStyle.normal.textColor = UITheme.Instance.accentCoral; errorStyle.alignment = TextAnchor.MiddleCenter; errorStyle.wordWrap = true; @@ -1416,10 +1480,10 @@ GUIStyle BaseBtnStyle(Color bgColor) } // 녹색 (로그인) - btnGreenStyle = BaseBtnStyle(new Color(0.15f, 0.55f, 0.15f, 1f)); + btnGreenStyle = BaseBtnStyle(UITheme.Instance.accentMint); // 파란색 (회원가입) - btnBlueStyle = BaseBtnStyle(new Color(0.2f, 0.35f, 0.7f, 1f)); + btnBlueStyle = BaseBtnStyle(UITheme.Instance.surfaceRaised); btnBlueStyle.fontSize = 30; // Google 브랜드에 맞춘 밝은 단일 소셜 버튼 @@ -1431,21 +1495,21 @@ GUIStyle BaseBtnStyle(Color bgColor) btnYellowStyle.active.textColor = googleText; // 회색 (게스트) - btnGrayStyle = BaseBtnStyle(new Color(0.35f, 0.35f, 0.38f, 1f)); + btnGrayStyle = BaseBtnStyle(UITheme.Instance.surfaceRaised); btnGrayStyle.fontSize = 28; // 구분선 separatorStyle = new GUIStyle(GUI.skin.label); separatorStyle.fontSize = 25; - separatorStyle.normal.textColor = new Color(0.5f, 0.5f, 0.55f, 1f); + separatorStyle.normal.textColor = UITheme.Instance.textMuted; separatorStyle.alignment = TextAnchor.MiddleCenter; // 라디오 버튼 - radioStyle = BaseBtnStyle(new Color(0.25f, 0.25f, 0.3f, 1f)); + radioStyle = BaseBtnStyle(UITheme.Instance.surfaceCard); radioStyle.fontSize = 25; radioStyle.fontStyle = FontStyle.Normal; - radioSelectedStyle = BaseBtnStyle(new Color(0.2f, 0.5f, 0.8f, 1f)); + radioSelectedStyle = BaseBtnStyle(UITheme.Instance.accentCoral); radioSelectedStyle.fontSize = 25; radioSelectedStyle.fontStyle = FontStyle.Bold; @@ -1453,7 +1517,7 @@ GUIStyle BaseBtnStyle(Color bgColor) sectionLabelStyle = new GUIStyle(GUI.skin.label); sectionLabelStyle.fontSize = 28; sectionLabelStyle.fontStyle = FontStyle.Bold; - sectionLabelStyle.normal.textColor = new Color(0.9f, 0.85f, 0.6f, 1f); + sectionLabelStyle.normal.textColor = UITheme.Instance.textPrimary; for (int i = 0; i < loadingDotTextures.Length; i++) { diff --git a/Assets/Scripts/UI/MinimapUI.cs b/Assets/Scripts/UI/MinimapUI.cs index 0eab40d..c339b74 100644 --- a/Assets/Scripts/UI/MinimapUI.cs +++ b/Assets/Scripts/UI/MinimapUI.cs @@ -1,4 +1,4 @@ -using InsectGame.Spawning; +using InsectGame.Spawning; using UnityEngine; namespace InsectGame.UI @@ -28,6 +28,12 @@ public class MinimapUI : MonoBehaviour /// 미니맵 좌변 x. 아래에 붙는 HUD가 좌변을 맞추는 데 쓴다. public static float LeftX => UIScale.VirtualSafeLeft + 16f; + /// 미니맵 판의 자리 — 순수 계산(전수 겹침 검사가 부른다). + public static Rect PanelRect(HudFrame f) + { + return new Rect(f.SafeLeft + 16f, f.ContentTop + TopOffset, PanelSize, PanelSize); + } + /// /// 좌측 스택(미니맵·퀘스트 칩·목표 행)이 지금 가려져 있는가. /// 의 펼침 패널이 이 영역을 통째로 덮으므로, 덮였으면 @@ -41,6 +47,17 @@ public class MinimapUI : MonoBehaviour [SerializeField] private float worldRadius = 45f; // 미니맵이 커버하는 월드 반경(m) + private InsectGame.Core.RegionManager regionManager; + private InsectGame.NPC.NpcManager npcManager; + public void AutoWire(InsectGame.Core.RegionManager manager) + { + if (regionManager == null) regionManager = manager; + } + public void AutoWire(InsectGame.NPC.NpcManager manager) + { + if (npcManager == null) npcManager = manager; + } + private Transform player; private InsectEntity[] insects; private float refreshTimer; @@ -49,7 +66,9 @@ public class MinimapUI : MonoBehaviour private InsectGame.Story.StoryObjectiveTracker objectiveTracker; private GUIStyle labelStyle; + private GUIStyle legendStyle; private GUIStyle wedgeStyle; + private GUIStyle taleMarkStyle; // 의뢰 주민 원 안의 !/? private Texture2D dotTex; private bool ready; @@ -75,10 +94,14 @@ private void EnsureAssets() ready = true; labelStyle = new GUIStyle(GUI.skin.label) { fontSize = 16, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - labelStyle.normal.textColor = new Color(0.7f, 0.85f, 1f); + labelStyle.normal.textColor = UITheme.Instance.textSecondary; + legendStyle = new GUIStyle(labelStyle) { alignment = TextAnchor.MiddleLeft }; wedgeStyle = new GUIStyle(GUI.skin.label) { fontSize = 22, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; wedgeStyle.normal.textColor = UITheme.Instance.accentAmber; + taleMarkStyle = new GUIStyle(GUI.skin.label) + { fontSize = 15, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + taleMarkStyle.normal.textColor = UITheme.Instance.surfaceBase; // 소프트 디스크는 UIShapes가 소유한다 — 여기 있던 MakeDisc는 하드 엣지라 // 확대 시 계단이 보였다(RegionMapUI 사본은 소프트였다). 공용판으로 통일. dotTex = UIShapes.Disc; @@ -103,17 +126,17 @@ private void OnGUI() { if (player == null) return; // 전체화면 모달(도감/배틀/포획선택 등)이 열려 있으면 숨김 — 필드 탐험 중에만. - if (ModalUIRegistry.IsAnyOpen()) return; + if (ModalUIRegistry.IsAnyOpen() || InsectGame.Core.DreamPrologueState.Active) return; // 좌상단 상태 패널이 펼쳐져 있으면 그 아래에 완전히 덮인다 — 그리지 않는다. if (LeftStackOccluded) return; EnsureAssets(); UIScale.Begin(); - float size = PanelSize; - float x = LeftX; - float y = UISafeLayout.ContentTop + TopOffset; // 좌상단 HUD(ContentTop) 닫힘 탭 아래 - Rect rect = new Rect(x, y, size, size); + Rect rect = PanelRect(HudFrame.Current); // 좌상단 HUD(ContentTop) 닫힘 탭 아래 + float size = rect.width; + float x = rect.x; + float y = rect.y; float cx = x + size / 2f; float cy = y + size / 2f; float mapRadius = size / 2f - 12f; @@ -127,7 +150,7 @@ private void OnGUI() UISurface.HudCard(rect); GUI.color = Color.white; - GUI.Label(new Rect(x, y + 6f, size, 24f), "미니맵", labelStyle); + UIHelper.LabelFit(new Rect(x, y + 6f, size, 24f), "주변 탐색", labelStyle); // 곤충 점 (월드 +Z = 미니맵 위쪽) Vector3 pp = player.position; @@ -147,7 +170,15 @@ private void OnGUI() } // 메인퀘스트 목표 쐐기 — 곤충 점 위, 플레이어 아래에 그려 셋이 겹쳐도 읽힌다. - DrawObjectiveWedge(cx, cy, mapRadius); + DrawWorldLandmarks(cx, cy, mapRadius); + if (regionManager == null || regionManager.CurrentSubArea == null) + { + DrawObjectiveWedge(cx, cy, mapRadius); + DrawLegend(x, y + size - 32f, size); + } + else + UIHelper.LabelFit(new Rect(x + 8f, y + size - 30f, size - 16f, 24f), + "출구로 돌아가기", labelStyle); // 플레이어(중심) + 진행방향 점 GUI.color = new Color(0.4f, 0.85f, 1f, 1f); @@ -165,10 +196,83 @@ private void OnGUI() UIScale.End(); } + /// + /// 범례 — 색 점 + 짧은 말. 예전엔 "노랑 주민·의뢰 · 민트 입구"를 한 줄 글자로 넣어 + /// 204px 상자에 맞추느라 LabelFit이 글자를 읽을 수 없는 크기까지 줄였다(세로 화면 캡처에서 + /// 자모가 뭉개져 보였다). 색 이름을 글로 쓰는 대신 그 색 점을 직접 보여 준다. + /// + private void DrawLegend(float x, float y, float size) + { + UITheme t = UITheme.Instance; + const float dot = 12f; + float itemW = (size - 24f) * 0.5f; + float lx = x + 12f; + GUI.color = t.accentAmber; + GUI.DrawTexture(new Rect(lx, y + 6f, dot, dot), dotTex); + GUI.color = Color.white; + UIHelper.LabelFit(new Rect(lx + dot + 4f, y, itemW - dot - 8f, 24f), "주민·의뢰", legendStyle); + lx += itemW; + GUI.color = t.accentMint; + GUI.DrawTexture(new Rect(lx, y + 6f, dot, dot), dotTex); + GUI.color = Color.white; + UIHelper.LabelFit(new Rect(lx + dot + 4f, y, itemW - dot - 8f, 24f), "입구", legendStyle); + } + /// /// 목표 방향 쐐기. 미니맵 반경(worldRadius) 안이면 실제 위치에, 밖이면 테두리에 붙여 /// 방향만 알려 준다 — 밖에 있다고 안 그리면 "목표가 멀 때는 아무 안내도 없는" 상태가 된다. /// + private void DrawWorldLandmarks(float cx, float cy, float mapRadius) + { + if (regionManager != null && regionManager.CurrentSubArea != null) return; + if (npcManager != null) + { + foreach (var npc in npcManager.Villagers) + if (npc != null && npc.gameObject.activeInHierarchy) + DrawLandmark(npc.transform.position, cx, cy, mapRadius, UITheme.Instance.accentAmber); + foreach (var npc in npcManager.StoryNpcs) + { + if (npc == null || !npc.gameObject.activeInHierarchy) continue; + // 의뢰 주민·본편 대상은 네모 대신 !/? 원 — 지도 배지·머리 위 표식과 같은 기호다. + InsectGame.NPC.QuestMark mark = objectiveTracker != null + ? objectiveTracker.QuestMarkOf(npc) : InsectGame.NPC.QuestMark.None; + if (mark != InsectGame.NPC.QuestMark.None) DrawTaleMark(npc.transform.position, cx, cy, mapRadius, mark); + else DrawLandmark(npc.transform.position, cx, cy, mapRadius, UITheme.Instance.accentAmber); + } + } + if (regionManager == null || regionManager.Regions == null) return; + foreach (var region in regionManager.Regions) + { + if (region == null || region.subAreas == null || !regionManager.IsRegionAccessible(region)) continue; + foreach (var sub in region.subAreas) + if (sub != null) + DrawLandmark(sub.centerPosition, cx, cy, mapRadius, UITheme.Instance.accentMint); + } + } + + private void DrawLandmark(Vector3 position, float cx, float cy, float mapRadius, Color color) + { + if (!MapMarkerProjection.TryRadarOffset(player.position, position, worldRadius, mapRadius, out Vector2 offset)) return; + GUI.color = color; + GUI.DrawTexture(new Rect(cx + offset.x - 5f, cy + offset.y - 5f, 10f, 10f), Texture2D.whiteTexture); + GUI.color = Color.white; + } + + private void DrawTaleMark(Vector3 position, float cx, float cy, float mapRadius, InsectGame.NPC.QuestMark mark) + { + if (!MapMarkerProjection.TryRadarOffset(player.position, position, worldRadius, mapRadius, out Vector2 offset)) return; + bool report = mark == InsectGame.NPC.QuestMark.Report; + // 본편 대상은 한 단 크고 민트다 — 여러 의뢰 사이에서 "지금 갈 사람"이 먼저 보이게(지도 배지와 같은 색). + bool main = mark == InsectGame.NPC.QuestMark.Main; + float half = main ? 11f : 9f; + Rect r = new Rect(cx + offset.x - half, cy + offset.y - half, half * 2f, half * 2f); + // 의뢰는 !·? 모두 호박색이고 기호로 가른다(지도 배지와 같은 규칙). + GUI.color = main ? UITheme.Instance.accentMint : UITheme.Instance.accentAmber; + GUI.DrawTexture(r, dotTex); + GUI.color = Color.white; + GUI.Label(r, report ? "?" : "!", taleMarkStyle); + } + private void DrawObjectiveWedge(float cx, float cy, float mapRadius) { if (objectiveTracker == null || !objectiveTracker.HasObjective @@ -179,6 +283,12 @@ private void DrawObjectiveWedge(float cx, float cy, float mapRadius) // 월드 +Z가 미니맵 위쪽이므로 화면 벡터는 (x, -z)다. float dist = objectiveTracker.DistanceToTarget; + + // 반경 안의 표식 달린 주민이면 쐐기를 겹치지 않는다 — 그 자리에 이미 !·?가 있다. + InsectGame.NPC.VillagerNpc targetNpc = objectiveTracker.TargetNpc; + if (dist <= worldRadius && targetNpc != null + && objectiveTracker.QuestMarkOf(targetNpc) != InsectGame.NPC.QuestMark.None) return; + float mapped = Mathf.Min(dist, worldRadius) / worldRadius * mapRadius; float wx = cx + dir.x * mapped; float wy = cy - dir.z * mapped; @@ -186,8 +296,8 @@ private void DrawObjectiveWedge(float cx, float cy, float mapRadius) // 위를 향한 "▲"를 목표 쪽으로 돌린다. GUI 회전은 시계방향이 양수라 atan2(x, z)가 그대로 각도. float angle = Mathf.Atan2(dir.x, dir.z) * Mathf.Rad2Deg; Matrix4x4 saved = GUI.matrix; - GUIUtility.RotateAroundPivot(angle, new Vector2(wx, wy)); - GUI.Label(new Rect(wx - 14f, wy - 14f, 28f, 28f), "▲", wedgeStyle); + GUI.matrix = MapMarkerProjection.PivotMatrix(saved, new Vector2(wx, wy), angle); + GUI.Label(new Rect(-14f, -14f, 28f, 28f), "▲", wedgeStyle); GUI.matrix = saved; } } diff --git a/Assets/Scripts/UI/ModalUIRegistry.cs b/Assets/Scripts/UI/ModalUIRegistry.cs index e5b2964..6472de5 100644 --- a/Assets/Scripts/UI/ModalUIRegistry.cs +++ b/Assets/Scripts/UI/ModalUIRegistry.cs @@ -41,6 +41,20 @@ public static bool IsAnyOpen() return stack.Count > 0; } + /// + /// 타입을 뺀 다른 모달이 열려 있는가. 모달 안의 버튼이 "지금 다른 + /// 연출(컷신·영상·대화)이 도는 중인가"를 물을 때 쓴다 — 자기 자신은 당연히 열려 있으므로. + /// + public static bool IsAnyOpenExcept(System.Type except) + { + for (int i = stack.Count - 1; i >= 0; i--) + { + if (stack[i] == null || !stack[i].IsOpen) { stack.RemoveAt(i); continue; } + if (except == null || !except.IsInstanceOfType(stack[i])) return true; + } + return false; + } + public static IModalUI TopModal { get diff --git a/Assets/Scripts/UI/NpcDialogueUI.cs b/Assets/Scripts/UI/NpcDialogueUI.cs index af9da4a..f857334 100644 --- a/Assets/Scripts/UI/NpcDialogueUI.cs +++ b/Assets/Scripts/UI/NpcDialogueUI.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using InsectGame.Core; using InsectGame.NPC; using UnityEngine; @@ -32,6 +33,12 @@ public class NpcDialogueUI : MonoBehaviour, IModalUI // 스토리 비트 렌더 — StoryDirector.StoryBeatTriggered 구독. 기존 대화 모달 렌더 재사용. private InsectGame.Story.StoryDirector storyDirector; + private InsectGame.Story.StoryObjectiveTracker objectiveTracker; + public void AutoWire(InsectGame.Story.StoryObjectiveTracker tracker) + { + if (objectiveTracker == null) objectiveTracker = tracker; + } + private InsectGame.Story.StoryBeat currentBeat; private InsectGame.Story.StoryLine[] storyLines; private bool storyMode; @@ -45,8 +52,75 @@ public class NpcDialogueUI : MonoBehaviour, IModalUI private int progressCacheIndex = -1; private int progressCacheTotal = -1; + // ── 스토리 무대(비트를 열 때 한 번 계산) ── 줄마다 누가 어디 서고 누가 말하는지, 어떤 연출인지. + // 규칙은 StoryDialogueStaging(순수 계산)이 정하고 여기선 그리기만 한다. + private StoryDialogueStaging.Stage[] storyStages; + private StoryDialogueStaging.LineFx[] storyFx; + private string[] storyActiveIds; + private float lineShownAt; + private float speakerChangedAt; + private bool lineRevealAll; + private GUIStyle namePlateStyle; + private GUIStyle progressStyle; + private readonly GUIContent namePlateContent = new GUIContent(); + + // ── 「지난 이야기」 카드 ── 장을 여는 비트가 **처음** 뜰 때(다시보기 아님) 첫 대사 앞에 한 장. + // 자리는 StoryRecapLayout(순수 계산)이 정하고, 줄 높이는 래핑으로 재서 (장, 화면 크기)마다 한 번 굽는다. + private InsectGame.Story.StoryChapter recapChapter; + private bool recapShowing; + private float recapShownAt; + private StoryRecapLayout.Plan recapPlan; + private InsectGame.Story.StoryChapter recapPlanChapter; + private float recapPlanW = -1f, recapPlanH = -1f; + private string[] recapLineTexts; + private GUIStyle recapTitleStyle; + private GUIStyle recapSectionStyle; + private GUIStyle recapBodyStyle; + private GUIStyle recapGoalStyle; + private GUIStyle recapHintStyle; + + // 잡담 띠의 "다음 이야기 · 할 일 — 이유" — 할 일·이유가 바뀔 때만 잇는다(OnGUI 매 패스 문자열 할당 방지). + private string nextStoryCache; + private string nextStoryLabelSource; + private string nextStoryWhySource; + + /// 지금 「지난 이야기」 카드가 떠 있는가(첫 대사 앞). + public bool IsShowingRecap => isOpen && recapShowing; + public bool IsOpen => isOpen; + // 라이벌 대결 — 평소 대화에 [대결] 버튼을 붙인다(라온). 누르면 대화를 닫고 다음 Update에서 건다: + // OnGUI 안에서 전투를 시작하면 이 모달이 닫히는 프레임과 전투 화면이 열리는 프레임이 엉킨다. + private InsectGame.NPC.NpcDuelController duelController; + private bool rivalDuelOffered; + private string pendingRivalNpcId; + + public void AutoWire(InsectGame.NPC.NpcDuelController duel) + { + if (duelController == null) duelController = duel; + } + + // 대사 직후 대결(StoryBeat.duelAfter) — 선택지 결과 대사처럼 자기 값이 빈 비트 뒤에도 대결이 기다리면 + // 마지막 버튼을 「승부!」로 바꾼다. 대결을 여는 건 런처다(이 창은 평소처럼 닫기만 한다). + private InsectGame.Story.StoryDuelLauncher duelLauncher; + private GUIStyle duelButtonStyle; + + public void AutoWire(InsectGame.Story.StoryDuelLauncher launcher) + { + if (duelLauncher == null) duelLauncher = launcher; + } + + /// + /// 지금 떠 있는 스토리 대사가 끝나면 대결이 열리는가 — 비트 자신의 duelAfter, 또는 런처가 이미 기다리는 대결 + /// (선택지를 단 비트가 끝나며 대기열에 넣었다). 다시보기·꿈은 대결을 열지 않는다. + /// + private bool StoryLeadsToDuel() + { + if (!storyMode || currentBeat == null) return false; + return DialogueDuelPrompt.LeadsToDuel(currentBeat.duelAfter, + duelLauncher != null && duelLauncher.HasPendingDuel, storyReplay, DreamPrologueState.Active); + } + public void AutoWire(PlayerMovement player) { if (playerMovement == null) playerMovement = player; @@ -103,28 +177,68 @@ private void OnStoryBeatTriggered(InsectGame.Story.StoryBeat beat) } // 스토리 비트를 대화 모달로 렌더 — lines[]를 순차 표시(speaker는 라인별). 닫으면 CompleteBeat 콜백. + // 장을 여는 비트면 첫 대사 앞에 「지난 이야기」 카드가 한 장 먼저 뜬다(StoryChapter.openingBeatId). public void ShowStory(InsectGame.Story.StoryBeat beat) + { + OpenStory(beat, false); + } + + /// + /// 스토리 대사를 연다. (저널 다시보기)면 닫을 때 완료 처리를 건너뛰고 「지난 이야기」 카드도 띄우지 않는다 — + /// 카드는 그 장에 처음 들어설 때의 되짚기다. 대사 앞 연출이 붙은 비트는 연출이 끝나며 여기로 오므로 순서는 + /// 대사 앞 영상(introVideoId) → 등장 연출(stageEnterId) → 「지난 이야기」 카드 → 첫 대사다 + /// (앞 둘은 이 차례로 돌리고, 카드는 여기서 선다 — 카드가 영상·연출과 겹치지 않는다). + /// 대사 뒤 영상(videoId)·컷신은 대사를 닫은 뒤(StoryBeatCompleted)에 돈다. + /// + private void OpenStory(InsectGame.Story.StoryBeat beat, bool replay) { if (beat == null) return; + // **꺼진·파괴된 창으로는 열지 않는다.** 대사 앞 영상·등장 연출은 어떤 길로 끝나든 콜백(ShowStory)을 부르는데, + // 씬 재로드·로그아웃·UI 루트 토글(오프닝 다시보기)이 그 끝을 만들면 이 창이 먼저 꺼져 있을 수 있다. 그대로 열면 + // static ModalUIRegistry에 열린 채 등록된다 — 꺼진 창은 OnGUI·OnDisable이 다시 안 돌아 닫힐 길이 없고, 파괴된 뒤에도 + // IModalUI 참조 비교는 null이 아니라(IsAnyOpen의 정리가 못 거른다) 새 씬의 조작이 영영 막힌다. + // 그래서 비트를 쥐고만 있다가 다시 켜지면 연다(OnEnable). 씬째 사라지면 비트는 열람 표시 없이 남아 다음 부팅에 다시 뜬다 — + // 렌더러가 없을 때의 StoryDirector 정책(deferredBeat: 보류했다가 구독되면 띄우고, 끝내 없으면 seen 미마킹)과 같다. + // 다시보기는 쥐지 않는다 — 저널에서 다시 누르면 된다. + if (this == null) return; + if (!isActiveAndEnabled) + { + if (!replay) heldStoryBeat = beat; + return; + } + // 대사 없음 — 표시 없이 즉시 완료(보상/seen 처리). if (beat.lines == null || beat.lines.Count == 0) { - if (storyDirector != null) storyDirector.CompleteBeat(beat.beatId); + if (!replay && storyDirector != null) storyDirector.CompleteBeat(beat.beatId); return; } // 다른 모달(주민 대화)이 열려 있으면 정리 후 스토리로 전환. if (isOpen) CloseModal(); + // 런처는 부트스트랩이 AutoWire로 잇는다. 배선이 빠졌을 때만 한 번 찾는다(비트를 열 때만 — 매 프레임 아님). + if (duelLauncher == null) duelLauncher = FindFirstObjectByType(); + currentBeat = beat; storyMode = true; - storyReplay = false; + storyReplay = replay; + recapChapter = null; + recapShowing = false; + if (InsectGame.Story.StoryService.TryGetChapterOpenedBy(beat.beatId, out InsectGame.Story.StoryChapter chapter) + && StoryRecapLayout.ShouldShow(chapter, replay)) + { + recapChapter = chapter; + recapShowing = true; + recapShownAt = Time.unscaledTime; + } storyLines = beat.lines.ToArray(); lines = new string[storyLines.Length]; for (int i = 0; i < storyLines.Length; i++) lines[i] = storyLines[i] != null ? storyLines[i].text : ""; - lineIndex = 0; + PrepareStage(beat); + BeginStoryLine(0); isOpen = true; openedFrame = Time.frameCount; ModalUIRegistry.Register(this); @@ -151,23 +265,34 @@ public void ShowStoryReplay(InsectGame.Story.StoryBeat beat) return; } - ShowStory(beat); - storyReplay = true; // ShowStory가 false로 돌려놓은 뒤에 켠다 + // 다시보기 표지는 OpenStory가 열린 모달을 정리(CloseModal)한 **뒤에** 켠다 — 카드도 다시보기에선 뜨지 않는다. + OpenStory(beat, true); } /// 대화 시작 — WorldInteractionController가 호출. public void Show(VillagerNpc npc) { - if (npc == null) return; + if (npc == null || isOpen) return; + // 진행 중인 스토리/주민 대화를 교체하면 화자와 완료 콜백이 어긋난다. + storyMode = false; + storyReplay = false; + currentBeat = null; + storyLines = null; + recapChapter = null; + recapShowing = false; currentNpc = npc; // 스토리 인물은 전용 잡담이 있으면 그걸 쓴다 — 마을 주민 풀로 떨어지면 // 명부회 간부가 날씨 이야기를 한다(비트를 아직 못 봤거나 전부 본 뒤의 경로). if (!npc.IsStoryNpc - || !NpcDialogueDatabase.TryGetStoryNpcLines(npc.StoryNpcId, out lines)) + || !NpcDialogueDatabase.TryGetStoryNpcLines(npc.StoryNpcId, + storyDirector != null && storyDirector.HasDefeatedStoryNpc(npc.StoryNpcId), + storyDirector != null && storyDirector.IsRegionCleansed(npc.RegionId), out lines)) { lines = NpcDialogueDatabase.GetLines(npc.NpcId, npc.RegionId); } lineIndex = 0; + rivalDuelOffered = npc.IsStoryNpc && duelController != null + && duelController.CanRivalDuel(npc.StoryNpcId, Time.time); isOpen = true; openedFrame = Time.frameCount; ModalUIRegistry.Register(this); @@ -200,6 +325,9 @@ public void CloseModal() if (currentNpc != null) currentNpc.EndTalk(); currentNpc = null; lines = null; + // 카드가 떠 있는 채 닫혀도(건너뛰기·ESC) 다음 열림에 남지 않게 — 카드는 OpenStory만 다시 세운다. + recapChapter = null; + recapShowing = false; // 스토리 비트였으면 완료 콜백(보상/seen). 상태를 먼저 비워 CompleteBeat 재진입에 안전. // 저널 다시보기(storyReplay)는 이미 열람·보상 완료된 비트라 콜백을 건너뛴다. @@ -218,6 +346,17 @@ public void CloseModal() private bool choiceResolved; + // 꺼진 채 열려던 스토리 비트(OpenStory의 가드) — 다시 켜지면 그때 연다. 씬째 사라지면 같이 사라진다(비트는 미열람으로 남는다). + private InsectGame.Story.StoryBeat heldStoryBeat; + + private void OnEnable() + { + if (heldStoryBeat == null || isOpen) return; + InsectGame.Story.StoryBeat beat = heldStoryBeat; + heldStoryBeat = null; + OpenStory(beat, false); + } + private void OnDisable() { choiceResolved = true; // 루트가 꺼지는 건 플레이어의 닫기가 아니다 — 선택 가드를 지나간다 @@ -236,6 +375,12 @@ private void OnDisable() private void Update() { + if (!string.IsNullOrEmpty(pendingRivalNpcId) && !isOpen) + { + string rivalId = pendingRivalNpcId; + pendingRivalNpcId = null; + if (duelController != null) duelController.TryStartRivalDuel(rivalId, Time.time); + } if (!isOpen) return; // 대화 상대가 사라짐(ApplyTuning 비활성화 등) — 안전 종료. 스토리 모드는 NPC가 없으므로 스킵. if (!storyMode && (currentNpc == null || !currentNpc.gameObject.activeInHierarchy)) @@ -265,12 +410,7 @@ private void OnGUI() } EnsureStyles(); - // 스토리는 글씨도 크다 — 스타일은 1회 캐시라 크기만 매 프레임 지정한다 - // (LabelFit이 넘칠 때 줄였다가 원복하므로 여기서 기준값을 다시 세워야 한다). - nameStyle.fontSize = storyMode ? 38 : 26; - lineStyle.fontSize = storyMode ? 34 : 24; UIScale.Begin(); - // 패널 페이드 — 대사창이 툭 튀어나오면 NPC가 다가와 인사하는 흐름이 거기서 끊긴다. // **열릴 때만** 페이드한다: 닫을 때는 CloseModal이 lines/storyLines/currentBeat을 // 그 자리에서 비우고 보상까지 지급하므로(CompleteBeat), 사라지는 동안 그릴 내용이 @@ -279,120 +419,543 @@ private void OnGUI() float panelAlpha = UIHelper.AnimatePanelOpen(ref openFade, isOpen, ref wasOpen); GUI.color = new Color(1f, 1f, 1f, panelAlpha); - // **스토리는 화면 가운데 크게, 일반 대화는 하단에.** - // 둘을 같은 하단 띠에 그리면 지금 보고 있는 것이 이야기인지 잡담인지 구분되지 않는다. - // 딤은 뒤 월드를 눌러 시선을 대사로 모으되, 완전히 가리지는 않는다 — - // "지금 어디서 듣고 있는지"가 보여야 장면이 이어진다. - Rect panel = storyMode - ? UISafeLayout.CenteredPanel(1180f, 560f) - : UISafeLayout.BottomPanel(920f, 210f); - float panelW = panel.width; - float panelH = panel.height; - float px = panel.x; - float py = panel.y; + // **스토리는 무대, 잡담은 하단 띠.** 둘을 같은 모양으로 그리면 지금 보고 있는 것이 + // 이야기인지 잡담인지 구분되지 않는다 — 스토리는 딤 위에 인물이 서서 주고받고, + // 잡담은 초상 없이 작은 검은 띠 하나다. + if (storyMode && recapShowing) DrawRecapCard(panelAlpha); + else if (storyMode) DrawStoryStage(panelAlpha); + else DrawAmbient(panelAlpha); - if (storyMode) - { - UISurface.Dim(0.68f); - UISurface.Card(new Rect(px, py, panelW, panelH), - new Color(0.04f, 0.05f, 0.10f, 0.97f), UITheme.Instance.accentAmber); - // 상단 액센트 — 둥근 모서리를 뚫지 않게 긴 축을 반경만큼 물린다(rules/ui-layout.md). - UISurface.Flat( - new Rect(px + UITheme.Radius.Card, py + 3f, - panelW - UITheme.Radius.Card * 2f, 5f), - UITheme.Instance.accentAmber); - } - else + // 페이드 알파를 남기지 않는다 — GUI.color는 전역이라 다음 컴포넌트의 OnGUI까지 물든다. + GUI.color = Color.white; + UIScale.End(); + } + + // ───────────────────────── 주민 잡담 — 하단 띠 ───────────────────────── + private void DrawAmbient(float panelAlpha) + { + nameStyle.fontSize = 26; + lineStyle.fontSize = 24; + Rect panel = UISafeLayout.BottomPanel(920f, 258f); + float panelW = panel.width, panelH = panel.height, px = panel.x, py = panel.y; + + // 페이드 알파를 곱해 넣고, 복구도 흰색이 아니라 그 알파로 되돌린다 — + // 흰색으로 되돌리면 이 뒤의 이름·대사·버튼이 페이드에서 빠진다. + GUI.color = new Color(0f, 0f, 0f, 0.82f * panelAlpha); + GUI.DrawTexture(new Rect(px, py, panelW, panelH), Texture2D.whiteTexture); + GUI.color = new Color(1f, 1f, 1f, panelAlpha); + + string npcName = currentNpc != null ? currentNpc.DisplayName : "주민"; + float textX = px + 28f; + float textW = panelW - 56f; + float nameH = 34f; + float nameY = py + 16f; + float lineY = nameY + nameH + 6f; + float btnBandH = 114f; + float lineH = Mathf.Max(40f, py + panelH - btnBandH - lineY); + + UIHelper.LabelFit(new Rect(textX, nameY, Mathf.Max(40f, textW - 110f), nameH), npcName, nameStyle); + // 대사 길이는 데이터가 정한다 — 상자에 안 들어가면 폰트를 줄여 맞춘다. + UIHelper.LabelFit(new Rect(textX, lineY, textW, lineH), + lines[Mathf.Clamp(lineIndex, 0, lines.Length - 1)], lineStyle); + + if (objectiveTracker != null && objectiveTracker.HasObjective) { - // 페이드 알파를 곱해 넣고, 복구도 흰색이 아니라 그 알파로 되돌린다 — - // 흰색으로 되돌리면 이 뒤의 이름·대사·버튼이 페이드에서 빠진다. - GUI.color = new Color(0f, 0f, 0f, 0.82f * panelAlpha); - GUI.DrawTexture(new Rect(px, py, panelW, panelH), Texture2D.whiteTexture); - GUI.color = new Color(1f, 1f, 1f, panelAlpha); + Color previousTextColor = lineStyle.normal.textColor; + lineStyle.normal.textColor = UITheme.Instance.textSecondary; + // 이유(「왜」)가 있으면 같은 줄 끝에 " — 이유"로 붙인다. 이 띠는 대사 줄과 버튼 줄 사이 한 줄뿐이라 + // 둘째 줄을 들이면 대사 상자가 줄어든다 — 넘치면 LabelFit이 글자를 줄여 맞춘다. + UIHelper.LabelFit(new Rect(textX, py + panelH - 108f, textW, 32f), + NextStoryLabel(objectiveTracker.Label, objectiveTracker.Why), lineStyle); + lineStyle.normal.textColor = previousTextColor; } - // 이름 + 대사 — 스토리 모드는 라인별 speaker(없으면 비트 speakerNpcId), 아니면 NPC 이름. - string npcName; - if (storyMode) + // 진행 표시 (n/총) — **상자 높이를 숫자로 박지 않는다**(한글 줄높이 ≈ fontSize × 1.35). + UIHelper.LabelFit(new Rect(px + panelW - 120f, nameY, 92f, Mathf.Ceil(nameStyle.fontSize * 1.35f)), + ProgressLabel(), nameStyle); + + float btnW = 170f, btnH = 56f; + float btnY = py + panelH - btnH - 14f; + if (lineIndex < lines.Length - 1) { - InsectGame.Story.StoryLine sl = (storyLines != null - && lineIndex >= 0 && lineIndex < storyLines.Length) ? storyLines[lineIndex] : null; - if (sl != null && !string.IsNullOrEmpty(sl.speaker)) npcName = sl.speaker; - else if (currentBeat != null && !string.IsNullOrEmpty(currentBeat.speakerNpcId)) npcName = currentBeat.speakerNpcId; - else npcName = "???"; - // 직접 다가가 말을 건 조우(NpcTalk)면 화자명에 플로리시 — 만남을 강조. - if (currentBeat != null && currentBeat.trigger != null && currentBeat.trigger.type == "NpcTalk") - npcName = "✦ " + npcName; + if (GUI.Button(new Rect(px + panelW - btnW * 2f - 40f, btnY, btnW, btnH), "다음", buttonStyle)) + lineIndex++; } - else + if (GUI.Button(new Rect(px + panelW - btnW - 24f, btnY, btnW, btnH), "닫기", buttonStyle)) + CloseModal(); + // 라이벌 대결 — 대사를 다 넘기지 않아도 건다(잡담은 매번 같은 말이라 끝까지 읽힐 이유가 없다). + if (rivalDuelOffered && currentNpc != null + && GUI.Button(new Rect(px + 24f, btnY, 220f, btnH), "승부하기", buttonStyle)) { - npcName = currentNpc != null ? currentNpc.DisplayName : "주민"; + pendingRivalNpcId = currentNpc.StoryNpcId; + CloseModal(); } - // 스토리 모드 + 아는 화자면 좌측에 포트레이트, 텍스트는 그만큼 우측으로 민다. - float textX = px + 28f; - float textW = panelW - 56f; - if (storyMode && currentBeat != null) + } + + /// "다음 이야기 · 할 일" 또는 "다음 이야기 · 할 일 — 이유". 할 일·이유가 바뀔 때만 다시 잇는다. + private string NextStoryLabel(string label, string why) + { + if (nextStoryCache == null || !ReferenceEquals(label, nextStoryLabelSource) || !ReferenceEquals(why, nextStoryWhySource)) { - float off = DrawStoryPortrait(px, py, panelH, currentBeat.speakerNpcId); - textX += off; - textW -= off; + nextStoryLabelSource = label; + nextStoryWhySource = why; + nextStoryCache = ComposeNextStory(label, why); } - // 중앙 패널은 훨씬 크므로 고정 오프셋을 그대로 쓰면 아래가 텅 빈다 — 높이에서 파생한다. - float nameH = storyMode ? 52f : 34f; - float nameY = py + (storyMode ? 30f : 16f); - float lineY = nameY + nameH + (storyMode ? 18f : 6f); - float btnBandH = storyMode ? 96f : 70f; - float lineH = Mathf.Max(40f, py + panelH - btnBandH - lineY); + return nextStoryCache; + } - GUI.Label(new Rect(textX, nameY, textW, nameH), npcName, nameStyle); - // 대사 길이는 데이터가 정한다 — 상자에 안 들어가면 폰트를 줄여 맞춘다. - // 초상화가 붙는 스토리 대사는 textW까지 좁아져 더 쉽게 넘친다. - UIHelper.LabelFit(new Rect(textX, lineY, textW, lineH), - lines[Mathf.Clamp(lineIndex, 0, lines.Length - 1)], lineStyle); + /// 잡담 띠의 다음 이야기 한 줄 — 순수. 이유가 비면 예전과 같다. + internal static string ComposeNextStory(string label, string why) + { + string head = "다음 이야기 · " + (label ?? string.Empty); + return string.IsNullOrEmpty(why) ? head : head + " — " + why; + } - // 진행 표시 (n/총) - // **상자 높이를 숫자로 박지 않는다.** 이 라벨은 이름과 같은 `nameStyle`을 쓰는데, - // 스토리 모드에서 그 폰트가 26 → 38로 커진 뒤에도 상자는 30px 그대로였다 — - // 한글 줄높이는 대략 fontSize × 1.35(=51)라 "1/4"가 위아래로 깎였다. - // 이름 쪽(nameH)은 52로 함께 키웠는데 여기만 남았다. - // - // 문자열도 캐시한다 — OnGUI는 프레임당 여러 번 도는데 보간은 매번 새 문자열이다. + // 문자열은 줄이 바뀔 때만 만든다 — OnGUI는 프레임당 여러 번 도는데 보간은 매번 새 문자열이다. + private string ProgressLabel() + { if (progressCacheIndex != lineIndex || progressCacheTotal != lines.Length) { progressCacheIndex = lineIndex; progressCacheTotal = lines.Length; progressCache = (lineIndex + 1) + "/" + lines.Length; } - float progH = Mathf.Ceil(nameStyle.fontSize * 1.35f); - UIHelper.LabelFit(new Rect(px + panelW - 120f, nameY, 92f, progH), - progressCache, nameStyle); + return progressCache; + } + + // ───────────────────────── 스토리 — 무대 ───────────────────────── + + /// 비트를 열 때 한 번 — 줄마다 초상 ID·좌우 배치·연출 태그를 풀어 둔다. + private void PrepareStage(InsectGame.Story.StoryBeat beat) + { + int n = storyLines.Length; + string[] ids = new string[n]; + storyFx = new StoryDialogueStaging.LineFx[n]; + for (int i = 0; i < n; i++) + { + InsectGame.Story.StoryLine line = storyLines[i]; + string speaker = line != null ? line.speaker : null; + // 지문은 누구의 얼굴도 빌리지 않는다 — 비트 화자로 떨어지면 해설을 그 사람이 말하는 것처럼 보인다. + ids[i] = StoryDialogueStaging.IsNarration(speaker) + ? null + : NpcDialogueDatabase.StoryPortraitId(speaker, beat.speakerNpcId); + // 초상이 없는 인물은 무대에 세우지 않는다(이름표만 뜬다). + if (ids[i] != null && !GetStoryPortrait(ids[i], out _, out _, out _, out _, out _, out _, out _)) + ids[i] = null; + storyFx[i] = StoryDialogueStaging.ParseFx(line != null ? line.fx : null); + } + storyStages = StoryDialogueStaging.AssignSides(ids); + storyActiveIds = new string[n]; + for (int i = 0; i < n; i++) + { + StoryDialogueStaging.Stage st = storyStages[i]; + storyActiveIds[i] = st.Active == StoryDialogueStaging.Side.Left ? st.LeftId + : st.Active == StoryDialogueStaging.Side.Right ? st.RightId : null; + } + } - // 버튼 — 마지막 줄이면 [닫기]만, 아니면 [다음]/[닫기] - float btnW = storyMode ? 220f : 170f; - float btnH = storyMode ? 72f : 56f; - float btnY = py + panelH - btnH - (storyMode ? 24f : 14f); + private void BeginStoryLine(int index) + { + float now = Time.unscaledTime; + bool speakerChanged = index == 0 || storyActiveIds == null || index >= storyActiveIds.Length + || storyActiveIds[index] != storyActiveIds[index - 1]; + lineIndex = index; + lineShownAt = now; + lineRevealAll = false; + if (speakerChanged) speakerChangedAt = now; + } + + private StoryDialogueStaging.LineFx CurrentFx => + storyFx != null && lineIndex >= 0 && lineIndex < storyFx.Length + ? storyFx[lineIndex] : StoryDialogueStaging.LineFx.None; + + /// 지금 줄이 다 나왔는가 — 다 나오기 전의 [다음]·탭은 넘기지 않고 줄을 마저 보여준다. + private bool IsStoryLineRevealed() + { + if (lineRevealAll || lines == null || lineIndex < 0 || lineIndex >= lines.Length) return true; + string text = lines[lineIndex] ?? ""; + return StoryDialogueStaging.VisibleChars(text.Length, Time.unscaledTime - lineShownAt, CurrentFx) >= text.Length; + } + + /// [다음]·탭·Space — 덜 나왔으면 마저 보여주고, 다 나왔으면 다음 줄, 마지막이면 닫는다. + private void AdvanceStory() + { + if (!IsStoryLineRevealed()) + { + lineRevealAll = true; + return; + } + if (lineIndex < lines.Length - 1) + { + BeginStoryLine(lineIndex + 1); + return; + } + if (!HasChoices) CloseModal(); // 선택지는 반드시 골라야 한다(CloseModal도 삼킨다) + } + + private void DrawStoryStage(float panelAlpha) + { + UITheme t = UITheme.Instance; + float now = Time.unscaledTime; + float since = now - lineShownAt; + StoryDialogueStaging.LineFx fx = CurrentFx; + InsectGame.Story.StoryLine sl = storyLines != null && lineIndex >= 0 && lineIndex < storyLines.Length + ? storyLines[lineIndex] : null; + bool narration = sl != null && StoryDialogueStaging.IsNarration(sl.speaker); + StoryDialogueStaging.Stage stage = storyStages != null && lineIndex >= 0 && lineIndex < storyStages.Length + ? storyStages[lineIndex] : default(StoryDialogueStaging.Stage); + + UISurface.Dim((fx & StoryDialogueStaging.LineFx.Dark) != 0 ? 0.86f : 0.66f); + + // 상자는 아래, 인물은 그 위에 선다. 세로 화면은 폭이 좁아 대사가 여러 줄로 접히므로 상자를 키운다. + // 상자 자리는 「지난 이야기」 카드와 같은 순수 계산(StoryRecapLayout.DialogueBox) — 카드가 상자 위 판에 선다. + bool portraitLayout = UIScale.IsPortrait; + Rect box = StoryRecapLayout.DialogueBox(HudFrame.Current); + box.x += StoryDialogueStaging.ShakeOffset(fx, since); + + // ── 인물 ── 발은 상자 뒤로 숨는다. 듣는 쪽을 먼저, 말하는 쪽을 나중에(앞에) 그린다. + float figH = portraitLayout ? 440f : 360f; + float scale = figH / 137f; // CharacterPortraitRenderer 치비 전신 ≈ 137 × scale + float cy = box.y + 40f - 74.5f * scale; // 발끝(cy + 74.5s)이 상자 윗변 40px 아래 + float inset = box.width * (portraitLayout ? 0.23f : 0.17f); + float leftX = box.x + inset, rightX = box.xMax - inset; + float hop = StoryDialogueStaging.HopOffset(now - speakerChangedAt); + bool leftActive = stage.Active == StoryDialogueStaging.Side.Left; + bool rightActive = stage.Active == StoryDialogueStaging.Side.Right; + if (!leftActive) DrawStageFigure(stage.LeftId, leftX, cy, scale, false, 0f, now); + if (!rightActive) DrawStageFigure(stage.RightId, rightX, cy, scale, false, 0f, now); + if (leftActive) DrawStageFigure(stage.LeftId, leftX, cy, scale, true, hop, now); + if (rightActive) DrawStageFigure(stage.RightId, rightX, cy, scale, true, hop, now); + + // ── 상자 ── + UISurface.Card(box, t.surfaceBase, t.accentAmber); + // 상단 액센트 — 둥근 모서리를 뚫지 않게 긴 축을 반경만큼 물린다(rules/ui-layout.md). + UISurface.Flat(new Rect(box.x + UITheme.Radius.Card, box.y + 3f, box.width - UITheme.Radius.Card * 2f, 5f), + t.accentAmber); + + // ── 이름표 ── 말하는 사람 쪽 가장자리. 지문엔 없다. + if (!narration) + DrawNamePlate(box, sl, rightActive); + + // ── 대사 ── + float btnBandH = portraitLayout ? 112f : 100f; + Rect textRect = new Rect(box.x + 40f, box.y + 50f, box.width - 80f, + Mathf.Max(40f, box.height - 50f - btnBandH)); + string text = lines[Mathf.Clamp(lineIndex, 0, lines.Length - 1)] ?? ""; + int visible = lineRevealAll ? text.Length : StoryDialogueStaging.VisibleChars(text.Length, since, fx); + + int baseSize = lineStyle.fontSize; + FontStyle baseFontStyle = lineStyle.fontStyle; + TextAnchor baseAnchor = lineStyle.alignment; + Color baseColor = lineStyle.normal.textColor; + int size = portraitLayout ? 36 : 34; + if ((fx & StoryDialogueStaging.LineFx.Shout) != 0) + { + size = Mathf.RoundToInt(size * 1.15f); + lineStyle.fontStyle = FontStyle.Bold; + } + if ((fx & StoryDialogueStaging.LineFx.Whisper) != 0) + { + size = Mathf.RoundToInt(size * 0.9f); + lineStyle.fontStyle = FontStyle.Italic; + lineStyle.normal.textColor = t.textSecondary; + } + if (narration) + { + lineStyle.fontStyle = FontStyle.Italic; + lineStyle.alignment = TextAnchor.MiddleCenter; + lineStyle.normal.textColor = new Color(0.9f, 0.86f, 0.74f); + } + lineStyle.fontSize = size; + UIHelper.LabelFitReveal(textRect, text, visible, lineStyle); + lineStyle.fontSize = baseSize; + lineStyle.fontStyle = baseFontStyle; + lineStyle.alignment = baseAnchor; + lineStyle.normal.textColor = baseColor; + + bool revealed = visible >= text.Length; bool isLast = lineIndex >= lines.Length - 1; - if (isLast && HasChoices) + // 계속 표시 — 다 나왔고 다음 줄이 있으면 오른쪽 아래에서 깜빡인다. + if (revealed && !isLast) + { + float blink = 0.45f + 0.55f * Mathf.Abs(Mathf.Sin(now * 3.2f)); + Color c = GUI.color; + GUI.color = new Color(t.accentAmber.r, t.accentAmber.g, t.accentAmber.b, c.a * blink); + UIHelper.LabelFit(new Rect(textRect.xMax - 44f, textRect.yMax - 44f, 44f, 44f), "▼", progressStyle); + GUI.color = c; + } + + // ── 진행 · 버튼 ── + float btnW = portraitLayout ? 230f : 220f; + float btnH = 72f; + float btnY = box.yMax - btnH - 22f; + UIHelper.LabelFit(new Rect(box.x + 36f, btnY + (btnH - 40f) * 0.5f, 110f, 40f), ProgressLabel(), progressStyle); + + bool choosing = isLast && revealed && HasChoices; + if (choosing) { - // 마지막 줄에 선택지가 있으면 [닫기] 대신 선택 버튼이 대사창을 닫는다 — - // 고르지 않고는 못 나간다(ESC는 IModalUI 경로라 여전히 닫히지만 그땐 아무 결과도 안 뜬다). - DrawChoices(textX, btnY, px + panelW - 28f - textX, btnH); + // 마지막 줄이 다 나온 뒤에만 고른다 — 덜 읽은 채 고르면 선택의 무게가 사라진다. + float choiceX = box.x + 160f; + DrawChoices(choiceX, btnY, box.xMax - 28f - choiceX, btnH); + if (!isOpen) return; // 골랐다 — CloseModal이 상태를 비웠다 } else { - if (!isLast) + // 대사 뒤 곧바로 대결이면 마지막 버튼이 「승부!」다(강조색) — 누르면 평소처럼 닫고, 대결은 런처가 연다. + bool duelNext = StoryLeadsToDuel(); + Rect advanceRect = new Rect(box.xMax - btnW - 24f, btnY, btnW, btnH); + bool advance = DialogueDuelPrompt.IsDuelButton(revealed, isLast, duelNext) + ? DrawDuelButton(advanceRect, DialogueDuelPrompt.DuelLabel) + : GUI.Button(advanceRect, DialogueDuelPrompt.AdvanceLabel(revealed, isLast, duelNext), buttonStyle); + if (advance) { - if (GUI.Button(new Rect(px + panelW - btnW * 2f - 40f, btnY, btnW, btnH), "다음", buttonStyle)) - lineIndex++; + AdvanceStory(); + if (!isOpen) return; } - if (GUI.Button(new Rect(px + panelW - btnW - 24f, btnY, btnW, btnH), "닫기", buttonStyle)) + // 건너뛰기 — 장면 전체를 닫는다(비트는 완료 처리된다). 마지막 줄에선 위 버튼이 그 일을 한다. + // 선택지가 있는 비트는 건너뛸 수 없다 — CloseModal이 선택 전 닫기를 삼키므로 누르면 아무 일도 없다. + // 대결이 이어지면 「건너뛰고 승부」 — 건너뛰어도 싸움은 열린다는 걸 버튼이 말한다. + if (!isLast && !HasChoices + && GUI.Button(new Rect(box.xMax - btnW * 2f - 40f, btnY, btnW, btnH), + DialogueDuelPrompt.SkipButtonLabel(duelNext), buttonStyle)) + { CloseModal(); + return; + } + + // 대사 영역을 누르거나 Space/Enter — [다음]과 같다. 버튼 띠와 겹치지 않는 자리라 버튼을 먹지 않는다. + if (GUI.Button(new Rect(box.x, box.y, box.width, textRect.yMax - box.y), GUIContent.none, GUIStyle.none)) + { + AdvanceStory(); + if (!isOpen) return; + } + Event e = Event.current; + if (e != null && e.type == EventType.KeyDown + && (e.keyCode == KeyCode.Space || e.keyCode == KeyCode.Return || e.keyCode == KeyCode.KeypadEnter)) + { + e.Use(); + AdvanceStory(); + if (!isOpen) return; + } } - // 페이드 알파를 남기지 않는다 — GUI.color는 전역이라 다음 컴포넌트의 OnGUI까지 물든다. - GUI.color = Color.white; - UIScale.End(); + // ── 번쩍임 ── 맨 위에 덮는다. + float flash = StoryDialogueStaging.FlashOverlay(fx, since); + if (flash > 0f) + UISurface.Flat(new Rect(0f, 0f, UIScale.VirtualScreenWidth, UIScale.VirtualScreenHeight), + new Color(1f, 1f, 1f, flash * panelAlpha)); + } + + // ───────────────────────── 「지난 이야기」 카드 — 장을 여는 비트의 첫 대사 앞 ───────────────────────── + + /// 카드를 넘긴다 — 첫 대사를 지금부터(타자 효과·화자 등장 포함) 시작한다. + private void EndRecap() + { + recapShowing = false; + BeginStoryLine(0); + } + + /// + /// 「지난 이야기」 카드 — 대사 상자 위 넓은 판() 가운데. 아래 대사 상자 자리에는 같은 [다음 ▶]·[건너뛰기]가 + /// 같은 자리에 서고, 화면 어디를 눌러도·Space/Enter도 넘긴다(대사와 같은 입력). 뜬 뒤 초는 + /// 입력을 받지 않는다 — 걷던 손가락이 카드를 바로 넘기지 않게. [건너뛰기]는 대사처럼 장면 전체를 닫는다(카드도 함께). + /// + private void DrawRecapCard(float panelAlpha) + { + if (recapChapter == null) { recapShowing = false; return; } + UITheme t = UITheme.Instance; + HudFrame f = HudFrame.Current; + UISurface.Dim(0.78f); + + EnsureRecapPlan(f); + StoryRecapLayout.Plan p = recapPlan; + + // ── 카드 ── 상단 액센트는 둥근 모서리를 뚫지 않게 긴 축을 반경만큼 물린다(rules/ui-layout.md). + UISurface.Card(p.Card, t.surfaceBase, t.accentAmber); + UISurface.Flat(new Rect(p.Card.x + UITheme.Radius.Card, p.Card.y + 3f, p.Card.width - UITheme.Radius.Card * 2f, 6f), + t.accentAmber); + string title = string.IsNullOrEmpty(recapChapter.title) ? recapChapter.chapterId : recapChapter.title; + UIHelper.LabelFit(p.Title, title, recapTitleStyle); + + if (p.Recap != null && p.Recap.Length > 0) + { + recapSectionStyle.normal.textColor = t.textSecondary; + UIHelper.LabelFit(p.RecapHeader, "지난 이야기", recapSectionStyle); + for (int i = 0; i < p.Recap.Length && recapLineTexts != null && i < recapLineTexts.Length; i++) + UIHelper.LabelFit(p.Recap[i], recapLineTexts[i], recapBodyStyle); + if (p.HasGoal) UISurface.Flat(p.Divider, t.surfaceBorder); + } + if (p.HasGoal) + { + recapSectionStyle.normal.textColor = t.accentMint; + UIHelper.LabelFit(p.GoalHeader, "이번 목표", recapSectionStyle); + UIHelper.LabelFit(p.Goal, recapChapter.goal, recapGoalStyle); + } + + // ── 대사 상자 자리 ── 첫 대사가 설 자리에 같은 버튼을 세운다(누르던 자리 그대로 넘긴다). + bool portraitLayout = f.Portrait; + Rect box = StoryRecapLayout.DialogueBox(f); + UISurface.Card(box, t.surfaceBase, t.accentAmber); + UISurface.Flat(new Rect(box.x + UITheme.Radius.Card, box.y + 3f, box.width - UITheme.Radius.Card * 2f, 5f), + t.accentAmber); + + bool ready = Time.unscaledTime - recapShownAt >= StoryRecapLayout.InputDelay; + float btnBandH = portraitLayout ? 112f : 100f; + Rect hintRect = new Rect(box.x + 40f, box.y + 40f, box.width - 80f, Mathf.Max(40f, box.height - 40f - btnBandH)); + Color baseColor = GUI.color; + GUI.color = new Color(baseColor.r, baseColor.g, baseColor.b, baseColor.a * (ready ? 1f : 0.4f)); + UIHelper.LabelFit(hintRect, "화면을 누르면 이야기가 시작돼요", recapHintStyle); + GUI.color = baseColor; + + float btnW = portraitLayout ? 230f : 220f; + float btnH = 72f; + float btnY = box.yMax - btnH - 22f; + // 버튼을 먼저 — IMGUI는 먼저 처리된 컨트롤이 MouseDown을 가져간다. 화면 전체 넘기기는 그 뒤에 깐다. + if (GUI.Button(new Rect(box.xMax - btnW - 24f, btnY, btnW, btnH), "다음 ▶", buttonStyle) && ready) + { + EndRecap(); + return; + } + // 선택지가 있는 비트는 건너뛸 수 없다(CloseModal이 선택 전 닫기를 삼킨다) — 대사 화면과 같은 규칙. + if (!HasChoices + && GUI.Button(new Rect(box.xMax - btnW * 2f - 40f, btnY, btnW, btnH), + DialogueDuelPrompt.SkipButtonLabel(StoryLeadsToDuel()), buttonStyle) && ready) + { + CloseModal(); + return; + } + if (GUI.Button(new Rect(0f, 0f, UIScale.VirtualScreenWidth, UIScale.VirtualScreenHeight), GUIContent.none, GUIStyle.none) + && ready) + { + EndRecap(); + return; + } + Event e = Event.current; + if (e != null && e.type == EventType.KeyDown + && (e.keyCode == KeyCode.Space || e.keyCode == KeyCode.Return || e.keyCode == KeyCode.KeypadEnter)) + { + e.Use(); + if (ready) EndRecap(); + } + } + + /// + /// 카드 배치를 (장, 화면 크기)마다 한 번 굽는다 — 줄 높이는 래핑으로 재야 해서(CalcHeight) OnGUI 안에서만 부른다. + /// 빈 줄은 건너뛴다. 줄머리에 "• "를 붙인다(아이가 줄을 하나씩 짚어 읽게). + /// + private void EnsureRecapPlan(HudFrame f) + { + if (recapLineTexts != null && ReferenceEquals(recapPlanChapter, recapChapter) + && recapPlanW == f.Width && recapPlanH == f.Height) return; + recapPlanChapter = recapChapter; + recapPlanW = f.Width; + recapPlanH = f.Height; + + List src = recapChapter.recap; + int n = 0; + if (src != null) + for (int i = 0; i < src.Count; i++) + if (!string.IsNullOrWhiteSpace(src[i])) n++; + recapLineTexts = new string[n]; + float[] heights = new float[n]; + float cardW = StoryRecapLayout.CardWidth(f); + float textW = StoryRecapLayout.TextWidth(cardW); + int k = 0; + if (src != null) + for (int i = 0; i < src.Count; i++) + { + if (string.IsNullOrWhiteSpace(src[i])) continue; + recapLineTexts[k] = "• " + src[i].Trim(); + heights[k] = StoryRecapLayout.BodyHeight(UIHelper.MeasureWrappedHeight(recapBodyStyle, recapLineTexts[k], textW)); + k++; + } + string goal = recapChapter.goal; + float goalH = string.IsNullOrWhiteSpace(goal) ? 0f + : StoryRecapLayout.BodyHeight(UIHelper.MeasureWrappedHeight(recapGoalStyle, goal, textW)); + recapPlan = StoryRecapLayout.Layout(StoryRecapLayout.Area(f), cardW, heights, goalH); + } + + /// + /// 무대 위 인물 하나. 말하는 쪽은 밝고 크게(화자가 바뀌면 한 번 뛰어오른다), 듣는 쪽은 어둡게 한 발 물러선다. + /// 숨 쉬듯 아주 조금 오르내린다 — 정지한 종이 인형처럼 보이지 않게. + /// + private static void DrawStageFigure(string id, float x, float cy, float scale, bool active, float hop, float now) + { + if (!HasStoryPortrait(id)) return; + + // 렌더러는 호출부의 GUI.color를 곱해 그린다(패널 페이드 알파) — 듣는 쪽은 그 위에 어둡게 곱한다. + Color prev = GUI.color; + GUI.color = active + ? prev + : new Color(prev.r * 0.42f, prev.g * 0.42f, prev.b * 0.5f, prev.a); + float s = active ? scale : scale * 0.93f; + float breath = Mathf.Sin(now * 1.6f + x * 0.013f) * 1.5f; + float y = cy - hop + breath + (active ? 0f : 10f); + TryDrawStoryPortrait(id, x, y, s); + GUI.color = prev; + } + + /// 이 스토리 인물에게 초상이 있는가 — 없으면 대사창·전투 컷인 모두 얼굴 없이 말한다. + internal static bool HasStoryPortrait(string id) + { + return !string.IsNullOrEmpty(id) + && GetStoryPortrait(id, out _, out _, out _, out _, out _, out _, out _); + } + + /// + /// 초상의 상의·모자색(모자 없으면 알파 0). 월드 외형(NpcVisualBuilder.StoryNpcAppearance)과 + /// 같은지 테스트가 대조한다 — 두 표가 따로 있어 한쪽만 고치면 대사창과 필드의 옷이 갈린다. + /// + internal static bool TryGetStoryPortraitColors(string id, out Color top, out Color hat) + { + top = hat = default; + return !string.IsNullOrEmpty(id) + && GetStoryPortrait(id, out _, out _, out _, out _, out _, out top, out hat); + } + + /// 초상의 피부·머리색·머리 모양(초상 번호) — 월드 외형과 같은지 테스트가 대조한다. + internal static bool TryGetStoryPortraitFace(string id, out Color skin, out Color hair, out int hairStyle) + { + skin = hair = default; + hairStyle = 0; + return !string.IsNullOrEmpty(id) + && GetStoryPortrait(id, out _, out skin, out hair, out hairStyle, out _, out _, out _); + } + + /// + /// 스토리 인물 전신 초상. 전투 컷인·말풍선도 이걸 쓴다 — 한 인물이 화면마다 다르게 생기면 안 된다. + /// 기준은 대사창 무대와 같다: (cx, cy)가 몸 가운데, 발끝이 cy + 74.5 × scale. 표에 없으면 false. + /// + internal static bool TryDrawStoryPortrait(string id, float cx, float cy, float scale) + { + if (string.IsNullOrEmpty(id)) return false; + if (!GetStoryPortrait(id, out int gender, out Color skin, out Color hair, + out int hairStyle, out int faceType, out Color top, out Color hat)) + return false; + CharacterPortraitRenderer.DrawWithColors(cx, cy, scale, gender, skin, hair, hairStyle, faceType, + top, new Color(0.18f, 0.22f, 0.28f), new Color(0.2f, 0.12f, 0.06f), hat, 0f, false); + return true; + } + + /// 이름표 — 상자 윗변에 걸친 작은 카드. 오른쪽 사람이 말하면 오른쪽 끝으로 옮긴다. + private void DrawNamePlate(Rect box, InsectGame.Story.StoryLine line, bool right) + { + string speaker = line != null ? line.speaker : null; + string fallback = currentBeat != null ? currentBeat.speakerNpcId : null; + string name = NpcDialogueDatabase.StorySpeakerName(string.IsNullOrEmpty(speaker) ? fallback : speaker); + // 직접 다가가 말을 건 조우(NpcTalk)면 첫 줄 화자명에 플로리시 — 만남을 강조. + if (lineIndex == 0 && currentBeat != null && currentBeat.trigger != null + && currentBeat.trigger.type == "NpcTalk") + name = "✦ " + name; + + namePlateContent.text = name; + float w = Mathf.Clamp(namePlateStyle.CalcSize(namePlateContent).x + 48f, 160f, box.width * 0.5f); + const float h = 62f; + Rect plate = new Rect(right ? box.xMax - 32f - w : box.x + 32f, box.y - h * 0.55f, w, h); + UITheme t = UITheme.Instance; + UISurface.Card(plate, t.surfaceRaised, t.accentAmber); + UIHelper.LabelFit(new Rect(plate.x + 12f, plate.y, plate.width - 24f, plate.height), name, namePlateStyle); } /// 지금 떠 있는 스토리 비트의 마지막 줄에 선택지가 붙어 있는가(다시보기는 제외). @@ -452,82 +1015,105 @@ private bool DrawChoiceButton(Rect r, string text) return GUI.Button(r, string.Empty, GUIStyle.none); } - // 스토리 화자(어르신/라온/세라) 좌측 포트레이트 — CharacterPortraitRenderer 재사용. - // 반환: 그린 포트레이트 폭 오프셋(0이면 아는 화자 아님 → 포트레이트 없음). - private float DrawStoryPortrait(float px, float py, float panelH, string speakerNpcId) + /// + /// 「승부!」 — 산호색 몸통에 호박색 테두리, 테두리가 숨 쉬듯 진해졌다 옅어진다(줄인 움직임 설정이면 멈춘다). + /// 선택 버튼과 같은 구조(서피스 + LabelFit + 투명 클릭)라 [다음 ▶]과 같은 자리·크기에 선다. + /// 맥동은 색이 아니라 알파로 준다 — 둥근 서피스는 색마다 텍스처를 구워 캐시하므로 색을 매 프레임 바꾸면 텍스처가 쌓인다. + /// + private bool DrawDuelButton(Rect r, string text) { - if (!GetStoryPortrait(speakerNpcId, out int gender, out int skinIdx, out int hairIdx, - out int hairStyle, out int faceType, out Color top, out Color hat)) - return 0f; - - // 중앙 패널은 세로로 길다 — panelH를 그대로 쓰면 초상화가 패널을 통째로 채운다. - // 정사각 상자를 상단에 붙이고 남는 세로는 대사가 쓴다. - float box = storyMode - ? Mathf.Min(300f, panelH * 0.52f) - : panelH - 24f; - float boxX = px + (storyMode ? 26f : 14f); - float boxY = py + (storyMode ? 26f : 12f); - - // 주변색(패널 페이드 알파)을 곱해 넣고 끝에 되돌린다 — 이 메서드는 호출부의 - // panelAlpha를 인자로 받지 않으므로, UISurface와 같은 방식으로 GUI.color를 보존한다. - Color ambient = GUI.color; - GUI.color = new Color(0.12f, 0.1f, 0.06f, 0.9f) * ambient; - GUI.DrawTexture(new Rect(boxX, boxY, box, box), Texture2D.whiteTexture); - GUI.color = new Color(1f, 0.85f, 0.45f, 0.5f) * ambient; - GUI.DrawTexture(new Rect(boxX, boxY, box, 3f), Texture2D.whiteTexture); - GUI.color = ambient; - - float scale = box / 150f; - Color bottom = new Color(0.18f, 0.22f, 0.28f); - Color shoe = new Color(0.2f, 0.12f, 0.06f); - CharacterPortraitRenderer.Draw(boxX + box * 0.5f, boxY + box * 0.52f, scale, - gender, skinIdx, hairIdx, hairStyle, faceType, top, bottom, shoe, hat, 0f, false); - - return box + 22f; - } - - private static bool GetStoryPortrait(string id, out int gender, out int skinIdx, out int hairIdx, + UITheme t = UITheme.Instance; + float pulse = BattlePresentation.ReducedMotion ? 1f : 0.5f + 0.5f * Mathf.Sin(Time.unscaledTime * 4f); + Color body = r.Contains(UIScale.VirtualMousePosition) ? Color.Lerp(t.accentCoral, Color.white, 0.16f) : t.accentCoral; + UISurface.Rounded(new Rect(r.x + 2f, r.y + 5f, r.width, r.height), t.surfaceShadow); + Color prev = GUI.color; + GUI.color = new Color(prev.r, prev.g, prev.b, prev.a * (0.55f + 0.45f * pulse)); + UISurface.Rounded(new Rect(r.x - 4f, r.y - 4f, r.width + 8f, r.height + 8f), t.accentAmber); + GUI.color = prev; + UISurface.Rounded(r, body); + UIHelper.LabelFit(new Rect(r.x + 10f, r.y, r.width - 20f, r.height), text, duelButtonStyle); + return GUI.Button(r, string.Empty, GUIStyle.none); + } + + /// + /// 초상 한 장의 재료. 표()는 성별·표정만 들고, + /// 피부·머리색·머리 모양·몸통·모자는 전부 월드 외형에서 받는다(). + /// + private static bool GetStoryPortrait(string id, out int gender, out Color skin, out Color hair, out int hairStyle, out int faceType, out Color top, out Color hat) + { + skin = hair = top = hat = default; + hairStyle = 0; + if (!GetStoryPortraitEntry(id, out gender, out faceType)) return false; + ApplyWorldAppearance(id, out skin, out hair, out hairStyle, out top, out hat); + return true; + } + + /// + /// 월드 외형(NpcVisualBuilder.StoryNpcAppearance) → 초상. 색을 표에 따로 적지 않는다. + /// + /// 따로 적었던 시절 두 표가 같은 번호를 서로 다른 팔레트로 읽었다(초상은 플레이어 팔레트 + /// CharacterPalette, 월드는 NPC 팔레트). 그래서 명부회 일곱 전원, 동행자 셋, 마을 주민 대부분이 필드와 + /// 달랐다 — 세라는 필드 검은 머리가 대사창 보라, 어르신·물결 할머니·너울은 필드 백발이 대사창 금발, 라온은 + /// 밝은 피부가 대사창 구릿빛이었다. 플레이어 팔레트엔 회색이 없어 번호로는 백발을 옮길 수도 없었다. + /// + /// 몸통은 겉옷을 입었으면 겉옷 색이다(플레이어 초상과 같은 관례 — CharacterPortraitRenderer의 + /// Outerwear 처리). 명부회 간부는 아이보리 코트, 하수는 남색 조끼라 대사창의 "검은 옷의 사내" 호칭과 초상의 + /// 짙은 몸통이 어긋나지 않는다. 머리 모양 번호는 두 체계가 다르다 — 월드 0 짧은·1 중간·2 올림 ↔ + /// 초상 0 짧은·1 중간·2 긴·3 올림. + /// + private static void ApplyWorldAppearance(string id, out Color skin, out Color hair, out int hairStyle, + out Color top, out Color hat) + { + NpcAppearance a = NpcVisualBuilder.StoryNpcAppearance(id); + skin = a.skin; + hair = a.hair; + hairStyle = a.hairStyle == 2 ? 3 : a.hairStyle; + top = VisibleTorsoColor(a); + hat = a.hasHat ? new Color(a.hat.r, a.hat.g, a.hat.b, 1f) : new Color(0f, 0f, 0f, 0f); + } + + /// 월드 모델에서 몸통으로 보이는 색 — 코트·조끼·도포는 겉옷 색, 그 밖엔 셔츠 색(앞치마는 앞판뿐이다). + internal static Color VisibleTorsoColor(NpcAppearance a) + { + bool outer = a.wear == NpcWear.Coat || a.wear == NpcWear.Vest || a.wear == NpcWear.Robe; + return outer ? a.wearColor : a.top; + } + + // 초상 표 — 초상이 있는 인물과 그 성별·표정(월드 외형에 성별·표정이 없다). 색·머리는 ApplyWorldAppearance. + // 표에 없으면 **얼굴 없이** 말한다 — 8~12장 대치의 간부들, 최종 보스인 관장까지 그랬다. + private static bool GetStoryPortraitEntry(string id, out int gender, out int faceType) { switch (id) { - case "catcher_rival": // 라온 — 밝은 주황 상의, 캡, 미소 - gender = 0; skinIdx = 2; hairIdx = 1; hairStyle = 0; faceType = 1; - top = new Color(1f, 0.55f, 0.3f); hat = new Color(1f, 0.65f, 0.2f); return true; - case "ruins_scholar": // 세라 — 보라 상의, 올림머리, 모자 없음 - gender = 1; skinIdx = 0; hairIdx = 4; hairStyle = 3; faceType = 0; - top = new Color(0.6f, 0.45f, 0.7f); hat = new Color(0f, 0f, 0f, 0f); return true; - case "village_elder": // 마을 어르신 — 따뜻한 상의, 모자, 밝은 머리 - gender = 0; skinIdx = 1; hairIdx = 2; hairStyle = 0; faceType = 0; - top = new Color(0.85f, 0.7f, 0.4f); hat = new Color(0.55f, 0.35f, 0.25f); return true; - // 1막 하수 2인 — 검은 상의로 통일한다. 이름 대신 그 색이 이들의 정체다. - // 2막 간부와 같은 계열이라 나중에 "그때 그 옷"으로 회수된다. - case "ledger_thug_cord": // 끈 — 챙 깊은 모자로 얼굴을 가린다 - gender = 0; skinIdx = 2; hairIdx = 0; hairStyle = 0; faceType = 1; - top = new Color(0.16f, 0.16f, 0.20f); hat = new Color(0.10f, 0.10f, 0.13f); return true; - case "ledger_thug_rule": // 자 — 모자 없이 묶은 머리 - gender = 1; skinIdx = 1; hairIdx = 3; hairStyle = 3; faceType = 1; - top = new Color(0.16f, 0.16f, 0.20f); hat = new Color(0f, 0f, 0f, 0f); return true; - - // 2막 간부 4인. 표에 없으면 **얼굴 없이** 말한다 — 1막 하수에게는 초상이 있는데 - // 8~12장 대치의 상대들, 심지어 최종 보스인 관장이 그랬다. 하수의 검은 상의를 - // 그대로 이어받되(같은 계열임이 색으로 읽힌다) 채도를 조금씩 달리해 구분한다. - case "ledger_grip": // 집게 — 힘으로 밀어붙인다. 짧은 머리, 모자 없음 - gender = 0; skinIdx = 3; hairIdx = 0; hairStyle = 0; faceType = 1; - top = new Color(0.18f, 0.17f, 0.21f); hat = new Color(0f, 0f, 0f, 0f); return true; - case "ledger_scale": // 저울 — 숫자로 말한다. 단정한 올림머리 - gender = 1; skinIdx = 0; hairIdx = 1; hairStyle = 3; faceType = 0; - top = new Color(0.20f, 0.20f, 0.26f); hat = new Color(0f, 0f, 0f, 0f); return true; - case "ledger_ink": // 먹 — 붓을 놓지 못한다. 챙 있는 모자로 눈을 가린다 - gender = 0; skinIdx = 1; hairIdx = 4; hairStyle = 0; faceType = 0; - top = new Color(0.14f, 0.14f, 0.18f); hat = new Color(0.09f, 0.09f, 0.12f); return true; - case "ledger_chief": // 관장 — 최종 보스. 유일하게 밝은 머리로 격을 가른다 - gender = 0; skinIdx = 2; hairIdx = 2; hairStyle = 0; faceType = 0; - top = new Color(0.12f, 0.12f, 0.16f); hat = new Color(0.30f, 0.26f, 0.18f); return true; - - default: - gender = 0; skinIdx = 0; hairIdx = 0; hairStyle = 0; faceType = 0; - top = Color.white; hat = new Color(0f, 0f, 0f, 0f); return false; + case "catcher_rival": gender = 0; faceType = 1; return true; // 라온 — 미소 + case "ruins_scholar": gender = 1; faceType = 0; return true; // 세라 + case "village_elder": gender = 0; faceType = 0; return true; // 마을 어르신 + + // 명부회 일곱 + case "ledger_thug_cord": gender = 0; faceType = 1; return true; // 끈 — 챙 깊은 모자로 얼굴을 가린다 + case "ledger_thug_pin": gender = 0; faceType = 0; return true; // 핀 — 가장 어린 말단 + case "ledger_thug_rule": gender = 1; faceType = 1; return true; // 자 — 모자 없이 묶은 머리 + case "ledger_grip": gender = 0; faceType = 1; return true; // 집게 — 힘으로 밀어붙인다 + case "ledger_scale": gender = 1; faceType = 0; return true; // 저울 — 숫자로 말한다 + case "ledger_ink": gender = 0; faceType = 0; return true; // 먹 — 붓을 놓지 못한다 + case "ledger_chief": gender = 0; faceType = 0; return true; // 관장 — 최종 보스 + + // 마을 이야기 주민 12인 + case "town_meadow": gender = 1; faceType = 1; return true; // 달래 + case "town_pond": gender = 1; faceType = 0; return true; // 물결 할머니 + case "town_forest": gender = 0; faceType = 1; return true; // 솔 + case "town_swamp": gender = 1; faceType = 0; return true; // 이끼 + case "town_mountain": gender = 0; faceType = 0; return true; // 너울 + case "town_garden": gender = 1; faceType = 1; return true; // 누리 + case "town_ruins": gender = 0; faceType = 0; return true; // 결 + case "town_hollow": gender = 1; faceType = 0; return true; // 메아리 + case "town_dunes": gender = 0; faceType = 1; return true; // 모래 + case "town_frostline": gender = 1; faceType = 0; return true; // 서리 + case "town_emberfall": gender = 0; faceType = 0; return true; // 숯 + case "town_canopy": gender = 1; faceType = 1; return true; // 잎새 + + default: gender = 0; faceType = 0; return false; } } @@ -541,7 +1127,7 @@ private void EnsureStyles() fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; - nameStyle.normal.textColor = new Color(1f, 0.85f, 0.45f); + nameStyle.normal.textColor = UITheme.Instance.accentAmber; lineStyle = new GUIStyle(GUI.skin.label) { @@ -567,7 +1153,261 @@ private void EnsureStyles() }; choiceStyle.normal.textColor = Color.white; + // 「승부!」 — 대사 버튼(72px)에 꽉 차게 크게. label 파생(서피스 위에 글자만 찍는다). + duelButtonStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 36, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter + }; + duelButtonStyle.normal.textColor = UITheme.Instance.textPrimary; + + // 무대 이름표 — 상자 윗변에 걸친 카드 안 가운데. + namePlateStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 34, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter + }; + namePlateStyle.normal.textColor = UITheme.Instance.accentAmber; + + // 진행 표시·계속 표시(▼) — 작고 흐리게. + progressStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 26, + alignment = TextAnchor.MiddleLeft + }; + progressStyle.normal.textColor = UITheme.Instance.textSecondary; + + // 「지난 이야기」 카드 — 아이가 읽는 화면이라 본문을 대사 본문(가로 34·세로 36) 이상으로 둔다. + recapTitleStyle = new GUIStyle(GUI.skin.label) + { + fontSize = StoryRecapLayout.TitleFont, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleLeft + }; + recapTitleStyle.normal.textColor = UITheme.Instance.accentAmber; + recapSectionStyle = new GUIStyle(GUI.skin.label) + { + fontSize = StoryRecapLayout.SectionFont, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleLeft + }; + recapSectionStyle.normal.textColor = UITheme.Instance.textSecondary; + recapBodyStyle = new GUIStyle(GUI.skin.label) + { + fontSize = StoryRecapLayout.BodyFont, + alignment = TextAnchor.UpperLeft, + wordWrap = true + }; + recapBodyStyle.normal.textColor = UITheme.Instance.textPrimary; + recapGoalStyle = new GUIStyle(GUI.skin.label) + { + fontSize = StoryRecapLayout.BodyFont, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.UpperLeft, + wordWrap = true + }; + recapGoalStyle.normal.textColor = UITheme.Instance.textPrimary; + recapHintStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 30, + alignment = TextAnchor.MiddleCenter, + wordWrap = true + }; + recapHintStyle.normal.textColor = UITheme.Instance.textSecondary; + stylesInited = true; } } + + /// + /// 스토리 대사 버튼의 문구 — 순수 계산. 대사가 끝나면 곧바로 대결이 열리는 장면(StoryBeat.duelAfter, 또는 + /// 선택지 결과 대사처럼 런처가 이미 대결을 기다리는 장면)이면 마지막 줄의 [닫기]가 「승부!」가 되고, [건너뛰기]는 + /// 「건너뛰고 승부」가 된다 — 아이가 "닫으면 끝"이라고 읽고 지나가지 않게. 누르는 일은 그대로 닫기다(대결은 런처가 연다). + /// + internal static class DialogueDuelPrompt + { + public const string DuelLabel = "승부!"; + public const string SkipToDuelLabel = "건너뛰고 승부"; + public const string SkipLabel = "건너뛰기"; + public const string CloseLabel = "닫기"; + public const string NextLabel = "다음 ▶"; + + /// + /// 이 대사가 끝나면 대결이 열리는가. 비트의 가 대결 표에 있는 상대이거나 + /// (StoryDuelLauncher.KindOf — 오타는 런처도 버린다), 런처가 이미 대결을 기다리면 참. 다시보기(저널)와 꿈은 대결을 열지 않는다. + /// + public static bool LeadsToDuel(string duelAfter, bool launcherPending, bool replay, bool dream) + { + if (replay || dream) return false; + if (!string.IsNullOrWhiteSpace(duelAfter) + && InsectGame.Story.StoryDuelLauncher.KindOf(duelAfter.Trim()) != InsectGame.Story.StoryDuelLauncher.DuelKind.None) + return true; + return launcherPending; + } + + /// 오른쪽 버튼이 「승부!」인가 — 마지막 줄이 다 나왔고 대결이 이어질 때만. 덜 나왔으면 [다음 ▶](줄을 마저 보인다). + public static bool IsDuelButton(bool revealed, bool isLast, bool duel) => revealed && isLast && duel; + + /// 오른쪽 버튼 문구. + public static string AdvanceLabel(bool revealed, bool isLast, bool duel) => + revealed && isLast ? (duel ? DuelLabel : CloseLabel) : NextLabel; + + /// 왼쪽 [건너뛰기] 문구. + public static string SkipButtonLabel(bool duel) => duel ? SkipToDuelLabel : SkipLabel; + } + + /// + /// 「지난 이야기」 카드의 자리 — 순수 계산. 장을 여는 비트(StoryChapter.openingBeatId)가 처음 뜰 때 첫 대사 앞에 한 장 선다 + /// (). 대사 상자( — 대사 무대가 같은 함수를 쓴다) 위, 안전 영역 윗변까지의 판 + /// () 가운데에 선다. + /// + /// 가로(가상 1920×1080): 상자 1400×350이 바닥에, 판은 그 위 24px까지(높이 약 640), 카드 폭 min(1200, 안전 폭). + /// 세로(가상 1080×1920): 상자 안전 폭×500, 판 높이 약 1280, 카드 폭 = 안전 폭(1032). + /// 높이는 줄마다 잰 높이(래핑 — 세로에선 30자 한 줄이 두 줄로 접힌다)로 쌓고, 판보다 크면 본문 줄만 같은 비율로 줄인다 + /// (그리기는 UIHelper.LabelFit이 글자를 줄여 맞춘다) — 줄 수가 늘어도 판 밖으로 나가지 않는다. + /// + public static class StoryRecapLayout + { + /// 장 제목. + public const int TitleFont = 48; + /// 「지난 이야기」·「이번 목표」 소제목. + public const int SectionFont = 28; + /// 지난 이야기 줄·목표 — 대사 본문(가로 34·세로 36) 이상. + public const int BodyFont = 36; + public const float PadX = 40f; + public const float PadY = 30f; + /// 가로 화면 카드 최대 폭 — 대사 상자(1400)보다 좁혀 한 줄을 눈으로 따라가기 쉽게. + public const float LandscapeMaxWidth = 1200f; + public const float DividerHeight = 2f; + /// 카드가 뜬 뒤 입력을 받지 않는 시간(초) — 걷던 손가락이 카드를 바로 넘기지 않게. + public const float InputDelay = 0.4f; + + // 대사 상자 — 무대(NpcDialogueUI.DrawStoryStage)와 카드가 같은 값을 쓴다. + public const float LandscapeBoxMaxWidth = 1400f; + public const float LandscapeBoxHeight = 350f; + public const float PortraitBoxHeight = 500f; + + /// 한 줄이 안 잘리는 상자 높이 — 한글 줄높이 ≈ 글자 × 1.35(rules/ui-layout.md). + public static float LineHeight(int fontSize) => Mathf.Ceil(fontSize * 1.35f); + + /// + /// 카드를 띄우는가 — 다시보기(저널)가 아니고, 장 데이터가 있고, 지난 이야기에 비지 않은 줄이 하나라도 있을 때. + /// 1장처럼 지난 이야기가 없는 장은 카드 없이 곧바로 대사다. + /// + public static bool ShouldShow(InsectGame.Story.StoryChapter chapter, bool replay) + { + if (replay || chapter == null || chapter.recap == null) return false; + for (int i = 0; i < chapter.recap.Count; i++) + if (!string.IsNullOrWhiteSpace(chapter.recap[i])) return true; + return false; + } + + /// 스토리 대사 상자(가상 좌표) — 가로 min(1400, 안전 폭)×350, 세로 안전 폭×500, 안전 영역 바닥. + public static Rect DialogueBox(HudFrame f) + { + float w = f.Portrait ? f.ContentWidth : Mathf.Min(LandscapeBoxMaxWidth, f.ContentWidth); + return f.BottomPanel(w, f.Portrait ? PortraitBoxHeight : LandscapeBoxHeight); + } + + /// 카드가 설 판 — 안전 영역 윗변부터 대사 상자 위 24px까지, 안전 폭 전부. + public static Rect Area(HudFrame f) + { + Rect box = DialogueBox(f); + float top = f.ContentTop; + float bottom = Mathf.Max(top + 1f, box.y - UITheme.Space.L); + return new Rect(f.ContentLeft, top, f.ContentWidth, bottom - top); + } + + public static float CardWidth(HudFrame f) => f.Portrait ? f.ContentWidth : Mathf.Min(LandscapeMaxWidth, f.ContentWidth); + + /// 카드 안 글자 폭 — 줄 높이를 이 폭으로 잰다. + public static float TextWidth(float cardWidth) => Mathf.Max(1f, cardWidth - PadX * 2f); + + /// 잰 높이(CalcHeight) → 본문 줄 상자 높이. 한 줄 높이(1.35배)보다 낮게 잡지 않는다. + public static float BodyHeight(float measured) => Mathf.Max(LineHeight(BodyFont), Mathf.Ceil(measured)); + + /// 카드 한 장의 배치. + public struct Plan + { + public Rect Card; + public Rect Title; + public Rect RecapHeader; + /// 지난 이야기 줄마다 하나. + public Rect[] Recap; + public Rect Divider; + public Rect GoalHeader; + public Rect Goal; + public bool HasGoal; + /// 판보다 커서 본문 줄을 줄였다(그리기의 LabelFit이 글자를 줄여 맞춘다). + public bool Squeezed; + } + + /// 본문 줄이 아닌 것(여백·제목·소제목·간격·구분선)의 높이. + public static float FixedHeight(int recapCount, bool hasGoal) + { + float h = PadY + LineHeight(TitleFont) + UITheme.Space.S; + if (recapCount > 0) + h += LineHeight(SectionFont) + UITheme.Space.XS + UITheme.Space.XS * (recapCount - 1); + if (hasGoal) + { + if (recapCount > 0) h += UITheme.Space.M + DividerHeight + UITheme.Space.M; + h += LineHeight(SectionFont) + UITheme.Space.XS; + } + return h + PadY; + } + + /// + /// 카드 배치 — () 가운데에 폭으로. 본문 줄 높이는 호출부가 잰 값 + /// ()이고, 가 0이면 목표 칸이 없다. 다 쌓은 높이가 판보다 크면 본문 줄만 + /// 같은 비율로 줄여 카드가 판 안에 든다. + /// + public static Plan Layout(Rect area, float cardWidth, IReadOnlyList recapHeights, float goalHeight) + { + int n = recapHeights != null ? recapHeights.Count : 0; + bool hasGoal = goalHeight > 0f; + float fixedH = FixedHeight(n, hasGoal); + float body = hasGoal ? goalHeight : 0f; + for (int i = 0; i < n; i++) body += Mathf.Max(0f, recapHeights[i]); + + float room = Mathf.Max(1f, area.height); + float k = 1f; + if (fixedH + body > room && body > 0f) k = Mathf.Clamp01((room - fixedH) / body); + float cardH = Mathf.Min(room, fixedH + body * k); + float w = Mathf.Clamp(cardWidth, 1f, Mathf.Max(1f, area.width)); + + var p = new Plan { HasGoal = hasGoal, Squeezed = k < 1f, Recap = new Rect[n] }; + p.Card = new Rect(area.x + (area.width - w) * 0.5f, area.y + (room - cardH) * 0.5f, w, cardH); + float x = p.Card.x + PadX; + float tw = TextWidth(w); + float y = p.Card.y + PadY; + p.Title = new Rect(x, y, tw, LineHeight(TitleFont)); + y += p.Title.height + UITheme.Space.S; + if (n > 0) + { + p.RecapHeader = new Rect(x, y, tw, LineHeight(SectionFont)); + y += p.RecapHeader.height + UITheme.Space.XS; + for (int i = 0; i < n; i++) + { + float h = Mathf.Max(1f, Mathf.Max(0f, recapHeights[i]) * k); + p.Recap[i] = new Rect(x, y, tw, h); + y += h + (i < n - 1 ? UITheme.Space.XS : 0f); + } + } + if (hasGoal) + { + if (n > 0) + { + y += UITheme.Space.M; + p.Divider = new Rect(x, y, tw, DividerHeight); + y += DividerHeight + UITheme.Space.M; + } + p.GoalHeader = new Rect(x, y, tw, LineHeight(SectionFont)); + y += p.GoalHeader.height + UITheme.Space.XS; + p.Goal = new Rect(x, y, tw, Mathf.Max(1f, goalHeight * k)); + } + return p; + } + } } diff --git a/Assets/Scripts/UI/OutfitVisualCapture.cs b/Assets/Scripts/UI/OutfitVisualCapture.cs new file mode 100644 index 0000000..cec7b7c --- /dev/null +++ b/Assets/Scripts/UI/OutfitVisualCapture.cs @@ -0,0 +1,174 @@ +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 의상 창·캐릭터 생성 화면의 **실제 IMGUI** 촬영 fixture(-battleScenario outfit). + /// + /// 두 화면은 OnGUI라 배치모드 캡처(OutfitRenderProbe)로는 3D 마네킹만 보이고 창은 안 보인다 + /// (rules/testing.md 「한계 셋」). 스탠드얼론 검수 빌드에서 ScreenCapture로 찍는다. + /// + /// 저장을 남기지 않는다 — 의상 매니저는 소유·장착을 PlayerPrefs에 쓰므로(해금·장착 연출용), + /// 시작 전에 두 키를 잡아 두었다가 끝나면 그대로 되돌린다. 입어보기·필터·확대는 창의 private 상태를 + /// 리플렉션으로 넣는다(버튼 좌표를 흉내 내면 해상도마다 깨진다). + /// + public static class OutfitVisualCapture + { + private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + private static readonly string[] GuardedKeys = { "InsectGame.Equipped", "InsectGame.OwnedOutfits" }; + + public static IEnumerator Run(string output, Camera camera) + { + camera.backgroundColor = new Color(0.12f, 0.16f, 0.14f); + + // ── 저장 보호 ── + Dictionary saved = new Dictionary(); + foreach (string k in GuardedKeys) + if (PlayerPrefs.HasKey(k)) saved[k] = PlayerPrefs.GetString(k); + + int shots = 0; + GameObject host = new GameObject("OutfitQA"); + try + { + CharacterOutfitManager outfit = host.AddComponent(); + OutfitBonusProvider bonus = host.AddComponent(); + bonus.AutoWire(outfit); + CharacterModelPreviewRenderer preview = new GameObject("CharacterModelPreviewQA").AddComponent(); + CharacterOutfitUI ui = new GameObject("CharacterOutfitUIQA").AddComponent(); + ui.AutoWire(outfit, bonus); + ui.AutoWire(preview); + + // 여러 벌을 가진 상태 — 보유·미보유·NEW·장착 테두리가 한 화면에 섞이게. + foreach (string id in new[] { "hat_straw", "hat_safari", "top_stripe", "top_galaxy", "top_vest", "bot_jeans", + "bot_overalls", "outer_raincoat", "shoe_waders", "bag_satchel" }) + outfit.UnlockItem(id); + HashSet news = (HashSet)typeof(CharacterOutfitManager).GetField("newItems", Private).GetValue(outfit); + news.Add("hat_flower"); + outfit.UnlockItem("hat_flower"); + news.Add("top_galaxy"); + outfit.Equip("top_stripe"); + + ui.Toggle(); + yield return Wait(2.2f); // 카드 썸네일은 2프레임에 1장씩 굽는다 + shots++; yield return Capture(output, "outfit-hat"); + + SetField(ui, "selectedSlot", OutfitSlot.Top); + yield return Wait(1.8f); + shots++; yield return Capture(output, "outfit-top"); + + // 여러 슬롯 입어보기(사기 전 조합 맞추기) — 카드 클릭이 넣는 것과 같은 상태 + Dictionary picks = + (Dictionary)typeof(CharacterOutfitUI).GetField("trySelections", Private).GetValue(ui); + picks[OutfitSlot.Top] = outfit.FindItem("top_galaxy"); + picks[OutfitSlot.Hat] = outfit.FindItem("hat_cowboy"); + picks[OutfitSlot.Bottom] = outfit.FindItem("bot_overalls"); + picks[OutfitSlot.Shoes] = outfit.FindItem("shoe_rocket"); + picks[OutfitSlot.Outerwear] = outfit.FindItem("outer_legendary"); + yield return Wait(0.8f); + shots++; yield return Capture(output, "outfit-tryon"); + + SetField(ui, "previewCloseUp", true); + yield return Wait(0.6f); + shots++; yield return Capture(output, "outfit-closeup"); + + picks.Clear(); + SetField(ui, "previewCloseUp", false); + SetField(ui, "selectedSlot", OutfitSlot.Outerwear); + System.Type filterType = typeof(CharacterOutfitUI).GetNestedType("ItemFilter", BindingFlags.NonPublic); + SetField(ui, "filter", System.Enum.Parse(filterType, "NotOwned")); + yield return Wait(1.6f); + shots++; yield return Capture(output, "outfit-outer-notowned"); + ui.CloseModal(); + yield return Wait(0.4f); + + // ── 캐시샵 — 재화·가격 라벨(예전 💎·🪙 이모지가 □로 깨지던 자리)과 왼쪽 3D 캐릭터 ── + // 매니저는 따로 둔다: 이미 있으면 Awake가 자기 GameObject를 파기하는데, host엔 의상 매니저가 있다. + GameObject shopHost = new GameObject("CashShopQA"); + shopHost.AddComponent(); + CashShopUI shop = new GameObject("CashShopUIQA").AddComponent(); + shop.AutoWire(preview); + shop.OpenAtTab(1); + yield return Wait(1.2f); + shots++; yield return Capture(output, "shop-items"); + shop.OpenAtTab(2); + yield return Wait(0.8f); + shots++; yield return Capture(output, "shop-boxes"); + shop.CloseModal(); + Object.Destroy(shop.gameObject); + Object.Destroy(shopHost); + yield return Wait(0.4f); + + // ── 캐릭터 생성 화면 ── + LoginUI login = new GameObject("LoginUIQA").AddComponent(); + login.AutoWire(preview); + typeof(LoginUI).GetMethod("EnterCharacterCreate", Private).Invoke(login, null); + yield return Wait(1.0f); + shots++; yield return Capture(output, "create-preset"); + + typeof(LoginUI).GetMethod("ApplyPreset", Private).Invoke(login, new object[] { 1 }); + System.Type stepType = typeof(LoginUI).GetNestedType("CreateStep", BindingFlags.Public | BindingFlags.NonPublic); + SetField(login, "createStep", System.Enum.Parse(stepType, "Customize")); + yield return Wait(1.0f); + shots++; yield return Capture(output, "create-customize"); + + SetField(login, "selectedHairStyle", 3); + SetField(login, "selectedHairColor", 4); + SetField(login, "selectedSkinColor", 3); + SetField(login, "selectedFaceType", 1); + yield return Wait(1.0f); + shots++; yield return Capture(output, "create-customize-changed"); + Object.Destroy(login.gameObject); + } + finally + { + // ── 저장 복원 ── + foreach (string k in GuardedKeys) + { + if (saved.TryGetValue(k, out string v)) PlayerPrefs.SetString(k, v); + else PlayerPrefs.DeleteKey(k); + } + PlayerPrefs.Save(); + } + + File.WriteAllText(Path.Combine(output, "README.txt"), + $"Actual standalone IMGUI. {shots} shots at {Screen.width}x{Screen.height} (mobile layout={UIScale.IsMobileLayout}).\n" + + "Outfit ownership/equipment written during the run are restored from a snapshot (no net PlayerPrefs change).\n" + + "Try-on selections, filter and close-up were injected into the window's private state (same state a card click sets).\n"); + Object.Destroy(host); + Application.Quit(shots > 0 ? 0 : 3); + } + + private static void SetField(object target, string name, object value) + { + FieldInfo f = target.GetType().GetField(name, Private); + if (f == null) throw new FixtureFieldMissing(target.GetType().Name, name); + f.SetValue(target, value); + } + + private static IEnumerator Wait(float seconds) + { + float until = Time.realtimeSinceStartup + seconds; + while (Time.realtimeSinceStartup < until) yield return null; + } + + private static IEnumerator Capture(string output, string name) + { + yield return new WaitForEndOfFrame(); + Texture2D shot = ScreenCapture.CaptureScreenshotAsTexture(); + File.WriteAllBytes(Path.Combine(output, name + ".png"), shot.EncodeToPNG()); + Object.Destroy(shot); + } + + private sealed class FixtureFieldMissing : System.Exception + { + public FixtureFieldMissing(string type, string field) : base(type + "." + field + " 필드가 없다 — 창 구조가 바뀌었으면 fixture도 고칠 것") { } + } + } +} +#endif diff --git a/Assets/Scripts/UI/OutfitVisualCapture.cs.meta b/Assets/Scripts/UI/OutfitVisualCapture.cs.meta new file mode 100644 index 0000000..0ed7cfd --- /dev/null +++ b/Assets/Scripts/UI/OutfitVisualCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 066ad55e1c37918418bed33fb27eb404 \ No newline at end of file diff --git a/Assets/Scripts/UI/PlayerHintOverlay.cs b/Assets/Scripts/UI/PlayerHintOverlay.cs index bf4de2a..5a29743 100644 --- a/Assets/Scripts/UI/PlayerHintOverlay.cs +++ b/Assets/Scripts/UI/PlayerHintOverlay.cs @@ -45,51 +45,62 @@ private void OnGUI() // 두 문구 모두 모달 뒤에 숨는다. 잠금 안내는 위 이유로, 차단 문구는 **대화창·컷신 위에 // 겹쳐 뜨기 때문**이다(스토리 NPC가 걸어와 말을 거는 지금 구조에선 리전에서 튕긴 직후 // 2초 창이 자주 열린다). 타이머 자체는 `PlayerMovement`의 frozen 분기가 줄인다. + // 「챔피언의 꿈」에서는 둘 다 띄우지 않는다 — 꿈의 잠금은 연출이라 ESC 안내가 틀리고, 꿈속엔 리전 경계가 없다. + if (DreamPrologueState.Active) return; bool modalOpen = ModalUIRegistry.IsAnyOpen(); bool showFrozen = playerMovement.IsFrozen && !modalOpen; float blockedAlpha = playerMovement.BlockedMessageAlpha; + // 차단 문구는 가운데 무대(HudStage)의 마지막 차례다. 시간은 PlayerMovement가 쥐고 있어 기다리게 할 수 없으니 + // 앞 차례(포획 결과·퀘스트 알림 등)가 서 있으면 이번엔 건너뛴다 — 다시 부딪히면 또 뜬다. bool showBlocked = blockedAlpha > 0f && !modalOpen - && !string.IsNullOrEmpty(playerMovement.BlockedMessage); + && !string.IsNullOrEmpty(playerMovement.BlockedMessage) + && HudStage.Request(HudStageItem.RegionLock); if (!showFrozen && !showBlocked) return; EnsureStyles(); UIScale.Begin(); + HudFrame frame = HudFrame.Current; if (showFrozen) { - const float h = 40f; - // **퀵액세스 바 위로 올린다.** `BottomY(h)` 그대로 두면 바(`BottomY(64)` + 배경 - // 여백 8)의 **안쪽에 통째로** 들어가 가운데 버튼 위에 글자가 찍혔다 — 이 문구가 - // 뜨는 조건(모달 없음)이 곧 바가 보이는 조건이라 겹침이 100%였다. - // x도 `ContentLeft`로 맞춘다. 예전엔 x만 `VirtualSafeLeft`(마진 미포함)이고 폭은 - // `ContentWidth`(마진 24 제외분)라, 가운데 정렬이 다른 UI 대비 24px 왼쪽으로 쏠렸다. - float y = UISafeLayout.BottomY(h) - QuickAccessBarUI.BarReservedHeight; - UIHelper.LabelFit( - new Rect(UISafeLayout.ContentLeft, y, UISafeLayout.ContentWidth, h), - "ESC를 누르면 이동 잠금을 해제합니다", frozenStyle); + UIHelper.LabelFit(FrozenRect(frame), "ESC를 누르면 이동 잠금을 해제합니다", frozenStyle); } if (showBlocked) { - // 화면 아래쪽 6할 — 비율 배치는 금지가 아니지만(rules/ui-layout.md) 고정 높이 - // 상자를 놓으므로 안전 영역 안으로 가둔다. - const float h = 40f; - float y = Mathf.Clamp(UIScale.VirtualScreenHeight * 0.6f, - UISafeLayout.ContentTop, UISafeLayout.ContentBottom - h); - Color col = BlockTextCol; col.a = blockedAlpha; blockStyle.normal.textColor = col; // 알파가 매 프레임 바뀐다(struct라 할당 아님) // 리전 이름 + 수문장 이름이 길이를 정하는데 상자는 고정이다(최장 30자쯤: // "이름 없는 자리 — 우듬지의 세계수나비에게 이겨야 열립니다"). 넘치면 글자를 줄여 맞춘다. - UIHelper.LabelFit( - new Rect(UISafeLayout.ContentLeft, y, UISafeLayout.ContentWidth, h), - playerMovement.BlockedMessage, blockStyle); + Rect blocked = BlockedRect(frame); + HudStage.Request(HudStageItem.RegionLock, blocked); + UIHelper.LabelFit(blocked, playerMovement.BlockedMessage, blockStyle); } UIScale.End(); } + public const float LineHeight = 40f; + public const float BlockedWidth = 900f; + + /// + /// 이동 잠금 안내의 자리 — 순수 계산. 동굴 입구 버튼 자리의 아랫줄이다: 잠긴 동안은 그 버튼도, 단축 바·잡기 버튼도 + /// 숨으니(모두 IsFrozen에서 물러난다) 비어 있는 자리다. 예전엔 바 높이만큼 위(BottomY−바)였는데, 바 높이가 바뀌자 + /// 데스크톱 퀘스트 칩·동굴 입구 버튼과 같은 줄이 됐다. + /// + public static Rect FrozenRect(HudFrame f) + { + Rect gate = SubAreaWorldBuilder.GateRect(f); + return new Rect(gate.x, gate.yMax - LineHeight, gate.width, LineHeight); + } + + /// 잠긴 리전 차단 문구 — 가운데 무대의 마지막 차례(). + public static Rect BlockedRect(HudFrame f) + { + return HudStage.Place(f, HudStageItem.RegionLock, BlockedWidth, LineHeight); + } + private void EnsureStyles() { if (stylesReady) return; diff --git a/Assets/Scripts/UI/PlayerStatusHUD.cs b/Assets/Scripts/UI/PlayerStatusHUD.cs index a71ada3..4506cfb 100644 --- a/Assets/Scripts/UI/PlayerStatusHUD.cs +++ b/Assets/Scripts/UI/PlayerStatusHUD.cs @@ -18,10 +18,10 @@ public class PlayerStatusHUD : MonoBehaviour // GUIStyle 캐싱 private GUIStyle sectionTitleStyle; + private GUIStyle playerNameStyle; private GUIStyle levelBadgeLabelStyle; private GUIStyle levelBadgeNumStyle; private GUIStyle xpTitleStyle; - private GUIStyle xpTextStyle; private GUIStyle xpPctStyle; private GUIStyle regionNameStyle; private GUIStyle regionSubStyle; @@ -46,6 +46,26 @@ public class PlayerStatusHUD : MonoBehaviour /// public bool IsExpanded => expanded; + /// + /// 닫힘 탭의 오른쪽 끝(가상 x). 상단 가운데 리전 배너가 세로 화면에서 이 탭과 우상단 단축 바 + /// 사이에 자리를 잡으려고 읽는다. 탭 폭이 바뀌면 배너도 같이 따라간다. + /// + public static float CollapsedTabRight => UIScale.VirtualSafeLeft + 8f + CollapsedTabWidth; + + private static float CollapsedTabWidth => UIScale.IsMobileLayout ? 84f : 64f; + + /// 닫힘 탭의 자리 — 순수 계산(전수 겹침 검사가 부른다). + public static Rect TabRect(HudFrame f) + { + return new Rect(f.SafeLeft + 8f, f.ContentTop, f.Mobile ? 84f : 64f, 140f); + } + + /// 펼친 패널의 자리(다 펼쳤을 때) — 순수 계산. 가운데 무대()가 이 오른쪽에서 시작한다. + public static Rect PanelRect(HudFrame f) + { + return new Rect(f.SafeLeft + 20f, f.ContentTop, PanelW, f.ClampHeight(PanelH)); + } + private bool expanded = true; private bool mobileLayoutInitialized; private float xpBarAnim; @@ -56,23 +76,11 @@ public class PlayerStatusHUD : MonoBehaviour private float subAreaAlertTimer; private bool subscribedSubArea; - // OnGUI 매 프레임 new Color 회피용 (alpha/scaled 동적 값 제외). - private static readonly Color PanelBgCol = new Color(0.03f, 0.04f, 0.08f, 0.92f); - private static readonly Color PanelAccentBlueCol = new Color(0.3f, 0.6f, 1f); - private static readonly Color PanelDividerCol = new Color(0.15f, 0.18f, 0.25f); - private static readonly Color LvBadgeBgDarkCol = new Color(0.15f, 0.25f, 0.5f); - private static readonly Color LvBadgeAccentCol = new Color(0.3f, 0.6f, 1f); - private static readonly Color XpBarBgCol = new Color(0.08f, 0.08f, 0.12f); - private static readonly Color XpBarFillDarkCol = new Color(0.2f, 0.5f, 0.9f); - private static readonly Color XpBarFillLightCol = new Color(0.3f, 0.65f, 1f); - private static readonly Color StatCandyPinkCol = new Color(1f, 0.7f, 0.85f); - private static readonly Color StatCoinGoldCol = new Color(1f, 0.85f, 0.3f); - private static readonly Color StatGemBlueCol = new Color(0.4f, 0.7f, 1f); - private static readonly Color StatTeamOrangeCol = new Color(1f, 0.6f, 0.3f); - private static readonly Color StatOwnedGreenCol = new Color(0.4f, 0.85f, 0.5f); - private static readonly Color StatDiscoveredBlueCol = new Color(0.6f, 0.8f, 1f); - private static readonly Color StatCapturedGoldCol = new Color(1f, 0.85f, 0.3f); - private static readonly Color RegionDefaultCol = new Color(0.6f, 0.7f, 0.8f); + // 색은 전부 UITheme 토큰에서 받는다(rules/ui-layout.md). 이 파일이 한때 자기 색 16개와 + // 각진 사각형 21개로 그려, 같은 화면의 미니맵·퀘스트 칩(HudCard)과 다른 앱처럼 보였다. + private const float PanelW = 480f; + private const float PanelH = 540f; + private const float TileH = 58f; // GetAllOwned 캐싱 — DrawCollectionSection 매 프레임 호출 회피 (CollectionUI 패턴). private int cachedOwnedCount; @@ -97,7 +105,7 @@ private string PlayerDisplayName { string saved = PlayerPrefs.GetString( InsectGame.Core.SaveScope.PrefsKey("InsectGame.Character.Name"), ""); - cachedPlayerName = string.IsNullOrWhiteSpace(saved) ? "PLAYER" : saved; + cachedPlayerName = string.IsNullOrWhiteSpace(saved) ? "탐험가" : saved; } return cachedPlayerName; } @@ -165,7 +173,10 @@ private void Update() // 모달이 열려 있는 동안에는 배너 수명을 태우지 않는다 — 위 OnGUI가 그리지 않으므로 // 그대로 두면 창을 닫았을 때 이미 사라진 뒤다(TutorialQuestUI의 완료 배너와 같은 처리). if (subAreaAlertTimer > 0f && !ModalUIRegistry.IsAnyOpen()) + { + HudStage.Request(HudStageItem.PlaceAlert); // 떠 있는 동안 무대의 다른 차례를 붙잡아 둔다 subAreaAlertTimer -= Time.deltaTime; + } } private void OnGUI() @@ -178,7 +189,7 @@ private void OnGUI() // 직접 히트테스트하고 `evt.Use()`로 소비한다. IMGUI는 z-order로 히트테스트를 // 가르지 않으므로, 모달의 좌상단 컨트롤을 누른 탭을 **이쪽이 먼저 먹고** // 상태 패널만 접혔다 펴진다(모바일 기본은 닫힘이라 그 자리에 탭이 서 있다). - if (ModalUIRegistry.IsAnyOpen()) return; + if (ModalUIRegistry.IsAnyOpen() || DreamPrologueState.Active) return; UIScale.Begin(); DrawSubAreaAlert(); @@ -187,16 +198,17 @@ private void OnGUI() InitStyles(); - float panelW = 480f; - float panelH = UISafeLayout.ClampHeight(540f); - float margin = 20f; - // 세이프 에어리어(노치/상태바) 안쪽으로 — 세로는 하네스의 ContentTop(인셋 + 세로 마진). - float safeL = SafeArea.Left / UIScale.Scale; - float py = UISafeLayout.ContentTop; + // 자리는 PanelRect/TabRect(순수 계산) — 세이프 에어리어 안쪽, 세로는 하네스의 ContentTop(인셋 + 세로 마진). + HudFrame frame = HudFrame.Current; + Rect openRect = PanelRect(frame); + float panelW = openRect.width; + float panelH = openRect.height; + float safeL = frame.SafeLeft; + float py = openRect.y; // 닫힘 상태에서는 패널을 화면 밖으로 '완전히' 밀어 잘린 숫자가 새어 보이지 않게 한다. // (기존엔 50px 띠만 남겨 우측 정렬된 스탯 값이 잘린 채 노출돼 깨져 보였음 — 가로/세로 공통 버그) - float openX = margin + safeL; + float openX = openRect.x; float closedX = -(panelW + 40f); float px = Mathf.Lerp(closedX, openX, toggleAnim); @@ -218,35 +230,18 @@ private void OnGUI() { FieldHudInput.RegisterBlockingRect(new Rect(px, py, panelW, panelH)); - GUI.color = PanelBgCol; - GUI.DrawTexture(new Rect(px, py, panelW, panelH), Texture2D.whiteTexture); - - GUI.color = PanelAccentBlueCol; - GUI.DrawTexture(new Rect(px, py, panelW, 4), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(px, py + panelH - 3, panelW, 3), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(px, py, 3, panelH), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(px + panelW - 3, py, 3, panelH), Texture2D.whiteTexture); - + // 미니맵·퀘스트 칩과 같은 반투명 HUD 카드 — 월드가 비치되 글자 대비는 유지된다. + UISurface.HudCard(new Rect(px, py, panelW, panelH)); GUI.color = Color.white; - float cy = py + 16; - - DrawLevelSection(px, cy, panelW); - cy += 135; - - DrawResourceSection(px, cy, panelW); - cy += 160; + DrawLevelSection(px, py + 14f, panelW); + DrawResourceSection(px, py + 142f, panelW); + DrawCollectionSection(px, py + 300f, panelW); + DrawRegionSection(px, py + 402f, panelW); - DrawCollectionSection(px, cy, panelW); - cy += 85; - - DrawRegionSection(px, cy, panelW); - - float toggleSize = UIScale.IsMobileLayout ? 58f : 38f; - panelToggleRect = new Rect(px + panelW - toggleSize - 8f, py + 8f, toggleSize, toggleSize); - GUI.color = PanelDividerCol; - GUI.DrawTexture(panelToggleRect, Texture2D.whiteTexture); - GUI.color = Color.white; + float toggleSize = UIScale.IsMobileLayout ? 58f : 44f; + panelToggleRect = new Rect(px + panelW - toggleSize - 12f, py + 12f, toggleSize, toggleSize); + UISurface.Rounded(panelToggleRect, UITheme.Instance.surfaceRaised, UITheme.Radius.Chip); GUI.Label(panelToggleRect, "◀", toggleStyle); } @@ -267,89 +262,74 @@ private void OnGUI() UIScale.End(); } - private void DrawLevelSection(float px, float cy, float pw) + private void DrawLevelSection(float px, float top, float pw) { + UITheme t = UITheme.Instance; int level = progress.Level; int xp = progress.CurrentXp; int xpNeeded = progress.XpToNextLevel; // 캐릭터 이름. 오래 리터럴 "PLAYER"였다 — 생성 화면이 이름을 받아 저장하고 // 클라우드 동기까지 하는데 게임 어디에서도 보여주지 않아 사실상 버려지는 값이었다. - // 한글 12자가 150px 상자를 넘길 수 있어 LabelFit으로 줄여 맞춘다(ui-layout.md). - UIHelper.LabelFit(new Rect(px + 20, cy, 150, 28), PlayerDisplayName, sectionTitleStyle); + // 한글 12자가 상자를 넘길 수 있어 LabelFit으로 줄여 맞춘다(ui-layout.md). + // 오른쪽 76px는 접기 버튼 자리다. + UIHelper.LabelFit(new Rect(px + 20f, top, pw - 96f, 34f), PlayerDisplayName, playerNameStyle); - float lvBadgeX = px + 20; - float lvBadgeY = cy + 32; + Rect badge = new Rect(px + 20f, top + 44f, 88f, 70f); + UISurface.Rounded(badge, Color.Lerp(t.surfaceRaised, t.accentMint, 0.45f), UITheme.Radius.Chip); + GUI.Label(new Rect(badge.x, badge.y + 2f, badge.width, 24f), "Lv", levelBadgeLabelStyle); + GUI.Label(new Rect(badge.x, badge.y + 22f, badge.width, 46f), level.ToString(), levelBadgeNumStyle); - GUI.color = LvBadgeBgDarkCol; - GUI.DrawTexture(new Rect(lvBadgeX, lvBadgeY, 84, 60), Texture2D.whiteTexture); - GUI.color = LvBadgeAccentCol; - GUI.DrawTexture(new Rect(lvBadgeX, lvBadgeY, 84, 3), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(lvBadgeX, lvBadgeY + 57, 84, 3), Texture2D.whiteTexture); - - GUI.color = Color.white; - GUI.Label(new Rect(lvBadgeX, lvBadgeY + 2, 84, 22), "LEVEL", levelBadgeLabelStyle); - GUI.Label(new Rect(lvBadgeX, lvBadgeY + 20, 84, 40), level.ToString(), levelBadgeNumStyle); - - float barX = lvBadgeX + 100; - float barW = pw - 140; - float barH = 26f; - float barY = lvBadgeY + 6; - - GUI.Label(new Rect(barX, lvBadgeY - 4, barW, 24), "경험치 (EXP)", xpTitleStyle); - - GUI.color = XpBarBgCol; - GUI.DrawTexture(new Rect(barX, barY + 24, barW, barH), Texture2D.whiteTexture); + float barX = badge.xMax + 16f; + float barW = px + pw - 20f - barX; + int percent = xpNeeded > 0 ? Mathf.RoundToInt((float)xp / xpNeeded * 100f) : 100; + GUI.Label(new Rect(barX, top + 46f, barW, 26f), "경험치", xpTitleStyle); + GUI.Label(new Rect(barX, top + 46f, barW, 26f), $"{xp} / {xpNeeded} · {percent}%", xpPctStyle); - if (xpBarAnim > 0) + // 20px 막대 — 둥근 9-slice는 테두리가 높이를 넘겨 뭉개지므로 각진 채로 둔다(ui-layout.md). + Rect track = new Rect(barX, top + 80f, barW, 20f); + UISurface.Flat(track, t.surfaceBase); + if (xpBarAnim > 0f) { - GUI.color = XpBarFillDarkCol; - GUI.DrawTexture(new Rect(barX, barY + 24 + barH / 2, barW * xpBarAnim, barH / 2), Texture2D.whiteTexture); - GUI.color = XpBarFillLightCol; - GUI.DrawTexture(new Rect(barX, barY + 24, barW * xpBarAnim, barH / 2), Texture2D.whiteTexture); - - float shine = Mathf.Sin(Time.time * 2f) * 0.15f; - if (shine > 0) - { - GUI.color = new Color(1f, 1f, 1f, shine); - GUI.DrawTexture(new Rect(barX, barY + 24, barW * xpBarAnim, barH), Texture2D.whiteTexture); - } + // 민트 — 보유 곤충 상세의 경험치 막대와 같은 색. 코랄은 이 테마에서 HP 위험·닫기 색이다. + Rect fill = new Rect(track.x, track.y, track.width * xpBarAnim, track.height); + UISurface.Flat(fill, t.accentMint); + // 윗면 광택 + 느린 반짝임 — 막대가 살아 있다는 신호만 준다. + float shine = 0.16f + Mathf.Max(0f, Mathf.Sin(Time.time * 2f)) * 0.12f; + UISurface.Flat(new Rect(fill.x, fill.y, fill.width, fill.height * 0.4f), new Color(1f, 1f, 1f, shine)); } - - GUI.color = Color.white; - GUI.Label(new Rect(barX, barY + 24, barW, barH), $"{xp} / {xpNeeded}", xpTextStyle); - - int percent = xpNeeded > 0 ? Mathf.RoundToInt((float)xp / xpNeeded * 100f) : 100; - GUI.Label(new Rect(barX, barY + 52, barW, 24), $"{percent}%", xpPctStyle); } - private void DrawResourceSection(float px, float cy, float pw) + private void DrawResourceSection(float px, float top, float pw) { - GUI.Label(new Rect(px + 20, cy, 150, 26), "RESOURCES", sectionTitleStyle); + UITheme t = UITheme.Instance; + GUI.Label(new Rect(px + 20f, top, pw - 40f, 26f), "재화", sectionTitleStyle); - float halfW = (pw - 56) / 2f; + float gap = 12f; + float halfW = (pw - 40f - gap) / 2f; + float row1Y = top + 30f; + float row2Y = row1Y + TileH + 8f; - // Row 1: 캔디 + 코인 - float row1Y = cy + 32; int candies = candyInventory != null ? candyInventory.Candies : 0; - DrawStatBox(px + 20, row1Y, halfW, 56, "캔디", candies.ToString(), StatCandyPinkCol); int coins = currencyWallet != null ? currencyWallet.Coins : 0; - DrawStatBox(px + 20 + halfW + 14, row1Y, halfW, 56, "코인", coins.ToString(), StatCoinGoldCol); - - // Row 2: 보석 + 배틀팀 - float row2Y = row1Y + 62; int gems = currencyWallet != null ? currencyWallet.Gems : 0; - DrawStatBox(px + 20, row2Y, halfW, 56, "보석", gems.ToString(), StatGemBlueCol); int teamCount = teamManager != null ? teamManager.FilledSlots : 0; - DrawStatBox(px + 20 + halfW + 14, row2Y, halfW, 56, "배틀팀", $"{teamCount}/5", StatTeamOrangeCol); + DrawStatBox(px + 20f, row1Y, halfW, TileH, "캔디", candies.ToString(), t.accentCoral); + DrawStatBox(px + 20f + halfW + gap, row1Y, halfW, TileH, "코인", coins.ToString(), t.coinColor); + // 보석은 파랑 — accentColor는 코랄로 동기화돼 있어(UITheme.SynchronizeLegacyTokens) 캔디와 같은 색이 됐다. + DrawStatBox(px + 20f, row2Y, halfW, TileH, "보석", gems.ToString(), t.itemRare); + DrawStatBox(px + 20f + halfW + gap, row2Y, halfW, TileH, "배틀팀", + $"{teamCount}/{BattleTeamManager.MaxSlots}", t.accentAmber); } - private void DrawCollectionSection(float px, float cy, float pw) + private void DrawCollectionSection(float px, float top, float pw) { - GUI.Label(new Rect(px + 20, cy, 180, 26), "COLLECTION", sectionTitleStyle); + UITheme t = UITheme.Instance; + GUI.Label(new Rect(px + 20f, top, pw - 40f, 26f), "수집", sectionTitleStyle); - float rowY = cy + 32; - float thirdW = (pw - 68) / 3f; + float gap = 10f; + float thirdW = (pw - 40f - gap * 2f) / 3f; + float rowY = top + 30f; // GetAllOwned 캐싱 — InsectUpdated 이벤트로 invalidate (매 프레임 List 할당 회피). if (ownedCountCacheDirty && insectCollection != null) @@ -357,7 +337,6 @@ private void DrawCollectionSection(float px, float cy, float pw) cachedOwnedCount = insectCollection.GetAllOwned().Count; ownedCountCacheDirty = false; } - DrawStatBox(px + 20, rowY, thirdW, 56, "보유", cachedOwnedCount.ToString(), StatOwnedGreenCol); int discovered = 0; int captured = 0; @@ -371,16 +350,18 @@ private void DrawCollectionSection(float px, float cy, float pw) if (r.capturedCount > 0) captured++; } } - DrawStatBox(px + 20 + thirdW + 12, rowY, thirdW, 56, "발견", discovered.ToString(), StatDiscoveredBlueCol); - DrawStatBox(px + 20 + (thirdW + 12) * 2, rowY, thirdW, 56, "포획", captured.ToString(), StatCapturedGoldCol); + DrawStatBox(px + 20f, rowY, thirdW, TileH, "보유", cachedOwnedCount.ToString(), t.accentMint); + DrawStatBox(px + 20f + thirdW + gap, rowY, thirdW, TileH, "발견", discovered.ToString(), t.textSecondary); + DrawStatBox(px + 20f + (thirdW + gap) * 2f, rowY, thirdW, TileH, "포획", captured.ToString(), t.accentAmber); } - private void DrawRegionSection(float px, float cy, float pw) + private void DrawRegionSection(float px, float top, float pw) { - GUI.Label(new Rect(px + 20, cy, 150, 26), "LOCATION", sectionTitleStyle); + UITheme t = UITheme.Instance; + GUI.Label(new Rect(px + 20f, top, pw - 40f, 26f), "현재 위치", sectionTitleStyle); string regionName = "탐험 중..."; - Color regionCol = RegionDefaultCol; + Color regionCol = t.textSecondary; string regionInsects = ""; if (regionManager != null && regionManager.CurrentRegion != null) { @@ -388,39 +369,45 @@ private void DrawRegionSection(float px, float cy, float pw) regionName = r.displayName; regionCol = r.themeColor; if (r.insectIds != null && r.insectIds.Length > 0) - regionInsects = $"출현 곤충: {r.insectIds.Length}종"; + regionInsects = $"출현 곤충 {r.insectIds.Length}종"; } - regionNameStyle.normal.textColor = regionCol; - GUI.Label(new Rect(px + 20, cy + 30, pw - 40, 36), regionName, regionNameStyle); + // 리전 색 점 + 이름 — 색을 글자에만 칠하면 어두운 테마색 리전은 읽기 어렵다. + UISurface.Rounded(new Rect(px + 20f, top + 40f, 14f, 14f), regionCol, 4f); + regionNameStyle.normal.textColor = Color.Lerp(regionCol, t.textPrimary, 0.25f); + UIHelper.LabelFit(new Rect(px + 42f, top + 28f, pw - 62f, 38f), regionName, regionNameStyle); // SubArea 안에서는 이름을 상시 표시 (▾ 표시 + 빛바랜 색) - float subY = cy + 62; + float subY = top + 68f; if (regionManager != null && regionManager.CurrentSubArea != null) { - Color subCol = new Color(regionCol.r * 0.85f + 0.15f, regionCol.g * 0.85f + 0.15f, regionCol.b * 0.85f + 0.15f); - regionSubStyle.normal.textColor = subCol; - UIHelper.LabelFit(new Rect(px + 20, subY, pw - 40, 24), $"▾ {regionManager.CurrentSubArea.displayName}", regionSubStyle); - subY += 22; + regionSubStyle.normal.textColor = Color.Lerp(regionCol, t.textPrimary, 0.4f); + UIHelper.LabelFit(new Rect(px + 42f, subY, pw - 62f, 26f), $"▾ {regionManager.CurrentSubArea.displayName}", regionSubStyle); + subY += 26f; } if (!string.IsNullOrEmpty(regionInsects)) - GUI.Label(new Rect(px + 20, subY, pw - 40, 24), regionInsects, regionSubStyle); + { + regionSubStyle.normal.textColor = t.textSecondary; + GUI.Label(new Rect(px + 42f, subY, pw - 62f, 26f), regionInsects, regionSubStyle); + } } + /// + /// 재화·수집 타일 — 둥근 표면 + 위쪽 액센트 줄. 줄은 3px라 각진 채로 두고 + /// 가로를 반경만큼 물려 둥근 모서리를 뚫지 않게 한다(ui-layout.md). + /// private void DrawStatBox(float x, float y, float w, float h, string label, string value, Color accent) { - GUI.color = new Color(accent.r * 0.08f, accent.g * 0.08f, accent.b * 0.08f, 0.8f); - GUI.DrawTexture(new Rect(x, y, w, h), Texture2D.whiteTexture); - GUI.color = accent; - GUI.DrawTexture(new Rect(x, y, w, 3), Texture2D.whiteTexture); + UITheme t = UITheme.Instance; + UISurface.Rounded(new Rect(x, y, w, h), t.surfaceRaised, UITheme.Radius.Chip); + UISurface.Flat(new Rect(x + UITheme.Radius.Chip, y + 3f, w - UITheme.Radius.Chip * 2f, 3f), accent); - statBoxLblStyle.normal.textColor = new Color(accent.r * 0.7f, accent.g * 0.7f, accent.b * 0.7f); GUI.color = Color.white; - GUI.Label(new Rect(x + 8, y + 4, w - 16, 22), label, statBoxLblStyle); + GUI.Label(new Rect(x + 12f, y + 8f, w - 24f, 22f), label, statBoxLblStyle); statBoxValStyle.normal.textColor = accent; - GUI.Label(new Rect(x + 8, y + 20, w - 16, 34), value, statBoxValStyle); + UIHelper.LabelFit(new Rect(x + 12f, y + 22f, w - 24f, 34f), value, statBoxValStyle); } public void AutoWire(PlayerProgressController prog, PlayerCandyInventory candy, @@ -468,119 +455,115 @@ private void InitStyles() { if (stylesInitialized) return; stylesInitialized = true; + UITheme t = UITheme.Instance; - sectionTitleStyle = new GUIStyle(GUI.skin.label) { fontSize = 19, fontStyle = FontStyle.Bold }; - sectionTitleStyle.normal.textColor = new Color(0.5f, 0.6f, 0.8f); + sectionTitleStyle = new GUIStyle(GUI.skin.label) { fontSize = 18, fontStyle = FontStyle.Bold }; + sectionTitleStyle.normal.textColor = t.textMuted; - levelBadgeLabelStyle = new GUIStyle(GUI.skin.label) { fontSize = 14, alignment = TextAnchor.MiddleCenter }; - levelBadgeLabelStyle.normal.textColor = new Color(0.5f, 0.7f, 1f); + playerNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 26, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + playerNameStyle.normal.textColor = t.textPrimary; - levelBadgeNumStyle = new GUIStyle(GUI.skin.label) { fontSize = 32, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - levelBadgeNumStyle.normal.textColor = Color.white; + levelBadgeLabelStyle = new GUIStyle(GUI.skin.label) { fontSize = 16, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + levelBadgeLabelStyle.normal.textColor = t.textSecondary; - xpTitleStyle = new GUIStyle(GUI.skin.label) { fontSize = 18, fontStyle = FontStyle.Bold }; - xpTitleStyle.normal.textColor = new Color(0.7f, 0.8f, 0.9f); + levelBadgeNumStyle = new GUIStyle(GUI.skin.label) { fontSize = 36, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + levelBadgeNumStyle.normal.textColor = t.textPrimary; - xpTextStyle = new GUIStyle(GUI.skin.label) { fontSize = 17, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - xpTextStyle.normal.textColor = new Color(0.9f, 0.95f, 1f); + xpTitleStyle = new GUIStyle(GUI.skin.label) { fontSize = 18, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + xpTitleStyle.normal.textColor = t.textSecondary; - xpPctStyle = new GUIStyle(GUI.skin.label) { fontSize = 17, alignment = TextAnchor.MiddleRight }; - xpPctStyle.normal.textColor = new Color(0.5f, 0.65f, 0.9f); + xpPctStyle = new GUIStyle(GUI.skin.label) { fontSize = 18, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleRight }; + xpPctStyle.normal.textColor = t.textPrimary; - regionNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 26, fontStyle = FontStyle.Bold }; + regionNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 28, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; - regionSubStyle = new GUIStyle(GUI.skin.label) { fontSize = 17 }; - regionSubStyle.normal.textColor = new Color(0.55f, 0.6f, 0.7f); + regionSubStyle = new GUIStyle(GUI.skin.label) { fontSize = 18, alignment = TextAnchor.MiddleLeft }; + regionSubStyle.normal.textColor = t.textSecondary; - statBoxLblStyle = new GUIStyle(GUI.skin.label) { fontSize = 16 }; - statBoxValStyle = new GUIStyle(GUI.skin.label) { fontSize = 24, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleRight }; + statBoxLblStyle = new GUIStyle(GUI.skin.label) { fontSize = 17, fontStyle = FontStyle.Bold }; + statBoxLblStyle.normal.textColor = t.textSecondary; + statBoxValStyle = new GUIStyle(GUI.skin.label) { fontSize = 26, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleRight }; toggleStyle = new GUIStyle(GUI.skin.label) { fontSize = 24, alignment = TextAnchor.MiddleCenter }; - toggleStyle.normal.textColor = new Color(0.6f, 0.7f, 0.9f); + toggleStyle.normal.textColor = t.textPrimary; alertNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; alertDescStyle = new GUIStyle(GUI.skin.label) { fontSize = 18, alignment = TextAnchor.MiddleCenter }; - // 닫힘 탭 전용 스타일 (textColor는 흰색 기반 — 알파는 GUI.color로 곱해 페이드) + // 닫힘 탭 전용 스타일 (알파는 GUI.color로 곱해 페이드) tabLabelStyle = new GUIStyle(GUI.skin.label) - { fontSize = 13, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - tabLabelStyle.normal.textColor = new Color(0.5f, 0.7f, 1f); + { fontSize = 16, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + tabLabelStyle.normal.textColor = t.textSecondary; tabNumStyle = new GUIStyle(GUI.skin.label) - { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - tabNumStyle.normal.textColor = Color.white; + { fontSize = 32, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + tabNumStyle.normal.textColor = t.textPrimary; tabHintStyle = new GUIStyle(GUI.skin.label) - { fontSize = 26, alignment = TextAnchor.MiddleCenter }; - tabHintStyle.normal.textColor = new Color(0.6f, 0.7f, 0.9f); + { fontSize = 24, alignment = TextAnchor.MiddleCenter }; + tabHintStyle.normal.textColor = t.textSecondary; } // OnGUI Rect 캐싱 회피용 — 닫힘 탭은 좌표가 safeL/py에만 의존해 매 프레임 new Rect를 만들지만 // 닫힘 상태에서만 그려지고 항목 수가 적어 영향 미미. 패널/탭 모두 IMGUI 관용 패턴 유지. private Rect DrawCollapsedTab(float safeL, float py, float strength) { - float tabW = UIScale.IsMobileLayout ? 72f : 54f; - float tabH = 134f; - float tabX = safeL + 6f; - float tabY = py; - Rect rect = new Rect(tabX, tabY, tabW, tabH); + Rect rect = TabRect(HudFrame.Current); + float tabW = rect.width; + float tabX = rect.x; + float tabY = rect.y; float a = Mathf.Clamp01(strength); if (a <= 0.001f) return rect; // 완전히 열림 — 탭은 그리지 않음 - // 배경 + accent (알파는 strength로 페이드) - GUI.color = new Color(PanelBgCol.r, PanelBgCol.g, PanelBgCol.b, PanelBgCol.a * a); - GUI.DrawTexture(rect, Texture2D.whiteTexture); - GUI.color = new Color(PanelAccentBlueCol.r, PanelAccentBlueCol.g, PanelAccentBlueCol.b, a); - GUI.DrawTexture(new Rect(tabX, tabY, tabW, 3), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(tabX + tabW - 3, tabY, 3, tabH), Texture2D.whiteTexture); - - // LV 뱃지 (닫힘 상태에서도 레벨 요약 표시) + // 알파는 GUI.color로 곱한다 — UISurface.Rounded가 호출부 알파를 살린다. GUI.color = new Color(1f, 1f, 1f, a); - GUI.Label(new Rect(tabX, tabY + 12, tabW, 18), "LV", tabLabelStyle); - GUI.Label(new Rect(tabX, tabY + 28, tabW, 36), progress.Level.ToString(), tabNumStyle); + UISurface.HudCard(rect); + + // 레벨 요약 — 닫힘 상태에서도 보인다. + GUI.Label(new Rect(tabX, tabY + 8f, tabW, 24f), "Lv", tabLabelStyle); + GUI.Label(new Rect(tabX, tabY + 30f, tabW, 40f), progress.Level.ToString(), tabNumStyle); - GUI.color = new Color(PanelDividerCol.r, PanelDividerCol.g, PanelDividerCol.b, a); - GUI.DrawTexture(new Rect(tabX + 12, tabY + 74, tabW - 24, 2), Texture2D.whiteTexture); + UISurface.Flat(new Rect(tabX + 14f, tabY + 80f, tabW - 28f, 2f), UITheme.Instance.surfaceBorder); // ▶ 펼치기 안내 - GUI.color = new Color(1f, 1f, 1f, a); - GUI.Label(new Rect(tabX, tabY + 84, tabW, 40), "▶", tabHintStyle); + GUI.Label(new Rect(tabX, tabY + 88f, tabW, 42f), "▶", tabHintStyle); GUI.color = Color.white; return rect; } + /// + /// 서브에리어·섬에 들어선 알림의 자리 — 가운데 무대의 고정 칸(). 예전엔 화면 폭 60%로 + /// 위쪽 가운데(ContentTop+70)에 떠서 리전 배너·내기 점수판·퀘스트 완료 알림·동굴 출입 토스트와 겹쳤다. + /// + public static Rect SubAreaAlertRect(HudFrame f) + { + return HudStage.Place(f, HudStageItem.PlaceAlert, f.Width * 0.6f, HudStage.PlaceAlertHeight); + } + private void DrawSubAreaAlert() { if (subAreaAlertTimer <= 0f) return; - + // 가운데 무대의 고정 칸(리전 진입 알림)에 선다 — 퀘스트 완료 같은 다른 카드는 이게 지나갈 때까지 기다린다. InitStyles(); + UITheme t = UITheme.Instance; float alpha = Mathf.Clamp01(subAreaAlertTimer / 0.5f); - float sw = UIScale.VirtualScreenWidth; - - // 배경 - // 세로 기준을 하네스로 — 이 배너만 y가 70/74/110으로 박혀 있어 노치 기기에서 - // 상태바 뒤로 들어갔다(VirtualSafeTop이 130쯤이면 배너 전체가 가려진다). - // +70은 상단 중앙 관례를 그대로 지킨 값이다: ContentTop(토스트) → +30(퀘스트 토스트) - // → 여기(+70~142) → +150(가이드 코치 배너). - float ay = UISafeLayout.ContentTop + 70f; - - GUI.color = new Color(0f, 0f, 0f, 0.75f * alpha); - GUI.DrawTexture(new Rect(sw * 0.2f, ay, sw * 0.6f, 72), Texture2D.whiteTexture); - // 상단 라인 - GUI.color = new Color(1f, 0.85f, 0.3f, 0.8f * alpha); - GUI.DrawTexture(new Rect(sw * 0.2f, ay, sw * 0.6f, 3), Texture2D.whiteTexture); - - // 서브에리어 이름 (캐시된 스타일 + 알파만 변경) - alertNameStyle.normal.textColor = new Color(1f, 0.9f, 0.4f, alpha); - GUI.color = Color.white; - GUI.Label(new Rect(sw * 0.2f, ay + 4f, sw * 0.6f, 38), subAreaAlertName, alertNameStyle); - - // 설명 - alertDescStyle.normal.textColor = new Color(0.8f, 0.8f, 0.8f, alpha * 0.9f); - GUI.Label(new Rect(sw * 0.2f, ay + 40f, sw * 0.6f, 26), subAreaAlertDesc, alertDescStyle); + Rect banner = SubAreaAlertRect(HudFrame.Current); + HudStage.Request(HudStageItem.PlaceAlert, banner); + float ay = banner.y; + + GUI.color = new Color(1f, 1f, 1f, alpha); + UISurface.HudCard(banner); + UISurface.Flat(new Rect(banner.x + UITheme.Radius.Card, banner.y + 3f, + banner.width - UITheme.Radius.Card * 2f, 3f), t.accentAmber); + + // 서브에리어 이름·설명 — 스타일은 캐시하고 알파만 GUI.color로 곱한다. + alertNameStyle.normal.textColor = t.accentAmber; + UIHelper.LabelFit(new Rect(banner.x + 16f, ay + 6f, banner.width - 32f, 38f), subAreaAlertName, alertNameStyle); + alertDescStyle.normal.textColor = t.textSecondary; + UIHelper.LabelFit(new Rect(banner.x + 16f, ay + 44f, banner.width - 32f, 26f), subAreaAlertDesc, alertDescStyle); GUI.color = Color.white; } diff --git a/Assets/Scripts/UI/QuestListLayout.cs b/Assets/Scripts/UI/QuestListLayout.cs index fd23bae..fd18a37 100644 --- a/Assets/Scripts/UI/QuestListLayout.cs +++ b/Assets/Scripts/UI/QuestListLayout.cs @@ -17,25 +17,42 @@ public static class QuestListLayout /// 펼쳤을 때 행 아래에 덧붙는 높이 — 설명 + 진행 바 + 보상 전체. public const float ExpandedExtra = 158f; - public static float GetRowHeight(bool expanded) - => expanded ? RowHeight + ExpandedExtra : RowHeight; + /// + /// 펼친 행에 [따라가기] 버튼이 붙을 때 더 붙는 높이(마을 의뢰). 버튼 64px — 모바일 터치 하한 + /// (UIScale.MinTouchHeight)을 넘기고 위아래 여백을 둔다. + /// + public const float TrackButtonExtra = 76f; + + /// [따라가기] 버튼 높이와 펼친 내용 아래 여백 — 합이 안이어야 한다. + public const float TrackButtonHeight = 64f; + public const float TrackButtonTopGap = 4f; + + public static float GetRowHeight(bool expanded, bool trackable = false) + { + if (!expanded) return RowHeight; + return RowHeight + ExpandedExtra + (trackable ? TrackButtonExtra : 0f); + } /// /// 스크롤 콘텐츠 총 높이. 스토리 섹션 헤더는 항상 그리고, 서브 섹션 헤더는 /// 서브 퀘스트가 하나라도 있을 때만 그린다(기존 렌더 동작과 동일). + /// 는 펼친 행 중 [따라가기]가 붙는 수 — + /// 행 높이()와 같은 판정을 넘겨야 스크롤 끝이 잘리지 않는다. /// - public static float GetContentHeight(int storyCount, int sideCount, int expandedCount) + public static float GetContentHeight(int storyCount, int sideCount, int expandedCount, + int expandedTrackableCount = 0) { int story = Mathf.Max(0, storyCount); int side = Mathf.Max(0, sideCount); int expanded = Mathf.Clamp(Mathf.Max(0, expandedCount), 0, story + side); + int trackable = Mathf.Clamp(Mathf.Max(0, expandedTrackableCount), 0, expanded); float height = SectionHeaderHeight + story * RowHeight; if (side > 0) { height += SectionHeaderHeight + side * RowHeight; } - return height + expanded * ExpandedExtra; + return height + expanded * ExpandedExtra + trackable * TrackButtonExtra; } } } diff --git a/Assets/Scripts/UI/QuickAccessBarUI.cs b/Assets/Scripts/UI/QuickAccessBarUI.cs index bc76d60..65b8077 100644 --- a/Assets/Scripts/UI/QuickAccessBarUI.cs +++ b/Assets/Scripts/UI/QuickAccessBarUI.cs @@ -4,17 +4,10 @@ namespace InsectGame.UI { - /// - /// 화면 진입점 11개를 한 줄로 모은 바. 데스크톱은 하단 가로, 모바일은 우측 세로다. - /// - /// 이 컴포넌트는 모달이 아니다. 예전엔 모바일에서 "메뉴 버튼 → 팝업" 2탭이라 - /// 그 팝업이 모달이었는데, 버튼을 직접 노출하도록 바꾸면서 팝업이 사라졌다. - /// IModalUI 구현과 mobileMenuOpen은 그 잔재로 남아 있었다 — - /// 어디서도 Register하지 않아 IsOpen은 영원히 false였고, - /// 그런데도 시그니처만 보면 이 바가 모달 스택에 참여하는 것처럼 읽혔다. - /// - public class QuickAccessBarUI : MonoBehaviour + /// Field shortcuts and an exclusive, keyboard-accessible destination menu. + public class QuickAccessBarUI : MonoBehaviour, IModalUI { + public enum Destination { Dex, Team, Training, Collection, Quest, Map, Outfit, Shop, Pvp, Story, Inventory, Settings, Badges, Island, Menu } [SerializeField] private DexScreenUI dexScreen; [SerializeField] private BattleTeamUI battleTeamUI; [SerializeField] private TrainingUI trainingUI; @@ -25,372 +18,268 @@ public class QuickAccessBarUI : MonoBehaviour [SerializeField] private TutorialQuestUI questUI; [SerializeField] private SocialPvpUI socialPvpUI; [SerializeField] private StoryJournalUI storyJournalUI; + [SerializeField] private BadgeCaseUI badgeCaseUI; [SerializeField] private InventoryUI inventoryScreen; - - // 전투/포획/미니게임 등 입력 차단용 신호 (CaptureInputController와 동일). + [SerializeField] private AccountSettingsUI settingsUI; + [SerializeField] private IslandVisitUI islandUI; [SerializeField] private BattleScreenUI battleScreen; [SerializeField] private RaidBattleUI raidScreen; [SerializeField] private PlayerMovement playerMovement; + public const float BarButtonHeight = 60f; + public const float BarReservedHeight = 88f; + private Vector2 menuScroll; + private readonly UIDirectScroll menuDirectScroll = new UIDirectScroll(); + private bool menuOpen; + private int lastToggleFrame = -1; + private GUIStyle buttonStyle; + public bool IsOpen => menuOpen; + private static readonly Destination[] shortcuts = { + Destination.Dex, Destination.Collection, Destination.Team, Destination.Inventory, + Destination.Quest, Destination.Map, Destination.Menu + }; + private static readonly Destination[] destinations = { Destination.Dex, Destination.Collection, Destination.Team, Destination.Training, Destination.Inventory, Destination.Quest, Destination.Story, Destination.Badges, Destination.Map, Destination.Island, Destination.Outfit, Destination.Shop, Destination.Pvp, Destination.Settings }; + public static System.Collections.Generic.IReadOnlyList Shortcuts => System.Array.AsReadOnly(shortcuts); + public static System.Collections.Generic.IReadOnlyList Destinations => System.Array.AsReadOnly(destinations); - private struct ButtonDef + public static KeyCode GetHotkey(Destination id) { - public string label; - /// - /// 안내 문구이자 실제 키 바인딩. 가 이 문자열을 - /// KeyCode로 파싱해 쓴다 — 키 표를 따로 적어 두지 않는다. - /// - public string key; - public Color color; - /// 미확인 완료 배지를 그리는 버튼인가(퀘스트 하나뿐이다). - public bool questBadge; + switch (id) + { + case Destination.Dex: return KeyCode.N; + case Destination.Team: return KeyCode.T; + case Destination.Training: return KeyCode.G; + case Destination.Collection: return KeyCode.C; + case Destination.Quest: return KeyCode.Q; + case Destination.Map: return KeyCode.M; + case Destination.Outfit: return KeyCode.P; + case Destination.Shop: return KeyCode.F4; + case Destination.Pvp: return KeyCode.F6; + case Destination.Story: return KeyCode.J; + case Destination.Inventory: return KeyCode.I; + case Destination.Badges: return KeyCode.K; + case Destination.Island: return KeyCode.H; + default: return KeyCode.None; + } } - private GUIStyle cachedBtnStyle; - private GUIStyle mobileGridButtonStyle; - private GUIStyle badgeStyleCache; - - /// 데스크톱 하단 바의 버튼 높이. 배치의 단일 출처다. - public const float BarButtonHeight = 64f; - - /// - /// 이 바가 화면 하단에서 실제로 먹는 높이(버튼 + 배경 여백 8 + 시각적 간격 4). - /// 다른 하단 UI는 이 값만큼 띄워야 한다 — `PlayerHintOverlay`가 그냥 - /// BottomY(40)을 쓰다가 바 안쪽에 통째로 들어가 버튼 위에 글자가 찍혔다. - /// 여기 값을 고치면 그쪽도 따라온다(사본을 두지 않는다). - /// - public const float BarReservedHeight = BarButtonHeight + 8f + 4f; - - private readonly ButtonDef[] buttons = new ButtonDef[] - { - new ButtonDef { label = "도감", key = "N", color = new Color(1f, 0.85f, 0.3f) }, - new ButtonDef { label = "배틀팀", key = "T", color = new Color(1f, 0.5f, 0.2f) }, - new ButtonDef { label = "훈련", key = "G", color = new Color(0.4f, 0.85f, 0.4f) }, - new ButtonDef { label = "컬렉션", key = "C", color = new Color(0.4f, 0.6f, 1f) }, - new ButtonDef { label = "퀘스트", key = "Q", color = new Color(0.3f, 0.9f, 0.7f), questBadge = true }, - new ButtonDef { label = "지도", key = "M", color = new Color(0.7f, 0.5f, 0.9f) }, - // '캐릭터'[V] 항목이 여기 있었다. 여는 화면(CharacterViewerUI)이 상점[F4]·의상[P]· - // 좌상단 PlayerStatusHUD의 완전한 중복이라 화면째 제거했다(사용자 요청). - new ButtonDef { label = "의상", key = "P", color = new Color(0.8f, 0.7f, 1f) }, - new ButtonDef { label = "상점", key = "F4", color = new Color(1f, 0.4f, 0.4f) }, - new ButtonDef { label = "PVP", key = "F6", color = new Color(0.25f, 0.75f, 1f) }, - // 스토리 저널 — 60비트로 늘어난 서사의 진행 상황을 보는 유일한 창구. - // 인덱스가 IsActive/OnClick의 case 번호다 — 그 둘은 아직 손으로 짝을 맞춘다. - new ButtonDef { label = "이야기", key = "J", color = new Color(1f, 0.79f, 0.3f) }, - // 가방 — 보유 아이템을 보고 쓰는 유일한 창구. **여기 없던 동안 아이템 시스템이 - // 통째로 잠겨 있었다**(uGUI 인벤토리는 만들어지기만 하고 여는 코드가 없었다). - // 맨 뒤에 붙인 이유는 아래 IsActive/OnClick의 case 번호가 이 배열 인덱스이기 때문이다 — - // 중간에 끼우면 그 아래 전부가 한 칸씩 어긋난다. - new ButtonDef { label = "가방", key = "I", color = new Color(1f, 0.66f, 0.38f) }, - }; - - /// - /// 버튼별 키 바인딩. buttons[].key에서 파생한다 — 예전엔 같은 표를 - /// Update와 OnGUI에 두 벌 더 적어 두었고, 그 둘은 인덱스로만 - /// 배열과 이어져 있었다. 항목을 중간에 하나 끼우면 세 표가 통째로 한 칸씩 어긋나 - /// C가 훈련을 열고 배지가 지도에 붙는다 — 예외도 경고도 없다. - /// (실제로 '캐릭터'[V] 항목을 배열에서 뺀 적이 있다.) - /// - /// 파싱 실패는 KeyCode.None으로 두고 경고한다. 조용히 안 먹는 키보다 낫다. - /// - private KeyCode[] hotkeyCache; - - private KeyCode[] Hotkeys() + private static string Label(Destination id) { - if (hotkeyCache != null) return hotkeyCache; - - hotkeyCache = new KeyCode[buttons.Length]; - for (int i = 0; i < buttons.Length; i++) + switch (id) { - if (System.Enum.TryParse(buttons[i].key, false, out KeyCode code)) - { - hotkeyCache[i] = code; - continue; - } - hotkeyCache[i] = KeyCode.None; - Debug.LogWarning($"[QuickBar] '{buttons[i].label}'의 키 표기 '{buttons[i].key}'를 " - + "KeyCode로 읽지 못했다 — 그 버튼은 키보드로 열리지 않는다"); + case Destination.Dex: return "도감"; + case Destination.Team: return "배틀팀"; + case Destination.Training: return "훈련"; + case Destination.Collection: return "보유 곤충"; + case Destination.Quest: return "퀘스트"; + case Destination.Map: return "지도"; + case Destination.Outfit: return "의상"; + case Destination.Shop: return "상점"; + case Destination.Pvp: return "PVP"; + case Destination.Story: return "이야기"; + case Destination.Inventory: return "가방"; + case Destination.Settings: return "설정 · 계정"; + case Destination.Badges: return "배지"; + case Destination.Island: return "내 섬"; + default: return "메뉴"; } - return hotkeyCache; } - private void Update() + public void CloseModal() { - if (IsInputBlocked()) return; + menuOpen = false; + menuDirectScroll.Reset(); + ModalUIRegistry.Unregister(this); + } + private void OnDisable() => CloseModal(); + private bool IsInputBlocked() => (battleScreen != null && battleScreen.IsBattleActive) + || (raidScreen != null && raidScreen.IsRaidActive) + || (playerMovement != null && playerMovement.IsFrozen); - KeyCode[] keys = Hotkeys(); - for (int i = 0; i < keys.Length; i++) - if (keys[i] != KeyCode.None && Input.GetKeyDown(keys[i])) TryHotkey(i); + private void Update() + { + if (IsInputBlocked()) { CloseModal(); return; } + foreach (Destination id in destinations) + { + KeyCode key = GetHotkey(id); + if (key != KeyCode.None && Input.GetKeyDown(key)) TryNavigate(id); + } } private void OnGUI() { - // 전투/포획/미니게임 중에는 핫키 처리도, 퀵바 렌더도 하지 않는다. if (IsInputBlocked()) return; - + if (menuOpen && !ReferenceEquals(ModalUIRegistry.TopModal, this)) return; Event e = Event.current; - if (e != null && e.type == EventType.KeyDown && e.keyCode != KeyCode.None) - { - // 소비는 TryHotkey가 통과했을 때만 한다. 모달 때문에 무시된 키를 - // e.Use()로 삼키면 그 모달의 텍스트필드가 글자를 못 받는다. - KeyCode[] keys = Hotkeys(); - for (int i = 0; i < keys.Length; i++) - { - if (keys[i] != e.keyCode) continue; - if (TryHotkey(i)) e.Use(); - break; - } - } - + if (e.type == EventType.KeyDown) + foreach (Destination id in destinations) + if (GetHotkey(id) != KeyCode.None && e.keyCode == GetHotkey(id) && TryNavigate(id)) { e.Use(); break; } + if (!menuOpen && (ModalUIRegistry.IsAnyOpen() || DreamPrologueState.Active)) return; UIScale.Begin(); - - if (UIScale.IsMobileLayout) - { - // 모달(도감/팀/배틀 등)이 열려 있지 않을 때만 — 필드 탐험 중 직접 접근용 우측 퀵바. - if (!ModalUIRegistry.IsAnyOpen()) - DrawMobileQuickBar(); - UIScale.End(); - return; - } - - float btnW = Mathf.Min(140f, (UIScale.VirtualScreenWidth - 100f) / buttons.Length); - const float btnH = BarButtonHeight; - float gap = 6f; - float totalW = buttons.Length * btnW + (buttons.Length - 1) * gap; - float startX = (UIScale.VirtualScreenWidth - totalW) / 2f; - // 제스처바(하단 세이프 인셋) + 세로 마진 위로. - float y = UISafeLayout.BottomY(btnH); - - Rect barRect = new Rect(startX - 14, y - 8, totalW + 28, btnH + 16); - - // **클릭-이동 억제 등록**(`CaptureInputController`의 '잡기' 버튼과 같은 이유). - // `PlayerMovement`는 `Input.GetMouseButtonDown(0)`을 Update에서 따로 폴링하는데, - // 탭한 프레임엔 아직 모달이 안 열려 `IsAnyOpen()`이 false이고 IMGUI라 `pointerOverUI`도 - // false다. 등록이 없으면 버튼 아래 월드 지점이 클릭 목표로 잡혀, 모달을 닫는 순간 - // 캐릭터가 거기로 걸어간다 — 메뉴를 열 때마다 매번. - FieldHudInput.RegisterBlockingRect(barRect); - - GUI.color = new Color(0, 0, 0, 0.5f); - GUI.DrawTexture(barRect, Texture2D.whiteTexture); - GUI.color = Color.white; - - for (int i = 0; i < buttons.Length; i++) - { - float bx = startX + i * (btnW + gap); - ButtonDef def = buttons[i]; - - bool isActive = IsActive(i); - - GUI.backgroundColor = isActive - ? new Color(def.color.r * 0.6f, def.color.g * 0.6f, def.color.b * 0.6f) - : new Color(def.color.r * 0.2f, def.color.g * 0.2f, def.color.b * 0.2f); - - if (cachedBtnStyle == null) - cachedBtnStyle = new GUIStyle(GUI.skin.button) - { fontSize = 20, fontStyle = FontStyle.Bold, richText = true }; - cachedBtnStyle.normal.textColor = isActive ? Color.white : def.color; - cachedBtnStyle.hover.textColor = Color.white; - - // **`OnClick`이 아니라 `TryHotkey`다.** 데스크톱 바는 모달이 열려 있어도 계속 - // 그려지므로(위 렌더 경로 주석 참조), 클릭이 가드를 안 거치면 도감을 켜 둔 채 - // 상점을 눌러 두 모달이 동시에 등록·렌더된다 — 뒤에 깔린 쪽이 "안 보이는데 입력만 - // 먹고" ESC도 그쪽부터 닫힌다. `TryHotkey`는 열려 있는 화면 자신의 버튼(=IsActive)만 - // 통과시키므로 토글로 닫는 동작은 그대로 산다. - if (GUI.Button(new Rect(bx, y, btnW, btnH), $"{def.label}\n[{def.key}]", cachedBtnStyle)) - TryHotkey(i); - - if (isActive) - { - GUI.color = def.color; - GUI.DrawTexture(new Rect(bx, y, btnW, 4), Texture2D.whiteTexture); - GUI.color = Color.white; - } - - if (def.questBadge) DrawQuestBadge(new Rect(bx, y, btnW, btnH)); - } - - GUI.backgroundColor = Color.white; + if (buttonStyle == null) + buttonStyle = new GUIStyle(GUI.skin.button) { fontSize = 24, fontStyle = FontStyle.Bold, richText = true }; + buttonStyle.normal.textColor = UITheme.Instance.textPrimary; + if (menuOpen) DrawMenu(); + else DrawShortcuts(); UIScale.End(); } - // 모바일: 우측 가장자리에 전 기능을 '직접' 노출하는 세로 퀵바. - // (기존엔 메뉴 버튼→팝업 2탭이라 번거로웠음. 메뉴 단계 제거.) - private void DrawMobileQuickBar() - { - EnsureMobileStyles(); - int n = buttons.Length; - float safeR = UIScale.VirtualSafeRight; - float gap = 8f; - float colW = 152f; - // 우측 상단 정렬 — 우하단의 원형 '잡기' 버튼 + '계정' 버튼 공간을 비운다. - // 상단 ~60% 영역에 배치(셀 높이 적응, 터치 최소 48). - float regionH = UISafeLayout.ContentHeight * 0.6f; - float cellH = Mathf.Clamp((regionH - 12f - (n - 1) * gap) / n, 48f, 88f); - float totalH = n * cellH + (n - 1) * gap; - float colX = UIScale.VirtualScreenWidth - safeR - colW - 14f; - float colY = UISafeLayout.ContentTop; - - Rect colRect = new Rect(colX - 8f, colY - 8f, colW + 16f, totalH + 16f); - - // 데스크톱 바와 같은 이유의 클릭-이동 억제 등록. - // **모바일이 더 나쁘다** — 키보드가 없어 `PlayerMovement`의 키 입력 기반 자동 해제도 - // 안 걸리므로, 한 번 잡힌 목표가 그대로 남아 모달을 닫자마자 걸어간다. - FieldHudInput.RegisterBlockingRect(colRect); - - // 컬럼 배경(가독성, 필드 가림 최소화를 위해 옅게) - GUI.color = new Color(0f, 0f, 0f, 0.3f); - GUI.DrawTexture(colRect, Texture2D.whiteTexture); - GUI.color = Color.white; - - for (int i = 0; i < n; i++) - { - float by = colY + i * (cellH + gap); - ButtonDef def = buttons[i]; - bool active = IsActive(i); - - GUI.backgroundColor = active - ? new Color(def.color.r * 0.62f, def.color.g * 0.62f, def.color.b * 0.62f, 1f) - : new Color(def.color.r * 0.26f, def.color.g * 0.26f, def.color.b * 0.26f, 0.92f); - mobileGridButtonStyle.normal.textColor = active ? Color.white : def.color; - if (GUI.Button(new Rect(colX, by, colW, cellH), def.label, mobileGridButtonStyle)) - TryHotkey(i); // 데스크톱과 같은 이유 — 모달 중첩 차단 + /// + /// 필드 단축 바의 자리(가상 좌표). 그리기와 같은 계산을 리전 배너()가 + /// 읽어 피해 간다 — 세로 화면에서 배너가 화면 중앙에 놓여 우상단 바 밑으로 파고들었다. + /// 데스크톱 폭 1200은 "보유 곤충 [C]"가 칸 안에 드는 값이다(1130일 때 "[C"에서 잘렸다). + /// + public static Rect ShortcutBarRect => + ShortcutBarRectIn(UIScale.IsMobileLayout, UISafeLayout.HorizontalBox, UISafeLayout.VerticalBox); - if (active) - { - GUI.color = def.color; - GUI.DrawTexture(new Rect(colX, by, colW, 4f), Texture2D.whiteTexture); - GUI.color = Color.white; - } + /// 화면 한 장()에서의 단축 바 — 전수 겹침 검사와 다른 HUD의 순수 배치가 부른다. + public static Rect ShortcutBarRectFor(HudFrame f) => ShortcutBarRectIn(f.Mobile, f.Horizontal, f.Vertical); - if (def.questBadge) DrawQuestBadge(new Rect(colX, by, colW, cellH)); - } - GUI.backgroundColor = Color.white; + /// + /// 의 순수 계산 — 안전 범위 두 축을 받는다(로 만든다). + /// 모바일은 TopPanel(324, h, Right), 데스크톱은 BottomPanel(1200, h)와 같은 값이다. + /// 퀘스트 칩()과 겹침 테스트가 화면 크기별로 이걸 읽는다. + /// + public static Rect ShortcutBarRectIn(bool mobile, UISafeLayout.SafeBox horizontal, UISafeLayout.SafeBox vertical) + { + int columns = mobile ? 2 : shortcuts.Length; + int rows = (shortcuts.Length + columns - 1) / columns; + float gap = mobile ? 8f : 6f; + float buttonHeight = mobile ? 68f : BarButtonHeight; + float desiredHeight = 16f + rows * buttonHeight + (rows - 1) * gap; + float w = UISafeLayout.ClampSize(mobile ? 324f : 1200f, horizontal); + float h = UISafeLayout.ClampSize(desiredHeight, vertical); + float x = UISafeLayout.AlignStart(w, mobile ? UISafeLayout.HAlign.Right : UISafeLayout.HAlign.Center, horizontal); + float y = mobile ? vertical.Start : UISafeLayout.EndStart(h, vertical); + return new Rect(x, y, w, h); } - // 퀘스트 버튼 우상단에 '미확인 완료' 개수 배지(빨간 사각 + 흰 숫자). 0이면 안 그린다. - // UIScale.Begin 매트릭스 안에서 호출되므로 좌표는 가상 캔버스 기준. - private void DrawQuestBadge(Rect btnRect) + private void DrawShortcuts() { - int count = TutorialQuestManager.Instance != null - ? TutorialQuestManager.Instance.UnseenCompletedCount : 0; - if (count <= 0) return; - - if (badgeStyleCache == null) + bool mobile = UIScale.IsMobileLayout; + int columns = mobile ? 2 : shortcuts.Length; + float gap = mobile ? 8f : 6f; + float buttonHeight = mobile ? 68f : BarButtonHeight; + Rect bar = ShortcutBarRect; + FieldHudInput.RegisterBlockingRect(bar); + UISurface.Card(bar); + float cellWidth = (bar.width - 16f - (columns - 1) * gap) / columns; + for (int i = 0; i < shortcuts.Length; i++) { - badgeStyleCache = new GUIStyle(GUI.skin.label) - { - fontSize = 18, - fontStyle = FontStyle.Bold, - alignment = TextAnchor.MiddleCenter - }; - badgeStyleCache.normal.textColor = Color.white; + Rect cell = new Rect(bar.x + 8f + (i % columns) * (cellWidth + gap), + bar.y + 8f + (i / columns) * (buttonHeight + gap), cellWidth, buttonHeight); + if (mobile && i == shortcuts.Length - 1) + cell.width = bar.width - 16f; + DrawDestination(cell, shortcuts[i]); } - - float d = Mathf.Clamp(btnRect.height * 0.45f, 24f, 32f); - Rect badge = new Rect(btnRect.xMax - d - 4f, btnRect.y + 4f, d, d); - - Color prev = GUI.color; - GUI.color = new Color(0.9f, 0.18f, 0.18f, 1f); - GUI.DrawTexture(badge, Texture2D.whiteTexture); - GUI.color = prev; - - GUI.Label(badge, count > 9 ? "9+" : count.ToString(), badgeStyleCache); } - private void EnsureMobileStyles() + private void DrawMenu() { - if (mobileGridButtonStyle != null) return; - mobileGridButtonStyle = new GUIStyle(GUI.skin.button) - { - fontSize = 24, - fontStyle = FontStyle.Bold, - alignment = TextAnchor.MiddleCenter - }; - mobileGridButtonStyle.normal.textColor = Color.white; - mobileGridButtonStyle.hover.textColor = Color.white; + UISurface.Dim(0.65f); + Rect panel = UISafeLayout.CenteredPanel(820f, 600f); + UISurface.Card(panel); + if (UISurface.Header(new Rect(panel.x + 3f, panel.y + 3f, panel.width - 6f, 78f), "탐험 메뉴", "")) { CloseModal(); return; } + float gap = UITheme.Space.S; + // 행 수는 목적지 수에서 센다 — 4로 박아 두면 13번째(배지)가 스크롤 밖으로 잘린다. + int rows = (destinations.Length + 2) / 3; + float cellW = (panel.width - 48f - gap * 2f) / 3f; + float cellH = Mathf.Max(UIScale.MinTouchHeight, (panel.height - 120f - gap * (rows - 1)) / rows); + Rect viewport = new Rect(panel.x + 24f, panel.y + 96f, panel.width - 48f, Mathf.Max(1f, panel.height - 112f)); + float contentH = rows * (cellH + gap); + menuDirectScroll.Handle(ref menuScroll, viewport, contentH, cellH); + menuScroll = GUI.BeginScrollView(viewport, menuScroll, new Rect(0f, 0f, viewport.width - 16f, contentH)); + cellW = (viewport.width - 16f - gap * 2f) / 3f; + for (int i = 0; i < destinations.Length; i++) + DrawDestination(new Rect((i % 3) * (cellW + gap), (i / 3) * (cellH + gap), cellW, cellH), destinations[i]); + GUI.EndScrollView(); + UISurface.ScrollAffordance(viewport, menuScroll, contentH, UITheme.Instance.accentMint); } - private bool IsActive(int index) + private void DrawDestination(Rect rect, Destination id) { - switch (index) + KeyCode key = GetHotkey(id); + string label = Label(id); + if (!UIScale.IsMobileLayout && key != KeyCode.None) label += $" [{key}]"; + bool enabled = GUI.enabled; + GUI.enabled = enabled && (id == Destination.Menu || Target(id) != null); + if (UISurface.Button(rect, label, UITheme.Instance.surfaceRaised, buttonStyle)) TryNavigate(id); + GUI.enabled = enabled; + if (id == Destination.Menu || id == Destination.Quest) { - case 0: return dexScreen != null && dexScreen.IsOpen; - case 1: return battleTeamUI != null && battleTeamUI.IsOpen; - case 2: return trainingUI != null && trainingUI.IsOpen; - case 3: return collectionUI != null && collectionUI.IsOpen; - case 4: return questUI != null && questUI.IsOpen; - case 5: return regionMapUI != null && regionMapUI.IsOpen; - case 6: return outfitUI != null && outfitUI.IsOpen; - case 7: return cashShopUI != null && cashShopUI.IsOpen; - case 8: return socialPvpUI != null && socialPvpUI.IsOpen; - case 9: return storyJournalUI != null && storyJournalUI.IsOpen; - case 10: return inventoryScreen != null && inventoryScreen.IsOpen; - default: return false; + int count = TutorialQuestManager.Instance != null ? TutorialQuestManager.Instance.UnseenCompletedCount : 0; + if (count > 0) UISurface.Chip(new Rect(rect.xMax - 36f, rect.y + 2f, 34f, 26f), count > 9 ? "9+" : count.ToString(), UITheme.Instance.accentCoral, UITheme.Instance.textPrimary); } + // 받을 이정표 보상이 있으면 점을 찍는다 — 옛 세이브는 케이스의 [받기]가 유일한 수령 경로다. + if (id == Destination.Badges && badgeCaseUI != null && badgeCaseUI.ClaimableCount > 0) + UISurface.Chip(new Rect(rect.xMax - 36f, rect.y + 2f, 34f, 26f), "!", UITheme.Instance.accentAmber, UITheme.Instance.surfaceBase); + // 조건을 채워 새로 얻은 의상 — 예전엔 소유 목록에만 조용히 붙어 아무도 몰랐다(CharacterOutfitManager.IsNew). + if (id == Destination.Outfit && CharacterOutfitManager.Instance != null && CharacterOutfitManager.Instance.HasAnyNew) + UISurface.Chip(new Rect(rect.xMax - 50f, rect.y + 2f, 48f, 26f), "NEW", UITheme.Instance.accentMint, UITheme.Instance.surfaceBase); + // 섬이 열렸는데 아직 안 가 봤거나, 수확물이 가득 찼다 — 섬은 필드에서 안 보이는 곳이라 여기서 알려야 한다. + if (id == Destination.Island && islandUI != null && islandUI.NeedsAttention) + UISurface.Chip(new Rect(rect.xMax - 36f, rect.y + 2f, 34f, 26f), "!", UITheme.Instance.accentAmber, UITheme.Instance.surfaceBase); } - private int lastToggleFrame = -1; - - // 전투(1v1/레이드)·포획·미니게임 중에는 메뉴 토글을 막는다. - // 배틀/레이드 진입 시 playerMovement.SetFrozen(true)가 호출되므로 frozen 하나로도 커버되나, - // CaptureInputController와 동일하게 명시적 신호도 함께 검사한다. - private bool IsInputBlocked() - { - if (battleScreen != null && battleScreen.IsBattleActive) return true; - if (raidScreen != null && raidScreen.IsRaidActive) return true; - if (playerMovement != null && playerMovement.IsFrozen) return true; - return false; - } - - /// - /// 핫키로 index를 토글한다. 다른 화면이 열려 있으면 무시하고 false를 반환한다 — - /// 호출부가 이벤트를 소비하지 않아야 그 화면의 텍스트필드가 글자를 받는다. - /// 지금 열려 있는 화면 자신의 키는 통과시켜 같은 키로 닫는 토글을 유지한다. - /// - /// - /// IsInputBlocked는 battle/raid/frozen만 본다. "모든 모달이 SetFrozen을 거니 - /// frozen 하나로 커버된다"는 전제가 깨져 있었다 — SocialPvpUI와 - /// WorldFieldMultiplayerUI는 모달로 등록하면서 SetFrozen을 부르지 않는다. - /// 그래서 친구코드·채팅 입력에 N/T/G/C/Q/M/P를 치면 글자마다 화면이 토글되고, - /// OnGUI의 e.Use()가 그 글자를 삼켜 입력조차 되지 않았다. - /// (렌더 경로는 이 가드를 쓰면 안 된다 — 데스크톱 바가 통째로 사라지고 - /// active 하이라이트 설계가 죽는다.) - /// - private bool TryHotkey(int index) + private IModalUI Target(Destination id) { - if (ModalUIRegistry.IsAnyOpen() && !IsActive(index)) return false; - OnClick(index); - return true; + switch (id) + { + case Destination.Dex: return dexScreen; + case Destination.Team: return battleTeamUI; + case Destination.Training: return trainingUI; + case Destination.Collection: return collectionUI; + case Destination.Quest: return questUI; + case Destination.Map: return regionMapUI; + case Destination.Outfit: return outfitUI; + case Destination.Shop: return cashShopUI; + case Destination.Pvp: return socialPvpUI; + case Destination.Story: return storyJournalUI; + case Destination.Badges: return badgeCaseUI; + case Destination.Inventory: return inventoryScreen; + case Destination.Settings: return settingsUI; + case Destination.Island: return islandUI; + default: return null; + } } - private void OnClick(int index) + private bool TryNavigate(Destination id) { - if (IsInputBlocked()) return; - if (Time.frameCount == lastToggleFrame) return; + if (IsInputBlocked()) return false; + IModalUI target = Target(id); + if (ModalUIRegistry.IsAnyOpenExcept(menuOpen ? typeof(QuickAccessBarUI) : null) + && (target == null || !ReferenceEquals(ModalUIRegistry.TopModal, target))) return false; + if (id != Destination.Menu && target == null) return false; + if (Time.frameCount == lastToggleFrame) return true; lastToggleFrame = Time.frameCount; - - switch (index) + if (id == Destination.Menu) { - case 0: if (dexScreen != null) dexScreen.Toggle(); break; - case 1: if (battleTeamUI != null) battleTeamUI.Toggle(); break; - case 2: if (trainingUI != null) trainingUI.Toggle(); break; - case 3: if (collectionUI != null) collectionUI.Toggle(); break; - case 4: if (questUI != null) questUI.Toggle(); break; - case 5: if (regionMapUI != null) regionMapUI.Toggle(); break; - case 6: if (outfitUI != null) outfitUI.Toggle(); break; - case 7: if (cashShopUI != null) cashShopUI.Toggle(); break; - case 8: if (socialPvpUI != null) socialPvpUI.Toggle(); break; - case 9: if (storyJournalUI != null) storyJournalUI.Toggle(); break; - case 10: if (inventoryScreen != null) inventoryScreen.Toggle(); break; - // 배열에 버튼만 늘리고 여기(와 IsActive)를 안 늘리면 그 버튼은 **아무 일도 - // 하지 않는다** — 눌러도 조용해서 배포까지 살아남는다. 누를 때만 찍히므로 - // 매 프레임 스팸이 되지 않는다. - default: - Debug.LogWarning($"[QuickBar] '{buttons[index].label}'(index {index})에 화면 " - + "배선이 없다 — buttons[]를 늘렸으면 IsActive/OnClick switch도 함께 늘린다"); - break; + if (menuOpen) CloseModal(); + else { menuOpen = true; ModalUIRegistry.Register(this); } + return true; } + CloseModal(); + switch (id) + { + case Destination.Dex: dexScreen.Toggle(); break; + case Destination.Team: battleTeamUI.Toggle(); break; + case Destination.Training: trainingUI.Toggle(); break; + case Destination.Collection: collectionUI.Toggle(); break; + case Destination.Quest: questUI.Toggle(); break; + case Destination.Map: regionMapUI.Toggle(); break; + case Destination.Outfit: outfitUI.Toggle(); break; + case Destination.Shop: cashShopUI.Toggle(); break; + case Destination.Pvp: socialPvpUI.Toggle(); break; + case Destination.Story: storyJournalUI.Toggle(); break; + case Destination.Badges: badgeCaseUI.Toggle(); break; + case Destination.Inventory: inventoryScreen.Toggle(); break; + case Destination.Settings: settingsUI.OpenSettings(); break; + case Destination.Island: islandUI.Toggle(); break; + } + return true; } + public void AutoWire(AccountSettingsUI settings) { if (settingsUI == null) settingsUI = settings; } + public void AutoWire(DexScreenUI dex, BattleTeamUI team, TrainingUI training, CollectionUI collection, RegionMapUI map) { @@ -399,6 +288,7 @@ public void AutoWire(DexScreenUI dex, BattleTeamUI team, TrainingUI training, if (trainingUI == null) trainingUI = training; if (collectionUI == null) collectionUI = collection; if (regionMapUI == null) regionMapUI = map; + collectionUI?.AutoWire(battleTeamUI, trainingUI); } public void AutoWire(CharacterOutfitUI outfit, CashShopUI cashShop) @@ -417,6 +307,11 @@ public void AutoWire(StoryJournalUI journal) if (storyJournalUI == null) storyJournalUI = journal; } + public void AutoWire(BadgeCaseUI badgeCase) + { + if (badgeCaseUI == null) badgeCaseUI = badgeCase; + } + public void AutoWire(SocialPvpUI social) { if (socialPvpUI == null) socialPvpUI = social; @@ -427,6 +322,11 @@ public void AutoWire(InventoryUI inventory) if (inventoryScreen == null) inventoryScreen = inventory; } + public void AutoWire(IslandVisitUI island) + { + if (islandUI == null) islandUI = island; + } + // 전투/포획/미니게임 중 입력 가드용 신호 주입. public void AutoWire(BattleScreenUI battle, RaidBattleUI raid, PlayerMovement movement) { diff --git a/Assets/Scripts/UI/RaidBattleUI.Draw.cs b/Assets/Scripts/UI/RaidBattleUI.Draw.cs index ea46db9..9becb2f 100644 --- a/Assets/Scripts/UI/RaidBattleUI.Draw.cs +++ b/Assets/Scripts/UI/RaidBattleUI.Draw.cs @@ -54,15 +54,12 @@ public partial class RaidBattleUI private GUIStyle teamHpNameStyleCache; // DrawTeamHpBars name private GUIStyle teamHpTextStyleCache; // DrawTeamHpBars hp text private GUIStyle teamHpLvStyleCache; // DrawTeamHpBars level - private GUIStyle introBossNameStyleCache; // DrawIntro boss name - private GUIStyle introSubStyleCache; // DrawIntro subtitle // 곤충 선택 패널(DrawInsectSelector)의 스타일 4개는 함께 사라졌다 — // 순차 턴에서는 차례가 슬롯 순서로 정해져 고를 패널 자체가 없다. private GUIStyle skillSelHeaderStyleCache; // DrawSkillSelector header private GUIStyle skillSelKeyStyleCache; // DrawSkillSelector key private GUIStyle skillSelNameStyleCache; // DrawSkillSelector name private GUIStyle skillSelTypeStyleCache; // DrawSkillSelector type - private GUIStyle skillSelEffStyleCache; // DrawSkillSelector 상성 배지(위력과 같은 줄, 우측) private GUIStyle skillSelInfoStyleCache; // DrawSkillSelector info private GUIStyle skillSelCdStyleCache; // DrawSkillSelector cooldown active private GUIStyle skillSelCdInfoStyleCache; // DrawSkillSelector cooldown info @@ -73,13 +70,8 @@ public partial class RaidBattleUI private GUIStyle aoeMemberDmgStyleCache; // DrawAttackEffects per-member AOE dmg private GUIStyle attackDmg2StyleCache; // DrawAttackEffects single-target dmg private GUIStyle actionTextStyleCache; // DrawActionText - private GUIStyle resultWinSubStyleCache; // DrawResult win subtitle - private GUIStyle resultValStyleCache; // DrawResult value - private GUIStyle resultBonusStyleCache; // DrawResult bonus - private GUIStyle resultWinHintStyleCache; // DrawResult win hint private GUIStyle resultFailStyleCache; // DrawResult fail title private GUIStyle resultFailSubStyleCache; // DrawResult fail sub - private GUIStyle resultFailHintStyleCache; // DrawResult fail hint private GUIStyle uniteBtnLabelStyleCache; // DrawUniteButton label private GUIStyle uniteBtnKeyHintStyleCache; // DrawUniteButton key hint private GUIStyle uniteGaugeLabelStyleCache; // DrawUniteGaugeBar label @@ -89,16 +81,16 @@ public partial class RaidBattleUI private GUIStyle buffTxtStyleCache; // DrawBuffDebuffEffect (buff) text private GUIStyle debuffArrowStyleCache; // DrawBuffDebuffEffect (debuff) arrow private GUIStyle debuffTxtStyleCache; // DrawBuffDebuffEffect (debuff) text - private GUIStyle introRaidBossStyleCache; // DrawIntro "RAID BOSS" - private GUIStyle introFightStyleCache; // DrawIntro "FIGHT!" + private GUIStyle introFightStyleCache; // 「팀의 턴」·보스 예고 큰 글자 private GUIStyle bossDmgNumStyleCache; // boss damage number (dynamic fontSize) - private GUIStyle resultWinTitleStyleCache; // DrawResult "RAID CLEAR!" private GUIStyle uniteLabelStyleCache; // DrawUniteAttackAnimation "★ 합체공격! ★" private GUIStyle uniteTotalStyleCache; // DrawUniteAttackAnimation total damage private GUIStyle bossIntentStyleCache; // 다음 보스 행동 예고 private GUIStyle comboStyleCache; // 동시 팀 러시 콤보 private GUIStyle slotContribStyleCache; // 슬롯별 기여(피해/회복/MISS) private GUIStyle slotSkillNameStyleCache; // 서포트가 쓴 스킬 이름 + private GUIStyle raidCritStyleCache; // 피해 숫자 위 「치명타!」 + // 변신 문구·수문장 배너·「○○의 모습」 스타일은 RaidBattleUI.Stage.cs(EnsureStageStyles)에 있다. /// /// 슬롯별 기여 문구를 라운드마다 한 번만 굽는다. OnGUI는 한 프레임에 여러 패스가 돌고 @@ -160,12 +152,6 @@ private void InitStyles() teamHpLvStyleCache = new GUIStyle(GUI.skin.label) { fontSize = 16 }; teamHpLvStyleCache.normal.textColor = new Color(0.6f, 0.6f, 0.6f); - introBossNameStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 38, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - - introSubStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 22, alignment = TextAnchor.MiddleCenter }; - skillSelHeaderStyleCache = new GUIStyle(GUI.skin.label) { fontSize = 26, @@ -193,10 +179,7 @@ private void InitStyles() skillSelTypeStyleCache = new GUIStyle(GUI.skin.label) { fontSize = 19, alignment = TextAnchor.MiddleLeft }; - // 상성 배지는 위력과 같은 줄의 오른쪽 칸이라 정렬이 달라 별도 스타일이다. 예전엔 - // skillSelTypeStyleCache를 재사용하며 색만 갈아 끼웠는데, 그러면 좌/우 정렬을 나눌 수 없다. - skillSelEffStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 19, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleRight }; + // 상성 칩은 BattleFeelDraw.MatchupChip이 자기 스타일로 그린다(타입 줄 오른쪽). skillSelInfoStyleCache = new GUIStyle(GUI.skin.label) { fontSize = 20, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; @@ -232,27 +215,12 @@ private void InitStyles() actionTextStyleCache = new GUIStyle(GUI.skin.label) { fontSize = 26, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - resultWinSubStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 22, alignment = TextAnchor.MiddleCenter }; - - resultValStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 24, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - - resultBonusStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 20, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - - resultWinHintStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 18, alignment = TextAnchor.MiddleCenter }; - resultFailStyleCache = new GUIStyle(GUI.skin.label) { fontSize = 52, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; resultFailSubStyleCache = new GUIStyle(GUI.skin.label) { fontSize = 22, alignment = TextAnchor.MiddleCenter }; - resultFailHintStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 18, alignment = TextAnchor.MiddleCenter }; - uniteBtnLabelStyleCache = new GUIStyle(GUI.skin.label) { fontSize = 20, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; uniteBtnLabelStyleCache.normal.textColor = new Color(1f, 0.95f, 0.5f); @@ -282,14 +250,10 @@ private void InitStyles() { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; // 동적 fontSize 캐시 (호출부에서 fontSize만 갱신) - introRaidBossStyleCache = new GUIStyle(GUI.skin.label) - { fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; introFightStyleCache = new GUIStyle(GUI.skin.label) { fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; bossDmgNumStyleCache = new GUIStyle(GUI.skin.label) { fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - resultWinTitleStyleCache = new GUIStyle(GUI.skin.label) - { fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; uniteLabelStyleCache = new GUIStyle(GUI.skin.label) { fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; uniteTotalStyleCache = new GUIStyle(GUI.skin.label) @@ -324,21 +288,22 @@ private void InitStyles() alignment = TextAnchor.MiddleCenter }; comboStyleCache.normal.textColor = new Color(0.45f, 0.95f, 1f); + + raidCritStyleCache = new GUIStyle(GUI.skin.label) + { + fontSize = 28, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter, + wordWrap = false + }; } private void DrawOverlay() { float sw = UIScale.VirtualScreenWidth; float sh = UIScale.VirtualScreenHeight; - // 3D 아레나 활성 시 2D 배경 스킵 - if (arena != null && arena.IsActive) - { - GUI.color = new Color(0.02f, 0.02f, 0.05f, 0.7f); - GUI.DrawTexture(new Rect(0, 0, sw, sh * 0.06f), Texture2D.whiteTexture); - GUI.color = Color.white; - GUI.Label(new Rect(0, 4, sw, 30), "RAID BATTLE", raidTurnStyleCache); - return; - } + // Boss panel already provides the raid title; keep the forest unobscured. + if (arena != null && arena.IsActive) return; float arenaY = sh * 0.06f; float arenaH = sh * 0.49f; @@ -433,10 +398,10 @@ private void DrawBossField() float bx = UIScale.VirtualScreenWidth * 0.5f; float by = UIScale.VirtualScreenHeight * 0.12f; - float breath = Mathf.Sin(Time.time * 1.5f) * 3f; + float breath = BattlePresentation.ReducedMotion ? 0f : (Mathf.Sin(Time.time * 1.5f) * 3f); by += breath; - if (bossShake > 0) + if (!BattlePresentation.ReducedMotion && bossShake > 0) { bx += Mathf.Sin(Time.time * 55f) * 12f; by += Mathf.Cos(Time.time * 55f) * 8f; @@ -450,6 +415,10 @@ private void DrawBossField() GUI.DrawTexture(new Rect(bx - 100, by - 100, 200, 200), Texture2D.whiteTexture); DrawBossSprite(bx, by, raidController.BossStats.Data, 5.5f); + // 2D 그림도 정체 그대로 — 빌린 모습은 이름 아래에 작게(RaidBattleUI.Stage). + string formCaption = BossFormCaption(); + if (formCaption != null) + DrawBossFormCaption(RaidStageLayout.SpriteFormCaption(bx, by + 34f * 5.5f + 24f), formCaption, true); } private void DrawBossSprite(float cx, float cy, InsectData data, float scale) { @@ -830,10 +799,10 @@ private void DrawTeamField() float ix = count > 1 ? startX + i * spacing : UIScale.VirtualScreenWidth * 0.5f; float iy = y; - float teamBreath = Mathf.Sin(Time.time * 2f + i * 0.8f) * 2f; + float teamBreath = BattlePresentation.ReducedMotion ? 0f : (Mathf.Sin(Time.time * 2f + i * 0.8f) * 2f); iy += teamBreath; - if (teamShake != null && i < teamShake.Length && teamShake[i] > 0) + if (!BattlePresentation.ReducedMotion && teamShake != null && i < teamShake.Length && teamShake[i] > 0) { ix += Mathf.Sin(Time.time * 55f) * 7f; iy += Mathf.Cos(Time.time * 55f) * 4f; @@ -943,37 +912,49 @@ private void DrawMiniInsect(float cx, float cy, InsectData data, float scale, fl private void DrawBossHpBar() { if (raidController.BossStats == null) return; + // 이긴 결과 화면은 위쪽을 「레이드 승리!」가 쓴다 — 보스는 이미 잡혔다(RaidBattleUI.Feel). + if (phase == Phase.Result && raidController.PlayerWon) return; var boss = raidController.BossStats; - float w = Mathf.Min(700, UIScale.VirtualScreenWidth * 0.7f); - float h = 100f; - float x = (UIScale.VirtualScreenWidth - w) / 2f; - float y = UISafeLayout.ContentTop; // 노치/상태바 + 세로 마진 아래로 - - GUI.color = new Color(0.05f, 0.03f, 0.08f, 0.95f); - GUI.DrawTexture(new Rect(x, y, w, h), Texture2D.whiteTexture); - - Color rarityCol = UITheme.Instance.GetInsectRarityColor(boss.Data.rarity); - GUI.color = new Color(0.9f, 0.15f, 0.1f); - GUI.DrawTexture(new Rect(x, y, w, 4), Texture2D.whiteTexture); - GUI.color = new Color(0.9f, 0.15f, 0.1f, 0.3f); - GUI.DrawTexture(new Rect(x, y + h - 2, w, 2), Texture2D.whiteTexture); - - bossHpNameStyleCache.normal.textColor = rarityCol; + // 위 가운데, 노치/상태바 + 세로 마진 아래로 — 자리는 순수 계산 한 곳(RaidStageLayout.BossCard). + Rect bossCardRect = RaidStageLayout.BossCard(HudFrame.Current); + float w = bossCardRect.width; + float h = bossCardRect.height; + float x = bossCardRect.x; + float y = bossCardRect.y; + + UISurface.Card(new Rect(x, y, w, h), UITheme.Instance.surfaceBase, UITheme.Instance.surfaceBorder); + UISurface.Flat(new Rect(x + 12f, y + 3f, w - 24f, 3f), UITheme.Instance.accentCoral); + bossHpNameStyleCache.normal.textColor = UITheme.Instance.textPrimary; GUI.color = Color.white; - UIHelper.LabelFit(new Rect(x + 14, y + 8, w * 0.5f, 30), - $"BOSS {boss.Data.displayName}", bossHpNameStyleCache); + UIHelper.LabelFit(new Rect(x + 14, y + 8, w - 150f, 32), + $"레이드 보스 · {boss.Data.displayName}", bossHpNameStyleCache); GUI.Label(new Rect(x + w - 130, y + 8, 116, 26), $"Lv.{boss.Level}", bossHpLvStyleCache); - UIHelper.LabelFit(new Rect(x + 14, y + 38, w - 28, 20), - $"ATK {boss.Attack} DEF {boss.Defense}", bossHpMiniStatStyleCache); + // 이름은 정체(「이름 없는 사마귀」) 그대로, 그 아래 줄 왼쪽에 지금 빌린 모습을 작게 — 원래 모습이면 예전 그대로다. + string formCaption = BossFormCaption(); + if (formCaption != null) + { + Rect bossCard = new Rect(x, y, w, h); + DrawBossFormCaption(RaidStageLayout.BossFormCaptionRow(bossCard), formCaption); // RaidBattleUI.Stage + Rect statRect = RaidStageLayout.BossStatRow(bossCard); + TextAnchor statAnchor = bossHpMiniStatStyleCache.alignment; + bossHpMiniStatStyleCache.alignment = TextAnchor.MiddleRight; + UIHelper.LabelFit(statRect, $"ATK {boss.Attack} DEF {boss.Defense}", bossHpMiniStatStyleCache); + bossHpMiniStatStyleCache.alignment = statAnchor; + } + else + { + UIHelper.LabelFit(new Rect(x + 14, y + 38, w - 28, 20), + $"ATK {boss.Attack} DEF {boss.Defense}", bossHpMiniStatStyleCache); + } float barX = x + 14; float barY = y + 62; float barW = w - 28; float barH = 26f; - GUI.color = new Color(0.12f, 0.08f, 0.12f); + GUI.color = UITheme.Instance.surfaceRaised; GUI.DrawTexture(new Rect(barX, barY, barW, barH), Texture2D.whiteTexture); float hpRatio = boss.MaxHp > 0 ? displayBossHp / boss.MaxHp : 0; @@ -992,171 +973,51 @@ private void DrawBossHpBar() private void DrawTeamHpBars() { if (raidController.TeamStats == null) return; + UITheme theme = UITheme.Instance; int count = raidController.TeamStats.Length; - float barW = 180f; - float barH = 82f; - float totalW2 = count * (barW + 10); - float startX = (UIScale.VirtualScreenWidth - totalW2) / 2f; - float y = UIScale.VirtualScreenHeight * 0.53f; - + if (count == 0) return; + // 수문장 등장·그림자 변신·결과 동안은 숨는다(RaidBattleUI.Impact — 보스 아랫부분·변신 연기를 가렸다). + float visible = TeamStripVisibility(); + if (visible <= 0.001f) return; + Color prevColor = GUI.color; + GUI.color = new Color(prevColor.r, prevColor.g, prevColor.b, prevColor.a * visible); + float barW = Mathf.Min(200f, (UISafeLayout.ContentWidth - (count - 1) * 10f) / count); + float totalW = count * barW + (count - 1) * 10f; + float startX = UISafeLayout.Content.center.x - totalW * 0.5f; + float y = TeamStripY(); // 3D 연출 중엔 화면 아래로 비켜선다(RaidBattleUI.Impact) for (int i = 0; i < count; i++) { var stats = raidController.TeamStats[i]; if (stats == null) continue; bool alive = stats.CurrentHp > 0; - // 순차 턴이라 "지금 차례"가 곧 선택이다 — 컨트롤러의 ActiveSlot이 단일 출처. - bool sel = alive && i == raidController.ActiveSlot; + bool selected = alive && i == raidController.ActiveSlot; bool acted = raidController.HasActedThisRound(i); - float bx = startX + i * (barW + 10); - - GUI.color = sel ? new Color(0.1f, 0.12f, 0.22f, 0.95f) : new Color(0.05f, 0.06f, 0.10f, alive ? 0.92f : 0.5f); - GUI.DrawTexture(new Rect(bx, y, barW, barH), Texture2D.whiteTexture); - - Color rarityCol = UITheme.Instance.GetInsectRarityColor(stats.Data.rarity); - GUI.color = sel ? new Color(1f, 0.85f, 0.2f) : (alive ? rarityCol : new Color(0.3f, 0.3f, 0.3f)); - GUI.DrawTexture(new Rect(bx, y, barW, 4), Texture2D.whiteTexture); - - // 행동을 마친 팀원은 한 겹 어둡게 덮어 "이번 라운드엔 끝났다"를 보여준다. - if (acted && alive) - { - GUI.color = new Color(0f, 0f, 0f, 0.42f); - GUI.DrawTexture(new Rect(bx, y, barW, barH), Texture2D.whiteTexture); - } - GUI.color = Color.white; - - teamHpNameStyleCache.normal.textColor = alive ? Color.white : new Color(0.4f, 0.4f, 0.4f); - // 차례 표시가 곧 팀 레이드의 진행 상황이다: ▶ 지금 / ✓ 완료 / 번호만 대기. - string turnMark = !alive ? $"{i + 1}." : sel ? "▶" : acted ? "✓" : $"{i + 1}."; - UIHelper.LabelFit(new Rect(bx + 8, y + 6, barW - 16, 22), - $"{turnMark} {stats.Data.displayName}", teamHpNameStyleCache); - - float hbX = bx + 8; - float hbY = y + 32; - float hbW = barW - 16; - float hbH = 20; - - GUI.color = new Color(0.12f, 0.12f, 0.18f); - GUI.DrawTexture(new Rect(hbX, hbY, hbW, hbH), Texture2D.whiteTexture); - - float dhp = displayTeamHp != null && i < displayTeamHp.Length ? displayTeamHp[i] : stats.CurrentHp; - float ratio = stats.MaxHp > 0 ? dhp / stats.MaxHp : 0; - Color hpCol = ratio > 0.5f ? new Color(0.3f, 0.85f, 0.35f) : - ratio > 0.2f ? new Color(0.95f, 0.8f, 0.2f) : - new Color(0.95f, 0.25f, 0.2f); - GUI.color = alive ? hpCol : new Color(0.3f, 0.15f, 0.15f); - GUI.DrawTexture(new Rect(hbX, hbY, hbW * Mathf.Clamp01(ratio), hbH), Texture2D.whiteTexture); - - GUI.color = Color.white; - GUI.Label(new Rect(hbX, hbY, hbW, hbH), - alive ? $"{Mathf.CeilToInt(dhp)}/{stats.MaxHp}" : "KO", teamHpTextStyleCache); - - UIHelper.LabelFit(new Rect(bx + 8, y + 58, barW - 16, 18), $"Lv.{stats.Level}", teamHpLvStyleCache); - } + float x = startX + i * (barW + 10f); + UISurface.Card(new Rect(x, y, barW, 104f), selected ? theme.surfaceRaised : theme.surfaceBase, + selected ? theme.accentMint : theme.surfaceBorder); + teamHpNameStyleCache.normal.textColor = theme.textPrimary; + UIHelper.LabelFit(new Rect(x + 10f, y + 8f, barW - 20f, 28f), stats.Data.displayName, teamHpNameStyleCache); + float hp = displayTeamHp != null && i < displayTeamHp.Length ? displayTeamHp[i] : stats.CurrentHp; + float ratio = stats.MaxHp > 0 ? Mathf.Clamp01(hp / stats.MaxHp) : 0f; + UISurface.Flat(new Rect(x + 10f, y + 40f, barW - 20f, 6f), theme.surfaceRaised); + UISurface.Flat(new Rect(x + 10f, y + 40f, (barW - 20f) * ratio, 6f), theme.accentMint); + teamHpTextStyleCache.normal.textColor = theme.textPrimary; + UIHelper.LabelFit(new Rect(x + 10f, y + 48f, barW - 20f, 26f), + alive ? $"{Mathf.CeilToInt(hp)} / {stats.MaxHp}" : "전투 불능", teamHpTextStyleCache); + teamHpLvStyleCache.normal.textColor = selected ? theme.accentMint : theme.textSecondary; + string state = !alive ? "쓰러짐" : selected ? "현재 차례" : acted ? "행동 완료" : "대기"; + UIHelper.LabelFit(new Rect(x + 10f, y + 76f, barW - 20f, 26f), $"Lv.{stats.Level} · {state}", teamHpLvStyleCache); + } + GUI.color = prevColor; } private void DrawIntro() { if (raidController.BossStats == null) return; - - float alpha = Mathf.Clamp01(introTimer / 0.6f); - float cx = UIScale.VirtualScreenWidth / 2f; - float cy = UIScale.VirtualScreenHeight * 0.30f; - float sw = UIScale.VirtualScreenWidth; - float sh = UIScale.VirtualScreenHeight; - - Color ec = UITheme.Instance.GetInsectRarityColor(raidController.BossStats.Data.rarity); - int rarity = (int)raidController.BossStats.Data.rarity; - - // Background rarity effect (Epic=purple pulse, Legendary=gold pulse) - if (rarity >= 3) // Epic+ - { - Color bgPulseCol = rarity >= 4 - ? new Color(1f, 0.8f, 0.2f, 0.06f + Mathf.Sin(Time.time * 2f) * 0.04f) // Legendary gold - : new Color(0.5f, 0.15f, 0.7f, 0.06f + Mathf.Sin(Time.time * 2f) * 0.04f); // Epic purple - GUI.color = bgPulseCol; - GUI.DrawTexture(new Rect(0, 0, sw, sh), Texture2D.whiteTexture); - } - - if (introTimer < 0.7f) - { - // "RAID BOSS" text drops from above - float dropT = Mathf.Clamp01(introTimer / 0.5f); - float easeT = dropT * dropT * (3f - 2f * dropT); // smoothstep - float textY = Mathf.Lerp(cy - 120, cy, easeT); - float scaleAnim = 0.5f + easeT * 0.5f; - int fontSize = (int)(60 * scaleAnim); - - // Impact flash when text lands - if (dropT > 0.85f && dropT < 1f) - { - float impactAlpha = (1f - (dropT - 0.85f) / 0.15f) * 0.4f; - GUI.color = new Color(1f, 0.3f, 0.15f, impactAlpha); - GUI.DrawTexture(new Rect(cx - 300, textY - 10, 600, 80), Texture2D.whiteTexture); - } - - GUI.color = new Color(0, 0, 0, 0.7f * alpha); - GUI.DrawTexture(new Rect(cx - 300, textY - 10, 600, 80), Texture2D.whiteTexture); - - // Red glow borders - GUI.color = new Color(1f, 0.2f, 0.1f, alpha * 0.6f); - GUI.DrawTexture(new Rect(cx - 300, textY - 10, 600, 4), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(cx - 300, textY + 66, 600, 4), Texture2D.whiteTexture); - - introRaidBossStyleCache.fontSize = fontSize; - introRaidBossStyleCache.normal.textColor = new Color(1f, 0.3f, 0.15f, alpha); - GUI.color = Color.white; - GUI.Label(new Rect(cx - 300, textY, 600, 60), "RAID BOSS", introRaidBossStyleCache); - } - else if (introTimer < 1.6f) - { - // Boss name appears with burning red/rarity-colored effect - float nameT = Mathf.Clamp01((introTimer - 0.7f) / 0.5f); - float namePulse = 0.8f + Mathf.Sin(Time.time * 6f) * 0.2f; - - GUI.color = new Color(0, 0, 0, 0.7f); - GUI.DrawTexture(new Rect(cx - 340, cy - 10, 680, 110), Texture2D.whiteTexture); - - // Rarity-colored border glow - GUI.color = new Color(ec.r, ec.g, ec.b, 0.6f * namePulse); - GUI.DrawTexture(new Rect(cx - 340, cy - 10, 680, 4), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(cx - 340, cy + 96, 680, 4), Texture2D.whiteTexture); - - // Fire-like shimmer behind name - float fireOffset1 = Mathf.Sin(Time.time * 8f) * 3f; - float fireOffset2 = Mathf.Sin(Time.time * 10f + 2f) * 2f; - GUI.color = new Color(1f, 0.2f, 0.1f, 0.15f * nameT); - GUI.DrawTexture(new Rect(cx - 250, cy - 2 + fireOffset1, 500, 50), Texture2D.whiteTexture); - GUI.color = new Color(1f, 0.5f, 0.1f, 0.1f * nameT); - GUI.DrawTexture(new Rect(cx - 200, cy + 2 + fireOffset2, 400, 40), Texture2D.whiteTexture); - - introBossNameStyleCache.normal.textColor = new Color(ec.r * namePulse, ec.g * namePulse, ec.b * namePulse, nameT); - GUI.color = Color.white; - UIHelper.LabelFit(new Rect(cx - 320, cy + 2, 640, 48), - $"{raidController.BossStats.Data.displayName} Lv.{raidController.BossStats.Level}", introBossNameStyleCache); - - introSubStyleCache.normal.textColor = new Color(1f, 0.8f, 0.3f, nameT); - GUI.Label(new Rect(cx - 250, cy + 56, 500, 30), - "5마리가 힘을 합쳐 쓰러뜨려라!", introSubStyleCache); - } - else - { - // "FIGHT!" text with punch effect - float fightT = Mathf.Clamp01((introTimer - 1.6f) / 0.4f); - float fightScale = 1f + Mathf.Max(0, 1f - fightT * 3f) * 0.6f; - int fightSize = Mathf.RoundToInt(72 * fightScale); - - // Screen flash on FIGHT - if (fightT < 0.3f) - { - GUI.color = new Color(1f, 1f, 1f, (0.3f - fightT) * 0.5f); - GUI.DrawTexture(new Rect(0, 0, sw, sh), Texture2D.whiteTexture); - } - - introFightStyleCache.fontSize = fightSize; - float shakeX = fightT < 0.3f ? Mathf.Sin(Time.time * 60f) * 4f : 0; - introFightStyleCache.normal.textColor = new Color(1f, 0.9f, 0.2f, Mathf.Clamp01(1f - (fightT - 0.5f) * 3f)); - GUI.color = Color.white; - GUI.Label(new Rect(cx - 200 + shakeX, cy + 10, 400, 80), "FIGHT!", introFightStyleCache); - } + // 수문장이면 위쪽(보스 자리)을 비우고 아래쪽 배너 하나로 시작한다(RaidBattleUI.Stage의 DrawGuardianIntro). + // 수문장이 아닌 레이드는 화면 쓸기 + 아래 무대의 「레이드 보스 ○○ 출현!」(RaidBattleUI.Feel) — 예전 "RAID BOSS → 이름 → FIGHT!"는 + // 화면 30%에 떠서 보스와 겹쳤다. + if (DrawGuardianIntro()) return; + DrawRaidEntry(); } /// 칩 순서 = 화면 배치 순서. 입력 히트테스트도 이 순서를 쓴다. internal static readonly RaidTeamStance[] StanceOrder = @@ -1222,11 +1083,10 @@ private void DrawSkillSelector() // 제스처바(하단 세이프 인셋) + 세로 마진 위로. 배경은 바닥까지 채워 빈틈 방지. float panelY = UISafeLayout.ContentBottom - panelH; - GUI.color = new Color(0.03f, 0.04f, 0.09f, 0.97f); - GUI.DrawTexture(new Rect(0, panelY, panelW, UIScale.VirtualScreenHeight - panelY), Texture2D.whiteTexture); - GUI.color = new Color(0.3f, 0.5f, 0.9f); - GUI.DrawTexture(new Rect(0, panelY, panelW, 4), Texture2D.whiteTexture); + UISurface.Card(new Rect(UISafeLayout.ContentLeft, panelY, UISafeLayout.ContentWidth, panelH), + UITheme.Instance.surfaceBase, UITheme.Instance.surfaceBorder); GUI.color = Color.white; + skillSelHeaderStyleCache.normal.textColor = UITheme.Instance.textPrimary; // 스탠스 칩은 곤충 선택 패널이 사라지면서 이리로 옮겨왔다 — 자동 위임의 성향을 정하는 // 유일한 조작이라 스킬 화면에 있어야 한다(차례를 소비하지 않는다). @@ -1273,30 +1133,14 @@ private void DrawSkillSelector() Vector2 mouseGui = UIScale.VirtualMousePosition; bool hovered = canUse && new Rect(bx, btnY, btnW, btnH).Contains(mouseGui); - Color bgCol = hovered ? new Color(0.16f, 0.20f, 0.36f) : - canUse ? new Color(0.08f, 0.10f, 0.20f) : new Color(0.05f, 0.05f, 0.07f); - GUI.color = bgCol; - GUI.DrawTexture(new Rect(bx, btnY, btnW, btnH), Texture2D.whiteTexture); - - Color borderCol = GetSkillColor(skill.effectType); - if (canUse) - { - GUI.color = hovered ? Color.white : borderCol; - GUI.DrawTexture(new Rect(bx, btnY, btnW, 5), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(bx, btnY + btnH - 3, btnW, 3), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(bx, btnY, 2, btnH), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(bx + btnW - 2, btnY, 2, btnH), Texture2D.whiteTexture); - } - else - { - GUI.color = new Color(0.25f, 0.25f, 0.25f); - GUI.DrawTexture(new Rect(bx, btnY, btnW, 2), Texture2D.whiteTexture); - } + UISurface.Card(new Rect(bx, btnY, btnW, btnH), + hovered ? UITheme.Instance.surfaceRaised : UITheme.Instance.surfaceCard, + hovered ? UITheme.Instance.accentMint : UITheme.Instance.surfaceBorder); Color skillCol = GetSkillColor(skill.effectType); - float iconSize = 32f; - GUI.color = new Color(skillCol.r, skillCol.g, skillCol.b, canUse ? 0.85f : 0.25f); - GUI.DrawTexture(new Rect(bx + 12, btnY + 12, iconSize, iconSize), Texture2D.whiteTexture); + UISurface.Chip(new Rect(bx + 12f, btnY + 12f, Mathf.Min(120f, btnW - 68f), 30f), + InsectTypeChart.GetDisplayName(skill.element), UITheme.Instance.surfaceBase, + canUse ? SkillUILayout.GetReadableAccent(skillCol) : UITheme.Instance.textSecondary); // 쿨다운 붉은 딤은 글자보다 **먼저**. 나중에 덮으면 글자까지 물들어 명암비가 내려간다 // (스킬명 8.77→7.21, 타입·위력 6.08→5.19). 1v1 카드와 같은 순서다. @@ -1337,29 +1181,20 @@ private void DrawSkillSelector() UIHelper.LabelFit(SkillUILayout.GetNameRect(skillCardRect, 50f, 12f, 50f), skill.displayName, skillSelNameStyleCache); + // 상성 칩 — 「아주 잘 통해요 ⬆⬆」…「거의 안 통해요 ⬇⬇」(데미지 스킬만, 화살표는 도형 — BattleFeelDraw). 보스의 **지금 모습** + // (CombatData) 기준이라 모습이 바뀌면 칩도 따라 바뀐다(RaidBossForms). 등급은 1대1 카드·타격 순간 표시와 같은 ElementMatchup. + // 타입 줄 오른쪽에 서고 타입 글자는 그 앞까지 — 가로 300px 카드의 위력 줄 오른쪽 칸(약 115px)엔 칩 글자가 안 들어간다. + Matchup matchup = skill.effectType == SkillEffectType.Damage && raidController.BossStats != null + ? ElementMatchup.Of(skill.element, raidController.BossStats.CombatData) + : Matchup.Neutral; + Rect typeRow = new Rect(bx + 12, btnY + 106, btnW - 24, 28); + Rect matchupChip = BattleFeelDraw.MatchupChip(typeRow, matchup, true, canUse); skillSelTypeStyleCache.normal.textColor = canUse ? SkillUILayout.GetReadableAccent(skillCol) : SkillUILayout.DisabledSecondaryTextColor; - UIHelper.LabelFit(new Rect(bx + 12, btnY + 106, btnW - 24, 28), + UIHelper.LabelFit(new Rect(typeRow.x, typeRow.y, Mathf.Max(1f, matchupChip.x - typeRow.x - UITheme.Space.XS), typeRow.height), RaidSkillTypeLabel(skill.effectType), skillSelTypeStyleCache); - // 상성 배지 — 보스에게 강/약(데미지 스킬만). InsectTypeChart는 이미 public. - if (skill.effectType == SkillEffectType.Damage && raidController.BossStats != null && raidController.BossStats.Data != null) - { - float eff = InsectTypeChart.GetEffectiveness(skill.element, - raidController.BossStats.Data.primaryType, raidController.BossStats.Data.secondaryType); - if (eff > 1.05f || eff < 0.95f) - { - bool strong = eff > 1.05f; - // 약점 붉은색은 호버 배경에서 4.25로 떨어졌다 → 0.58/0.52로 올려 5.29. - skillSelEffStyleCache.normal.textColor = canUse - ? (strong ? new Color(0.4f, 1f, 0.5f) : new Color(1f, 0.58f, 0.52f)) - : SkillUILayout.DisabledSecondaryTextColor; - GUI.Label(detailRows.Effectiveness, - strong ? "효과적 ▲" : "비효과 ▼", skillSelEffStyleCache); - } - } - skillSelInfoStyleCache.normal.textColor = canUse ? new Color(0.96f, 0.91f, 0.72f) : SkillUILayout.DisabledSecondaryTextColor; @@ -1441,25 +1276,15 @@ private Rect DrawAutoButton(float x, float y, float w, float h, string label, string hint, Color baseCol) { Rect rect = new Rect(x, y, w, h); - bool hovered = rect.Contains(UIScale.VirtualMousePosition); - - GUI.color = hovered - ? new Color(baseCol.r * 1.6f, baseCol.g * 1.6f, baseCol.b * 1.6f, 0.97f) - : new Color(baseCol.r, baseCol.g, baseCol.b, 0.95f); - GUI.DrawTexture(rect, Texture2D.whiteTexture); - - GUI.color = new Color(0.55f, 0.82f, 0.95f, 0.8f); - GUI.DrawTexture(new Rect(x, y, w, 3), Texture2D.whiteTexture); - GUI.color = Color.white; - - // 합체 버튼과 스타일 캐시를 공유한다 — OnGUI에서 GUIStyle을 새로 만들지 않기 위해서다. - GUI.Label(new Rect(x, y + 8, w, 28), label, uniteBtnLabelStyleCache); - uniteBtnKeyHintStyleCache.normal.textColor = new Color(0.82f, 0.92f, 1f, 0.85f); - GUI.Label(new Rect(x, y + 36, w, 22), hint, uniteBtnKeyHintStyleCache); - + UISurface.Card(rect, rect.Contains(UIScale.VirtualMousePosition) ? UITheme.Instance.surfaceRaised : UITheme.Instance.surfaceCard, + UITheme.Instance.surfaceBorder); + uniteBtnLabelStyleCache.normal.textColor = UITheme.Instance.accentMint; + uniteBtnKeyHintStyleCache.normal.textColor = UITheme.Instance.textSecondary; + UIHelper.LabelFit(new Rect(x + 6f, y + 6f, w - 12f, 28f), label, uniteBtnLabelStyleCache); + UIHelper.LabelFit(new Rect(x + 6f, y + 34f, w - 12f, 26f), hint, uniteBtnKeyHintStyleCache); return rect; } - // 스킬 효과 타입 라벨(신규 타입 포함). + private static string RaidSkillTypeLabel(SkillEffectType t) { switch (t) @@ -1513,18 +1338,20 @@ private void DrawBossIntent() if (phase == Phase.None || phase == Phase.Intro || phase == Phase.Result || phase == Phase.BossAttack || raidController == null) return; + // 변신이 걸려 있거나 변신 중이면 감춘다 — 컨트롤러는 임계를 넘긴 그 행동에서 이미 예고를 새 모습의 기술로 고쳐 썼다. + // 그대로 두면 변신 연출보다 새 기술 이름이 먼저 보인다. 변신 단계가 끝나면 새 예고가 뜬다. + if (phase == Phase.BossTransform || bossFormPending) return; RaidBossIntent intent = activeRound != null ? activeRound.BossIntent : raidController.NextBossIntent; if (intent == null) return; - float sw = UIScale.VirtualScreenWidth; - float safeTop = SafeArea.Top / UIScale.Scale; - float width = Mathf.Min(680f, sw - 48f); - float x = (sw - width) * 0.5f; - float y = safeTop + 142f; - float height = UIScale.IsMobileLayout ? 64f : 54f; + Rect intentRect = RaidStageLayout.BossIntent(HudFrame.Current); + float width = intentRect.width; + float x = intentRect.x; + float y = intentRect.y; + float height = intentRect.height; Color accent = intent.IsArea ? new Color(1f, 0.35f, 0.25f) : new Color(1f, 0.72f, 0.25f); @@ -1543,20 +1370,17 @@ private void DrawBossIntent() string name = !string.IsNullOrEmpty(intent.DisplayName) ? intent.DisplayName : intent.IsArea ? "전체 공격" : "강공격"; - string prefix = phase == Phase.BossTelegraph ? "CASTING" : "NEXT"; + string prefix = phase == Phase.BossTelegraph ? "공격 준비" : "다음 행동"; string areaLabel = intent.IsArea ? " · 전원 대상" : target; - GUI.color = new Color(0.035f, 0.035f, 0.075f, 0.94f); - GUI.DrawTexture(new Rect(x, y, width, height), Texture2D.whiteTexture); - GUI.color = new Color(accent.r, accent.g, accent.b, 0.9f); - GUI.DrawTexture(new Rect(x, y, 6f, height), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(x, y, width, 3f), Texture2D.whiteTexture); + UISurface.Card(new Rect(x, y, width, height), UITheme.Instance.surfaceBase, UITheme.Instance.surfaceBorder); + UISurface.Flat(new Rect(x + 12f, y + 3f, width - 24f, 3f), accent); bossIntentStyleCache.fontSize = UIScale.IsMobileLayout ? 25 : 23; bossIntentStyleCache.normal.textColor = new Color(1f, 0.92f, 0.78f); GUI.color = Color.white; - GUI.Label(new Rect(x + 16f, y + 5f, width - 32f, height - 12f), - $"⚠ {prefix} · {name}{areaLabel}", bossIntentStyleCache); + UIHelper.LabelFit(new Rect(x + 16f, y + 5f, width - 32f, height - 12f), + $"{prefix} · {name}{areaLabel}", bossIntentStyleCache); float progress = phase == Phase.BossTelegraph ? Mathf.Clamp01(phaseTimer / BossTelegraphDuration) @@ -1585,22 +1409,29 @@ private void DrawBossTelegraph() ? new Color(1f, 0.22f, 0.16f) : new Color(1f, 0.58f, 0.18f); - GUI.color = new Color(accent.r, accent.g, accent.b, 0.10f * pulse); - GUI.DrawTexture(new Rect(0, sh * 0.20f, sw, sh * 0.28f), Texture2D.whiteTexture); + // 3D면 띠를 보스 HP·예고 상자 바로 아래(20%)로 올리고 화면을 물들이는 틴트는 뺀다 — 28% 높이의 + // 띠와 20~48% 틴트가 보스 몸통을 덮어, 보스가 기를 모으고 몸을 젖히는 예고가 가려졌다. + bool arena3D = Arena3D; + float barY = arena3D ? sh * 0.20f : sh * 0.28f; + if (!arena3D) + { + GUI.color = new Color(accent.r, accent.g, accent.b, 0.10f * pulse); + GUI.DrawTexture(new Rect(0, sh * 0.20f, sw, sh * 0.28f), Texture2D.whiteTexture); + } GUI.color = new Color(0.025f, 0.02f, 0.05f, 0.74f); - GUI.DrawTexture(new Rect(0, sh * 0.28f, sw, 104f), Texture2D.whiteTexture); + GUI.DrawTexture(new Rect(0, barY, sw, 104f), Texture2D.whiteTexture); GUI.color = new Color(accent.r, accent.g, accent.b, pulse); - GUI.DrawTexture(new Rect(0, sh * 0.28f, sw, 4f), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(0, sh * 0.28f + 100f, sw, 4f), Texture2D.whiteTexture); + GUI.DrawTexture(new Rect(0, barY, sw, 4f), Texture2D.whiteTexture); + GUI.DrawTexture(new Rect(0, barY + 100f, sw, 4f), Texture2D.whiteTexture); introFightStyleCache.fontSize = UIScale.IsMobileLayout ? 50 : 58; introFightStyleCache.normal.textColor = new Color(accent.r, accent.g, accent.b, 1f); GUI.color = Color.white; - GUI.Label(new Rect(0, sh * 0.28f + 4f, sw, 62f), - intent.IsArea ? "⚠ 보스 전체 공격!" : "⚠ 보스 공격 준비!", introFightStyleCache); + GUI.Label(new Rect(0, barY + 4f, sw, 62f), + intent.IsArea ? "보스 전체 공격!" : "보스 공격 준비!", introFightStyleCache); bossIntentStyleCache.normal.textColor = Color.white; - GUI.Label(new Rect(0, sh * 0.28f + 60f, sw, 36f), + GUI.Label(new Rect(0, barY + 60f, sw, 36f), intent.DisplayName, bossIntentStyleCache); } /// @@ -1729,6 +1560,8 @@ private void DrawSlotContributions(float t) float x = RaidSlotLayout.AnchorX(action.SourceSlot, teamCount, sw); float y = sh * 0.40f - 58f - localT * 44f; + // 3D면 그 팀원 모델 머리 위 — 2D 슬롯 좌표는 3D 배치와 어긋난다. + if (ArenaPoint(action.SourceSlot, out Vector2 member)) { x = member.x; y = member.y - 110f - localT * 44f; } float alpha = Mathf.Clamp01(1f - localT * 0.7f); Color col = ContributionColor(action); @@ -1758,6 +1591,7 @@ private void DrawSlotContributions(float t) float hx = RaidSlotLayout.AnchorX(slot, teamCount, sw); float hy = sh * 0.40f - 96f - rise * 40f; + if (ArenaPoint(slot, out Vector2 healed)) { hx = healed.x; hy = healed.y - 150f - rise * 40f; } float hAlpha = Mathf.Clamp01(1f - rise * 0.7f); slotContribStyleCache.normal.textColor = new Color( theme.accentMint.r, theme.accentMint.g, theme.accentMint.b, hAlpha); @@ -1785,6 +1619,10 @@ private void DrawAttackEffects() { float bossX = sw * 0.5f; float bossY = sh * 0.12f; + // 3D 아레나면 숫자를 실제 보스 위에 붙이고, 2D용 사각형 투사체·임팩트는 그리지 않는다 — + // 화면 고정 좌표(위 중앙)에 그려져 HP 바 근처에 사각형이 떠다녔다(레이드 QA 캡처). + bool arena3D = ArenaPoint(-1, out Vector2 bossPoint); + if (arena3D) { bossX = bossPoint.x; bossY = bossPoint.y; } // 방금 행동한 그 곤충의 스킬을 쓴다. 순차 턴에서 라운드의 첫 행동(리더)을 읽으면 // 3번째 곤충이 때리는데 1번째 곤충의 속성 이펙트가 터진다. @@ -1799,7 +1637,7 @@ private void DrawAttackEffects() } Color elemCol = GetElementColor(element); - if (t < 0.35f) + if (t < 0.35f && !arena3D) { float projT = t / 0.35f; float easeT = projT * projT * (3f - 2f * projT); @@ -1828,37 +1666,66 @@ private void DrawAttackEffects() } } - if (t >= 0.3f && t < 0.7f) + if (t >= 0.3f && t < 0.7f && !arena3D) { float impactT = (t - 0.3f) / 0.4f; DrawElementImpact(bossX, bossY, impactT, element, elemCol); } - if (t >= 0.3f) + // 3D 타격은 볼리 0.46초의 끝에서 난다 — 숫자를 그 순간에 맞춘다. + float dmgStart = arena3D ? 0.45f : 0.3f; + if (t >= dmgStart) { - float dmgT = (t - 0.3f) / 0.7f; - - // Skill name with colored background flash - if (!string.IsNullOrEmpty(lastSkillUsedName)) + float dmgT = (t - dmgStart) / 0.7f; + // 치명타(이 곤충의 행동, RaidActionResult.Critical)면 금빛 큰 숫자 + 그림자 + 「치명타!」. + // 합체공격은 치명타를 굴리지 않는다(lastMemberAction이 null). + bool crit = lastMemberAction != null && lastMemberAction.Critical; + // 「치명타!」·상성 표시가 숫자 위에 서므로 2D 기술 이름은 그만큼 더 올린다. + Matchup memberMatchup = lastMemberAction != null ? lastMemberAction.Matchup : Matchup.Neutral; + float skillLift = crit ? 48f : 0f; + if (memberMatchup != Matchup.Neutral) skillLift += MatchupHud.CaptionHeight + MatchupHud.CaptionGap; + + // Skill name with colored background flash — 3D에선 시전자가 말풍선으로 외친다. + // 전용기면 크게·속성색 테두리로(레이드는 사전 컷인이 없다 — RaidBattleUI.Feel). + if (!arena3D && !string.IsNullOrEmpty(lastSkillUsedName) && ShownActionSignature) + { + DrawRaidSignatureName2D(bossX, bossY - 150f - skillLift - dmgT * 20f, Mathf.Clamp01(1f - dmgT * 1.5f)); + } + else if (!arena3D && !string.IsNullOrEmpty(lastSkillUsedName)) { float skillAlpha = Mathf.Clamp01(1f - dmgT * 1.5f); // Skill name background glow GUI.color = new Color(skillColor.r, skillColor.g, skillColor.b, skillAlpha * 0.15f); - GUI.DrawTexture(new Rect(bossX - 170, bossY - 140 - dmgT * 20f, 340, 44), Texture2D.whiteTexture); + GUI.DrawTexture(new Rect(bossX - 170, bossY - 140 - skillLift - dmgT * 20f, 340, 44), Texture2D.whiteTexture); attackSkillNameStyleCache.normal.textColor = new Color(skillColor.r, skillColor.g, skillColor.b, skillAlpha); GUI.color = Color.white; - GUI.Label(new Rect(bossX - 170, bossY - 136 - dmgT * 20f, 340, 36), lastSkillUsedName, attackSkillNameStyleCache); + GUI.Label(new Rect(bossX - 170, bossY - 136 - skillLift - dmgT * 20f, 340, 36), lastSkillUsedName, attackSkillNameStyleCache); } // Damage number float dmgAlpha = Mathf.Clamp01(1f - dmgT * 0.8f); float dmgScale = 1f + Mathf.Sin(dmgT * Mathf.PI * 0.5f) * 0.3f; - bossDmgNumStyleCache.fontSize = (int)(48 * dmgScale); - bossDmgNumStyleCache.normal.textColor = new Color(1, 1, 0.3f, dmgAlpha); + int dmgFont = Mathf.RoundToInt((crit ? 68f : 48f) * dmgScale); + // 가운데는 옛 56px 상자(보스 − 90)의 가운데 그대로 — 상자만 글자 줄높이까지 키워 위아래가 잘리지 않게. + float dmgCenterY = bossY - 62f - dmgT * 50f; + Rect dmgRect = new Rect(bossX - 150f, dmgCenterY - dmgFont * 0.7f, 300f, dmgFont * 1.4f); + bossDmgNumStyleCache.fontSize = dmgFont; GUI.color = Color.white; - GUI.Label(new Rect(bossX - 70, bossY - 90 - dmgT * 50, 140, 56), $"-{lastDmgToBoss}", bossDmgNumStyleCache); + if (crit) + { + bossDmgNumStyleCache.normal.textColor = new Color(0f, 0f, 0f, 0.8f * dmgAlpha); + GUI.Label(new Rect(dmgRect.x + 3f, dmgRect.y + 3f, dmgRect.width, dmgRect.height), $"-{lastDmgToBoss}", bossDmgNumStyleCache); + } + Color dmgCol = crit ? UITheme.Instance.accentAmber : new Color(1f, 1f, 0.3f); + dmgCol.a = dmgAlpha; + bossDmgNumStyleCache.normal.textColor = dmgCol; + GUI.Label(dmgRect, $"-{lastDmgToBoss}", bossDmgNumStyleCache); + if (crit) DrawRaidCritCaption(bossX, dmgCenterY - dmgFont * 0.55f - 22f, true, dmgAlpha); + // 상성 — 숫자(치명타면 「치명타!」) 바로 위에 화살표 + 「아주 잘 통했다!」(보스의 지금 모습 기준 — 리졸버가 적어 둔 등급). + DrawRaidImpactMatchup(memberMatchup, bossX, MatchupHud.CaptionCenterY(dmgCenterY, dmgFont, crit), dmgAlpha, + (t - dmgStart) * 1f); // "TEAM RUSH ×N"이 여기 있었다. N은 라운드에 쌓인 행동 수인데 이 페이즈가 // 보여 주는 건 그중 **한 마리**의 공격이라, 단일 타격 위에 ×3이 붙고 그 옆의 @@ -1877,7 +1744,7 @@ private void DrawAttackEffects() if (lastAoe) { // Full screen red flash for AOE - float aoeFlash = Mathf.Clamp01(1f - t * 3f) * 0.3f; + float aoeFlash = BattlePresentation.ReducedFlashes ? 0f : (Mathf.Clamp01(1f - t * 3f) * 0.3f); if (aoeFlash > 0) { GUI.color = new Color(1f, 0.1f, 0.05f, aoeFlash); @@ -1897,9 +1764,12 @@ private void DrawAttackEffects() // "AOE Attack!" large text float aoeTextAlpha = Mathf.Clamp01(1f - t * 1.2f); aoeLabelStyleCache.normal.textColor = new Color(1f, 0.2f, 0.15f, aoeTextAlpha); - float shakeX = t < 0.5f ? Mathf.Sin(Time.time * 50f) * 5f : 0; + float shakeX = BattlePresentation.ReducedMotion ? 0f : (t < 0.5f ? Mathf.Sin(Time.time * 50f) * 5f : 0); GUI.color = Color.white; UIHelper.LabelFit(new Rect(sw / 2 - 200 + shakeX, sh * 0.33f - t * 15f, 400, 52), "전체 공격!", aoeLabelStyleCache); + // 전체 공격은 한 번 굴린 치명타가 맞은 전원에 같다 — 「치명타!」는 제목 위에 한 번만(다섯 마리 위에 다섯 번 띄우지 않는다). + bool aoeCrit = BossActionCritical; + if (aoeCrit) DrawRaidCritCaption(sw / 2 + shakeX, sh * 0.33f - t * 15f - 24f, false, aoeTextAlpha); // Damage number below aoeDmgStyleCache.normal.textColor = new Color(1, 0.3f, 0.3f, 1f - t * 0.6f); @@ -1924,9 +1794,23 @@ private void DrawAttackEffects() ? activeRound.BossDamageBySlot[i] : 0; if (memberDamage <= 0) continue; - aoeMemberDmgStyleCache.normal.textColor = new Color(1, 0.4f, 0.3f, mAlpha); - UIHelper.LabelFit(new Rect(mx - 42, my - 40 - memberT * 30, 84, 30), - $"-{memberDamage}", aoeMemberDmgStyleCache); + if (aoeCrit) + { + // 치명타면 한 단 크게(26 → 34pt) — 가운데는 평소 상자(my − 40, 30px)의 가운데 그대로. + Color memberCol = UITheme.Instance.accentCoral; + memberCol.a = mAlpha; + aoeMemberDmgStyleCache.fontSize = 34; + aoeMemberDmgStyleCache.normal.textColor = memberCol; + UIHelper.LabelFit(new Rect(mx - 60f, my - 25f - memberT * 30f - 24f, 120f, 48f), + $"-{memberDamage}", aoeMemberDmgStyleCache); + aoeMemberDmgStyleCache.fontSize = 26; + } + else + { + aoeMemberDmgStyleCache.normal.textColor = new Color(1, 0.4f, 0.3f, mAlpha); + UIHelper.LabelFit(new Rect(mx - 42, my - 40 - memberT * 30, 84, 30), + $"-{memberDamage}", aoeMemberDmgStyleCache); + } } } } @@ -1936,9 +1820,13 @@ private void DrawAttackEffects() int teamCount = raidController.TeamStats.Length; float hx = RaidSlotLayout.AnchorX(lastHitSlot, teamCount, sw); float hy = sh * 0.40f; + // 3D면 맞은 팀원 모델 위에 숫자만 — 빨간 사각형 투사체·섬광은 2D 폴백 전용이다. + bool arena3D = ArenaPoint(lastHitSlot, out Vector2 hitPoint); + if (arena3D) { hx = hitPoint.x; hy = hitPoint.y; } + float popAt = arena3D ? 0.42f : 0.35f; // 3D 보스 공격은 0.42초에 닿는다 // Red energy projectile from boss to target - if (t < 0.4f) + if (t < 0.4f && !arena3D) { float projT = t / 0.4f; float easeT = projT * projT * (3f - 2f * projT); @@ -1967,171 +1855,167 @@ private void DrawAttackEffects() } } - if (t >= 0.35f) + if (t >= popAt) { - float impT = (t - 0.35f) / 0.4f; + float impT = (t - popAt) / 0.4f; - // Impact flash on hit member - float flashAlpha = (1f - Mathf.Clamp01(impT)) * 0.5f; - GUI.color = new Color(0.9f, 0.2f, 0.3f, flashAlpha); - float fs = 60f + impT * 40f; - GUI.DrawTexture(new Rect(hx - fs / 2, hy - fs / 2, fs, fs), Texture2D.whiteTexture); + // Impact flash on hit member (2D 폴백 — 3D는 모델이 직접 번쩍인다) + if (!arena3D) + { + float flashAlpha = BattlePresentation.ReducedFlashes ? 0f : ((1f - Mathf.Clamp01(impT)) * 0.5f); + GUI.color = new Color(0.9f, 0.2f, 0.3f, flashAlpha); + float fs = 60f + impT * 40f; + GUI.DrawTexture(new Rect(hx - fs / 2, hy - fs / 2, fs, fs), Texture2D.whiteTexture); + } - // Damage popup + // Damage popup — 보스의 치명타(BossAction.Critical)면 더 큰 붉은 숫자 + 「치명타!」. float dmgAlpha2 = Mathf.Clamp01(1f - impT * 0.7f); - attackDmg2StyleCache.normal.textColor = new Color(1, 0.3f, 0.3f, dmgAlpha2); GUI.color = Color.white; - float shakeX = impT < 0.5f ? Mathf.Sin(Time.time * 50f) * 4f : 0; + float shakeX = BattlePresentation.ReducedMotion ? 0f : (impT < 0.5f ? Mathf.Sin(Time.time * 50f) * 4f : 0); int actualDamage = activeRound != null && lastHitSlot < activeRound.BossDamageBySlot.Length ? activeRound.BossDamageBySlot[lastHitSlot] : lastDmgToTeam; - UIHelper.LabelFit(new Rect(hx - 48 + shakeX, hy - 60 - impT * 30, 96, 36), - $"-{actualDamage}", attackDmg2StyleCache); + if (BossActionCritical) + { + // 가운데는 평소 상자(hy − 60, 36px)의 가운데 그대로, 글자만 32 → 44pt. + float popCenterY = hy - 42f - impT * 30f; + Color hitCol = UITheme.Instance.accentCoral; + hitCol.a = dmgAlpha2; + attackDmg2StyleCache.fontSize = 44; + attackDmg2StyleCache.normal.textColor = hitCol; + UIHelper.LabelFit(new Rect(hx - 80f + shakeX, popCenterY - 30f, 160f, 60f), + $"-{actualDamage}", attackDmg2StyleCache); + attackDmg2StyleCache.fontSize = 32; + DrawRaidCritCaption(hx, popCenterY - 30f - 22f, false, dmgAlpha2); + } + else + { + attackDmg2StyleCache.normal.textColor = new Color(1, 0.3f, 0.3f, dmgAlpha2); + UIHelper.LabelFit(new Rect(hx - 48 + shakeX, hy - 60 - impT * 30, 96, 36), + $"-{actualDamage}", attackDmg2StyleCache); + } + // 상성 — 숫자(치명타면 「치명타!」 줄) 바로 위. 보스 단일 공격은 맞은 팀원으로 잰 등급이다(전체 공격은 보통). + Matchup bossMatchup = activeRound != null && activeRound.BossAction != null + ? activeRound.BossAction.Matchup : Matchup.Neutral; + float numberTop = BossActionCritical ? hy - 42f - impT * 30f - 30f - 42f : hy - 60f - impT * 30f; + DrawRaidImpactMatchup(bossMatchup, hx, MatchupHud.CaptionCenterAbove(numberTop), dmgAlpha2, t - popAt); } } } GUI.color = Color.white; } + /// 이번 보스 행동(단일·전체 공격)이 치명타였나 — 리졸버가 한 행동에 한 번 굴린 값(RaidActionResult.Critical). + private bool BossActionCritical => activeRound != null && activeRound.BossAction != null && activeRound.BossAction.Critical; + + /// 지금 화면이 보여 주는 행동이 치명타였나 — 팀원 차례면 그 곤충의 행동, 보스 차례면 보스 행동. + private bool ShownActionCritical => phase == Phase.PlayerAttack ? lastMemberAction != null && lastMemberAction.Critical + : phase == Phase.BossAttack && BossActionCritical; + + /// + /// 피해 숫자 위 「치명타!」 한 줄(가운데 ). 우리 편 치명타는 금빛, 맞은 치명타는 붉은빛 — + /// 1v1(BattleScreenUI.DrawCritCaption)과 같은 문구·색 규칙이다. + /// + private void DrawRaidCritCaption(float cx, float centerY, bool teamCrit, float alpha) + { + Rect r = new Rect(cx - 150f, centerY - 20f, 300f, 40f); + Color prev = GUI.color; + GUI.color = Color.white; + raidCritStyleCache.normal.textColor = new Color(0f, 0f, 0f, 0.75f * alpha); + UIHelper.LabelFit(new Rect(r.x + 2f, r.y + 2f, r.width, r.height), "치명타!", raidCritStyleCache); + Color c = teamCrit ? UITheme.Instance.accentAmber : UITheme.Instance.accentCoral; + c.a = alpha; + raidCritStyleCache.normal.textColor = c; + UIHelper.LabelFit(r, "치명타!", raidCritStyleCache); + GUI.color = prev; + } + private void DrawActionText() { if (string.IsNullOrEmpty(actionText)) return; + // 인트로·그림자 변신·결과 동안은 그 단계의 문구가 화면을 쓴다 — 앞 행동의 문구가 변신 연기·승리 화면 위에 떠 있었다. + if (phase == Phase.Intro || phase == Phase.BossTransform || phase == Phase.Result) return; float alpha = Mathf.Clamp01(actionTimer / 0.5f); float cx = UIScale.VirtualScreenWidth / 2f; float cy = UIScale.VirtualScreenHeight * 0.50f; + // 전용기 행동이면 크게·속성색 테두리로 — 레이드는 볼리 길이가 숫자 시각에 묶여 사전 컷인이 없다(RaidBattleUI.Feel). + bool signature = ActionTextIsSignature(out Color signatureElement); string[] lines = actionText.Split('\n'); - float lineH = 40f; + float lineH = signature ? 52f : 40f; float totalH = lines.Length * lineH; - float bgW = 700; + // 3D면 팀 패널 줄 바로 위에 붙인다 — 화면 한가운데 띠가 팀원·보스 발치를 가렸다. + if (Arena3D) cy = TeamStripY() - totalH - 26f; + float bgW = signature ? Mathf.Min(860f, UISafeLayout.ContentWidth) : 700f; GUI.color = new Color(0, 0, 0, 0.75f * alpha); GUI.DrawTexture(new Rect(cx - bgW / 2, cy - 8, bgW, totalH + 18), Texture2D.whiteTexture); - GUI.color = new Color(0.3f, 0.5f, 0.9f, 0.5f * alpha); - GUI.DrawTexture(new Rect(cx - bgW / 2, cy - 8, bgW, 3), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(cx - bgW / 2, cy + totalH + 7, bgW, 3), Texture2D.whiteTexture); + // 치명타 행동이면 위아래 띠를 금빛으로 — 문구 안의 "치명타!"(컨트롤러가 붙인다)와 같이 눈에 띄게. 전용기면 속성색으로 굵게. + Color stripe = signature ? signatureElement + : ShownActionCritical ? UITheme.Instance.accentAmber : new Color(0.3f, 0.5f, 0.9f); + float stripeH = signature ? 6f : 3f; + GUI.color = new Color(stripe.r, stripe.g, stripe.b, (signature ? 1f : 0.5f) * alpha); + GUI.DrawTexture(new Rect(cx - bgW / 2, cy - 8, bgW, stripeH), Texture2D.whiteTexture); + GUI.DrawTexture(new Rect(cx - bgW / 2, cy + totalH + 10 - stripeH, bgW, stripeH), Texture2D.whiteTexture); + if (signature) + { + GUI.DrawTexture(new Rect(cx - bgW / 2, cy - 8, stripeH, totalH + 18), Texture2D.whiteTexture); + GUI.DrawTexture(new Rect(cx + bgW / 2 - stripeH, cy - 8, stripeH, totalH + 18), Texture2D.whiteTexture); + GUI.color = Color.white; + BattleFeelDraw.SignatureBadge(new Rect(cx - bgW / 2, cy - 8 - UITheme.Space.XS - 40f, 140f, 40f), alpha); + } + actionTextStyleCache.fontSize = signature ? 34 : 26; actionTextStyleCache.normal.textColor = new Color(1, 1, 1, alpha); GUI.color = Color.white; + // 줄 길이는 데이터(곤충·기술 이름, 치명타·피해 문구)가 정한다 — 넘치면 글자를 줄여 띠 안에 맞춘다. for (int i = 0; i < lines.Length; i++) - GUI.Label(new Rect(cx - bgW / 2, cy + i * lineH, bgW, lineH), lines[i], actionTextStyleCache); + UIHelper.LabelFit(new Rect(cx - bgW / 2 + 12f, cy + i * lineH, bgW - 24f, lineH), lines[i], actionTextStyleCache); + actionTextStyleCache.fontSize = 26; } private void DrawResult() { + // 이겼으면 승리 화면 — 위쪽 큰 「레이드 승리!」, 아래 보상 판, 가운데는 팀 전원 점프(RaidBattleUI.Feel). + if (raidController.PlayerWon) + { + DrawRaidVictory(); + GUI.color = Color.white; + return; + } + float alpha = Mathf.Clamp01(resultTimer / 0.5f); float cx = UIScale.VirtualScreenWidth / 2f; float cy = UIScale.VirtualScreenHeight * 0.26f; float sw = UIScale.VirtualScreenWidth; float sh = UIScale.VirtualScreenHeight; - bool won = raidController.PlayerWon; - - if (won) - { - // Victory: gold-toned overlay - GUI.color = new Color(0f, 0f, 0f, 0.6f * alpha); - GUI.DrawTexture(new Rect(0, 0, sw, sh), Texture2D.whiteTexture); - - // Gold pulsing border glow - float goldPulse = 0.5f + Mathf.Sin(Time.time * 3f) * 0.15f; - GUI.color = new Color(1f, 0.85f, 0.2f, 0.08f * goldPulse * alpha); - GUI.DrawTexture(new Rect(0, 0, sw, sh), Texture2D.whiteTexture); - - // Panel - GUI.color = new Color(0.02f, 0.02f, 0.05f, 0.85f * alpha); - GUI.DrawTexture(new Rect(cx - 340, cy - 20, 680, 240), Texture2D.whiteTexture); - - // Gold borders - GUI.color = new Color(1f, 0.85f, 0.2f, 0.7f * alpha); - GUI.DrawTexture(new Rect(cx - 340, cy - 20, 680, 4), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(cx - 340, cy + 216, 680, 4), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(cx - 340, cy - 20, 4, 240), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(cx + 336, cy - 20, 4, 240), Texture2D.whiteTexture); - GUI.color = Color.white; - - // "RAID CLEAR!" title with scale-in - float titleScale = 1f + Mathf.Max(0, 1f - resultTimer * 3f) * 0.4f; - int titleFs = Mathf.RoundToInt(56 * titleScale); - resultWinTitleStyleCache.fontSize = titleFs; - resultWinTitleStyleCache.normal.textColor = new Color(1f, 0.85f, 0.2f, alpha); - GUI.Label(new Rect(cx - 300, cy, 600, 62), "RAID CLEAR!", resultWinTitleStyleCache); - - // Capture message - resultWinSubStyleCache.normal.textColor = new Color(0.9f, 0.9f, 0.9f, alpha); - UIHelper.LabelFit(new Rect(cx - 300, cy + 68, 600, 28), - $"보스 {raidController.BossStats.Data.displayName}을(를) 포획했다!", resultWinSubStyleCache); - - // Animated reward display - float rewardDelay = 0.8f; - float candyAlpha = Mathf.Clamp01((resultTimer - rewardDelay) * 3f); - float xpAlpha = Mathf.Clamp01((resultTimer - rewardDelay - 0.2f) * 3f); - float bonusAlpha = Mathf.Clamp01((resultTimer - rewardDelay - 0.4f) * 3f); - - // Candy reward with bounce - float candyBounce = candyAlpha > 0 ? Mathf.Max(0, Mathf.Sin((resultTimer - rewardDelay) * 8f) * (1f - candyAlpha) * 10f) : 0; - resultValStyleCache.normal.textColor = new Color(1f, 0.5f, 0.8f, candyAlpha); - UIHelper.LabelFit(new Rect(cx - 200, cy + 100 - candyBounce, 200, 28), $"+{raidController.RewardCandy} Candy (x3)", resultValStyleCache); - - // XP reward with bounce - float xpBounce = xpAlpha > 0 ? Mathf.Max(0, Mathf.Sin((resultTimer - rewardDelay - 0.2f) * 8f) * (1f - xpAlpha) * 10f) : 0; - resultValStyleCache.normal.textColor = new Color(0.4f, 0.8f, 1f, xpAlpha); - UIHelper.LabelFit(new Rect(cx, cy + 100 - xpBounce, 200, 28), $"+{raidController.RewardExp} XP (x3)", resultValStyleCache); - - // Bonus text - resultBonusStyleCache.normal.textColor = new Color(0.6f, 1f, 0.6f, bonusAlpha); - GUI.Label(new Rect(cx - 220, cy + 140, 440, 26), "레이드 보너스: 보상 x3!", resultBonusStyleCache); - - // Sparkle effects around rewards - if (resultTimer > rewardDelay) - { - for (int i = 0; i < 6; i++) - { - float sparkT = (resultTimer - rewardDelay + i * 0.3f) % 1.5f; - float sparkAlpha2 = Mathf.Clamp01(1f - sparkT) * 0.6f; - float sparkX = cx - 200 + Mathf.Sin(i * 2.1f + Time.time * 2f) * 180; - float sparkY = cy + 100 + Mathf.Cos(i * 1.7f + Time.time * 1.5f) * 30; - GUI.color = new Color(1f, 0.9f, 0.4f, sparkAlpha2 * alpha); - GUI.DrawTexture(new Rect(sparkX, sparkY, 4, 4), Texture2D.whiteTexture); - } - } - - // Timer hint - float hintAlpha = Mathf.Clamp01(resultTimer - 2f); - resultWinHintStyleCache.normal.textColor = new Color(0.6f, 0.6f, 0.6f, hintAlpha * 0.7f); - GUI.color = Color.white; - GUI.Label(new Rect(cx - 150, cy + 180, 300, 24), "잠시 후 자동으로 돌아갑니다...", resultWinHintStyleCache); - } - else - { - // Defeat: dark red overlay - GUI.color = new Color(0.05f, 0f, 0f, 0.75f * alpha); - GUI.DrawTexture(new Rect(0, 0, sw, sh), Texture2D.whiteTexture); + // 패배는 예전 창 그대로 — 안내만 「눌러서 계속」(이제 눌러야 닫힌다 — BattleResultRules). + // Defeat: dark red overlay + GUI.color = new Color(0.05f, 0f, 0f, 0.75f * alpha); + GUI.DrawTexture(new Rect(0, 0, sw, sh), Texture2D.whiteTexture); - // Panel - GUI.color = new Color(0.03f, 0.01f, 0.01f, 0.9f * alpha); - GUI.DrawTexture(new Rect(cx - 320, cy - 15, 640, 160), Texture2D.whiteTexture); + // Panel + GUI.color = new Color(0.03f, 0.01f, 0.01f, 0.9f * alpha); + GUI.DrawTexture(new Rect(cx - 320, cy - 15, 640, 160), Texture2D.whiteTexture); - // Red borders - GUI.color = new Color(1f, 0.2f, 0.15f, 0.5f * alpha); - GUI.DrawTexture(new Rect(cx - 320, cy - 15, 640, 4), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(cx - 320, cy + 141, 640, 4), Texture2D.whiteTexture); - GUI.color = Color.white; + // Red borders + GUI.color = new Color(1f, 0.2f, 0.15f, 0.5f * alpha); + GUI.DrawTexture(new Rect(cx - 320, cy - 15, 640, 4), Texture2D.whiteTexture); + GUI.DrawTexture(new Rect(cx - 320, cy + 141, 640, 4), Texture2D.whiteTexture); + GUI.color = Color.white; - // "RAID FAILED" with shake - float failShake = resultTimer < 1f ? Mathf.Sin(Time.time * 30f) * 3f * (1f - resultTimer) : 0; - resultFailStyleCache.normal.textColor = new Color(1f, 0.25f, 0.2f, alpha); - UIHelper.LabelFit(new Rect(cx - 300 + failShake, cy + 10, 600, 62), "RAID FAILED", resultFailStyleCache); + // "RAID FAILED" with shake + float failShake = BattlePresentation.ReducedMotion ? 0f : (resultTimer < 1f ? Mathf.Sin(Time.time * 30f) * 3f * (1f - resultTimer) : 0); + resultFailStyleCache.normal.textColor = new Color(1f, 0.25f, 0.2f, alpha); + UIHelper.LabelFit(new Rect(cx - 300 + failShake, cy + 10, 600, 62), "RAID FAILED", resultFailStyleCache); - // Subtitle - resultFailSubStyleCache.normal.textColor = new Color(0.6f, 0.4f, 0.4f, alpha); - GUI.Label(new Rect(cx - 250, cy + 80, 500, 28), "팀이 전멸했습니다...", resultFailSubStyleCache); + // Subtitle + resultFailSubStyleCache.normal.textColor = new Color(0.6f, 0.4f, 0.4f, alpha); + UIHelper.LabelFit(new Rect(cx - 250, cy + 72, 500, 32), "팀이 전멸했습니다...", resultFailSubStyleCache); - // Timer hint - float hintAlpha = Mathf.Clamp01(resultTimer - 2f); - resultFailHintStyleCache.normal.textColor = new Color(0.5f, 0.4f, 0.4f, hintAlpha * 0.7f); - GUI.Label(new Rect(cx - 150, cy + 116, 300, 24), "잠시 후 자동으로 돌아갑니다...", resultFailHintStyleCache); - } + BattleFeelDraw.ContinueHintLine(new Rect(cx - 220, cy + 106, 440, 36), ResultCanClose, + ResultShownSeconds - BattleResultRules.InputLockSeconds, BattlePresentation.ReducedMotion); GUI.color = Color.white; } private void DrawUniteButton(float x, float y, float h) @@ -2175,84 +2059,46 @@ private void DrawUniteButton(float x, float y, float h) } private void DrawUniteGaugeBar() { - if (raidController == null || !raidController.IsActive) return; - if (phase == Phase.Intro || phase == Phase.Result) return; - + if (raidController == null || !raidController.IsActive || phase == Phase.Intro || phase == Phase.Result) return; + // 3D 연출 중엔 팀 패널 줄이 아래로 비켜서면서 게이지만 보스 발치(화면 64%)에 남았다 — 같이 비킨다. + // 줄이 숨는 단계(그림자 변신 — RaidBattleUI.Impact)에도 같이 숨는다. + if (Arena3D && teamStripDrop > 0.5f) return; + if (teamStripHide > 0.5f) return; float gauge = raidController.UniteGauge; float max = RaidBattleController.UniteGaugeMax; - float ratio = Mathf.Clamp01(gauge / max); bool ready = raidController.CanUniteAttack; - - float barW = 360f; - float barH = 36f; - float bx = (UIScale.VirtualScreenWidth - barW) / 2f; - float by = UISafeLayout.ContentBottom - 160f; // 하단 패널(ContentBottom 기준)과 상대 위치 유지 - - GUI.color = new Color(0.05f, 0.05f, 0.1f, 0.9f); - GUI.DrawTexture(new Rect(bx - 4, by - 4, barW + 8, barH + 28), Texture2D.whiteTexture); - - Color borderCol = ready - ? Color.Lerp(new Color(1f, 0.85f, 0.2f), new Color(1f, 0.5f, 0.1f), Mathf.Sin(Time.time * 6f) * 0.5f + 0.5f) - : new Color(0.3f, 0.3f, 0.4f); - GUI.color = borderCol; - GUI.DrawTexture(new Rect(bx - 4, by - 4, barW + 8, 2), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(bx - 4, by + barH + 2, barW + 8, 2), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(bx - 4, by - 4, 2, barH + 8), Texture2D.whiteTexture); - GUI.DrawTexture(new Rect(bx + barW + 2, by - 4, 2, barH + 8), Texture2D.whiteTexture); - - GUI.color = new Color(0.1f, 0.1f, 0.15f); - GUI.DrawTexture(new Rect(bx, by, barW, barH), Texture2D.whiteTexture); - - if (ratio > 0) - { - Color fillBot = ready ? new Color(1f, 0.7f, 0.1f) : new Color(0.2f, 0.5f, 0.9f); - Color fillTop = ready ? new Color(1f, 0.9f, 0.3f) : new Color(0.4f, 0.7f, 1f); - float halfH = barH / 2f; - GUI.color = fillBot; - GUI.DrawTexture(new Rect(bx, by + halfH, barW * ratio, halfH), Texture2D.whiteTexture); - GUI.color = fillTop; - GUI.DrawTexture(new Rect(bx, by, barW * ratio, halfH), Texture2D.whiteTexture); - - if (ready) - { - float pulse = Mathf.Sin(Time.time * 4f) * 0.3f + 0.3f; - GUI.color = new Color(1f, 1f, 1f, pulse); - GUI.DrawTexture(new Rect(bx, by, barW * ratio, barH), Texture2D.whiteTexture); - } - } - - uniteGaugeLabelStyleCache.normal.textColor = ready ? new Color(1f, 0.95f, 0.4f) : new Color(0.7f, 0.7f, 0.8f); - GUI.color = Color.white; - GUI.Label(new Rect(bx, by, barW, barH), - ready - ? (UIScale.IsMobileLayout ? "★ 합체공격 준비! ★" : "★ 합체공격 준비! [F] ★") - : $"합체 게이지 {Mathf.RoundToInt(gauge)}/{Mathf.RoundToInt(max)}", - uniteGaugeLabelStyleCache); - - if (ready) - { - uniteGaugeHintStyleCache.normal.textColor = new Color(1f, 0.8f, 0.3f, Mathf.Sin(Time.time * 3f) * 0.3f + 0.7f); - GUI.Label(new Rect(bx, by + barH + 2, barW, 22), - UIScale.IsMobileLayout ? "합체공격 버튼을 눌러 동시 공격!" : "F키를 눌러 전체 곤충이 동시 공격!", - uniteGaugeHintStyleCache); - } + // 자리는 순수 계산 한 곳(RaidStageLayout.UniteGauge) — 변신 문구가 이 아래 무대에 선다. + Rect gaugeRect = RaidStageLayout.UniteGauge(HudFrame.Current); + float width = gaugeRect.width; + float x = gaugeRect.x; + float y = gaugeRect.y; + UISurface.Card(gaugeRect, UITheme.Instance.surfaceBase, UITheme.Instance.surfaceBorder); + uniteGaugeLabelStyleCache.normal.textColor = ready ? UITheme.Instance.accentAmber : UITheme.Instance.textSecondary; + UIHelper.LabelFit(new Rect(x + 12f, y + 4f, width - 24f, 30f), + ready ? "합체공격 준비 완료" : $"합체 게이지 {Mathf.RoundToInt(gauge)} / {Mathf.RoundToInt(max)}", uniteGaugeLabelStyleCache); + UISurface.Flat(new Rect(x + 12f, y + 36f, width - 24f, 4f), UITheme.Instance.surfaceRaised); + UISurface.Flat(new Rect(x + 12f, y + 36f, (width - 24f) * Mathf.Clamp01(gauge / max), 4f), UITheme.Instance.accentAmber); } private void DrawUniteAttackAnimation() { float sw = UIScale.VirtualScreenWidth; float sh = UIScale.VirtualScreenHeight; float t = uniteAnimTimer; + // 3D 아레나가 돌진·타격·폭발을 직접 보여 준다(BattleArenaController.Raid). 그때는 2D 폴백 그림 — + // 화면 전체 노란 섬광, 사각형 곤충 그림·잔상, 사각형 폭발 — 을 걷어내고 숫자와 제목만 남긴다. + // 걷어내기 전엔 0.4초간 화면이 노랗게 덮이고 3D 장면 위로 사각형이 날아다녔다(합체공격 QA 캡처). + bool arena3D = ArenaPoint(-1, out Vector2 bossPoint); // Initial bright flash - float flashAlpha = Mathf.Clamp01(1f - t * 2f) * 0.7f; + float flashAlpha = BattlePresentation.ReducedFlashes || arena3D ? 0f : (Mathf.Clamp01(1f - t * 2f) * 0.7f); if (flashAlpha > 0) { GUI.color = new Color(1f, 0.9f, 0.3f, flashAlpha); GUI.DrawTexture(new Rect(0, 0, sw, sh), Texture2D.whiteTexture); } - float bossCx = sw * 0.5f; - float bossCy = sh * 0.22f; + float bossCx = arena3D ? bossPoint.x : sw * 0.5f; + float bossCy = arena3D ? bossPoint.y : sh * 0.22f; // Sequential team member rush if (raidController.TeamStats != null) @@ -2263,10 +2109,11 @@ private void DrawUniteAttackAnimation() { if (raidController.TeamStats[i] == null || raidController.TeamStats[i].CurrentHp <= 0) continue; - float memberDelay = idx * 0.2f; // Staggered timing per member + // 시각은 아레나와 같은 표(RaidUniteTimeline)에서 — 3D 타격 순간에 숫자가 뜬다. + float departAt = RaidUniteTimeline.MemberStart + idx * RaidUniteTimeline.MemberStagger; float startX = sw * 0.1f + idx * (sw * 0.16f); float startY = sh * 0.55f; - float progress = Mathf.Clamp01((t - 0.3f - memberDelay) / 0.45f); + float progress = arena3D ? 0f : Mathf.Clamp01((t - departAt) / RaidUniteTimeline.MemberTravel); float cx = Mathf.Lerp(startX, bossCx, progress); float cy = Mathf.Lerp(startY, bossCy, progress) - Mathf.Sin(progress * Mathf.PI) * 90f; @@ -2300,11 +2147,10 @@ private void DrawUniteAttackAnimation() } // Per-slot hit flash on boss - float hitTime = 0.3f + memberDelay + 0.45f; - float hitT = t - hitTime; - if (hitT > 0 && hitT < 0.3f) + float hitT = t - RaidUniteTimeline.MemberHit(idx); + if (hitT > 0 && hitT < 0.3f && !arena3D) { - float hitAlpha = (0.3f - hitT) / 0.3f; + float hitAlpha = BattlePresentation.ReducedFlashes ? 0f : ((0.3f - hitT) / 0.3f); float hitSize = 50f + hitT * 120f; GUI.color = new Color(memberCol.r, memberCol.g, memberCol.b, hitAlpha * 0.5f); GUI.DrawTexture(new Rect(bossCx - hitSize / 2, bossCy - hitSize / 2, hitSize, hitSize), Texture2D.whiteTexture); @@ -2326,16 +2172,16 @@ private void DrawUniteAttackAnimation() } } - // Final combined impact explosion - float impactT = t - 1.5f; - if (impactT > 0 && impactT < 1.0f) + // Final combined impact explosion (2D 폴백 — 3D는 아레나의 합동 일격) + float impactT = t - RaidUniteTimeline.FinalStrike; + if (impactT > 0 && impactT < 1.0f && !arena3D) { float impactAlpha = Mathf.Clamp01(1f - impactT / 1.0f); // Bright center flash if (impactT < 0.2f) { - float brightFlash = (0.2f - impactT) / 0.2f * 0.5f; + float brightFlash = BattlePresentation.ReducedFlashes ? 0f : ((0.2f - impactT) / 0.2f * 0.5f); GUI.color = new Color(1f, 1f, 1f, brightFlash); GUI.DrawTexture(new Rect(0, 0, sw, sh), Texture2D.whiteTexture); } @@ -2385,40 +2231,47 @@ private void DrawUniteAttackAnimation() int fs = Mathf.RoundToInt(48 * scaleVal); // Text shake during impact period - float shakeX = 0; + float shakeX = 0f; float shakeY = 0; - if (t > 1.5f && t < 2.0f) + if (!BattlePresentation.ReducedMotion && t > RaidUniteTimeline.FinalStrike && t < RaidUniteTimeline.FinalStrike + 0.5f) { shakeX = Mathf.Sin(Time.time * 45f) * 6f; shakeY = Mathf.Cos(Time.time * 50f) * 3f; } - // Background glow behind text - GUI.color = new Color(1f, 0.7f, 0.1f, txtAlpha * 0.15f); - GUI.DrawTexture(new Rect(sw * 0.2f, sh * 0.40f + shakeY, sw * 0.6f, 68), Texture2D.whiteTexture); + // 3D면 제목을 위로 올리고 노란 띠는 뺀다 — 화면 42% 높이의 띠가 보스 발치와 돌진하는 + // 팀원을 통째로 덮었다. + float bannerY = arena3D ? sh * 0.215f : sh * 0.42f; // 3D: 보스 HP·예고 줄 아래 + if (!arena3D) + { + // Background glow behind text + GUI.color = new Color(1f, 0.7f, 0.1f, txtAlpha * 0.15f); + GUI.DrawTexture(new Rect(sw * 0.2f, sh * 0.40f + shakeY, sw * 0.6f, 68), Texture2D.whiteTexture); + } uniteLabelStyleCache.fontSize = fs; uniteLabelStyleCache.normal.textColor = new Color(1f, 0.9f, 0.2f, txtAlpha); GUI.color = Color.white; - GUI.Label(new Rect(shakeX, sh * 0.42f + shakeY, sw, 60), "★ 합체공격! ★", uniteLabelStyleCache); + GUI.Label(new Rect(shakeX, bannerY + shakeY, sw, 60), "★ 합체공격! ★", uniteLabelStyleCache); } // Total damage display - if (lastDmgToBoss > 0 && t > 1.8f) + if (lastDmgToBoss > 0 && t > RaidUniteTimeline.TotalReveal) { - float totalT = t - 1.8f; + float totalT = t - RaidUniteTimeline.TotalReveal; float totalAlpha = Mathf.Clamp01(totalT / 0.3f); float totalScale = 1f + Mathf.Max(0, 1f - totalT * 2f) * 0.4f; int totalFs = Mathf.RoundToInt(52 * totalScale); + float totalY = arena3D ? sh * 0.285f : sh * 0.30f; // Red glow background GUI.color = new Color(1f, 0.1f, 0.05f, totalAlpha * 0.12f); - GUI.DrawTexture(new Rect(sw * 0.25f, sh * 0.28f, sw * 0.5f, 68), Texture2D.whiteTexture); + GUI.DrawTexture(new Rect(sw * 0.25f, totalY - 2f, sw * 0.5f, 68), Texture2D.whiteTexture); uniteTotalStyleCache.fontSize = totalFs; uniteTotalStyleCache.normal.textColor = new Color(1f, 0.2f, 0.1f, totalAlpha); GUI.color = Color.white; - GUI.Label(new Rect(0, sh * 0.30f, sw, 60), $"TOTAL -{lastDmgToBoss}", uniteTotalStyleCache); + GUI.Label(new Rect(0, totalY, sw, 60), $"TOTAL -{lastDmgToBoss}", uniteTotalStyleCache); } } private void DrawRotatedLine(float x1, float y1, float x2, float y2, float thickness, Color color) @@ -2579,7 +2432,7 @@ private void DrawLeafImpact(float tgtX, float tgtY, float impactT, Color elemCol DrawRotatedLine(tgtX + slashLen, tgtY - slashLen, tgtX - slashLen, tgtY + slashLen, thick, slashCol); // Flash at center - float flashAlpha = (1f - impactT) * 0.6f; + float flashAlpha = BattlePresentation.ReducedFlashes ? 0f : ((1f - impactT) * 0.6f); float flashSize = 50f + impactT * 30f; GUI.color = new Color(elemCol.r, elemCol.g, elemCol.b, flashAlpha * 0.3f); GUI.DrawTexture(new Rect(tgtX - flashSize / 2, tgtY - flashSize / 2, flashSize, flashSize), Texture2D.whiteTexture); @@ -2640,7 +2493,7 @@ private void DrawWindImpact(float tgtX, float tgtY, float impactT, Color elemCol private void DrawElectricImpact(float tgtX, float tgtY, float impactT, Color elemCol) { // Yellow flash - float flashAlpha = (1f - impactT) * 0.8f; + float flashAlpha = BattlePresentation.ReducedFlashes ? 0f : ((1f - impactT) * 0.8f); float flashSize = 90f + impactT * 60f; GUI.color = new Color(elemCol.r, elemCol.g, elemCol.b, flashAlpha * 0.35f); GUI.DrawTexture(new Rect(tgtX - flashSize / 2, tgtY - flashSize / 2, flashSize, flashSize), Texture2D.whiteTexture); @@ -2820,7 +2673,7 @@ private void DrawMetalImpact(float tgtX, float tgtY, float impactT, Color elemCo // Bright highlight flash at intersection float hlSize = 30f * (1f - impactT); - GUI.color = new Color(1f, 1f, 1f, slashAlpha * 0.7f); + GUI.color = new Color(1f, 1f, 1f, BattlePresentation.ReducedFlashes ? 0f : slashAlpha * 0.7f); GUI.DrawTexture(new Rect(tgtX - hlSize / 2, tgtY - hlSize / 2, hlSize, hlSize), Texture2D.whiteTexture); // Metal shards flying outward @@ -2849,7 +2702,7 @@ private void DrawMetalImpact(float tgtX, float tgtY, float impactT, Color elemCo private void DrawDefaultImpact(float tgtX, float tgtY, float impactT) { // Impact flash - float flashAlpha = (1f - impactT) * 0.7f; + float flashAlpha = BattlePresentation.ReducedFlashes ? 0f : ((1f - impactT) * 0.7f); float flashSize = 100f + impactT * 80f; GUI.color = new Color(1f, 0.5f, 0.2f, flashAlpha); GUI.DrawTexture(new Rect(tgtX - flashSize / 2, tgtY - flashSize / 2, flashSize, flashSize), Texture2D.whiteTexture); @@ -3014,19 +2867,7 @@ private void DrawBuffDebuffEffect(float t) } } } - private Color GetSkillColor(SkillEffectType type) - { - switch (type) - { - case SkillEffectType.Damage: return new Color(0.9f, 0.35f, 0.3f); - case SkillEffectType.BuffAttack: return new Color(0.3f, 0.8f, 0.4f); - case SkillEffectType.DebuffAttack: return new Color(0.7f, 0.4f, 0.9f); - case SkillEffectType.Heal: return new Color(0.35f, 0.92f, 0.62f); - case SkillEffectType.DefenseBuff: return new Color(0.35f, 0.68f, 1f); - case SkillEffectType.Stun: return new Color(1f, 0.86f, 0.25f); - case SkillEffectType.PoisonDot: return new Color(0.68f, 0.35f, 0.88f); - default: return Color.gray; - } - } + // 팔레트는 UITheme이 단일 출처다(1v1 배틀·훈련소와 같은 표). + private Color GetSkillColor(SkillEffectType type) => UITheme.Instance.GetSkillColor(type); } } diff --git a/Assets/Scripts/UI/RaidBattleUI.Feel.cs b/Assets/Scripts/UI/RaidBattleUI.Feel.cs new file mode 100644 index 0000000..5b4539a --- /dev/null +++ b/Assets/Scripts/UI/RaidBattleUI.Feel.cs @@ -0,0 +1,158 @@ +using System.Collections.Generic; +using InsectGame.Battle; +using InsectGame.Core; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 레이드 화면의 체감 화면(4단계) — 일반 레이드 진입(화면 쓸기 + 아래 무대의 「레이드 보스 ○○ 출현!」), 타격 순간의 상성 표시, + /// 전용기 행동 강조(사전 컷인 없이 행동 문구를 크게·속성색 테두리로), 승리 화면. 1대1(BattleScreenUI.Feel)과 같은 순수 계산· + /// 그리기()를 쓴다. 수문장 레이드의 진입은 3단계 배너(RaidBattleUI.Stage)가 그대로 맡는다. + /// + /// 모놀리스(RaidBattleUI.cs·.Draw.cs)를 키우지 않으려고 partial로 뗐다. 본체는 DrawIntro(진입), DrawAttackEffects + /// (상성·2D 전용기 이름), DrawActionText(전용기 강조), DrawResult(승리 화면)에서 부른다. + /// + public partial class RaidBattleUI + { + // ── 진입(일반 레이드) ── + + private string raidEntryTitle; + private InsectData raidEntryFor; + + /// + /// 일반 레이드 진입 — 처음 1.4초(인트로 시계)에 화면 쓸기, 그 위로 아래 무대 가운데에 「레이드 보스 ○○ 출현!」 + 「모두 힘을 합쳐 쓰러뜨리자!」. + /// 위쪽(보스 자리)은 비운다 — 아레나 진입 샷(1초)이 보스 옆에서 팀 뒤로 돈다. 인트로 끝 0.25초에 사라지고 스킬 패널이 같은 자리를 이어받는다. + /// + private void DrawRaidEntry() + { + if (raidController == null || raidController.BossStats == null) return; + HudFrame f = HudFrame.Current; + bool reduced = BattlePresentation.ReducedMotion; + float p = BattleReadPacing.EntryProgress(introTimer); + BattleFeelDraw.EntryWipe(f, p, reduced); + InsectData boss = raidController.BossStats.Data; + if (raidEntryTitle == null || !ReferenceEquals(boss, raidEntryFor)) + { + raidEntryFor = boss; + raidEntryTitle = BattleEntryText.Raid(boss != null ? boss.displayName : null); + } + float alpha = BattleEntryStaging.TitleAlpha(p, introTimer, IntroSeconds, false); + if (alpha <= 0.001f) return; + float since = (p - BattleEntryStaging.TitleIn) * BattleReadPacing.EntryIntroSeconds; + Color accent = boss != null ? UITheme.Instance.GetInsectRarityColor(boss.rarity) : UITheme.Instance.accentCoral; + BattleFeelDraw.EntryTitle(BattleEntryLayout.RaidTitle(f), raidEntryTitle, BattleEntryText.RaidSubLine, + BattleEntryLayout.RaidTitleFont, alpha, BattleEntryStaging.Pop(since, reduced), accent); + } + + // ── 상성 ── + + /// 타격 순간의 상성 표시 — 보통이면 없다. 는 숫자가 뜬 뒤 초(튀어나오는 크기). + private void DrawRaidImpactMatchup(Matchup m, float cx, float cy, float alpha, float age) + { + if (m == Matchup.Neutral) return; + BattleFeelDraw.MatchupCaption(HudFrame.Current, cx, cy, m, alpha, MatchupHud.CaptionScale(age, BattlePresentation.ReducedMotion)); + } + + // ── 전용기 ── + + /// 지금 화면이 보여 주는 행동 — 팀원 차례면 그 곤충의 행동, 보스 예고·공격이면 보스 행동. 합체공격은 null. + private RaidActionResult ShownAction + { + get + { + if (phase == Phase.PlayerAttack) return lastMemberAction; + if ((phase == Phase.BossAttack || phase == Phase.BossTelegraph) && activeRound != null) return activeRound.BossAction; + return null; + } + } + + /// 지금 보이는 팀원 행동이 전용기인가(2D 폴백의 기술 이름 강조). + private bool ShownActionSignature + { + get + { + RaidActionResult a = ShownAction; + return a != null && a.IsSignature; + } + } + + private string actionStyleFor; + private bool actionStyleSignature; + private Color actionStyleElement; + + /// + /// 행동 문구 띠가 전용기 행동의 것인가 — 문구가 바뀌는 순간(그 행동의 연출 첫 프레임) 한 번 정해 둔다. 띠는 단계가 넘어간 뒤에도 + /// 잠깐 남는데(1.6~2초), 그때 단계로 다시 물으면 같은 문구가 도중에 보통 모양으로 바뀐다. + /// + private bool ActionTextIsSignature(out Color element) + { + if (!ReferenceEquals(actionStyleFor, actionText)) + { + actionStyleFor = actionText; + RaidActionResult a = ShownAction; + actionStyleSignature = a != null && a.IsSignature; + actionStyleElement = GetElementColor(a != null ? a.Element : InsectElement.Bug); + } + element = actionStyleElement; + return actionStyleSignature; + } + + /// + /// 2D 폴백(아레나 없음)의 전용기 이름 — 보스 위에 크게, 속성색 테두리 판 + 「전용기!」 배지. 3D는 시전자가 말풍선으로 외치고 + /// 행동 문구 띠가 강조된다(). + /// + private void DrawRaidSignatureName2D(float cx, float top, float alpha) + { + if (alpha <= 0.001f || string.IsNullOrEmpty(lastSkillUsedName)) return; + RaidActionResult a = ShownAction; + Color element = GetElementColor(a != null ? a.Element : InsectElement.Bug); + UITheme theme = UITheme.Instance; + Rect plate = new Rect(cx - 230f, top, 460f, 64f); + Color prev = GUI.color; + GUI.color = new Color(prev.r, prev.g, prev.b, prev.a * alpha); + UISurface.Card(plate, theme.surfaceBase, element); + attackSkillNameStyleCache.fontSize = 40; + attackSkillNameStyleCache.normal.textColor = SkillUILayout.GetReadableAccent(element); + // 기술 이름을 데이터가 정한다 — 넘치면 글자를 줄여 맞춘다. + UIHelper.LabelFit(new Rect(plate.x + 16f, plate.y + 6f, plate.width - 32f, plate.height - 12f), lastSkillUsedName, + attackSkillNameStyleCache); + attackSkillNameStyleCache.fontSize = 32; + GUI.color = prev; + BattleFeelDraw.SignatureBadge(new Rect(plate.x, plate.y - UITheme.Space.XS - 40f, 140f, 40f), alpha); + } + + // ── 승리 ── + + private List raidVictoryLines; + private InsectBattleStats raidVictoryFor; + + /// + /// 레이드 승리 화면 — 1대1과 같은 결: 위쪽 큰 「레이드 승리!」, 가운데는 팀 전원 점프(아레나, ), + /// 아래 보상 판(0.8초부터 한 줄씩 — 캔디·경험치·보너스·보스 포획), 맨 아래 「눌러서 계속」. 시계는 실제 초(). + /// 위쪽 보스 카드·팀 줄·합체 게이지는 이 동안 걷힌다. + /// + private void DrawRaidVictory() + { + HudFrame f = HudFrame.Current; + bool reduced = BattlePresentation.ReducedMotion; + float shown = ResultShownSeconds; + UISurface.Dim(0.12f); + BattleFeelDraw.VictoryTitle(f, BattleVictoryLayout.RaidTitle, shown, reduced); + BattleFeelDraw.RewardPanel(f, RaidVictoryLines(), shown); + BattleFeelDraw.ContinueHint(f, ResultCanClose, shown - BattleResultRules.InputLockSeconds, reduced); + } + + private List RaidVictoryLines() + { + InsectBattleStats boss = raidController != null ? raidController.BossStats : null; + if (raidVictoryLines != null && ReferenceEquals(raidVictoryFor, boss)) return raidVictoryLines; + raidVictoryFor = boss; + string bossName = boss != null && boss.Data != null ? boss.Data.displayName : null; + raidVictoryLines = BattleRewardLines.Raid(raidController != null ? raidController.RewardCandy : 0, + raidController != null ? raidController.RewardExp : 0, bossName); + return raidVictoryLines; + } + } +} diff --git a/Assets/Scripts/UI/RaidBattleUI.Feel.cs.meta b/Assets/Scripts/UI/RaidBattleUI.Feel.cs.meta new file mode 100644 index 0000000..cc0001d --- /dev/null +++ b/Assets/Scripts/UI/RaidBattleUI.Feel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 680d8347f89673b4fb7028e98849128b \ No newline at end of file diff --git a/Assets/Scripts/UI/RaidBattleUI.Flow.cs b/Assets/Scripts/UI/RaidBattleUI.Flow.cs new file mode 100644 index 0000000..ab33fb1 --- /dev/null +++ b/Assets/Scripts/UI/RaidBattleUI.Flow.cs @@ -0,0 +1,112 @@ +using InsectGame.Battle; +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 레이드 화면의 흐름 — 결과 화면 닫기와 읽는 단계(인트로·그림자 변신)의 배속 하한. 1대1(BattleScreenUI.Flow)과 + /// 같은 순수 규칙(·)을 쓴다. 모놀리스 본체를 키우지 않으려고 partial로 뗐다 — + /// 본체는 Update(시계 배율·결과 닫기)·OnGUI(Space/Enter)·OnRaidEnded/EndRaid(초기화)에서 부른다. + /// + /// 결과 화면의 두 시계: 그리기용 resultTimer는 예전처럼 연출 시계(배속)다 — 보상 숫자가 튀어나오는 박자가 그대로다. + /// 닫기 판정은 실제 시간(resultRealTimer)으로 잰다 — 2배속이라고 잠금이 0.3초가 되지 않게. + /// + public partial class RaidBattleUI + { + // ── 결과 화면 닫기 ── + + private float resultRealTimer; + private bool wantResultClose; + private bool resultCloseArmed; + private float resultArmedAt; + + /// + /// 결과 화면(RAID CLEAR/FAILED)이 지금 누름을 받는가(처음 초가 지났다). + /// ui-dev가 「잠시 후 자동으로 돌아갑니다...」 대신 「눌러서 계속」을 이때부터 보인다. 결과 화면이 아니면 false. + /// + public bool ResultCanClose => phase == Phase.Result && BattleResultRules.CanClose(resultRealTimer); + + /// 결과 화면이 뜬 뒤 지난 실제 초. 결과 화면이 아니면 0. + public float ResultShownSeconds => phase == Phase.Result ? resultRealTimer : 0f; + + private void ResetResultClose() + { + resultRealTimer = 0f; + wantResultClose = false; + resultCloseArmed = false; + resultArmedAt = 0f; + } + + /// + /// 결과 화면의 한 프레임 — 탭·클릭·Space/Enter를 받아(잠금 뒤) 손을 뗄 때 닫는다. 레이드에는 저절로 닫히는 길이 없다 + /// (꿈 챔피언전은 1대1이다). wantMouseClick은 Update 말미가 비우므로 여기서 먼저 읽는다. + /// + private void TickResultClose() + { + // 플레이어가 닫을 때까지 묶어 둔다 — 1대1과 같은 까닭(PlayerMovement의 20초 자동 해제·ESC 해제 뒤 탭이 클릭-이동으로 샌다). + if (playerMovement != null && !playerMovement.IsFrozen) playerMovement.SetFrozen(true); + resultRealTimer += Time.unscaledDeltaTime; + bool pressed = wantResultClose || wantMouseClick || BattleResultInput.PressedThisFrame(); + wantResultClose = false; + if (!resultCloseArmed && BattleResultRules.AcceptsPress(resultRealTimer, pressed)) + { + resultCloseArmed = true; + resultArmedAt = resultRealTimer; + } + + bool released = resultCloseArmed + && BattleResultRules.ReleaseComplete(BattleResultInput.Held(), resultRealTimer - resultArmedAt); + if (released || BattleResultRules.ShouldAutoClose(resultRealTimer, sandbox: false)) + EndRaid(); + } + + /// OnGUI의 KeyDown — 결과 화면에서 Space/Enter를 받는다(스킬 키 Q·W·E·R 등은 건드리지 않는다). + private void CaptureResultKey(Event evt) + { + if (phase != Phase.Result || evt == null || evt.type != EventType.KeyDown) return; + if (!BattleResultInput.IsCloseKey(evt.keyCode)) return; + wantResultClose = true; + evt.Use(); + } + + // ── 읽는 단계의 배속 하한 ── + + private bool IsGuardianRaid => raidController != null && !string.IsNullOrEmpty(raidController.BossGuardianRegionId); + + /// 단계 시계(phaseTimer) 배율 — 그림자 변신만 하한을 둔다. + private float PhaseClockScale => phase == Phase.BossTransform + ? BattleReadPacing.ClockScale(BossTransformDuration, BattleReadPacing.BossTransformMinSeconds, BattlePresentation.Speed) + : 1f; + + /// + /// 인트로 시계(introTimer) 배율 — 수문장 등장(2.6초)은 , + /// 일반 레이드 인트로(2초)는 하한. + /// + private float IntroClockScale => phase == Phase.Intro + ? BattleReadPacing.ClockScale(IntroSeconds, + IsGuardianRaid ? BattleReadPacing.GuardianIntroMinSeconds : BattleReadPacing.RaidIntroMinSeconds, + BattlePresentation.Speed) + : 1f; + + /// + /// 그림자 변신 연출(아레나 PlayBossTransform)에 넘길 길이(연출 시계 초) — 변신 단계와 같은 실제 시간에 끝난다. + /// 1배속 1.5초, 2배속 2.0초(실제 1.0초). 을 넘기면 2배속에서 연출이 0.75초에 끝나 + /// 「쿵」 문구( 0.6)가 울부짖음보다 0.15초 늦다. + /// + public float BossTransformStagingSeconds => + BattleReadPacing.StagingSeconds(BossTransformDuration, BattleReadPacing.BossTransformMinSeconds, BattlePresentation.Speed); + + /// + /// 수문장 등장 컷(아레나 PlayGuardianIntro — 초 단위 시각표)이 화면 인트로와 같은 박자로 돌려면 연출 시계에 곱할 배율. + /// 1배속 1, 2배속 0.65(2.6초 → 실제 2.0초). 일반 레이드면 1. 인트로 밖에서도 같은 값을 준다(시작 프레임에 읽어 가도 되게). + /// + public float GuardianIntroClockScale => IsGuardianRaid + ? BattleReadPacing.ClockScale(BattleStaging.GuardianIntroSeconds, BattleReadPacing.GuardianIntroMinSeconds, BattlePresentation.Speed) + : 1f; + + /// 인트로 진행률 0~1(레이드 밖이면 0, 인트로가 끝났으면 1). + public float IntroProgress => phase == Phase.None ? 0f + : phase == Phase.Intro ? Mathf.Clamp01(introTimer / IntroSeconds) : 1f; + } +} diff --git a/Assets/Scripts/UI/RaidBattleUI.Flow.cs.meta b/Assets/Scripts/UI/RaidBattleUI.Flow.cs.meta new file mode 100644 index 0000000..ae1a4c1 --- /dev/null +++ b/Assets/Scripts/UI/RaidBattleUI.Flow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4f50c1c9b6ebb3e4186a516140d07060 \ No newline at end of file diff --git a/Assets/Scripts/UI/RaidBattleUI.Impact.cs b/Assets/Scripts/UI/RaidBattleUI.Impact.cs new file mode 100644 index 0000000..4187f0b --- /dev/null +++ b/Assets/Scripts/UI/RaidBattleUI.Impact.cs @@ -0,0 +1,141 @@ +using InsectGame.Battle; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 레이드 연출에 넘길 타격 정보와 3D 위치 — 모놀리스 본체(RaidBattleUI.cs·.Draw.cs)를 + /// 키우지 않으려고 떼었다. 본체는 팀원 행동·보스 예고·보스 공격 콜백에서 여기를 부르고, + /// 3D 아레나가 켜져 있으면 2D용 사각형 투사체·섬광 대신 로 숫자만 모델 위에 붙인다. + /// + public partial class RaidBattleUI + { + /// 3D 아레나가 연출을 맡고 있다 — 2D 폴백 그림(사각형 투사체·화면 섬광·폭발)은 그리지 않는다. + private bool Arena3D => arena != null && arena.IsActive; + + /// + /// 팀원 한 마리의 공격 세기. 보스 HP는 팀원 다섯 마리가 여러 라운드에 나눠 깎는 크기라 + /// 1v1처럼 "최대 HP의 40%"를 만점으로 두면 모든 타격이 솜방망이가 된다 — 보스 최대 HP의 10%를 만점으로 본다. + /// + private BattleArenaController.HitCue BuildMemberCue(RaidActionResult action) + { + int bossMax = raidController != null && raidController.BossStats != null ? raidController.BossStats.MaxHp : 0; + // 치명타는 리졸버가 굴린 진짜 판정이다(RaidActionResult.Critical) — 세기 구간과 치명타 연출이 함께 따른다. + bool critical = action.Critical && action.Damage > 0; + // 전용기는 리졸버가 1대1과 같은 기준(SignatureSkills)으로 적어 둔 표지다 — 빗나가도 꺼낸 것 자체가 연출거리다. + return new BattleArenaController.HitCue(action.DisplayName, + BattleArenaController.HitCue.WeightFor(action.Damage, Mathf.Max(1, bossMax / 4), critical), + action.KnockedOut, action.Missed, critical, action.IsSignature); + } + + /// 보스 공격을 맞는 쪽의 세기 — 피해 ÷ 그 팀원의 최대 HP, 쓰러졌으면 마무리. + private BattleArenaController.HitCue BuildBossCue(RaidRoundResult round, int slot) + { + if (round == null || raidController == null || raidController.TeamStats == null) return BattleArenaController.HitCue.None; + int index = slot; + if (index < 0) + { + // 전체 공격 — 가장 크게 맞은 팀원 기준으로 세기를 잡는다. + int best = 0; + for (int i = 0; i < round.BossDamageBySlot.Length; i++) + if (round.BossDamageBySlot[i] > best) { best = round.BossDamageBySlot[i]; index = i; } + } + if (index < 0 || index >= round.BossDamageBySlot.Length || index >= raidController.TeamStats.Length) + return BattleArenaController.HitCue.None; + InsectBattleStats victim = raidController.TeamStats[index]; + int damage = round.BossDamageBySlot[index]; + int maxHp = victim != null ? victim.MaxHp : 0; + bool fainted = victim != null && damage > 0 && victim.CurrentHp <= 0; + // 보스의 한 행동에 한 번 굴린 판정(전체 공격이면 맞은 전원이 같다) — 붉은 별 빛살·CriticalHit 소리가 따른다. + bool critical = round.BossAction != null && round.BossAction.Critical && damage > 0; + bool signature = round.BossAction != null && round.BossAction.IsSignature; // 보스의 지금 모습 기준(리졸버) + return new BattleArenaController.HitCue(null, BattleArenaController.HitCue.WeightFor(damage, maxHp, critical), + fainted, false, critical, signature); + } + + /// + /// 레이드를 시작할 때 이미 기절해 있던 슬롯(HP 0). 아레나가 그 모델을 세우지 않도록 + /// BattleArenaController.SetupRaidBattle(…, teamDown)에 넘긴다 — 컨트롤러는 그 슬롯을 처음부터 + /// "쓰러짐을 보여 줬다"로 쳐서(teamFaintPresented) 레이드 내내 아무도 눕히지 않는다. + /// + private bool[] TeamDownAtStart() + { + InsectBattleStats[] team = raidController != null ? raidController.TeamStats : null; + if (team == null) return null; + var down = new bool[team.Length]; + for (int i = 0; i < team.Length; i++) + down[i] = team[i] == null || team[i].CurrentHp <= 0; + return down; + } + + /// + /// 보스 공격 예고를 3D로도 건다 — 보스가 젖히며 기를 모으고, 노리는 팀원 발밑에 경고 고리. + /// 길이는 UI 예고 배너와 같다(BossTelegraphDuration). 이름 있는 기술(시그니처)만 외친다 — + /// 일반 공격의 이름은 "공격"이라 외치면 어색하다. + /// + private void BeginBossTelegraphPresentation() + { + if (!Arena3D || raidController == null) return; + RaidBossIntent intent = activeRound != null ? activeRound.BossIntent : raidController.NextBossIntent; + if (intent == null) return; + arena.PlayRaidBossTelegraph(intent.TargetSlot, intent.IsArea, intent.Element, + intent.Skill != null ? intent.DisplayName : null, BossTelegraphDuration); + } + + private const float TeamStripHeight = 104f; + private float teamStripDrop; + private float teamStripHide; + + /// + /// 팀 HP 패널 줄의 y. 스킬을 고를 때는 화면 53%(스킬 패널 바로 위), 3D 연출이 도는 동안 + /// (팀원 공격·보스 예고·보스 공격·합체공격)에는 화면 아래로 내려 비켜선다 — 레이드 카메라 구도에서 + /// 팀원 모델이 정확히 그 줄 뒤에 서서, 돌진도 피격도 패널에 가려 보이지 않았다(QA 캡처, 개편 전부터). + /// 내려갈 때는 곧바로, 올라올 때만 0.25초에 걸쳐 미끄러진다(실제 시간 — 히트스톱에 멈추지 않게, ). + /// 예전엔 내려갈 때도 미끄러져서, 기술을 고른 순간 스킬 패널이 사라진 자리에 줄과 행동 문구 띠가 화면 가운데에 떴다가 내려갔다. + /// + private float TeamStripY() + { + float rest = Mathf.Clamp(UIScale.VirtualScreenHeight * 0.53f, UISafeLayout.ContentTop, + UISafeLayout.ContentBottom - TeamStripHeight); + if (!Arena3D) return rest; + bool acting = phase == Phase.PlayerAttack || phase == Phase.BossTelegraph + || phase == Phase.BossAttack || phase == Phase.UniteAttack; + if (acting) teamStripDrop = 1f; // 이벤트 종류와 무관하게 곧바로 — 레이아웃 패스와 그리기 패스가 같은 자리를 본다 + else if (Event.current == null || Event.current.type == EventType.Repaint) + teamStripDrop = RaidTeamStrip.NextDrop(teamStripDrop, false, Time.unscaledDeltaTime); + float eased = Mathf.SmoothStep(0f, 1f, teamStripDrop); + return Mathf.Lerp(rest, UISafeLayout.BottomY(TeamStripHeight), eased); + } + + /// + /// 팀 줄의 불투명도(1 = 보임). 수문장 등장·그림자 변신·결과 동안은 숨는다 — 그때 줄이 화면 가운데(53%)에 떠서 보스 아랫부분과 + /// 변신 연기를 가렸고(3단계 QA), 아래로 내리면 바닥에 서는 수문장 배너·변신 문구와 겹친다. 수문장 등장은 전투 첫 장면이라 곧바로 숨고, + /// 나머지는 0.2초에 옅어진다(). 합체 게이지도 같이 숨는다. + /// + private float TeamStripVisibility() + { + bool guardianIntro = phase == Phase.Intro && IsGuardianRaid; + bool hidden = RaidTeamStrip.Hidden(guardianIntro, phase == Phase.BossTransform, phase == Phase.Result); + if (hidden && guardianIntro) teamStripHide = 1f; + else if (Event.current == null || Event.current.type == EventType.Repaint) + teamStripHide = RaidTeamStrip.NextHide(teamStripHide, hidden, false, Time.unscaledDeltaTime); + return 1f - teamStripHide; + } + + /// + /// 아레나 화면 좌표(픽셀, y 위쪽)를 가상 캔버스 좌표로. 보스면 은 -1. + /// 3D가 아니거나 카메라 뒤면 false — 호출부는 2D 고정 좌표로 폴백한다. + /// + private bool ArenaPoint(int slot, out Vector2 point) + { + point = Vector2.zero; + if (!Arena3D) return false; + Vector3 screen = slot < 0 ? arena.GetCombatantScreenPosition(false) : arena.GetTeamScreenPosition(slot); + if (screen.z <= 0f) return false; + point = new Vector2( + screen.x / Mathf.Max(1, Screen.width) * UIScale.VirtualScreenWidth, + (1f - screen.y / Mathf.Max(1, Screen.height)) * UIScale.VirtualScreenHeight); + return true; + } + } +} diff --git a/Assets/Scripts/UI/RaidBattleUI.Impact.cs.meta b/Assets/Scripts/UI/RaidBattleUI.Impact.cs.meta new file mode 100644 index 0000000..245a0f7 --- /dev/null +++ b/Assets/Scripts/UI/RaidBattleUI.Impact.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bb5b9e2b08b70854788d9d4f9cb61892 \ No newline at end of file diff --git a/Assets/Scripts/UI/RaidBattleUI.Stage.cs b/Assets/Scripts/UI/RaidBattleUI.Stage.cs new file mode 100644 index 0000000..4a60a01 --- /dev/null +++ b/Assets/Scripts/UI/RaidBattleUI.Stage.cs @@ -0,0 +1,524 @@ +using InsectGame.Battle; +using InsectGame.Core; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 레이드의 이야기 문구 — 수문장 등장 배너(인트로), 보스 변신 문구(변신 단계), 보스 이름 아래 「○○의 모습」. + /// 모놀리스(RaidBattleUI.cs·.Draw.cs)를 키우지 않으려고 partial로 뗐다(BattleScreenUI.Duel과 같은 까닭). + /// + /// 위쪽은 보스 자리다 — 수문장 등장 카메라(visual-dev)가 보스를 화면 위 2/3에 잡고, 변신 연기도 거기서 핀다. 그래서 글은 전부 + /// 아래쪽에 선다(자리는 , 순수 계산). 데이터는 읽기만 한다 — 별칭·등장 줄은 + /// (game-designer), 변신 줄·모습은 (battle-dev). + /// 본체는 세 군데서 부른다 — DrawIntro 첫 줄, OnGUI의 변신 단계 분기, DrawBossHpBar·DrawBossField의 이름 아래. + /// + public partial class RaidBattleUI + { + private bool stageStylesReady; + private GUIStyle bossFormCaptionStyle; + private GUIStyle transformLineStyle; + private GUIStyle guardianTitleStyle; + private GUIStyle guardianEpithetStyle; + private GUIStyle guardianNameStyle; + private GUIStyle guardianLineStyle; + private GUIContent guardianMeasure; + + private void EnsureStageStyles() + { + if (stageStylesReady) return; + stageStylesReady = true; + bossFormCaptionStyle = new GUIStyle(GUI.skin.label) + { fontSize = RaidStageLayout.FormCaptionFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + transformLineStyle = new GUIStyle(GUI.skin.label) + { fontSize = RaidStageLayout.TransformFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + guardianTitleStyle = new GUIStyle(GUI.skin.label) + { fontSize = RaidStageLayout.GuardianTitleFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + guardianEpithetStyle = new GUIStyle(GUI.skin.label) + { fontSize = RaidStageLayout.GuardianEpithetFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + guardianNameStyle = new GUIStyle(GUI.skin.label) + { fontSize = RaidStageLayout.GuardianNameFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + guardianLineStyle = new GUIStyle(GUI.skin.label) + { fontSize = RaidStageLayout.GuardianLineFont, fontStyle = FontStyle.Italic, alignment = TextAnchor.MiddleCenter }; + guardianMeasure = new GUIContent(); + } + + // ── 보스 이름 아래 「○○의 모습」 ── + + private InsectData formCaptionFor; + private string formCaption; + + /// + /// 지금 보여 줄 「○○의 모습」(원래 모습이면 null). 변신이 걸려 있지만 아직 연출 전이거나, 변신 단계에서 아레나가 모델을 아직 + /// 안 바꿨으면( 전) 바뀌기 전 모습이다 — 컨트롤러는 임계를 넘긴 그 행동에서 이미 모습을 + /// 바꿨는데, 이름표가 먼저 바뀌면 연기 속 모델과 이름이 어긋난다. 문자열은 모습이 바뀔 때만 만든다. + /// + private string BossFormCaption() + { + if (raidController == null) return null; + RaidBossFormChange unrevealed = RaidStageLayout.UnrevealedChange(bossFormPending, pendingBossTransform, + ActiveBossTransform, BossTransformProgress); + InsectData shown = RaidStageLayout.ShownForm(raidController.BossFormIndex, raidController.BossFormData, unrevealed); + if (shown == null) return null; + if (!ReferenceEquals(shown, formCaptionFor) || formCaption == null) + { + formCaptionFor = shown; + formCaption = RaidStageLayout.FormCaptionText(shown.displayName); + } + return formCaption; + } + + private void DrawBossFormCaption(Rect rect, string caption, bool centered = false) + { + EnsureStageStyles(); + bossFormCaptionStyle.alignment = centered ? TextAnchor.MiddleCenter : TextAnchor.MiddleLeft; + bossFormCaptionStyle.normal.textColor = UITheme.Instance.accentAmber; + UIHelper.LabelFit(rect, caption, bossFormCaptionStyle); + } + + // ── 변신 문구 ── + + /// + /// 변신 단계(, 입력 없음) 동안 아래 무대 가운데에 「그림자가 ○○의 모습을 빌렸다!」를 크게. + /// 위쪽의 보스(연기·모델 교체 — visual-dev)는 가리지 않는다. 처음 0.12에 떠오르고 마지막 0.1에 사라진다(진행률 기준). + /// 연기가 덮기 전부터 뜬다 — 단계가 배속 1.5초라 바뀌는 순간()에 띄우면 읽을 틈이 없다. + /// + private void DrawBossTransform() + { + string line = BossTransformLine; + if (string.IsNullOrEmpty(line)) return; + EnsureStageStyles(); + + UITheme theme = UITheme.Instance; + float p = BossTransformProgress; + float alpha = Mathf.Lerp(RaidStageLayout.AppearAlpha, 1f, Mathf.Clamp01(p / 0.12f)) * Mathf.Clamp01((1f - p) / 0.1f); + if (alpha <= 0.001f) return; + float rise = BattlePresentation.ReducedMotion ? 0f + : (1f - Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(p / 0.2f))) * RaidStageLayout.BannerRise; + + Rect banner = RaidStageLayout.TransformBanner(HudFrame.Current); + banner.y += rise; + Color prev = GUI.color; + GUI.color = new Color(prev.r, prev.g, prev.b, prev.a * alpha); + UISurface.Card(banner, theme.surfaceBase, theme.accentCoral); + UISurface.Flat(new Rect(banner.x + UITheme.Radius.Card, banner.y + 3f, banner.width - UITheme.Radius.Card * 2f, 5f), + theme.accentCoral); + transformLineStyle.normal.textColor = theme.textPrimary; + // 새 모습이 연기를 뚫고 울부짖는 박자(BattleStaging.TransformRoar)에 글자가 한 번 "쿵" — 상자가 아니라 GUI 행렬로 키운다. + Rect text = new Rect(banner.x + 28f, banner.y + 10f, banner.width - 56f, banner.height - 20f); + float punch = BattlePresentation.ReducedMotion ? 1f + : RaidStageLayout.RoarPunch((p - BattleStaging.TransformRoar) * BossTransformDuration); + Matrix4x4 saved = GUI.matrix; + if (punch > 1.0001f) + GUI.matrix = saved * Matrix4x4.TRS(new Vector3(text.center.x, text.center.y, 0f), Quaternion.identity, + new Vector3(punch, punch, 1f)) * Matrix4x4.TRS(new Vector3(-text.center.x, -text.center.y, 0f), + Quaternion.identity, Vector3.one); + // 빌린 곤충 이름을 데이터가 정한다 — 넘치면 글자를 줄여 맞춘다. + UIHelper.LabelFit(text, line, transformLineStyle); + GUI.matrix = saved; + GUI.color = prev; + } + + // ── 수문장 등장 배너 ── + + private string guardianIntroKey = string.Empty; + private bool guardianIntroReady; + private bool guardianHasIntro; + private string guardianTitle; + private string guardianEpithet; + private string guardianName; + private string guardianLine; + private InsectElement guardianPrimary; + private InsectElement guardianSecondary; + private string guardianPrimaryLabel; + private string guardianSecondaryLabel; + private float guardianNameWidth = -1f; + + /// + /// 수문장 레이드면 인트로 동안 아래쪽 배너를 그리고 true — 「초원의 수문장」(작게) / 별칭(크게) / 곤충 이름 + 속성 칩 / 등장 한 줄. + /// 수문장이 아니면 false(예전 RAID BOSS → 이름 → FIGHT! 인트로가 그대로 돈다). 별칭 표에 없는 리전이면 칭호와 이름만 선다. + /// 박자는 아레나의 등장 컷()에 맞춘다 — 판은 아래에서 0.35초에 걸쳐 떠오르고, 별칭은 수문장이 포효하는 + /// 순간() "쾅" 하고 찍히고, 등장 한 줄은 그 뒤에 들어온다. 인트로 끝 0.2초에 옅어지고 + /// 스킬 패널이 같은 자리를 이어받는다. + /// + private bool DrawGuardianIntro() + { + if (!PrepareGuardianIntro()) return false; + EnsureStageStyles(); + + UITheme theme = UITheme.Instance; + HudFrame f = HudFrame.Current; + float t = introTimer; + bool reduced = BattlePresentation.ReducedMotion; + float ease = reduced ? 1f : Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(t / 0.35f)); + // 인트로 끝(IntroSeconds — 수문장은 BattleStaging.GuardianIntroSeconds) 0.2초 전부터 옅어진다. 스킬 패널이 같은 자리를 이어받는다. + float alpha = Mathf.Lerp(RaidStageLayout.AppearAlpha, 1f, Mathf.Clamp01(t / 0.25f)) * Mathf.Clamp01((IntroSeconds - t) / 0.2f); + if (alpha <= 0.001f) return true; + float drop = (1f - ease) * RaidStageLayout.GuardianSlide; + + RaidStageLayout.GuardianPlan plan = RaidStageLayout.GuardianBanner(f, guardianHasIntro); + Rect card = Shift(plan.Card, drop); + + Color prev = GUI.color; + GUI.color = new Color(prev.r, prev.g, prev.b, prev.a * alpha); + UISurface.Card(card, theme.surfaceBase, theme.accentAmber); + UISurface.Flat(new Rect(card.x + UITheme.Radius.Card, card.y + 3f, card.width - UITheme.Radius.Card * 2f, 6f), + theme.accentCoral); + + guardianTitleStyle.normal.textColor = theme.accentAmber; + UIHelper.LabelFit(Shift(plan.Title, drop), guardianTitle, guardianTitleStyle); + if (guardianHasIntro) + { + // 별칭은 수문장이 포효하는 박자(BattleStaging.GuardianRoarAt)에 "쾅" 하고 찍힌다 — 잠깐 컸다가 제 크기로. + // 크기는 글자 상자가 아니라 GUI 행렬로 키운다(상자를 매 프레임 바꾸면 LabelFit 캐시가 프레임마다 새 항목으로 찬다). + float sinceRoar = t - BattleStaging.GuardianRoarAt; + float epithetAlpha = reduced ? 1f : Mathf.Clamp01(sinceRoar / 0.08f); + if (epithetAlpha > 0.001f) + { + float punch = reduced ? 1f : RaidStageLayout.EpithetPunch(sinceRoar); + Rect epithet = Shift(plan.Epithet, drop); + Matrix4x4 saved = GUI.matrix; + if (punch > 1.0001f) + GUI.matrix = saved * Matrix4x4.TRS(new Vector3(epithet.center.x, epithet.center.y, 0f), Quaternion.identity, + new Vector3(punch, punch, 1f)) * Matrix4x4.TRS(new Vector3(-epithet.center.x, -epithet.center.y, 0f), + Quaternion.identity, Vector3.one); + GUI.color = new Color(prev.r, prev.g, prev.b, prev.a * alpha * epithetAlpha); + guardianEpithetStyle.normal.textColor = theme.textPrimary; + UIHelper.LabelFit(epithet, guardianEpithet, guardianEpithetStyle); + GUI.matrix = saved; + GUI.color = new Color(prev.r, prev.g, prev.b, prev.a * alpha); + } + } + + // 이름 + 속성 칩 — 묶음째 가운데. 이름 폭은 한 번만 잰다(레이드 한 번 동안 바뀌지 않는다). + if (guardianNameWidth < 0f) + { + guardianMeasure.text = guardianName; + guardianNameWidth = guardianNameStyle.CalcSize(guardianMeasure).x; + } + float chip1 = RaidStageLayout.ChipWidth(guardianPrimaryLabel); + float chip2 = guardianSecondaryLabel != null ? RaidStageLayout.ChipWidth(guardianSecondaryLabel) : 0f; + RaidStageLayout.NameRow row = RaidStageLayout.LayoutNameRow(Shift(plan.NameRow, drop), guardianNameWidth, chip1, chip2); + guardianNameStyle.normal.textColor = theme.textPrimary; + UIHelper.LabelFit(row.Name, guardianName, guardianNameStyle); + DrawGuardianChip(row.Chip1, guardianPrimaryLabel, guardianPrimary); + if (chip2 > 0f) DrawGuardianChip(row.Chip2, guardianSecondaryLabel, guardianSecondary); + + if (guardianHasIntro) + { + // 등장 한 줄은 포효 뒤에 들어온다. + float lineAlpha = reduced ? 1f : Mathf.Clamp01((t - BattleStaging.GuardianRoarAt - 0.2f) / 0.25f); + GUI.color = new Color(prev.r, prev.g, prev.b, prev.a * alpha * lineAlpha); + guardianLineStyle.normal.textColor = theme.textSecondary; + UIHelper.LabelFit(Shift(plan.Line, drop), guardianLine, guardianLineStyle); + } + GUI.color = prev; + return true; + } + + private static Rect Shift(Rect r, float dy) => new Rect(r.x, r.y + dy, r.width, r.height); + + private void DrawGuardianChip(Rect rect, string label, InsectElement element) + { + if (rect.width <= 0f || string.IsNullOrEmpty(label)) return; + UITheme theme = UITheme.Instance; + Color elem = GetElementColor(element); + UISurface.Chip(rect, label, Color.Lerp(theme.surfaceBase, elem, 0.35f), SkillUILayout.GetReadableAccent(elem)); + } + + /// + /// 이번 레이드가 수문장전이면 배너 글을 한 번 짓는다(리전이 바뀔 때만). 칭호의 리전 이름·곤충 이름은 리전 정의에서, + /// 별칭·등장 줄은 에서 — 같은 말을 두 곳에 두지 않는다. 리전 관리자가 없는 씬(QA 캡처)에서는 정의표를 본다. + /// + private bool PrepareGuardianIntro() + { + string region = raidController != null ? raidController.BossGuardianRegionId : null; + if (string.IsNullOrEmpty(region) || raidController.BossStats == null || raidController.BossStats.Data == null) + { + guardianIntroKey = string.Empty; + guardianIntroReady = false; + return false; + } + if (region == guardianIntroKey) return guardianIntroReady; + + guardianIntroKey = region; + guardianNameWidth = -1f; + RegionData data = FindGuardianRegion(region); + InsectData boss = raidController.BossStats.Data; + guardianTitle = data != null && !string.IsNullOrEmpty(data.displayName) ? $"{data.displayName}의 수문장" : "수문장"; + guardianName = data != null && !string.IsNullOrEmpty(data.guardianDisplayName) ? data.guardianDisplayName : boss.displayName; + guardianHasIntro = GuardianIntros.TryGet(region, out GuardianIntros.Intro intro) + && !string.IsNullOrEmpty(intro.epithet); + guardianEpithet = guardianHasIntro ? intro.epithet : null; + guardianLine = guardianHasIntro ? intro.line : null; + guardianPrimary = boss.primaryType; + guardianSecondary = boss.secondaryType; + guardianPrimaryLabel = InsectTypeChart.GetDisplayName(guardianPrimary); + guardianSecondaryLabel = guardianSecondary != InsectElement.None && guardianSecondary != guardianPrimary + ? InsectTypeChart.GetDisplayName(guardianSecondary) + : null; + guardianIntroReady = true; + return true; + } + + private RegionData FindGuardianRegion(string regionId) + { + if (cachedRegionMgr == null) cachedRegionMgr = FindFirstObjectByType(); + RegionData found = cachedRegionMgr != null ? cachedRegionMgr.GetRegionById(regionId) : null; + if (found != null) return found; + foreach (RegionData r in RegionDefinitions.CreateAll()) + if (r != null && r.regionId == regionId) return r; + return null; + } + } + + /// + /// 레이드 화면의 자리 — 순수 계산(테스트가 화면 여러 장으로 겹침을 잰다). 위쪽은 보스, 가운데(화면 53%)는 팀 패널 줄과 + /// 합체 게이지, 그 아래가 아래 무대다 — 스킬 패널이 서는 자리인데 인트로·변신 단계엔 비어 있어 이야기 문구가 선다. + /// + /// 변신 문구: 아래 무대 가운데, 높이 112 · 폭 min(1200, 안전 폭). 가로(1920×1080) y≈842, 세로(1080×1920) y≈1472. + /// 수문장 배너: 안전 영역 바닥에 붙는다. 별칭이 있으면 높이 277(가로 y≈771 — 화면 위 71%까지 비운다, 세로 y≈1585), 없으면 139. + /// 폭 min(1100, 안전 폭). 팀 패널 줄(인트로 동안 쉬는 자리)과 겹치지 않는다. + /// + public static class RaidStageLayout + { + /// 한 줄이 안 잘리는 높이 — 한글 줄높이 ≈ 글자 × 1.35(rules/ui-layout.md). + public static float LineHeight(int fontSize) => Mathf.Ceil(fontSize * 1.35f); + + // ── 고정 자리(그리는 쪽과 같은 식 — 여기가 단일 출처) ── + + public const float BossCardMaxWidth = 700f; + public const float BossCardHeight = 100f; + + /// 보스 HP 카드(DrawBossHpBar) — 위 가운데, 폭 min(700, 화면 70%). + public static Rect BossCard(HudFrame f) + { + float w = Mathf.Min(BossCardMaxWidth, f.Width * 0.7f); + return new Rect((f.Width - w) * 0.5f, f.ContentTop, w, BossCardHeight); + } + + public const int FormCaptionFont = 20; + /// 이름 아래 줄의 왼쪽 몫 — 「○○의 모습」. 오른쪽 나머지에 ATK·DEF가 오른쪽 맞춤으로 선다. + public const float FormCaptionShare = 0.58f; + + /// 보스 카드 이름 아래 줄 왼쪽 — 「호랑나비의 모습」. + public static Rect BossFormCaptionRow(Rect card) => + new Rect(card.x + 14f, card.y + 36f, (card.width - 28f) * FormCaptionShare, 24f); + + /// 보스 카드 이름 아래 줄 오른쪽 — 모습 글자가 있을 때의 ATK·DEF(오른쪽 맞춤). + public static Rect BossStatRow(Rect card) + { + float left = (card.width - 28f) * FormCaptionShare + UITheme.Space.S; + return new Rect(card.x + 14f + left, card.y + 38f, Mathf.Max(1f, card.width - 28f - left), 20f); + } + + /// 2D 보스 그림(아레나가 꺼졌을 때) 이름 밑 「○○의 모습」 — 가운데 맞춤. + public static Rect SpriteFormCaption(float centerX, float top) => new Rect(centerX - 170f, top, 340f, 30f); + + /// 「호랑나비의 모습」. + public static string FormCaptionText(string formName) => + string.IsNullOrEmpty(formName) ? null : formName + "의 모습"; + + /// + /// 아직 화면에 드러나지 않은 변신 — 걸려 있지만 연출 전()이면 그 변신, 변신 단계에서 아레나가 모델을 + /// 바꾸기 전(진행률 < )이면 지금 단계의 변신. 다 드러났으면 null. + /// + public static RaidBossFormChange UnrevealedChange(bool transformPending, RaidBossFormChange pending, + RaidBossFormChange active, float transformProgress) + { + if (transformPending && pending != null) return pending; + if (active != null && transformProgress < BattleStaging.TransformSwap) return active; + return null; + } + + /// + /// 이름 아래에 보여 줄 모습(원래 모습이면 null). 아직 드러나지 않은 변신()이 있으면 바뀌기 전 + /// 모습 — 그것도 원래 모습이었으면 null. + /// + public static InsectData ShownForm(int formIndex, InsectData formData, RaidBossFormChange unrevealed) + { + if (unrevealed != null) return unrevealed.FromIndex > 0 ? unrevealed.FromData : null; + return formIndex > 0 ? formData : null; + } + + /// 보스 예고(DrawBossIntent) — 보스 카드 아래. + public static Rect BossIntent(HudFrame f) + { + float w = Mathf.Min(680f, f.Width - 48f); + return new Rect((f.Width - w) * 0.5f, f.ContentTop + 110f, w, f.Mobile ? 64f : 54f); + } + + public const float TeamStripHeight = 104f; + + /// + /// 팀 패널 줄이 쉬는 자리(스킬을 고를 때·일반 레이드 인트로) — 화면 53%, 안전 영역 안. 폭은 안전 폭 전체로 잡는다(겹침 검사용 띠). + /// RaidBattleUI.Impact.TeamStripY의 쉬는 자리와 같은 식이다. 수문장 등장·그림자 변신·결과 동안은 줄이 숨는다() — + /// 그때 이 자리에 떠서 보스 아랫부분과 변신 연기를 가렸다. + /// + public static Rect TeamStripRest(HudFrame f) + { + float y = Mathf.Clamp(f.Height * 0.53f, f.ContentTop, f.ContentBottom - TeamStripHeight); + return new Rect(f.ContentLeft, y, f.ContentWidth, TeamStripHeight); + } + + public const float UniteGaugeMaxWidth = 400f; + public const float UniteGaugeHeight = 46f; + + /// 합체 게이지(DrawUniteGaugeBar) — 팀 패널 줄 바로 아래. + public static Rect UniteGauge(HudFrame f) + { + float w = Mathf.Min(UniteGaugeMaxWidth, f.ContentWidth); + float x = f.ContentLeft + f.ContentWidth * 0.5f - w * 0.5f; + float y = Mathf.Clamp(f.Height * 0.53f + 114f, f.ContentTop, f.ContentBottom - UniteGaugeHeight); + return new Rect(x, y, w, UniteGaugeHeight); + } + + /// 아래 무대 — 합체 게이지 밑(간격 16)부터 안전 영역 바닥까지, 안전 폭 전체. + public static Rect LowerStage(HudFrame f) + { + float top = UniteGauge(f).yMax + UITheme.Space.M; + return new Rect(f.ContentLeft, top, f.ContentWidth, Mathf.Max(1f, f.ContentBottom - top)); + } + + /// 떠오를 때 아래에서 올라오는 거리. + public const float BannerRise = 12f; + /// 뜨는 첫 프레임의 불투명도 — 0에서 시작하면 단계의 첫 장면(QA 상태 캡처)에 글자가 없다. + public const float AppearAlpha = 0.35f; + + // ── 변신 문구 ── + + public const int TransformFont = 46; + public const float TransformBannerHeight = 112f; + public const float TransformBannerMaxWidth = 1200f; + + /// 변신 문구 판 — 아래 무대 가운데. + public static Rect TransformBanner(HudFrame f) + { + Rect stage = LowerStage(f); + float w = Mathf.Min(TransformBannerMaxWidth, f.ContentWidth); + float h = Mathf.Min(TransformBannerHeight, stage.height); + return new Rect(stage.x + (stage.width - w) * 0.5f, stage.y + (stage.height - h) * 0.5f, w, h); + } + + // ── 수문장 등장 배너 ── + + public const int GuardianTitleFont = 30; + public const int GuardianEpithetFont = 60; + public const int GuardianNameFont = 38; + public const int GuardianLineFont = 30; + public const float GuardianMaxWidth = 1100f; + public const float GuardianPadX = 32f; + public const float GuardianPadY = 20f; + public const float GuardianChipHeight = 44f; + public const float GuardianChipPadding = 28f; + /// 칩 글자 한 자 폭 어림(칩 글자는 높이 × 0.52 ≈ 23pt) — 속성 이름은 1~3자라 재지 않고 어림한다. + public const float GuardianChipCharWidth = 24f; + /// 떠오를 때 아래에서 올라오는 거리 — 바닥 마진의 최솟값(24) 이하라 미끄러지는 동안에도 화면 밖으로 나가지 않는다. + public const float GuardianSlide = UISafeLayout.MinMargin; + + /// 별칭이 포효 박자에 찍힐 때의 크기 — 카드 폭을 크게 넘지 않게 작게 둔다. + public const float EpithetPunchPeak = 1.12f; + public const float EpithetPunchSeconds = 0.22f; + + /// 포효 뒤 초의 별칭 배율 — 1.12에서 0.22초에 걸쳐 1로. 포효 전(보이지 않는다)은 1.12. + public static float EpithetPunch(float sinceRoar) + { + if (sinceRoar <= 0f) return EpithetPunchPeak; + if (sinceRoar >= EpithetPunchSeconds) return 1f; + return Mathf.Lerp(EpithetPunchPeak, 1f, Mathf.SmoothStep(0f, 1f, sinceRoar / EpithetPunchSeconds)); + } + + /// 변신 문구가 울부짖는 박자에 한 번 커지는 크기(이미 떠 있는 글자라 별칭보다 작게). + public const float RoarPunchPeak = 1.07f; + + /// + /// 울부짖은 뒤 초의 변신 문구 배율 — 그 전엔 1(이미 읽히는 중이다), 울부짖는 순간 1.07로 튀었다가 + /// 0.22초에 걸쳐 1로. + /// + public static float RoarPunch(float sinceRoar) + { + if (sinceRoar < 0f || sinceRoar >= EpithetPunchSeconds) return 1f; + return Mathf.Lerp(RoarPunchPeak, 1f, Mathf.SmoothStep(0f, 1f, sinceRoar / EpithetPunchSeconds)); + } + + public struct GuardianPlan + { + public Rect Card; + public Rect Title; + /// 별칭(있을 때만). + public Rect Epithet; + /// 곤충 이름 + 속성 칩 줄(로 나눈다). + public Rect NameRow; + /// 등장 한 줄(있을 때만). + public Rect Line; + public bool HasIntro; + } + + /// 배너 높이 — 별칭·등장 줄이 있으면 넷, 없으면 칭호·이름 둘. + public static float GuardianHeight(bool hasIntro) + { + float h = GuardianPadY + LineHeight(GuardianTitleFont) + UITheme.Space.XS; + if (hasIntro) h += LineHeight(GuardianEpithetFont) + UITheme.Space.XS; + h += Mathf.Max(LineHeight(GuardianNameFont), GuardianChipHeight); + if (hasIntro) h += UITheme.Space.S + LineHeight(GuardianLineFont); + return h + GuardianPadY; + } + + /// 수문장 배너 — 안전 영역 바닥에 붙는 카드 한 장, 줄은 위에서부터 칭호 · 별칭 · 이름 줄 · 등장 줄. + public static GuardianPlan GuardianBanner(HudFrame f, bool hasIntro) + { + float w = Mathf.Min(GuardianMaxWidth, f.ContentWidth); + Rect card = f.BottomPanel(w, GuardianHeight(hasIntro)); + float x = card.x + GuardianPadX; + float tw = Mathf.Max(1f, card.width - GuardianPadX * 2f); + float y = card.y + GuardianPadY; + var plan = new GuardianPlan { Card = card, HasIntro = hasIntro }; + plan.Title = new Rect(x, y, tw, LineHeight(GuardianTitleFont)); + y = plan.Title.yMax + UITheme.Space.XS; + if (hasIntro) + { + plan.Epithet = new Rect(x, y, tw, LineHeight(GuardianEpithetFont)); + y = plan.Epithet.yMax + UITheme.Space.XS; + } + plan.NameRow = new Rect(x, y, tw, Mathf.Max(LineHeight(GuardianNameFont), GuardianChipHeight)); + y = plan.NameRow.yMax; + if (hasIntro) + plan.Line = new Rect(x, y + UITheme.Space.S, tw, LineHeight(GuardianLineFont)); + return plan; + } + + /// 속성 칩 폭 — 글자 수 × 어림 폭 + 좌우 여백. + public static float ChipWidth(string label) => + string.IsNullOrEmpty(label) ? 0f : label.Length * GuardianChipCharWidth + GuardianChipPadding; + + public struct NameRow + { + public Rect Name; + public Rect Chip1; + /// 두 번째 속성 칩(없으면 폭 0). + public Rect Chip2; + } + + /// + /// 이름 + 칩(하나 또는 둘)을 묶음째 줄 가운데에. 줄이 모자라면 이름 상자만 줄인다(그리기의 LabelFit이 글자를 줄여 맞춘다) — + /// 칩은 속성이라 잘리면 안 된다. + /// + public static NameRow LayoutNameRow(Rect row, float nameWidth, float chip1Width, float chip2Width) + { + float gap = UITheme.Space.S; + float chips = Mathf.Max(0f, chip1Width) + (chip2Width > 0f ? gap + chip2Width : 0f); + float chipsBlock = chips > 0f ? gap + chips : 0f; + float name = Mathf.Clamp(nameWidth, 1f, Mathf.Max(1f, row.width - chipsBlock)); + float total = name + chipsBlock; + float x = row.x + (row.width - total) * 0.5f; + float chipY = row.y + (row.height - GuardianChipHeight) * 0.5f; + var r = new NameRow { Name = new Rect(x, row.y, name, row.height) }; + float cx = x + name + gap; + r.Chip1 = chip1Width > 0f ? new Rect(cx, chipY, chip1Width, GuardianChipHeight) : new Rect(cx, chipY, 0f, 0f); + cx += chip1Width + gap; + r.Chip2 = chip2Width > 0f ? new Rect(cx, chipY, chip2Width, GuardianChipHeight) : new Rect(cx, chipY, 0f, 0f); + return r; + } + } +} diff --git a/Assets/Scripts/UI/RaidBattleUI.Stage.cs.meta b/Assets/Scripts/UI/RaidBattleUI.Stage.cs.meta new file mode 100644 index 0000000..a4208c7 --- /dev/null +++ b/Assets/Scripts/UI/RaidBattleUI.Stage.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 26e88b2ca746e2e42b87e0679abf12bf \ No newline at end of file diff --git a/Assets/Scripts/UI/RaidBattleUI.cs b/Assets/Scripts/UI/RaidBattleUI.cs index ff47975..02c19e3 100644 --- a/Assets/Scripts/UI/RaidBattleUI.cs +++ b/Assets/Scripts/UI/RaidBattleUI.cs @@ -28,6 +28,8 @@ private enum Phase BossAttack, UniteAttack, TeamTurnAnnounce, + // 보스가 모습을 바꾸는 짧은 단계(입력 없음, BossTransformDuration) — RaidBossForms. TryEnterBossTransform 참조. + BossTransform, Result } @@ -75,9 +77,28 @@ private enum Phase // 5인 팀이면 3명의 착탄·데미지 숫자와 폭발·TOTAL이 통째로 렌더되지 않았다(아레나 코루틴은 // 0.42+0.28=0.70s에 끝나 `teamAnimationComplete`가 먼저 서고, 2D 폴백은 아예 즉시 true라 // 두 경로 모두 정확히 상한에서 이탈한다). 오버레이는 3D 아레나 위에도 그려지므로 공통 문제다. - private const float UniteRushMinDuration = 2.5f; + // 이제 아레나와 오버레이가 같은 표를 쓴다 — 값은 RaidUniteTimeline.Total(2.5초) 한 곳에서 바꾼다. + private const float UniteRushMinDuration = RaidUniteTimeline.Total; private const float BossTelegraphDuration = 0.72f; private const float BossImpactMinDuration = 0.8f; + // 쓰러짐 대기 — 아레나 쓰러짐(BattleArenaController.FaintSeconds 0.6초: 눕고·가라앉고·투명해져 꺼진다)이 다 끝나고 + // 빈자리가 한 박자(0.15초) 보인 뒤에 다음 전이(결과·다음 차례·팀 턴 배너)로 넘어간다. 1v1의 쓰러짐 대기 + // (BattleScreenUI.FinishFaintPresentation, 0.7초)와 같은 박자다. 둘 다 연출 시계(BattlePresentation.DeltaTime)로 잰다 — + // 마무리 일격의 슬로모션이 쓰러짐을 늦추면 대기도 같이 늘어난다. 예전엔 보스 공격 최소 길이(0.8초)를 빌려 썼다. + private const float FaintHoldDuration = BattleArenaController.FaintSeconds + 0.15f; + // 인트로 길이(연출 시계 초). 수문장 레이드는 아레나의 등장 컷(올려다보며 다가가기 → 포효 → 원래 구도)만큼 0.6초 길다 — + // 그 길이의 단일 출처는 BattleStaging.GuardianIntroSeconds다. 등장 배너(ui-dev)도 이 동안 그린다. + private const float RaidIntroSeconds = 2f; + private float IntroSeconds => raidController != null && !string.IsNullOrEmpty(raidController.BossGuardianRegionId) + ? BattleStaging.GuardianIntroSeconds + : RaidIntroSeconds; + + // ── 쓰러짐 대기 ── + // 행동은 커맨드 순간에 해결돼 HP 0이 먼저 정해지지만, 쓰러지는 모습은 공격 연출이 끝난 뒤에 보여야 한다. + // 연출 한 단계(팀원 공격·합체공격·보스 공격)가 끝나는 자리에서 한 번 쓸고(faintSwept), 누가 쓰러지기 시작했으면 + // 그동안(faintHoldTimer) 다음 전이 — 결과(RAID CLEAR/FAILED)·다음 차례·팀 턴 배너 — 를 미룬다. + private bool faintSwept; + private float faintHoldTimer; private float displayBossHp; private float[] displayTeamHp; @@ -130,9 +151,8 @@ private void OnDisable() raidController.RaidTeamRushResolved -= OnRaidTeamRushResolved; raidController.RaidBossResponseResolved -= OnRaidBossResponseResolved; raidController.RaidRoundCompleted -= OnRaidRoundCompleted; + raidController.BossFormChanged -= OnRaidBossFormChanged; } - // timeScale 안전 복구 (다른 시스템이 변경한 채 종료된 경우 대비) - if (Time.timeScale < 0.99f) Time.timeScale = 1f; } private void OnRaidUpdated() @@ -158,9 +178,15 @@ private void OnRaidUpdated() bossAnimationComplete = false; bossResponseRequested = false; presentationCompletionRequested = false; + faintSwept = false; + faintHoldTimer = 0f; + bossFormPending = false; + pendingBossTransform = null; + activeBossTransform = null; if (AudioManager.Instance != null) { - AudioManager.Instance.PlayBGM(BgmType.RaidBattle); + // 수문장 레이드면 수문장 곡(BossGuardianRegionId는 StartRaid가 RaidUpdated 전에 세운 스냅샷이다). + AudioManager.Instance.PlayBGM(BattleMusic.Raid(raidController.BossGuardianRegionId)); AudioManager.Instance.PlaySFX(SfxType.BossAppear); } @@ -177,8 +203,10 @@ private void OnRaidUpdated() int[] teamLevels3d = new int[count]; for (int tl = 0; tl < count; tl++) teamLevels3d[tl] = raidController.TeamStats[tl] != null ? raidController.TeamStats[tl].Level : 1; + // 시작부터 기절해 있던 팀원은 아레나에 세우지 않는다(TeamDownAtStart — 전투 중 쓰러진 팀원과 같게 보인다). if (bossData3d != null) - arena.SetupRaidBattle(bossData3d, bossLevel3d, bossShiny3d, teamData3d, teamLevels3d, bossWorldPos); + arena.SetupRaidBattle(bossData3d, bossLevel3d, bossShiny3d, teamData3d, teamLevels3d, bossWorldPos, + TeamDownAtStart(), raidController.BossGuardianRegionId); // 수문장이면 등장 컷(visual-dev) } else if (cameraFollower != null && bossEnt != null) { @@ -225,6 +253,7 @@ private void OnRaidMemberActionResolved(RaidActionResult action) bossAnimationComplete = false; bossResponseRequested = false; presentationCompletionRequested = false; + faintSwept = false; phase = Phase.PlayerAttack; phaseTimer = 0f; uniteAnimTimer = 0f; @@ -241,7 +270,8 @@ private void OnRaidMemberActionResolved(RaidActionResult action) arena.PlayRaidVolley( new[] { action.SourceSlot }, new[] { action.Element }, - () => { teamAnimationComplete = true; }); + () => { teamAnimationComplete = true; }, + BuildMemberCue(action)); // 기술명 외치기 + 피해에 맞춘 타격감 } /// @@ -271,6 +301,7 @@ private void OnRaidTeamRushResolved(RaidRoundResult round) bossAnimationComplete = false; bossResponseRequested = false; presentationCompletionRequested = false; + faintSwept = false; phaseTimer = 0f; uniteAnimTimer = 0f; phase = Phase.UniteAttack; @@ -281,11 +312,9 @@ private void OnRaidTeamRushResolved(RaidRoundResult round) if (arena == null || !arena.IsActive) return; - arena.PlayUniteAttackAnimation(() => - { - teamAnimationComplete = true; - if (cameraFollower != null) cameraFollower.Shake(0.5f, 0.6f); - }); + // 흔들림은 아레나가 합동 일격 순간에 건다(RaidUniteTimeline.FinalStrike). 여기서 또 흔들면 + // 팀원이 제자리로 돌아온 **뒤에** 화면이 흔들렸다. + arena.PlayUniteAttackAnimation(() => { teamAnimationComplete = true; }); } private void OnRaidBossResponseResolved(RaidRoundResult round) @@ -298,12 +327,13 @@ private void OnRaidBossResponseResolved(RaidRoundResult round) lastAoe = bossAction != null && bossAction.Kind == RaidActionKind.BossArea; lastHitSlot = bossAction != null ? bossAction.TargetSlot : -1; actionText = bossAction != null && !string.IsNullOrEmpty(bossAction.DisplayName) - ? bossAction.DisplayName + ? (bossAction.Critical ? bossAction.DisplayName + " — 치명타!" : bossAction.DisplayName) : round.BossResponseSkipped ? "보스가 기절해 움직이지 못한다!" : ""; actionTimer = 2f; bossAnimationComplete = arena == null || !arena.IsActive || round.BossResponseSkipped || bossAction == null; presentationCompletionRequested = false; + faintSwept = false; phase = Phase.BossAttack; phaseTimer = 0f; @@ -320,7 +350,8 @@ private void OnRaidBossResponseResolved(RaidRoundResult round) bossAction.Element, lastAoe, lastHitSlot, - () => { bossAnimationComplete = true; }); + () => { bossAnimationComplete = true; }, + BuildBossCue(round, lastAoe ? -1 : lastHitSlot)); } private void OnRaidRoundCompleted(RaidRoundResult round) @@ -359,12 +390,15 @@ private void OnRaidEnded(bool playerWon) { resultShown = true; resultTimer = 0f; + ResetResultClose(); phase = Phase.Result; if (AudioManager.Instance != null) { AudioManager.Instance.PlaySFX(playerWon ? SfxType.Victory : SfxType.Defeat); AudioManager.Instance.PlayBGM(playerWon ? BgmType.Victory : BgmType.Defeat); } + // 승리 연출(visual-dev) — 팀 전원 점프 + 팀을 담는 카메라, BattleFlourish.VictorySeconds. 패배는 그대로. + if (playerWon && arena != null && arena.IsActive) arena.PlayRaidVictory(); if (playerWon) { // 레이드 진행은 TutorialQuestManager가 raidController.RaidEnded를 직접 구독(OnRaidEnded)해 @@ -395,18 +429,24 @@ private void CheckRaidGuardianDefeat() private void Update() { if (phase == Phase.None) return; - - phaseTimer += Time.deltaTime; - introTimer += Time.deltaTime; - if (actionTimer > 0) actionTimer -= Time.deltaTime; - if (bossShake > 0) bossShake -= Time.deltaTime; - if (resultShown) resultTimer += Time.deltaTime; + // 전투 화면이 떠 있는 동안 플레이어를 계속 묶어 둔다 — SetFrozen(true)는 PlayerMovement의 자동 해제 시계(AutoUnfreezeTime 20초)를 + // 0으로 되돌린다. 안 그러면 20초 넘는 전투나 ESC 뒤에 고정이 풀려, 기술 버튼을 누른 탭이 숨은 플레이어의 클릭-이동으로 샌다 + // (결과 화면만 막던 것을 전투 전체로 넓혔다 — 2026-10-04). + if (playerMovement != null) playerMovement.SetFrozen(true); + + // 읽는 단계(인트로·그림자 변신)는 배속을 덜 탄다 — 배율은 RaidBattleUI.Flow(BattleReadPacing). 1배속이면 1이다. + phaseTimer += BattlePresentation.DeltaTime * PhaseClockScale; + introTimer += BattlePresentation.DeltaTime * IntroClockScale; + if (actionTimer > 0) actionTimer -= BattlePresentation.DeltaTime; + if (faintHoldTimer > 0f) faintHoldTimer -= BattlePresentation.DeltaTime; + if (bossShake > 0) bossShake -= BattlePresentation.DeltaTime; + if (resultShown) resultTimer += BattlePresentation.DeltaTime; if (teamShake != null) for (int i = 0; i < teamShake.Length; i++) - if (teamShake[i] > 0) teamShake[i] -= Time.deltaTime; + if (teamShake[i] > 0) teamShake[i] -= BattlePresentation.DeltaTime; - float hpSpeed = 80f * Time.deltaTime; + float hpSpeed = 80f * BattlePresentation.DeltaTime; if (raidController.BossStats != null) displayBossHp = Mathf.MoveTowards(displayBossHp, raidController.BossStats.CurrentHp, hpSpeed); if (raidController.TeamStats != null && displayTeamHp != null) @@ -423,7 +463,7 @@ private void Update() AudioManager.Instance.SetBattleIntensity(intensity); } - if (phase == Phase.Intro && introTimer > 2f) + if (phase == Phase.Intro && introTimer > IntroSeconds) { phase = Phase.SelectSkill; phaseTimer = 0f; @@ -458,7 +498,7 @@ private void Update() wantUnite = false; } - if (wantMouseClick || Input.GetMouseButtonDown(0)) + if ((wantMouseClick || Input.GetMouseButtonDown(0)) && !IsSpeedControlPointerHit) { Vector2 mp = wantMouseClick ? guiMousePos : UIScale.VirtualMousePosition; @@ -497,41 +537,26 @@ private void Update() if (phase == Phase.UniteAttack || phase == Phase.PlayerAttack) { if (phase == Phase.UniteAttack) - uniteAnimTimer += Time.deltaTime; + uniteAnimTimer += BattlePresentation.DeltaTime; float minDuration = phase == Phase.UniteAttack ? UniteRushMinDuration : TeamRushMinDuration; - bool animationReady = teamAnimationComplete || phaseTimer > 2.4f; - if (animationReady && phaseTimer >= minDuration) + bool animationReady = teamAnimationComplete || arena == null || !arena.IsActive; + // 마지막 일격으로 보스가 쓰러졌으면 결과(RAID CLEAR)보다 쓰러짐이 먼저 보인다. + if (animationReady && phaseTimer >= minDuration && !HoldForFaints()) { - // 순서가 중요하다: **남은 팀원이 먼저다.** 아직 행동하지 않은 곤충이 있으면 - // 보스 턴이 아니라 다음 곤충의 스킬 화면으로 돌아간다. - if (raidController.CanSubmitTeamCommand) - { - if (autoPilotRemaining) - { - TryAutoOne(); // 전원 자동 — 다음 한 마리의 연출로 곧바로 이어진다 - } - else - { - phase = Phase.SelectSkill; - phaseTimer = 0f; - selectedSlot = raidController.ActiveSlot; - } - } - else if (raidController.IsAwaitingBossResponse) - { - phase = Phase.BossTelegraph; - phaseTimer = 0f; - bossResponseRequested = false; - } - else - { - TryCompleteRoundPresentation(); - } + // 이 공격이 보스의 모습을 바꿨으면 다음 차례 전에 변신 단계를 끼운다(쓰러졌으면 변신 없이 쓰러짐). + if (!TryEnterBossTransform()) + AdvanceAfterTeamPresentation(); } } + if (phase == Phase.BossTransform && phaseTimer >= BossTransformDuration) + { + activeBossTransform = null; + AdvanceAfterTeamPresentation(); + } + if (phase == Phase.BossTelegraph && phaseTimer >= BossTelegraphDuration && !bossResponseRequested) @@ -544,14 +569,15 @@ private void Update() if (phase == Phase.BossAttack) { - bool animationReady = bossAnimationComplete || phaseTimer > 2.4f; - if (animationReady && phaseTimer >= BossImpactMinDuration) + bool animationReady = bossAnimationComplete || arena == null || !arena.IsActive; + // 보스 공격에 쓰러진 팀원이 눕는 동안 기다린다 — 전멸이면 RAID FAILED가 그 위를 덮지 않게. + if (animationReady && phaseTimer >= BossImpactMinDuration && !HoldForFaints()) TryCompleteRoundPresentation(); } if (phase == Phase.TeamTurnAnnounce) { - announceTimer -= Time.deltaTime; + announceTimer -= BattlePresentation.DeltaTime; if (wantMouseClick) announceTimer = 0f; // 탭으로 즉시 스킵(소거는 아래 말미가 담당) if (announceTimer <= 0f) { @@ -561,8 +587,9 @@ private void Update() } } - if (phase == Phase.Result && resultTimer > 5f) - EndRaid(); + // 결과 화면은 탭·클릭·Space/Enter로만 닫힌다(처음 0.6초는 안 받는다) — RaidBattleUI.Flow. + if (phase == Phase.Result) + TickResultClose(); // 탭 래치 차단 — `Input.GetMouseButtonDown`과 같은 한 프레임 수명으로 맞춘다. // `wantMouseClick`은 OnGUI(:MouseDown)가 세우는데 소거를 조작 분기 **안**에서만 하면, @@ -572,6 +599,124 @@ private void Update() wantMouseClick = false; } + /// + /// 연출 한 단계가 끝난 자리에서 HP 0인 쪽을 쓰러뜨리고(), + /// 쓰러지는 동안 true를 돌려 호출부의 전이를 미룬다. 단계마다 한 번만 쓴다(faintSwept). + /// 길이는 — 아레나 쓰러짐()에서 파생된다. + /// + private bool HoldForFaints() + { + if (!faintSwept) + { + faintSwept = true; + if (raidController != null && raidController.PresentPendingFaints()) + faintHoldTimer = FaintHoldDuration; + } + return faintHoldTimer > 0f; + } + + /// + /// 팀원 한 마리(또는 합체공격)의 연출이 끝난 자리의 다음 단계. 남은 팀원이 먼저다 — 아직 행동하지 않은 곤충이 있으면 + /// 보스 턴이 아니라 다음 곤충의 스킬 화면으로 돌아간다. 변신 단계가 끝난 뒤에도 여기로 온다. + /// + private void AdvanceAfterTeamPresentation() + { + if (raidController == null) return; + if (raidController.CanSubmitTeamCommand) + { + if (autoPilotRemaining) + { + TryAutoOne(); // 전원 자동 — 다음 한 마리의 연출로 곧바로 이어진다 + } + else + { + phase = Phase.SelectSkill; + phaseTimer = 0f; + selectedSlot = raidController.ActiveSlot; + } + } + else if (raidController.IsAwaitingBossResponse) + { + phase = Phase.BossTelegraph; + phaseTimer = 0f; + bossResponseRequested = false; + BeginBossTelegraphPresentation(); // 3D 예고 — 기 모으기·경고 고리·포효 + } + else + { + TryCompleteRoundPresentation(); + } + } + + // ── 보스 변신(RaidBossForms) ── + // 컨트롤러는 임계를 넘긴 그 행동 안에서 이미 모습을 바꿨다(속성·기술). 화면은 그 공격의 연출과 쓰러짐 대기를 + // 마친 뒤에 변신 단계를 한 번 끼운다 — 한 행동에 임계 둘을 넘겨도 컨트롤러 이벤트가 한 번이라 단계도 한 번이다. + // 아직 단계로 못 들어간 사이에 이벤트가 또 와도(자동 위임 등) 깃발 하나로 합쳐 마지막 모습만 보인다. + + /// + /// 변신 단계 길이(배속 시간 — 1배속 실제 초). 이 동안 입력을 받지 않는다. 연출(visual-dev)·문구(ui-dev)가 이 길이에 맞춘다. + /// 문구를 읽어야 하는 단계라 2배속이어도 실제 (1.0초) 밑으로는 줄지 않는다 + /// (단계 시계가 덜 빨라진다 — RaidBattleUI.Flow). 같은 실제 시간에 끝나는 연출 길이는 . + /// + public const float BossTransformDuration = 1.5f; + + /// 지금 보스가 모습을 바꾸는 중인가(변신 단계). + public bool IsBossTransforming => phase == Phase.BossTransform; + + /// + /// 변신 단계 진행률 0~1(단계 밖이면 0). 아레나 보스 모델은 진행률 BattleStaging.TransformSwap(0.5, 연기가 가장 짙을 때)에 + /// 새 모습으로 바뀌고 TransformRoar(0.6)에 울부짖는다 — 문구를 그 박자에 맞추려면 이 값을 읽는다. + /// + public float BossTransformProgress => phase == Phase.BossTransform ? Mathf.Clamp01(phaseTimer / BossTransformDuration) : 0f; + + /// 변신 단계의 표시 한 줄("그림자가 호랑나비의 모습을 빌렸다!"). 단계 밖이면 빈 문자열. + public string BossTransformLine => phase == Phase.BossTransform && raidController != null + ? raidController.BossFormLine ?? string.Empty + : string.Empty; + + /// + /// 지금 단계에서 보여 주는 변신(바뀌기 전·후 모습과 번호). 단계 밖이면 null. 이벤트가 합쳐졌으면 마지막 것이다 — + /// 바뀐 뒤 모습은 늘 raidController.BossFormData와 같다. + /// + public RaidBossFormChange ActiveBossTransform => phase == Phase.BossTransform ? activeBossTransform : null; + + private bool bossFormPending; + private RaidBossFormChange pendingBossTransform; + private RaidBossFormChange activeBossTransform; + + private void OnRaidBossFormChanged(RaidBossFormChange change) + { + if (change == null || phase == Phase.None) return; + bossFormPending = true; + pendingBossTransform = change; + } + + /// + /// 변신이 걸려 있으면 변신 단계로 들어가고 true. 보스가 쓰러졌으면 변신 없이 쓰러진다(깃발만 버리고 false) — + /// 쓰러짐은 이미 가 보여 줬다. + /// + private bool TryEnterBossTransform() + { + if (!bossFormPending) return false; + bossFormPending = false; + RaidBossFormChange change = pendingBossTransform; + pendingBossTransform = null; + if (raidController == null || raidController.BossStats == null || raidController.BossStats.CurrentHp <= 0) + return false; + + phase = Phase.BossTransform; + phaseTimer = 0f; + activeBossTransform = change; + if (arena != null && arena.IsActive) + { + InsectEntity bossEnt = raidController.BossEntity; + // 모델 교체는 연출 한가운데(연기가 가장 짙을 때) 아레나가 한다 — 그림자 변신(visual-dev). + arena.PlayBossTransform(raidController.BossFormData, raidController.BossStats.Level, + bossEnt != null && bossEnt.IsShiny, BossTransformStagingSeconds); // 단계와 같은 실제 시간에 끝나는 길이(2배속 하한) + } + return true; + } + private void TryCompleteRoundPresentation() { if (presentationCompletionRequested || raidController == null) return; @@ -656,12 +801,29 @@ private void TryUnite() // 기능 손실은 없다 — 새 경로 `OnRaidBossResponseResolved`가 `arena.PlayRaidBossAttack(..., lastHitSlot, ...)`로 // 피격 슬롯을 인자로 직접 넘긴다. 즉 대상 지정이 필드에서 매개변수로 옮겨간 것뿐이다. + private GUIStyle speedControlStyle; + private bool ShowSpeedControl => phase != Phase.None && phase != Phase.Intro && !resultShown; + private Rect SpeedControlRect => UISafeLayout.TopPanel(132f, 56f, UISafeLayout.HAlign.Right); + private bool IsSpeedControlPointerHit => ShowSpeedControl && SpeedControlRect.Contains(UIScale.VirtualMousePosition); + + private void DrawSpeedControl() + { + if (!ShowSpeedControl) return; + if (speedControlStyle == null) + speedControlStyle = new GUIStyle(GUI.skin.label) { fontSize = 24, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + speedControlStyle.normal.textColor = UITheme.Instance.textPrimary; + if (UISurface.Button(SpeedControlRect, $"속도 {BattlePresentation.Speed:0}×", UITheme.Instance.surfaceRaised, speedControlStyle)) + BattlePresentation.Speed = BattlePresentation.Speed < 1.5f ? 2f : 1f; + } + private void OnGUI() { if (phase == Phase.None) return; UIScale.Begin(); InitStyles(); + // 레이드는 사전 컷인이 없다 — 전용기 이름은 아레나 머리 위 말풍선이 알린다(1대1 화면이 끈 정적 표지를 되살린다, RaidBattleUI.Feel). + BattleArenaController.ShowSignatureCallout = true; Event evt = Event.current; if (evt != null && evt.type == EventType.KeyDown) { @@ -691,8 +853,9 @@ private void OnGUI() evt.Use(); break; } } + CaptureResultKey(evt); // 결과 화면의 Space/Enter(RaidBattleUI.Flow) - if (evt != null && evt.type == EventType.MouseDown && evt.button == 0) + if (evt != null && evt.type == EventType.MouseDown && evt.button == 0 && !IsSpeedControlPointerHit) { wantMouseClick = true; guiMousePos = evt.mousePosition; @@ -719,16 +882,23 @@ private void OnGUI() DrawBossTelegraph(); else if (phase == Phase.TeamTurnAnnounce) DrawTeamTurnAnnounce(); + else if (phase == Phase.BossTransform) + DrawBossTransform(); // 「그림자가 ○○의 모습을 빌렸다!」 — RaidBattleUI.Stage if (actionTimer > 0) DrawActionText(); + // 기술 이름 외치기·비명·의성어 — 1v1과 같은 오버레이. 결과 패널보다 먼저(아래에). + BattleShoutOverlay.Draw(arena); + if (resultShown) DrawResult(); // 전투 문구는 1v1과 같은 오버레이를 쓴다 — 아레나가 픽셀 좌표로 그리던 자리. BattleEffectTextOverlay.Draw(arena); + DrawSpeedControl(); + UIScale.End(); } @@ -750,6 +920,7 @@ private void EndRaid() phaseTimer = 0f; introTimer = 0f; resultTimer = 0f; + ResetResultClose(); uniteAnimTimer = 0f; announceTimer = 0f; displayTeamHp = null; @@ -763,6 +934,11 @@ private void EndRaid() bossAnimationComplete = false; bossResponseRequested = false; presentationCompletionRequested = false; + faintSwept = false; + faintHoldTimer = 0f; + bossFormPending = false; + pendingBossTransform = null; + activeBossTransform = null; // 입력 표면도 함께 비운다 — 남겨두면 지난 레이드의 버튼 Rect가 다음 레이드 첫 프레임의 // 히트 테스트에 그대로 쓰인다(위 `wantMouseClick` 프레임 스코프화와 같은 결함 계열). raidSkillCount = 0; @@ -794,6 +970,7 @@ public void AutoWire(RaidBattleController rc, CameraFollower cam, PlayerMovement raidController.RaidTeamRushResolved -= OnRaidTeamRushResolved; raidController.RaidBossResponseResolved -= OnRaidBossResponseResolved; raidController.RaidRoundCompleted -= OnRaidRoundCompleted; + raidController.BossFormChanged -= OnRaidBossFormChanged; } if (raidController == null || raidController != rc) @@ -820,12 +997,14 @@ private void SubscribeRaidController() raidController.RaidTeamRushResolved -= OnRaidTeamRushResolved; raidController.RaidBossResponseResolved -= OnRaidBossResponseResolved; raidController.RaidRoundCompleted -= OnRaidRoundCompleted; + raidController.BossFormChanged -= OnRaidBossFormChanged; raidController.RaidUpdated += OnRaidUpdated; raidController.RaidEnded += OnRaidEnded; raidController.RaidMemberActionResolved += OnRaidMemberActionResolved; raidController.RaidTeamRushResolved += OnRaidTeamRushResolved; raidController.RaidBossResponseResolved += OnRaidBossResponseResolved; raidController.RaidRoundCompleted += OnRaidRoundCompleted; + raidController.BossFormChanged += OnRaidBossFormChanged; } // 전투가 끝났다고 알려 줄 곳. 없어도 레이드는 그대로 돌아간다 — 스토리 쪽이 12초 뒤 diff --git a/Assets/Scripts/UI/RegionMapUI.cs b/Assets/Scripts/UI/RegionMapUI.cs index 05c3652..c7baedb 100644 --- a/Assets/Scripts/UI/RegionMapUI.cs +++ b/Assets/Scripts/UI/RegionMapUI.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using InsectGame.Core; using InsectGame.Data; using InsectGame.Dex; @@ -20,6 +20,12 @@ public class RegionMapUI : MonoBehaviour, IModalUI // 전부 저쪽에 있어서 여기는 좌표와 문구를 받아 그리기만 한다. private InsectGame.Story.StoryObjectiveTracker objectiveTracker; + private InsectGame.NPC.NpcManager npcManager; + public void AutoWire(InsectGame.NPC.NpcManager manager) + { + if (npcManager == null) npcManager = manager; + } + private bool isOpen; private string selectedRegionId; // 지도에서 선택돼 정보패널에 표시되는 리전(도감 아님) private bool dexOpen; // 도감 브라우저 열림(정보패널 [도감]이 켬) @@ -40,26 +46,17 @@ public class RegionMapUI : MonoBehaviour, IModalUI private readonly Dictionary dexInfoCache = new Dictionary(); private readonly Dictionary hiddenNameCache = new Dictionary(); - // 리전 간 공간 인접(길). RegionData.connections는 전부 null이라 여기서 토폴로지 유지. 정적이라 프레임당 할당 없음. - // - // **1막 진행 사슬 6링크가 전부 여기 있어야 한다.** 해금은 - // meadow→pond→forest→swamp→mountain→ruins(+garden) 한 줄로 도는데, - // 예전엔 그중 `pond→forest`와 `swamp→mountain`이 **지도에도 땅에도 없었다** — - // 다음 목적지로 가는 길만 골라서 안 그려져 있었다. 공간상 가장 가까운 쌍이 - // meadow―swamp(101m)이고 사슬인 pond→forest는 280m라, 지도를 눈으로 훑으면 - // 오히려 사슬이 아닌 쪽이 길처럼 보인다. - // - // 사슬이 아닌 간선(meadow―forest·meadow―swamp·forest―mountain)은 그대로 둔다. - // 이 표는 진행 순서가 아니라 **공간 인접**이고, 그쪽은 실제로 가깝다. - private static readonly string[,] Connections = { - {"meadow","pond"}, {"meadow","forest"}, {"meadow","swamp"}, {"meadow","garden"}, - {"mountain","ruins"}, {"forest","swamp"}, {"forest","mountain"}, - {"pond","forest"}, {"swamp","mountain"}, - // ── 2막(ver2) ── 유적 너머로 이어지는 사슬. 빠뜨리면 지도에 길이 안 그려져 - // 신규 리전이 허공에 뜬 섬으로 보인다. - {"ruins","hollow"}, {"hollow","dunes"}, {"dunes","frostline"}, - {"frostline","emberfall"}, {"emberfall","canopy"}, {"canopy","nameless"} - }; + private RegionData[] routeRegions; + private readonly List mapRoutes = new List(); + + private void EnsureMapRoutes(RegionData[] regions) + { + if (ReferenceEquals(routeRegions, regions)) return; + routeRegions = regions; + mapRoutes.Clear(); + foreach (WorldRouteEdge edge in WorldRouteLayout.FieldConnections) + mapRoutes.Add(WorldRouteLayout.BuildRoute(regions, edge.FromRegionId, edge.ToRegionId)); + } private struct RaidBossMarker { @@ -67,6 +64,8 @@ private struct RaidBossMarker public Vector3 worldPos; public InsectRarity rarity; public InsectEntity entity; + // 표식을 단 순간의 몸 번호 — 풀 객체라 같은 참조가 다른 개체로 되살아난다(InsectEntity.SpawnSerial). + public int serial; } public bool IsOpen => isOpen; @@ -123,7 +122,7 @@ private void OnDisable() private Texture2D discTex; private GUIStyle titleStyle, closeStyle, levelStyle, noDataStyle; private GUIStyle regionNameStyle, subNameStyle, raidLabelStyle, guardianStyle, mapErrStyle; - private GUIStyle objectiveStyle; + private GUIStyle objectiveStyle, taleBadgeStyle; private GUIStyle infoTitleStyle, infoLineStyle, legendStyle, btnStyle, hintStyle; private GUIStyle detailTitleStyle, detailBtnStyle, detailDescStyle, detailSummaryStyle, detailNoInsectStyle; private GUIStyle dexNameStyle, dexInfoStyle, dexCheckStyle, dexUnknownStyle, dexHiddenStyle, dexNotCaughtStyle; @@ -148,6 +147,8 @@ private void EnsureAssets() raidLabelStyle = Label(17, FontStyle.Bold, TextAnchor.MiddleCenter, Color.white); guardianStyle = Label(16, FontStyle.Bold, TextAnchor.MiddleCenter, new Color(1f, 0.75f, 0.55f)); objectiveStyle = Label(19, FontStyle.Bold, TextAnchor.MiddleCenter, UITheme.Instance.accentMint); + // 의뢰 주민 배지 안의 !/? — 밝은 원 위에 올라가므로 어두운 글자. + taleBadgeStyle = Label(20, FontStyle.Bold, TextAnchor.MiddleCenter, UITheme.Instance.surfaceBase); mapErrStyle = Label(26, FontStyle.Bold, TextAnchor.MiddleCenter, new Color(1f, 0.6f, 0.6f)); infoTitleStyle = Label(34, FontStyle.Bold, TextAnchor.MiddleLeft, Color.white); @@ -187,7 +188,8 @@ private void OnRaidBossSpawned(InsectEntity entity) name = entity.Data.displayName, worldPos = entity.transform.position, rarity = entity.Data.rarity, - entity = entity + entity = entity, + serial = entity.SpawnSerial }); } @@ -195,7 +197,9 @@ private void Update() { for (int i = raidMarkers.Count - 1; i >= 0; i--) { - if (raidMarkers[i].entity == null || !raidMarkers[i].entity.gameObject.activeInHierarchy) + // 몸 번호가 바뀌었으면 풀로 돌아갔다가 다른 개체로 다시 선 것이다 — 활성 여부만으론 못 가린다. + if (raidMarkers[i].entity == null || !raidMarkers[i].entity.gameObject.activeInHierarchy + || raidMarkers[i].entity.SpawnSerial != raidMarkers[i].serial) raidMarkers.RemoveAt(i); } if (errorTimer > 0f) errorTimer -= Time.deltaTime; @@ -295,7 +299,7 @@ private void DrawMap() } // ─── 월드→지도 좌표 변환(종횡비 보존 fit) 캐시 ─── - private float wmMinX, wmMinZ, wmW, wmH, wmOx, wmOy, wmDrawW, wmDrawH; + private float wmMinX, wmMinZ, wmW, wmH, wmOx, wmOy, wmDrawW, wmDrawH, wmScale; private Vector2 WorldToMap(float wx, float wz) { @@ -304,6 +308,9 @@ private Vector2 WorldToMap(float wx, float wz) return new Vector2(wmOx + nx * wmDrawW, wmOy + (1f - nz) * wmDrawH); } + // 리전 원의 지도 반경(px). 원 그리기와 목표 마커 배치가 같은 값을 써야 한다. + private float RegionMapRadius(RegionData region) => Mathf.Clamp(region.radius * wmScale, 30f, 130f); + private void DrawWorldMap(Rect area) { // 지도 배경 @@ -313,6 +320,8 @@ private void DrawWorldMap(Rect area) GUI.DrawTexture(new Rect(area.x, area.y, area.width, 2f), Texture2D.whiteTexture); RegionData[] regions = regionManager.Regions; + if (regions == null || regions.Length == 0) return; + EnsureMapRoutes(regions); // 월드 경계 계산 float minX = float.MaxValue, maxX = float.MinValue, minZ = float.MaxValue, maxZ = float.MinValue; @@ -324,6 +333,12 @@ private void DrawWorldMap(Rect area) minZ = Mathf.Min(minZ, r.centerPosition.z - r.radius); maxZ = Mathf.Max(maxZ, r.centerPosition.z + r.radius); } + foreach (Vector3[] route in mapRoutes) + foreach (Vector3 point in route) + { + minX = Mathf.Min(minX, point.x); maxX = Mathf.Max(maxX, point.x); + minZ = Mathf.Min(minZ, point.z); maxZ = Mathf.Max(maxZ, point.z); + } float pad = 40f; minX -= pad; maxX += pad; minZ -= pad; maxZ += pad; wmW = Mathf.Max(1f, maxX - minX); @@ -334,18 +349,19 @@ private void DrawWorldMap(Rect area) wmDrawW = wmW * fit; wmDrawH = wmH * fit; wmOx = area.x + (area.width - wmDrawW) * 0.5f; wmOy = area.y + (area.height - wmDrawH) * 0.5f; - float worldToPx = fit; + wmScale = fit; + + // 0) 의뢰 주민 배지 탭 → 따라가기. **리전·서브에리어 버튼보다 먼저** 처리한다 — + // IMGUI는 먼저 호출된 GUI.Button이 MouseDown을 가져가는데, 배지는 리전 원 안에 있어 + // 뒤에 두면 배지를 눌러도 리전 선택만 된다. 좌표 변환(wmOx 등)이 위에서 끝난 뒤라야 한다. + HandleTaleMarkerInput(area); // 1) 연결선(길) — 회전 quad로 깔끔하게 Color pathCol = new Color(0.5f, 0.44f, 0.3f, 0.5f); - for (int i = 0; i < Connections.GetLength(0); i++) - { - RegionData a = FindRegion(regions, Connections[i, 0]); - RegionData b = FindRegion(regions, Connections[i, 1]); - if (a == null || b == null) continue; - DrawThickLine(WorldToMap(a.centerPosition.x, a.centerPosition.z), - WorldToMap(b.centerPosition.x, b.centerPosition.z), 3f, pathCol); - } + foreach (Vector3[] route in mapRoutes) + for (int i = 1; i < route.Length; i++) + DrawThickLine(WorldToMap(route[i - 1].x, route[i - 1].z), + WorldToMap(route[i].x, route[i].z), 3f, pathCol); // 2) 리전 원 foreach (var r in regions) @@ -354,7 +370,7 @@ private void DrawWorldMap(Rect area) bool accessible = regionManager.IsRegionAccessible(r); bool current = regionManager.CurrentRegion == r; Vector2 c = WorldToMap(r.centerPosition.x, r.centerPosition.z); - float cr = Mathf.Clamp(r.radius * worldToPx, 30f, 130f); + float cr = RegionMapRadius(r); Color theme = r.themeColor; Color fill = accessible ? theme : Desaturate(theme, 0.7f); @@ -373,7 +389,7 @@ private void DrawWorldMap(Rect area) // 이름 regionNameStyle.normal.textColor = accessible ? Color.white : new Color(0.7f, 0.7f, 0.72f); GUI.color = Color.white; - UIHelper.LabelFit(new Rect(c.x - Mathf.Max(95f, cr + 10f), c.y - 18f, Mathf.Max(190f, (cr + 10f) * 2f), 36f), r.displayName, regionNameStyle); + // 선택 강조 링 if (selectedRegionId == r.regionId) @@ -387,7 +403,7 @@ private void DrawWorldMap(Rect area) GUI.DrawTexture(new Rect(c.x - cr, c.y - cr, cr * 2f, cr * 2f), discTex); regionNameStyle.normal.textColor = accessible ? Color.white : new Color(0.7f, 0.7f, 0.72f); GUI.color = Color.white; - UIHelper.LabelFit(new Rect(c.x - Mathf.Max(95f, cr + 10f), c.y - 18f, Mathf.Max(190f, (cr + 10f) * 2f), 36f), r.displayName, regionNameStyle); + } // 서브에리어 — 소형 disc(형태 통일) + 클릭 텔레포트 @@ -395,6 +411,7 @@ private void DrawWorldMap(Rect area) { foreach (var sub in r.subAreas) { + if (sub == null) continue; Vector2 sc = WorldToMap(sub.centerPosition.x, sub.centerPosition.z); float sr = Mathf.Clamp(cr * 0.22f, 8f, 20f); Color subCol = accessible ? GetSubAreaColor(sub.environmentType) : new Color(0.4f, 0.4f, 0.42f); @@ -405,13 +422,14 @@ private void DrawWorldMap(Rect area) GUI.color = new Color(subCol.r, subCol.g, subCol.b, 0.85f); GUI.DrawTexture(new Rect(sc.x - sr, sc.y - sr, sr * 2f, sr * 2f), discTex); GUI.color = Color.white; - UIHelper.LabelFit(new Rect(sc.x - 85f, sc.y + sr + 1f, 170f, 22f), sub.displayName, subNameStyle); + if (selectedRegionId == r.regionId || new Rect(sc.x - sr, sc.y - sr, sr * 2f, sr * 2f).Contains(Event.current.mousePosition)) + UIHelper.LabelFit(new Rect(sc.x - 85f, sc.y + sr + 1f, 170f, 22f), sub.displayName, subNameStyle); float hot = Mathf.Max(sr, 14f); if (GUI.Button(new Rect(sc.x - hot, sc.y - hot, hot * 2f, hot * 2f), "", GUIStyle.none)) { if (accessible) TeleportToSubArea(sub); - else { errorMessage = $"먼저 {r.displayName}을(를) 해금하세요 (이전 지역의 수문장 격파)"; errorTimer = 3f; } + else { errorMessage = LockMessage(r); errorTimer = 3f; } } } } @@ -426,9 +444,15 @@ private void DrawWorldMap(Rect area) GUI.color = new Color(1f, 0.8f, 0.6f, 1f); GUI.DrawTexture(new Rect(gc.x - 4f, gc.y - 4f, 8f, 8f), discTex); GUI.color = Color.white; - GUI.Label(new Rect(gc.x - 60f, gc.y + 10f, 120f, 22f), $"수문장 Lv{r.guardianLevel}", guardianStyle); + if (new Rect(gc.x - 12f, gc.y - 12f, 24f, 24f).Contains(Event.current.mousePosition)) + UIHelper.LabelFit(new Rect(gc.x - 60f, gc.y + 10f, 120f, 22f), $"수문장 Lv{r.guardianLevel}", guardianStyle); } + regionNameStyle.wordWrap = true; + UIHelper.LabelFit(new Rect(c.x - cr + 3f, c.y - 27f, cr * 2f - 6f, 54f), + r.displayName, regionNameStyle, 12); + regionNameStyle.wordWrap = false; + // 리전 클릭 버튼 — 서브에리어 버튼 '이후' 호출해야 서브 점 클릭이 리전에 먹히지 않음(IMGUI 이벤트 소비 순서). if (GUI.Button(new Rect(c.x - cr, c.y - cr, cr * 2f, cr * 2f), "", GUIStyle.none)) { @@ -445,6 +469,7 @@ private void DrawWorldMap(Rect area) // 지도가 가리키는 곳에 가도 아무것도 없다(Update가 이미 entity를 들고 있다). Vector3 mp = m.entity != null ? m.entity.transform.position : m.worldPos; Vector2 mc = WorldToMap(mp.x, mp.z); + if (!area.Contains(mc)) continue; Color raidCol = m.rarity == InsectRarity.Legendary ? new Color(1f, 0.8f, 0.15f) : new Color(0.7f, 0.3f, 0.95f); float pulse = 0.7f + Mathf.Sin(Time.time * 4f) * 0.3f; GUI.color = new Color(raidCol.r, raidCol.g, raidCol.b, 0.3f * pulse); @@ -458,16 +483,24 @@ private void DrawWorldMap(Rect area) // 3b) 스토리 목표 마커 — "지금 어디로 가야 하는가". 수문장(주황)·레이드(보라)와 // 색으로 구분되게 민트다. 레이드 뒤, 플레이어 앞에 그려 플레이어를 가리지 않는다. + // 맥동은 주민 배지 **밑에** 깐다 — 위에 얹으면 따라가는 주민의 !·? 글자가 흐려진다. + DrawStoryObjectiveHalo(area); + DrawNpcMarkers(area); DrawStoryObjectiveMarker(area); // 4) 플레이어 마커 — disc + 진행방향 Transform pl = GetPlayer(); if (pl != null) { - Vector2 pc = WorldToMap(pl.position.x, pl.position.z); + Vector3 mapPosition = MapMarkerProjection.OverworldPlayerPosition(pl.position, + regionManager != null ? regionManager.CurrentSubArea : null); + Vector2 pc = WorldToMap(mapPosition.x, mapPosition.z); GUI.color = new Color(0.4f, 0.85f, 1f, 1f); GUI.DrawTexture(new Rect(pc.x - 8f, pc.y - 8f, 16f, 16f), discTex); - Vector3 f = pl.forward; + if (regionManager.CurrentSubArea != null) + UIHelper.LabelFit(new Rect(pc.x - 90f, pc.y + 14f, 180f, 24f), + "서브지역 탐험 중", subNameStyle); + Vector3 f = regionManager.CurrentSubArea != null ? Vector3.zero : pl.forward; Vector2 fd = new Vector2(f.x, -f.z); if (fd.sqrMagnitude > 0.001f) { @@ -486,40 +519,241 @@ private void DrawWorldMap(Rect area) /// 미니맵 쐐기는 방향만 알려 준다(반경 밖이면 테두리에 붙는다). 목표가 다른 /// 리전이면 방향만으로는 어디인지 알 수 없어서, 전체 지도에 실제 위치를 찍는다. /// - private void DrawStoryObjectiveMarker(Rect area) + private void DrawNpcMarkers(Rect area) { + if (npcManager == null) return; + foreach (var npc in npcManager.StoryNpcs) + { + if (npc == null || !npc.gameObject.activeInHierarchy) continue; + Vector3 position = npc.transform.position; + Vector2 marker = WorldToMap(position.x, position.z); + if (!area.Contains(marker)) continue; + + InsectGame.NPC.QuestMark mark = objectiveTracker != null + ? objectiveTracker.TaleMarkOf(npc) : InsectGame.NPC.QuestMark.None; + if (mark != InsectGame.NPC.QuestMark.None) + { + DrawTaleBadge(marker, npc, mark, area); + continue; + } + + // 본편 대상. 평소엔 목표 마커가 같은 자리에 같은 배지를 그리므로(그쪽은 지도 밖이면 테두리에 + // 붙여 준다) 여기서는 건너뛴다. 의뢰를 따라가는 중에만 여기서 그린다 — 목표 마커가 의뢰로 + // 옮겨 가도 "본편은 저기"가 지도에 남고, 누르면 본편으로 돌아온다. + if (objectiveTracker != null && npc == objectiveTracker.MainMarkNpc) + { + if (objectiveTracker.IsTrackingTale) + { + DrawQuestBadge(marker, UITheme.Instance.accentMint, "!", false); + DrawBadgeName(marker, npc.DisplayName, area); + } + continue; + } + + GUI.color = UITheme.Instance.accentAmber; + GUI.DrawTexture(new Rect(marker.x - 5f, marker.y - 5f, 10f, 10f), discTex); + GUI.color = Color.white; + if (new Rect(marker.x - 10f, marker.y - 10f, 20f, 20f).Contains(Event.current.mousePosition)) + UIHelper.LabelFit(new Rect(Mathf.Clamp(marker.x - 70f, area.x, area.xMax - 140f), + Mathf.Clamp(marker.y + 8f, area.y, area.yMax - 24f), 140f, 24f), npc.DisplayName, subNameStyle); + } + } + + // ── 의뢰 주민 배지 ── + // 반경은 손가락이 닿게 잡는다 — 지도 점(10px)과 같은 크기면 모바일에서 누를 수가 없다. + private const float TaleBadgeRadius = 13f; + private const float TaleBadgeHotPad = 8f; + // 내 위치 점(반경 8) + 진행방향 점(18px 앞, 반경 5)이 차지하는 자리 바깥까지. + private const float PlayerMarkerClearance = 14f; + + /// + /// 지도에서 의뢰 주민 배지를 누르면 그 이야기를 따라간다. 지도는 닫지 않는다 — 목표 마커(민트)가 + /// 곧바로 그 자리로 옮겨 가는 것을 보여 주고, 다른 리전이면 이어서 [이동]을 누르게 한다. + /// + private void HandleTaleMarkerInput(Rect area) + { + Event e = Event.current; + if (e == null || e.type != EventType.MouseDown || e.button != 0 || objectiveTracker == null) return; + + var marks = objectiveTracker.TaleMarkers; + float hot = TaleBadgeRadius + TaleBadgeHotPad; + for (int i = 0; i < marks.Count; i++) + { + InsectGame.NPC.VillagerNpc npc = marks[i].Npc; + if (npc == null || !npc.gameObject.activeInHierarchy) continue; + Vector2 m = WorldToMap(npc.transform.position.x, npc.transform.position.z); + if (!area.Contains(m)) continue; + if (!new Rect(m.x - hot, m.y - hot, hot * 2f, hot * 2f).Contains(e.mousePosition)) continue; + + selectedRegionId = npc.RegionId; // 정보 패널도 그 리전으로 — [이동]이 바로 보인다 + objectiveTracker.TrackTale(npc.StoryNpcId); + e.Use(); + return; + } + + // 본편 표식 — 의뢰를 따라가던 중에만 따로 서 있다. 누르면 본편으로 돌아온다. + InsectGame.NPC.VillagerNpc main = objectiveTracker.MainMarkNpc; + if (main != null && objectiveTracker.IsTrackingTale && main.gameObject.activeInHierarchy) + { + Vector2 m = WorldToMap(main.transform.position.x, main.transform.position.z); + if (area.Contains(m) && new Rect(m.x - hot, m.y - hot, hot * 2f, hot * 2f).Contains(e.mousePosition)) + { + if (!string.IsNullOrEmpty(main.RegionId)) selectedRegionId = main.RegionId; + objectiveTracker.StopTrackingTale(); + e.Use(); + return; + } + } + + // 목표 마커 — 그 리전을 골라 [이동]이 바로 보이게 한다. 마커가 리전 원 위쪽 가장자리나 + // 지도 테두리에 붙어 있으면 그 밑의 리전 버튼은 다른 리전(또는 빈 땅)이다. + if (TryGetObjectiveMarker(area, out Vector2 om, out _) + && !string.IsNullOrEmpty(objectiveTracker.TargetRegionId) + && new Rect(om.x - hot, om.y - hot, hot * 2f, hot * 2f).Contains(e.mousePosition)) + { + selectedRegionId = objectiveTracker.TargetRegionId; + e.Use(); + } + } + + private void DrawTaleBadge(Vector2 m, InsectGame.NPC.VillagerNpc npc, InsectGame.NPC.QuestMark mark, Rect area) + { + // !·? 모두 호박색 — 민트는 이야기 목표 마커와 서브에리어 자리라, ?를 민트로 두면 셋이 한 색이 됐다. + // 새 이야기와 보고는 기호로 가른다(머리 위 표식·미니맵과 같은 규칙). + // 따라가는 주민은 흰 테두리로 한 번 더 두른다 — 여러 배지 중 어느 것을 골랐는지 보이게. + bool tracked = objectiveTracker != null && objectiveTracker.IsTaleTracked(npc.StoryNpcId); + DrawQuestBadge(m, UITheme.Instance.accentAmber, + mark == InsectGame.NPC.QuestMark.Report ? "?" : "!", tracked); + + // 이름은 누르기 전에도 보인다 — 배지만으로는 누구의 의뢰인지 모른다. 다른 점과 달리 개수가 적다(주민 7명). + DrawBadgeName(m, npc.DisplayName, area); + } + + /// !·? 원 배지 하나. 마을 의뢰(호박)와 본편 목표(민트)가 같은 모양을 쓰고 색으로 갈린다. + private void DrawQuestBadge(Vector2 m, Color color, string glyph, bool ringed) + { + UITheme t = UITheme.Instance; + float r = TaleBadgeRadius; + GUI.color = ringed ? Color.white : new Color(t.surfaceShadow.r, t.surfaceShadow.g, t.surfaceShadow.b, 0.55f); + GUI.DrawTexture(new Rect(m.x - r - 3f, m.y - r - 3f, (r + 3f) * 2f, (r + 3f) * 2f), discTex); + GUI.color = color; + GUI.DrawTexture(new Rect(m.x - r, m.y - r, r * 2f, r * 2f), discTex); + GUI.color = Color.white; + GUI.Label(new Rect(m.x - r, m.y - r, r * 2f, r * 2f), glyph, taleBadgeStyle); + } + + private void DrawBadgeName(Vector2 m, string name, Rect area) + { + const float lw = 150f; + UIHelper.LabelFit(new Rect(Mathf.Clamp(m.x - lw * 0.5f, area.x, area.xMax - lw), + Mathf.Clamp(m.y + TaleBadgeRadius + 2f, area.y, area.yMax - 24f), lw, 24f), name, subNameStyle); + } + + // 목표 라벨 폭 캐시 — 트래커가 값이 같으면 같은 문자열 참조를 유지하므로 참조 비교로 적중을 가린다. + private string objectiveLabelMeasured; + private float objectiveLabelTextWidth; + + /// + /// 목표 마커가 지도에 놓일 자리. 그리기(맥동·배지·라벨)와 탭 판정이 같은 자리를 써야 해서 한곳에 둔다. + /// + /// 그 자리에 주민 배지가 이미 그려진다 — 배지를 겹쳐 그리지 않는다. + private bool TryGetObjectiveMarker(Rect area, out Vector2 marker, out bool onNpcBadge) + { + marker = Vector2.zero; + onNpcBadge = false; if (objectiveTracker == null || !objectiveTracker.HasObjective - || !objectiveTracker.HasWorldTarget) return; + || !objectiveTracker.HasWorldTarget) return false; Vector3 wp = objectiveTracker.TargetPosition; - Vector2 mc = WorldToMap(wp.x, wp.z); + Vector2 raw = WorldToMap(wp.x, wp.z); + + // 리전 중심을 가리키는 목표(리전 진입·"그 리전에서 포획")는 원의 위쪽으로 올린다 — + // 중심에는 리전 이름이 있어 배지가 그 글자를 덮는다. + RegionData region = regionManager != null && !string.IsNullOrEmpty(objectiveTracker.TargetRegionId) + ? regionManager.GetRegionById(objectiveTracker.TargetRegionId) : null; + if (region != null && (region.centerPosition - wp).sqrMagnitude < 0.01f) + raw.y -= RegionMapRadius(region) * 0.62f; + + // 따라가는 의뢰 주민은 자기 배지(호박색 !·?)가 이미 그 자리에 있다. + InsectGame.NPC.VillagerNpc npc = objectiveTracker.TargetNpc; + onNpcBadge = npc != null && area.Contains(raw) + && objectiveTracker.TaleMarkOf(npc) != InsectGame.NPC.QuestMark.None; + + // 목표가 바로 옆이면(첫 목표인 어르신이 9m 앞이다) 지도 축척에서는 내 위치 점과 한 자리라 + // 배지가 그 밑에 깔린다. 방향은 살린 채 한 걸음 떼어 놓는다. 주민 배지 위에 얹는 맥동은 + // 그 배지를 따라가야 하므로 옮기지 않는다. + Transform pl = GetPlayer(); + if (pl != null && !onNpcBadge) + { + Vector3 pp = MapMarkerProjection.OverworldPlayerPosition(pl.position, + regionManager != null ? regionManager.CurrentSubArea : null); + raw = MapMarkerProjection.KeepClear(raw, WorldToMap(pp.x, pp.z), TaleBadgeRadius + PlayerMarkerClearance); + } + // **지도 경계는 리전 원들로만 잡는다** — 서브에리어도 NPC도 그 밖에 있을 수 있고, - // 지도는 Group으로 클립되지 않아 나가면 정보 패널 위에 민트 점이 떠 버린다. + // 지도는 Group으로 클립되지 않아 나가면 정보 패널 위에 배지가 떠 버린다. // 나침반처럼 테두리에 붙인다(방향은 남고, 라벨이 바로 옆에 붙어 뭔지는 읽힌다). - mc.x = Mathf.Clamp(mc.x, area.x + 10f, area.xMax - 10f); - mc.y = Mathf.Clamp(mc.y, area.y + 10f, area.yMax - 10f); - Color mint = UITheme.Instance.accentMint; + float inset = TaleBadgeRadius + 5f; + marker = new Vector2(Mathf.Clamp(raw.x, area.x + inset, area.xMax - inset), + Mathf.Clamp(raw.y, area.y + inset, area.yMax - inset)); + return true; + } + + // 본편은 민트, 따라가는 의뢰는 호박 — 배지·맥동·라벨이 한 색으로 묶인다. + private Color ObjectiveColor() + { + UITheme t = UITheme.Instance; + return objectiveTracker.IsTrackingTale ? t.accentAmber : t.accentMint; + } + + private void DrawStoryObjectiveHalo(Rect area) + { + if (!TryGetObjectiveMarker(area, out Vector2 mc, out _)) return; // 맥동은 레이드 마커와 같은 관용구 — 두 마커가 서로 다른 박자로 뛰면 산만해진다. + Color col = ObjectiveColor(); float pulse = 0.7f + Mathf.Sin(Time.time * 4f) * 0.3f; - GUI.color = new Color(mint.r, mint.g, mint.b, 0.28f * pulse); - GUI.DrawTexture(new Rect(mc.x - 17f, mc.y - 17f, 34f, 34f), discTex); - GUI.color = new Color(mint.r, mint.g, mint.b, pulse); - GUI.DrawTexture(new Rect(mc.x - 8f, mc.y - 8f, 16f, 16f), discTex); + float hr = TaleBadgeRadius + 4f + 7f * pulse; + GUI.color = new Color(col.r, col.g, col.b, 0.36f * pulse); + GUI.DrawTexture(new Rect(mc.x - hr, mc.y - hr, hr * 2f, hr * 2f), discTex); GUI.color = Color.white; - GUI.DrawTexture(new Rect(mc.x - 3f, mc.y - 3f, 6f, 6f), discTex); + } + + /// + /// 지금 가야 할 곳의 ! 배지와 목표 문구. 예전엔 16px 민트 점이었고 문구는 마우스를 올려야 + /// 떴다 — 모바일에는 호버가 없어 "점이 뭔지"를 알 길이 없었다. 지도에서 가장 중요한 한 줄이라 늘 띄운다. + /// + private void DrawStoryObjectiveMarker(Rect area) + { + if (!TryGetObjectiveMarker(area, out Vector2 mc, out bool onNpcBadge)) return; - // 라벨을 지도 영역 안으로 가둔다. 목표가 지도 가장자리에 있으면 280px 라벨이 - // 패널 제목이나 정보 패널 위로 삐져나간다(지도는 Group으로 클립되지 않는다). - // 위쪽에 자리가 없으면 마커 아래로 내린다. - float lw = Mathf.Min(280f, area.width); + Color col = ObjectiveColor(); + if (!onNpcBadge) DrawQuestBadge(mc, col, "!", true); + + string text = objectiveTracker.Label; + if (string.IsNullOrEmpty(text)) return; + if (!ReferenceEquals(objectiveLabelMeasured, text)) + { + objectiveLabelMeasured = text; + objectiveLabelTextWidth = objectiveStyle.CalcSize(new GUIContent(text)).x; + } + + // 라벨을 지도 영역 안으로 가둔다(지도는 Group으로 클립되지 않는다). 위쪽에 자리가 없으면 아래로 — + // 아래엔 주민 이름이 붙으므로 그 한 줄(26px)만큼 더 내린다. + const float lh = 30f; + float lw = Mathf.Min(objectiveLabelTextWidth + 28f, Mathf.Min(360f, area.width)); float lx = Mathf.Clamp(mc.x - lw * 0.5f, area.x, area.xMax - lw); - float ly = mc.y - 36f >= area.y ? mc.y - 36f : mc.y + 14f; - ly = Mathf.Clamp(ly, area.y, area.yMax - 24f); + float gap = TaleBadgeRadius + 8f; + float ly = mc.y - gap - lh >= area.y ? mc.y - gap - lh : mc.y + gap + (onNpcBadge ? 26f : 0f); + ly = Mathf.Clamp(ly, area.y, area.yMax - lh); - // Label()이 wordWrap을 전역으로 꺼 두어 긴 목표명은 가로로 잘린다 — LabelFit이 - // 폭까지 보고 폰트를 줄인다(rules/ui-layout.md). - UIHelper.LabelFit(new Rect(lx, ly, lw, 24f), objectiveTracker.Label, objectiveStyle); + // 리전 원·이름 위에 얹히므로 바탕을 깐다. Label()이 wordWrap을 꺼 두어 긴 목표명은 가로로 + // 잘린다 — LabelFit이 폭까지 보고 폰트를 줄인다(rules/ui-layout.md). + UITheme t = UITheme.Instance; + Rect pill = new Rect(lx, ly, lw, lh); + UISurface.Rounded(pill, new Color(t.surfaceBase.r, t.surfaceBase.g, t.surfaceBase.b, 0.92f), UITheme.Radius.Chip); + objectiveStyle.normal.textColor = col; + UIHelper.LabelFit(new Rect(lx + 10f, ly, lw - 20f, lh), text, objectiveStyle); GUI.color = Color.white; } @@ -544,7 +778,7 @@ private void DrawRegionInfoPanel(Rect area) // 대략 334px밖에 안 된다 — 고정 34px로 일곱 줄을 쌓으면 마지막 두 줄이 아래 // 안내 문구를 뚫고 나간다. 범례는 한 항목만 빠져도 지도를 못 읽으므로 // 줄을 버리는 대신 간격을 줄인다(rules/ui-layout.md의 "고정 개수 행" 처리). - const int LegendRows = 7; + const int LegendRows = 8; float legendPitch = Mathf.Clamp( (area.y + area.height - 66f - ly) / LegendRows, 22f, 34f); @@ -554,7 +788,8 @@ private void DrawRegionInfoPanel(Rect area) DrawLegendRow(ix, ref ly, iw, new Color(0.7f, 0.3f, 0.95f), "레이드 보스", legendPitch); DrawLegendRow(ix, ref ly, iw, new Color(0.4f, 0.85f, 1f), "내 위치", legendPitch); DrawLegendRow(ix, ref ly, iw, new Color(0.5f, 0.7f, 0.4f), "서브에리어 (소형 원, 눌러 이동)", legendPitch); - DrawLegendRow(ix, ref ly, iw, UITheme.Instance.accentMint, "이야기 목표 (민트, 맥동)", legendPitch); + DrawLegendRow(ix, ref ly, iw, UITheme.Instance.accentMint, "본편 목표 (민트 !, 맥동)", legendPitch); + DrawLegendRow(ix, ref ly, iw, UITheme.Instance.accentAmber, "마을 의뢰 (!·? 눌러 따라가기)", legendPitch); // 목표 문구 자체는 여기 두지 않는다 — 마커 바로 위에 이미 붙어 있고(지도), // 패널 밖 HUD 목표 행에도 있다. 세로에서는 넣을 자리도 없다. @@ -622,7 +857,7 @@ private void DrawRegionInfoPanel(Rect area) if (GUI.Button(new Rect(ix, btnY, halfW, btnH), "이동", btnStyle)) { if (accessible) { TeleportToRegion(region); CloseModal(); } - else { errorMessage = "잠긴 지역입니다 (이전 지역의 수문장 격파)"; errorTimer = 3f; } + else { errorMessage = LockMessage(region); errorTimer = 3f; } } GUI.backgroundColor = UITheme.Instance.btnSecondary; if (GUI.Button(new Rect(ix + halfW + 12f, btnY, halfW, btnH), "도감", btnStyle)) @@ -682,13 +917,25 @@ private void DrawThickLine(Vector2 a, Vector2 b, float thickness, Color col) if (len < 0.5f) return; float ang = Mathf.Atan2(d.y, d.x) * Mathf.Rad2Deg; Matrix4x4 saved = GUI.matrix; - GUIUtility.RotateAroundPivot(ang, a); + GUI.matrix = MapMarkerProjection.PivotMatrix(saved, a, ang); GUI.color = col; - GUI.DrawTexture(new Rect(a.x, a.y - thickness * 0.5f, len, thickness), Texture2D.whiteTexture); + GUI.DrawTexture(new Rect(0f, -thickness * 0.5f, len, thickness), Texture2D.whiteTexture); GUI.matrix = saved; GUI.color = Color.white; } + /// + /// 잠긴 지역을 눌렀을 때의 안내 — 왜 잠겼는지를 리전 관리자가 말한다("서릿길 — 집게에게 이겨야 열립니다", + /// RegionManager.DescribeLock). 예전엔 "이전 지역의 수문장 격파"로 박혀 있어 이야기 대결로 잠긴 지역에서 거짓말이 됐다. + /// 문구가 비면(판정이 갈린 드문 경우) 그래도 한 줄은 띄운다 — 누르고 아무 반응이 없으면 고장으로 보인다. + /// + private string LockMessage(RegionData region) + { + string text = regionManager != null && region != null ? regionManager.DescribeLock(region) : null; + if (!string.IsNullOrEmpty(text)) return text; + return region != null ? $"{region.displayName} — 아직 갈 수 없습니다" : "아직 갈 수 없습니다"; + } + private void DrawErrorToast() { if (errorTimer <= 0f || string.IsNullOrEmpty(errorMessage)) return; @@ -818,7 +1065,7 @@ private string DexInfoLine(InsectData data) string key = data.insectId ?? data.displayName ?? string.Empty; if (dexInfoCache.TryGetValue(key, out string cached)) return cached; - string line = $"{data.rarity} | CP {PlayerInsectCombatPower.CalculateBasePreview(data, data.minLevel)}"; + string line = $"{data.rarity.Korean()} | CP {PlayerInsectCombatPower.CalculateBasePreview(data, data.minLevel)}"; dexInfoCache[key] = line; return line; } @@ -864,9 +1111,12 @@ private void TeleportToSubArea(Data.SubAreaData sub) if (sub == null) return; Transform pl = GetPlayer(); if (pl == null) return; + // Exit restores the main world synchronously before the new destination is assigned. + if (regionManager != null) regionManager.ForceExitSubArea(); Vector3 dest = sub.centerPosition; dest.y = pl.position.y; pl.position = dest; + pl.GetComponent()?.StopNavigation(); // 맵은 닫지 않음 — 다음 Update에서 SubArea 진입 팝업 자동 발화. } @@ -875,9 +1125,11 @@ private void TeleportToRegion(RegionData region) if (region == null) return; Transform pl = GetPlayer(); if (pl == null) return; + if (regionManager != null) regionManager.ForceExitSubArea(); Vector3 dest = region.centerPosition; dest.y = pl.position.y; pl.position = dest; + pl.GetComponent()?.StopNavigation(); } private Color GetSubAreaColor(string envType) @@ -937,4 +1189,45 @@ public void AutoWire(InsectSpawner sp) if (spawner != null) spawner.RaidBossSpawned += OnRaidBossSpawned; } } + internal static class MapMarkerProjection + { + internal static Matrix4x4 PivotMatrix(Matrix4x4 parent, Vector2 pivot, float angle) + { + return parent * Matrix4x4.TRS(new Vector3(pivot.x, pivot.y, 0f), + Quaternion.Euler(0f, 0f, angle), Vector3.one); + } + + internal static Vector3 OverworldPlayerPosition(Vector3 playerPosition, SubAreaData currentSubArea) + { + return currentSubArea != null ? currentSubArea.centerPosition : playerPosition; + } + + /// + /// 가 에서 보다 가까우면 + /// 같은 방향으로 그만큼 밀어낸다(방향은 "어느 쪽으로 가면 되는가"라 지킨다). 정확히 겹치면 위로. + /// + internal static Vector2 KeepClear(Vector2 marker, Vector2 obstacle, float minGap) + { + if (minGap <= 0f) return marker; + Vector2 delta = marker - obstacle; + float sqr = delta.sqrMagnitude; + if (sqr >= minGap * minGap) return marker; + // GUI 좌표는 아래가 +y다 — 위는 (0, -1). + Vector2 direction = sqr > 0.0001f ? delta / Mathf.Sqrt(sqr) : new Vector2(0f, -1f); + return obstacle + direction * minGap; + } + + internal static bool TryRadarOffset(Vector3 origin, Vector3 target, float worldRadius, + float mapRadius, out Vector2 offset) + { + Vector3 delta = target - origin; + offset = Vector2.zero; + if (worldRadius <= 0f || mapRadius <= 0f) return false; + Vector2 horizontal = new Vector2(delta.x, -delta.z); + if (horizontal.sqrMagnitude > worldRadius * worldRadius) return false; + offset = horizontal / worldRadius * mapRadius; + return true; + } + } + } diff --git a/Assets/Scripts/UI/SaveConflictUI.cs b/Assets/Scripts/UI/SaveConflictUI.cs index 96e95e0..90f3f2e 100644 --- a/Assets/Scripts/UI/SaveConflictUI.cs +++ b/Assets/Scripts/UI/SaveConflictUI.cs @@ -66,6 +66,8 @@ private static long ComputeLocalSaveUnix() GameConstants.SaveFiles.PlayerItems, GameConstants.SaveFiles.BattleTeam, GameConstants.SaveFiles.DexSave, + // 섬만 꾸미고 닫은 세션도 "로컬에 더 새 진행이 있다"로 잡혀야 한다. + GameConstants.SaveFiles.Island, }; long newest = 0; diff --git a/Assets/Scripts/UI/StoryDialogueStaging.cs b/Assets/Scripts/UI/StoryDialogueStaging.cs new file mode 100644 index 0000000..0fa4688 --- /dev/null +++ b/Assets/Scripts/UI/StoryDialogueStaging.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; + +namespace InsectGame.UI +{ + /// + /// 스토리 대사 한 줄의 연출 규칙 — 줄별 fx 태그 해석, 타자 공개 글자 수, 인물의 좌우 배치. + /// + /// 대사창()이 그리는 것과 **무엇을 언제 보여주는가**를 나눈다. + /// 이쪽은 Unity API를 하나도 쓰지 않는 순수 계산이라 StoryDialogueStagingTests가 씬 없이 고정한다. + /// + /// 옛 대사창은 한 줄을 통째로 띄우고 초상 하나를 왼쪽에 붙였다. 144비트 중 두 사람 이상이 말하는 + /// 장면이 23개뿐이었던 것도 있지만, 주고받아도 주고받는 것처럼 보이지 않았다 — 화자가 바뀌어도 + /// 같은 자리의 초상만 바뀌었다. 지금은 먼저 말한 사람이 왼쪽, 다음 사람이 오른쪽에 서서 마주 본다. + /// + public static class StoryDialogueStaging + { + /// 해설 줄의 화자 — 초상·이름표 없이 가운데 기울임으로 그린다. + public const string NarrationSpeaker = "지문"; + + /// 기본 타자 속도(글자/초). 한국어 한 줄(30~50자)이 1~1.3초에 다 나온다. + public const float CharsPerSecond = 38f; + /// pause — 글자가 나오기 전의 사이(間). + public const float PauseSeconds = 0.7f; + /// 흔들림 길이·세기(가상 픽셀). + public const float ShakeSeconds = 0.45f; + public const float ShakeAmplitude = 12f; + /// 번쩍임이 사라지는 시간과 시작 알파. + public const float FlashSeconds = 0.4f; + public const float FlashAlpha = 0.75f; + /// 화자가 바뀔 때 새 화자가 뛰어오르는 시간·높이. + public const float HopSeconds = 0.22f; + public const float HopHeight = 14f; + + [Flags] + public enum LineFx + { + None = 0, + /// 화면이 흔들린다 — 충격·붕괴·분노. + Shake = 1 << 0, + /// 흰 번쩍임 — 깨달음·섬광. + Flash = 1 << 1, + /// 말하기 전 사이 — 망설임·무게. + Pause = 1 << 2, + /// 외침 — 크게, 빠르게, 짧게 흔들린다. + Shout = 1 << 3, + /// 속삭임 — 작게, 흐리게, 느리게. + Whisper = 1 << 4, + /// 느리게 — 한 글자씩 곱씹는 줄. + Slow = 1 << 5, + /// 배경이 더 어두워진다 — 불길함. + Dark = 1 << 6, + } + + /// 저작에 쓸 수 있는 토큰 — story_lint 검사 28이 이 목록과 대조한다(여기가 단일 출처). + public static readonly string[] KnownFxTokens = + { + "shake", "flash", "pause", "shout", "whisper", "slow", "dark", + }; + + public static bool IsNarration(string speaker) => speaker == NarrationSpeaker; + + /// 쉼표로 이어진 토큰을 해석한다. 대소문자·공백은 무시하고, 모르는 토큰은 버린다. + public static LineFx ParseFx(string fx) + { + if (string.IsNullOrEmpty(fx)) return LineFx.None; + LineFx result = LineFx.None; + string[] tokens = fx.Split(','); + for (int i = 0; i < tokens.Length; i++) + { + switch (tokens[i].Trim().ToLowerInvariant()) + { + case "shake": result |= LineFx.Shake; break; + case "flash": result |= LineFx.Flash; break; + case "pause": result |= LineFx.Pause; break; + case "shout": result |= LineFx.Shout; break; + case "whisper": result |= LineFx.Whisper; break; + case "slow": result |= LineFx.Slow; break; + case "dark": result |= LineFx.Dark; break; + } + } + return result; + } + + public static float TypingSpeed(LineFx fx) + { + float speed = CharsPerSecond; + if ((fx & LineFx.Shout) != 0) speed *= 1.5f; + if ((fx & LineFx.Whisper) != 0) speed *= 0.7f; + if ((fx & LineFx.Slow) != 0) speed *= 0.5f; + return speed; + } + + public static float StartDelay(LineFx fx) => (fx & LineFx.Pause) != 0 ? PauseSeconds : 0f; + + /// 줄이 뜬 지 초 뒤에 보이는 글자 수. + public static int VisibleChars(int length, float elapsed, LineFx fx) + { + if (length <= 0) return 0; + float typing = elapsed - StartDelay(fx); + if (typing <= 0f) return 0; + double count = Math.Floor(typing * TypingSpeed(fx)); + return count >= length ? length : (int)count; + } + + /// 줄이 다 나오는 데 걸리는 시간(초). + public static float RevealDuration(int length, LineFx fx) + { + return length <= 0 ? 0f : StartDelay(fx) + length / TypingSpeed(fx); + } + + /// 흔들림 가로 오프셋 — 감쇠하는 떨림. 연출이 없거나 끝났으면 0. + public static float ShakeOffset(LineFx fx, float elapsed) + { + float duration = (fx & LineFx.Shake) != 0 ? ShakeSeconds : (fx & LineFx.Shout) != 0 ? ShakeSeconds * 0.5f : 0f; + if (duration <= 0f || elapsed < 0f || elapsed >= duration) return 0f; + float amplitude = (fx & LineFx.Shake) != 0 ? ShakeAmplitude : ShakeAmplitude * 0.5f; + float decay = 1f - elapsed / duration; + return (float)Math.Sin(elapsed * 70.0) * amplitude * decay * decay; + } + + /// 번쩍임 오버레이 알파 — 줄이 뜨는 순간 가장 밝고 빠르게 걷힌다. + public static float FlashOverlay(LineFx fx, float elapsed) + { + if ((fx & LineFx.Flash) == 0 || elapsed < 0f || elapsed >= FlashSeconds) return 0f; + float t = 1f - elapsed / FlashSeconds; + return FlashAlpha * t * t; + } + + /// 새 화자가 뛰어오르는 높이(위로 +). 화자가 그대로면 0. + public static float HopOffset(float sinceSpeakerChange) + { + if (sinceSpeakerChange < 0f || sinceSpeakerChange >= HopSeconds) return 0f; + float t = sinceSpeakerChange / HopSeconds; + return HopHeight * 4f * t * (1f - t); + } + + public enum Side + { + None, + Left, + Right, + } + + /// 한 줄에서 무대에 선 두 사람과 지금 말하는 쪽. + public struct Stage + { + public string LeftId; + public string RightId; + public Side Active; + } + + /// + /// 줄마다 무대 배치를 정한다. 는 줄별 초상 ID이고 null이면 + /// 초상이 없는 줄(지문·미등록 화자)이다 — 그 줄엔 아무도 말하지 않고 서 있던 사람은 그대로 선다. + /// + /// 먼저 말한 사람이 왼쪽, 다음 사람이 오른쪽. 세 번째 사람이 끼면 더 오래 말하지 않은 쪽과 + /// 자리를 바꾼다 — 방금 말한 사람을 치우면 대꾸하는 그림이 끊긴다. + /// + public static Stage[] AssignSides(IList portraitIds) + { + int n = portraitIds != null ? portraitIds.Count : 0; + Stage[] stages = new Stage[n]; + string left = null, right = null; + int leftLast = -1, rightLast = -1; + for (int i = 0; i < n; i++) + { + string id = portraitIds[i]; + Side active = Side.None; + if (!string.IsNullOrEmpty(id)) + { + if (id == left) { active = Side.Left; leftLast = i; } + else if (id == right) { active = Side.Right; rightLast = i; } + else if (left == null) { left = id; active = Side.Left; leftLast = i; } + else if (right == null) { right = id; active = Side.Right; rightLast = i; } + else if (leftLast < rightLast) { left = id; active = Side.Left; leftLast = i; } + else { right = id; active = Side.Right; rightLast = i; } + } + stages[i] = new Stage { LeftId = left, RightId = right, Active = active }; + } + return stages; + } + } +} diff --git a/Assets/Scripts/UI/StoryDialogueStaging.cs.meta b/Assets/Scripts/UI/StoryDialogueStaging.cs.meta new file mode 100644 index 0000000..362e329 --- /dev/null +++ b/Assets/Scripts/UI/StoryDialogueStaging.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6d9aa74c43d62294bb5f3977cc047aca \ No newline at end of file diff --git a/Assets/Scripts/UI/StoryJournalUI.cs b/Assets/Scripts/UI/StoryJournalUI.cs index 7e1a336..4a4813d 100644 --- a/Assets/Scripts/UI/StoryJournalUI.cs +++ b/Assets/Scripts/UI/StoryJournalUI.cs @@ -19,11 +19,26 @@ namespace InsectGame.UI /// StoryBeat.order가 여기서 처음 쓰인다 — 발화 순서는 prereq가 정하고 order는 /// 문서용 메타였는데(StoryService가 Dictionary라 순서 보장이 없다), 저널의 **표시 순서**로는 /// 정확히 이 값이 필요하다. + /// + /// 영상 다시보기: 본 비트에 영상(대사 앞 introVideoId·뒤 videoId — story_lint 검사 13이 한 비트에 하나만 허용)이 + /// 있으면 「다시 읽기」 왼쪽에 「▶ 영상」이 선다. 로 틀고(스토리 부수효과 없음) 저널은 그 아래 열린 채 둔다 — + /// 영상이 모달 스택 맨 위라 ESC·「건너뛰기」는 영상만 닫고 저널로 돌아온다. 영상이 화면을 덮는 동안 저널은 그리지도 + /// 입력을 받지도 않는다(영상의 검은 판은 클릭을 먹지 않아 그 아래 버튼이 눌린다). /// public class StoryJournalUI : MonoBehaviour, IModalUI { private StoryDirector storyDirector; private NpcDialogueUI dialogueUI; + private StoryVideoDirector videoDirector; + + // 대사 앞·뒤 영상이 둘 다 있는 비트는 이야기 순서대로 이어 튼다 — 앞 영상이 끝나면(건너뛰어도) 뒤 영상. + // **실제 데이터에서는 생기지 않는다** — story_lint 검사 13이 한 비트에 introVideoId와 videoId를 함께 두는 것을 막는다. + // 그 규칙이 풀리거나 검사 밖 데이터가 들어와도 뒤 영상이 조용히 사라지지 않게 남겨 둔 방어다. + private string queuedReplayVideoId; + // 영상을 열지 못했을 때 머리줄에 잠깐 띄우는 안내. + private string replayNotice; + private float replayNoticeUntil; + private const float ReplayNoticeSeconds = 2.5f; private bool isOpen; private string selectedChapter; @@ -37,7 +52,18 @@ public class StoryJournalUI : MonoBehaviour, IModalUI private int totalBeats; private bool stylesReady; - private GUIStyle titleStyle, closeStyle, tabStyle, rowTitleStyle, rowMetaStyle, hintStyle, lockStyle; + private GUIStyle titleStyle, closeStyle, tabStyle, rowTitleStyle, rowMetaStyle, hintStyle, lockStyle, noticeStyle; + private GUIStyle headSectionStyle, headBodyStyle, headGoalStyle; + + // 장 머리(지난 이야기·이번 목표) — 고른 장의 여는 비트를 이미 봤을 때만 그린다(스포일러 금지). + // 줄 높이는 래핑으로 재야 해서(CalcHeight) (장, 열람 수, 목록 영역)이 바뀔 때만 다시 굽는다. + private StoryJournalHeadLayout.Plan headPlan; + private bool headVisible; + private string headChapterId; + private int headSeenCount = -1; + private Rect headArea; + private string[] headLines; + private string headGoal; /// /// 챕터 탭의 표시 순서와 이름. 배열이지 Dictionary가 아니다 — @@ -45,7 +71,7 @@ public class StoryJournalUI : MonoBehaviour, IModalUI /// 뒤섞여 뜰 수 있다(스토리 엔진이 StoryService.AllBeats()의 Dictionary 순서를 /// 못 믿어 prereq로 엮는 것과 같은 이유다). 순서가 의미를 가지면 배열로 적는다. /// - /// Story.json의 chapterId는 현재 ch1…ch12/fin/side/npc다. 여기 없는 chapterId는 + /// Story.json의 chapterId는 현재 ch1…ch12/fin/bl/town/side/npc다. 여기 없는 chapterId는 /// ID를 그대로 라벨로 쓰고 뒤에 붙는다 — 챕터를 추가해도 저널이 깨지지 않는다. /// private static readonly (string id, string label)[] ChapterOrder = @@ -64,6 +90,7 @@ private static readonly (string id, string label)[] ChapterOrder = ("ch12", "12장 · 이름 없는 자리"), ("fin", "종장"), ("bl", "오염 거점"), + ("town", "마을 이야기"), ("side", "곁이야기"), ("npc", "동행자와의 대화"), }; @@ -74,6 +101,12 @@ public void AutoWire(StoryDirector director, NpcDialogueUI dialogue) if (dialogueUI == null) dialogueUI = dialogue; } + /// 영상 다시보기 — 없으면 「▶ 영상」 버튼이 서지 않는다. + public void AutoWire(StoryVideoDirector video) + { + if (videoDirector == null) videoDirector = video; + } + // 인덱스는 선택지 열람 여부에 따라 달라진다(안 고른 결과는 숨긴다). 세션당 1회 만들면 // 저널을 먼저 연 뒤 고른 결과가 세션 내내 안 뜨고 진행률 분모도 어긋난다. 이벤트 구독 대신 // **SeenCount 스냅샷**을 키로 쓴다 — 클라우드 반영(StoryDirector.ReloadFromDisk)은 이벤트 없이 @@ -92,6 +125,7 @@ public void Toggle() scroll = Vector2.zero; } directScroll.Reset(); + queuedReplayVideoId = null; if (isOpen) ModalUIRegistry.Register(this); else ModalUIRegistry.Unregister(this); } @@ -100,6 +134,7 @@ public void CloseModal() { isOpen = false; directScroll.Reset(); + queuedReplayVideoId = null; ModalUIRegistry.Unregister(this); } @@ -109,9 +144,24 @@ private void OnDisable() // (ESC가 이 모달을 건너뛰어 영구히 안 닫힘). NpcDialogueUI가 같은 이유로 이렇게 한다. isOpen = false; directScroll.Reset(); + queuedReplayVideoId = null; ModalUIRegistry.Unregister(this); } + /// 영상 다시보기가 화면을 덮고 있는가(이어 틀 영상이 기다리는 한 프레임 포함). + private bool VideoCovering => videoDirector != null && (videoDirector.IsPlaying || queuedReplayVideoId != null); + + private void Update() + { + // 대사 앞·뒤 영상을 이어 튼다 — 앞 영상이 끝난(또는 건너뛴) 다음 프레임에 뒤 영상. + if (queuedReplayVideoId == null) return; + if (!isOpen || videoDirector == null) { queuedReplayVideoId = null; return; } + if (videoDirector.IsPlaying) return; + string next = queuedReplayVideoId; + queuedReplayVideoId = null; + if (!videoDirector.PlayReplay(next)) ShowReplayNotice(); + } + // ── 인덱스 ── private void EnsureIndex() @@ -122,6 +172,7 @@ private void EnsureIndex() beatsByChapter = new Dictionary>(); rowMetaCache.Clear(); + rowVideoCache.Clear(); headerCache = null; totalBeats = 0; foreach (StoryBeat beat in StoryService.AllBeats()) @@ -188,6 +239,9 @@ private int SeenIn(string chapter) private static string ChapterLabel(string chapterId) { + // 장 데이터(Story.json "chapters")의 제목이 있으면 그걸 쓴다 — 순서는 ChapterOrder 배열이 그대로 정한다. + if (StoryService.TryGetChapter(chapterId, out StoryChapter chapter) && !string.IsNullOrWhiteSpace(chapter.title)) + return chapter.title; for (int i = 0; i < ChapterOrder.Length; i++) { if (ChapterOrder[i].id == chapterId) return ChapterOrder[i].label; @@ -202,6 +256,9 @@ private void OnGUI() if (!isOpen) return; // 다시보기 대화가 떠 있는 동안은 저널을 그리지 않는다 — 대화 모달이 위에 있어야 한다. if (dialogueUI != null && dialogueUI.IsOpen) return; + // 영상 다시보기도 같다 — 영상은 화면 전체를 덮지만 검은 판은 클릭을 먹지 않아서, 여기서 그리면 + // 「건너뛰기」 밖을 누른 탭이 그 아래 저널 버튼(다시 읽기·▶ 영상·탭·X)으로 새어 든다. + if (VideoCovering) return; UIScale.Begin(); EnsureStyles(); EnsureIndex(); @@ -229,7 +286,10 @@ private void DrawPanel() headerCache = seen + " / " + totalBeats + " 장면을 지나왔다"; } GUI.Label(new Rect(px + 26f, py + 14f, pw - 220f, 50f), "여행의 기록", titleStyle); - GUI.Label(new Rect(px + 26f, py + 58f, pw - 220f, 28f), headerCache, hintStyle); + if (replayNotice != null && Time.unscaledTime < replayNoticeUntil) + UIHelper.LabelFit(new Rect(px + 26f, py + 58f, pw - 220f, 28f), replayNotice, noticeStyle); + else + GUI.Label(new Rect(px + 26f, py + 58f, pw - 220f, 28f), headerCache, hintStyle); if (GUI.Button(new Rect(px + pw - 74f, py + 14f, 58f, 58f), "X", closeStyle)) { CloseModal(); return; } float bodyY = py + 96f; @@ -322,8 +382,15 @@ private void DrawBeatList(Rect area) return; } - float rowH = UIScale.IsMobileLayout ? 104f : 92f; - float gap = 8f; + // 장 머리 — 목록 위. 아직 그 장에 들어서지 않았으면(여는 비트 미열람) 그리지 않고 목록이 영역을 다 쓴다. + if (EnsureChapterHead(area)) + { + DrawChapterHead(); + area = headPlan.List; + } + + float rowH = RowHeight; + float gap = RowGap; float contentH = list.Count * (rowH + gap); Rect view = new Rect(0f, 0f, area.width, contentH); directScroll.Handle(ref scroll, area, contentH, rowH * 0.5f); @@ -337,6 +404,72 @@ private void DrawBeatList(Rect area) UISurface.ScrollAffordance(area, scroll, contentH, UITheme.Instance.accentAmber); } + private static float RowHeight => UIScale.IsMobileLayout ? 104f : 92f; + private const float RowGap = 8f; + + /// + /// 이 장의 머리(지난 이야기·이번 목표)를 보여도 되는가 — 스포일러 금지. 장 데이터에 내용이 있고, 그 장을 여는 비트 + /// (openingBeatId)를 이미 봤을 때만. 여는 비트가 비어 있는 장은 그 장의 비트를 하나라도 봤을 때. + /// + private bool CanShowChapterHead(string chapterId, out StoryChapter chapter) + { + chapter = null; + if (storyDirector == null || string.IsNullOrEmpty(chapterId)) return false; + if (!StoryService.TryGetChapter(chapterId, out chapter) || chapter == null) return false; + if (!StoryJournalHeadLayout.HasContent(chapter)) return false; + return string.IsNullOrEmpty(chapter.openingBeatId) + ? SeenIn(chapterId) > 0 + : storyDirector.HasSeen(chapter.openingBeatId); + } + + /// 장 머리 배치를 (장, 열람 수, 목록 영역)마다 한 번 굽는다. 그릴 게 있으면 true. + private bool EnsureChapterHead(Rect area) + { + int seen = storyDirector != null ? storyDirector.SeenCount : 0; + if (headChapterId == selectedChapter && headSeenCount == seen && headArea == area) return headVisible; + headChapterId = selectedChapter; + headSeenCount = seen; + headArea = area; + headVisible = CanShowChapterHead(selectedChapter, out StoryChapter chapter); + if (!headVisible) return false; + + float textW = StoryJournalHeadLayout.TextWidth(area.width); + List lines = new List(); + List heights = new List(); + if (chapter.recap != null) + { + for (int i = 0; i < chapter.recap.Count; i++) + { + string line = chapter.recap[i]; + if (string.IsNullOrWhiteSpace(line)) continue; + string text = "• " + line.Trim(); + lines.Add(text); + heights.Add(StoryJournalHeadLayout.BodyHeight(UIHelper.MeasureWrappedHeight(headBodyStyle, text, textW))); + } + } + headLines = lines.ToArray(); + headGoal = string.IsNullOrWhiteSpace(chapter.goal) ? null : "이번 목표 · " + chapter.goal.Trim(); + float goalH = headGoal == null ? 0f + : StoryJournalHeadLayout.BodyHeight(UIHelper.MeasureWrappedHeight(headGoalStyle, headGoal, textW)); + headPlan = StoryJournalHeadLayout.Layout(area, heights, goalH); + return true; + } + + private void DrawChapterHead() + { + UITheme t = UITheme.Instance; + StoryJournalHeadLayout.Plan p = headPlan; + UISurface.Card(p.Block, t.surfaceCard, t.surfaceBorder); + if (p.Recap != null && p.Recap.Length > 0) + { + UIHelper.LabelFit(p.RecapHeader, "지난 이야기", headSectionStyle); + for (int i = 0; i < p.Recap.Length && headLines != null && i < headLines.Length; i++) + UIHelper.LabelFit(p.Recap[i], headLines[i], headBodyStyle); + if (p.HasGoal) UISurface.Flat(p.Divider, t.surfaceBorder); + } + if (p.HasGoal && headGoal != null) UIHelper.LabelFit(p.Goal, headGoal, headGoalStyle); + } + private void DrawBeatRow(Rect rect, StoryBeat beat) { if (beat == null) return; @@ -353,36 +486,123 @@ private void DrawBeatRow(Rect rect, StoryBeat beat) Mathf.Max(4f, rect.height - 6f - UITheme.Radius.Card * 2f)), seen ? UITheme.Instance.accentMint : new Color(0.3f, 0.31f, 0.36f)); - float textX = rect.x + 22f; - // 버튼(150) + 여백과 연동한다 — 패널이 ClampSize로 좁아지면 고정 감산은 폭을 음수로 만든다. - const float btnZone = 150f + 36f; - float textW = Mathf.Max(120f, rect.width - btnZone); + float textX = rect.x + StoryJournalRowLayout.TextLeft; if (seen) { + // 영상 버튼은 본 비트에만 — 이 분기 밖(미열람)에는 아예 없다(스포일러 금지). + bool hasVideo = HasReplayVideo(beat); + float textW = StoryJournalRowLayout.TextWidth(rect, hasVideo); // 첫 줄을 제목처럼 쓴다 — 비트에 별도 제목 필드가 없고, 첫 대사가 늘 그 장면을 연다. string head = FirstLine(beat); rowTitleStyle.normal.textColor = Color.white; UIHelper.LabelFit(new Rect(textX, rect.y + 14f, textW, 34f), head, rowTitleStyle); UIHelper.LabelFit(new Rect(textX, rect.y + 50f, textW, 26f), RowMeta(beat), rowMetaStyle); - float bw = 150f; - float bh = Mathf.Max(UIScale.MinTouchHeight, 46f); + // 「다시 읽기」는 영상 유무와 무관하게 늘 같은 자리(행 오른쪽 끝) — 행마다 버튼 열이 흔들리지 않게. GUI.backgroundColor = UITheme.Instance.btnPrimary; - if (GUI.Button(new Rect(rect.x + rect.width - bw - 18f, rect.y + (rect.height - bh) * 0.5f, bw, bh), - "다시 읽기", tabStyle)) + if (GUI.Button(StoryJournalRowLayout.ReplayButton(rect), "다시 읽기", tabStyle)) { ReplayBeat(beat); } + if (hasVideo) + { + GUI.backgroundColor = UITheme.Instance.accentCoral; + if (GUI.Button(StoryJournalRowLayout.VideoButton(rect), StoryJournalRowLayout.VideoLabel, tabStyle)) + { + PlayBeatVideos(beat); + } + } GUI.backgroundColor = Color.white; } else { + float textW = StoryJournalRowLayout.TextWidth(rect, false); UIHelper.LabelFit(new Rect(textX, rect.y + 14f, textW, 34f), "아직 지나지 않은 장면", lockStyle); UIHelper.LabelFit(new Rect(textX, rect.y + 50f, textW, 26f), HintFor(beat), rowMetaStyle); } } + // ── 영상 다시보기 ── + + // 비트별 다시 볼 영상(이야기 순서) — 라이브러리 조회가 정의 객체를 새로 만들어서 행마다 매 패스 묻지 않는다. + // 인덱스를 다시 구울 때(EnsureIndex) 함께 비운다. + private readonly Dictionary rowVideoCache = new Dictionary(); + + private StoryJournalVideo.Pair VideosOf(StoryBeat beat) + { + if (!rowVideoCache.TryGetValue(beat.beatId, out StoryJournalVideo.Pair pair)) + { + pair = StoryJournalVideo.For(beat, IsKnownVideo); + rowVideoCache[beat.beatId] = pair; + } + return pair; + } + + private static bool IsKnownVideo(string videoId) => StoryVideoLibrary.TryGet(videoId, out _); + + private bool HasReplayVideo(StoryBeat beat) => videoDirector != null && VideosOf(beat).HasAny; + + private void PlayBeatVideos(StoryBeat beat) + { + if (videoDirector == null || beat == null) return; + // 그리기 쪽과 같은 거름을 한 번 더 — 아직 안 본 비트의 영상은 열지 않는다(스포일러 금지). + if (storyDirector == null || !storyDirector.HasSeen(beat.beatId)) return; + StoryJournalVideo.Pair pair = VideosOf(beat); + if (!pair.HasAny) return; + + directScroll.Reset(); // 누르던 손가락이 영상이 끝난 뒤 목록 드래그로 남지 않게 + queuedReplayVideoId = null; + if (videoDirector.PlayReplay(pair.First)) + { + queuedReplayVideoId = pair.Then; // 뒤 영상은 앞 영상이 닫힌 다음 프레임에(Update) + return; + } + // 앞 영상을 못 열면 뒤 영상이라도 — 둘 다 안 되면 머리줄에 잠깐 알린다. + if (pair.Then == null || !videoDirector.PlayReplay(pair.Then)) ShowReplayNotice(); + } + + private void ShowReplayNotice() + { + replayNotice = "영상을 열 수 없어요. 잠시 뒤에 다시 눌러 주세요."; + replayNoticeUntil = Time.unscaledTime + ReplayNoticeSeconds; + } + + // ── 검수 캡처 전용 (StoryVideoVisualCapture) — 저장을 건드리지 않는다 ── + + /// 이 장을 고르고 이 비트 행이 목록 위쪽에 보이게 연다. + internal void OpenForCapture(string chapterId, string focusBeatId) + { + if (!isOpen) Toggle(); + EnsureIndex(); + if (beatsByChapter == null) return; + if (!string.IsNullOrEmpty(chapterId) && beatsByChapter.ContainsKey(chapterId)) selectedChapter = chapterId; + scroll = Vector2.zero; + directScroll.Reset(); + if (string.IsNullOrEmpty(focusBeatId) || string.IsNullOrEmpty(selectedChapter) + || !beatsByChapter.TryGetValue(selectedChapter, out List list)) return; + int index = list.FindIndex(b => b != null && b.beatId == focusBeatId); + // 한 행 위부터 보이게 — 그 위가 잠긴 행인지 본 행인지도 함께 찍힌다. 넘치는 값은 그리기의 Handle이 clamp한다. + if (index > 0) scroll.y = (index - 1) * (RowHeight + RowGap); + } + + /// 「▶ 영상」을 누른 것과 같은 길. 영상이 시작됐으면 true. + internal bool PressVideoForCapture(string beatId) + { + if (!StoryService.TryGetBeat(beatId, out StoryBeat beat) || beat == null) return false; + PlayBeatVideos(beat); + return videoDirector != null && videoDirector.IsPlaying; + } + + /// 이 행에 「▶ 영상」이 서는가(검수 README용). + internal bool ShowsVideoButtonForCapture(string beatId) + { + if (!StoryService.TryGetBeat(beatId, out StoryBeat beat) || beat == null) return false; + return storyDirector != null && storyDirector.HasSeen(beat.beatId) && HasReplayVideo(beat); + } + + internal bool HasQueuedVideoForCapture => queuedReplayVideoId != null; + // 행마다 OnGUI 패스마다 문자열을 만들면 스크롤뷰가 클립만 하고 루프는 전 행을 돌아 할당이 쌓인다. private readonly Dictionary rowMetaCache = new Dictionary(); private string headerCache; @@ -407,12 +627,20 @@ private static string FirstLine(StoryBeat beat) private static string SpeakerOf(StoryBeat beat) { - if (beat.lines != null && beat.lines.Count > 0 - && beat.lines[0] != null && !string.IsNullOrEmpty(beat.lines[0].speaker)) + // 첫 **인물** 화자 — 지문으로 여는 장면이 목록에 "지문"이라는 사람으로 뜨지 않게 건너뛴다. + if (beat.lines != null) { - return beat.lines[0].speaker; + for (int i = 0; i < beat.lines.Count; i++) + { + StoryLine line = beat.lines[i]; + if (line != null && !string.IsNullOrEmpty(line.speaker) + && !StoryDialogueStaging.IsNarration(line.speaker)) + return line.speaker; + } } - return string.IsNullOrEmpty(beat.speakerNpcId) ? "???" : beat.speakerNpcId; + // 지문뿐인 비트(마을 징후 등)는 그 장면의 인물로 — ID("town_meadow")가 그대로 뜨지 않게 표시명을 쓴다. + return string.IsNullOrEmpty(beat.speakerNpcId) ? "???" + : InsectGame.NPC.NpcDialogueDatabase.StorySpeakerName(beat.speakerNpcId); } /// @@ -470,6 +698,13 @@ private void EnsureStyles() rowMetaStyle = Label(19, FontStyle.Normal, TextAnchor.MiddleLeft, UITheme.Instance.textSecondary); hintStyle = Label(20, FontStyle.Normal, TextAnchor.MiddleLeft, UITheme.Instance.textMuted); lockStyle = Label(23, FontStyle.Bold, TextAnchor.MiddleLeft, UITheme.Instance.textMuted); + noticeStyle = Label(20, FontStyle.Bold, TextAnchor.MiddleLeft, UITheme.Instance.accentCoral); + headSectionStyle = Label(StoryJournalHeadLayout.SectionFont, FontStyle.Bold, TextAnchor.MiddleLeft, + UITheme.Instance.textSecondary); + headBodyStyle = Label(StoryJournalHeadLayout.BodyFont, FontStyle.Normal, TextAnchor.UpperLeft, UITheme.Instance.textPrimary); + headBodyStyle.wordWrap = true; + headGoalStyle = Label(StoryJournalHeadLayout.BodyFont, FontStyle.Bold, TextAnchor.UpperLeft, UITheme.Instance.accentMint); + headGoalStyle.wordWrap = true; } private static GUIStyle Label(int size, FontStyle fs, TextAnchor anchor, Color col) @@ -479,4 +714,180 @@ private static GUIStyle Label(int size, FontStyle fs, TextAnchor anchor, Color c return s; } } + + /// + /// 저널 비트 행의 버튼 자리 — 순수 계산. 「다시 읽기」는 늘 행 오른쪽 끝(영상 유무와 무관하게 같은 열), + /// 「▶ 영상」은 그 왼쪽. 둘 다 터치 높이 하한() 이상이고 행 안에서 세로 가운데다. + /// 글자 폭은 가장 왼쪽 버튼 앞에서 끊는다 — 패널이 좁아져도 글자가 버튼 밑으로 들어가지 않게(넘치면 LabelFit이 줄인다). + /// + public static class StoryJournalRowLayout + { + public const string VideoLabel = "▶ 영상"; + public const float ReplayWidth = 150f; + public const float VideoWidth = 128f; + public const float ButtonGap = 10f; + /// 행 오른쪽 끝과 「다시 읽기」 사이. + public const float EdgePad = 18f; + /// 행 왼쪽 끝(열람 레일 포함)과 글자 사이. + public const float TextLeft = 22f; + /// 글자와 가장 왼쪽 버튼 사이. + public const float TextGap = 10f; + public const float MinTextWidth = 120f; + + public static float ButtonHeight => Mathf.Max(UIScale.MinTouchHeight, 46f); + + public static Rect ReplayButton(Rect row) + { + float h = ButtonHeight; + return new Rect(row.xMax - EdgePad - ReplayWidth, row.y + (row.height - h) * 0.5f, ReplayWidth, h); + } + + public static Rect VideoButton(Rect row) + { + Rect replay = ReplayButton(row); + return new Rect(replay.x - ButtonGap - VideoWidth, replay.y, VideoWidth, replay.height); + } + + public static float TextWidth(Rect row, bool hasVideo) + { + float right = hasVideo ? VideoButton(row).x : ReplayButton(row).x; + return Mathf.Max(MinTextWidth, right - TextGap - (row.x + TextLeft)); + } + } + + /// + /// 저널에서 다시 볼 영상 — 순수 계산. 한 비트에 대사 앞 영상(introVideoId)과 뒤 영상(videoId)이 + /// 함께 있으면 이야기 순서(앞 → 뒤)로 둘 다, 하나면 그것 하나. 라이브러리가 모르는 ID는 뺀다(버튼을 눌러도 안 나오는 버튼을 세우지 않는다). + /// 「둘 다」는 지금 데이터에선 없다 — story_lint 검사 13이 한 비트의 두 영상을 금지한다(규칙이 바뀔 때를 위한 방어). + /// 본 비트인지는 부르는 쪽이 거른다 — 저널은 본 행에서만 이 값을 묻는다. + /// + public static class StoryJournalVideo + { + public readonly struct Pair + { + /// 먼저 틀 영상. 없으면 null. + public readonly string First; + /// 앞 영상이 닫힌 뒤 이어 틀 영상. 없으면 null. + public readonly string Then; + + public Pair(string first, string then) + { + First = first; + Then = then; + } + + public bool HasAny => First != null; + } + + public static Pair For(StoryBeat beat, System.Func isKnown) + { + if (beat == null) return default; + string intro = Usable(beat.introVideoId, isKnown); + string outro = Usable(beat.videoId, isKnown); + if (intro != null && outro != null && intro != outro) return new Pair(intro, outro); + return new Pair(intro ?? outro, null); + } + + private static string Usable(string videoId, System.Func isKnown) + { + if (string.IsNullOrWhiteSpace(videoId)) return null; + return isKnown == null || isKnown(videoId) ? videoId : null; + } + } + + /// + /// 저널 장 머리(지난 이야기·이번 목표)의 자리 — 순수 계산. 비트 목록 영역() 맨 위에 블록을 놓고 목록은 그 아래를 쓴다. + /// 블록은 목록 영역 높이의 까지만 — 넘치면 본문 줄을 같은 비율로 줄인다(그리기의 LabelFit이 글자를 줄인다). + /// 목록이 한 줄도 안 보이게 되는 일이 없다. + /// + public static class StoryJournalHeadLayout + { + public const int SectionFont = 20; + public const int BodyFont = 22; + public const float Pad = 14f; + /// 블록이 차지할 수 있는 목록 영역 높이의 몫. + public const float MaxShare = 0.5f; + public const float DividerHeight = 2f; + + public static float LineHeight(int fontSize) => Mathf.Ceil(fontSize * 1.35f); + public static float TextWidth(float areaWidth) => Mathf.Max(1f, areaWidth - Pad * 2f); + public static float BodyHeight(float measured) => Mathf.Max(LineHeight(BodyFont), Mathf.Ceil(measured)); + + /// 그릴 내용이 있는가 — 지난 이야기에 비지 않은 줄이 있거나 목표가 있다. + public static bool HasContent(StoryChapter chapter) + { + if (chapter == null) return false; + if (!string.IsNullOrWhiteSpace(chapter.goal)) return true; + if (chapter.recap == null) return false; + for (int i = 0; i < chapter.recap.Count; i++) + if (!string.IsNullOrWhiteSpace(chapter.recap[i])) return true; + return false; + } + + public struct Plan + { + public Rect Block; + public Rect RecapHeader; + public Rect[] Recap; + public Rect Divider; + public Rect Goal; + public bool HasGoal; + /// 블록 아래 남은 목록 영역. + public Rect List; + public bool Squeezed; + } + + public static float FixedHeight(int recapCount, bool hasGoal) + { + float h = Pad; + if (recapCount > 0) + h += LineHeight(SectionFont) + UITheme.Space.XS + UITheme.Space.XS * (recapCount - 1); + if (hasGoal && recapCount > 0) h += UITheme.Space.S + DividerHeight + UITheme.Space.S; + return h + Pad; + } + + public static Plan Layout(Rect area, IReadOnlyList recapHeights, float goalHeight) + { + int n = recapHeights != null ? recapHeights.Count : 0; + bool hasGoal = goalHeight > 0f; + var p = new Plan { HasGoal = hasGoal, Recap = new Rect[n], List = area }; + if (n == 0 && !hasGoal) return p; + + float fixedH = FixedHeight(n, hasGoal); + float body = hasGoal ? goalHeight : 0f; + for (int i = 0; i < n; i++) body += Mathf.Max(0f, recapHeights[i]); + float cap = Mathf.Max(1f, area.height * MaxShare); + float k = 1f; + if (fixedH + body > cap && body > 0f) k = Mathf.Clamp01((cap - fixedH) / body); + float blockH = Mathf.Min(cap, fixedH + body * k); + p.Squeezed = k < 1f; + p.Block = new Rect(area.x, area.y, area.width, blockH); + + float x = area.x + Pad; + float tw = TextWidth(area.width); + float y = area.y + Pad; + if (n > 0) + { + p.RecapHeader = new Rect(x, y, tw, LineHeight(SectionFont)); + y += p.RecapHeader.height + UITheme.Space.XS; + for (int i = 0; i < n; i++) + { + float h = Mathf.Max(1f, Mathf.Max(0f, recapHeights[i]) * k); + p.Recap[i] = new Rect(x, y, tw, h); + y += h + (i < n - 1 ? UITheme.Space.XS : 0f); + } + if (hasGoal) + { + y += UITheme.Space.S; + p.Divider = new Rect(x, y, tw, DividerHeight); + y += DividerHeight + UITheme.Space.S; + } + } + if (hasGoal) p.Goal = new Rect(x, y, tw, Mathf.Max(1f, goalHeight * k)); + + float listTop = p.Block.yMax + UITheme.Space.S; + p.List = new Rect(area.x, listTop, area.width, Mathf.Max(1f, area.yMax - listTop)); + return p; + } + } } diff --git a/Assets/Scripts/UI/StoryRecapVisualCapture.cs b/Assets/Scripts/UI/StoryRecapVisualCapture.cs new file mode 100644 index 0000000..7f6ce93 --- /dev/null +++ b/Assets/Scripts/UI/StoryRecapVisualCapture.cs @@ -0,0 +1,310 @@ +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using InsectGame.Core; +using InsectGame.Story; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 「지난 이야기」 카드·HUD 목표 행 두 줄(할 일 + 왜)·잡담 띠의 "다음 이야기 — 이유"·대사 직후 대결 버튼(「건너뛰고 승부」·「승부!」) + /// — 실제 IMGUI 검수(-battleScenario story의 앞 컷). + /// 대사 무대 컷(StoryDialogueCapture)보다 먼저 돌고, 끝나면 만든 것을 전부 치운다. + /// + /// 저장을 건드리지 않는다 — 를 만들지도 주입하지도 않아 카드 뒤 대사를 닫아도 완료 처리(보상·열람 기록)가 없고, + /// 목표 트래커는 꺼진 오브젝트에 세워 갱신(Refresh)이 돌지 않는다(필드는 리플렉션으로 채운다). 진짜 퀘스트 칩은 로그인 세션이 있어야 그려져서 + /// 칩 자리는 같은 순수 계산()으로 대역을 그리고, 목표 행은 진짜 가 그린다. + /// + public static class StoryRecapVisualCapture + { + private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + /// 카드 검수에 쓰는 장 — Story.json의 장 데이터가 있으면 그걸, 없으면 아래 예시로 만든다. + public const string SampleChapterId = "ch8"; + /// 「승부!」 검수 비트 — 집게 대치(대사가 끝나면 곧바로 간부전). 없으면 아래 예시로 만든다. + public const string DuelBeatId = "ch8_confront"; + + public static IEnumerator Run(string output, Camera camera) + { + var made = new List(); + var notes = new StringBuilder(); + var shots = new List(); + try + { + // ── 1. 「지난 이야기」 카드 → 첫 대사 ── + var uiGo = new GameObject("RecapQA"); + made.Add(uiGo); + NpcDialogueUI ui = uiGo.AddComponent(); + + bool fromData = StoryService.TryGetChapter(SampleChapterId, out StoryChapter chapter) + && StoryRecapLayout.ShouldShow(chapter, false); + if (!fromData) chapter = SampleChapter(); + StoryBeat beat = null; + bool realBeat = !string.IsNullOrEmpty(chapter.openingBeatId) + && StoryService.TryGetBeat(chapter.openingBeatId, out beat) + && beat != null && beat.lines != null && beat.lines.Count > 0; + if (!realBeat) beat = SampleBeat(chapter.openingBeatId); + notes.AppendLine($"recap chapter: {(fromData ? "Story.json" : "fixture sample")} {chapter.chapterId} \"{chapter.title}\""); + notes.AppendLine($"opening beat: {(realBeat ? "Story.json" : "fixture sample")} {beat.beatId}"); + + // 진짜 발화 경로(ShowStory, 다시보기 아님)로 연다 — 장 데이터에 이 비트가 여는 장이 있으면 카드가 스스로 선다. + ui.ShowStory(beat); + bool cardByRealPath = ui.IsShowingRecap; + if (!cardByRealPath) ShowRecapCard(ui, chapter); // 데이터가 아직 없으면 카드만 주입한다 + notes.AppendLine($"card opened by: {(cardByRealPath ? "real ShowStory path (TryGetChapterOpenedBy)" : "injected (no chapter data for this beat)")}"); + yield return Wait(1.0f); // 패널 페이드 + 입력 지연(0.4초)이 지나 안내 글자가 진해진 뒤 + yield return Capture(output, "recap-card", shots); + + // 카드를 넘긴다(탭과 같은 경로) → 첫 대사가 타자 효과로 시작한다. + typeof(NpcDialogueUI).GetMethod("EndRecap", Private)?.Invoke(ui, null); + typeof(NpcDialogueUI).GetField("lineRevealAll", Private)?.SetValue(ui, true); + yield return Wait(0.6f); + yield return Capture(output, "recap-first-line", shots); + ForceClose(ui); + + // 줄이 많고 긴 장 — 판(대사 상자 위)을 넘지 않고 글자를 줄여 맞추는지. + ui.ShowStory(beat); + ShowRecapCard(ui, LongChapter()); + yield return Wait(1.0f); + yield return Capture(output, "recap-card-long", shots); + ForceClose(ui); + + // ── 2. HUD 목표 행 — 할 일 + 왜 두 줄 ── + var trackerGo = new GameObject("ObjectiveWhyQA"); + made.Add(trackerGo); + trackerGo.SetActive(false); // Refresh(진짜 목표 고르기)가 돌지 않게 — 필드만 채운다 + StoryObjectiveTracker tracker = trackerGo.AddComponent(); + var playerGo = new GameObject("ObjectiveWhyQAPlayer"); + made.Add(playerGo); + SetField(tracker, "hasObjective", true); + SetField(tracker, "label", "모래언덕으로"); + SetField(tracker, "hasWorldTarget", true); + SetField(tracker, "targetPosition", new Vector3(0f, 0f, 42f)); + SetField(tracker, "playerTransform", playerGo.transform); + bool whySet = SetWhy(tracker, "상자에 갇힌 곤충이 있대"); + notes.AppendLine($"tracker Why injected: {whySet} (Why = \"{tracker.Why}\")"); + + var questGo = new GameObject("QuestRowQA"); + made.Add(questGo); + questGo.SetActive(false); // 진짜 OnGUI(로그인 세션이 필요한 칩)는 돌지 않게 — 행만 대역이 부른다 + TutorialQuestUI questUi = questGo.AddComponent(); + questUi.AutoWire(tracker); + var standInGo = new GameObject("QuestChipStandInQA"); + made.Add(standInGo); + standInGo.AddComponent().Bind(questUi, tracker); + yield return Wait(0.6f); + yield return Capture(output, "hud-objective-why", shots); + + // 비교 — 이유가 비면 예전과 같은 한 줄 높이. + SetWhy(tracker, string.Empty); + yield return Wait(0.4f); + yield return Capture(output, "hud-objective-oneline", shots); + UnityEngine.Object.Destroy(standInGo); + + // ── 3. 잡담 띠 — "다음 이야기 · 할 일 — 이유" ── + SetWhy(tracker, "상자에 갇힌 곤충이 있대"); + var npcGo = new GameObject("AmbientWhyQA"); + made.Add(npcGo); + var npc = npcGo.AddComponent(); + npc.Initialize(new NpcSpawnAnchor { regionId = "meadow" }, "qa_resident", "마을 주민", 8173); + ui.AutoWire(tracker); + ui.Show(npc); + yield return Wait(0.8f); + yield return Capture(output, "ambient-next-why", shots); + ForceClose(ui); + + // ── 4. 대사 직후 대결 — 첫 줄의 「건너뛰고 승부」, 마지막 줄의 「승부!」 ── + // 진짜 발화 경로(ShowStory)로 연다. 대결 대기열(StoryDuelLauncher)은 세우지 않는다 — 비트 자신의 duelAfter만으로 버튼이 바뀌어야 한다. + bool realDuelBeat = StoryService.TryGetBeat(DuelBeatId, out StoryBeat duelBeat) + && duelBeat != null && duelBeat.lines != null && duelBeat.lines.Count > 1 + && !string.IsNullOrEmpty(duelBeat.duelAfter); + if (!realDuelBeat) duelBeat = SampleDuelBeat(); + notes.AppendLine($"duel beat: {(realDuelBeat ? "Story.json" : "fixture sample")} {duelBeat.beatId} → duelAfter {duelBeat.duelAfter}"); + ui.ShowStory(duelBeat); + if (ui.IsShowingRecap) typeof(NpcDialogueUI).GetMethod("EndRecap", Private)?.Invoke(ui, null); + typeof(NpcDialogueUI).GetField("lineRevealAll", Private)?.SetValue(ui, true); + yield return Wait(0.8f); + yield return Capture(output, "duel-skip", shots); + + typeof(NpcDialogueUI).GetMethod("BeginStoryLine", Private)?.Invoke(ui, new object[] { duelBeat.lines.Count - 1 }); + typeof(NpcDialogueUI).GetField("lineRevealAll", Private)?.SetValue(ui, true); + yield return Wait(0.6f); + yield return Capture(output, "duel-button", shots); + ForceClose(ui); + } + finally + { + foreach (GameObject go in made) + if (go != null) UnityEngine.Object.Destroy(go); + } + yield return null; // Destroy는 프레임 끝에 — 다음 장면(대사 무대)이 이 화면들 없이 시작하게 + + File.WriteAllText(Path.Combine(output, "recap-README.txt"), + "Actual standalone IMGUI. 「지난 이야기」 card (NpcDialogueUI), HUD objective row with the why line (real TutorialQuestUI row, " + + "quest chip is a stand-in from the same QuestChipLayout), ambient 'next story — why', duel-after dialogue buttons " + + "(duel-skip: first line [건너뛰고 승부], duel-button: last line 「승부!」). No StoryDirector/save services.\n" + + notes + "shots: " + string.Join(", ", shots) + "\n"); + } + + /// 장 데이터가 아직 없을 때 쓰는 예시(작업 지시의 ch8). + private static StoryChapter SampleChapter() => new StoryChapter + { + chapterId = SampleChapterId, + title = "8장 · 모래언덕", + openingBeatId = "ch8_arrive", + recap = new List + { + "유적 아래에서 움직이는 빈칸을 봤다.", + "그건 이름을 훔치는 그림자였다.", + "그림자를 쫓아 텅 빈 들을 지나왔다.", + }, + goal = "모래언덕의 상자 창고를 찾아라", + }; + + /// 판 넘침 검수 — 줄이 많고 길다(세로 화면에서 줄마다 두세 줄로 접힌다). + private static StoryChapter LongChapter() => new StoryChapter + { + chapterId = "qa_long", + title = "12장 · 이름 없는 자리 (긴 제목 검수용 — 아주 아주 긴 장 제목)", + openingBeatId = "qa_long_open", + recap = new List + { + "잿불 골짜기에서 먹이 장부의 마지막 쪽을 찢었다. 불씨가 하늘로 날아올랐다.", + "우듬지 꼭대기에서 라온이 다시 돌아왔다. 이번엔 우리 편이었다.", + "세라가 지도 뒷면에 적힌 이름 없는 자리의 위치를 읽어 냈다.", + "명부회 관장이 모든 곤충의 이름을 지우려 한다는 걸 알게 됐다.", + "어르신이 처음으로 옛날 챔피언 이야기를 들려줬다.", + "모두와 함께 마지막 길을 나섰다. 돌아올 때는 이름을 되찾아 오기로 약속했다.", + }, + goal = "이름 없는 자리 맨 안쪽까지 가서 관장을 만나고, 지워진 이름을 하나도 빠짐없이 되찾아라", + }; + + private static StoryBeat SampleBeat(string beatId) => new StoryBeat + { + beatId = string.IsNullOrEmpty(beatId) ? "qa_recap_open" : beatId, + chapterId = SampleChapterId, + speakerNpcId = "ruins_scholar", + lines = new List + { + new StoryLine { speaker = "세라", text = "여기가 모래언덕이야. 바람 냄새가 달라." }, + new StoryLine { speaker = "라온", text = "상자 창고는 저 언덕 너머래!" }, + }, + }; + + /// 대치 비트가 데이터에 없을 때 — 마지막 줄 뒤 곧바로 간부전. + private static StoryBeat SampleDuelBeat() => new StoryBeat + { + beatId = "qa_duel_after", + chapterId = SampleChapterId, + speakerNpcId = "ledger_grip", + duelAfter = "ledger_grip", + lines = new List + { + new StoryLine { speaker = "집게", text = "상자는 우리 거다. 돌아가라." }, + new StoryLine { speaker = "라온", text = "안 돼! 그 안에 곤충이 있잖아!" }, + new StoryLine { speaker = "집게", text = "상자를 열고 싶으면 실력으로 와라.", fx = "dark" }, + }, + }; + + /// 선택지가 붙은 비트여도 닫는다 — 선택 전 닫기를 삼키는 가드(choiceResolved)를 지나간다(OnDisable과 같은 길). + private static void ForceClose(NpcDialogueUI ui) + { + SetField(ui, "choiceResolved", true); + ui.CloseModal(); + } + + /// 장 데이터가 이 비트를 모를 때 카드만 세운다(진짜 경로는 NpcDialogueUI.OpenStory). + private static void ShowRecapCard(NpcDialogueUI ui, StoryChapter chapter) + { + SetField(ui, "recapChapter", chapter); + SetField(ui, "recapShowing", true); + SetField(ui, "recapShownAt", Time.unscaledTime); + } + + private static void SetField(object target, string name, object value) + { + FieldInfo f = target.GetType().GetField(name, Private); + if (f == null) throw new MissingFieldException(target.GetType().Name, name); + f.SetValue(target, value); + } + + /// + /// 트래커의 「왜」를 채운다 — 쓰기 가능한 Why 속성이 있으면 그걸, 없으면 이름에 why가 든 string 필드. + /// 트래커 쪽 구현(game-designer)이 바뀌어도 검수가 통째로 서지 않게 이름을 하나로 못 박지 않는다. + /// + private static bool SetWhy(StoryObjectiveTracker tracker, string value) + { + Type type = typeof(StoryObjectiveTracker); + PropertyInfo prop = type.GetProperty("Why", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + MethodInfo setter = prop != null ? prop.GetSetMethod(true) : null; + if (setter != null) setter.Invoke(tracker, new object[] { value }); + else + { + foreach (FieldInfo f in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + if (f.FieldType != typeof(string) || f.Name.IndexOf("why", StringComparison.OrdinalIgnoreCase) < 0) continue; + f.SetValue(tracker, value); + break; + } + } + return string.Equals(tracker.Why ?? string.Empty, value ?? string.Empty, StringComparison.Ordinal); + } + + private static IEnumerator Wait(float seconds) + { + float until = Time.realtimeSinceStartup + seconds; + while (Time.realtimeSinceStartup < until) yield return null; + } + + private static IEnumerator Capture(string output, string name, List shots) + { + yield return new WaitForEndOfFrame(); + Texture2D shot = ScreenCapture.CaptureScreenshotAsTexture(); + File.WriteAllBytes(Path.Combine(output, name + ".png"), shot.EncodeToPNG()); + UnityEngine.Object.Destroy(shot); + shots.Add(name); + } + } + + /// + /// 검수 전용 — 퀘스트 칩 대역(진짜 칩은 로그인 세션이 있어야 그려진다) + 그 아래 진짜 목표 행. + /// 칩 자리는 실제와 같은 순수 계산()이고, 목표 행 높이는 「왜」 유무를 따른다. + /// + internal class RecapQaQuestStack : MonoBehaviour + { + private TutorialQuestUI questUi; + private StoryObjectiveTracker tracker; + private GUIStyle chipStyle; + + public void Bind(TutorialQuestUI ui, StoryObjectiveTracker objective) + { + questUi = ui; + tracker = objective; + } + + private void OnGUI() + { + if (questUi == null || tracker == null) return; + UIScale.Begin(); + if (chipStyle == null) + { + chipStyle = new GUIStyle(GUI.skin.label) + { fontSize = QuestChipLayout.TitleFontSize, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + chipStyle.normal.textColor = UITheme.Instance.accentAmber; + } + HudFrame f = HudFrame.Current; + bool hasWhy = !string.IsNullOrEmpty(tracker.Why); + Rect chip = QuestChipLayout.ChipRect(f, QuestChipLayout.ExpandedHeight, QuestChipLayout.RowHeightFor(hasWhy)); + UISurface.HudCard(chip); + UIHelper.LabelFit(new Rect(chip.x + UITheme.Space.S, chip.y + UITheme.Space.S, chip.width - UITheme.Space.S * 2f, + Mathf.Ceil(QuestChipLayout.TitleFontSize * 1.35f)), "★ 퀘스트 칩 자리(검수용)", chipStyle); + questUi.DrawObjectiveRowForCapture(chip, QuestChipLayout.StackWidth(f)); + UIScale.End(); + } + } +} +#endif diff --git a/Assets/Scripts/UI/StoryRecapVisualCapture.cs.meta b/Assets/Scripts/UI/StoryRecapVisualCapture.cs.meta new file mode 100644 index 0000000..f0927c4 --- /dev/null +++ b/Assets/Scripts/UI/StoryRecapVisualCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a224b76dc5e686e4cb5a87b5b6597940 \ No newline at end of file diff --git a/Assets/Scripts/UI/StoryVideoScreenLayout.cs b/Assets/Scripts/UI/StoryVideoScreenLayout.cs new file mode 100644 index 0000000..df0af2a --- /dev/null +++ b/Assets/Scripts/UI/StoryVideoScreenLayout.cs @@ -0,0 +1,67 @@ +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 스토리 영상 화면(StoryVideoDirector)의 자막 띠·「건너뛰기」 자리 — 순수 계산. 가상 좌표()를 받는다. + /// + /// 아이가 읽는 자막이라 글자는 36pt 굵게, 두 줄까지 들어가는 칸, 진한 띠(0.68)다. 「건너뛰기」는 가로 화면에서는 오른쪽 아래, + /// 세로 화면에서는 오른쪽 위다 — 세로에서는 자막 띠가 화면 폭을 거의 다 써서 아래에 두면 띠와 겹친다(예전 배치는 세로 720×1280에서 가로 224px·세로 2px가 겹쳤다). + /// 가로에서도 노치 인셋이 커서 띠와 버튼이 맞닿으면 띠를 버튼 위로 올린다. 그림은 QA 빌드 -battleScenario story-video. + /// + public static class StoryVideoScreenLayout + { + public const int SubtitleFontSize = 36; + public const int SkipFontSize = 28; + /// 자막 띠의 검정 불투명도(자막 페이드가 이 위에 곱해진다). + public const float SubtitleBandAlpha = 0.68f; + /// 자막 칸에 들어가야 하는 줄 수. + public const int SubtitleLines = 2; + public const float SubtitleMaxWidth = 1200f; + /// 세이프 에어리어 안쪽 좌우 여백(띠가 화면 끝에 붙지 않게). + public const float SubtitleSideMargin = 40f; + public const float SubtitlePadX = 28f; + public const float SubtitlePadY = 12f; + public const float SkipWidth = 240f; + public const float SkipHeight = 56f; + /// 세이프 에어리어 오른쪽 끝과 「건너뛰기」 사이. + public const float SkipInset = 24f; + /// 띠를 버튼 위로 올릴 때 둘 사이. + public const float Gap = 16f; + + /// 한글 줄높이 — literal_fit_lint·DexScreenUI.LineH와 같은 식(fontSize × 1.35). + public static float LineHeight(int fontSize) => Mathf.Ceil(fontSize * 1.35f); + + /// 자막 글자 칸 높이 — 두 줄 + 라벨 안쪽 여백 몫. + public static float SubtitleTextHeight => LineHeight(SubtitleFontSize) * SubtitleLines + 4f; + + public static float SubtitleBandHeight => SubtitleTextHeight + SubtitlePadY * 2f; + + public static Rect Skip(HudFrame f) + { + float x = f.Width - f.SafeRight - SkipInset - SkipWidth; + float y = f.Portrait ? f.ContentTop : f.ContentBottom - SkipHeight; + return new Rect(x, y, SkipWidth, SkipHeight); + } + + /// 자막 띠 — 세이프 에어리어 가운데, 콘텐츠 아래 끝에 붙는다. 「건너뛰기」와 겹치면 그 위로 올린다. + public static Rect SubtitleBand(HudFrame f) + { + float room = Mathf.Max(1f, f.Width - f.SafeLeft - f.SafeRight); + float w = Mathf.Max(1f, Mathf.Min(SubtitleMaxWidth, room - SubtitleSideMargin * 2f)); + float x = f.SafeLeft + (room - w) * 0.5f; + float h = SubtitleBandHeight; + var band = new Rect(x, f.ContentBottom - h, w, h); + Rect skip = Skip(f); + if (band.Overlaps(skip)) band.y = skip.y - Gap - h; + return band; + } + + /// 띠 안의 글자 칸. + public static Rect SubtitleText(Rect band) => new Rect( + band.x + SubtitlePadX, + band.y + SubtitlePadY, + Mathf.Max(1f, band.width - SubtitlePadX * 2f), + Mathf.Max(1f, band.height - SubtitlePadY * 2f)); + } +} diff --git a/Assets/Scripts/UI/StoryVideoScreenLayout.cs.meta b/Assets/Scripts/UI/StoryVideoScreenLayout.cs.meta new file mode 100644 index 0000000..7c07115 --- /dev/null +++ b/Assets/Scripts/UI/StoryVideoScreenLayout.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 637c571087aaa8e468f01247260b490a \ No newline at end of file diff --git a/Assets/Scripts/UI/StoryVideoVisualCapture.cs b/Assets/Scripts/UI/StoryVideoVisualCapture.cs new file mode 100644 index 0000000..9677e40 --- /dev/null +++ b/Assets/Scripts/UI/StoryVideoVisualCapture.cs @@ -0,0 +1,619 @@ +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Reflection; +using System.Text; +using InsectGame.Core; +using InsectGame.Story; +using UnityEngine; +using UnityEngine.Video; + +namespace InsectGame.UI +{ + /// + /// 스토리 영상 촬영 fixture(-battleScenario story-video) — 진짜 가 진짜 디코더로 + /// 영상을 틀고, 자막·「건너뛰기」가 실제로 그려진 화면을 찍는다(IMGUI라 배치 캡처에는 안 잡힌다 — rules/testing.md 「한계 셋」). + /// + /// 편마다 첫 자막 0.5초 앞 한 장 + 자막마다 그 가운데 한 장. README에 실제로 재생됐는지·첫 프레임까지 걸린 시간·실제 재생 길이를 적는다. + /// -videoOnly ch6_wall,ch7_fence로 일부만(앞의 vid_는 붙여도 빼도 된다). + /// + /// 앞머리에 저널 한 벌(00-journal-*): 본 비트에 「▶ 영상」이 선 화면 → 눌러서 영상이 저널을 덮은 화면 → ESC(PlayerMovement와 같은 + /// )로 영상만 닫히고 저널이 맨 위로 돌아온 화면. 그 판정도 README에 적는다. + /// + /// 저장을 건드리지 않는다 — 영상 지휘자는 AutoWire하지 않는다(스토리 구독·조작 잠금·카메라 없음. Awake가 플레이어·소리를 만들고, + /// AutoWire의 세 참조는 전부 null 가드라 없어도 가 그대로 돈다). + /// 저널의 열람 기록은 꺼진 오브젝트의 에 메모리로만 넣는다 — Awake(디스크 읽기)·Start(구독·발화)가 돌지 않고 + /// 저장 경로(Save)는 발화·완료에서만 불리는데 그 둘이 없다. + /// + /// 종료 코드: 0 전부 재생 · 3 하나라도 재생 실패(라이브러리에 없음·파일 없음·디코더 오류·준비 초과·도중 멈춤) · 5 영상은 됐지만 저널 ESC 판정 실패 · + /// 2 찍을 영상이 없음. 전부 재생하면 15편 × 10~15초라 3분을 넘는다 — 검수 하네스의 감시 시계(기본 120초)를 그만큼 늘린다. + /// + public static class StoryVideoVisualCapture + { + private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + public const string OnlyArg = "-videoOnly"; + + /// + /// 이야기 순서. 라이브러리 상수(리플렉션)와 Story.json이 부르는 다른 ID는 뒤에 붙는다 — 새 영상을 여기 안 적어도 찍힌다. + /// 여기 적었는데 라이브러리에 없으면 「라이브러리에 없음」으로 실패한다(아직 안 넣은 영상이 조용히 빠지지 않게). + /// + private static readonly string[] StoryOrder = + { + "vid_ch1_prologue", "vid_ch2_watchers", "vid_ch3_scholar", "vid_ch4_crates", "vid_ch5_summit", + "vid_ch6_wall", "vid_ch7_fence", "vid_ch8_vault", "vid_ch9_archive", "vid_ch10_kiln", + "vid_ch11_crown", "vid_ch12_ledger", "vid_fin_shadow", "vid_fin_return", "vid_fin_epilogue", + }; + + /// 첫 자막보다 이만큼 앞에서 한 장 — 자막 없는 화면(그림만 + 건너뛰기). + private const float PreCueLead = 0.5f; + /// 준비를 기다리는 상한. 지휘자 자신의 상한(5초)보다 길다 — 지휘자가 먼저 포기하면 그걸 그대로 적는다. + private const float PrepareGiveUp = 10f; + /// 끝을 기다리는 여유. 지휘자의 종료 안전망(저작 길이 + 3초) 너머까지. + private const float EndGrace = 6f; + /// 편 사이 쉼 — 앞 편의 Stop과 다음 편의 Play가 같은 프레임에 붙지 않게. + private const float GapSeconds = 0.4f; + + private sealed class Result + { + public string Id; + public bool Known; + public string File = "-"; + public bool FileExists; + public float Expected; + public int CueCount; + public bool ReplayAccepted; + public bool Prepared; + public float PrepareSeconds = -1f; + public float FirstFrameSeconds = -1f; + public float PlayedSeconds = -1f; + public double FileLength = -1d; + public uint Width, Height; + public int ShotsPlanned; + public int ShotsTaken; + public bool QaTimeout; + /// 재생기가 공개로 알린 상태가 틀렸다 — 다른 영상 ID를 틀었거나, 다시보기가 대사 앞 영상(콜백 있음)으로 돌았다. + public bool WrongState; + public string Ended = "-"; + public string Note = ""; + public bool Played => Known && Prepared && ShotsPlanned > 0 && ShotsTaken == ShotsPlanned && !QaTimeout && !WrongState; + } + + private sealed class JournalReport + { + public bool Ran; + public string Skipped = ""; + public string BeatId = "-"; + public string Videos = "-"; + public bool ButtonShown; + public bool VideoStarted; + public bool VideoOnTop; + public bool JournalOpenUnder; + public bool SecondVideoChained; + public bool VideoClosed; + public bool JournalStillOpen; + public bool JournalBackOnTop; + public readonly List Shots = new List(); + public bool Passed => Ran && ButtonShown && VideoStarted && VideoOnTop && JournalOpenUnder + && VideoClosed && JournalStillOpen && JournalBackOnTop; + } + + private struct Shot + { + public string Name; + public float At; + } + + public static IEnumerator Run(string output, Camera camera) + { + var made = new List(); + var results = new List(); + var journal = new JournalReport(); + var notes = new StringBuilder(); + + List selected = Select(notes, out bool filtered); + if (selected.Count == 0) + { + File.WriteAllText(Path.Combine(output, "README.txt"), "No story video selected.\n" + notes.ToString()); + Application.Quit(2); + yield break; + } + + // 영상 뒤 월드는 비어 있다 — 카메라는 검은 바탕만. 소리는 실제처럼 들리게 리스너를 세운다(검수 장면엔 없다). + CameraFollower follower = camera != null ? camera.GetComponent() : null; + if (follower != null) follower.enabled = false; + if (camera != null) + { + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = Color.black; + if (UnityEngine.Object.FindFirstObjectByType() == null) camera.gameObject.AddComponent(); + } + + var directorGo = new GameObject("StoryVideoQADirector"); + made.Add(directorGo); + StoryVideoDirector director = directorGo.AddComponent(); + VideoPlayer player = directorGo.GetComponent(); + + ExtendWatchdog(selected, notes); + + yield return JournalShots(output, director, player, journal, made); + + for (int i = 0; i < selected.Count; i++) + { + var r = new Result { Id = selected[i] }; + results.Add(r); + yield return PlayOne(output, i, r, director, player); + Debug.Log("[StoryVideoQA] " + Line(r)); + if (director.IsPlaying) director.Stop(); + yield return Wait(GapSeconds); + } + + foreach (GameObject go in made) + if (go != null) UnityEngine.Object.Destroy(go); + + int played = 0; + foreach (Result r in results) if (r.Played) played++; + bool videosOk = played == results.Count; + bool journalOk = !journal.Ran || journal.Passed; + int exit = !videosOk ? 3 : !journalOk ? 5 : 0; + + WriteReport(output, results, journal, notes, filtered, played, exit); + Application.Quit(exit); + } + + // ── 고르기 ── + + private static List Select(StringBuilder notes, out bool filtered) + { + List all = CandidateIds(); + string[] args = Environment.GetCommandLineArgs(); + int a = Array.IndexOf(args, OnlyArg); + filtered = a >= 0 && a + 1 < args.Length; + if (!filtered) return all; + + var picked = new List(); + foreach (string raw in args[a + 1].Split(',')) + { + string token = raw.Trim(); + if (token.Length == 0) continue; + string id = all.Find(x => x == token || x == "vid_" + token); + if (id == null) + { + // 모르는 ID도 그대로 넣는다 — 「라이브러리에 없음」으로 실패해 오타가 조용히 빠지지 않는다. + id = token.StartsWith("vid_", StringComparison.Ordinal) ? token : "vid_" + token; + notes.AppendLine($"-videoOnly '{token}' is not a known video id (tried {id})."); + } + if (!picked.Contains(id)) picked.Add(id); + } + return picked; + } + + private static List CandidateIds() + { + var ids = new List(); + void Add(string id) + { + if (!string.IsNullOrWhiteSpace(id) && !ids.Contains(id)) ids.Add(id); + } + foreach (string id in StoryOrder) Add(id); + foreach (FieldInfo f in typeof(StoryVideoLibrary).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (!f.IsLiteral || f.FieldType != typeof(string)) continue; + string value = f.GetRawConstantValue() as string; + if (value != null && value.StartsWith("vid_", StringComparison.Ordinal)) Add(value); + } + foreach (StoryBeat beat in StoryService.AllBeats()) + { + if (beat == null) continue; + Add(beat.introVideoId); + Add(beat.videoId); + } + return ids; + } + + // ── 한 편 ── + + private static IEnumerator PlayOne(string output, int index, Result r, StoryVideoDirector director, VideoPlayer player) + { + if (!StoryVideoLibrary.TryGet(r.Id, out StoryVideoDefinition def) || def == null) + { + r.Note = "not in StoryVideoLibrary"; + yield break; + } + r.Known = true; + r.File = def.fileName; + r.Expected = def.expectedDuration; + r.CueCount = def.cues != null ? def.cues.Length : 0; + // 데스크톱 QA 빌드라 파일 유무를 미리 볼 수 있다(지휘자도 같은 경로를 본다). + r.FileExists = File.Exists(Application.streamingAssetsPath + "/Video/" + def.fileName); + + List shots = ShotsFor(def); + r.ShotsPlanned = shots.Count; + string prefix = $"{index + 1:D2}-{Short(r.Id)}"; + + float t0 = Time.realtimeSinceStartup; + if (!director.PlayReplay(r.Id)) + { + // 데스크톱은 지휘자가 파일 유무를 먼저 보고 시작도 않고 끝낸다 — 그때도 false다. + r.Note = r.FileExists ? "PlayReplay returned false" : "file missing in StreamingAssets/Video (PlayReplay returned false)"; + yield break; + } + r.ReplayAccepted = true; + // 공개 상태로 맞춰 본다 — 다시보기는 그 영상을, 콜백 없이(대사를 열지 않고) 틀어야 한다. + if (director.CurrentVideoId != r.Id || director.IsPlayingPrelude) + { + r.WrongState = true; + r.Note = $"state: CurrentVideoId={director.CurrentVideoId ?? "null"} IsPlayingPrelude={director.IsPlayingPrelude}. "; + } + + while (director.IsPlaying && !IsPrepared(director, player) && Time.realtimeSinceStartup - t0 < PrepareGiveUp) + { + TrackFirstFrame(r, player, t0); + yield return null; + } + float waited = Time.realtimeSinceStartup - t0; + if (!director.IsPlaying) + { + r.Note += r.FileExists + ? $"director stopped before the first frame after {waited:F1}s (decoder error or its own prepare timeout)" + : "file missing in StreamingAssets/Video — director skipped it"; + yield break; + } + if (!IsPrepared(director, player)) + { + r.Note += $"QA gave up waiting for prepare after {waited:F1}s"; + director.Stop(); + yield break; + } + r.Prepared = true; + r.PrepareSeconds = waited; + float playStart = Time.realtimeSinceStartup; + r.FileLength = player != null ? player.length : -1d; + if (player != null) + { + r.Width = player.width; + r.Height = player.height; + } + + for (int s = 0; s < shots.Count; s++) + { + while (director.IsPlaying && Clock(director, player) < shots[s].At) + { + TrackFirstFrame(r, player, t0); + yield return null; + } + if (!director.IsPlaying) + { + r.Note += $"stopped at {Time.realtimeSinceStartup - playStart:F1}s before shot '{shots[s].Name}' (t={shots[s].At:F1}s)"; + break; + } + yield return Capture(output, $"{prefix}-{s}-{shots[s].Name}"); + r.ShotsTaken++; + } + + float giveUp = Time.realtimeSinceStartup + Mathf.Max(0f, r.Expected - Clock(director, player)) + EndGrace; + while (director.IsPlaying && Time.realtimeSinceStartup < giveUp) + { + TrackFirstFrame(r, player, t0); + yield return null; + } + r.PlayedSeconds = Time.realtimeSinceStartup - playStart; + if (director.IsPlaying) + { + r.QaTimeout = true; + r.Ended = "QA timeout — the director never stopped"; + director.Stop(); + } + else r.Ended = ClassifyEnd(r); + } + + /// 첫 자막 0.5초 앞 + 자막마다 가운데. 자막이 없는 편은 0.5초와 한가운데. + private static List ShotsFor(StoryVideoDefinition def) + { + var shots = new List(); + if (def.cues != null && def.cues.Length > 0) + { + var cues = new List(def.cues); + cues.Sort((x, y) => x.at.CompareTo(y.at)); + shots.Add(new Shot { Name = "pre", At = Mathf.Max(0f, cues[0].at - PreCueLead) }); + for (int i = 0; i < cues.Count; i++) + shots.Add(new Shot { Name = "cue" + (i + 1), At = cues[i].at + cues[i].duration * 0.5f }); + } + else + { + shots.Add(new Shot { Name = "start", At = 0.5f }); + shots.Add(new Shot { Name = "mid", At = def.expectedDuration * 0.5f }); + } + return shots; + } + + private static string ClassifyEnd(Result r) + { + if (r.FileLength <= 0d) return "stopped (file length unknown)"; + if (r.PlayedSeconds < r.FileLength - 0.75d) + return r.PlayedSeconds >= r.Expected + 2.5f + ? "cut by the director's overrun guard (file longer than authored length + 3s)" + : "stopped before the end of the file (decoder error?)"; + if (r.PlayedSeconds > r.FileLength + 1.5d) return "ended by the overrun guard (no loopPointReached?)"; + return "end of file"; + } + + // ── 저널 ── + + private static IEnumerator JournalShots(string output, StoryVideoDirector director, VideoPlayer player, + JournalReport report, List made) + { + StoryBeat beat = PickJournalBeat(); + if (beat == null) + { + report.Skipped = "no beat in Story.json has a video known to StoryVideoLibrary"; + yield break; + } + report.BeatId = beat.beatId; + StoryJournalVideo.Pair pair = StoryJournalVideo.For(beat, IsKnown); + report.Videos = pair.Then != null ? pair.First + " → " + pair.Then : pair.First; + + StoryDirector story = MemoryStoryDirector(beat, made, out string why); + if (story == null) + { + report.Skipped = why; + yield break; + } + + var journalGo = new GameObject("StoryVideoQAJournal"); + made.Add(journalGo); + StoryJournalUI ui = journalGo.AddComponent(); + ui.AutoWire(story, null); // 대화창 없음 — 「다시 읽기」는 눌러도 아무것도 안 연다 + ui.AutoWire(director); + ui.OpenForCapture(beat.chapterId, beat.beatId); + report.Ran = true; + + yield return Wait(0.6f); + report.ButtonShown = ui.ShowsVideoButtonForCapture(beat.beatId); + yield return Capture(output, "00-journal-1-video-button"); + report.Shots.Add("00-journal-1-video-button"); + + // 「▶ 영상」과 같은 길로 튼다. + report.VideoStarted = ui.PressVideoForCapture(beat.beatId) + && director.CurrentVideoId == pair.First && !director.IsPlayingPrelude; + if (report.VideoStarted) + { + yield return WaitForClock(director, player, 1.2f, 8f); + report.VideoOnTop = ReferenceEquals(ModalUIRegistry.TopModal, director); + report.JournalOpenUnder = ui.IsOpen; + yield return Capture(output, "00-journal-2-video-over"); + report.Shots.Add("00-journal-2-video-over"); + + // ESC — PlayerMovement가 부르는 것과 같은 한 줄. 최상위 모달 하나만 닫는다. + ModalUIRegistry.HandleEscape(); + yield return null; // 저널 Update가 이어 틀 뒤 영상을 집는 프레임 + yield return null; + if (director.IsPlaying) + { + // 대사 앞·뒤 영상이 둘 다 있는 비트 — 앞 영상을 건너뛰면 뒤 영상이 이어진다. + // 지금 데이터에선 안 생긴다(story_lint 검사 13이 한 비트의 두 영상을 금지) — 저널의 방어 경로를 남겨 둔 만큼 여기도 둔다. + report.SecondVideoChained = true; + yield return WaitForClock(director, player, 1.2f, 8f); + yield return Capture(output, "00-journal-3-second-video"); + report.Shots.Add("00-journal-3-second-video"); + ModalUIRegistry.HandleEscape(); + yield return null; + } + yield return Wait(0.4f); + report.VideoClosed = !director.IsPlaying && !ui.HasQueuedVideoForCapture; + report.JournalStillOpen = ui.IsOpen; + report.JournalBackOnTop = ReferenceEquals(ModalUIRegistry.TopModal, ui); + yield return Capture(output, "00-journal-4-after-esc"); + report.Shots.Add("00-journal-4-after-esc"); + } + + ui.CloseModal(); + if (director.IsPlaying) director.Stop(); + UnityEngine.Object.Destroy(journalGo); + yield return Wait(GapSeconds); + } + + /// 저널에 세울 비트 — 대사 앞·뒤 영상이 둘 다 있는 것 → 앞 영상 → 뒤 영상 순으로, 그 안에서는 이야기 앞쪽. + private static StoryBeat PickJournalBeat() + { + StoryBeat best = null; + int bestKind = int.MaxValue, bestChapter = int.MaxValue, bestOrder = int.MaxValue; + foreach (StoryBeat beat in StoryService.AllBeats()) + { + if (beat == null || string.IsNullOrEmpty(beat.beatId)) continue; + StoryJournalVideo.Pair pair = StoryJournalVideo.For(beat, IsKnown); + if (!pair.HasAny) continue; + bool intro = !string.IsNullOrWhiteSpace(beat.introVideoId) && IsKnown(beat.introVideoId); + int kind = pair.Then != null ? 0 : intro ? 1 : 2; + int chapter = ChapterRank(beat.chapterId); + bool better = kind < bestKind + || kind == bestKind && chapter < bestChapter + || kind == bestKind && chapter == bestChapter && beat.order < bestOrder + || kind == bestKind && chapter == bestChapter && beat.order == bestOrder + && string.CompareOrdinal(beat.beatId, best.beatId) < 0; + if (!better) continue; + best = beat; + bestKind = kind; + bestChapter = chapter; + bestOrder = beat.order; + } + return best; + } + + private static int ChapterRank(string chapterId) + { + if (string.IsNullOrEmpty(chapterId)) return 999; + if (chapterId == "fin") return 100; + if (chapterId.StartsWith("ch", StringComparison.Ordinal) + && int.TryParse(chapterId.Substring(2), NumberStyles.Integer, CultureInfo.InvariantCulture, out int n)) return n; + return 500; + } + + /// + /// 꺼진 오브젝트에 세운 이야기 지휘자 — 그 장에서 이 비트까지(선택지 결과 제외)를 본 것으로 메모리에만 적는다. + /// 꺼져 있어 Awake(디스크 읽기)·Start(구독·즉시 발화)·Update가 돌지 않는다. + /// + private static StoryDirector MemoryStoryDirector(StoryBeat target, List made, out string why) + { + why = ""; + var go = new GameObject("StoryVideoQAStory"); + made.Add(go); + go.SetActive(false); + StoryDirector story = go.AddComponent(); + FieldInfo field = typeof(StoryDirector).GetField("progress", Private); + if (field == null || field.FieldType != typeof(StoryProgressData)) + { + why = "StoryDirector.progress field not found — journal shot skipped"; + return null; + } + var progress = new StoryProgressData { activeChapterId = target.chapterId }; + foreach (StoryBeat beat in StoryService.AllBeats()) + { + if (beat == null || string.IsNullOrEmpty(beat.beatId) || beat.chapterId != target.chapterId) continue; + if (beat.beatId != target.beatId && (beat.order > target.order || story.IsChoiceTarget(beat.beatId))) continue; + progress.seenBeatIds.Add(beat.beatId); + } + if (!progress.seenBeatIds.Contains(target.beatId)) progress.seenBeatIds.Add(target.beatId); + try + { + field.SetValue(story, progress); + } + catch (Exception e) + { + why = "could not inject seen beats: " + e.Message; + return null; + } + return story; + } + + private static bool IsKnown(string videoId) => StoryVideoLibrary.TryGet(videoId, out _); + + // ── 재생 상태 읽기 ── + // 어떤 영상인지·대사 앞 영상인지는 공개 값(CurrentVideoId·IsPlayingPrelude)으로 본다. 준비 완료(prepared)와 자막 시계(elapsed)는 + // 공개 값이 없어 안쪽 필드를 읽는다 — 대사 앞 영상이 들어온 뒤(2026-10-05)에도 같은 이름·형이다. 이름이 바뀌면 VideoPlayer로 물러선다. + + private static readonly FieldInfo PreparedField = typeof(StoryVideoDirector).GetField("prepared", Private); + private static readonly FieldInfo ElapsedField = typeof(StoryVideoDirector).GetField("elapsed", Private); + + private static bool IsPrepared(StoryVideoDirector director, VideoPlayer player) + { + if (PreparedField != null && PreparedField.FieldType == typeof(bool)) return (bool)PreparedField.GetValue(director); + return player != null && player.isPrepared && player.isPlaying; + } + + /// 자막을 고르는 시계 — 지휘자의 elapsed(자막이 이걸 본다). 없으면 영상 시계. + private static float Clock(StoryVideoDirector director, VideoPlayer player) + { + if (ElapsedField != null && ElapsedField.FieldType == typeof(float)) return (float)ElapsedField.GetValue(director); + return player != null ? (float)player.time : 0f; + } + + private static void TrackFirstFrame(Result r, VideoPlayer player, float t0) + { + if (r.FirstFrameSeconds >= 0f || player == null) return; + if (player.isPlaying && player.frame >= 0) r.FirstFrameSeconds = Time.realtimeSinceStartup - t0; + } + + private static IEnumerator WaitForClock(StoryVideoDirector director, VideoPlayer player, float clock, float giveUpSeconds) + { + float giveUp = Time.realtimeSinceStartup + giveUpSeconds; + while (director.IsPlaying && Time.realtimeSinceStartup < giveUp + && (!IsPrepared(director, player) || Clock(director, player) < clock)) + yield return null; + } + + /// + /// 검수 하네스의 감시 시계(BattleVisualCapture.watchdogSeconds, 기본 120초)를 이 장면 길이만큼 늘린다. + /// 못 찾으면 README에 적는다 — 그때는 120초를 넘는 실행이 종료 코드 4로 끊긴다(-videoOnly로 나눠 찍을 것). + /// + private static void ExtendWatchdog(List selected, StringBuilder notes) + { + float budget = 60f; // 저널 한 벌 + 여유 + foreach (string id in selected) + { + float expected = StoryVideoLibrary.TryGet(id, out StoryVideoDefinition def) && def != null ? def.expectedDuration : 0f; + budget += expected + 5f + 3f + EndGrace + GapSeconds; + } + var harness = UnityEngine.Object.FindFirstObjectByType(); + FieldInfo field = typeof(InsectGame.Battle.BattleVisualCapture).GetField("watchdogSeconds", Private); + if (harness == null || field == null || field.FieldType != typeof(float)) + { + notes.AppendLine("Watchdog NOT extended (harness field not found) — runs over 120s end with exit 4; split with -videoOnly."); + return; + } + // 감시는 (지금 − 시작) > 한도로 끊는다. 시작 시각 ≥ 0이라 '지금 + 예산'이면 이 장면이 끝날 때까지 안전하다. + float need = Time.realtimeSinceStartup + budget; + if ((float)field.GetValue(harness) < need) field.SetValue(harness, need); + notes.AppendLine($"Watchdog extended to {need:F0}s for {selected.Count} video(s)."); + } + + // ── 보고 ── + + private static string Short(string id) => id != null && id.StartsWith("vid_", StringComparison.Ordinal) ? id.Substring(4) : id; + + private static string Secs(float s) => s < 0f ? "-" : s.ToString("F2", CultureInfo.InvariantCulture) + "s"; + + private static string Line(Result r) + { + string length = r.FileLength > 0d ? r.FileLength.ToString("F2", CultureInfo.InvariantCulture) + "s" : "-"; + string drift = r.FileLength > 0d && Math.Abs(r.FileLength - r.Expected) > 0.5d ? " [file length differs from authored]" : ""; + return $"{(r.Played ? "PLAYED " : "FAILED ")} {r.Id,-20} file={r.File} exists={(r.FileExists ? "yes" : "no")}" + + $" prepare={Secs(r.PrepareSeconds)} firstFrame={Secs(r.FirstFrameSeconds)}" + + $" played={Secs(r.PlayedSeconds)} (file {length}, authored {r.Expected.ToString("F1", CultureInfo.InvariantCulture)}s){drift}" + + $" size={(r.Width > 0 ? r.Width + "x" + r.Height : "-")} cues={r.CueCount} shots={r.ShotsTaken}/{r.ShotsPlanned}" + + $" ended={r.Ended}" + (string.IsNullOrEmpty(r.Note) ? "" : " note=" + r.Note); + } + + private static void WriteReport(string output, List results, JournalReport journal, StringBuilder notes, + bool filtered, int played, int exit) + { + var sb = new StringBuilder(); + sb.AppendLine($"Story videos: {played}/{results.Count} played for real (exit {exit})."); + sb.AppendLine($"Actual standalone VideoPlayer + IMGUI (subtitles, 「건너뛰기」) via StoryVideoDirector.PlayReplay. " + + $"{Screen.width}x{Screen.height} (mobile layout={UIScale.IsMobileLayout}, portrait={UIScale.IsPortrait})."); + sb.AppendLine(filtered ? "Filter: " + OnlyArg + " (subset only)." : "Filter: none (every known video)."); + sb.AppendLine("Shots per video: NN--0-pre (first cue - 0.5s, no subtitle) and NN--k-cueK (middle of each cue)."); + sb.AppendLine("prepare = PlayReplay → the director starts drawing the video (prepareCompleted). firstFrame = PlayReplay → VideoPlayer.frame >= 0 while playing."); + sb.AppendLine("played = first drawn frame → the director closed the video (wall clock). file = VideoPlayer.length from the decoder; authored = StoryVideoLibrary.expectedDuration."); + sb.AppendLine("In-memory fixture: the video director is not AutoWired (no story subscription, freeze or camera); nothing is saved."); + sb.Append(notes.ToString()); + sb.AppendLine(); + foreach (Result r in results) sb.AppendLine(Line(r)); + sb.AppendLine(); + if (!journal.Ran) + { + sb.AppendLine("Journal: skipped — " + journal.Skipped); + } + else + { + sb.AppendLine($"Journal ▶ 영상 / ESC: {(journal.Passed ? "PASS" : "FAIL")} beat={journal.BeatId} videos={journal.Videos}"); + sb.AppendLine($" button shown on a seen row={journal.ButtonShown} video started={journal.VideoStarted}" + + $" video on top of the modal stack={journal.VideoOnTop} journal still open under it={journal.JournalOpenUnder}"); + sb.AppendLine($" after ESC: video closed={journal.VideoClosed} journal still open={journal.JournalStillOpen}" + + $" journal back on top={journal.JournalBackOnTop} second video chained={journal.SecondVideoChained}"); + sb.AppendLine(" seen beats: that chapter up to this beat (choice outcomes excluded), injected into an inactive StoryDirector."); + sb.AppendLine(" shots: " + string.Join(", ", journal.Shots)); + } + File.WriteAllText(Path.Combine(output, "README.txt"), sb.ToString()); + } + + // ── 공용 ── + + private static IEnumerator Wait(float seconds) + { + float until = Time.realtimeSinceStartup + seconds; + while (Time.realtimeSinceStartup < until) yield return null; + } + + private static IEnumerator Capture(string output, string name) + { + yield return new WaitForEndOfFrame(); + Texture2D shot = ScreenCapture.CaptureScreenshotAsTexture(); + File.WriteAllBytes(Path.Combine(output, name + ".png"), shot.EncodeToPNG()); + UnityEngine.Object.Destroy(shot); + } + } +} +#endif diff --git a/Assets/Scripts/UI/StoryVideoVisualCapture.cs.meta b/Assets/Scripts/UI/StoryVideoVisualCapture.cs.meta new file mode 100644 index 0000000..ba24752 --- /dev/null +++ b/Assets/Scripts/UI/StoryVideoVisualCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1aba087e5e7679a4aa0fe7ed2800a016 \ No newline at end of file diff --git a/Assets/Scripts/UI/TrainingUI.cs b/Assets/Scripts/UI/TrainingUI.cs index 025b4f4..00a18d8 100644 --- a/Assets/Scripts/UI/TrainingUI.cs +++ b/Assets/Scripts/UI/TrainingUI.cs @@ -6,6 +6,17 @@ namespace InsectGame.UI { + /// + /// 훈련소 — 곤충 선택 → 훈련 방법 → (성장 훈련 | 기술 습득 | 기술 장착 | 기술 교체). + /// + /// 「성장 훈련」은 레벨업과 능력치(개체값 HP·공격·방어) 훈련을 한 화면에 둔다. 등급(S~D)은 개체값 합에서 + /// 파생되므로 능력치를 올리면 등급이 저절로 오른다. 가격은 전부 TrainingManager → + /// TrainingPricing에서 받는다 — 이 화면은 값을 만들지 않는다. + /// + /// 표면·색은 배틀팀·보유 곤충과 같은 규칙(UISurface + UITheme 토큰)을 따른다. 한때 이 화면만 + /// 영어 제목("TRAINING CENTER"·"< Back"·"Equip")에 기본 스킨 버튼 10개였고, 기술 설명이 3종만 알아 + /// 회복·기절·독·방어 기술을 "ATK DOWN"으로 적었다. + /// public class TrainingUI : MonoBehaviour, IModalUI { [SerializeField] private TrainingManager trainingManager; @@ -14,7 +25,7 @@ public class TrainingUI : MonoBehaviour, IModalUI private bool isOpen; - private enum Page { InsectSelect, MethodSelect, SkillLearn, SkillEquip, SkillReplace } + private enum Page { InsectSelect, MethodSelect, Growth, SkillLearn, SkillEquip, SkillReplace } private Page page; private string selectedInstanceId; private int selectedMethodIndex = -1; @@ -23,203 +34,71 @@ private enum Page { InsectSelect, MethodSelect, SkillLearn, SkillEquip, SkillRep private readonly UIDirectScroll directScroll = new UIDirectScroll(); private string feedbackMsg; private float feedbackTimer; + private bool feedbackBig; // 등급 상승·습득 완료처럼 큰 소식은 앰버로 띄운다 + + // ── 스타일 캐시 — OnGUI 매 프레임 new GUIStyle 금지. 동적인 건 textColor뿐이다. ── + private bool stylesReady; + private GUIStyle guideStyle; // 헤더 아래 안내 한 줄 + private GUIStyle titleStyle; // 카드 제목(색 동적) + private GUIStyle infoStyle; // 카드 둘째 줄 + private GUIStyle detailStyle; // 셋째 줄·진척 숫자 + private GUIStyle descStyle; // 방식 설명(두 줄 래핑) + private GUIStyle sectionStyle; // 소제목 + private GUIStyle buttonStyle; // UISurface.Button 라벨(richText — 둘째 줄에 비용) + private GUIStyle gradeStyle; // 등급 큰 글자(색 동적) + private GUIStyle valueStyle; // "12 / 15" + private GUIStyle slotNumStyle; + private GUIStyle emptyStyle; + private GUIStyle feedbackStyle; + + private void InitStyles() + { + if (stylesReady) return; + stylesReady = true; + UITheme t = UITheme.Instance; - // DrawInsectSelect 핫스팟 캐시 — owned.Count × 4 GUIStyle/프레임 회피. - private bool insectSelectStylesReady; - private GUIStyle insectSelectSubStyle; - private GUIStyle insectSelectNameStyle; // textColor 동적 - private GUIStyle insectSelectInfoStyle; - private GUIStyle insectSelectBtnStyle; - - private static readonly Color TrainSubGrayCol = new Color(0.6f, 0.6f, 0.6f); - private static readonly Color TrainItemBgCol = new Color(0.1f, 0.12f, 0.18f, 0.85f); - private static readonly Color TrainInfoGrayCol = new Color(0.55f, 0.55f, 0.55f); - private static readonly Color TrainBtnGreenCol = new Color(0.3f, 0.45f, 0.25f); - - // 잔여 영역(Panel/Back/Method/SkillLearn/SkillEquip/SkillReplace/SkillCard/Feedback) 캐시. - private bool trainDetailStylesReady; - private GUIStyle panelTitleStyle; - private GUIStyle panelCloseStyle; - private GUIStyle backBtnStyle; - private GUIStyle methodNameStyle; // textColor 동적 (rc) - private GUIStyle methodEquipBtnStyle; - private GUIStyle methodCardNameStyle; // textColor 동적 - private GUIStyle methodCardDescStyle; - private GUIStyle methodCostStyle; // textColor 동적 (canTrain) - private GUIStyle methodLockStyle; - private GUIStyle methodCountStyle; - private GUIStyle methodTrainBtnStyle; - private GUIStyle learnHeaderStyle; // textColor 동적 (theme) - private GUIStyle learnBtnStyle; - private GUIStyle learnedTagStyle; - private GUIStyle equipNameStyle; // textColor 동적 - private GUIStyle equipSlotNumStyle; - private GUIStyle equipSlotNameStyle; // textColor 동적 - private GUIStyle equipSlotInfoStyle; - private GUIStyle equipRemBtnStyle; - private GUIStyle equipEmptyStyle; - private GUIStyle equipLearnedHeaderStyle; - private GUIStyle equipLearnedNameStyle; // textColor 동적 - private GUIStyle equipLearnedInfoStyle; - private GUIStyle equipEqBtnStyle; - private GUIStyle equipEqTagStyle; - private GUIStyle replaceHeaderStyle; - private GUIStyle replaceNewStyle; // textColor 동적 - private GUIStyle replaceOldNameStyle; // textColor 동적 - private GUIStyle replaceOldInfoStyle; - private GUIStyle replaceForgetBtnStyle; - private GUIStyle replaceCancelBtnStyle; - private GUIStyle cardSkillNameStyle; // textColor 동적 - private GUIStyle cardDescStyle; - private GUIStyle cardCdStyle; - private GUIStyle feedbackStyle; // textColor 동적 (alpha) - - private static readonly Color PanelBgCol = new Color(0.04f, 0.06f, 0.1f, 0.96f); - private static readonly Color PanelHeaderCol = new Color(0.15f, 0.2f, 0.3f); - private static readonly Color PanelAccentOrangeCol = new Color(0.9f, 0.6f, 0.2f); - private static readonly Color PanelTitleYellowCol = new Color(1f, 0.85f, 0.4f); - private static readonly Color MethodEquipBtnCol = new Color(0.2f, 0.35f, 0.55f); - private static readonly Color MethodCardDarkCol = new Color(0.3f, 0.3f, 0.3f); - private static readonly Color MethodNameDimCol = new Color(0.4f, 0.4f, 0.4f); - private static readonly Color MethodDescGrayCol = new Color(0.55f, 0.55f, 0.55f); - private static readonly Color MethodCostOkCol = new Color(1f, 0.5f, 0.8f); - private static readonly Color MethodCostBadCol = new Color(0.5f, 0.3f, 0.3f); - private static readonly Color MethodLockRedCol = new Color(1f, 0.4f, 0.3f); - private static readonly Color MethodCountGrayCol = new Color(0.5f, 0.5f, 0.5f); - private static readonly Color MethodTrainOkBgCol = new Color(0.3f, 0.5f, 0.25f); - private static readonly Color MethodTrainOffBgCol = new Color(0.2f, 0.2f, 0.2f); - private static readonly Color LearnBtnFullCol = new Color(0.5f, 0.35f, 0.2f); - private static readonly Color LearnBtnOkCol = new Color(0.25f, 0.5f, 0.3f); - private static readonly Color LearnBtnOffCol = new Color(0.2f, 0.2f, 0.2f); - private static readonly Color LearnedTagGreenCol = new Color(0.3f, 0.9f, 0.5f); - private static readonly Color EquipSlotBgCol = new Color(0.1f, 0.12f, 0.18f, 0.85f); - private static readonly Color EquipSlotNumGrayCol = new Color(0.58f, 0.62f, 0.76f); - private static readonly Color EquipInfoGrayCol = new Color(0.72f, 0.72f, 0.76f); - private static readonly Color EquipRemBgCol = new Color(0.4f, 0.2f, 0.2f); - private static readonly Color EquipEmptyGrayCol = new Color(0.66f, 0.66f, 0.72f); - private static readonly Color EquipLearnedHeaderBlueCol = new Color(0.7f, 0.75f, 1f); - private static readonly Color EquipLearnedBgCol = new Color(0.08f, 0.1f, 0.15f, 0.8f); - private static readonly Color EquipNameDimCol = new Color(0.66f, 0.66f, 0.7f); - private static readonly Color EquipLearnedInfoCol = new Color(0.68f, 0.68f, 0.72f); - private static readonly Color EquipEqBgCol = new Color(0.2f, 0.4f, 0.3f); - private static readonly Color EquipEqTagGreenCol = new Color(0.4f, 0.7f, 0.4f); - private static readonly Color ReplaceHeaderGrayCol = new Color(0.7f, 0.7f, 0.7f); - private static readonly Color ReplaceOldBgCol = new Color(0.1f, 0.12f, 0.18f, 0.85f); - private static readonly Color ReplaceOldInfoCol = new Color(0.55f, 0.55f, 0.55f); - private static readonly Color ReplaceForgetBgCol = new Color(0.55f, 0.2f, 0.2f); - private static readonly Color ReplaceCancelBgCol = new Color(0.3f, 0.3f, 0.35f); - private static readonly Color CardLearnedBgCol = new Color(0.08f, 0.1f, 0.14f, 0.7f); - private static readonly Color CardActiveBgCol = new Color(0.1f, 0.12f, 0.18f, 0.85f); - private static readonly Color CardLearnedNameCol = new Color(0.68f, 0.68f, 0.72f); - private static readonly Color CardDescGrayCol = new Color(0.72f, 0.72f, 0.76f); - private static readonly Color CardCdGrayCol = new Color(0.66f, 0.66f, 0.72f); - - private void InitTrainDetailStyles() - { - if (trainDetailStylesReady) return; - panelTitleStyle = new GUIStyle(GUI.skin.label) { fontSize = 42, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - panelTitleStyle.normal.textColor = PanelTitleYellowCol; - panelCloseStyle = new GUIStyle(GUI.skin.button) { fontSize = 34, fontStyle = FontStyle.Bold }; - backBtnStyle = new GUIStyle(GUI.skin.button) { fontSize = 31, fontStyle = FontStyle.Bold }; - methodNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 34, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - methodEquipBtnStyle = new GUIStyle(GUI.skin.button) { fontSize = 32, fontStyle = FontStyle.Bold }; - methodCardNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 32, fontStyle = FontStyle.Bold }; - methodCardDescStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, wordWrap = true }; - methodCardDescStyle.normal.textColor = MethodDescGrayCol; - methodCostStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, alignment = TextAnchor.MiddleRight }; - methodLockStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, alignment = TextAnchor.MiddleRight }; - methodLockStyle.normal.textColor = MethodLockRedCol; - methodCountStyle = new GUIStyle(GUI.skin.label) { fontSize = 28, alignment = TextAnchor.MiddleRight }; - methodCountStyle.normal.textColor = MethodCountGrayCol; - methodTrainBtnStyle = new GUIStyle(GUI.skin.button) { fontSize = 32, fontStyle = FontStyle.Bold }; - learnHeaderStyle = new GUIStyle(GUI.skin.label) { fontSize = 32, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - learnBtnStyle = new GUIStyle(GUI.skin.button) { fontSize = 32, fontStyle = FontStyle.Bold }; - learnedTagStyle = new GUIStyle(GUI.skin.label) { fontSize = 32, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleRight }; - learnedTagStyle.normal.textColor = LearnedTagGreenCol; - equipNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 32, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - equipSlotNumStyle = new GUIStyle(GUI.skin.label) { fontSize = 40, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - equipSlotNumStyle.normal.textColor = EquipSlotNumGrayCol; - equipSlotNameStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 29, - fontStyle = FontStyle.Bold, - alignment = TextAnchor.MiddleLeft, - wordWrap = true, - clipping = TextClipping.Clip - }; - equipSlotInfoStyle = new GUIStyle(GUI.skin.label) { fontSize = 30 }; - equipSlotInfoStyle.normal.textColor = EquipInfoGrayCol; - equipRemBtnStyle = new GUIStyle(GUI.skin.button) { fontSize = 30 }; - equipEmptyStyle = new GUIStyle(GUI.skin.label) { fontSize = 32, fontStyle = FontStyle.Italic }; - equipEmptyStyle.normal.textColor = EquipEmptyGrayCol; - equipLearnedHeaderStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, fontStyle = FontStyle.Bold }; - equipLearnedHeaderStyle.normal.textColor = EquipLearnedHeaderBlueCol; - equipLearnedNameStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 29, - fontStyle = FontStyle.Bold, - alignment = TextAnchor.MiddleLeft, - wordWrap = true, - clipping = TextClipping.Clip - }; - equipLearnedInfoStyle = new GUIStyle(GUI.skin.label) { fontSize = 28 }; - equipLearnedInfoStyle.normal.textColor = EquipLearnedInfoCol; - equipEqBtnStyle = new GUIStyle(GUI.skin.button) { fontSize = 28 }; - equipEqTagStyle = new GUIStyle(GUI.skin.label) { fontSize = 28, alignment = TextAnchor.MiddleRight }; - equipEqTagStyle.normal.textColor = EquipEqTagGreenCol; - replaceHeaderStyle = new GUIStyle(GUI.skin.label) { fontSize = 34, alignment = TextAnchor.MiddleCenter }; - replaceHeaderStyle.normal.textColor = ReplaceHeaderGrayCol; - replaceNewStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 30, - fontStyle = FontStyle.Bold, - alignment = TextAnchor.MiddleCenter, - wordWrap = true, - clipping = TextClipping.Clip + guideStyle = new GUIStyle(GUI.skin.label) { fontSize = 28, alignment = TextAnchor.MiddleLeft }; + guideStyle.normal.textColor = t.textSecondary; + titleStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 32, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft, + wordWrap = true, clipping = TextClipping.Clip }; - replaceOldNameStyle = new GUIStyle(GUI.skin.label) + infoStyle = new GUIStyle(GUI.skin.label) { fontSize = 25, alignment = TextAnchor.MiddleLeft }; + infoStyle.normal.textColor = t.textSecondary; + detailStyle = new GUIStyle(GUI.skin.label) { fontSize = 22, alignment = TextAnchor.MiddleLeft }; + detailStyle.normal.textColor = t.textMuted; + descStyle = new GUIStyle(GUI.skin.label) { fontSize = 25, alignment = TextAnchor.UpperLeft, wordWrap = true }; + descStyle.normal.textColor = t.textSecondary; + sectionStyle = new GUIStyle(GUI.skin.label) { fontSize = 26, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + sectionStyle.normal.textColor = t.textPrimary; + buttonStyle = new GUIStyle(GUI.skin.label) { - fontSize = 29, - fontStyle = FontStyle.Bold, - alignment = TextAnchor.MiddleLeft, - wordWrap = true, - clipping = TextClipping.Clip + fontSize = 28, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, richText = true }; - replaceOldInfoStyle = new GUIStyle(GUI.skin.label) { fontSize = 30 }; - replaceOldInfoStyle.normal.textColor = ReplaceOldInfoCol; - replaceForgetBtnStyle = new GUIStyle(GUI.skin.button) { fontSize = 32, fontStyle = FontStyle.Bold }; - replaceCancelBtnStyle = new GUIStyle(GUI.skin.button) { fontSize = 30 }; - cardSkillNameStyle = new GUIStyle(GUI.skin.label) - { - fontSize = 29, - fontStyle = FontStyle.Bold, - alignment = TextAnchor.MiddleLeft, - wordWrap = true, - clipping = TextClipping.Clip + buttonStyle.normal.textColor = t.textPrimary; + gradeStyle = new GUIStyle(GUI.skin.label) { fontSize = 60, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + valueStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + valueStyle.normal.textColor = t.textPrimary; + slotNumStyle = new GUIStyle(GUI.skin.label) { fontSize = 28, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + slotNumStyle.normal.textColor = t.textSecondary; + emptyStyle = new GUIStyle(GUI.skin.label) { fontSize = 26, fontStyle = FontStyle.Italic, alignment = TextAnchor.MiddleLeft }; + emptyStyle.normal.textColor = t.textMuted; + feedbackStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 32, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, + wordWrap = true, clipping = TextClipping.Clip }; - cardDescStyle = new GUIStyle(GUI.skin.label) { fontSize = 30, wordWrap = true }; - cardDescStyle.normal.textColor = CardDescGrayCol; - cardCdStyle = new GUIStyle(GUI.skin.label) { fontSize = 28 }; - cardCdStyle.normal.textColor = CardCdGrayCol; - feedbackStyle = new GUIStyle(GUI.skin.label) { fontSize = 44, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; - trainDetailStylesReady = true; } // GetAllOwned 매 프레임 호출 회피 — InsectUpdated 이벤트로 invalidate (CollectionUI 패턴). private List cachedOwned; private bool ownedCacheDirty = true; - private void InitInsectSelectStyles() - { - if (insectSelectStylesReady) return; - insectSelectSubStyle = new GUIStyle(GUI.skin.label) { fontSize = 34, alignment = TextAnchor.MiddleCenter }; - insectSelectSubStyle.normal.textColor = TrainSubGrayCol; - insectSelectNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 32, fontStyle = FontStyle.Bold }; - insectSelectInfoStyle = new GUIStyle(GUI.skin.label) { fontSize = 30 }; - insectSelectInfoStyle.normal.textColor = TrainInfoGrayCol; - insectSelectBtnStyle = new GUIStyle(GUI.skin.button) { fontSize = 32 }; - insectSelectStylesReady = true; - } + // 기술 목록 캐시 — GetAvailableSkills는 호출마다 List·배열을 새로 만든다. 습득·교체·레벨업은 + // 전부 InsectUpdated로 오고, 방식·곤충을 바꾸는 건 페이지 전환이라 그 둘에서 비운다. + private InsectSkill[] cachedSkills; + private bool skillsCacheDirty = true; private List GetCachedOwned() { @@ -233,11 +112,10 @@ private List GetCachedOwned() } /// - /// 목록 한 줄의 정보 문구("Lv.n | 스킬 a/b | 장착 c/d | 크기") 캐시. - /// 개체마다 문자열 2개(크기 접미 + 본문 보간)와 크기 계산이 들던 자리인데, 호출부가 - /// 목록 루프 안이라 개체 수 × OnGUI 패스마다 반복됐다(60마리면 패스당 120개). - /// 값은 레벨·습득/장착 수·크기에서만 파생되고 그 변화는 전부 InsectUpdated로 오므로, - /// 보유 목록 캐시와 같은 신호로 함께 비운다. + /// 목록 한 줄의 정보 문구("Lv.n · 개체값 S · 기술 a/6 · 크기") 캐시. + /// 개체마다 문자열 2개와 크기 계산이 들던 자리인데 목록 루프 안이라 개체 수 × OnGUI 패스마다 + /// 반복됐다. 값은 레벨·개체값·습득 수·크기에서만 파생되고 그 변화는 전부 InsectUpdated로 + /// 오므로 보유 목록 캐시와 같은 신호로 함께 비운다. /// private readonly Dictionary ownedInfoCache = new Dictionary(); @@ -256,16 +134,32 @@ private static string BuildOwnedInfoLine(PlayerInsectData pid, InsectData data) { int learned = pid.learnedSkillIds != null ? pid.learnedSkillIds.Count : 0; string sizeStr = data != null - ? " | " + InsectSizeCalculator.SizeLabel(InsectSizeCalculator.SizeMm(data, pid)) + ? " · " + InsectSizeCalculator.SizeLabel(InsectSizeCalculator.SizeMm(data, pid)) : string.Empty; - return $"Lv.{pid.level} | 스킬: {learned}/{PlayerInsectData.MaxLearnedSkills}" - + $" | 장착: {pid.EquippedCount()}/{PlayerInsectData.MaxEquipSlots}{sizeStr}"; + return $"Lv.{pid.level} · 개체값 {CapturePopupUI.GetGradeLabel(pid.Grade)}" + + $" · 기술 {learned}/{PlayerInsectData.MaxLearnedSkills}{sizeStr}"; + } + + // 헤더 부제("캔디 340")는 값이 바뀔 때만 다시 만든다 — OnGUI는 프레임당 여러 패스다. + private string candySubtitle = string.Empty; + private int candySubtitleValue = int.MinValue; + + private string CandySubtitle() + { + int candy = candyInventory != null ? candyInventory.Candies : 0; + if (candy != candySubtitleValue) + { + candySubtitleValue = candy; + candySubtitle = $"캔디 {candy}"; + } + return candySubtitle; } private void HandleInsectUpdated(PlayerInsectData _) { ownedCacheDirty = true; - ownedInfoCache.Clear(); // 레벨업·스킬 습득·장착 변경이 전부 이 신호로 온다 + skillsCacheDirty = true; + ownedInfoCache.Clear(); // 레벨업·능력치·스킬 습득·장착 변경이 전부 이 신호로 온다 } private void OnEnable() @@ -323,17 +217,20 @@ private void ChangePage(Page nextPage) page = nextPage; scrollPos = Vector2.zero; directScroll.Reset(); + skillsCacheDirty = true; } private void OnGUI() { if (!isOpen) return; + InitStyles(); UIScale.Begin(); switch (page) { case Page.InsectSelect: DrawInsectSelect(); break; case Page.MethodSelect: DrawMethodSelect(); break; + case Page.Growth: DrawGrowth(); break; case Page.SkillLearn: DrawSkillLearn(); break; case Page.SkillEquip: DrawSkillEquip(); break; case Page.SkillReplace: DrawSkillReplace(); break; @@ -344,402 +241,722 @@ private void OnGUI() UIScale.End(); } - private void DrawPanel(string title, out float px, out float py, out float pw, out float ph) + // ── 공통 틀 ───────────────────────────────────────────────────────────── + + /// + /// 창 자리 — 배틀팀·보유 곤충과 같은 크기. 세로 화면은 높이를 크게 잡는다(1000×1000 고정일 때 + /// 세로 화면 가운데 절반만 쓰고 위아래가 비었다). 안전 영역을 넘으면 하네스가 줄인다. + /// + private static Rect PanelRect() { - InitTrainDetailStyles(); - Rect panel = UISafeLayout.CenteredPanel(1000f, 1000f); - pw = panel.width; - ph = panel.height; - px = panel.x; - py = panel.y; + bool mobile = UIScale.IsMobileLayout; + return UISafeLayout.CenteredPanel(mobile ? 1000f : 1040f, mobile ? 1560f : 960f); + } - UISurface.Card(new Rect(px, py, pw, ph), PanelBgCol, UITheme.Instance.surfaceBorder); - UISurface.Rounded(new Rect(px + 3f, py + 3f, pw - 6f, 70f), PanelHeaderCol); - GUI.color = PanelAccentOrangeCol; - GUI.DrawTexture(new Rect(px, py + 70, pw, 5), Texture2D.whiteTexture); + /// 카드 + 코랄 헤더. 헤더 오른쪽 버튼(닫기/뒤로)을 눌렀으면 true. + private static bool DrawShell(string title, string subtitle, string actionLabel, out Rect panel) + { + panel = PanelRect(); + UISurface.Card(panel); + return UISurface.Header(new Rect(panel.x + 3f, panel.y + 3f, panel.width - 6f, 88f), title, subtitle, actionLabel); + } - GUI.color = Color.white; - GUI.Label(new Rect(px + 120, py + 8, pw - 240, 58), title, panelTitleStyle); + private static float ActionHeight => SkillUILayout.GetTouchHeight(UIScale.IsMobileLayout, 58f, 68f); - if (GUI.Button(new Rect(px + pw - 80f, py + 6f, 68f, UIScale.IsMobileLayout ? 62f : 50f), "X", panelCloseStyle)) - CloseModal(); + /// 세로로 긴 레일 — 얇은 것은 각지게, 세로를 카드 반경만큼 물린다(ui-layout.md). + private static void DrawRail(Rect card, Color color) + { + UISurface.Flat(new Rect(card.x + 3f, card.y + 3f + UITheme.Radius.Card, 6f, + Mathf.Max(4f, card.height - 6f - UITheme.Radius.Card * 2f)), color); } - private bool DrawBackButton(float px, float py) + private void DrawThumb(Rect frame, InsectData data, PlayerInsectData pid) { - InitTrainDetailStyles(); - return GUI.Button(new Rect(px + 12, py + 6f, 150f, UIScale.IsMobileLayout ? 62f : 50f), "< Back", backBtnStyle); + UITheme t = UITheme.Instance; + Color rc = data != null ? t.GetInsectRarityColor(data.rarity) : t.surfaceBorder; + UISurface.Rounded(frame, Color.Lerp(t.surfaceBase, rc, 0.14f)); + if (data != null) + InsectVisual.Draw(frame.center.x, frame.center.y, frame.width * 0.94f, data, pid != null && pid.isShiny, 1f); } - private void DrawInsectSelect() + /// + /// 선택한 곤충 요약 카드 — 썸네일 · 이름 · Lv·타입 · 오른쪽에 개체값 등급. 방법 선택과 성장 훈련이 같이 쓴다. + /// 만큼 오른쪽을 비워 두고(호출부가 버튼을 둔다) 등급은 그 왼쪽에 그린다. + /// + private void DrawInsectSummary(Rect r, PlayerInsectData pid, InsectData data, float rightReserve) { - DrawPanel("TRAINING CENTER", out float px, out float py, out float pw, out float ph); + UITheme t = UITheme.Instance; + Color rc = data != null ? t.GetInsectRarityColor(data.rarity) : t.textSecondary; + UISurface.Card(r, t.surfaceRaised, t.surfaceBorder); + DrawRail(r, rc); + + float thumb = r.height - 24f; + DrawThumb(new Rect(r.x + 20f, r.y + 12f, thumb, thumb), data, pid); + + float gradeW = 150f; + float textX = r.x + 20f + thumb + 20f; + float textW = Mathf.Max(80f, r.xMax - rightReserve - gradeW - 16f - textX); + + titleStyle.normal.textColor = SkillUILayout.GetReadableAccent(rc); + UIHelper.LabelFit(new Rect(textX, r.y + r.height * 0.5f - 50f, textW, 46f), + data != null ? (pid.isShiny ? "★ " + data.displayName : data.displayName) : pid.insectId, titleStyle); + + string typeLabel = data != null + ? InsectTypeChart.GetDisplayName(data.primaryType) + + (data.secondaryType != InsectElement.None ? "/" + InsectTypeChart.GetDisplayName(data.secondaryType) : "") + : "타입 미상"; + UIHelper.LabelFit(new Rect(textX, r.y + r.height * 0.5f + 2f, textW, 38f), + data != null ? $"Lv.{pid.level} · {data.rarity.Korean()} · {typeLabel}" : $"Lv.{pid.level}", infoStyle); + + // 개체값 등급 — 능력치 훈련의 결과가 곧바로 보이는 자리. + IVGrade grade = pid.Grade; + Color gc = t.GetGradeColor(grade); + Rect gradeBox = new Rect(r.xMax - rightReserve - gradeW - 8f, r.y + 12f, gradeW, r.height - 24f); + UISurface.Rounded(gradeBox, Color.Lerp(t.surfaceBase, gc, 0.12f)); + detailStyle.alignment = TextAnchor.MiddleCenter; + UIHelper.LabelFit(new Rect(gradeBox.x, gradeBox.y + 4f, gradeW, 30f), "개체값", detailStyle); + detailStyle.alignment = TextAnchor.MiddleLeft; + gradeStyle.normal.textColor = gc; + GUI.Label(new Rect(gradeBox.x, gradeBox.y + 30f, gradeW, Mathf.Max(40f, gradeBox.height - 34f)), + CapturePopupUI.GetGradeLabel(grade), gradeStyle); + } + + // ── 1. 곤충 선택 ──────────────────────────────────────────────────────── + private void DrawInsectSelect() + { + UITheme t = UITheme.Instance; + if (DrawShell("훈련소", CandySubtitle(), "× 닫기", out Rect panel)) { CloseModal(); return; } if (collection == null) return; List owned = GetCachedOwned(); if (owned == null) return; - InitInsectSelectStyles(); - - int candy = candyInventory != null ? candyInventory.Candies : 0; - GUI.Label(new Rect(px, py + 78, pw, 44), $"훈련할 곤충을 선택하세요 | 캔디: {candy}", insectSelectSubStyle); + UIHelper.LabelFit(new Rect(panel.x + 28f, panel.y + 102f, panel.width - 56f, 48f), + "훈련할 곤충을 고르세요 — 기술·레벨·능력치를 키웁니다", guideStyle); - float listY = py + 128; - float listH = ph - 138; - float itemH = UIScale.IsMobileLayout ? 150f : 130f; - Rect area = new Rect(px + 10, listY, pw - 20, listH); + float listY = panel.y + 160f; + float itemH = UIScale.IsMobileLayout ? 168f : 130f; + Rect area = new Rect(panel.x + 16f, listY, panel.width - 32f, Mathf.Max(1f, panel.yMax - listY - 16f)); float contentHeight = owned.Count * itemH; - Rect view = new Rect(0, 0, area.width, contentHeight); + Rect view = new Rect(0, 0, area.width - 12f, contentHeight); directScroll.Handle(ref scrollPos, area, contentHeight, itemH * 0.35f); - scrollPos = GUI.BeginScrollView( - area, - scrollPos, - view, - GUIStyle.none, - GUIStyle.none); + scrollPos = GUI.BeginScrollView(area, scrollPos, view, GUIStyle.none, GUIStyle.none); // 화면에 걸치는 줄만 그린다. IMGUI 스크롤뷰엔 가상화가 없어서, 컬링하지 않으면 보유 곤충 - // 전부에 대해 아래 `InsectVisual.Draw`가 3D 썸네일을 요청한다 — 캐시는 한 뷰포트 분량(24칸)이라 - // 60마리를 매 패스 훑으면 LRU가 절대 안정되지 않고 렌더러가 프레임마다 곤충 모델을 - // 만들었다 부순다(도감에서 P0였던 것과 같은 구조, 2026-08-06 audit). + // 전부에 대해 `InsectVisual.Draw`가 3D 썸네일을 요청한다 — 캐시는 한 뷰포트 분량(24칸)이라 + // 60마리를 매 패스 훑으면 LRU가 안정되지 않고 렌더러가 프레임마다 곤충 모델을 만들었다 + // 부순다(도감에서 P0였던 것과 같은 구조, 2026-08-06 audit). DexBrowseLayout.GetVisibleRowRange( - scrollPos.y, area.height, itemH - 3f, 3f, owned.Count, + scrollPos.y, area.height, itemH - 8f, 8f, owned.Count, out int firstVisible, out int lastVisible); for (int i = firstVisible; i <= lastVisible; i++) { PlayerInsectData pid = owned[i]; InsectData data = collection.GetInsectData(pid.insectId); - Rect r = new Rect(0, i * itemH, view.width, itemH - 3); - - Color rc = data != null ? UITheme.Instance.GetInsectRarityColor(data.rarity) : Color.gray; - UISurface.Card(r, TrainItemBgCol, UITheme.Instance.surfaceBorder); - GUI.color = rc; - GUI.DrawTexture(new Rect(r.x, r.y, 6, r.height), Texture2D.whiteTexture); - - if (data != null) - InsectVisual.Draw(r.x + 70, r.y + r.height / 2f, 96f, data, pid != null && pid.isShiny, 1f); - - // 캐시 스타일 + textColor만 동적 갱신 (BattleScreenUI 패턴). - insectSelectNameStyle.normal.textColor = rc; - GUI.color = Color.white; - // #코드 미표시 — 개체 구분은 아래 줄의 레벨·스킬 수·크기가 맡는다. - string name = data != null ? data.displayName : pid.insectId; - GUI.Label(new Rect(r.x + 120, r.y + 14, r.width - 290, 44), name, insectSelectNameStyle); - - GUI.Label(new Rect(r.x + 120, r.y + 66, r.width - 290, 38), - OwnedInfoLine(pid, data), insectSelectInfoStyle); - - GUI.backgroundColor = TrainBtnGreenCol; - float trainH = UIScale.IsMobileLayout ? 64f : 52f; - if (GUI.Button(new Rect(r.x + r.width - 160, r.y + r.height / 2f - trainH * 0.5f, 140, trainH), "훈련", insectSelectBtnStyle)) + Rect r = new Rect(4f, i * itemH, view.width - 4f, itemH - 8f); + Color rc = data != null ? t.GetInsectRarityColor(data.rarity) : t.textSecondary; + + UISurface.Card(r, t.surfaceRaised, t.surfaceBorder); + DrawRail(r, rc); + float thumb = Mathf.Min(r.height - 20f, UIScale.IsMobileLayout ? 140f : 104f); + DrawThumb(new Rect(r.x + 20f, r.y + (r.height - thumb) / 2f, thumb, thumb), data, pid); + + float textX = r.x + 20f + thumb + 20f; + float textW = Mathf.Max(80f, r.xMax - 200f - textX); + // #코드 미표시 — 개체 구분은 아래 줄의 레벨·등급·크기가 맡는다. + titleStyle.normal.textColor = SkillUILayout.GetReadableAccent(rc); + UIHelper.LabelFit(new Rect(textX, r.y + r.height * 0.5f - 48f, textW, 46f), + data != null ? data.displayName : pid.insectId, titleStyle); + UIHelper.LabelFit(new Rect(textX, r.y + r.height * 0.5f + 4f, textW, 38f), OwnedInfoLine(pid, data), infoStyle); + + float bh = ActionHeight; + if (UISurface.Button(new Rect(r.xMax - 180f, r.y + (r.height - bh) / 2f, 160f, bh), "훈련", t.btnPrimary, buttonStyle) + && !directScroll.IsDragging) { selectedInstanceId = pid.instanceId; ChangePage(Page.MethodSelect); } - GUI.backgroundColor = Color.white; } GUI.EndScrollView(); + UISurface.ScrollAffordance(area, scrollPos, contentHeight, t.accentCoral); } + // ── 2. 훈련 방법 ──────────────────────────────────────────────────────── + private void DrawMethodSelect() { - DrawPanel("CHOOSE TRAINING", out float px, out float py, out float pw, out float ph); - if (DrawBackButton(px, py)) { ChangePage(Page.InsectSelect); return; } + UITheme t = UITheme.Instance; + if (DrawShell("훈련 방법", CandySubtitle(), "‹ 뒤로", out Rect panel)) { ChangePage(Page.InsectSelect); return; } PlayerInsectData pid = GetPid(); - InsectData data = pid != null ? collection.GetInsectData(pid.insectId) : null; if (pid == null) { ChangePage(Page.InsectSelect); return; } + InsectData data = collection.GetInsectData(pid.insectId); - Color rc = data != null ? UITheme.Instance.GetInsectRarityColor(data.rarity) : Color.gray; - // 캐시 + textColor 동적 갱신. - methodNameStyle.normal.textColor = rc; - string typeLabel = data != null - ? InsectTypeChart.GetDisplayName(data.primaryType) - + (data.secondaryType != InsectElement.None ? "/" + InsectTypeChart.GetDisplayName(data.secondaryType) : "") - : "타입 미상"; - UIHelper.LabelFit(new Rect(px, py + 80, pw, 44), - data != null ? $"{data.displayName} Lv.{pid.level} · {typeLabel} 타입" : pid.insectId, - methodNameStyle); - - GUI.backgroundColor = MethodEquipBtnCol; - if (GUI.Button(new Rect(px + pw - 240f, py + 76f, 220f, UIScale.IsMobileLayout ? 64f : 50f), "스킬 장착", methodEquipBtnStyle)) + Rect summary = new Rect(panel.x + 20f, panel.y + 104f, panel.width - 40f, 150f); + DrawInsectSummary(summary, pid, data, 220f); + // "기술 장착"은 surfaceBorder — btnSecondary는 surfaceRaised로 동기화돼 요약 카드와 같은 색이라 버튼이 사라진다. + float bh = ActionHeight; + if (UISurface.Button(new Rect(summary.xMax - 216f, summary.y + (summary.height - bh) / 2f, 196f, bh), + "기술 장착", t.surfaceBorder, buttonStyle)) + { ChangePage(Page.SkillEquip); - GUI.backgroundColor = Color.white; + return; + } if (trainingManager == null || trainingManager.Methods == null) return; - TrainingMethod[] methods = trainingManager.Methods; - float cardH = 172f; - // 6개 방식이 세로/가로 캔버스를 넘지 않도록 스크롤 컨테이너로 감싼다. - Rect area = new Rect(px + 10, py + 138, pw - 20, ph - 150); - float contentHeight = methods.Length * (cardH + 8); - Rect view = new Rect(0, 0, area.width, contentHeight); + + float cardH = UIScale.IsMobileLayout ? 186f : 164f; + const float gap = 10f; + float listY = summary.yMax + 16f; + Rect area = new Rect(panel.x + 16f, listY, panel.width - 32f, Mathf.Max(1f, panel.yMax - listY - 16f)); + int cardCount = methods.Length + 1; // 맨 위 「성장 훈련」 + 기술 방식들 + float contentHeight = cardCount * (cardH + gap); + Rect view = new Rect(0, 0, area.width - 12f, contentHeight); directScroll.Handle(ref scrollPos, area, contentHeight, cardH * 0.35f); - scrollPos = GUI.BeginScrollView( - area, - scrollPos, - view, - GUIStyle.none, - GUIStyle.none); - float cardW = view.width; + scrollPos = GUI.BeginScrollView(area, scrollPos, view, GUIStyle.none, GUIStyle.none); + + // 성장 훈련 — 레벨·능력치는 기술과 달리 방식 데이터(TrainingMethod)가 아니라 곤충 자신의 값이라 + // 카드만 같은 모양으로 두고 목록 맨 위에 고정한다. + Rect growth = new Rect(4f, 0f, view.width - 4f, cardH); + RefreshGrowthTexts(pid); + if (DrawMethodCard(growth, "성장 훈련", "레벨업과 능력치(개체값) 훈련 — 능력치를 올리면 등급이 오릅니다", + t.accentAmber, growthStatusText, t.textSecondary, true)) + { + ChangePage(Page.Growth); + GUI.EndScrollView(); + return; + } for (int i = 0; i < methods.Length; i++) { - var m = methods[i]; - float cy = i * (cardH + 8); - bool canTrain = trainingManager.CanTrain(m, pid); + TrainingMethod m = methods[i]; + Rect card = new Rect(4f, (i + 1) * (cardH + gap), view.width - 4f, cardH); bool levelOk = pid.level >= m.requiredLevel; - - // themeColor scaled new Color는 struct stack 거짓양성 (BattleArenaController 판단 일관). - GUI.color = new Color(m.themeColor.r * 0.15f, m.themeColor.g * 0.15f, m.themeColor.b * 0.15f, 0.8f); - GUI.DrawTexture(new Rect(0, cy, cardW, cardH), Texture2D.whiteTexture); - GUI.color = levelOk ? m.themeColor : MethodCardDarkCol; - GUI.DrawTexture(new Rect(0, cy, 6, cardH), Texture2D.whiteTexture); - - methodCardNameStyle.normal.textColor = levelOk ? m.themeColor : MethodNameDimCol; - GUI.color = Color.white; - UIHelper.LabelFit(new Rect(24, cy + 16, cardW - 380, 44), m.displayName, methodCardNameStyle); - - GUI.Label(new Rect(24, cy + 70, cardW - 380, 90), m.description, methodCardDescStyle); - - methodCostStyle.normal.textColor = canTrain ? MethodCostOkCol : MethodCostBadCol; - GUI.Label(new Rect(cardW - 340, cy + 16, 175, 38), $"비용: {m.candyCost}", methodCostStyle); - + bool canTrain = trainingManager.CanTrain(m, pid); + string status; + Color statusColor; if (!levelOk) { - GUI.Label(new Rect(cardW - 340, cy + 62, 175, 38), $"Lv.{m.requiredLevel} 필요", methodLockStyle); + status = $"곤충 Lv.{m.requiredLevel} 필요"; + statusColor = t.accentCoral; + } + else + { + int count = trainingManager.GetAvailableSkillCount(m, pid); + status = m.methodId == TrainingManager.DiscMethodId ? $"디스크 기술 {count}개" : $"배울 수 있는 기술 {count}개"; + statusColor = count > 0 ? t.textSecondary : t.textMuted; } - int skillCount = trainingManager.GetAvailableSkillCount(m, pid); - UIHelper.LabelFit(new Rect(cardW - 340, cy + cardH - 44, 175, 34), $"스킬 {skillCount}개", methodCountStyle); - - GUI.backgroundColor = canTrain ? MethodTrainOkBgCol : MethodTrainOffBgCol; - GUI.enabled = canTrain; - float startButtonH = UIScale.IsMobileLayout ? 64f : 52f; - if (GUI.Button(new Rect(cardW - 150, cy + cardH / 2f - startButtonH * 0.5f, 130, startButtonH), "시작", methodTrainBtnStyle)) + if (DrawMethodCard(card, m.displayName, m.description, levelOk ? m.themeColor : t.textMuted, + status, statusColor, canTrain)) { selectedMethodIndex = i; ChangePage(Page.SkillLearn); + GUI.EndScrollView(); + return; } - GUI.enabled = true; - GUI.backgroundColor = Color.white; } GUI.EndScrollView(); + UISurface.ScrollAffordance(area, scrollPos, contentHeight, t.accentCoral); } - private void DrawSkillLearn() + /// 방법 카드 한 장 — 레일 · 이름 · 설명(두 줄) · 상태 줄 · [시작]. 눌렀으면 true. + private bool DrawMethodCard(Rect r, string name, string description, Color accent, string status, Color statusColor, bool enabled) { - DrawPanel("LEARN SKILLS", out float px, out float py, out float pw, out float ph); - if (DrawBackButton(px, py)) { ChangePage(Page.MethodSelect); return; } + UITheme t = UITheme.Instance; + UISurface.Card(r, enabled ? t.surfaceRaised : t.surfaceCard, t.surfaceBorder); + DrawRail(r, accent); + + float textX = r.x + 28f; + float textW = Mathf.Max(80f, r.width - 250f); + titleStyle.normal.textColor = SkillUILayout.GetReadableAccent(accent); + UIHelper.LabelFit(new Rect(textX, r.y + 12f, textW, 44f), name, titleStyle); + UIHelper.LabelFit(new Rect(textX, r.y + 58f, textW, Mathf.Max(34f, r.height - 58f - 46f)), description, descStyle); + infoStyle.normal.textColor = statusColor; + UIHelper.LabelFit(new Rect(textX, r.yMax - 42f, textW, 34f), status, infoStyle); + infoStyle.normal.textColor = t.textSecondary; + + float bh = ActionHeight; + bool prevEnabled = GUI.enabled; + GUI.enabled = prevEnabled && enabled; + bool pressed = UISurface.Button(new Rect(r.xMax - 196f, r.y + (r.height - bh) / 2f, 176f, bh), "시작", + enabled ? t.btnPrimary : t.btnDisabled, buttonStyle); + GUI.enabled = prevEnabled; + return pressed && enabled && !directScroll.IsDragging; + } - PlayerInsectData pid = GetPid(); - if (pid == null || trainingManager == null || selectedMethodIndex < 0) + // ── 3. 성장 훈련 ──────────────────────────────────────────────────────── + + private static readonly GrowthStat[] GrowthStats = { GrowthStat.Hp, GrowthStat.Attack, GrowthStat.Defense }; + + // 버튼 라벨("훈련\n캔디 124")은 비용이 바뀔 때만 다시 만든다 — 줄 넷 × OnGUI 패스마다 보간하지 않는다. + private readonly string[] growthLabels = new string[4]; + private readonly int[] growthLabelCosts = { -1, -1, -1, -1 }; + + private string GrowthButtonLabel(int row, string verb, int cost) + { + if (growthLabelCosts[row] != cost || growthLabels[row] == null) { - ChangePage(Page.MethodSelect); + growthLabelCosts[row] = cost; + growthLabels[row] = $"{verb}\n캔디 {cost}"; + } + return growthLabels[row]; + } + + private static string StatName(GrowthStat stat) + { + switch (stat) + { + case GrowthStat.Attack: return "공격"; + case GrowthStat.Defense: return "방어"; + default: return "체력"; + } + } + + private static Color StatColor(GrowthStat stat) + { + UITheme t = UITheme.Instance; + switch (stat) + { + case GrowthStat.Attack: return t.accentCoral; + case GrowthStat.Defense: return t.skillDefense; + default: return t.accentMint; + } + } + + private static readonly string HpEffectText = $"훈련 1회: 최대 HP +{PlayerInsectData.HpPerIv}"; + private static readonly string LevelEffectText = + $"레벨업: 최대 HP +{GameConstants.Battle.HpPerLevel} · 공격 +{PlayerInsectData.AtkPerLevel} · 방어 +{PlayerInsectData.DefPerLevel}"; + + private static string StatEffect(GrowthStat stat) + { + switch (stat) + { + case GrowthStat.Attack: return "훈련 1회: 공격 +1"; + case GrowthStat.Defense: return "훈련 1회: 방어 +1"; + default: return HpEffectText; + } + } + + // 성장 화면의 파생 문구 — 레벨·개체값이 바뀔 때만 다시 만든다(OnGUI 패스마다 보간하지 않는다). + private string growthTextsFor; + private int growthTextsLevel = -1; + private int growthTextsIvSum = -1; + private int growthTextsHp = -1, growthTextsAtk = -1, growthTextsDef = -1; + private string growthStatusText = string.Empty; + private string growthLevelText = string.Empty; + private string growthFooterText = string.Empty; + private readonly string[] growthIvTexts = new string[3]; + + private void RefreshGrowthTexts(PlayerInsectData pid) + { + if (growthTextsFor == pid.instanceId && growthTextsLevel == pid.level && growthTextsIvSum == pid.IvSum + && growthTextsHp == pid.ivHp && growthTextsAtk == pid.ivAtk && growthTextsDef == pid.ivDef) return; + growthTextsFor = pid.instanceId; + growthTextsLevel = pid.level; + growthTextsIvSum = pid.IvSum; + growthTextsHp = pid.ivHp; + growthTextsAtk = pid.ivAtk; + growthTextsDef = pid.ivDef; + + IVGrade grade = pid.Grade; + growthStatusText = $"Lv.{pid.level} · 개체값 등급 {CapturePopupUI.GetGradeLabel(grade)}"; + bool maxLevel = trainingManager != null && trainingManager.IsMaxLevel(pid); + growthLevelText = maxLevel ? $"Lv.{pid.level} (최대)" : $"Lv.{pid.level} → {pid.level + 1}"; + // 다음 등급까지 개체값 합이 얼마나 남았나. 경계는 PlayerInsectData가 단일 출처. + growthFooterText = grade == IVGrade.S + ? $"최고 등급입니다 — 개체값 합 {pid.IvSum}/{PlayerInsectData.MaxIV * 3}" + : $"다음 등급 {CapturePopupUI.GetGradeLabel(grade + 1)}까지 개체값 +{PlayerInsectData.MinIvSumFor(grade + 1) - pid.IvSum}" + + $" · 등급은 세 능력치의 합(0~{PlayerInsectData.MaxIV * 3})으로 정해집니다"; + for (int i = 0; i < GrowthStats.Length; i++) + growthIvTexts[i] = $"{pid.GetIv(GrowthStats[i])} / {PlayerInsectData.MaxIV}"; + } + + private void DrawGrowth() + { + UITheme t = UITheme.Instance; + if (DrawShell("성장 훈련", CandySubtitle(), "‹ 뒤로", out Rect panel)) { ChangePage(Page.MethodSelect); return; } + + PlayerInsectData pid = GetPid(); + if (pid == null || trainingManager == null) { ChangePage(Page.InsectSelect); return; } + InsectData data = collection.GetInsectData(pid.insectId); + + Rect summary = new Rect(panel.x + 20f, panel.y + 104f, panel.width - 40f, 150f); + DrawInsectSummary(summary, pid, data, 0f); + RefreshGrowthTexts(pid); + const float footerH = 50f; + + float rowsTop = summary.yMax + 14f; + const float gap = 10f; + float avail = panel.yMax - rowsTop - footerH - 20f; + // 세로 화면은 줄이 창 끝까지 차게 둔다 — 240에서 멈추면 아래 1/6이 빈 채로 남았다. + float rowH = Mathf.Clamp((avail - gap * 3f) / 4f, UIScale.MinTouchHeight + 40f, UIScale.IsMobileLayout ? 320f : 150f); + + // 레벨 — 보유 곤충 창과 같은 가격(TrainingManager가 컬렉션의 정본을 읽는다). + bool maxLevel = trainingManager.IsMaxLevel(pid); + int maxLv = collection != null ? collection.GetMaxLevel(pid.insectId) : pid.level; + int levelCost = trainingManager.GetLevelUpCost(pid); + Rect levelRow = new Rect(panel.x + 20f, rowsTop, panel.width - 40f, rowH); + if (DrawGrowthRow(levelRow, "레벨", t.accentAmber, growthLevelText, + maxLv > 0 ? (float)pid.level / maxLv : 1f, + LevelEffectText, + maxLevel ? "최대 레벨" : GrowthButtonLabel(0, "레벨업", levelCost), + !maxLevel && trainingManager.CanTrainLevel(pid))) + { + int before = pid.level; + if (trainingManager.TrainLevel(pid)) + ShowFeedback($"레벨 업! Lv.{before} → Lv.{pid.level}", true); } - TrainingMethod method = trainingManager.Methods[selectedMethodIndex]; - InsectSkill[] skills = trainingManager.GetAvailableSkills(method, pid); + for (int i = 0; i < GrowthStats.Length; i++) + { + GrowthStat stat = GrowthStats[i]; + int iv = pid.GetIv(stat); + bool maxed = iv >= PlayerInsectData.MaxIV; + int cost = trainingManager.GetStatTrainingCost(pid, stat); + Rect row = new Rect(panel.x + 20f, rowsTop + (i + 1) * (rowH + gap), panel.width - 40f, rowH); + if (DrawGrowthRow(row, StatName(stat), StatColor(stat), growthIvTexts[i], + (float)iv / PlayerInsectData.MaxIV, + maxed ? "이 능력치는 최대입니다" : StatEffect(stat), + maxed ? "최대" : GrowthButtonLabel(i + 1, "훈련", cost), + !maxed && trainingManager.CanTrainStat(pid, stat))) + { + if (trainingManager.TrainStat(pid, stat)) + { + IVGrade before = trainingManager.LastStatGradeBefore; + IVGrade after = pid.Grade; + if (after > before) + ShowFeedback($"등급 상승! {CapturePopupUI.GetGradeLabel(before)} → {CapturePopupUI.GetGradeLabel(after)}", true); + else + ShowFeedback($"{StatName(stat)} 개체값 {pid.GetIv(stat)}/{PlayerInsectData.MaxIV}", false); + } + } + } - learnHeaderStyle.normal.textColor = method.themeColor; - UIHelper.LabelFit(new Rect(px, py + 80, pw, 42), $"{method.displayName} | 기술별 캔디 비용", learnHeaderStyle); + infoStyle.normal.textColor = t.textMuted; + UIHelper.LabelFit(new Rect(panel.x + 28f, panel.yMax - footerH - 12f, panel.width - 56f, footerH), growthFooterText, infoStyle); + infoStyle.normal.textColor = t.textSecondary; + } - float listY = py + 128; - float listH = ph - 138; - // 진행바(DrawTrainingProgressBar)가 쿨다운 라벨(y 120~154) 아래 들어갈 자리까지 포함한 높이. - float itemH = UIScale.IsMobileLayout ? 192f : 180f; - Rect area = new Rect(px + 10, listY, pw - 20, listH); + /// 성장 훈련 한 줄 — 이름 · 현재값 · 막대 · 효과 · [버튼]. 눌렀으면 true. + private bool DrawGrowthRow(Rect r, string label, Color accent, string valueText, float ratio, string effect, + string buttonLabel, bool canPress) + { + UITheme t = UITheme.Instance; + UISurface.Card(r, t.surfaceRaised, t.surfaceBorder); + DrawRail(r, accent); + + const float blockH = 124f; + float top = r.y + Mathf.Max(10f, (r.height - blockH) / 2f); + float textX = r.x + 28f; + float barW = Mathf.Max(80f, r.width - 28f - 260f); + + titleStyle.normal.textColor = SkillUILayout.GetReadableAccent(accent); + UIHelper.LabelFit(new Rect(textX, top, 120f, 44f), label, titleStyle); + UIHelper.LabelFit(new Rect(textX + 124f, top, Mathf.Max(40f, barW - 124f), 44f), valueText, valueStyle); + UISurface.Meter(new Rect(textX, top + 54f, barW, 16f), ratio, accent); + UIHelper.LabelFit(new Rect(textX, top + 80f, barW, 36f), effect, infoStyle); + + float bh = SkillUILayout.GetTouchHeight(UIScale.IsMobileLayout, 76f, 84f); + bool prevEnabled = GUI.enabled; + GUI.enabled = prevEnabled && canPress; + bool pressed = UISurface.Button(new Rect(r.xMax - 226f, r.y + (r.height - bh) / 2f, 206f, bh), buttonLabel, + canPress ? t.btnPrimary : t.btnDisabled, buttonStyle); + GUI.enabled = prevEnabled; + return pressed && canPress; + } + + // ── 4. 기술 습득 ──────────────────────────────────────────────────────── + + private InsectSkill[] GetCachedSkills(TrainingMethod method, PlayerInsectData pid) + { + if (skillsCacheDirty || cachedSkills == null) + { + cachedSkills = trainingManager.GetAvailableSkills(method, pid); + skillsCacheDirty = false; + } + return cachedSkills; + } + + private void DrawSkillLearn() + { + UITheme t = UITheme.Instance; + PlayerInsectData pid = GetPid(); + if (pid == null || trainingManager == null || selectedMethodIndex < 0 + || trainingManager.Methods == null || selectedMethodIndex >= trainingManager.Methods.Length) + { + ChangePage(Page.MethodSelect); + return; + } + TrainingMethod method = trainingManager.Methods[selectedMethodIndex]; + if (DrawShell(method.displayName, CandySubtitle(), "‹ 뒤로", out Rect panel)) { ChangePage(Page.MethodSelect); return; } + + bool isDisc = method.methodId == TrainingManager.DiscMethodId; + UIHelper.LabelFit(new Rect(panel.x + 28f, panel.y + 102f, panel.width - 56f, 48f), + isDisc ? "디스크 1장으로 바로 배웁니다 — 캔디는 들지 않습니다" + : "필요 횟수만큼 훈련하면 배웁니다 — 캔디는 회마다 듭니다. 강한 기술일수록 비싸고 오래 걸립니다", + guideStyle); + + InsectSkill[] skills = GetCachedSkills(method, pid); + float listY = panel.y + 160f; + float itemH = UIScale.IsMobileLayout ? 200f : 168f; + Rect area = new Rect(panel.x + 16f, listY, panel.width - 32f, Mathf.Max(1f, panel.yMax - listY - 16f)); float contentHeight = skills.Length * itemH; - Rect view = new Rect(0, 0, area.width, contentHeight); + Rect view = new Rect(0, 0, area.width - 12f, contentHeight); directScroll.Handle(ref scrollPos, area, contentHeight, itemH * 0.35f); - scrollPos = GUI.BeginScrollView( - area, - scrollPos, - view, - GUIStyle.none, - GUIStyle.none); + scrollPos = GUI.BeginScrollView(area, scrollPos, view, GUIStyle.none, GUIStyle.none); + + if (skills.Length == 0) + { + GUI.EndScrollView(); + UIHelper.LabelFit(new Rect(area.x + 12f, area.y + 12f, area.width - 24f, 48f), + isDisc ? "이 곤충에게 쓸 수 있는 디스크가 없습니다" : "지금 배울 수 있는 기술이 없습니다 — 레벨을 올려 보세요", + emptyStyle); + return; + } for (int i = 0; i < skills.Length; i++) { InsectSkill skill = skills[i]; if (skill == null) continue; - Rect r = new Rect(0, i * itemH, view.width, itemH - 4); + Rect r = new Rect(4f, i * itemH, view.width - 4f, itemH - 8f); bool learned = pid.HasLearnedSkill(skill.skillId); - DrawSkillCard(r, skill, learned, method.themeColor); - if (!learned) DrawTrainingProgressBar(r, skill, pid, method, method.themeColor); + int required = trainingManager.GetRequiredSessions(method, skill); + int trainingCost = trainingManager.GetTrainingCost(method, pid, skill.skillId); + // 가격 줄 — 이 화면에서 가장 중요한 숫자다(요구: 기술 가격은 능력에 맞게). 배운 기술엔 가격을 안 적는다. + string costLine = learned ? $"쿨다운 {skill.cooldownTurns}턴" + : isDisc ? $"쿨다운 {skill.cooldownTurns}턴 · 보유 디스크 {trainingManager.GetDiscCount(skill.skillId)}장" + : $"쿨다운 {skill.cooldownTurns}턴 · 습득까지 {required}회 · 총 {trainingCost * required} 캔디"; + DrawSkillCard(r, skill, learned, costLine, 230f); - if (!learned) + if (learned) { - int trainingCost = trainingManager.GetTrainingCost(method, pid, skill.skillId); - bool canAfford = trainingManager.CanTrain(method, pid, skill.skillId); - - // **교체는 마지막 회차에만 묻는다.** 누적 훈련은 여러 번 눌러야 습득되는데 - // 슬롯이 찼다고 매 회차 교체 화면으로 보내면, 아직 배우지도 않은 기술 때문에 - // 멀쩡한 기술을 몇 번이고 버리라고 묻는 꼴이 된다. - int required = trainingManager.GetRequiredSessions(method, skill); - int progress = pid.GetTrainingProgress(skill.skillId); - bool isFinalSession = progress + 1 >= required; - bool needsReplace = isFinalSession && pid.IsSkillsFull(); - - // 디스크 방식은 습득 회차에 디스크 1장이 **소모**된다 — 버튼에 그 사실과 보유 장수를 - // 적는다. 옛은 캔디 1만 보여 줘서 Legendary 디스크가 아무 표시 없이 사라졌다. - bool isDisc = method.methodId == TrainingManager.DiscMethodId; - string btnLabel = isDisc ? (needsReplace ? "교체" : "디스크 사용") + $" ×{trainingManager.GetDiscCount(skill.skillId)}" - : needsReplace ? $"교체 {trainingCost}" - : required > 1 ? $"훈련 {trainingCost}" // 회차는 진행바 라벨 하나로만 보여 준다 - : $"습득 {trainingCost}"; - GUI.backgroundColor = canAfford ? (needsReplace ? LearnBtnFullCol : LearnBtnOkCol) : LearnBtnOffCol; - GUI.enabled = canAfford; - float learnButtonH = UIScale.IsMobileLayout ? 64f : 52f; - learnBtnStyle.fontSize = isDisc ? 22 : 32; // "디스크 사용 ×n"은 32pt로 140px를 넘는다 — 1회 캐시 스타일이라 매 프레임 지정 - bool learnPressed = GUI.Button(new Rect(r.x + r.width - 160, r.y + r.height / 2f - learnButtonH * 0.5f, 140, learnButtonH), btnLabel, learnBtnStyle); - learnBtnStyle.fontSize = 32; - if (learnPressed) - { - if (needsReplace) - { - pendingNewSkillId = skill.skillId; - ChangePage(Page.SkillReplace); - } - else if (trainingManager.TrainSkill(method, pid, skill.skillId)) - { - // 습득 회차인지 중간 회차인지 말해 준다 — 안 그러면 캔디만 나가고 - // 아무 일도 안 일어난 것처럼 보인다(누적 훈련의 가장 큰 함정). - feedbackMsg = trainingManager.LastTrainingLearned - ? (isDisc ? $"{skill.displayName} 습득 완료! (디스크 1장 소모)" : $"{skill.displayName} 습득 완료!") - : $"{skill.displayName} 훈련 {trainingManager.LastTrainingProgress}/{trainingManager.LastTrainingRequired}"; - feedbackTimer = 2f; - } - } - GUI.enabled = true; - GUI.backgroundColor = Color.white; + UISurface.Chip(new Rect(r.xMax - 206f, r.y + r.height / 2f - 22f, 186f, 44f), "습득 완료", + Color.Lerp(t.surfaceBase, t.accentMint, 0.3f), t.accentMint); + continue; } - else + + int progress = pid.GetTrainingProgress(skill.skillId); + if (required > 1) DrawTrainingProgressBar(r, progress, required, method.themeColor, 250f); + + bool canAfford = trainingManager.CanTrain(method, pid, skill.skillId); + // **교체는 마지막 회차에만 묻는다.** 누적 훈련은 여러 번 눌러야 습득되는데 + // 슬롯이 찼다고 매 회차 교체 화면으로 보내면, 아직 배우지도 않은 기술 때문에 + // 멀쩡한 기술을 몇 번이고 버리라고 묻는 꼴이 된다. + bool isFinalSession = progress + 1 >= required; + bool needsReplace = isFinalSession && pid.IsSkillsFull(); + + // 디스크 방식은 습득 회차에 디스크 1장이 **소모**된다 — 버튼에 그 사실을 적는다. + // 옛은 캔디 1만 보여 줘서 Legendary 디스크가 아무 표시 없이 사라졌다. + string verb = isDisc ? (needsReplace ? "교체" : "디스크 사용") + : needsReplace ? "교체" + : required > 1 ? $"훈련 {Mathf.Min(progress + 1, required)}/{required}" + : "습득"; + string btnLabel = isDisc ? $"{verb}\n디스크 1장" : $"{verb}\n캔디 {trainingCost}"; + + float bh = SkillUILayout.GetTouchHeight(UIScale.IsMobileLayout, 76f, 84f); + bool prevEnabled = GUI.enabled; + GUI.enabled = prevEnabled && canAfford; + bool pressed = UISurface.Button(new Rect(r.xMax - 226f, r.y + (r.height - bh) / 2f, 206f, bh), btnLabel, + canAfford ? (needsReplace ? t.accentAmber : t.btnPrimary) : t.btnDisabled, buttonStyle); + GUI.enabled = prevEnabled; + + if (pressed && canAfford && !directScroll.IsDragging) { - GUI.Label(new Rect(r.x + r.width - 180, r.y + r.height / 2f - 28, 160, 56), - "습득완료", learnedTagStyle); + if (needsReplace) + { + pendingNewSkillId = skill.skillId; + ChangePage(Page.SkillReplace); + GUI.EndScrollView(); + return; + } + if (trainingManager.TrainSkill(method, pid, skill.skillId)) + { + // 습득 회차인지 중간 회차인지 말해 준다 — 안 그러면 캔디만 나가고 + // 아무 일도 안 일어난 것처럼 보인다(누적 훈련의 가장 큰 함정). + if (trainingManager.LastTrainingLearned) + ShowFeedback(isDisc ? $"{skill.displayName} 습득! (디스크 1장 소모)" : $"{skill.displayName} 습득!", true); + else + ShowFeedback($"{skill.displayName} 훈련 {trainingManager.LastTrainingProgress}/{trainingManager.LastTrainingRequired}", false); + } } } GUI.EndScrollView(); + UISurface.ScrollAffordance(area, scrollPos, contentHeight, t.accentCoral); } - private void DrawSkillEquip() + /// + /// 누적 훈련 진행 바. 얇은 것은 각지게 그린다(UISurface.Flat) — + /// 둥근 배경은 9-slice라 짧은 변이 반경+4보다 작으면 슬라이스가 겹쳐 뭉개진다 + /// (rules/ui-layout.md). + /// + private void DrawTrainingProgressBar(Rect r, int progress, int required, Color accent, float rightReserve) { - DrawPanel("EQUIP SKILLS", out float px, out float py, out float pw, out float ph); - if (DrawBackButton(px, py)) { ChangePage(Page.MethodSelect); return; } + UITheme t = UITheme.Instance; + float barW = r.width - 28f - rightReserve - 80f; + if (barW < 40f) return; + progress = Mathf.Clamp(progress, 0, required); + Rect track = new Rect(r.x + 28f, r.yMax - 22f, barW, 8f); + UISurface.Flat(track, Color.Lerp(t.surfaceBase, Color.black, 0.4f)); + if (progress > 0) + UISurface.Flat(new Rect(track.x, track.y, track.width * progress / required, track.height), accent); + UIHelper.LabelFit(new Rect(track.xMax + 12f, track.y - 12f, 70f, 30f), $"{progress}/{required}", detailStyle); + } - PlayerInsectData pid = GetPid(); - if (pid == null) { ChangePage(Page.InsectSelect); return; } + /// 기술 카드 — 레일 · 이름 · 속성·효과 · 셋째 줄. 오른쪽 는 버튼 자리. + private void DrawSkillCard(Rect r, InsectSkill skill, bool learned, string thirdLine, float rightReserve) + { + UITheme t = UITheme.Instance; + Color sc = t.GetSkillColor(skill.effectType); + UISurface.Card(r, learned ? t.surfaceBase : t.surfaceRaised, t.surfaceBorder); + DrawRail(r, learned ? Color.Lerp(sc, t.surfaceBase, 0.5f) : sc); + + float textX = r.x + 28f; + float textW = Mathf.Max(80f, r.width - 28f - rightReserve); + titleStyle.normal.textColor = learned ? t.textSecondary : SkillUILayout.GetReadableAccent(sc); + UIHelper.LabelFit(new Rect(textX, r.y + 10f, textW, 44f), skill.displayName, titleStyle); + UIHelper.LabelFit(new Rect(textX, r.y + 56f, textW, 36f), + $"{InsectTypeChart.GetDisplayName(skill.element)} 타입 · {SkillEffectText(skill)}", infoStyle); + if (!string.IsNullOrEmpty(thirdLine)) + { + // 셋째 줄은 가격·교체 안내라 흐린 textMuted로 두면 안 읽힌다(검수 캡처에서 확인). + detailStyle.normal.textColor = learned ? t.textMuted : t.textSecondary; + UIHelper.LabelFit(new Rect(textX, r.y + 94f, textW, 32f), thirdLine, detailStyle); + detailStyle.normal.textColor = t.textMuted; + } + } - InsectData data = collection.GetInsectData(pid.insectId); - Color rc = data != null ? UITheme.Instance.GetInsectRarityColor(data.rarity) : Color.gray; + /// 효과 한 줄 — 7종 전부(옛 화면은 피해·공격 상승 외엔 전부 "ATK DOWN"으로 적었다). + private static string SkillEffectText(InsectSkill skill) + { + switch (skill.effectType) + { + case SkillEffectType.BuffAttack: + return $"공격 +{skill.effectValue * 100f:0}% · {skill.effectDurationTurns}턴"; + case SkillEffectType.DebuffAttack: + return $"상대 공격 -{skill.effectValue * 100f:0}% · {skill.effectDurationTurns}턴"; + case SkillEffectType.DefenseBuff: + return $"방어 +{skill.effectValue * 100f:0}% · {skill.effectDurationTurns}턴"; + case SkillEffectType.Heal: + return $"HP {skill.effectValue * 100f:0}% 회복"; + case SkillEffectType.PoisonDot: + return $"독 {skill.power} × {skill.effectDurationTurns}턴"; + case SkillEffectType.Stun: + return "상대 행동 1회 봉인"; + default: + return skill.accuracy < 0.999f + ? $"위력 {skill.power} · 명중 {skill.accuracy * 100f:0}%" + : $"위력 {skill.power}"; + } + } + + // ── 5. 기술 장착 ──────────────────────────────────────────────────────── + + private string equipSubtitle = string.Empty; + private int equipSubtitleCount = -1; + + private void DrawSkillEquip() + { + UITheme t = UITheme.Instance; + PlayerInsectData pid = GetPid(); + if (pid == null || trainingManager == null) { ChangePage(Page.InsectSelect); return; } - equipNameStyle.normal.textColor = rc; - UIHelper.LabelFit(new Rect(px, py + 80, pw, 42), - $"{(data != null ? data.displayName : pid.insectId)} - Skill Slots", equipNameStyle); + int equipped = pid.EquippedCount(); + if (equipped != equipSubtitleCount) + { + equipSubtitleCount = equipped; + equipSubtitle = $"{equipped}/{PlayerInsectData.MaxEquipSlots} 장착"; + } + if (DrawShell("기술 장착", equipSubtitle, "‹ 뒤로", out Rect panel)) { ChangePage(Page.MethodSelect); return; } - float slotY = py + 128; - float slotH = UIScale.IsMobileLayout ? 126f : 112f; + InsectData data = collection.GetInsectData(pid.insectId); + UIHelper.LabelFit(new Rect(panel.x + 28f, panel.y + 102f, panel.width - 56f, 44f), + $"{(data != null ? data.displayName : pid.insectId)} — 전투에 들고 나갈 기술 {PlayerInsectData.MaxEquipSlots}개", + sectionStyle); + float slotY = panel.y + 154f; + float slotH = UIScale.IsMobileLayout ? 128f : 100f; + const float slotGap = 8f; for (int i = 0; i < PlayerInsectData.MaxEquipSlots; i++) { - float sy = slotY + i * (slotH + 4); + Rect slot = new Rect(panel.x + 20f, slotY + i * (slotH + slotGap), panel.width - 40f, slotH); string eqId = pid.GetEquippedSkill(i); InsectSkill eqSkill = eqId != null ? trainingManager.GetSkill(eqId) : null; - UISurface.Card(new Rect(px + 15, sy, pw - 30, slotH), EquipSlotBgCol, UITheme.Instance.surfaceBorder); - - GUI.color = Color.white; - GUI.Label(new Rect(px + 15, sy, 60, slotH), $"{i + 1}", equipSlotNumStyle); + UISurface.Card(slot, eqSkill != null ? t.surfaceRaised : t.surfaceBase, t.surfaceBorder); + Rect badge = new Rect(slot.x + 16f, slot.center.y - 22f, 44f, 44f); + UISurface.Rounded(badge, t.surfaceBase, UITheme.Radius.Chip); + GUI.Label(badge, (i + 1).ToString(), slotNumStyle); - if (eqSkill != null) + if (eqSkill == null) { - Color sc = GetSkillColor(eqSkill.effectType); - GUI.color = sc; - GUI.DrawTexture(new Rect(px + 15, sy, 5, slotH), Texture2D.whiteTexture); - GUI.color = Color.white; - - equipSlotNameStyle.normal.textColor = SkillUILayout.GetReadableAccent(sc); - GUI.Label(new Rect(px + 90, sy + 8, pw - 320, 52), - eqSkill.displayName, equipSlotNameStyle); - - string typeStr = eqSkill.effectType == SkillEffectType.Damage ? $"DMG {eqSkill.power}" : - eqSkill.effectType == SkillEffectType.BuffAttack ? $"ATK UP +{eqSkill.effectValue * 100:0}%" : - $"ATK DOWN -{eqSkill.effectValue * 100:0}%"; - GUI.Label(new Rect(px + 90, sy + 64, pw - 320, 40), - $"{typeStr} | CD: {eqSkill.cooldownTurns}t", equipSlotInfoStyle); - - GUI.backgroundColor = EquipRemBgCol; - float removeButtonH = UIScale.IsMobileLayout ? 62f : 48f; - if (GUI.Button(new Rect(px + pw - 170, sy + (slotH - removeButtonH) * 0.5f, 130, removeButtonH), "해제", equipRemBtnStyle)) - { - pid.EquipSkill(null, i); - // **`ForceSave`만으로는 부족하다** — 그건 디스크에만 쓰고 `InsectUpdated`를 - // 쏘지 않는다. 보유 목록의 "장착: c/d" 문자열 캐시(`ownedInfoCache`)는 그 - // 이벤트로만 비워지므로, 여기서 알리지 않으면 **이 화면 자신의 장착·해제만** - // 목록에 반영되지 않고 옛 값으로 얼어붙는다(닫았다 열어도 유지된다). - collection.NotifyInsectChanged(pid); - collection.ForceSave(); - } - GUI.backgroundColor = Color.white; + UIHelper.LabelFit(new Rect(slot.x + 80f, slot.center.y - 20f, slot.width - 120f, 40f), "빈 슬롯", emptyStyle); + continue; } - else + + Color sc = t.GetSkillColor(eqSkill.effectType); + UISurface.Flat(new Rect(slot.x + 70f, slot.y + UITheme.Radius.Card, 5f, slot.height - UITheme.Radius.Card * 2f), sc); + float textW = Mathf.Max(80f, slot.width - 80f - 200f); + titleStyle.normal.textColor = SkillUILayout.GetReadableAccent(sc); + UIHelper.LabelFit(new Rect(slot.x + 90f, slot.center.y - 44f, textW, 44f), eqSkill.displayName, titleStyle); + UIHelper.LabelFit(new Rect(slot.x + 90f, slot.center.y + 2f, textW, 36f), + $"{SkillEffectText(eqSkill)} · 쿨다운 {eqSkill.cooldownTurns}턴", infoStyle); + + float bh = ActionHeight; + if (UISurface.Button(new Rect(slot.xMax - 176f, slot.center.y - bh / 2f, 156f, bh), "해제", + Color.Lerp(t.btnDanger, t.surfaceRaised, 0.35f), buttonStyle)) { - GUI.Label(new Rect(px + 90, sy + slotH / 2f - 20, pw - 200, 40), "빈 슬롯", equipEmptyStyle); + pid.EquipSkill(null, i); + // **`ForceSave`만으로는 부족하다** — 그건 디스크에만 쓰고 `InsectUpdated`를 + // 쏘지 않는다. 보유 목록의 문자열 캐시(`ownedInfoCache`)는 그 이벤트로만 + // 비워지므로, 여기서 알리지 않으면 이 화면 자신의 장착·해제만 목록에 반영되지 않는다. + collection.NotifyInsectChanged(pid); + collection.ForceSave(); } } - float learnedY = slotY + PlayerInsectData.MaxEquipSlots * (slotH + 4) + 12; - GUI.Label(new Rect(px + 15, learnedY, pw - 30, 42), "Learned Skills:", equipLearnedHeaderStyle); - - float listY2 = learnedY + 50; - float listH2 = ph - (listY2 - py) - 10; - float itemH2 = UIScale.IsMobileLayout ? 108f : 96f; - + float learnedY = slotY + PlayerInsectData.MaxEquipSlots * (slotH + slotGap) + 10f; List learned = pid.learnedSkillIds ?? new List(); - Rect area = new Rect(px + 15, listY2, pw - 30, listH2); - float contentHeight = learned.Count * itemH2; - Rect viewR = new Rect(0, 0, area.width, contentHeight); - directScroll.Handle(ref scrollPos, area, contentHeight, itemH2 * 0.35f); - scrollPos = GUI.BeginScrollView( - area, - scrollPos, - viewR, - GUIStyle.none, - GUIStyle.none); + UIHelper.LabelFit(new Rect(panel.x + 28f, learnedY, panel.width - 56f, 44f), + $"배운 기술 {learned.Count}/{PlayerInsectData.MaxLearnedSkills}", sectionStyle); + + float listY = learnedY + 52f; + float itemH = UIScale.IsMobileLayout ? 116f : 96f; + Rect area = new Rect(panel.x + 16f, listY, panel.width - 32f, Mathf.Max(1f, panel.yMax - listY - 16f)); + float contentHeight = learned.Count * itemH; + Rect view = new Rect(0, 0, area.width - 12f, contentHeight); + directScroll.Handle(ref scrollPos, area, contentHeight, itemH * 0.35f); + scrollPos = GUI.BeginScrollView(area, scrollPos, view, GUIStyle.none, GUIStyle.none); for (int i = 0; i < learned.Count; i++) { InsectSkill sk = trainingManager.GetSkill(learned[i]); if (sk == null) continue; - - Rect r = new Rect(0, i * itemH2, viewR.width, itemH2 - 3); + Rect r = new Rect(4f, i * itemH, view.width - 4f, itemH - 8f); bool isEquipped = IsEquipped(pid, sk.skillId); + Color sc = t.GetSkillColor(sk.effectType); - UISurface.Card(r, EquipLearnedBgCol, UITheme.Instance.surfaceBorder); - - Color sc2 = GetSkillColor(sk.effectType); - GUI.color = sc2; - GUI.DrawTexture(new Rect(r.x, r.y, 5, r.height), Texture2D.whiteTexture); - GUI.color = Color.white; - - equipLearnedNameStyle.normal.textColor = isEquipped - ? EquipNameDimCol - : SkillUILayout.GetReadableAccent(sc2); - GUI.Label(new Rect(r.x + 16, r.y + 6, r.width - 210, 48), - sk.displayName, equipLearnedNameStyle); + UISurface.Card(r, isEquipped ? t.surfaceBase : t.surfaceRaised, t.surfaceBorder); + DrawRail(r, isEquipped ? Color.Lerp(sc, t.surfaceBase, 0.5f) : sc); + float textW = Mathf.Max(80f, r.width - 28f - 200f); + titleStyle.normal.textColor = isEquipped ? t.textSecondary : SkillUILayout.GetReadableAccent(sc); + UIHelper.LabelFit(new Rect(r.x + 28f, r.center.y - 42f, textW, 42f), sk.displayName, titleStyle); + UIHelper.LabelFit(new Rect(r.x + 28f, r.center.y + 2f, textW, 34f), SkillEffectText(sk), infoStyle); - GUI.Label(new Rect(r.x + 16, r.y + 56, r.width - 210, 36), - sk.effectType == SkillEffectType.Damage ? $"DMG {sk.power}" : sk.effectType.ToString(), equipLearnedInfoStyle); - - if (!isEquipped && pid.EquippedCount() < PlayerInsectData.MaxEquipSlots) + if (isEquipped) + { + UISurface.Chip(new Rect(r.xMax - 176f, r.center.y - 20f, 156f, 40f), "장착 중", + Color.Lerp(t.surfaceBase, t.accentMint, 0.3f), t.accentMint); + } + else if (pid.EquippedCount() < PlayerInsectData.MaxEquipSlots) { - GUI.backgroundColor = EquipEqBgCol; - float equipButtonH = UIScale.IsMobileLayout ? 62f : 48f; - if (GUI.Button(new Rect(r.x + r.width - 134f, r.y + r.height / 2f - equipButtonH * 0.5f, 118f, equipButtonH), "Equip", equipEqBtnStyle)) + float bh = ActionHeight; + if (UISurface.Button(new Rect(r.xMax - 176f, r.center.y - bh / 2f, 156f, bh), "장착", t.btnPrimary, buttonStyle) + && !directScroll.IsDragging) { for (int s = 0; s < PlayerInsectData.MaxEquipSlots; s++) { @@ -750,28 +967,26 @@ private void DrawSkillEquip() collection.ForceSave(); // q_equip 진행도 — 사용자 직접 장착만 카운트 // (TrainingManager 자동 장착/PlayerInsectCollection 마이그레이션은 제외) - InsectGame.Core.TutorialQuestManager.Instance?.NotifySkillEquipped(); + TutorialQuestManager.Instance?.NotifySkillEquipped(); break; } } } - GUI.backgroundColor = Color.white; - } - else if (isEquipped) - { - GUI.Label(new Rect(r.x + r.width - 160, r.y + r.height / 2f - 18, 150, 36), "장착중", equipEqTagStyle); } } GUI.EndScrollView(); + UISurface.ScrollAffordance(area, scrollPos, contentHeight, t.accentCoral); } + // ── 6. 기술 교체 ──────────────────────────────────────────────────────── + private void DrawSkillReplace() { - DrawPanel("REPLACE SKILL", out float px, out float py, out float pw, out float ph); - if (DrawBackButton(px, py)) + UITheme t = UITheme.Instance; + if (DrawShell("기술 교체", $"{PlayerInsectData.MaxLearnedSkills}/{PlayerInsectData.MaxLearnedSkills} 가득 참", "‹ 뒤로", out Rect panel)) { - ChangePage(Page.SkillLearn); pendingNewSkillId = null; + ChangePage(Page.SkillLearn); return; } @@ -785,94 +1000,63 @@ private void DrawSkillReplace() InsectSkill newSkill = trainingManager.GetSkill(pendingNewSkillId); if (newSkill == null) { ChangePage(Page.SkillLearn); return; } - TrainingMethod method = selectedMethodIndex >= 0 ? trainingManager.Methods[selectedMethodIndex] : null; + TrainingMethod method = selectedMethodIndex >= 0 && trainingManager.Methods != null + && selectedMethodIndex < trainingManager.Methods.Length + ? trainingManager.Methods[selectedMethodIndex] : null; int replaceCost = trainingManager.GetTrainingCost(method, pid, pendingNewSkillId); - bool isDiscReplace = method != null && method.methodId == TrainingManager.DiscMethodId; - GUI.Label(new Rect(px, py + 80, pw, 44), - isDiscReplace - ? $"기술이 가득 찼습니다 ({PlayerInsectData.MaxLearnedSkills}/{PlayerInsectData.MaxLearnedSkills}) · 잊으면 디스크 1장 소모" - : $"기술이 가득 찼습니다 ({PlayerInsectData.MaxLearnedSkills}/{PlayerInsectData.MaxLearnedSkills}) · 교체 비용 {replaceCost} 캔디", - replaceHeaderStyle); - - Color nc = GetSkillColor(newSkill.effectType); - replaceNewStyle.normal.textColor = SkillUILayout.GetReadableAccent(nc); - string newInfo = newSkill.effectType == SkillEffectType.Damage ? $"DMG {newSkill.power}" : - newSkill.effectType == SkillEffectType.BuffAttack ? $"ATK UP +{newSkill.effectValue * 100:0}%" : - $"ATK DOWN -{newSkill.effectValue * 100:0}%"; - GUI.Label(new Rect(px + 24f, py + 124, pw - 48f, 58), - $"New: {newSkill.displayName} ({newInfo})", replaceNewStyle); - - GUI.color = nc; - GUI.DrawTexture(new Rect(px + 100, py + 188, pw - 200, 2), Texture2D.whiteTexture); - GUI.color = Color.white; + + // 새 기술 카드 — 무엇을 얻는지 먼저 보여 준다. + Rect newCard = new Rect(panel.x + 20f, panel.y + 104f, panel.width - 40f, 136f); + DrawSkillCard(newCard, newSkill, false, + isDiscReplace ? "잊을 기술을 고르면 디스크 1장을 써서 바로 배웁니다" + : $"잊을 기술을 고르면 캔디 {replaceCost}로 마지막 훈련을 마치고 배웁니다", + 170f); + UISurface.Chip(new Rect(newCard.xMax - 150f, newCard.y + 12f, 130f, 36f), "새 기술", + Color.Lerp(t.surfaceBase, t.accentAmber, 0.35f), t.accentAmber); float cardH = 140f; - System.Collections.Generic.List learned = pid.learnedSkillIds ?? new System.Collections.Generic.List(); - float cancelY = py + ph - 72f; - Rect listArea = new Rect( - px + 15f, - py + 204f, - pw - 30f, - Mathf.Max(80f, cancelY - (py + 204f) - 12f)); + List learned = pid.learnedSkillIds ?? new List(); + float cancelH = ActionHeight; + float cancelY = panel.yMax - cancelH - 16f; + float listTop = newCard.yMax + 16f; + Rect listArea = new Rect(panel.x + 16f, listTop, panel.width - 32f, Mathf.Max(80f, cancelY - listTop - 12f)); float contentHeight = GetSkillReplacementContentHeight(learned.Count); - Rect viewRect = new Rect(0f, 0f, listArea.width, contentHeight); + Rect viewRect = new Rect(0f, 0f, listArea.width - 12f, contentHeight); directScroll.Handle(ref scrollPos, listArea, contentHeight, cardH * 0.35f); - scrollPos = GUI.BeginScrollView( - listArea, - scrollPos, - viewRect, - GUIStyle.none, - GUIStyle.none); + scrollPos = GUI.BeginScrollView(listArea, scrollPos, viewRect, GUIStyle.none, GUIStyle.none); for (int i = 0; i < learned.Count; i++) { InsectSkill old = trainingManager.GetSkill(learned[i]); if (old == null) continue; - float cy = i * (cardH + 4f); - Color oc = GetSkillColor(old.effectType); - - UISurface.Card(new Rect(0f, cy, viewRect.width, cardH), ReplaceOldBgCol, UITheme.Instance.surfaceBorder); - GUI.color = oc; - GUI.DrawTexture(new Rect(0f, cy, 6f, cardH), Texture2D.whiteTexture); - GUI.color = Color.white; - - replaceOldNameStyle.normal.textColor = SkillUILayout.GetReadableAccent(oc); - GUI.Label(new Rect(25f, cy + 8f, viewRect.width - 270f, 52f), - old.displayName, replaceOldNameStyle); - - string oldInfo = old.effectType == SkillEffectType.Damage ? $"DMG {old.power}" : - old.effectType == SkillEffectType.BuffAttack ? $"ATK UP +{old.effectValue * 100:0}%" : - $"ATK DOWN -{old.effectValue * 100:0}%"; - GUI.Label(new Rect(25f, cy + 66f, viewRect.width - 270f, 40f), - $"{oldInfo} | CD: {old.cooldownTurns}t", replaceOldInfoStyle); - - GUI.backgroundColor = ReplaceForgetBgCol; - if (GUI.Button( - new Rect(viewRect.width - 195f, cy + cardH / 2f - 28f, 160f, 56f), - "잊기", - replaceForgetBtnStyle)) + Rect r = new Rect(4f, i * (cardH + 4f), viewRect.width - 4f, cardH); + DrawSkillCard(r, old, false, IsEquipped(pid, old.skillId) ? "장착 중 — 잊으면 새 기술이 그 슬롯에 들어갑니다" : null, 210f); + + float bh = ActionHeight; + if (UISurface.Button(new Rect(r.xMax - 186f, r.center.y - bh / 2f, 166f, bh), "잊기", + Color.Lerp(t.btnDanger, t.surfaceRaised, 0.35f), buttonStyle) + && !directScroll.IsDragging) { if (method != null && trainingManager.TrainSkill(method, pid, pendingNewSkillId, old.skillId)) { - feedbackMsg = $"{old.displayName}을 잊고 {newSkill.displayName}을 습득했습니다!"; - feedbackTimer = 2.5f; + ShowFeedback($"{old.displayName}을(를) 잊고 {newSkill.displayName}을(를) 배웠습니다!", true); pendingNewSkillId = null; ChangePage(Page.SkillLearn); + GUI.EndScrollView(); + return; } } - GUI.backgroundColor = Color.white; } GUI.EndScrollView(); + UISurface.ScrollAffordance(listArea, scrollPos, contentHeight, t.accentCoral); - GUI.backgroundColor = ReplaceCancelBgCol; - if (GUI.Button(new Rect(px + pw / 2f - 100, cancelY, 200, 56), "Cancel", replaceCancelBtnStyle)) + if (UISurface.Button(new Rect(panel.center.x - 120f, cancelY, 240f, cancelH), "취소", t.surfaceBorder, buttonStyle)) { pendingNewSkillId = null; ChangePage(Page.SkillLearn); } - GUI.backgroundColor = Color.white; } internal static float GetSkillReplacementContentHeight(int learnedSkillCount) @@ -880,70 +1064,33 @@ internal static float GetSkillReplacementContentHeight(int learnedSkillCount) return Mathf.Max(0, learnedSkillCount) * 144f; } - /// - /// 누적 훈련 진행 바. 얇은 것은 각지게 그린다(UISurface.Flat) — - /// 둥근 배경은 9-slice라 짧은 변이 반경+4보다 작으면 슬라이스가 겹쳐 뭉개진다 - /// (rules/ui-layout.md). - /// - private void DrawTrainingProgressBar(Rect r, InsectSkill skill, PlayerInsectData pid, TrainingMethod method, Color accent) - { - if (skill == null || pid == null || trainingManager == null) return; + // ── 알림 ──────────────────────────────────────────────────────────────── - int required = trainingManager.GetRequiredSessions(method, skill); - if (required <= 1) return; // 한 번에 배우는 기술엔 바를 그리지 않는다 - - int progress = Mathf.Clamp(pid.GetTrainingProgress(skill.skillId), 0, required); - UITheme t = UITheme.Instance; - - float barW = r.width - 220f; - if (barW < 40f) return; - // 쿨다운 라벨이 r.y+120~154를 쓴다 — 그 아래(158+)에 놓아야 글자를 관통하지 않는다. - Rect track = new Rect(r.x + 18f, Mathf.Max(r.y + 160f, r.y + r.height - 18f), barW, 7f); - UISurface.Flat(track, t.surfaceBorder); - if (progress > 0) - UISurface.Flat(new Rect(track.x, track.y, track.width * progress / required, track.height), accent); - - cardCdStyle.normal.textColor = t.textMuted; - UIHelper.LabelFit(new Rect(track.xMax + 10f, track.y - 12f, 90f, 30f), - $"{progress}/{required}", cardCdStyle); - } - - private void DrawSkillCard(Rect r, InsectSkill skill, bool learned, Color accent) + private void ShowFeedback(string message, bool big) { - InitTrainDetailStyles(); - GUI.color = learned ? CardLearnedBgCol : CardActiveBgCol; - GUI.DrawTexture(r, Texture2D.whiteTexture); - - Color sc = GetSkillColor(skill.effectType); - GUI.color = sc; - GUI.DrawTexture(new Rect(r.x, r.y, 6, r.height), Texture2D.whiteTexture); - GUI.color = Color.white; - - // textColor 동적 갱신 (learned/sc). - cardSkillNameStyle.normal.textColor = learned - ? CardLearnedNameCol - : SkillUILayout.GetReadableAccent(sc); - GUI.Label(new Rect(r.x + 18, r.y + 6, r.width - 200, 58), - skill.displayName, cardSkillNameStyle); - - string elementName = InsectTypeChart.GetDisplayName(skill.element); - string typeStr = skill.effectType == SkillEffectType.Damage ? $"{elementName} 타입 · 데미지: {skill.power}" : - skill.effectType == SkillEffectType.BuffAttack ? $"{elementName} 타입 · ATK UP +{skill.effectValue * 100:0}% ({skill.effectDurationTurns}t)" : - $"{elementName} 타입 · ATK DOWN -{skill.effectValue * 100:0}% ({skill.effectDurationTurns}t)"; - GUI.Label(new Rect(r.x + 18, r.y + 66, r.width - 200, 50), - typeStr, cardDescStyle); - - UIHelper.LabelFit(new Rect(r.x + 18, r.y + 120, r.width - 200, 34), - $"쿨다운: {skill.cooldownTurns}턴", cardCdStyle); + feedbackMsg = message; + feedbackBig = big; + feedbackTimer = big ? 2.5f : 1.8f; } + /// 헤더 바로 아래 가운데 토스트 — 창 안 목록을 가리지 않게 짧게 띄운다. private void DrawFeedback() { - InitTrainDetailStyles(); - float alpha = Mathf.Clamp01(feedbackTimer / 0.5f); - // alpha 동적이라 매 호출 textColor 갱신 (new Color struct stack 거짓양성). - feedbackStyle.normal.textColor = new Color(0.3f, 1f, 0.5f, alpha); - GUI.Label(new Rect(0, UIScale.VirtualScreenHeight * 0.15f, UIScale.VirtualScreenWidth, 60), feedbackMsg, feedbackStyle); + UITheme t = UITheme.Instance; + float alpha = Mathf.Clamp01(feedbackTimer / 0.4f); + Rect panel = PanelRect(); + float w = Mathf.Min(panel.width - 80f, 760f); + Rect toast = new Rect(panel.center.x - w / 2f, panel.y + 100f, w, 76f); + Color accent = feedbackBig ? t.accentAmber : t.accentMint; + + Color prev = GUI.color; + GUI.color = new Color(1f, 1f, 1f, alpha); + UISurface.Card(toast, t.surfaceCard, Color.Lerp(t.surfaceBorder, accent, 0.7f)); + UISurface.Flat(new Rect(toast.x + UITheme.Radius.Card, toast.y + 3f, toast.width - UITheme.Radius.Card * 2f, 4f), accent); + GUI.color = prev; + + feedbackStyle.normal.textColor = new Color(accent.r, accent.g, accent.b, alpha); + UIHelper.LabelFit(new Rect(toast.x + 16f, toast.y + 8f, toast.width - 32f, toast.height - 12f), feedbackMsg, feedbackStyle); } private PlayerInsectData GetPid() @@ -952,24 +1099,13 @@ private PlayerInsectData GetPid() return collection.GetByInstanceId(selectedInstanceId); } - private bool IsEquipped(PlayerInsectData pid, string skillId) + private static bool IsEquipped(PlayerInsectData pid, string skillId) { for (int i = 0; i < PlayerInsectData.MaxEquipSlots; i++) if (pid.GetEquippedSkill(i) == skillId) return true; return false; } - private Color GetSkillColor(SkillEffectType type) - { - switch (type) - { - case SkillEffectType.Damage: return new Color(0.9f, 0.35f, 0.3f); - case SkillEffectType.BuffAttack: return new Color(0.3f, 0.8f, 0.4f); - case SkillEffectType.DebuffAttack: return new Color(0.7f, 0.4f, 0.9f); - default: return Color.gray; - } - } - public void AutoWire(TrainingManager tm, PlayerInsectCollection col, PlayerCandyInventory candy) { if (trainingManager == null) trainingManager = tm; @@ -989,5 +1125,29 @@ public void AutoWire(TrainingManager tm, PlayerInsectCollection col, PlayerCandy if (candyInventory == null) candyInventory = candy; } +#if UNITY_EDITOR || DEVELOPMENT_BUILD + /// + /// QA 캡처(FieldHudVisualCapture)가 특정 페이지를 바로 띄우는 진입점. 게임 코드는 부르지 않는다 — + /// 플레이어 흐름은 항상 곤충 선택에서 시작한다. + /// + internal void OpenForCapture(string instanceId, string pageName, int methodIndex = -1, string pendingSkillId = null) + { + if (!isOpen) Toggle(); + selectedInstanceId = instanceId; + selectedMethodIndex = methodIndex; + pendingNewSkillId = pendingSkillId; + switch (pageName) + { + case "method": ChangePage(Page.MethodSelect); break; + case "growth": ChangePage(Page.Growth); break; + case "learn": ChangePage(Page.SkillLearn); break; + case "equip": ChangePage(Page.SkillEquip); break; + case "replace": ChangePage(Page.SkillReplace); break; + default: ChangePage(Page.InsectSelect); break; + } + } + + internal void ShowFeedbackForCapture(string message, bool big) => ShowFeedback(message, big); +#endif } } diff --git a/Assets/Scripts/UI/TutorialQuestUI.cs b/Assets/Scripts/UI/TutorialQuestUI.cs index f1f8970..85178e2 100644 --- a/Assets/Scripts/UI/TutorialQuestUI.cs +++ b/Assets/Scripts/UI/TutorialQuestUI.cs @@ -97,6 +97,8 @@ private void SetTutorialHidden(bool hidden) private readonly List rewardChipBuffer = new List(4); // 아코디언으로 펼쳐진 퀘스트. 빈 문자열이면 모두 접힌 상태. private string expandedQuestId = string.Empty; + // 펼친 행에 [따라가기]가 붙는가 — DrawDetailPanel이 패스마다 한 번 정하고 DrawQuestRow가 읽는다. + private bool expandedRowTrackable; private float newQuestAnimTimer; private string newQuestTitle; @@ -121,6 +123,7 @@ private void SetTutorialHidden(bool hidden) private GUIStyle panelSurfaceBtnStyleCache; // 칩의 UISurface.Button용 (label 파생) private GUIStyle objectiveStyleCache; // 목표 행 (label 파생 — UISurface.Button에 넘긴다) private GUIStyle objectiveStatusStyleCache; // 목표 행 아래 일시 안내 + private GUIStyle objectiveWhyStyleCache; // 목표 행 둘째 줄(「왜」) — 작고 흐리게 // 메인퀘스트 목표 행. 위치·이름·거리는 전부 트래커가 풀어 준다(UI는 그리기만). private InsectGame.Story.StoryObjectiveTracker objectiveTracker; @@ -129,7 +132,18 @@ private void SetTutorialHidden(bool hidden) // 칩(또는 숨김 버튼)이 끝나는 y — 목표 행이 그 아래에 붙는다. 칩 높이가 상태마다 // 달라(완료/숨김/진행 중) 상수로 둘 수 없어, 그린 쪽이 실제 값을 남긴다. private float objectiveRowTop; + private float objectiveRowLeft; + private float objectiveRowWidth; private bool objectiveRowVisible; + + // 목표 행 높이 — 칩이 데스크톱에서 이만큼 자리를 비워 두고 위로 올라간다(그리기와 같은 값이어야 한다). + // 「왜」(트래커의 이유 한 줄)가 있으면 두 줄 높이다 — 칩 자리·그리기·등록이 같은 술어(HasObjectiveWhy)를 본다. + private float ObjectiveRowHeight => QuestChipLayout.RowHeightFor(HasObjectiveWhy); + + /// 목표 행에 「왜」 둘째 줄을 붙이는가 — 목표가 있고 트래커의 이유가 비어 있지 않을 때. + private bool HasObjectiveWhy => + objectiveTracker != null && objectiveTracker.HasObjective && !string.IsNullOrEmpty(objectiveTracker.Why); + // 목표 행 문자열 캐시 — OnGUI 매 프레임 보간 문자열 할당 차단. private string objectiveLabelCache; private string objectiveLabelSource; @@ -162,20 +176,20 @@ private void SetTutorialHidden(bool hidden) // 목록이 매 프레임 그려지므로 캐시하지 않으면 행마다 문자열 할당이 쌓인다. private readonly Dictionary rewardTextCache = new Dictionary(); - private static readonly Color DoneTextCol = new Color(0.9f, 0.75f, 0.2f); - private static readonly Color QuestTitleCol = new Color(0.9f, 0.75f, 0.2f); - private static readonly Color QuestHintBaseCol = new Color(0.65f, 0.65f, 0.65f); - private static readonly Color CompHeaderBaseCol = new Color(0.2f, 1f, 0.4f); - private static readonly Color CompTitleBaseCol = new Color(1f, 0.95f, 0.8f); - private static readonly Color RewardBaseCol = new Color(1f, 0.85f, 0.5f); - private static readonly Color NewQuestBaseCol = new Color(0.7f, 0.85f, 1f); - private static readonly Color NewQuestDescCol = new Color(0.85f, 0.92f, 1f); - private static readonly Color NewQuestPromptCol = new Color(0.5f, 0.9f, 0.6f); - private static readonly Color RowCompletedCol = new Color(0.5f, 0.8f, 0.5f); - private static readonly Color RowLockedCol = new Color(0.45f, 0.45f, 0.5f); - private static readonly Color RowPendingCol = new Color(0.7f, 0.7f, 0.7f); - private static readonly Color StatusCompletedCol = new Color(0.4f, 0.75f, 0.4f); - private static readonly Color StatusActiveCol = new Color(0.9f, 0.85f, 0.3f); + private static Color DoneTextCol => UITheme.Instance.accentMint; + private static Color QuestTitleCol => UITheme.Instance.accentAmber; + private static Color QuestHintBaseCol => UITheme.Instance.textSecondary; + private static Color CompHeaderBaseCol => UITheme.Instance.accentMint; + private static Color CompTitleBaseCol => UITheme.Instance.textPrimary; + private static Color RewardBaseCol => UITheme.Instance.accentAmber; + private static Color NewQuestBaseCol => UITheme.Instance.accentCoral; + private static Color NewQuestDescCol => UITheme.Instance.textSecondary; + private static Color NewQuestPromptCol => UITheme.Instance.accentMint; + private static Color RowCompletedCol => UITheme.Instance.accentMint; + private static Color RowLockedCol => UITheme.Instance.textMuted; + private static Color RowPendingCol => UITheme.Instance.textSecondary; + private static Color StatusCompletedCol => UITheme.Instance.accentMint; + private static Color StatusActiveCol => UITheme.Instance.accentAmber; private void InitQuestPanelStyles() { @@ -187,7 +201,7 @@ private void InitQuestPanelStyles() doneStyleCache.normal.textColor = DoneTextCol; questTitleStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 34, fontStyle = FontStyle.Bold }; + { fontSize = QuestChipLayout.TitleFontSize, fontStyle = FontStyle.Bold }; questTitleStyleCache.normal.textColor = QuestTitleCol; questDescStyleCache = new GUIStyle(GUI.skin.label) @@ -195,7 +209,7 @@ private void InitQuestPanelStyles() questDescStyleCache.normal.textColor = Color.white; questProgStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 26, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + { fontSize = QuestChipLayout.ProgressFontSize, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; questProgStyleCache.normal.textColor = Color.white; questHintStyleCache = new GUIStyle(GUI.skin.label) @@ -213,12 +227,17 @@ private void InitQuestPanelStyles() panelSurfaceBtnStyleCache.normal.textColor = UITheme.Instance.textPrimary; objectiveStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 27, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; + { fontSize = QuestChipLayout.ObjectiveFontSize, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; objectiveStyleCache.normal.textColor = UITheme.Instance.textPrimary; objectiveStatusStyleCache = new GUIStyle(GUI.skin.label) { fontSize = 23, alignment = TextAnchor.MiddleLeft, wordWrap = false }; objectiveStatusStyleCache.normal.textColor = UITheme.Instance.accentAmber; + + // 「왜」 줄 — 할 일보다 작고 흐리게. textMuted는 반투명 HUD 카드(밝은 월드 위)에서 대비가 모자라 보조 글자색을 쓴다. + objectiveWhyStyleCache = new GUIStyle(GUI.skin.label) + { fontSize = QuestChipLayout.WhyFontSize, alignment = TextAnchor.MiddleLeft, wordWrap = false }; + objectiveWhyStyleCache.normal.textColor = UITheme.Instance.textSecondary; } /// @@ -234,23 +253,55 @@ private static float RowH(GUIStyle style) return Mathf.Ceil(style.fontSize * 1.35f); } + // 완료·다음 퀘스트 알림의 글자 크기 — 패널 높이가 여기서 나온다(아래 순수 배치와 스타일이 같은 값을 쓴다). + private const int DoneHeaderFont = 40; + private const int DoneTitleFont = 31; + private const int DoneRewardFont = 28; + private const int NextHeaderFont = 31; + private const int NextDescFont = 26; + private const int NextPromptFont = 24; + /// 알림 패널이 바라는 폭 — 무대()가 좁으면 그 폭으로 줄어든다. + public const float NoticePanelWidth = 640f; + + private static float LineH(int fontSize) => Mathf.Ceil(fontSize * 1.35f); + + /// + /// 퀘스트 완료 알림의 자리 — 가운데 무대의 차례 항목(). 예전엔 화면 위 가운데(ContentTop)라 + /// 리전 배너·내기 점수판·세로 화면의 단축 바와 미니맵을 덮었다. + /// + public static Rect QuestDoneRect(HudFrame f, bool hasReward) + { + float h = 12f + LineH(DoneHeaderFont) + 6f + LineH(DoneTitleFont) + + (hasReward ? 6f + LineH(DoneRewardFont) : 0f) + 14f; + return HudStage.Place(f, HudStageItem.QuestDone, NoticePanelWidth, h); + } + + /// 다음 퀘스트 알림의 자리 — 가운데 무대의 차례 항목(). + public static Rect QuestNextRect(HudFrame f, bool hasDesc) + { + float h = hasDesc + ? 8f + LineH(NextHeaderFont) + 6f + LineH(NextDescFont) * 2f + 6f + LineH(NextPromptFont) + 10f + : 8f + LineH(NextHeaderFont) + 10f; + return HudStage.Place(f, HudStageItem.QuestNext, NoticePanelWidth, h); + } + private void InitNotifStyles() { if (notifStylesReady) return; notifStylesReady = true; compHeaderStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 40, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + { fontSize = DoneHeaderFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; compTitleStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 31, alignment = TextAnchor.MiddleCenter }; + { fontSize = DoneTitleFont, alignment = TextAnchor.MiddleCenter }; rewardStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 28, alignment = TextAnchor.MiddleCenter }; + { fontSize = DoneRewardFont, alignment = TextAnchor.MiddleCenter }; newQuestStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 31, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; + { fontSize = NextHeaderFont, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; newQuestDescStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 26, alignment = TextAnchor.MiddleCenter, wordWrap = true }; + { fontSize = NextDescFont, alignment = TextAnchor.MiddleCenter, wordWrap = true }; newQuestPromptStyleCache = new GUIStyle(GUI.skin.label) - { fontSize = 24, fontStyle = FontStyle.Italic, alignment = TextAnchor.MiddleCenter }; + { fontSize = NextPromptFont, fontStyle = FontStyle.Italic, alignment = TextAnchor.MiddleCenter }; // 모두 textColor는 alpha 동적이라 매 호출 갱신. } @@ -430,16 +481,31 @@ private void Update() // (모달 위에 겹쳐 그리지 않는 이유는 이 파일에 GUI.depth가 없어 그리기 순서가 // 불확정이고, 배너가 상점·도감 위를 덮는 건 더 나쁘기 때문이다.) if (ModalUIRegistry.IsAnyOpen()) return; + // 꿈 동안은 알림을 그리지 않으므로(OnGUI) 수명도 멈춘다 — 무대를 보이지 않는 카드가 차지하지 않게. + if (InsectGame.Core.DreamPrologueState.Active) return; - if (completionAnimTimer > 0f) completionAnimTimer -= Time.deltaTime; + // 두 알림은 가운데 무대(HudStage)에 선다 — 다른 카드(포획 결과·동굴 진입 알림 등)가 서 있으면 차례를 기다리고, + // 기다리는 동안은 수명을 태우지 않는다(모달과 같은 이유 — 기다리다 사라지면 못 본다). + bool doneShowing = completionAnimTimer > 0f && HudStage.Request(HudStageItem.QuestDone); + if (doneShowing) completionAnimTimer -= Time.deltaTime; if (rewardAnimTimer > 0f) rewardAnimTimer -= Time.deltaTime; - // 완료 알림이 끝나길 기다린 뒤(newQuestDelay) 새 퀘스트 배너 수명 소진. - if (newQuestDelay > 0f) newQuestDelay -= Time.deltaTime; - else if (newQuestAnimTimer > 0f) newQuestAnimTimer -= Time.deltaTime; + // 완료 알림이 끝나길 기다린 뒤(newQuestDelay) 새 퀘스트 배너 수명 소진. 완료 알림이 기다리는 동안은 지연도 멈춘다. + if (newQuestDelay > 0f) + { + if (completionAnimTimer <= 0f || doneShowing) newQuestDelay -= Time.deltaTime; + } + else if (newQuestAnimTimer > 0f) + { + // 숨김 중엔 그리지 않으니 무대도 잡지 않고 수명만 흐른다(예전과 같다). + if (tutorialHidden || HudStage.Request(HudStageItem.QuestNext)) newQuestAnimTimer -= Time.deltaTime; + } } private void OnGUI() { + // 「챔피언의 꿈」 동안은 퀘스트 칩·목표 행·알림을 모두 숨긴다 — 꿈 밖의 진행이 꿈속에 비치면 안 된다. + if (InsectGame.Core.DreamPrologueState.Active) return; + // 형제 HUD(PlayerStatusHUD 등)와 동일한 가상 캔버스(1920x1080 / 1080x1920)에서 그려 // 고DPI 기기에서도 폰트·패널 크기가 일관되게 보이도록 한다. 내부 좌표는 모두 가상 단위. UIScale.Begin(); @@ -484,20 +550,20 @@ private void DrawQuestPanel() UITheme theme = UITheme.Instance; bool guideLock = guided != null && guided.IsGuiding; - // 미니맵과 좌변을 맞춘다 — 예전엔 20 vs 16으로 4px 어긋나 있었다. - float chipX = MinimapUI.LeftX; + // 자리는 QuestChipLayout(순수 계산)이 정한다 — 세로 모바일은 미니맵 아래, 가로 모바일은 미니맵 오른쪽(아래는 조이스틱 + // 시작 영역이다), 데스크톱은 좌하단에서 단축 바 왼쪽 끝까지만 쓰고 목표 행 자리까지 비워 두고 바닥에서 위로 쌓는다. + HudFrame frame = HudFrame.Current; + float stackW = QuestChipLayout.StackWidth(frame); + float rowReserve = objectiveTracker != null && objectiveTracker.HasObjective ? ObjectiveRowHeight : 0f; // 숨김: 작은 복원 버튼만. 단 강제 가이드 중엔 숨김 무시(칩 강제 표시). if (tutorialHidden && !guideLock) { - float rW = UIScale.IsMobileLayout ? 230f : 190f; - float rH = UIScale.IsMobileLayout ? 56f : 40f; - float rY = UIScale.IsMobileLayout - ? MinimapUI.StackBelowY // 모바일: 미니맵 아래 - : UISafeLayout.BottomY(rH); // 데스크톱: 좌하단 - objectiveRowTop = rY + rH + UITheme.Space.XS; + Rect restoreRect = QuestChipLayout.RestoreRect(frame, rowReserve); + objectiveRowLeft = restoreRect.x; + objectiveRowWidth = stackW; + objectiveRowTop = QuestChipLayout.Row(restoreRect, stackW, rowReserve).y; objectiveRowVisible = true; - Rect restoreRect = new Rect(chipX, rY, rW, rH); FieldHudInput.RegisterBlockingRect(restoreRect); if (UISurface.Button(restoreRect, "▼ 퀘스트 보기", theme.surfaceRaised, panelSurfaceBtnStyleCache)) SetTutorialHidden(false); @@ -508,20 +574,16 @@ private void DrawQuestPanel() bool done = act == null && questManager.AllCompleted; if (act == null && !done) return; - // 컴팩트 칩 — 제목+진행바만. 좌하단(조작법 제거로 빈 자리)/모바일은 미니맵 아래. - float chipW = UIScale.IsMobileLayout - ? Mathf.Min(500f, UIScale.VirtualScreenWidth - UIScale.VirtualSafeLeft - UIScale.VirtualSafeRight - 40f) - : 400f; + // 컴팩트 칩 — 제목+진행바만. 데스크톱 좌하단(조작법 제거로 빈 자리) / 모바일은 미니맵 곁(위 QuestChipLayout). float cpad = UITheme.Space.S; float ctitleH = RowH(questTitleStyleCache); float cbarH = done ? 0f : RowH(questProgStyleCache); float crowGap = done ? 0f : UITheme.Space.XS; float chipH = cpad + ctitleH + crowGap + cbarH + cpad; - float chipY = UIScale.IsMobileLayout - ? MinimapUI.StackBelowY // 모바일: 미니맵 아래 - : UISafeLayout.BottomY(chipH); // 데스크톱: 좌하단 - Rect chipRect = new Rect(chipX, chipY, chipW, chipH); - objectiveRowTop = chipRect.yMax + UITheme.Space.XS; + Rect chipRect = QuestChipLayout.ChipRect(frame, chipH, rowReserve); + objectiveRowLeft = chipRect.x; + objectiveRowWidth = stackW; + objectiveRowTop = QuestChipLayout.Row(chipRect, stackW, rowReserve).y; objectiveRowVisible = true; // **필드 위에 겹쳐 그리는 버튼은 자기 영역을 매 프레임 등록한다.** @@ -602,13 +664,28 @@ private void DrawObjectiveRow() // 히트 테스트를 가르지 않는다). if (ModalUIRegistry.IsAnyOpen()) return; + // 자리·폭은 칩을 그릴 때 QuestChipLayout이 정해 둔 것을 쓴다(데스크톱은 칩이 이 행 높이만큼 올라가 있다). + DrawObjectiveRowAt(new Rect(objectiveRowLeft, objectiveRowTop, objectiveRowWidth, ObjectiveRowHeight)); + } + + /// + /// 검수 fixture 전용 — 로그인 세션(퀘스트 칩) 없이 목표 행만 칩 자리 아래에 그린다. 자리는 실제와 같은 + /// 와 (「왜」가 있으면 두 줄)다. OnGUI 안에서 부를 것. + /// + internal void DrawObjectiveRowForCapture(Rect chip, float width) + { + if (objectiveTracker == null || !objectiveTracker.HasObjective) return; + InitQuestPanelStyles(); + objectiveRowLeft = chip.x; + objectiveRowWidth = width; + objectiveRowTop = QuestChipLayout.Row(chip, width, ObjectiveRowHeight).y; + DrawObjectiveRowAt(new Rect(objectiveRowLeft, objectiveRowTop, objectiveRowWidth, ObjectiveRowHeight)); + } + + private void DrawObjectiveRowAt(Rect row) + { UITheme theme = UITheme.Instance; - float x = MinimapUI.LeftX; - float w = UIScale.IsMobileLayout - ? Mathf.Min(500f, UIScale.VirtualScreenWidth - UIScale.VirtualSafeLeft - UIScale.VirtualSafeRight - 40f) - : 400f; - float h = Mathf.Ceil(objectiveStyleCache.fontSize * 1.35f) + UITheme.Space.S * 2f; - Rect row = new Rect(x, objectiveRowTop, w, h); + bool hasWhy = HasObjectiveWhy; // 칩과 같은 이유로 등록한다 — 여기는 더 나쁘다. 이 행을 누르면 자동 주행이 시작되는데 // **같은 탭이 클릭-이동으로도 발화해** 목표로 달려가면서 동시에 탭 지점으로 걸어가려 @@ -639,27 +716,45 @@ private void DrawObjectiveRow() } string label = objectiveLabelCache; + // 의뢰를 따라가는 중이면 오른쪽 끝에 ✕(해제 → 본편 목표로 복귀)를 붙인다. + // **✕를 먼저 그린다** — IMGUI는 먼저 처리된 버튼이 MouseDown을 가져가므로, 행 버튼이 + // 먼저면 ✕를 눌러도 자동 주행이 시작된다. 행은 ✕ 폭만큼 줄여 겹치지 않게 한다. + // ✕ 폭은 한 줄 행 높이로 고정한다 — 「왜」 줄로 행이 길어져도 정사각형으로 키우면 할 일 글자 폭만 줄어든다. + Rect body = row; + if (objectiveTracker.IsTrackingTale) + { + float closeW = Mathf.Min(row.height, QuestChipLayout.RowHeight); + Rect closeRect = new Rect(row.xMax - closeW, row.y, closeW, row.height); + body = new Rect(row.x, row.y, row.width - closeW - UITheme.Space.XS, row.height); + if (UISurface.Button(closeRect, "✕", theme.surfaceRaised, panelSurfaceBtnStyleCache)) + { + objectiveTracker.StopTrackingTale(); + return; // 목표가 본편으로 바뀌었다 — 옛 라벨로 한 번 더 그리지 않는다 + } + } + if (canRun) { Color bg = running ? theme.accentCoral : theme.surfaceRaised; - if (UISurface.Button(row, string.Empty, bg, panelSurfaceBtnStyleCache)) + if (UISurface.Button(body, string.Empty, bg, panelSurfaceBtnStyleCache)) objectiveTracker.Toggle(); - // 라벨은 좌측 정렬이라 UISurface.Button의 중앙 정렬 스타일을 쓰지 않고 따로 그린다. - UIHelper.LabelFit( - new Rect(row.x + UITheme.Space.M, row.y, row.width - UITheme.Space.M * 2f, row.height), - label, objectiveStyleCache); } else { - UISurface.HudCard(row); - UIHelper.LabelFit( - new Rect(row.x + UITheme.Space.M, row.y, row.width - UITheme.Space.M * 2f, row.height), - label, objectiveStyleCache); + UISurface.HudCard(body); } + // 라벨은 좌측 정렬이라 UISurface.Button의 중앙 정렬 스타일을 쓰지 않고 따로 그린다. + // 「왜」가 있으면 할 일은 윗줄, 이유는 그 아래 작고 흐린 한 줄(둘 다 넘치면 글자를 줄여 맞춘다). + UIHelper.LabelFit(QuestChipLayout.RowMainLine(body, hasWhy), label, objectiveStyleCache); - // 일시 안내(길 막힘 / 다른 리전) — 행 아래 한 줄. + // 일시 안내(길 막힘 / 다른 리전). 「왜」 줄이 있으면 그 줄을 잠깐 빌린다 — 행 아래에 한 줄을 더 내리면 두 줄 행 밑으로 + // 무대(가운데 카드 자리) 윗변까지 닿는다(세로 화면). 안내가 사라지면 이유가 돌아온다. 이유가 없으면 예전처럼 행 아래 한 줄. string status = objectiveTracker.StatusMessage; - if (!string.IsNullOrEmpty(status)) + bool statusInRow = hasWhy && !string.IsNullOrEmpty(status); + if (hasWhy) + UIHelper.LabelFit(QuestChipLayout.RowWhyLine(body), statusInRow ? status : objectiveTracker.Why, + statusInRow ? objectiveStatusStyleCache : objectiveWhyStyleCache); + if (!statusInRow && !string.IsNullOrEmpty(status)) { UIHelper.LabelFit( new Rect(row.x + UITheme.Space.XS, row.yMax + 2f, @@ -792,28 +887,16 @@ private void DrawActiveQuestDetail() private void DrawCompletionNotification() { if (completionAnimTimer <= 0f) return; + if (!HudStage.Request(HudStageItem.QuestDone)) return; // 무대에 다른 카드가 서 있다 — 차례를 기다린다 + // 나타날 때는 위에서 미끄러지지 않고 제자리에서 밝아진다 — 무대 밖(리전 배너·내기 점수판)으로 삐져나오지 않게. float alpha; - float slideOffset; - if (completionAnimTimer > 2.5f) - { - // Slide in (0..0.5s) - float t = (3f - completionAnimTimer) / 0.5f; - alpha = Mathf.Clamp01(t); - slideOffset = Mathf.Lerp(-60f, 0f, t); - } + alpha = Mathf.Clamp01((3f - completionAnimTimer) / 0.5f); else if (completionAnimTimer < 0.5f) - { - // Fade out alpha = Mathf.Clamp01(completionAnimTimer / 0.5f); - slideOffset = 0f; - } else - { alpha = 1f; - slideOffset = 0f; - } InitNotifStyles(); @@ -825,12 +908,12 @@ private void DrawCompletionNotification() float rewH = RowH(rewardStyleCache); bool hasReward = !string.IsNullOrEmpty(completedRewardText); - float availW = UIScale.VirtualScreenWidth - UIScale.VirtualSafeLeft - UIScale.VirtualSafeRight; - float panelW = Mathf.Min(640f, availW - 24f); - float panelH = UISafeLayout.ClampHeight( - 12f + headH + 6f + titleH + (hasReward ? 6f + rewH : 0f) + 14f); - float panelX = UIScale.VirtualSafeLeft + (availW - panelW) * 0.5f; - float panelY = UISafeLayout.ContentTop + slideOffset; + Rect panel = QuestDoneRect(HudFrame.Current, hasReward); + HudStage.Request(HudStageItem.QuestDone, panel); // 무대 안의 가운데 것들이 겹치면 비켜서게 자리를 알린다 + float panelW = panel.width; + float panelH = panel.height; + float panelX = panel.x; + float panelY = panel.y; // Background GUI.color = new Color(0.15f, 0.12f, 0.02f, 0.9f * alpha); @@ -851,7 +934,8 @@ private void DrawCompletionNotification() // Quest title compTitleStyleCache.normal.textColor = new Color(CompTitleBaseCol.r, CompTitleBaseCol.g, CompTitleBaseCol.b, alpha); - GUI.Label(new Rect(panelX, rowY, panelW, titleH), + // 제목 길이는 데이터가 정한다 — 무대가 좁은 화면(가로 모바일 섬)에서 잘리지 않게 줄여 맞춘다. + UIHelper.LabelFit(new Rect(panelX + 12f, rowY, panelW - 24f, titleH), "\"" + (completedQuestTitle ?? "") + "\"", compTitleStyleCache); rowY += titleH + 6f; @@ -859,7 +943,7 @@ private void DrawCompletionNotification() if (hasReward) { rewardStyleCache.normal.textColor = new Color(RewardBaseCol.r, RewardBaseCol.g, RewardBaseCol.b, alpha); - GUI.Label(new Rect(panelX, rowY, panelW, rewH), + UIHelper.LabelFit(new Rect(panelX + 12f, rowY, panelW - 24f, rewH), "\ubcf4\uc0c1: " + completedRewardText, rewardStyleCache); } @@ -874,6 +958,7 @@ private void DrawNewQuestNotification() if (tutorialHidden) return; // \uc228\uae40 \uc911\uc5d4 \ub2e4\uc74c \ub2e8\uacc4 \uc548\ub0b4 \uc548 \ub744\uc6c0(\uc644\ub8cc \uc54c\ub9bc\uc740 \ubcc4\ub3c4 \uc720\uc9c0) if (newQuestDelay > 0f) return; // \uc644\ub8cc \uc54c\ub9bc\uc774 \ub05d\ub0a0 \ub54c\uae4c\uc9c0 \ub300\uae30 if (newQuestAnimTimer <= 0f) return; + if (!HudStage.Request(HudStageItem.QuestNext)) return; // 무대 차례를 기다린다 float alpha; if (newQuestAnimTimer > 3f) @@ -893,19 +978,18 @@ private void DrawNewQuestNotification() InitNotifStyles(); bool hasDesc = !string.IsNullOrEmpty(newQuestDesc); - float availW = UIScale.VirtualScreenWidth - UIScale.VirtualSafeLeft - UIScale.VirtualSafeRight; - float panelW = Mathf.Min(640f, availW - 24f); // 높이는 전부 폰트에서 파생한다(RowH 주석 참조). 설명은 두 줄까지 잡는다 — // 길이가 데이터에서 오므로 wordWrap이 접히는 경우가 있다. float nqHeadH = RowH(newQuestStyleCache); float nqDescH = RowH(newQuestDescStyleCache) * 2f; float nqPromptH = RowH(newQuestPromptStyleCache); - float panelH = UISafeLayout.ClampHeight( - hasDesc ? 8f + nqHeadH + 6f + nqDescH + 6f + nqPromptH + 10f - : 8f + nqHeadH + 10f); - float panelX = UIScale.VirtualSafeLeft + (availW - panelW) * 0.5f; - float panelY = UISafeLayout.ContentTop + 30f; // 퀘스트 토스트(ContentTop) 아래로 + Rect panel = QuestNextRect(HudFrame.Current, hasDesc); + HudStage.Request(HudStageItem.QuestNext, panel); + float panelW = panel.width; + float panelH = panel.height; + float panelX = panel.x; + float panelY = panel.y; GUI.color = new Color(0.08f, 0.15f, 0.3f, 0.92f * alpha); GUI.DrawTexture(new Rect(panelX, panelY, panelW, panelH), Texture2D.whiteTexture); @@ -919,7 +1003,7 @@ private void DrawNewQuestNotification() // 헤더: 다음 단계가 "무엇"인지 float nqY = panelY + 8f; newQuestStyleCache.normal.textColor = new Color(NewQuestBaseCol.r, NewQuestBaseCol.g, NewQuestBaseCol.b, alpha); - GUI.Label(new Rect(panelX, nqY, panelW, nqHeadH), + UIHelper.LabelFit(new Rect(panelX + 12f, nqY, panelW - 24f, nqHeadH), "다음 단계 \u2192 \"" + (newQuestTitle ?? "") + "\"", newQuestStyleCache); nqY += nqHeadH + 6f; @@ -979,6 +1063,9 @@ private void DrawDetailPanel() if (questManager == null) return; InitDetailStyles(); + // [따라가기] 버튼이 칩과 같은 표면 스타일(panelSurfaceBtnStyleCache)을 쓴다 — 칩이 한 번도 + // 안 그려진 채(가이드 숨김 등) 목록부터 열면 null이라 여기서도 보장한다(가드가 있어 1회뿐). + InitQuestPanelStyles(); float panelW = UIScale.IsMobileLayout ? Mathf.Min(860f, UIScale.VirtualScreenWidth - UIScale.VirtualSafeLeft - UIScale.VirtualSafeRight - 32f) @@ -1034,7 +1121,15 @@ private void DrawDetailPanel() float headH = QuestListLayout.SectionHeaderHeight; // 펼쳐진 행이 있으면 그만큼 콘텐츠가 길어진다(한 번에 하나만 펼친다). int expandedCount = string.IsNullOrEmpty(expandedQuestId) ? 0 : 1; - float contentH = QuestListLayout.GetContentHeight(story.Count, side.Count, expandedCount); + // [따라가기] 여부는 **이번 패스에 한 번만** 정한다 — 행 높이(DrawQuestRow)와 콘텐츠 높이가 + // 같은 답을 봐야 스크롤 끝이 안 잘린다. 판정이 스토리 진행을 훑어서 매 행마다 부를 값도 아니다. + // Layout 패스에서만 정한다 — 판정이 이야기 비트 전체를 훑어서, Repaint·입력 패스마다 되풀이하면 + // 행을 펼쳐 둔 동안 프레임마다 두세 번 돈다. 같은 프레임의 뒤 패스는 이 값을 그대로 쓴다. + if (Event.current.type == EventType.Layout) + expandedRowTrackable = expandedCount > 0 && objectiveTracker != null + && (objectiveTracker.IsQuestTracked(expandedQuestId) || objectiveTracker.CanTrackQuestNow(expandedQuestId)); + float contentH = QuestListLayout.GetContentHeight(story.Count, side.Count, expandedCount, + expandedRowTrackable ? 1 : 0); Rect listArea = new Rect(listX, listY, listW, listH); Rect viewRect = new Rect(0, 0, listW, contentH); detailDirectScroll.Handle(ref detailScroll, listArea, contentH, rowH); @@ -1053,7 +1148,8 @@ private void DrawDetailPanel() if (side.Count > 0) { - DrawQuestSectionHeader(viewRect.width, ref ry, headH, "\u25c6 \uc11c\ube0c (\ubc18\ubcf5 \uc2dc \ubaa9\ud45c \uc0c1\uc2b9)"); + // \ub9c8\uc744 \uc758\ub8b0(1\ud68c)\uac00 \ud568\uaed8 \ub4e4\uc5b4\uc624\uba74\uc11c "\ubc18\ubcf5 \uc2dc \ubaa9\ud45c \uc0c1\uc2b9"\ub9cc\uc73c\ub85c\ub294 \uc124\uba85\uc774 \ubaa8\uc790\ub77c\ub2e4. + DrawQuestSectionHeader(viewRect.width, ref ry, headH, "\u25c6 \uc11c\ube0c \u00b7 \uc678\uc804 \u00b7 \ub9c8\uc744 \uc758\ub8b0"); for (int i = 0; i < side.Count; i++) DrawQuestRow(side[i], viewRect.width, ref ry, rowH, i); } @@ -1086,20 +1182,21 @@ private void DrawQuestRow(TutorialQuest quest, float width, ref float ry, float if (isSide) { - bool unlocked = string.IsNullOrEmpty(quest.prerequisiteQuestId) - || questManager.IsQuestCompleted(quest.prerequisiteQuestId); + // 선행 퀘스트 + (지역 의뢰면) 리전 해금 — 진행을 세는 쪽과 같은 판정을 쓴다. + // prereq만 보면 잠긴 리전의 의뢰가 0/5 진행 중으로 떠 갈 수 없는 곳을 할 일처럼 보인다. + // 완료를 먼저 본다 — 끝낸 의뢰는 나중에 리전 접근이 꺼져도(마스터 특권 해제 등) 완료다. tgt = questManager.EffectiveTarget(quest); - if (!unlocked) - { - icon = "\ud83d\udd12 "; titleCol = RowLockedCol; - statusText = "\ubbf8\ud574\uae08"; statusCol = RowLockedCol; - } - else if (!quest.repeatable && questManager.IsQuestCompleted(quest.questId)) + if (!quest.repeatable && questManager.IsQuestCompleted(quest.questId)) { icon = "\u2713 "; titleCol = RowCompletedCol; statusText = "\uc644\ub8cc"; statusCol = StatusCompletedCol; cur = tgt; } + else if (!questManager.IsSideUnlocked(quest)) + { + icon = "\ud83d\udd12 "; titleCol = RowLockedCol; + statusText = "\ubbf8\ud574\uae08"; statusCol = RowLockedCol; + } else { icon = "\u25c6 "; titleCol = Color.white; @@ -1147,7 +1244,8 @@ private void DrawQuestRow(TutorialQuest quest, float width, ref float ry, float } bool expanded = !string.IsNullOrEmpty(quest.questId) && quest.questId == expandedQuestId; - float totalH = QuestListLayout.GetRowHeight(expanded); + bool trackable = expanded && expandedRowTrackable; + float totalH = QuestListLayout.GetRowHeight(expanded, trackable); UITheme t = UITheme.Instance; // \ubc30\uacbd(\uad50\ub300) + \ud65c\uc131 \uc2a4\ud1a0\ub9ac \ud558\uc774\ub77c\uc774\ud2b8 \u2014 \ud3bc\uce5c \uc601\uc5ed\uae4c\uc9c0 \ud568\uaed8 \uce60\ud55c\ub2e4. @@ -1171,7 +1269,8 @@ private void DrawQuestRow(TutorialQuest quest, float width, ref float ry, float GUI.color = Color.white; detailRowStyleCache.normal.textColor = titleCol; - GUI.Label(new Rect(10f, ry, width * 0.46f, rowH), icon + QuestTitle(quest), detailRowStyleCache); + // 마을 의뢰 제목("◆ [잿불 골짜기] 타지 않은 기억")은 28pt에서 이 상자를 넘는다 — 줄여 맞춘다. + UIHelper.LabelFit(new Rect(10f, ry, width * 0.46f, rowH), icon + QuestTitle(quest), detailRowStyleCache); // \ubcf4\uc0c1 \uc694\uc57d \u2014 \ubaa9\ub85d\uc5d0\uc11c "\uc774\uac70 \uae68\uba74 \ubb58 \uc8fc\ub098"\uac00 \ubc14\ub85c \ubcf4\uc774\uac8c. string rewardText = GetRewardText(quest); @@ -1229,6 +1328,28 @@ private void DrawQuestRow(TutorialQuest quest, float width, ref float ry, float GUI.Label(new Rect(16f, ey, 60f, rewardRowH), "\ubcf4\uc0c1", detailRewardLabelStyleCache); GUI.Label(new Rect(80f, ey, width - 96f, rewardRowH), string.IsNullOrEmpty(rewardText) ? "\uc5c6\uc74c" : rewardText, detailRewardStyleCache); + + // \ub9c8\uc744 \uc758\ub8b0 \u2014 [\ub530\ub77c\uac00\uae30]. \ub204\ub974\uba74 \ubaa9\ud45c \ud589\u00b7\ubbf8\ub2c8\ub9f5\u00b7\uc9c0\ub3c4\uac00 \uadf8 \uc8fc\ubbfc\uc758 \uc774\uc57c\uae30\ub97c \uac00\ub9ac\ud0a8\ub2e4. + // \ubc84\ud2bc \uc790\ub9ac\ub294 \ud589 \ub192\uc774 \uacc4\uc0b0(QuestListLayout.TrackButtonExtra)\uacfc \uac19\uc740 \ud310\uc815\uc73c\ub85c \ud655\ubcf4\ub3fc \uc788\ub2e4. + if (trackable) + { + Rect btn = new Rect(16f, + ry + QuestListLayout.RowHeight + QuestListLayout.ExpandedExtra + QuestListLayout.TrackButtonTopGap, + Mathf.Min(360f, width - 32f), QuestListLayout.TrackButtonHeight); + bool tracked = objectiveTracker.IsQuestTracked(quest.questId); + Color btnBg = tracked ? t.accentCoral : t.accentMint; + if (UISurface.Button(btn, tracked ? "\ub530\ub77c\uac00\uae30 \ud574\uc81c" : "\u25b6 \ub530\ub77c\uac00\uae30", btnBg, panelSurfaceBtnStyleCache) + && !detailDirectScroll.IsDragging) + { + if (tracked) objectiveTracker.StopTrackingTale(); + else + { + objectiveTracker.TrackQuest(quest.questId); + // \ubaa9\ub85d\uc744 \ub2eb\uc544 \ubc14\ub85c \ud544\ub4dc\uc640 \ubaa9\ud45c \ud589\uc744 \ubcf4\uac8c \ud55c\ub2e4 \u2014 \ub530\ub77c\uac00\uae30\ub97c \ucf1c \ub193\uace0 \ubaa9\ub85d\uc5d0 \uba38\ubb3c \uc774\uc720\uac00 \uc5c6\ub2e4. + SetDetailOpen(false); + } + } + } } ry += totalH; @@ -1303,4 +1424,196 @@ public void AutoWire(ItemDatabase database) rewardTextCache.Clear(); } } + + /// 퀘스트 칩 스택(칩·목표 행)이 서는 곳. + public enum QuestStackPlace + { + /// 모바일 세로 — 미니맵 아래에서 아래로. + UnderMinimap, + /// 모바일 가로 — 미니맵 오른쪽, 위쪽 가운데 코치 배너 띠 아래. + BesideMinimap, + /// 데스크톱 — 좌하단, 단축 바 왼쪽 끝까지, 바닥에서 위로. + BottomLeft + } + + /// + /// 퀘스트 칩·숨김 복원 버튼·목표 행의 자리 — 순수 계산. 가 이걸로 그리고 + /// FieldHudInput에 같은 Rect를 등록한다. 겹침 테스트(WorldClockHudTests.QuestChip_*)가 화면 크기별로 읽는다. + /// + /// 모바일 세로: 미니맵 아래 좌측 스택(MinimapUI.StackBelowY)에서 아래로 — 칩, 그 아래 목표 행. 폭 최대 500. + /// 모바일 가로: 미니맵 오른쪽(x = 미니맵 오른쪽 끝 + 10), 위쪽 가운데 코치 배너 띠(ContentTop + 150~250, + /// ) 바로 아래에서 아래로. 가로 화면은 미니맵 아래에 128px(412~540)밖에 안 남아 + /// 칩(108) + 목표 행(57)이 화면 가운데 줄 아래 — 가상 조이스틱의 시작 영역(좌하단 사분면) — 으로 43px 내려갔다. 미니맵 오른쪽에 두면 + /// 칩 아랫변이 미니맵 아랫변과 거의 맞고(400 vs 402) 목표 행이 y 463에서 끝난다. 미니맵 오른쪽 위 띠를 쓰지 않는 이유는 첫 가이드의 + /// 코치 배너(가운데 폭 720)가 거기 서기 때문이다 — 1920 폭에서 x 600부터라 폭 500 칩(246~746)과 겹친다. + /// 데스크톱: 좌하단, 단축 바 왼쪽 끝까지만 쓴다(최대 400). 16:9(가상 폭 1920)에서 단축 바(폭 1200, 가운데)가 + /// x 360에서 시작해 폭 400 칩(x 16~416)과 56px 겹쳤다 — 칩은 334로 줄고, 21:9(2560)처럼 넓으면 400 그대로다. + /// 그리고 바닥에서 위로 쌓는다: 목표 행이 뜰 거면 그 높이만큼 칩을 올린다. 예전엔 칩이 바닥에 붙고 목표 행이 그 아래 + /// (안전 영역 밖, 1080 화면에서 y 1054~1111)에 그려져 거의 보이지 않았다. + /// 단축 바 옆에 280도 안 남는 화면(데스크톱 레이아웃의 세로에 가까운 창)에서는 단축 바 위에 쌓는다. + /// + public static class QuestChipLayout + { + public const float MobileMaxWidth = 500f; + public const float DesktopMaxWidth = 400f; + /// 데스크톱에서 단축 바 옆에 칩을 둘 최소 폭 — 이보다 좁으면 단축 바 위로 올린다. + public const float DesktopMinBesideWidth = 280f; + public const float RestoreWidthMobile = 230f; + public const float RestoreHeightMobile = 56f; + public const float RestoreWidthDesktop = 190f; + public const float RestoreHeightDesktop = 40f; + /// 칩 제목·진행·목표 행 글자 크기 — 칩과 목표 행의 높이가 여기서 나온다(한글 줄높이 ≈ 글자 × 1.35). + public const int TitleFontSize = 34; + public const int ProgressFontSize = 26; + public const int ObjectiveFontSize = 27; + + private static float LineHeight(int fontSize) => Mathf.Ceil(fontSize * 1.35f); + + /// 진행 중인 퀘스트 칩 높이 — 여백 + 제목 + 간격 + 진행 줄 + 여백(108). + public static float ExpandedHeight => UITheme.Space.S + LineHeight(TitleFontSize) + UITheme.Space.XS + + LineHeight(ProgressFontSize) + UITheme.Space.S; + + /// 모두 끝난 칩 높이(진행 줄 없음, 66). + public static float DoneHeight => UITheme.Space.S + LineHeight(TitleFontSize) + UITheme.Space.S; + + /// 목표 행 높이(57) — 「왜」 줄이 없을 때. + public static float RowHeight => LineHeight(ObjectiveFontSize) + UITheme.Space.S * 2f; + + /// 목표 행 둘째 줄(「왜」 — StoryObjectiveTracker.Why)의 글자 크기. 할 일보다 작고 흐리다. + public const int WhyFontSize = 22; + /// 「왜」 줄을 할 일 글자(▶·◈ 다음) 밑에 맞추는 들여쓰기. + public const float WhyIndent = 30f; + + /// + /// 「왜」 줄이 붙은 목표 행 높이(87) — 여백 + 할 일 한 줄 + 이유 한 줄 + 여백. 데스크톱은 칩이 이만큼 더 올라가고, + /// 모바일은 행이 그만큼 아래로 길어진다. 가운데 무대()는 늘 이 큰 쪽을 피한다. + /// + public static float RowHeightWithWhy => LineHeight(ObjectiveFontSize) + LineHeight(WhyFontSize) + UITheme.Space.S * 2f; + + /// 목표 행 높이 — 「왜」가 있으면 두 줄. 칩이 비워 둘 자리·그리기·히트테스트·등록이 전부 이 값을 쓴다. + public static float RowHeightFor(bool hasWhy) => hasWhy ? RowHeightWithWhy : RowHeight; + + /// + /// 목표 행 안 할 일 글자 자리. 「왜」가 없으면 행 전체(가운데 정렬 그대로), 있으면 윗줄. + /// 는 행에서 ✕(의뢰 해제) 자리를 뺀 몸통이다. + /// + public static Rect RowMainLine(Rect body, bool hasWhy) + { + float x = body.x + UITheme.Space.M; + float w = Mathf.Max(1f, body.width - UITheme.Space.M * 2f); + return hasWhy + ? new Rect(x, body.y + UITheme.Space.S, w, LineHeight(ObjectiveFontSize)) + : new Rect(x, body.y, w, body.height); + } + + /// 목표 행 둘째 줄(「왜」)의 자리 — 할 일 줄 바로 아래, 글리프 폭만큼 들여 쓴다. + public static Rect RowWhyLine(Rect body) + { + float x = body.x + UITheme.Space.M + WhyIndent; + float w = Mathf.Max(1f, body.width - UITheme.Space.M * 2f - WhyIndent); + return new Rect(x, body.y + UITheme.Space.S + LineHeight(ObjectiveFontSize), w, LineHeight(WhyFontSize)); + } + + /// + /// 가로 모바일 스택의 윗변 — 안전 영역 위에서 위쪽 가운데 줄(리전 배너·내기 점수판, 데스크톱 코치 배너 띠 높이) 아래까지(260). + /// 모바일 코치 배너는 이제 화면 가운데 줄에 서지만, 이 높이가 칩을 미니맵 아랫변과 맞추고 목표 행을 조이스틱 자리 위에서 끝낸다. + /// + public const float BesideMinimapTop = + GuidedTutorialController.CoachTopOffset + GuidedTutorialController.CoachHeight + UITheme.Space.S; + + public static QuestStackPlace PlaceFor(bool mobileLayout, bool portrait) + { + if (!mobileLayout) return QuestStackPlace.BottomLeft; + return portrait ? QuestStackPlace.UnderMinimap : QuestStackPlace.BesideMinimap; + } + + /// 스택의 왼쪽 끝. 는 MinimapUI.LeftX. + public static float Left(QuestStackPlace place, float minimapLeft) + { + return place == QuestStackPlace.BesideMinimap ? minimapLeft + MinimapUI.PanelSize + UITheme.Space.S : minimapLeft; + } + + /// + /// 칩·목표 행의 폭. 는 MinimapUI.LeftX, + /// 는 단축 바(QuickAccessBarUI.ShortcutBarRect). + /// + public static float Width(QuestStackPlace place, float screenWidth, float safeLeft, float safeRight, float minimapLeft, + Rect quickBar) + { + float room = screenWidth - safeLeft - safeRight - 40f; + if (place == QuestStackPlace.UnderMinimap) return Mathf.Min(MobileMaxWidth, room); + if (place == QuestStackPlace.BesideMinimap) + return Mathf.Min(MobileMaxWidth, Mathf.Max(1f, room - MinimapUI.PanelSize - UITheme.Space.S)); + float beside = BesideBar(minimapLeft, quickBar); + return Mathf.Min(DesktopMaxWidth, beside >= DesktopMinBesideWidth ? beside : room); + } + + /// 모바일 스택의 윗변(데스크톱은 쓰지 않는다 — 에서 위로 쌓는다). + public static float MobileTop(QuestStackPlace place, float contentTop) + { + return place == QuestStackPlace.BesideMinimap + ? contentTop + BesideMinimapTop + : contentTop + MinimapUI.TopOffset + MinimapUI.PanelSize + UITheme.Space.S; // = MinimapUI.StackBelowY + } + + /// 데스크톱 스택(칩 + 목표 행)의 바닥 — 단축 바 옆에 서면 안전 영역 바닥, 아니면 단축 바 위. + public static float DesktopBottom(float minimapLeft, Rect quickBar, float contentBottom) + { + return BesideBar(minimapLeft, quickBar) >= DesktopMinBesideWidth ? contentBottom : quickBar.y - UITheme.Space.S; + } + + /// + /// 칩(또는 숨김 복원 버튼). 모바일은 에서 아래로. 가 0보다 크면 + /// 그 아래 목표 행 자리를 비워 둔다 — 데스크톱은 바닥()에서 위로 쌓으므로 칩이 그만큼 올라간다. + /// + public static Rect Chip(QuestStackPlace place, float x, float width, float height, float rowHeight, float mobileTop, + float desktopBottom) + { + if (place != QuestStackPlace.BottomLeft) return new Rect(x, mobileTop, width, height); + float stack = height + (rowHeight > 0f ? UITheme.Space.XS + rowHeight : 0f); + return new Rect(x, desktopBottom - stack, width, height); + } + + /// 목표 행 — 칩(또는 복원 버튼) 바로 아래, 칩 폭(). + public static Rect Row(Rect chip, float width, float rowHeight) + { + return new Rect(chip.x, chip.yMax + UITheme.Space.XS, width, rowHeight); + } + + private static float BesideBar(float minimapLeft, Rect quickBar) + { + return quickBar.x - UITheme.Space.S - minimapLeft; + } + + // ── 화면 한 장(HudFrame)에서 ── + + /// 칩·목표 행 폭(화면 한 장 기준). + public static float StackWidth(HudFrame f) + { + QuestStackPlace place = PlaceFor(f.Mobile, f.Portrait); + return Width(place, f.Width, f.SafeLeft, f.SafeRight, f.SafeLeft + 16f, QuickAccessBarUI.ShortcutBarRectFor(f)); + } + + /// 칩(높이 )의 자리 — > 0이면 그 아래 목표 행 자리를 비운다. + public static Rect ChipRect(HudFrame f, float height, float rowHeight) + { + return StackRect(f, StackWidth(f), height, rowHeight); + } + + /// 숨김 상태의 복원 버튼 자리. + public static Rect RestoreRect(HudFrame f, float rowHeight) + { + float w = Mathf.Min(f.Mobile ? RestoreWidthMobile : RestoreWidthDesktop, StackWidth(f)); + return StackRect(f, w, f.Mobile ? RestoreHeightMobile : RestoreHeightDesktop, rowHeight); + } + + private static Rect StackRect(HudFrame f, float width, float height, float rowHeight) + { + QuestStackPlace place = PlaceFor(f.Mobile, f.Portrait); + float minimapLeft = f.SafeLeft + 16f; // = MinimapUI.LeftX + Rect bar = QuickAccessBarUI.ShortcutBarRectFor(f); + return Chip(place, Left(place, minimapLeft), width, height, rowHeight, MobileTop(place, f.ContentTop), + DesktopBottom(minimapLeft, bar, f.ContentBottom)); + } + } } diff --git a/Assets/Scripts/UI/UIHelper.cs b/Assets/Scripts/UI/UIHelper.cs index 783440e..f64f0a8 100644 --- a/Assets/Scripts/UI/UIHelper.cs +++ b/Assets/Scripts/UI/UIHelper.cs @@ -10,6 +10,29 @@ public static class UIHelper private static readonly Dictionary styleCache = new Dictionary(); private const int MaxCacheSize = 256; + // ── 텍스처 채우기 ── + + /// + /// 텍스처를 상자에 가운데 잘라 채우는(cover) UV. 16:9 마스터 하나로 세로 화면까지 + /// 덮을 때 쓴다 — 오프닝 스틸과 스토리 영상이 공유한다. + /// + public static Rect CalculateCoverUv(Rect target, int textureWidth, int textureHeight) + { + if (target.width <= 0f || target.height <= 0f || textureWidth <= 0 || textureHeight <= 0) + return new Rect(0f, 0f, 1f, 1f); + + float targetAspect = target.width / target.height; + float textureAspect = textureWidth / (float)textureHeight; + if (textureAspect > targetAspect) + { + float visibleWidth = targetAspect / textureAspect; + return new Rect((1f - visibleWidth) * 0.5f, 0f, visibleWidth, 1f); + } + + float visibleHeight = textureAspect / targetAspect; + return new Rect(0f, (1f - visibleHeight) * 0.5f, 1f, visibleHeight); + } + // ── 텍스처 ── /// @@ -233,6 +256,52 @@ public static void LabelFit(Rect rect, string text, GUIStyle style, int minFontS style.fontSize = baseSize; } + // 공개 중 문자열 1칸 캐시 — 같은 (텍스트, 공개 글자 수)면 OnGUI 패스마다 새로 잇지 않는다. + private static string revealSource; + private static int revealCount = -1; + private static string revealCache; + + /// + /// 타자 효과용 — 앞 글자만 보이게 그린다. + /// + /// 잘라서(Substring) 그리면 두 가지가 흔들린다. ① 글자가 늘 때마다 줄바꿈 위치가 바뀌어 + /// 단어가 윗줄에서 아랫줄로 튄다. ② LabelFit의 축소가 지금 보이는 길이로 폰트를 정해 + /// 줄이 다 나오는 순간 글자가 갑자기 작아진다. 그래서 폰트는 전체 문장으로 맞추고, + /// 아직 안 나온 꼬리는 투명색 리치 텍스트로 자리만 잡아 둔다 — 레이아웃이 처음부터 최종형이다. + /// + public static void LabelFitReveal(Rect rect, string text, int visibleChars, GUIStyle style, int minFontSize = 0) + { + if (style == null || string.IsNullOrEmpty(text)) + return; + // 다 나왔거나, 리치 텍스트 태그와 부딪칠 글자가 있으면 그냥 LabelFit — 대사에 꺾쇠는 없지만 + // 들어오면 태그로 먹혀 글자가 사라지므로 레이아웃 흔들림을 감수한다. + if (visibleChars >= text.Length || text.IndexOf('<') >= 0) + { + LabelFit(rect, visibleChars >= text.Length ? text : text.Substring(0, Mathf.Max(0, visibleChars)), style, minFontSize); + return; + } + + int baseSize = style.fontSize > 0 ? style.fontSize : 12; + int floor = Mathf.Clamp( + minFontSize > 0 ? minFontSize : Mathf.Min(baseSize, MinReadableFontSize), 1, baseSize); + int fitted = FitFontSize(text, rect.width, rect.height, style, baseSize, floor); + + int shown = Mathf.Max(0, visibleChars); + if (!ReferenceEquals(revealSource, text) || revealCount != shown) + { + revealSource = text; + revealCount = shown; + revealCache = text.Substring(0, shown) + "" + text.Substring(shown) + ""; + } + + bool rich = style.richText; + style.richText = true; + style.fontSize = fitted; + GUI.Label(rect, revealCache, style); + style.fontSize = baseSize; + style.richText = rich; + } + /// /// 래핑된 가 에서 차지하는 높이. /// 상자를 키울 수 있는 레이아웃(스크롤 목록 등)에서 쓴다. diff --git a/Assets/Scripts/UI/UISafeLayout.cs b/Assets/Scripts/UI/UISafeLayout.cs index 474e4f5..ef18dac 100644 --- a/Assets/Scripts/UI/UISafeLayout.cs +++ b/Assets/Scripts/UI/UISafeLayout.cs @@ -245,4 +245,135 @@ public static Rect BottomPanel(float width, float height, HAlign align = HAlign. public static float BottomY(float height) => EndStart(height, VerticalBox); } } + + /// + /// 화면 한 장의 배치 기준 — 실제 화면이든 테스트가 세운 화면이든 같은 식으로 HUD 자리를 계산하게 한다. + /// 필드 HUD의 순수 배치 함수가 이걸 받는다. 그리기는 (Screen·SafeArea·)를 넘기고, + /// 전수 겹침 검사(HudOverlapSweepTests)는 해상도·스케일·노치별로 을 세워 넘긴다 — + /// 식을 테스트에 베껴 두면 원본이 바뀔 때 테스트가 거짓으로 통과하므로, 계산은 언제나 HUD 쪽 함수를 부른다. + /// 가상 좌표·스케일·모바일 판정은 과 같은 식이다(UISafeLayoutTests가 맞춰 본다). + /// 화면 모양 말고 하나 더 — 섬 HUD가 서 있는지()를 든다. 가운데 무대(HudStage)의 자리가 그걸로 갈린다. + /// + public readonly struct HudFrame + { + /// 실제 화면 픽셀. + public readonly float PixelWidth; + public readonly float PixelHeight; + /// 세이프 에어리어 인셋(픽셀, GUI 기준 — 위는 노치, 아래는 제스처 바). + public readonly float PixelSafeLeft; + public readonly float PixelSafeRight; + public readonly float PixelSafeTop; + public readonly float PixelSafeBottom; + /// 과 같은 값. + public readonly float Scale; + /// 가상 화면(·Height). + public readonly float Width; + public readonly float Height; + /// 과 같은 값. + public readonly bool Mobile; + /// 와 같은 값. + public readonly bool Portrait; + /// + /// 섬 HUD 판(IslandHudUI — 내 섬·남의 섬)이 이 화면에 서 있는가. 가운데 무대(HudStage.Area)가 이때만 섬 HUD를 피한다 — + /// 필드에서도 피하면 세로 화면의 카드가 화면 왼쪽 절반으로 밀린다(2026-10-03 QA 실측). + /// 는 HudPresence(섬 HUD가 매 프레임 표시한다)에서 읽고, 은 false(필드)다 — + /// 섬 화면은 로 세운다. + /// + public readonly bool IslandHud; + + private HudFrame(float pixelWidth, float pixelHeight, float safeLeft, float safeRight, float safeTop, float safeBottom, + bool mobile, bool islandHud) + { + IslandHud = islandHud; + PixelWidth = Mathf.Max(1f, pixelWidth); + PixelHeight = Mathf.Max(1f, pixelHeight); + PixelSafeLeft = Mathf.Max(0f, safeLeft); + PixelSafeRight = Mathf.Max(0f, safeRight); + PixelSafeTop = Mathf.Max(0f, safeTop); + PixelSafeBottom = Mathf.Max(0f, safeBottom); + Portrait = PixelHeight > PixelWidth; + float refW = Portrait ? UIScale.PortraitReferenceWidth : UIScale.ReferenceWidth; + float refH = Portrait ? UIScale.PortraitReferenceHeight : UIScale.ReferenceHeight; + Scale = Mathf.Max(0.3f, Mathf.Min(PixelWidth / refW, PixelHeight / refH)); + Width = PixelWidth / Scale; + Height = PixelHeight / Scale; + Mobile = mobile; + } + + /// + /// 화면을 세운다. 은 Application.isMobilePlatform — 세로로 긴 창은 그것과 무관하게 + /// 모바일 배치다(과 같은 판정). + /// + public static HudFrame ForScreen(float pixelWidth, float pixelHeight, float safeLeft, float safeRight, float safeTop, + float safeBottom, bool mobilePlatform) + { + bool mobile = mobilePlatform || pixelHeight > pixelWidth * 1.08f; + return new HudFrame(pixelWidth, pixelHeight, safeLeft, safeRight, safeTop, safeBottom, mobile, false); + } + + /// 지금 화면(섬 HUD가 서 있는지는 HudPresence에서). + public static HudFrame Current => new HudFrame(Screen.width, Screen.height, SafeArea.Left, SafeArea.Right, + SafeArea.Top, SafeArea.Bottom, UIScale.IsMobileLayout, HudPresence.IsShowing(HudPresenceItem.IslandHud)); + + /// 같은 화면에서 섬 HUD가 서 있거나() 없는 판. + public HudFrame WithIslandHud(bool on) => new HudFrame(PixelWidth, PixelHeight, PixelSafeLeft, PixelSafeRight, + PixelSafeTop, PixelSafeBottom, Mobile, on); + + // ── 가상 좌표 ── + + public float SafeLeft => PixelSafeLeft / Scale; + public float SafeRight => PixelSafeRight / Scale; + public float SafeTop => PixelSafeTop / Scale; + public float SafeBottom => PixelSafeBottom / Scale; + + public UISafeLayout.SafeBox Horizontal => + UISafeLayout.ComputeWithMargin(Width, SafeLeft, SafeRight, UISafeLayout.MarginX); + public UISafeLayout.SafeBox Vertical => UISafeLayout.Compute(Height, SafeTop, SafeBottom); + + public float ContentLeft => Horizontal.Start; + public float ContentRight => Horizontal.End; + public float ContentWidth => Horizontal.Extent; + public float ContentTop => Vertical.Start; + public float ContentBottom => Vertical.End; + public float ContentHeight => Vertical.Extent; + + public float ClampWidth(float desired) => UISafeLayout.ClampSize(desired, Horizontal); + public float ClampHeight(float desired) => UISafeLayout.ClampSize(desired, Vertical); + + /// UISafeLayout.TopPanel과 같은 값. + public Rect TopPanel(float width, float height, UISafeLayout.HAlign align = UISafeLayout.HAlign.Center) + { + UISafeLayout.SafeBox h = Horizontal; + UISafeLayout.SafeBox v = Vertical; + float w = UISafeLayout.ClampSize(width, h); + return new Rect(UISafeLayout.AlignStart(w, align, h), v.Start, w, UISafeLayout.ClampSize(height, v)); + } + + /// UISafeLayout.BottomPanel과 같은 값. + public Rect BottomPanel(float width, float height, UISafeLayout.HAlign align = UISafeLayout.HAlign.Center) + { + UISafeLayout.SafeBox h = Horizontal; + UISafeLayout.SafeBox v = Vertical; + float w = UISafeLayout.ClampSize(width, h); + float ph = UISafeLayout.ClampSize(height, v); + return new Rect(UISafeLayout.AlignStart(w, align, h), UISafeLayout.EndStart(ph, v), w, ph); + } + + /// UISafeLayout.CenteredY와 같은 값. + public float CenteredY(float height) => UISafeLayout.CenterStart(height, Vertical); + + // ── 픽셀 좌표(UIScale.Begin을 쓰지 않는 화면) ── + + public UISafeLayout.SafeBox PixelHorizontal => + UISafeLayout.ComputeWithMargin(PixelWidth, PixelSafeLeft, PixelSafeRight, UISafeLayout.MarginX); + public UISafeLayout.SafeBox PixelVertical => UISafeLayout.Compute(PixelHeight, PixelSafeTop, PixelSafeBottom); + + /// 가상 → 픽셀. + public Rect ToPixels(Rect virtualRect) => + new Rect(virtualRect.x * Scale, virtualRect.y * Scale, virtualRect.width * Scale, virtualRect.height * Scale); + + /// 픽셀 → 가상(FieldHudInput.RegisterBlockingRect가 받는 좌표). + public Rect ToVirtual(Rect pixelRect) => + new Rect(pixelRect.x / Scale, pixelRect.y / Scale, pixelRect.width / Scale, pixelRect.height / Scale); + } } diff --git a/Assets/Scripts/UI/UIShapes.cs b/Assets/Scripts/UI/UIShapes.cs index 560411e..6110c18 100644 --- a/Assets/Scripts/UI/UIShapes.cs +++ b/Assets/Scripts/UI/UIShapes.cs @@ -160,5 +160,67 @@ public static void Silhouette(Rect rect, Color color, float pad, float roundness Part(new Rect(rect.x - pad, rect.y - pad, rect.width + pad * 2f, rect.height + pad * 2f), color, roundness); } + + // ── 화살표(전투 상성 표시) ── + + private const int TriangleSize = 64; + private static Texture2D triangleTex; + + /// + /// 위를 가리키는 안티앨리어싱 삼각형 알파 텍스처(흰색) — 꼭짓점이 위 가운데, 밑변이 아래. 아래를 가리킬 때는 + /// 텍스처 좌표를 뒤집어 그린다(). 글리프(⬆)는 기기 폰트에 없을 수 있어 도형으로 그린다. + /// + public static Texture2D Triangle + { + get + { + if (triangleTex != null) return triangleTex; + triangleTex = new Texture2D(TriangleSize, TriangleSize, TextureFormat.RGBA32, false) + { + wrapMode = TextureWrapMode.Clamp + }; + float edge = EdgePixels / TriangleSize; + float slant = Mathf.Sqrt(5f); // 빗변 v = 1 − 2|u − ½|의 법선 길이 + for (int y = 0; y < TriangleSize; y++) + { + for (int x = 0; x < TriangleSize; x++) + { + float u = (x + 0.5f) / TriangleSize; + float v = (y + 0.5f) / TriangleSize; // 텍스처 v는 위로 증가 — 꼭짓점이 위 + float toSide = (1f - v - 2f * Mathf.Abs(u - 0.5f)) / slant; + float alpha = Mathf.Clamp01(Mathf.Min(toSide, v) / edge); + triangleTex.SetPixel(x, y, new Color(1f, 1f, 1f, alpha)); + } + } + triangleTex.Apply(); + return triangleTex; + } + } + + /// + /// 굵은 화살표 하나 — 위 58%가 머리(삼각형), 아래가 몸통(폭 40%). 이 거짓이면 아래를 가리킨다. + /// + public static void Arrow(Rect rect, bool up, Color color) + { + if (rect.width <= 0f || rect.height <= 0f) return; + float headH = rect.height * 0.58f; + float stemW = rect.width * 0.4f; + float stemX = rect.x + (rect.width - stemW) * 0.5f; + Color prev = GUI.color; + GUI.color = prev * color; + if (up) + { + GUI.DrawTexture(new Rect(rect.x, rect.y, rect.width, headH), Triangle); + // 머리 밑변과 몸통이 1px 겹치게 — 사이가 벌어져 보이지 않게. + GUI.DrawTexture(new Rect(stemX, rect.y + headH - 1f, stemW, rect.height - headH + 1f), Texture2D.whiteTexture); + } + else + { + GUI.DrawTexture(new Rect(stemX, rect.y, stemW, rect.height - headH + 1f), Texture2D.whiteTexture); + GUI.DrawTextureWithTexCoords(new Rect(rect.x, rect.yMax - headH, rect.width, headH), Triangle, + new Rect(0f, 1f, 1f, -1f)); + } + GUI.color = prev; + } } } diff --git a/Assets/Scripts/UI/UISurface.cs b/Assets/Scripts/UI/UISurface.cs index ae4a720..136ce7a 100644 --- a/Assets/Scripts/UI/UISurface.cs +++ b/Assets/Scripts/UI/UISurface.cs @@ -179,7 +179,11 @@ public static void HudCard(Rect rect) public static bool Button(Rect rect, string label, Color background, GUIStyle style, bool selected = false) { Color body = selected ? Color.Lerp(background, Color.white, 0.32f) : background; - if (rect.Contains(UIScale.VirtualMousePosition)) + // 호버는 지금 GUI 좌표계의 마우스로 잰다 — 스크롤 뷰·그룹·GUI.matrix가 반영돼 rect와 같은 공간이다. + // 가상 화면 좌표(UIScale.VirtualMousePosition)로 재면 스크롤 뷰 안 버튼(퀘스트 목록 [따라가기])은 + // 버튼이 아니라 스크롤 원점 기준의 엉뚱한 자리에서 밝아졌다. + Event e = Event.current; + if (e != null && rect.Contains(e.mousePosition)) { body = Color.Lerp(body, Color.white, 0.16f); } @@ -190,6 +194,18 @@ public static bool Button(Rect rect, string label, Color background, GUIStyle st return GUI.Button(rect, string.Empty, GUIStyle.none); } + /// + /// 얇은 진행 막대(HP·경험치) — 트랙과 채움 모두 각진 채로 그린다. 20px 안팎이라 둥근 + /// 9-slice를 쓰면 테두리 폭이 높이를 넘겨 뭉개진다(의 규칙과 같다). + /// + public static void Meter(Rect rect, float ratio, Color fill) + { + // 트랙은 surfaceBase보다 한 단 어둡게 — 흐리게 가라앉힌 행(바탕이 surfaceBase)에서도 보이게. + Flat(rect, Color.Lerp(UITheme.Instance.surfaceBase, Color.black, 0.4f)); + float r = Mathf.Clamp01(ratio); + if (r > 0f) Flat(new Rect(rect.x, rect.y, rect.width * r, rect.height), fill); + } + /// 등급·속성·보상 등을 담는 작은 알약 배지. public static void Chip(Rect rect, string text, Color background, Color textColor) { diff --git a/Assets/Scripts/UI/UITheme.cs b/Assets/Scripts/UI/UITheme.cs index 077b8fe..1436200 100644 --- a/Assets/Scripts/UI/UITheme.cs +++ b/Assets/Scripts/UI/UITheme.cs @@ -100,6 +100,37 @@ public static class Radius public Color gradeC = new Color(0.3f, 0.8f, 0.3f); public Color gradeD = new Color(0.6f, 0.6f, 0.6f); + // 기술 효과 종류별 색 — 1v1 배틀·레이드·훈련소가 같은 기술을 같은 색으로 칠한다. + // 예전엔 BattleScreenUI와 RaidBattleUI.Draw가 같은 7색을 각자 들고 있었고, 훈련소는 3색만 알아서 + // 회복·기절·독·방어 기술이 회색 레일에 "ATK DOWN"으로 적혔다. + [Header("Skill Effect")] + public Color skillDamage = new Color(0.9f, 0.35f, 0.3f); + public Color skillBuff = new Color(0.3f, 0.8f, 0.4f); + public Color skillDebuff = new Color(0.7f, 0.4f, 0.9f); + public Color skillHeal = new Color(0.35f, 0.92f, 0.62f); + public Color skillDefense = new Color(0.35f, 0.68f, 1f); + public Color skillStun = new Color(1f, 0.86f, 0.25f); + public Color skillPoison = new Color(0.68f, 0.35f, 0.88f); + + private void OnEnable() => SynchronizeLegacyTokens(); + private void OnValidate() => SynchronizeLegacyTokens(); + private void SynchronizeLegacyTokens() + { + panelBg = surfaceBase; + panelHeaderBg = surfaceRaised; + tabNormal = surfaceCard; + tabSelected = accentCoral; + btnPrimary = accentMint; + btnSecondary = surfaceRaised; + btnDanger = accentCoral; + btnDisabled = surfaceBorder; + titleColor = textPrimary; + labelColor = textPrimary; + coinColor = accentAmber; + accentColor = accentCoral; + bonusColor = accentMint; + } + public Color GetInsectRarityColor(InsectRarity rarity) { switch (rarity) @@ -151,6 +182,28 @@ public Color GetInsectColor(string insectId, InsectRarity rarity) return Color.HSVToRGB(hue, Mathf.Clamp01(sat), Mathf.Clamp01(val)); } + /// HP 막대 색 — 절반 위 민트, 20% 위 앰버, 그 아래 코랄. 배틀팀·포획 출전 창이 같은 기준을 쓴다. + public Color GetHpColor(float ratio) + { + if (ratio > 0.5f) return accentMint; + return ratio > 0.2f ? accentAmber : accentCoral; + } + + public Color GetSkillColor(SkillEffectType type) + { + switch (type) + { + case SkillEffectType.Damage: return skillDamage; + case SkillEffectType.BuffAttack: return skillBuff; + case SkillEffectType.DebuffAttack: return skillDebuff; + case SkillEffectType.Heal: return skillHeal; + case SkillEffectType.DefenseBuff: return skillDefense; + case SkillEffectType.Stun: return skillStun; + case SkillEffectType.PoisonDot: return skillPoison; + default: return textMuted; + } + } + public Color GetGradeColor(IVGrade grade) { switch (grade) diff --git a/Assets/Scripts/UI/VirtualJoystickUI.cs b/Assets/Scripts/UI/VirtualJoystickUI.cs index be49648..71173e2 100644 --- a/Assets/Scripts/UI/VirtualJoystickUI.cs +++ b/Assets/Scripts/UI/VirtualJoystickUI.cs @@ -10,6 +10,10 @@ namespace InsectGame.UI /// - 멀티터치: fingerId로 조이스틱 손가락을 추적(다른 손가락 탭/메뉴와 독립). /// - 모달 열림/프리즈 시 비활성(이동 차단). 에디터에선 마우스로도 동작(테스트). /// - 아날로그: 부분 기울임=부분 속도(PlayerMovement에서 크기 보존). + /// - 필드 HUD(에 등록된 자리) 위에서는 시작하지 않는다(유휴 힌트 원 안은 예외) — . + /// - 모바일 배치에서만 켠다(). 데스크톱 배치는 좌하단에 퀘스트 칩이 서고(조이스틱 자리를 + /// 비워 두는 건 모바일 배치뿐이다) 이동은 키보드·클릭으로 한다 — 예전엔 데스크톱에서도 안내 원이 퀘스트 칩 위에 그려지고 + /// 마우스로 좌하단을 누르면 조이스틱이 켜졌다. /// public class VirtualJoystickUI : MonoBehaviour { @@ -22,7 +26,7 @@ public class VirtualJoystickUI : MonoBehaviour private Texture2D baseTex, knobTex; - private float BaseRadius => Mathf.Min(Screen.width, Screen.height) * 0.14f; + private float BaseRadius => HintRadius(Screen.width, Screen.height); public void AutoWire(PlayerMovement pm) { @@ -33,8 +37,8 @@ private void Update() { if (player == null) player = FindFirstObjectByType(); - // 모달/프리즈 중엔 조이스틱 비활성(이동 차단) — 메뉴 조작과 충돌 방지. - bool blocked = ModalUIRegistry.IsAnyOpen() || (player != null && player.IsFrozen); + // 모달/프리즈 중엔 조이스틱 비활성(이동 차단) — 메뉴 조작과 충돌 방지. 데스크톱 배치에서는 아예 쓰지 않는다. + bool blocked = !UIScale.IsMobileLayout || ModalUIRegistry.IsAnyOpen() || (player != null && player.IsFrozen); if (blocked) { Deactivate(); @@ -60,8 +64,62 @@ private void Update() // 좌/하단 세이프 에어리어(노치/제스처바)는 제외 — OS 제스처에 먹히는 데드존에서 시작 방지. private bool InZone(Vector2 p) { - return p.x > SafeArea.Left && p.y > SafeArea.Bottom - && p.x < Screen.width * 0.5f && p.y < Screen.height * 0.5f; + return CanBeginAt(p, Screen.width, Screen.height, SafeArea.Left, SafeArea.Bottom, + FieldHudInput.IsScreenPointOverHud(p)); + } + + /// + /// 이 화면 점(Y-up 픽셀)에서 조이스틱을 시작해도 되는가 — 좌하단 사분면(왼쪽·아래 세이프 에어리어 제외) 안이고 + /// 필드 HUD 위가 아닐 것( = ). + /// 단 유휴 힌트 원 안은 HUD가 있어도 시작한다 — "여기를 누르면 움직인다"고 그려 둔 자리다. + /// + /// HUD 판정을 더한 이유: 사분면에 걸친 HUD 버튼(대화 버튼, 동굴 입구 버튼, 섬 안내 배너)을 누르면 그 버튼과 함께 조이스틱이 + /// 켜져 캐릭터가 움직였다 — 클릭-이동(PlayerMovement)은 이미 같은 등록 목록으로 그 탭을 거르는데 조이스틱만 안 걸렀다. + /// 힌트 원을 예외로 둔 이유: 잠깐 뜨는 안내가 힌트 자리를 덮더라도 거기서는 켜져야 처음 걷는 사람에게 조작이 고장 난 것처럼 안 보인다 + /// (2026-10-03 전엔 꿈속 섬 안내 카드가 세로 화면에서 그 자리를 덮었다 — 지금은 잡기 글자 위로 올렸다). 늘 떠 있는 HUD는 힌트 원을 + /// 덮지 않는다(HudOverlapSweepTests가 잰다). + /// 시작만 막는다 — 이미 잡은 조이스틱은 손가락이 HUD 위를 지나가도 놓치지 않는다. 사분면 자체는 줄이지 않는다(입력 데드존). + /// + public static bool CanBeginAt(Vector2 screenPoint, float screenWidth, float screenHeight, float safeLeft, float safeBottom, + bool overHud) + { + bool inQuadrant = screenPoint.x > safeLeft && screenPoint.y > safeBottom + && screenPoint.x < screenWidth * 0.5f && screenPoint.y < screenHeight * 0.5f; + if (!inQuadrant) return false; + if (!overHud) return true; + float r = HintRadius(screenWidth, screenHeight); + return (screenPoint - HintCenter(screenWidth, screenHeight, safeLeft, safeBottom)).sqrMagnitude <= r * r; + } + + /// 베이스 원 반지름(픽셀) — 짧은 변의 14%. 유휴 힌트 원도 같은 크기다. + public static float HintRadius(float screenWidth, float screenHeight) + { + return Mathf.Min(screenWidth, screenHeight) * 0.14f; + } + + /// 유휴 힌트 원의 가운데(Y-up 픽셀) — 좌하단 모서리에서 반지름의 1.25배 안쪽(세이프 에어리어 안). + public static Vector2 HintCenter(float screenWidth, float screenHeight, float safeLeft, float safeBottom) + { + float r = HintRadius(screenWidth, screenHeight); + return new Vector2(r * 1.25f + safeLeft, r * 1.25f + safeBottom); + } + + /// 이 화면에서 조이스틱을 쓰는가 — 모바일 배치만. + public static bool EnabledFor(HudFrame f) => f.Mobile; + + /// 유휴 힌트 원을 감싸는 사각형(가상 좌표, GUI의 아래로 자라는 y) — 겹침 전수 검사가 읽는다. + public static Rect HintRect(HudFrame f) + { + float r = HintRadius(f.PixelWidth, f.PixelHeight); + Vector2 c = HintCenter(f.PixelWidth, f.PixelHeight, f.PixelSafeLeft, f.PixelSafeBottom); + return f.ToVirtual(new Rect(c.x - r, f.PixelHeight - c.y - r, r * 2f, r * 2f)); + } + + /// 조이스틱을 시작할 수 있는 사분면(가상 좌표) — 왼쪽·아래 세이프 에어리어를 뺀 좌하단 사분면. + public static Rect ZoneRect(HudFrame f) + { + return f.ToVirtual(new Rect(f.PixelSafeLeft, f.PixelHeight * 0.5f, + f.PixelWidth * 0.5f - f.PixelSafeLeft, f.PixelHeight * 0.5f - f.PixelSafeBottom)); } // 베이스 원이 화면(세이프 에어리어) 안에 완전히 들어오도록 원점을 클램프 — 가장자리에서 눌러도 @@ -145,6 +203,7 @@ private void Deactivate() private void OnGUI() { + if (!UIScale.IsMobileLayout) return; EnsureTex(); float r = BaseRadius; @@ -153,10 +212,11 @@ private void OnGUI() DrawCircle(originScreen, r, baseTex, new Color(1f, 1f, 1f, 0.22f)); DrawCircle(knobScreen, r * 0.5f, knobTex, new Color(0.6f, 0.85f, 1f, 0.55f)); } - else + else if (!ModalUIRegistry.IsAnyOpen() && (player == null || !player.IsFrozen)) { // 유휴 힌트(좌하단 코너) — 조이스틱 위치 발견성. 세이프 에어리어 안쪽으로. - Vector2 hint = new Vector2(r * 1.25f + SafeArea.Left, r * 1.25f + SafeArea.Bottom); + // 조이스틱이 꺼진 동안(창이 열렸거나 조작이 묶임)은 그리지 않는다 — 창 위에 비치고, 눌러도 안 움직인다. + Vector2 hint = HintCenter(Screen.width, Screen.height, SafeArea.Left, SafeArea.Bottom); DrawCircle(hint, r, baseTex, new Color(1f, 1f, 1f, 0.10f)); DrawCircle(hint, r * 0.5f, knobTex, new Color(1f, 1f, 1f, 0.14f)); } diff --git a/Assets/Scripts/UI/WorldClockHUD.cs b/Assets/Scripts/UI/WorldClockHUD.cs new file mode 100644 index 0000000..21b7f66 --- /dev/null +++ b/Assets/Scripts/UI/WorldClockHUD.cs @@ -0,0 +1,487 @@ +using InsectGame.Core; +using InsectGame.Data; +using UnityEngine; + +namespace InsectGame.UI +{ + /// + /// 필드 HUD의 시각·날씨 칩("밤 22:10 | 비")과 시간대·날씨가 바뀔 때 잠깐 뜨는 알림 한 줄. + /// + /// 날씨는 지금 선 지역 기준이다() — 설산에서는 비가 와도 "눈"이다. + /// 동굴·방 같은 서브에리어 안은 하늘이 안 보이니 시각만 보이고(날씨 칸·알림 없음), 나의 섬은 하늘 아래다 — + /// 섬 하늘도 낮·밤·날씨를 따르므로(SubAreaEnvironment) 칩과 알림이 그대로 뜨고, 지역이 없어 세계 날씨를 쓴다 + /// (). + /// + /// 자리: 우측 열의 맨 아래. 데스크톱은 우상단 포획 아이템 패널() 아래, + /// 모바일은 우상단 단축 바() 아래다. 좌측은 상태 패널·미니맵·퀘스트 칩, + /// 상단 가운데는 리전 배너·내기 점수판·코치 배너·소식 카드 사다리, 좌하단은 가상 조이스틱, 우하단은 잡기 버튼이라 비는 곳이 이 열뿐이다. + /// 세로 모바일 섬에서는 단축 바 아래를 섬 HUD 열이 차지하므로 그 열 아래로 내려간다(). + /// 가로 모바일 섬(HUD가 단축 바 왼쪽 두 칸 판)과 데스크톱 섬(HUD가 하단 가운데 줄)은 필드와 같은 자리다. + /// + /// 비모달이라 칩과 알림의 자리를 에 등록한다 — 안 하면 칩을 누른 탭이 월드 클릭-이동으로 새어 + /// 캐릭터가 걸어간다. 판정(시각 표기·알림 문구·좌표)은 에 있다. + /// + public class WorldClockHUD : MonoBehaviour + { + private const float NoticeSeconds = 3f; + private const float NoticeFadeIn = 0.25f; + private const float NoticeFadeOut = 0.4f; + private const float NoticeSlide = 36f; + + [SerializeField] private WorldStateProvider worldState; + [SerializeField] private RegionManager regionManager; + [SerializeField] private PlayerMovement playerMovement; + // 섬에서 남의 섬을 구경 중인지만 읽는다(모바일 섬 HUD 판의 높이가 달라 칩 자리가 바뀐다). + [SerializeField] private IslandWorldBuilder islandWorld; + private bool islandWorldSearched; + + // 실제로 구독해 둔 대상 — 해지는 이 참조로 한다(worldState가 바뀌어도 옛 대상에서 정확히 뗀다). + private GameClock subscribedClock; + private WeatherSystem subscribedWeather; + private DayPhase lastPhase; + + private string noticeText = string.Empty; + private Color noticeAccent; + private float noticeTimer; + // 날씨 알림이 말한 "그 자리에서 보이던 날씨" — 다른 리전·섬으로 옮겨 보이는 날씨가 달라지면 알림을 거둔다. + private bool noticeAboutPhase; + private WeatherType noticeWeather; + + // 시각 라벨은 10분 칸이 바뀔 때만 다시 만든다(현실 5초마다) — OnGUI 매 패스 문자열 할당 방지. + private string timeLabel = string.Empty; + private int timeLabelKey = -1; + + private GUIStyle timeStyle; + private GUIStyle weatherStyle; + private GUIStyle noticeStyle; + private bool stylesReady; + + /// Bootstrap이 부른다. 구독까지 여기서 한다 — AddComponent가 부르는 OnEnable 시점엔 시계가 아직 없다. + public void AutoWire(WorldStateProvider provider, RegionManager region, PlayerMovement movement) + { + if (worldState == null) worldState = provider; + if (regionManager == null) regionManager = region; + if (playerMovement == null) playerMovement = movement; + SubscribeEvents(); + } + + /// + /// 섬 월드 — 남의 섬 구경 중인지 보려고. 안 불러도 섬에 처음 들어갈 때 한 번 찾는다(그 뒤로는 찾지 않는다). + /// + public void AutoWire(IslandWorldBuilder world) + { + if (islandWorld == null) islandWorld = world; + } + + private void OnEnable() + { + SubscribeEvents(); + } + + private void OnDisable() + { + if (subscribedClock != null) subscribedClock.DayPhaseChanged -= OnDayPhaseChanged; + if (subscribedWeather != null) subscribedWeather.WeatherChanged -= OnWeatherChanged; + subscribedClock = null; + subscribedWeather = null; + noticeTimer = 0f; + } + + // `-=` 뒤 `+=`라 AutoWire와 OnEnable이 둘 다 불러도 중복 구독이 되지 않는다. + private void SubscribeEvents() + { + if (subscribedClock != null) subscribedClock.DayPhaseChanged -= OnDayPhaseChanged; + if (subscribedWeather != null) subscribedWeather.WeatherChanged -= OnWeatherChanged; + + subscribedClock = worldState != null ? worldState.Clock : null; + subscribedWeather = worldState != null ? worldState.Weather : null; + + if (subscribedClock != null) + { + subscribedClock.DayPhaseChanged += OnDayPhaseChanged; + lastPhase = subscribedClock.GetDayPhase(); // 구독 전에 지나간 변화는 알리지 않는다 + } + if (subscribedWeather != null) subscribedWeather.WeatherChanged += OnWeatherChanged; + } + + // ── 읽기 ── + + private GameClock Clock => worldState != null ? worldState.Clock : null; + + private WeatherType GlobalWeather + { + get + { + WeatherSystem weather = worldState != null ? worldState.Weather : null; + return weather != null ? weather.CurrentWeather : WeatherType.Clear; + } + } + + private string CurrentRegionId + { + get + { + RegionData region = regionManager != null ? regionManager.CurrentRegion : null; + return region != null ? region.regionId : null; + } + } + + private SubAreaData CurrentSubArea => regionManager != null ? regionManager.CurrentSubArea : null; + + // 동굴·방 — 하늘이 안 보인다. 나의 섬(분리 구역)은 하늘 아래라 여기 들지 않는다. + private bool Indoors + { + get + { + SubAreaData area = CurrentSubArea; + return area != null && !area.detached; + } + } + + private bool OnIsland + { + get + { + SubAreaData area = CurrentSubArea; + return area != null && area.detached; + } + } + + // 날씨를 읽을 지역 — 섬(서브에리어)이면 null(세계 날씨). 섬에 있는 동안 CurrentRegion은 떠나온 리전에 붙어 있다. + private string SkyRegionId => WorldClockRules.SkyRegionId(CurrentRegionId, CurrentSubArea != null); + + private bool VisitingIsland + { + get + { + if (islandWorld == null && !islandWorldSearched) + { + islandWorldSearched = true; // 배선이 안 됐을 때의 대비 — 한 번만 찾는다(없는 씬에서 매 프레임 훑지 않게) + islandWorld = FindFirstObjectByType(); + } + return islandWorld != null && islandWorld.IsVisiting; + } + } + + // 꿈 연출·메뉴·대사·전투·포획이 화면을 덮었거나 조작이 묶였다 — 그리지 않고 알림 시간도 흐르지 않는다. + private bool Covered => + DreamPrologueState.Active + || ModalUIRegistry.IsAnyOpen() + || (playerMovement != null && playerMovement.IsFrozen); + + // ── 변화 알림 ── + + private void OnDayPhaseChanged(DayPhase phase) + { + WeatherType weather = GlobalWeather; + RaiseNotice(new SkyState(lastPhase, weather), new SkyState(phase, weather)); + lastPhase = phase; + } + + private void OnWeatherChanged(WeatherType previous, WeatherType next) + { + DayPhase phase = Clock != null ? Clock.GetDayPhase() : lastPhase; + RaiseNotice(new SkyState(phase, previous), new SkyState(phase, next)); + } + + // 연달아 오면 쌓지 않고 최신 하나로 바꿔 처음부터 다시 보여 준다. + private void RaiseNotice(SkyState before, SkyState after) + { + if (DreamPrologueState.Active) return; // 꿈 밖의 하늘이 꿈속에 비치지 않게 + ClockNotice notice = WorldClockRules.ChooseNotice(before, after, SkyRegionId, Indoors); + if (!notice.HasValue) return; + + noticeText = notice.Text; + noticeAboutPhase = notice.AboutPhase; + noticeWeather = notice.Weather; + noticeAccent = notice.AboutPhase ? PhaseAccent(notice.Phase) : WeatherAccent(notice.Weather); + noticeTimer = NoticeSeconds; + } + + private void Update() + { + if (noticeTimer <= 0f) return; + // 알림은 열린 하늘 아래 얘기다 — 동굴·방으로 들어가면 거둔다(섬은 하늘 아래라 그대로 둔다). + if (Indoors) { noticeTimer = 0f; return; } + // 날씨 알림은 그 자리에서 보이던 날씨였다 — 옮겨 간 곳(다른 리전·섬)에서 보이는 날씨가 다르면 거둔다 + // (설산의 "눈이 내리기 시작합니다"가 비 오는 섬에 뜨지 않게). 시간대는 어디서나 같아 그대로 둔다. + if (!noticeAboutPhase && WeatherForecast.EffectiveIn(GlobalWeather, SkyRegionId) != noticeWeather) + { + noticeTimer = 0f; + return; + } + if (Covered) return; // 가려진 동안은 시간을 태우지 않는다(창을 닫았을 때 이미 사라진 뒤면 못 본다) + noticeTimer -= Time.unscaledDeltaTime; + } + + // ── 그리기 ── + + private void OnGUI() + { + if (Clock == null || Covered) return; + + UIScale.Begin(); + EnsureStyles(); + + bool mobile = UIScale.IsMobileLayout; + Rect chip = ChipRect(mobile); + + // 버튼은 없지만 불투명 패널이다 — 안 등록하면 칩을 누른 탭이 클릭-이동으로 새어 캐릭터가 걸어간다. + FieldHudInput.RegisterBlockingRect(chip); + DrawChip(chip, mobile); + if (noticeTimer > 0f) DrawNotice(chip, mobile); + + GUI.color = Color.white; + UIScale.End(); + } + + // 필드는 우측 열의 맨 아래(데스크톱: 포획 아이템 패널 아래 · 모바일: 단축 바 아래). 세로 모바일 섬은 단축 바 아래를 + // 섬 HUD 열이 차지하므로 그 열 아래로 내려간다. 가로 모바일 섬(HUD가 단축 바 왼쪽)과 데스크톱 섬(HUD가 하단 가운데)은 + // 필드와 같은 자리다. + private Rect ChipRect(bool mobile) + { + HudFrame frame = HudFrame.Current; + bool island = OnIsland; + // 남의 섬 여부는 세로 모바일 섬에서만 자리를 바꾼다 — 그때만 찾는다. + bool visiting = island && frame.Mobile && frame.Portrait && VisitingIsland; + return WorldClockRules.Chip(frame, island, visiting); + } + + private void EnsureStyles() + { + if (stylesReady) return; + stylesReady = true; + UITheme t = UITheme.Instance; + + timeStyle = new GUIStyle(GUI.skin.label) + { fontSize = 28, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft, wordWrap = false }; + timeStyle.normal.textColor = t.textPrimary; + + weatherStyle = new GUIStyle(timeStyle); // 색은 날씨마다 매 그리기에 정한다 + + noticeStyle = new GUIStyle(GUI.skin.label) + { fontSize = 26, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, wordWrap = true }; + noticeStyle.normal.textColor = t.textPrimary; + } + + private void DrawChip(Rect chip, bool mobile) + { + UITheme t = UITheme.Instance; + GameClock clock = Clock; + float hour = clock.GetHourFloat(); + DayPhase phase = clock.GetDayPhase(); + bool indoors = Indoors; + // 동굴·방은 하늘이 안 보인다 — 날씨를 말하지 않고, 아이콘은 시간대의 해·달만 쓴다. 섬은 세계 날씨다. + WeatherType weather = indoors ? WeatherType.Clear : WeatherForecast.EffectiveIn(GlobalWeather, SkyRegionId); + + int step = WorldClockRules.ClockStepIndex(hour); + if (step != timeLabelKey) + { + timeLabelKey = step; + timeLabel = WorldClockRules.TimeLabel(hour); + } + + UISurface.HudCard(chip); + // 위쪽 액센트 줄 — 시간대 색. 얇아서 각진 채로 두고 긴 축을 반경만큼 물린다(ui-layout.md). + UISurface.Flat(new Rect(chip.x + UITheme.Radius.Card, chip.y + 3f, + chip.width - UITheme.Radius.Card * 2f, 3f), PhaseAccent(phase)); + + float pad = UITheme.Space.M; + float iconSize = chip.height - 20f; + Rect icon = new Rect(chip.x + pad, chip.y + (chip.height - iconSize) * 0.5f + 1f, iconSize, iconSize); + DrawSkyIcon(icon, phase, weather); + + float textX = icon.xMax + UITheme.Space.S; + float textRight = chip.xMax - pad; + float textY = chip.y + 8f; + float textH = chip.height - 14f; + int fontSize = mobile ? 26 : 28; + timeStyle.fontSize = fontSize; + weatherStyle.fontSize = fontSize; + + GUI.color = Color.white; + if (indoors) + { + UIHelper.LabelFit(new Rect(textX, textY, textRight - textX, textH), timeLabel, timeStyle); + return; + } + + float timeW = (textRight - textX) * 0.56f; + UIHelper.LabelFit(new Rect(textX, textY, timeW, textH), timeLabel, timeStyle); + + float dividerX = textX + timeW + UITheme.Space.XS; + UISurface.Flat(new Rect(dividerX, chip.y + chip.height * 0.28f, 2f, chip.height * 0.44f), t.surfaceBorder); + + float weatherX = dividerX + UITheme.Space.S; + weatherStyle.normal.textColor = WeatherAccent(weather); + UIHelper.LabelFit(new Rect(weatherX, textY, textRight - weatherX, textH), + WeatherForecast.DisplayName(weather), weatherStyle); + } + + private void DrawNotice(Rect chip, bool mobile) + { + float elapsed = NoticeSeconds - noticeTimer; + float appear = Mathf.Clamp01(elapsed / NoticeFadeIn); + float alpha = appear * Mathf.Clamp01(noticeTimer / NoticeFadeOut); + + Rect rect = WorldClockRules.NoticeBelow(HudFrame.Current, chip); + rect.x += (1f - appear) * (1f - appear) * NoticeSlide; // 오른쪽 가장자리에서 미끄러져 들어온다 + FieldHudInput.RegisterBlockingRect(rect); + + // 알파는 GUI.color로만 곱한다(글자 색에 또 굽지 않는다 — 제곱으로 어두워진다). + GUI.color = new Color(1f, 1f, 1f, alpha); + UISurface.HudCard(rect); + UISurface.Flat(new Rect(rect.x + UITheme.Radius.Card, rect.y + 3f, + rect.width - UITheme.Radius.Card * 2f, 3f), noticeAccent); + + noticeStyle.fontSize = mobile ? 24 : 26; + float pad = UITheme.Space.M; + UIHelper.LabelFit(new Rect(rect.x + pad, rect.y + 10f, rect.width - pad * 2f, rect.height - 16f), + noticeText, noticeStyle); + GUI.color = Color.white; + } + + // ── 색 — 전부 UITheme 토큰에서 파생한다 ── + + private static Color PhaseAccent(DayPhase phase) + { + UITheme t = UITheme.Instance; + switch (phase) + { + case DayPhase.Morning: return t.accentAmber; + case DayPhase.Day: return Color.Lerp(t.accentAmber, t.textPrimary, 0.3f); + case DayPhase.Evening: return t.accentCoral; + default: return t.itemRare; + } + } + + private static Color WeatherAccent(WeatherType weather) + { + UITheme t = UITheme.Instance; + switch (weather) + { + case WeatherType.Rain: return t.itemRare; + case WeatherType.Snow: return t.textPrimary; + case WeatherType.Fog: return t.textSecondary; + case WeatherType.Wind: return t.accentMint; + default: return t.accentAmber; + } + } + + // ── 아이콘 — UIShapes의 원과 UISurface의 막대만으로 그린다(회전 없음: UIScale≠1이면 피벗이 어긋난다) ── + // 좌표는 전부 정사각 상자(box) 안의 0~1 비율이다. + + private static void DrawSkyIcon(Rect box, DayPhase phase, WeatherType weather) + { + UITheme t = UITheme.Instance; + Color cloud = Color.Lerp(t.textSecondary, t.textPrimary, 0.4f); + switch (weather) + { + case WeatherType.Rain: + DrawCloud(box, 0f, cloud); + Drop(box, 0.30f, 0.70f, t.itemRare); + Drop(box, 0.50f, 0.78f, t.itemRare); + Drop(box, 0.70f, 0.70f, t.itemRare); + break; + case WeatherType.Snow: + DrawCloud(box, 0f, cloud); + Dot(box, 0.30f, 0.78f, 0.10f, t.textPrimary); + Dot(box, 0.50f, 0.89f, 0.10f, t.textPrimary); + Dot(box, 0.70f, 0.78f, 0.10f, t.textPrimary); + break; + case WeatherType.Fog: + { + Color fog = Color.Lerp(cloud, t.textMuted, 0.5f); + DrawCloud(box, -0.04f, fog); + Bar(box, 0.14f, 0.70f, 0.62f, 0.07f, fog); + Bar(box, 0.26f, 0.84f, 0.62f, 0.07f, fog); + break; + } + case WeatherType.Wind: + Bar(box, 0.08f, 0.28f, 0.62f, 0.08f, t.accentMint); + Bar(box, 0.20f, 0.50f, 0.72f, 0.08f, t.accentMint); + Bar(box, 0.08f, 0.72f, 0.48f, 0.08f, t.accentMint); + break; + default: + switch (phase) + { + case DayPhase.Morning: DrawHorizonSun(box, t.accentAmber); break; + case DayPhase.Evening: DrawHorizonSun(box, t.accentCoral); break; + case DayPhase.Day: DrawSun(box, t.accentAmber); break; + default: DrawMoon(box, t); break; + } + break; + } + } + + private static void Dot(Rect box, float cx, float cy, float diameter, Color color) + { + float d = diameter * box.width; + UIShapes.Ellipse(new Rect(box.x + cx * box.width - d * 0.5f, box.y + cy * box.height - d * 0.5f, d, d), color); + } + + private static void Drop(Rect box, float cx, float top, Color color) + { + UIShapes.Ellipse(new Rect(box.x + (cx - 0.035f) * box.width, box.y + top * box.height, + 0.07f * box.width, 0.16f * box.height), color); + } + + // 가로 막대 — 몸통은 각지게, 양 끝은 원으로 덮어 둥글린다. + private static void Bar(Rect box, float x, float y, float width, float height, Color color) + { + float th = Mathf.Max(2f, height * box.height); + Rect r = new Rect(box.x + x * box.width, box.y + y * box.height, width * box.width, th); + UIShapes.Ellipse(new Rect(r.x, r.y, th, th), color); + UIShapes.Ellipse(new Rect(r.xMax - th, r.y, th, th), color); + UISurface.Flat(new Rect(r.x + th * 0.5f, r.y, Mathf.Max(1f, r.width - th), th), color); + } + + private static void DrawCloud(Rect box, float dy, Color color) + { + Dot(box, 0.34f, 0.38f + dy, 0.30f, color); + Dot(box, 0.56f, 0.30f + dy, 0.40f, color); + Dot(box, 0.74f, 0.42f + dy, 0.26f, color); + UIShapes.Ellipse(new Rect(box.x + 0.12f * box.width, box.y + (0.36f + dy) * box.height, + 0.76f * box.width, 0.24f * box.height), color); + } + + private static void DrawSun(Rect box, Color color) + { + Dot(box, 0.5f, 0.5f, 0.44f, color); + for (int i = 0; i < 8; i++) + { + float a = i * Mathf.PI * 0.25f; + Dot(box, 0.5f + Mathf.Cos(a) * 0.38f, 0.5f + Mathf.Sin(a) * 0.38f, 0.09f, color); + } + } + + // 지평선 위로 반쯤 뜬 해 — 그룹 클립으로 원의 아랫부분을 잘라 낸다(마스크용 배경색을 따로 칠하지 않는다). + private static void DrawHorizonSun(Rect box, Color color) + { + GUI.BeginGroup(new Rect(box.x, box.y, box.width, box.height * 0.62f)); + Rect local = new Rect(0f, 0f, box.width, box.height); + Dot(local, 0.5f, 0.62f, 0.5f, color); + for (int i = 0; i < 5; i++) + { + float a = Mathf.PI + (i + 1) * Mathf.PI / 6f; // 210°~330° — 위쪽 부채꼴만 + Dot(local, 0.5f + Mathf.Cos(a) * 0.40f, 0.62f + Mathf.Sin(a) * 0.40f, 0.09f, color); + } + GUI.EndGroup(); + UISurface.Flat(new Rect(box.x + box.width * 0.06f, box.y + box.height * 0.66f, + box.width * 0.88f, Mathf.Max(2f, box.height * 0.07f)), color); + } + + // 달 — 이지러진 모양은 배경색으로 덮어야 해서(반투명 카드라 색이 안 맞는다) 분화구 있는 보름달로 그린다. + private static void DrawMoon(Rect box, UITheme t) + { + Color moon = Color.Lerp(t.textPrimary, t.accentAmber, 0.2f); + Dot(box, 0.44f, 0.54f, 0.56f, moon); + Dot(box, 0.34f, 0.44f, 0.12f, t.textMuted); + Dot(box, 0.52f, 0.64f, 0.09f, t.textMuted); + Dot(box, 0.56f, 0.42f, 0.07f, t.textMuted); + Dot(box, 0.80f, 0.20f, 0.10f, t.textPrimary); + Dot(box, 0.90f, 0.46f, 0.06f, t.textPrimary); + Dot(box, 0.76f, 0.84f, 0.07f, t.textPrimary); + } + } +} diff --git a/Assets/Scripts/UI/WorldClockHUD.cs.meta b/Assets/Scripts/UI/WorldClockHUD.cs.meta new file mode 100644 index 0000000..2af27bd --- /dev/null +++ b/Assets/Scripts/UI/WorldClockHUD.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6bc5eea2f304ff142af5d0c589ebc56f \ No newline at end of file diff --git a/Assets/Scripts/UI/WorldClockRules.cs b/Assets/Scripts/UI/WorldClockRules.cs new file mode 100644 index 0000000..76b98bc --- /dev/null +++ b/Assets/Scripts/UI/WorldClockRules.cs @@ -0,0 +1,213 @@ +using System.Globalization; +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.UI +{ + /// 하늘의 상태 한 장 — 시간대와 세계 날씨. 지역에 따라 다르게 보이는 것은 판정이 지역을 받아 입힌다. + public readonly struct SkyState + { + public readonly DayPhase Phase; + public readonly WeatherType Weather; + + public SkyState(DayPhase phase, WeatherType weather) + { + Phase = phase; + Weather = weather; + } + } + + /// + /// 알림 한 줄. 가 거짓이면 알릴 변화가 없다(지역 기준으로 화면에 보이는 것이 그대로다). + /// 색을 고르는 쪽(HUD)이 읽도록 무엇에 대한 알림인지(시간대/날씨)와 새 값을 함께 든다. + /// + public readonly struct ClockNotice + { + public readonly string Text; + public readonly bool AboutPhase; + public readonly DayPhase Phase; + /// 이 지역에서 보이는 새 날씨( 적용 후). + public readonly WeatherType Weather; + + public ClockNotice(string text, bool aboutPhase, DayPhase phase, WeatherType weather) + { + Text = text; + AboutPhase = aboutPhase; + Phase = phase; + Weather = weather; + } + + public bool HasValue => !string.IsNullOrEmpty(Text); + } + + /// + /// 의 순수 규칙 — 시각 표기, 알림 문구 고르기, 칩·알림의 자리. + /// 그리기는 테스트할 수 없으니(IMGUI) 판정과 좌표 계산을 여기로 뺐다. + /// + /// 시각은 10분 단위로 내림한다. 게임 하루가 12분이라 게임 1분이 현실 0.5초다 — 분 단위로 그리면 숫자가 + /// 1초에 두 번 바뀌어 읽을 수 없고, 반올림하면 20:55가 "21:00"으로 보여 시간대 이름("저녁")과 어긋난다. + /// 내림이면 시간대 경계(정시)에서만 이름과 시각이 함께 바뀐다. + /// + public static class WorldClockRules + { + public const int ClockStepMinutes = 10; + public const int ClockStepsPerDay = 24 * 60 / ClockStepMinutes; + + // ── 시각 표기 ── + + /// 시각(0~24, 범위 밖은 감는다)이 하루 144칸(10분) 중 몇 번째 칸인가 — 라벨 캐시 키로도 쓴다. + public static int ClockStepIndex(float hour) + { + float h = Mathf.Repeat(hour, 24f); + int minutes = Mathf.Clamp(Mathf.FloorToInt(h * 60f), 0, 24 * 60 - 1); + return minutes / ClockStepMinutes; + } + + /// "22:10" — 24시간제, 두 자리씩. 24:00은 나오지 않는다(자정은 00:00). + public static string FormatClock(float hour) + { + int minutes = ClockStepIndex(hour) * ClockStepMinutes; + return (minutes / 60).ToString("00", CultureInfo.InvariantCulture) + + ":" + (minutes % 60).ToString("00", CultureInfo.InvariantCulture); + } + + /// "밤 22:10" — 시간대 이름은 시계와 같은 식()으로 정한다. + public static string TimeLabel(float hour) + { + DayPhase phase = GameClock.PhaseOfHour(Mathf.Repeat(hour, 24f)); + return WeatherForecast.DisplayName(phase) + " " + FormatClock(hour); + } + + // ── 어느 하늘인가 ── + + /// + /// 날씨를 읽을 지역 ID — 서브에리어(섬 포함)에 있으면 null(세계 날씨 그대로). 섬에 있는 동안 + /// RegionManager.CurrentRegion은 떠나온 리전에 붙어 있어서, 그걸 읽으면 설산에서 섬으로 건너간 사람에게 "눈"이 보인다. + /// 전투 보정(BattleEnvironment.WeatherRegionId)과 같은 기준이다 — 전투 칩과 필드 칩의 날씨가 갈리지 않는다. + /// + public static string SkyRegionId(string currentRegionId, bool inSubArea) + { + return inSubArea ? null : currentRegionId; + } + + // ── 변화 알림 ── + + /// + /// 이전 → 새 하늘에서 알릴 한 줄. 화면에 보이는 것이 안 바뀌었으면 알리지 않는다 — + /// 설산에서 비가 눈으로 바뀌어도(둘 다 "눈") 사막에서 눈이 센바람으로 바뀌어도(둘 다 "센바람") 지역 기준으로는 그대로다. + /// 서브에리어 안()은 하늘이 안 보이니 알리지 않는다. + /// 시간대와 날씨가 한꺼번에 바뀌면 시간대가 이긴다(야행성 곤충 안내가 날씨보다 중요하다). + /// + public static ClockNotice ChooseNotice(SkyState previous, SkyState current, string regionId, bool indoors) + { + if (indoors) return default; + + WeatherType after = WeatherForecast.EffectiveIn(current.Weather, regionId); + if (previous.Phase != current.Phase) + return new ClockNotice(PhaseNotice(current.Phase), true, current.Phase, after); + + WeatherType before = WeatherForecast.EffectiveIn(previous.Weather, regionId); + if (before == after) return default; + return new ClockNotice(WeatherNotice(before, after), false, current.Phase, after); + } + + public static string PhaseNotice(DayPhase phase) + { + switch (phase) + { + case DayPhase.Morning: return "아침이 밝았습니다"; + case DayPhase.Day: return "낮이 되었습니다"; + case DayPhase.Evening: return "해가 저물어 갑니다"; + default: return "밤이 되었습니다 — 야행성 곤충이 깨어납니다"; + } + } + + /// 날씨가 에서 로 바뀐 것을 말한다(둘은 달라야 한다). + public static string WeatherNotice(WeatherType before, WeatherType after) + { + switch (after) + { + case WeatherType.Rain: return "비가 내리기 시작합니다"; + case WeatherType.Snow: return "눈이 내리기 시작합니다"; + case WeatherType.Fog: return "안개가 끼기 시작합니다"; + case WeatherType.Wind: return "센바람이 불기 시작합니다"; + default: + // 맑아졌다 — 무엇이 걷혔는지로 말한다. + switch (before) + { + case WeatherType.Rain: return "비가 그쳤습니다"; + case WeatherType.Snow: return "눈이 그쳤습니다"; + case WeatherType.Fog: return "안개가 걷혔습니다"; + case WeatherType.Wind: return "바람이 잦아들었습니다"; + default: return "하늘이 맑아졌습니다"; + } + } + } + + // ── 배치 ── + + public const float DesktopChipHeight = 64f; + public const float MobileChipHeight = 60f; + public const float NoticeHeight = 72f; + public const float DesktopNoticeWidth = 540f; + public const float MobileNoticeWidth = 480f; + + /// + /// 칩은 우측 열의 맨 아래에 붙는다 — 데스크톱은 우상단 포획 아이템 패널 아래, 모바일은 우상단 단축 바 아래. + /// 폭과 x를 에 맞춰 위 패널과 한 줄로 선다. + /// + public static Rect ChipBelow(Rect anchor, bool mobile) + { + return new Rect(anchor.x, anchor.yMax + UITheme.Space.S, anchor.width, + mobile ? MobileChipHeight : DesktopChipHeight); + } + + /// 알림 카드 — 칩 바로 아래, 칩과 오른쪽 끝을 맞춰 왼쪽으로 펼친다. 는 칩 오른쪽 끝에서 왼쪽 안전 가장자리까지. + public static Rect NoticeBelow(Rect chip, bool mobile, float availableWidth) + { + float w = Mathf.Min(mobile ? MobileNoticeWidth : DesktopNoticeWidth, Mathf.Max(1f, availableWidth)); + return new Rect(chip.xMax - w, chip.yMax + UITheme.Space.XS, w, NoticeHeight); + } + + /// + /// 나의 섬(모바일)의 칩 자리. 은 지금 그려지는 섬 HUD 판(). + /// + /// 세로 화면: 판 바로 아래 — 섬 HUD 열이 단축 바 아래(필드 칩 자리)를 차지하므로 그 아래로 내려간다. "우측 열의 맨 아래"라는 + /// 필드 규칙 그대로다. 그 아래로 화면 59% 줄(안내 배너·소식 카드)까지 비어 있고, 왼쪽은 미니맵·퀘스트 칩, 우하단은 잡기 버튼이다. + /// 가로 화면: 필드와 같은 자리(단축 바 아래). 가로에서는 섬 HUD가 단축 바 왼쪽 두 칸 판이라 + /// () 단축 바 아래가 비어 있다. 판 아래로 내리면 화면 아래쪽 잡기 버튼에 가까워진다. + /// + public static Rect IslandMobileChip(Rect quick, Rect islandPanel, bool portrait) + { + return portrait + ? new Rect(islandPanel.x, islandPanel.yMax + UITheme.Space.S, islandPanel.width, MobileChipHeight) + : ChipBelow(quick, true); + } + + /// 필드의 칩 자리 — 데스크톱은 포획 아이템 패널 아래, 모바일은 단축 바 아래. + public static Rect FieldChip(HudFrame f) + { + return f.Mobile + ? ChipBelow(QuickAccessBarUI.ShortcutBarRectFor(f), true) + : ChipBelow(KeyGuideHUD.CaptureItemsRectFor(f), false); + } + + /// + /// 지금 칩 자리 — 필드는 . 세로 모바일 섬은 단축 바 아래를 섬 HUD 열이 차지하므로 그 열 아래 + /// (, 남의 섬이면 짧은 판 아래). 가로 모바일 섬과 데스크톱 섬은 필드와 같다. + /// + public static Rect Chip(HudFrame f, bool onIsland, bool visiting) + { + if (!onIsland || !f.Mobile || !f.Portrait) return FieldChip(f); + Rect quick = QuickAccessBarUI.ShortcutBarRectFor(f); + Rect panel = IslandHudLayout.Panel(IslandHudForm.Column, quick, visiting, f.ContentHeight); + return IslandMobileChip(quick, panel, true); + } + + /// 알림 카드 — 칩 바로 아래, 왼쪽 안전 가장자리까지 펼칠 수 있다. + public static Rect NoticeBelow(HudFrame f, Rect chip) + { + return NoticeBelow(chip, f.Mobile, chip.xMax - f.ContentLeft); + } + } +} diff --git a/Assets/Scripts/UI/WorldClockRules.cs.meta b/Assets/Scripts/UI/WorldClockRules.cs.meta new file mode 100644 index 0000000..1dd1fc5 --- /dev/null +++ b/Assets/Scripts/UI/WorldClockRules.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e8647ae8cecb2ad498a762b0629ed3f0 \ No newline at end of file diff --git a/Assets/Scripts/UI/WorldFieldMultiplayerUI.cs b/Assets/Scripts/UI/WorldFieldMultiplayerUI.cs index 597ce0b..72e241d 100644 --- a/Assets/Scripts/UI/WorldFieldMultiplayerUI.cs +++ b/Assets/Scripts/UI/WorldFieldMultiplayerUI.cs @@ -38,7 +38,10 @@ private sealed class RemoteAvatar private string pendingBlockUid = string.Empty; private float blockConfirmUntil; private string toast = string.Empty; - private float toastUntil; + // 남은 표시 시간 — 가운데 무대에서 차례를 기다리거나 화면이 가려진 동안은 줄지 않는다. + private float toastRemaining; + private RegionManager regionManager; + private bool regionSearched; private Vector2 friendScroll; private readonly UIDirectScroll friendDirectScroll = new UIDirectScroll(); @@ -129,6 +132,7 @@ private void Unsubscribe() private void Update() { + TickToast(); nearestPlayer = null; if (manager == null || !manager.IsJoined || localPlayer == null) return; @@ -255,7 +259,7 @@ private void HandleError(string message) private void ShowToast(string message) { toast = message ?? string.Empty; - toastUntil = Time.unscaledTime + 3.5f; + toastRemaining = 3.5f; } private RemoteAvatar CreateRemoteAvatar(WorldPlayer player) @@ -347,30 +351,93 @@ private void OnGUI() { if (manager == null) return; InitStyles(); - if (!manager.IsJoined) + // 다른 HUD와 같은 가상 캔버스(1920×1080 / 1080×1920)에 그린다 — 예전엔 이 화면만 픽셀 좌표라 스케일이 1이 아닌 + // 화면에서 혼자 크기가 달랐고, 자리를 다른 HUD의 순수 배치와 맞춰 볼 수 없었다(겹침 전수 검사가 이 좌표를 읽는다). + UIScale.Begin(); + try { - if (invites.Count > 0) DrawInvitePopup(invites[0]); - if (Time.unscaledTime < toastUntil) DrawToast(); + DrawScaled(HudFrame.Current); + } + finally + { + UIScale.End(); + } + } + + private void DrawScaled(HudFrame f) + { + // 자기 창(대화 입력·친구 초대)이 열려 있으면 그 창만 그린다 — 필드 HUD는 다른 모달 때처럼 물러난다. + if (IsOpen) + { + if (chatOpen) + { + // 대상은 nearestPlayer가 아니라 고정 uid로 해석한다. 대상이 사라지면 + // 입력창만 감추는 게 아니라 모달을 닫아야 입력 잠금이 풀린다. + WorldPlayer chatTarget = ResolveChatTarget(); + if (chatTarget != null) DrawChatComposer(f, chatTarget); + else CloseModal(); + } + if (friendsOpen) DrawFriendInvitePanel(f); + // 자기 창에서 한 일(초대 보냄·실패)의 안내는 창 위에서 바로 보인다 — 창과 겹치지 않는 자리(ModalToastRect). + if (toastRemaining > 0f && IsOpen) DrawToastAt(ModalToastRect(f, chatOpen)); return; } - DrawFieldStatus(); - DrawMessages(); - if (nearestPlayer != null) DrawNearbyInteraction(nearestPlayer); - if (chatOpen) + if (!FieldVisible()) return; + + if (manager.IsJoined) + { + DrawFieldStatus(f); + DrawMessages(f); + // 근처 탐험가 패널은 가운데 행동 자리(대화 버튼과 같은 자리)에 선다 — 대화 버튼이 섰거나 서 있는 카드와 겹치면 비켜선다. + if (nearestPlayer != null && !HudPresence.IsShowing(HudPresenceItem.Talk) && !HudStage.OccupiedOver(NearbyRect(f))) + DrawNearbyInteraction(f, nearestPlayer); + } + // 초대·안내는 가운데 무대(HudStage)의 차례 항목이다 — 앞 차례가 서 있으면 기다린다(안내는 시간도 멈춘다). + if (invites.Count > 0 && HudStage.Request(HudStageItem.NetInvite)) DrawInvitePopup(f, invites[0]); + if (toastRemaining > 0f && HudStage.Request(HudStageItem.NetToast)) DrawToast(f); + } + + /// + /// 필드 HUD를 그릴 수 있는가 — 다른 창·조작 잠금(포획·전투)·「챔피언의 꿈」·나의 섬에서는 물러난다. + /// 예전엔 아무 조건 없이 그려 상점·도감 위에도, 섬 화면의 버튼 줄 위에도 이 패널이 떴다. + /// + private bool FieldVisible() + { + if (DreamPrologueState.Active) return false; + if (ModalUIRegistry.IsAnyOpen()) return false; + if (localPlayer != null && localPlayer.IsFrozen) return false; + return !OnIsland; + } + + private bool OnIsland + { + get + { + if (regionManager == null && !regionSearched) + { + regionSearched = true; // 한 번만 찾는다(없는 씬에서 매 프레임 훑지 않게) + regionManager = FindFirstObjectByType(); + } + InsectGame.Data.SubAreaData area = regionManager != null ? regionManager.CurrentSubArea : null; + return area != null && area.detached; + } + } + + // 서 있는 동안만 줄어든다 — 무대 차례를 기다리거나 화면이 가려진 동안은 멈춘다(못 보고 지나가지 않게). + private void TickToast() + { + if (toastRemaining <= 0f) return; + if (IsOpen) { - // 대상은 nearestPlayer가 아니라 고정 uid로 해석한다. 대상이 사라지면 - // 입력창만 감추는 게 아니라 모달을 닫아야 입력 잠금이 풀린다. - WorldPlayer chatTarget = ResolveChatTarget(); - if (chatTarget != null) DrawChatComposer(chatTarget); - else CloseModal(); + // 자기 창(대화 입력·친구 초대)이 열려 있으면 그 창 곁에 바로 띄운다 — 무대 차례와 무관하다. + toastRemaining -= Time.unscaledDeltaTime; + return; } - if (friendsOpen) DrawFriendInvitePanel(); - if (invites.Count > 0) DrawInvitePopup(invites[0]); - if (Time.unscaledTime < toastUntil) DrawToast(); + if (!FieldVisible()) return; + if (!HudStage.Request(HudStageItem.NetToast)) return; + toastRemaining -= Time.unscaledDeltaTime; } - // 세이프 에어리어를 반영한 가용폭 클램프 + 중앙 정렬 X (가로 비대칭 노치 보정). - // DrawFieldStatus가 이미 쓰던 방식을 중앙 정렬 패널 전반에 통일한다. /// /// 이 패널 위의 탭이 월드 클릭-이동으로 새지 않게 등록한다. /// @@ -379,34 +446,98 @@ private void OnGUI() /// pointerOverUI도 false다. 등록이 없으면 버튼 아래 월드 지점이 클릭 목표로 잡혀 /// "3:3 대전"을 누른 순간 캐릭터가 상대 뒤로 걸어간다. 같은 결함을 `QuickAccessBarUI`가 /// P0으로 겪었고 `CaptureInputController`는 처음부터 등록하고 있었다. - /// - /// 좌표 변환에 주의. 이 화면은 UIScale.Begin()을 쓰지 않는 **픽셀 좌표계** - /// (UISafeLayout.Px)인데 RegisterBlockingRect는 **가상 좌표**를 받는다 - /// (IsScreenPointOverHud가 화면좌표를 UIScale.Scale로 나눠 비교한다). - /// 그대로 넘기면 스케일이 1이 아닌 기기에서 엉뚱한 영역이 막힌다. + /// 이 화면은 가상 캔버스(UIScale.Begin())에 그리므로 Rect를 그대로 넘긴다. + /// + private static void BlockFieldClicks(Rect virtualRect) + { + FieldHudInput.RegisterBlockingRect(virtualRect); + } + + // ── 자리(순수 계산 — 겹침 전수 검사가 읽는다) ── + + public const float StatusWidth = 360f; + public const float StatusHeight = 122f; + public const float MessagesWidth = 470f; + public const float MessagesMaxHeight = 190f; + public const float NearbyHeight = 132f; + + /// + /// 필드 상태 판(필드 이름·인원·초대 버튼). 데스크톱·세로 모바일: 오른쪽 열의 시각 알림 자리 아래. + /// 가로 모바일: 우상단 단축 바 왼쪽. 예전엔 화면 오른쪽 위(ContentTop)라 포획 아이템 패널·시각 칩·단축 바를 덮었다. /// - private static void BlockFieldClicks(float x, float y, float w, float h) + public static Rect StatusRect(HudFrame f) + { + float s = UITheme.Space.S; + float w = Mathf.Min(StatusWidth, f.ContentWidth); + Rect bar = QuickAccessBarUI.ShortcutBarRectFor(f); + if (f.Mobile && !f.Portrait) + return new Rect(bar.x - s - w, f.ContentTop, w, StatusHeight); + Rect notice = WorldClockRules.NoticeBelow(f, WorldClockRules.FieldChip(f)); + float x = f.Mobile ? bar.xMax - w : f.Width - f.SafeRight - 20f - w; + return new Rect(x, notice.yMax + s, w, StatusHeight); + } + + /// + /// 대화 기록 판(최대 크기 — 줄 수가 적으면 아래가 짧아진다). 데스크톱: 왼쪽 상태 판 아래. + /// 가로 모바일: 필드 상태 판 바로 아래(오른쪽 맞춤) — 왼쪽은 미니맵·퀘스트 칩 아래가 곧 조이스틱 자리다. + /// 세로 모바일: 가운데 무대 왼쪽 위 — 무대에 카드가 서면 비켜선다. + /// 예전엔 화면 아래(BottomY−150)라 잡기 버튼 줄·조이스틱 자리·퀘스트 칩과 겹쳤다. + /// + public static Rect MessagesRect(HudFrame f) + { + float s = UITheme.Space.S; + float w = Mathf.Min(MessagesWidth, f.ContentWidth); + if (f.Mobile && !f.Portrait) + { + Rect status = StatusRect(f); + return new Rect(status.xMax - w, status.yMax + s, w, MessagesMaxHeight); + } + float x = f.SafeLeft + 16f; + float y = f.Mobile ? HudStage.Area(f).y : PlayerStatusHUD.PanelRect(f).yMax + s; + return new Rect(x, y, w, MessagesMaxHeight); + } + + /// 근처 탐험가 패널 — 가운데 대화 버튼 자리()와 같은 폭·윗변. + public static Rect NearbyRect(HudFrame f) { - float s = UIScale.Scale; - if (s <= 0f) return; - FieldHudInput.RegisterBlockingRect(new Rect(x / s, y / s, w / s, h / s)); + Rect talk = WorldInteractionController.TalkRect(f); + float y = Mathf.Min(talk.y, f.ContentBottom - NearbyHeight); + return new Rect(talk.x, y, talk.width, NearbyHeight); } - private static float SafeClampW(float desired) => - Mathf.Min(desired, Screen.width - SafeArea.Left - SafeArea.Right - 32f); + /// 필드 초대 — 가운데 무대의 차례 항목. + public static Rect InviteRect(HudFrame f) => HudStage.Place(f, HudStageItem.NetInvite, 520f, 190f); + + /// 필드 멀티 안내 — 가운데 무대의 차례 항목. + public static Rect ToastRect(HudFrame f) => HudStage.Place(f, HudStageItem.NetToast, 560f, 58f); + + public const float ComposerHeight = 124f; + public const float FriendPanelHeight = 520f; + + /// + /// 자기 창이 열려 있을 때의 안내 자리 — 창과 겹치지 않게. 대화 입력(가운데)은 그 바로 위, 친구 초대(위쪽)는 그 바로 아래. + /// 예전엔 화면 위 가운데(ContentTop)라 친구 초대 창의 제목 줄을 덮었다. + /// + public static Rect ModalToastRect(HudFrame f, bool chat) + { + const float h = 58f; + float w = Mathf.Min(560f, f.ContentWidth); + float x = f.ContentLeft + (f.ContentWidth - w) * 0.5f; + float y = chat + ? Mathf.Max(f.ContentTop, f.CenteredY(ComposerHeight) - UITheme.Space.S - h) + : Mathf.Min(f.ContentTop + f.ClampHeight(FriendPanelHeight) + UITheme.Space.S, f.ContentBottom - h); + return new Rect(x, y, w, h); + } - private static float SafeCenterX(float w) => - SafeArea.Left + (Screen.width - SafeArea.Left - SafeArea.Right - w) * 0.5f; + // ── 그리기 ── - private void DrawFieldStatus() + private void DrawFieldStatus(HudFrame f) { - WorldInstance world = manager.CurrentWorld; - float w = Mathf.Min(360f, Screen.width - SafeArea.Left - SafeArea.Right - 32f); - float x = Screen.width - SafeArea.Right - w - 18f; - float y = UISafeLayout.Px.ContentTop; - BlockFieldClicks(x, y, w, 122f); // 탭이 월드 클릭-이동으로 새지 않게 - GUI.Box(new Rect(x, y, w, 122f), "", panelStyle); - GUI.Label(new Rect(x + 12f, y + 8f, w - 24f, 36f), cachedWorldTitle, titleStyle); + Rect r = StatusRect(f); + float x = r.x, y = r.y, w = r.width; + BlockFieldClicks(r); // 탭이 월드 클릭-이동으로 새지 않게 + GUI.Box(r, "", panelStyle); + UIHelper.LabelFit(new Rect(x + 12f, y + 8f, w - 24f, 36f), cachedWorldTitle, titleStyle); if (GUI.Button(new Rect(x + 14f, y + 54f, w - 28f, 56f), "친구를 이 필드로 초대", buttonStyle)) { bool opening = !friendsOpen; @@ -416,15 +547,14 @@ private void DrawFieldStatus() } } - private void DrawNearbyInteraction(WorldPlayer player) + private void DrawNearbyInteraction(HudFrame f, WorldPlayer player) { - float w = SafeClampW(620f); - float h = 132f; - float x = SafeCenterX(w); - float y = UISafeLayout.Px.BottomY(h); - BlockFieldClicks(x, y, w, h); // 탭이 월드 클릭-이동으로 새지 않게 - GUI.Box(new Rect(x, y, w, h), "", panelStyle); - GUI.Label(new Rect(x + 18f, y + 10f, w - 36f, 30f), cachedNearbyLabel, titleStyle); + Rect r = NearbyRect(f); + float x = r.x, y = r.y, w = r.width; + BlockFieldClicks(r); // 탭이 월드 클릭-이동으로 새지 않게 + HudPresence.Mark(HudPresenceItem.Nearby); // 코치 배너가 겹치면 비켜 준다 + GUI.Box(r, "", panelStyle); + UIHelper.LabelFit(new Rect(x + 18f, y + 8f, w - 36f, 36f), cachedNearbyLabel, titleStyle); float gap = 8f; float btnW = (w - 44f - gap * 2f) / 3f; @@ -468,17 +598,17 @@ private void DrawNearbyInteraction(WorldPlayer player) } } - private void DrawChatComposer(WorldPlayer player) + private void DrawChatComposer(HudFrame f, WorldPlayer player) { - float w = SafeClampW(620f); - float h = 120f; - float x = SafeCenterX(w); - float y = UISafeLayout.Px.CenteredY(h); - BlockFieldClicks(x, y, w, h); // 탭이 월드 클릭-이동으로 새지 않게 + float w = Mathf.Min(620f, f.ContentWidth); + float h = ComposerHeight; + float x = f.ContentLeft + (f.ContentWidth - w) * 0.5f; + float y = f.CenteredY(h); + BlockFieldClicks(new Rect(x, y, w, h)); // 탭이 월드 클릭-이동으로 새지 않게 GUI.Box(new Rect(x, y, w, h), "", panelStyle); - UIHelper.LabelFit(new Rect(x + 14f, y + 8f, w - 28f, 27f), player.displayName + "에게 말하기", titleStyle); - chatInput = GUI.TextField(new Rect(x + 14f, y + 42f, w - 150f, 55f), chatInput, 80, fieldStyle); - if (GUI.Button(new Rect(x + w - 126f, y + 42f, 112f, 55f), "보내기", buttonStyle)) + UIHelper.LabelFit(new Rect(x + 14f, y + 6f, w - 28f, 34f), player.displayName + "에게 말하기", titleStyle); + chatInput = GUI.TextField(new Rect(x + 14f, y + 46f, w - 150f, 60f), chatInput, 80, fieldStyle); + if (GUI.Button(new Rect(x + w - 126f, y + 46f, 112f, 60f), "보내기", buttonStyle)) { // player는 ResolveChatTarget이 chatTargetUid로 해석한 고정 대상이다. manager.SendPrivateChat(player.uid, chatInput); @@ -486,29 +616,33 @@ private void DrawChatComposer(WorldPlayer player) } } - private void DrawMessages() + private void DrawMessages(HudFrame f) { if (messages.Count == 0) return; - float w = Mathf.Min(470f, Screen.width * 0.46f); - float h = Mathf.Min(190f, messages.Count * 34f + 20f); - float x = SafeArea.Left + 16f; - float y = UISafeLayout.Px.BottomY(h) - 150f; // 하단 근접 패널 위 - GUI.Box(new Rect(x, y, w, h), "", panelStyle); + Rect slot = MessagesRect(f); + // 상태 판을 펼치면 겹치는 기록은 비켜선다(가로 모바일 — 미니맵 아래 자리를 펼친 판이 덮는다). + if (MinimapUI.LeftStackOccluded && slot.Overlaps(PlayerStatusHUD.PanelRect(f))) return; + // 세로 모바일에서는 무대 안이라 카드가 서면 비켜선다. + float h = Mathf.Min(MessagesMaxHeight, messages.Count * 34f + 20f); + Rect r = new Rect(slot.x, slot.y, slot.width, h); + if (f.Mobile && f.Portrait && HudStage.OccupiedOver(r)) return; + BlockFieldClicks(r); // 불투명 판 — 위의 탭이 월드 클릭-이동으로 새지 않게 + GUI.Box(r, "", panelStyle); int first = Mathf.Max(0, messageLines.Count - 5); for (int i = first; i < messageLines.Count; i++) { - GUI.Label(new Rect(x + 12f, y + 8f + (i - first) * 34f, w - 24f, 30f), + UIHelper.LabelFit(new Rect(r.x + 12f, r.y + 8f + (i - first) * 34f, r.width - 24f, 32f), messageLines[i], smallStyle); } } - private void DrawFriendInvitePanel() + private void DrawFriendInvitePanel(HudFrame f) { - float w = SafeClampW(520f); - float h = UISafeLayout.Px.ClampHeight(520f); - float x = SafeCenterX(w); - float y = UISafeLayout.Px.ContentTop; - BlockFieldClicks(x, y, w, h); // 탭이 월드 클릭-이동으로 새지 않게 + float w = Mathf.Min(520f, f.ContentWidth); + float h = f.ClampHeight(FriendPanelHeight); + float x = f.ContentLeft + (f.ContentWidth - w) * 0.5f; + float y = f.ContentTop; + BlockFieldClicks(new Rect(x, y, w, h)); // 탭이 월드 클릭-이동으로 새지 않게 GUI.Box(new Rect(x, y, w, h), "", panelStyle); GUI.Label(new Rect(x + 16f, y + 12f, w - 100f, 36f), "친구 필드 초대", titleStyle); if (GUI.Button(new Rect(x + w - 72f, y + 8f, 58f, 56f), "X", dangerStyle)) CloseModal(); @@ -516,22 +650,17 @@ private void DrawFriendInvitePanel() PvpProfileSnapshot[] friends = SocialPvpManager.Instance != null ? SocialPvpManager.Instance.State.friends : Array.Empty(); - Rect view = new Rect(x + 16f, y + 72f, w - 32f, h - 88f); + Rect view = new Rect(x + 16f, y + 72f, w - 32f, Mathf.Max(1f, h - 88f)); float contentH = Mathf.Max(view.height, friends.Length * 76f); - HandleScreenSpaceDirectScroll( - ref friendScroll, - friendDirectScroll, - view, - contentH, - 38f); + friendDirectScroll.Handle(ref friendScroll, view, contentH, 38f); friendScroll = GUI.BeginScrollView(view, friendScroll, new Rect(0f, 0f, view.width - 18f, contentH)); if (friends.Length == 0) - GUI.Label(new Rect(8f, 20f, view.width - 40f, 60f), "친구 목록이 비어 있습니다.\n소셜 메뉴에서 친구를 먼저 추가하세요.", labelStyle); + UIHelper.LabelFit(new Rect(8f, 20f, view.width - 40f, 64f), "친구 목록이 비어 있습니다.\n소셜 메뉴에서 친구를 먼저 추가하세요.", labelStyle); for (int i = 0; i < friends.Length; i++) { PvpProfileSnapshot friend = friends[i]; float rowY = i * 76f; - GUI.Label(new Rect(8f, rowY + 8f, view.width - 176f, 56f), + UIHelper.LabelFit(new Rect(8f, rowY + 8f, view.width - 176f, 56f), $"{friend.displayName} Lv.{friend.level}", labelStyle); if (GUI.Button(new Rect(view.width - 160f, rowY + 7f, 128f, 56f), "초대", buttonStyle)) manager.InviteFriend(friend.uid); @@ -545,53 +674,34 @@ private void ResetFriendScroll() friendDirectScroll.Reset(); } - private static void HandleScreenSpaceDirectScroll( - ref Vector2 scrollPosition, - UIDirectScroll directScroll, - Rect viewport, - float contentHeight, - float wheelStep) - { - float scale = Mathf.Max(0.3f, UIScale.Scale); - Vector2 virtualScroll = scrollPosition / scale; - Rect virtualViewport = new Rect( - viewport.x / scale, - viewport.y / scale, - viewport.width / scale, - viewport.height / scale); - directScroll.Handle( - ref virtualScroll, - virtualViewport, - contentHeight / scale, - wheelStep / scale); - scrollPosition = virtualScroll * scale; - } - - private void DrawInvitePopup(WorldInviteSnapshot invite) - { - float w = SafeClampW(520f); - float h = 190f; - float x = SafeCenterX(w); - float y = UISafeLayout.Px.ContentTop + 100f; // 상단 초대/친구 패널 아래 - BlockFieldClicks(x, y, w, h); // 탭이 월드 클릭-이동으로 새지 않게 - GUI.Box(new Rect(x, y, w, h), "", panelStyle); - GUI.Label(new Rect(x + 16f, y + 12f, w - 32f, 32f), "필드 초대", titleStyle); - GUI.Label(new Rect(x + 16f, y + 50f, w - 32f, 40f), + private void DrawInvitePopup(HudFrame f, WorldInviteSnapshot invite) + { + Rect r = InviteRect(f); + HudStage.Request(HudStageItem.NetInvite, r); + float x = r.x, y = r.y, w = r.width; + BlockFieldClicks(r); // 탭이 월드 클릭-이동으로 새지 않게 + GUI.Box(r, "", panelStyle); + GUI.Label(new Rect(x + 16f, y + 10f, w - 32f, 36f), "필드 초대", titleStyle); + UIHelper.LabelFit(new Rect(x + 16f, y + 50f, w - 32f, 54f), $"{invite.displayName}님이 {invite.worldName}에 초대했습니다.", labelStyle); float btnW = (w - 48f) * 0.5f; - if (GUI.Button(new Rect(x + 16f, y + 112f, btnW, 58f), "함께 입장", buttonStyle)) + if (GUI.Button(new Rect(x + 16f, y + 116f, btnW, 58f), "함께 입장", buttonStyle)) manager.RespondInvite(invite.inviteId, true); - if (GUI.Button(new Rect(x + 24f + btnW, y + 112f, btnW, 58f), "거절", dangerStyle)) + if (GUI.Button(new Rect(x + 32f + btnW, y + 116f, btnW, 58f), "거절", dangerStyle)) manager.RespondInvite(invite.inviteId, false); } - private void DrawToast() + private void DrawToast(HudFrame f) + { + Rect r = ToastRect(f); + HudStage.Request(HudStageItem.NetToast, r); + DrawToastAt(r); + } + + private void DrawToastAt(Rect r) { - float w = SafeClampW(560f); - float x = SafeCenterX(w); - float y = UISafeLayout.Px.ContentTop; - GUI.Box(new Rect(x, y, w, 58f), "", panelStyle); - GUI.Label(new Rect(x + 12f, y + 8f, w - 24f, 42f), toast, labelStyle); + GUI.Box(r, "", panelStyle); + UIHelper.LabelFit(new Rect(r.x + 12f, r.y + 6f, r.width - 24f, r.height - 12f), toast, labelStyle); } private void InitStyles() @@ -602,25 +712,26 @@ private void InitStyles() panelStyle.normal.background = UIHelper.GetCachedTex(new Color(0.04f, 0.08f, 0.12f, 0.94f)); panelStyle.padding = new RectOffset(8, 8, 8, 8); + // 글자 크기는 가상 캔버스 기준이다(다른 HUD와 같은 단위 — 1920×1080에서 예전 픽셀 크기보다 조금 크다). titleStyle = new GUIStyle(GUI.skin.label) { - fontSize = 20, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft + fontSize = 24, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft }; titleStyle.normal.textColor = new Color(0.48f, 1f, 0.62f, 1f); labelStyle = new GUIStyle(GUI.skin.label) { - fontSize = 17, alignment = TextAnchor.MiddleLeft, wordWrap = true + fontSize = 22, alignment = TextAnchor.MiddleLeft, wordWrap = true }; labelStyle.normal.textColor = Color.white; - smallStyle = new GUIStyle(labelStyle) { fontSize = 15 }; + smallStyle = new GUIStyle(labelStyle) { fontSize = 20 }; buttonStyle = MakeButtonStyle(new Color(0.12f, 0.46f, 0.3f, 1f)); dangerStyle = MakeButtonStyle(new Color(0.55f, 0.15f, 0.18f, 1f)); disabledStyle = MakeButtonStyle(new Color(0.25f, 0.27f, 0.3f, 1f)); fieldStyle = new GUIStyle(GUI.skin.textField) { - fontSize = 19, padding = new RectOffset(12, 12, 8, 8) + fontSize = 24, padding = new RectOffset(12, 12, 8, 8) }; fieldStyle.normal.textColor = Color.white; fieldStyle.normal.background = UIHelper.GetCachedTex(new Color(0.08f, 0.13f, 0.18f, 1f)); @@ -632,7 +743,7 @@ private static GUIStyle MakeButtonStyle(Color color) { var style = new GUIStyle(GUI.skin.button) { - fontSize = 17, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter + fontSize = 22, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter }; // Color * float는 알파까지 곱한다 — 그대로 쓰면 눌림 상태(0.82)가 반투명해진다. // 명도만 조절하고 알파는 보존한다. diff --git a/Assets/Scripts/UI/WorldInteractionController.cs b/Assets/Scripts/UI/WorldInteractionController.cs index 98b430a..b99b988 100644 --- a/Assets/Scripts/UI/WorldInteractionController.cs +++ b/Assets/Scripts/UI/WorldInteractionController.cs @@ -36,6 +36,7 @@ public class WorldInteractionController : MonoBehaviour private CameraFollower cameraFollower; // 첫 조우 시네마틱 줌 private HospitalUI hospital; // 병원 치료 UI private NpcDuelController duelController; // 곤충잡이 아이 대결 + private IslandVisitUI islandVisit; // 나루터 → 섬 나들목 창 private readonly List points = new List(); @@ -78,6 +79,11 @@ public void AutoWire(HospitalUI hospitalUi) if (hospital == null) hospital = hospitalUi; } + public void AutoWire(IslandVisitUI islandVisitUi) + { + if (islandVisit == null) islandVisit = islandVisitUi; + } + public void AutoWire(NpcDialogueUI dialogueUi) { if (dialogue == null) dialogue = dialogueUi; @@ -370,6 +376,9 @@ private void Activate() case InteractionKind.Hospital: if (hospital != null) hospital.Toggle(); break; + case InteractionKind.IslandDock: + if (islandVisit != null) islandVisit.Toggle(); + break; } } @@ -385,66 +394,76 @@ private void Activate() // 컨트롤러가 찍으면 그 시각이 전투 결과 화면이 열리는 프레임이라, 화면이 4초를 // 채우고 닫힐 때쯤엔 3.5초 창이 이미 지나 토스트가 한 번도 안 보인다. private string resultToastText = string.Empty; - private float resultToastShownAt = float.MinValue; + // 남은 표시 시간 — 가운데 무대(HudStage)에서 차례를 기다리는 동안은 줄지 않는다. + private float resultToastRemaining; private void OnGUI() { // 모달(전투 화면 포함) 중에는 그리지도 않고 **꺼내 오지도 않는다** — // 여기서 소비하면 필드로 돌아오기 전에 창이 시작돼 같은 결함을 반복한다. if (ModalUIRegistry.IsAnyOpen()) return; + // 「챔피언의 꿈」에는 마을·주민이 없다 — 꿈 밖의 대결 결과도 꿈속에 비치지 않게 기다린다. + if (DreamPrologueState.Active) return; // 대결 결과 토스트는 대상이 없어도(대결 직후엔 아이가 쿨다운이라 대상에서 빠진다) 떠야 한다. if (duelController != null && duelController.TryConsumeResult(out string freshResult)) { resultToastText = freshResult; - resultToastShownAt = Time.time; + resultToastRemaining = ResultToastSeconds; } - bool showResult = !string.IsNullOrEmpty(resultToastText) - && Time.time - resultToastShownAt < ResultToastSeconds; + // 가운데 무대에 선다 — 앞 차례(포획 결과·퀘스트 완료 등)가 서 있으면 기다리고 시간도 흐르지 않는다. + bool showResult = !string.IsNullOrEmpty(resultToastText) && resultToastRemaining > 0f + && HudStage.Request(HudStageItem.DuelResult); + if (showResult && Event.current != null && Event.current.type == EventType.Repaint) + resultToastRemaining -= Time.unscaledDeltaTime; - if (!hasPriorityTarget && !showResult) return; + if (!hasPriorityTarget && !showResult) + { + centerButtonRect = new Rect(0, 0, 0, 0); + return; + } EnsureStyles(); EnsureCircleTex(); UIScale.Begin(); + HudFrame frame = HudFrame.Current; if (showResult) { // 길이를 **데이터가** 정한다(상대 이름 + 보상 아이템 + 거점 이름) — 800×44 고정 // 상자에 raw GUI.Label로 그리면 긴 조합에서 조용히 잘린다. rules/ui-layout.md의 // 그 규칙 그대로 LabelFit으로 맞춰 넣는다. - UIHelper.LabelFit( - new Rect(UIScale.VirtualScreenWidth / 2f - 400f, UISafeLayout.ContentBottom - 150f, 800f, 44f), - resultToastText, - promptStyle); + Rect resultRect = DuelResultRect(frame); + HudStage.Request(HudStageItem.DuelResult, resultRect); + UIHelper.LabelFit(resultRect, resultToastText, promptStyle); } if (!hasPriorityTarget) { + centerButtonRect = new Rect(0, 0, 0, 0); UIScale.End(); return; } - float vw = UIScale.VirtualScreenWidth; - float safeR = UIScale.VirtualSafeRight; - - // 프롬프트 — 화면 하단 중앙에서 좌측 오프셋 (잡기 버튼/미스 피드백과 겹침 회피). - // **길이를 데이터가 정한다**(NPC 표시명·건물 라벨)는데 상자는 640 고정이고 wordWrap도 - // 없어서, 이름이 길면 **가로로 잘린다**(rules/ui-layout.md). `text_fit_lint`는 라벨 인자가 - // 캐시된 문자열(`promptText`)이라 데이터 출처를 못 봐서 이 자리를 놓친다. - UIHelper.LabelFit(new Rect(vw / 2f - 560f, UISafeLayout.ContentBottom - 96f, 640f, 44f), - promptText, promptStyle); - - DrawCenterButton(vw); + // 가운데 대화 버튼과 그 아래 안내 글자는 가운데 무대 안에 선다 — 서 있는 카드와 겹치면 비켜선다 + // (E 키와 우하단 원형 버튼은 그대로 살아 있다). + if (HudStage.OccupiedOver(TalkRect(frame)) || HudStage.OccupiedOver(PromptRect(frame))) + { + centerButtonRect = new Rect(0, 0, 0, 0); + } + else + { + HudPresence.Mark(HudPresenceItem.Talk); // 코치 배너가 겹치면 비켜 준다 + // 프롬프트 — 대화 버튼 바로 아래. **길이를 데이터가 정한다**(NPC 표시명·건물 라벨)는데 상자는 고정이라 + // LabelFit으로 맞춘다(rules/ui-layout.md). 예전엔 화면 아래 가운데(ContentBottom−96)라 데스크톱 단축 바· + // 동굴 입구 버튼과 겹쳤다. + UIHelper.LabelFit(PromptRect(frame), promptText, promptStyle); + DrawCenterButton(frame); + } // 원형 상호작용 버튼 — 잡기 버튼(우하단, 반경 96) 왼쪽에 배치 - float radius = 80f; - float accountClear = 92f / UIScale.Scale; // CaptureInputController와 동일한 '계정' 버튼 회피 보정 - float catchCx = vw - safeR - 96f - 40f; // 잡기 버튼 중심 X (DrawCatchButton과 동기) - float cx = catchCx - 96f - radius - 36f; // 잡기 버튼 왼쪽 - float cy = UISafeLayout.ContentBottom - 96f - accountClear; // 잡기 버튼과 같은 높이(중심 정렬) - Rect rect = new Rect(cx - radius, cy - radius, radius * 2f, radius * 2f); + Rect rect = InteractButtonRect(frame); interactButtonRect = rect; FieldHudInput.RegisterBlockingRect(rect); // 버튼 위 탭의 클릭-이동 오발 차단 @@ -469,15 +488,44 @@ private void OnGUI() /// 시선이 머무는 자리에 놓는다. 고정 높이라 세로 마진 안으로 clamp한다 /// (rules/ui-layout.md: 비율 배치는 허용, 다만 고정 높이는 가둔다). /// - private void DrawCenterButton(float vw) + /// + /// 가운데 대화 버튼의 자리 — 순수 계산. 플레이어 머리 위쯤(세로 58%)에 두어 시선이 머무는 자리에 놓는다. + /// 가운데 무대() 안이라 카드가 서면 비켜선다. + /// + public static Rect TalkRect(HudFrame f) + { + float w = Mathf.Min(560f, f.Width - 80f); + float h = f.Mobile ? 132f : 108f; + float y = Mathf.Clamp(f.Height * 0.58f, f.ContentTop, f.ContentBottom - h); + return new Rect((f.Width - w) * 0.5f, y, w, h); + } + + /// 대화 대상 안내 글자(이름 포함) — 대화 버튼 바로 아래, 같은 폭. + public static Rect PromptRect(HudFrame f) + { + Rect talk = TalkRect(f); + return new Rect(talk.x, talk.yMax + UITheme.Space.XS, talk.width, 44f); + } + + /// 우하단 원형 상호작용 버튼 — 잡기 버튼 왼쪽, 같은 높이(중심 정렬). + public static Rect InteractButtonRect(HudFrame f) + { + const float radius = 80f; + Vector2 c = InsectGame.Capture.CatchButtonLayout.Center(f); + float cx = c.x - InsectGame.Capture.CatchButtonLayout.Radius - InsectGame.Capture.CatchButtonLayout.NeighborGap - radius; + return new Rect(cx - radius, c.y - radius, radius * 2f, radius * 2f); + } + + /// 대결 결과 글자 — 가운데 무대의 차례 항목(). + public static Rect DuelResultRect(HudFrame f) + { + return HudStage.Place(f, HudStageItem.DuelResult, 800f, 44f); + } + + private void DrawCenterButton(HudFrame f) { - float w = Mathf.Min(560f, vw - 80f); - float h = UIScale.IsMobileLayout ? 132f : 108f; - float y = Mathf.Clamp( - UIScale.VirtualScreenHeight * 0.58f, - UISafeLayout.ContentTop, - UISafeLayout.ContentBottom - h); - Rect rect = new Rect((vw - w) * 0.5f, y, w, h); + Rect rect = TalkRect(f); + float h = rect.height; centerButtonRect = rect; FieldHudInput.RegisterBlockingRect(rect); // 배너 위 탭이 클릭-이동으로 새지 않게 @@ -513,6 +561,7 @@ private static string ShortButtonLabel(InteractionKind kind) case InteractionKind.Gacha: return "상자"; case InteractionKind.Training: return "훈련"; case InteractionKind.Hospital: return "병원"; + case InteractionKind.IslandDock: return "내 섬"; default: return "확인"; } } diff --git a/Assets/Scripts/UI/WorldMapVisualCapture.cs b/Assets/Scripts/UI/WorldMapVisualCapture.cs new file mode 100644 index 0000000..2849baf --- /dev/null +++ b/Assets/Scripts/UI/WorldMapVisualCapture.cs @@ -0,0 +1,113 @@ +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.NPC; +using InsectGame.Story; +using UnityEngine; + +namespace InsectGame.UI +{ + /// Standalone IMGUI map fixture. State is injected in memory; no bootstrap or save services. + public static class WorldMapVisualCapture + { + private const BindingFlags Fields = BindingFlags.Instance | BindingFlags.NonPublic; + + private static void Set(object instance, string name, object value) + { + FieldInfo field = instance.GetType().GetField(name, Fields); + if (field == null) throw new System.MissingFieldException(instance.GetType().Name, name); + field.SetValue(instance, value); + } + + public static IEnumerator Run(string output, Camera camera) + { + RegionData[] regions = RegionDefinitions.CreateAll(); + RegionData meadow = WorldRouteLayout.Find(regions, "meadow"); + if (meadow == null || meadow.subAreas == null || meadow.subAreas.Length == 0) + { Debug.LogError("Map fixture requires meadow and its entrance."); Application.Quit(3); yield break; } + SubAreaData entrance = meadow.subAreas[0]; + var manager = new GameObject("MapQARegionState").AddComponent(); + manager.enabled = false; + // Do not call Initialize: it loads account progression. All fixture flags are transient. + Set(manager, "regions", regions); + Set(manager, "currentRegion", meadow); + Set(manager, "unlockedRegions", new HashSet { "meadow", "pond", "forest" }); + var player = new GameObject("Player"); + player.tag = "Player"; + player.transform.position = entrance.centerPosition + new Vector3(-12f, .5f, -8f); + var npcs = new GameObject("MapQANpcs").AddComponent(); + npcs.enabled = false; + var npc = new GameObject("MapQAStoryNpc").AddComponent(); + npc.Initialize(new NpcSpawnAnchor { regionId = "meadow", position = player.transform.position + Vector3.right * 10f }, + "map_qa_elder", "마을 어르신", 8173, "village_elder"); + npc.transform.position = player.transform.position + Vector3.right * 10f; + ((List)typeof(NpcManager).GetField("storyNpcs", Fields).GetValue(npcs)).Add(npc); + var tracker = new GameObject("MapQAObjective").AddComponent(); + tracker.enabled = false; + Set(tracker, "hasObjective", true); + Set(tracker, "hasWorldTarget", true); + Set(tracker, "targetPosition", npc.transform.position); + Set(tracker, "playerTransform", player.transform); + Set(tracker, "targetNpc", npc); + Set(tracker, "targetRegionId", "meadow"); + // 실제 게임에선 Refresh가 정한다(트래커를 꺼 둔 fixture라 직접 넣는다) — 지도·미니맵의 본편 ! 배지. + Set(tracker, "mainMarkNpc", npc); + npc.SetQuestMark(QuestMark.Main); + Set(tracker, "label", "마을 어르신에게 말 걸기"); + var map = new GameObject("MapQAWorldMap").AddComponent(); + Set(map, "regionManager", manager); + Set(map, "playerTransform", player.transform); + map.AutoWire(npcs); + map.AutoWire(tracker); + var minimap = new GameObject("MapQAMinimap").AddComponent(); + minimap.AutoWire(manager); + minimap.AutoWire(npcs); + minimap.AutoWire(tracker); + camera.transform.position = player.transform.position + new Vector3(0f, 25f, -22f); + camera.transform.LookAt(player.transform.position); + var ground = GameObject.CreatePrimitive(PrimitiveType.Plane); + ground.name = "MapQAGround"; + ground.transform.position = player.transform.position - Vector3.up * .5f; + ground.transform.localScale = Vector3.one * 12f; + ground.GetComponent().material.color = new Color(.22f, .31f, .26f); + var light = new GameObject("MapQAKey").AddComponent(); + light.type = LightType.Directional; + light.transform.rotation = Quaternion.Euler(55f, -30f, 0f); + + map.Toggle(); + yield return Capture(output, "map-overworld"); + Set(manager, "currentSubArea", entrance); + manager.SetSubAreaSticky(true); + player.transform.position = new Vector3(2000f, .5f, 2000f); + yield return Capture(output, "map-subarea"); + map.CloseModal(); + yield return Capture(output, "minimap-subarea"); + Set(manager, "currentSubArea", null); + manager.SetSubAreaSticky(false); + player.transform.position = entrance.centerPosition + new Vector3(-12f, .5f, -8f); + yield return Capture(output, "minimap-overworld"); + File.WriteAllText(Path.Combine(output, "README.txt"), + "Artificial layout fixture; actual standalone RegionMapUI/MinimapUI IMGUI captured after end-of-frame. " + + "Real RegionDefinitions and WorldRouteLayout; injected meadow/pond/forest unlocks, elder position and objective. " + + "Subarea state and player coordinates are injected, not an actual entry transition. " + + "No PlaySceneBootstrap, AuthManager, StoryDirector, PlayerProgressController or save-service creation; " + + "RegionManager.Initialize is deliberately not called. Does not validate traversal, persistence, clicks or quest progression. " + + "Screen " + Screen.width + "x" + Screen.height + ". Camera background is a staging fixture."); + Application.Quit(0); + } + + private static IEnumerator Capture(string output, string name) + { + yield return new WaitForSecondsRealtime(.8f); + yield return new WaitForEndOfFrame(); + Texture2D screenshot = ScreenCapture.CaptureScreenshotAsTexture(); + File.WriteAllBytes(Path.Combine(output, name + ".png"), screenshot.EncodeToPNG()); + Object.Destroy(screenshot); + } + } +} +#endif diff --git a/Assets/Scripts/UI/WorldMapVisualCapture.cs.meta b/Assets/Scripts/UI/WorldMapVisualCapture.cs.meta new file mode 100644 index 0000000..3d9cffe --- /dev/null +++ b/Assets/Scripts/UI/WorldMapVisualCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bb81252ce52d398468b87857fd97523d \ No newline at end of file diff --git a/Assets/Shaders.meta b/Assets/Shaders.meta new file mode 100644 index 0000000..e9051bb --- /dev/null +++ b/Assets/Shaders.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ca76c873ff093ca4aa2e650fdebc7e99 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Shaders/Resources.meta b/Assets/Shaders/Resources.meta new file mode 100644 index 0000000..9672ae3 --- /dev/null +++ b/Assets/Shaders/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6384f0560350fab4085706cc5fa580ea +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Shaders/Resources/GatewayText.shader b/Assets/Shaders/Resources/GatewayText.shader new file mode 100644 index 0000000..3a21251 --- /dev/null +++ b/Assets/Shaders/Resources/GatewayText.shader @@ -0,0 +1,43 @@ +Shader "InsectGame/GatewayText" +{ + Properties + { + _MainTex ("Font Atlas", 2D) = "white" {} + _Color ("Tint", Color) = (1,1,1,1) + } + SubShader + { + Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" } + Pass + { + Cull Back + ZTest LEqual + ZWrite Off + Blend SrcAlpha OneMinusSrcAlpha + Lighting Off + CGPROGRAM + #pragma vertex vert + #pragma fragment frag + #include "UnityCG.cginc" + struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; }; + struct v2f { float4 position : SV_POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; }; + sampler2D _MainTex; + fixed4 _Color; + v2f vert(appdata input) + { + v2f output; + output.position = UnityObjectToClipPos(input.vertex); + output.uv = input.uv; + output.color = input.color * _Color; + return output; + } + fixed4 frag(v2f input) : SV_Target + { + fixed4 color = input.color; + color.a *= tex2D(_MainTex, input.uv).a; + return color; + } + ENDCG + } + } +} diff --git a/Assets/Shaders/Resources/GatewayText.shader.meta b/Assets/Shaders/Resources/GatewayText.shader.meta new file mode 100644 index 0000000..b62aef0 --- /dev/null +++ b/Assets/Shaders/Resources/GatewayText.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: d6e0383cafaa88b4cb37698f5f264a14 +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets.meta b/Assets/StreamingAssets.meta new file mode 100644 index 0000000..1761f37 --- /dev/null +++ b/Assets/StreamingAssets.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 476dd926d3f9977459c1fa637195948d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video.meta b/Assets/StreamingAssets/Video.meta new file mode 100644 index 0000000..3f95c34 --- /dev/null +++ b/Assets/StreamingAssets/Video.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5205d38d04e8f3240becf2ac317f2944 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/.gitkeep b/Assets/StreamingAssets/Video/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Assets/StreamingAssets/Video/ch10_kiln.mp4 b/Assets/StreamingAssets/Video/ch10_kiln.mp4 new file mode 100644 index 0000000..93dcc8b Binary files /dev/null and b/Assets/StreamingAssets/Video/ch10_kiln.mp4 differ diff --git a/Assets/StreamingAssets/Video/ch10_kiln.mp4.meta b/Assets/StreamingAssets/Video/ch10_kiln.mp4.meta new file mode 100644 index 0000000..56f649d --- /dev/null +++ b/Assets/StreamingAssets/Video/ch10_kiln.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5a500ec8c2233a041a4b003e88b07dec +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/ch11_crown.mp4 b/Assets/StreamingAssets/Video/ch11_crown.mp4 new file mode 100644 index 0000000..3bd7b22 Binary files /dev/null and b/Assets/StreamingAssets/Video/ch11_crown.mp4 differ diff --git a/Assets/StreamingAssets/Video/ch11_crown.mp4.meta b/Assets/StreamingAssets/Video/ch11_crown.mp4.meta new file mode 100644 index 0000000..b75c88b --- /dev/null +++ b/Assets/StreamingAssets/Video/ch11_crown.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: cad0080e59c891f429e216b9beefe1d3 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/ch12_ledger.mp4 b/Assets/StreamingAssets/Video/ch12_ledger.mp4 new file mode 100644 index 0000000..003d07f Binary files /dev/null and b/Assets/StreamingAssets/Video/ch12_ledger.mp4 differ diff --git a/Assets/StreamingAssets/Video/ch12_ledger.mp4.meta b/Assets/StreamingAssets/Video/ch12_ledger.mp4.meta new file mode 100644 index 0000000..bd32423 --- /dev/null +++ b/Assets/StreamingAssets/Video/ch12_ledger.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 86d54bc4fffdf734ab9a04e8a73f321a +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/ch1_prologue.mp4 b/Assets/StreamingAssets/Video/ch1_prologue.mp4 new file mode 100644 index 0000000..bc25fa9 Binary files /dev/null and b/Assets/StreamingAssets/Video/ch1_prologue.mp4 differ diff --git a/Assets/StreamingAssets/Video/ch1_prologue.mp4.meta b/Assets/StreamingAssets/Video/ch1_prologue.mp4.meta new file mode 100644 index 0000000..110baaa --- /dev/null +++ b/Assets/StreamingAssets/Video/ch1_prologue.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 724441f2bd2abaf42aa6dc0f640a75e4 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/ch2_watchers.mp4 b/Assets/StreamingAssets/Video/ch2_watchers.mp4 new file mode 100644 index 0000000..bb9b306 Binary files /dev/null and b/Assets/StreamingAssets/Video/ch2_watchers.mp4 differ diff --git a/Assets/StreamingAssets/Video/ch2_watchers.mp4.meta b/Assets/StreamingAssets/Video/ch2_watchers.mp4.meta new file mode 100644 index 0000000..e788fd1 --- /dev/null +++ b/Assets/StreamingAssets/Video/ch2_watchers.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 34bf2e11bc37e6c4e86c9dbb6fda13ca +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/ch3_scholar.mp4 b/Assets/StreamingAssets/Video/ch3_scholar.mp4 new file mode 100644 index 0000000..4c92df4 Binary files /dev/null and b/Assets/StreamingAssets/Video/ch3_scholar.mp4 differ diff --git a/Assets/StreamingAssets/Video/ch3_scholar.mp4.meta b/Assets/StreamingAssets/Video/ch3_scholar.mp4.meta new file mode 100644 index 0000000..f6d0e1e --- /dev/null +++ b/Assets/StreamingAssets/Video/ch3_scholar.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a1db8e0a31639764aa2ddbebeafa2a34 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/ch4_crates.mp4 b/Assets/StreamingAssets/Video/ch4_crates.mp4 new file mode 100644 index 0000000..2a32c7b Binary files /dev/null and b/Assets/StreamingAssets/Video/ch4_crates.mp4 differ diff --git a/Assets/StreamingAssets/Video/ch4_crates.mp4.meta b/Assets/StreamingAssets/Video/ch4_crates.mp4.meta new file mode 100644 index 0000000..17efa12 --- /dev/null +++ b/Assets/StreamingAssets/Video/ch4_crates.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 345550178bad0094c8cc8113692ca62e +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/ch5_summit.mp4 b/Assets/StreamingAssets/Video/ch5_summit.mp4 new file mode 100644 index 0000000..8187fbd Binary files /dev/null and b/Assets/StreamingAssets/Video/ch5_summit.mp4 differ diff --git a/Assets/StreamingAssets/Video/ch5_summit.mp4.meta b/Assets/StreamingAssets/Video/ch5_summit.mp4.meta new file mode 100644 index 0000000..7ac71ba --- /dev/null +++ b/Assets/StreamingAssets/Video/ch5_summit.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7d1082f5ceafdfc4b9345b27a9038d44 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/ch6_wall.mp4 b/Assets/StreamingAssets/Video/ch6_wall.mp4 new file mode 100644 index 0000000..88897ef Binary files /dev/null and b/Assets/StreamingAssets/Video/ch6_wall.mp4 differ diff --git a/Assets/StreamingAssets/Video/ch6_wall.mp4.meta b/Assets/StreamingAssets/Video/ch6_wall.mp4.meta new file mode 100644 index 0000000..82e964b --- /dev/null +++ b/Assets/StreamingAssets/Video/ch6_wall.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: fe6259900e5daf14fa45845541250683 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/ch7_fence.mp4 b/Assets/StreamingAssets/Video/ch7_fence.mp4 new file mode 100644 index 0000000..19fc2ae Binary files /dev/null and b/Assets/StreamingAssets/Video/ch7_fence.mp4 differ diff --git a/Assets/StreamingAssets/Video/ch7_fence.mp4.meta b/Assets/StreamingAssets/Video/ch7_fence.mp4.meta new file mode 100644 index 0000000..e687e22 --- /dev/null +++ b/Assets/StreamingAssets/Video/ch7_fence.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 72a44682cbf77664299995d03fb13c22 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/ch8_vault.mp4 b/Assets/StreamingAssets/Video/ch8_vault.mp4 new file mode 100644 index 0000000..572b01a Binary files /dev/null and b/Assets/StreamingAssets/Video/ch8_vault.mp4 differ diff --git a/Assets/StreamingAssets/Video/ch8_vault.mp4.meta b/Assets/StreamingAssets/Video/ch8_vault.mp4.meta new file mode 100644 index 0000000..c4221d7 --- /dev/null +++ b/Assets/StreamingAssets/Video/ch8_vault.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f4dc9189c7958f24090a7dd6b5bc8e6b +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/ch9_archive.mp4 b/Assets/StreamingAssets/Video/ch9_archive.mp4 new file mode 100644 index 0000000..c368095 Binary files /dev/null and b/Assets/StreamingAssets/Video/ch9_archive.mp4 differ diff --git a/Assets/StreamingAssets/Video/ch9_archive.mp4.meta b/Assets/StreamingAssets/Video/ch9_archive.mp4.meta new file mode 100644 index 0000000..3f534f7 --- /dev/null +++ b/Assets/StreamingAssets/Video/ch9_archive.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 589c418f6d581a941ae36f780f10b9f2 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/dream_arena.mp4 b/Assets/StreamingAssets/Video/dream_arena.mp4 new file mode 100644 index 0000000..70f1ea6 Binary files /dev/null and b/Assets/StreamingAssets/Video/dream_arena.mp4 differ diff --git a/Assets/StreamingAssets/Video/dream_arena.mp4.meta b/Assets/StreamingAssets/Video/dream_arena.mp4.meta new file mode 100644 index 0000000..4890baf --- /dev/null +++ b/Assets/StreamingAssets/Video/dream_arena.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0d6402ede88b434499890c6872172186 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/fin_epilogue.mp4 b/Assets/StreamingAssets/Video/fin_epilogue.mp4 new file mode 100644 index 0000000..66b047c Binary files /dev/null and b/Assets/StreamingAssets/Video/fin_epilogue.mp4 differ diff --git a/Assets/StreamingAssets/Video/fin_epilogue.mp4.meta b/Assets/StreamingAssets/Video/fin_epilogue.mp4.meta new file mode 100644 index 0000000..69c0f13 --- /dev/null +++ b/Assets/StreamingAssets/Video/fin_epilogue.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3ddc443e7e6e95141804907d6016c767 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/fin_return.mp4 b/Assets/StreamingAssets/Video/fin_return.mp4 new file mode 100644 index 0000000..62d7188 Binary files /dev/null and b/Assets/StreamingAssets/Video/fin_return.mp4 differ diff --git a/Assets/StreamingAssets/Video/fin_return.mp4.meta b/Assets/StreamingAssets/Video/fin_return.mp4.meta new file mode 100644 index 0000000..43e5480 --- /dev/null +++ b/Assets/StreamingAssets/Video/fin_return.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3678a455366d9e242a9afd9e7b798d8c +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/fin_shadow.mp4 b/Assets/StreamingAssets/Video/fin_shadow.mp4 new file mode 100644 index 0000000..eb2d3f4 Binary files /dev/null and b/Assets/StreamingAssets/Video/fin_shadow.mp4 differ diff --git a/Assets/StreamingAssets/Video/fin_shadow.mp4.meta b/Assets/StreamingAssets/Video/fin_shadow.mp4.meta new file mode 100644 index 0000000..088772b --- /dev/null +++ b/Assets/StreamingAssets/Video/fin_shadow.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4a96a2e17094b9747ae63db31bd4232e +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/opening_prologue_landscape.mp4 b/Assets/StreamingAssets/Video/opening_prologue_landscape.mp4 new file mode 100644 index 0000000..f7842d2 Binary files /dev/null and b/Assets/StreamingAssets/Video/opening_prologue_landscape.mp4 differ diff --git a/Assets/StreamingAssets/Video/opening_prologue_landscape.mp4.meta b/Assets/StreamingAssets/Video/opening_prologue_landscape.mp4.meta new file mode 100644 index 0000000..db973b6 --- /dev/null +++ b/Assets/StreamingAssets/Video/opening_prologue_landscape.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f91fcbd7721e8d546ad10d1a1171df6e +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/Video/opening_prologue_portrait.mp4 b/Assets/StreamingAssets/Video/opening_prologue_portrait.mp4 new file mode 100644 index 0000000..292d141 Binary files /dev/null and b/Assets/StreamingAssets/Video/opening_prologue_portrait.mp4 differ diff --git a/Assets/StreamingAssets/Video/opening_prologue_portrait.mp4.meta b/Assets/StreamingAssets/Video/opening_prologue_portrait.mp4.meta new file mode 100644 index 0000000..75111ee --- /dev/null +++ b/Assets/StreamingAssets/Video/opening_prologue_portrait.mp4.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7d3541371c71eb5468e953e1414fd2cd +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/AmbushRulesTests.cs b/Assets/Tests/EditMode/AmbushRulesTests.cs new file mode 100644 index 0000000..dc751d6 --- /dev/null +++ b/Assets/Tests/EditMode/AmbushRulesTests.cs @@ -0,0 +1,511 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using System.Text; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Spawning; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 습격() — 거절 조건 하나하나, 쿨다운 경계, 싸울 곤충, 접근 속도·길 고르기, 그리고 진짜 곤충 DB에서 + /// 습격형이 어느 시간·날씨에 깨는지. + /// + /// 실패가 조용한 계열이다: 거절 조건 하나가 빠지면 꿈속·동굴·대화 중에 곤충이 덮치고, 싸울 곤충 검사가 빠지면 첫 파트너가 없는 + /// 플레이어가 닫을 수 없는 창에 갇힌다. 예외도 경고도 없다. + /// + [TestFixture] + public class AmbushRulesTests + { + private const BindingFlags Inst = BindingFlags.Instance | BindingFlags.NonPublic; + + private static readonly DayPhase[] Phases = (DayPhase[])System.Enum.GetValues(typeof(DayPhase)); + private static readonly WeatherType[] Weathers = (WeatherType[])System.Enum.GetValues(typeof(WeatherType)); + + private readonly List shared = new List(); + private readonly List perTest = new List(); + private InsectDatabase db; + + [TearDown] + public void TearDown() + { + foreach (Object o in perTest) if (o != null) Object.DestroyImmediate(o); + perTest.Clear(); + } + + [OneTimeTearDown] + public void ReleaseSharedDatabase() + { + foreach (Object o in shared) if (o != null) Object.DestroyImmediate(o); + shared.Clear(); + db = null; + } + + private InsectDatabase RealDatabase() + { + if (db != null) return db; + var host = new GameObject("AmbushRulesTests_RealDb"); + host.SetActive(false); // Awake(월드 생성)를 막고 private 생성 함수만 부른다 + shared.Add(host); + var bootstrap = host.AddComponent(); + db = (InsectDatabase)typeof(PlaySceneBootstrap).GetMethod("EnsureExpandedDatabase", Inst).Invoke(bootstrap, null); + Assert.IsNotNull(db); + shared.Add(db); + return db; + } + + private static WorldState State(DayPhase phase, WeatherType weather) + => new WorldState { DayPhase = phase, Weather = weather, Hour24 = 0 }; + + /// 밤에 깨는 습격형(비를 싫어함). + private static readonly InsectHabit NightHunter = + new InsectHabit(InsectActivity.Nocturnal, WeatherSet.None, WeatherSet.Rain, InsectTemperament.Ambusher); + + /// 모든 칸이 습격을 허락하는 상태 — 각 테스트는 여기서 칸 하나만 바꾼다. + private static AmbushRules.Context Ready() + { + return new AmbushRules.Context + { + Habit = NightHunter, + State = State(DayPhase.Night, WeatherType.Clear), + IsGuardian = false, + IsEngaged = false, + EntityCooldownLeft = 0f, + SinceLastAmbushEnded = float.PositiveInfinity, + SinceLastEncounterEnded = float.PositiveInfinity, + PlayerInEncounter = false, + DreamActive = false, + InSubArea = false, + PlayerFrozen = false, + ModalOpen = false, + CanFight = true + }; + } + + // ── 거절 조건 하나씩 ── + + [Test] + public void Check_EverythingAllows_ReturnsNone() + { + Assert.AreEqual(AmbushRefusal.None, AmbushRules.Check(Ready())); + Assert.IsTrue(AmbushRules.CanAmbush(Ready())); + } + + [Test] + public void Check_DocileSpecies_IsNotAwake_AtAnyTime() + { + AmbushRules.Context c = Ready(); + c.Habit = new InsectHabit(InsectActivity.Nocturnal, WeatherSet.Fog, WeatherSet.None, InsectTemperament.Docile); + foreach (DayPhase p in Phases) + foreach (WeatherType w in Weathers) + { + c.State = State(p, w); + Assert.AreEqual(AmbushRefusal.NotAwake, AmbushRules.Check(c), $"온순한 종이 {p}/{w}에 덤벼든다"); + } + } + + [Test] + public void Check_NocturnalAmbusherOutsideItsHours_IsNotAwake() + { + AmbushRules.Context c = Ready(); + c.State = State(DayPhase.Day, WeatherType.Clear); + Assert.AreEqual(AmbushRefusal.NotAwake, AmbushRules.Check(c), "낮에 야행성 습격형이 덤벼든다"); + c.State = State(DayPhase.Morning, WeatherType.Clear); + Assert.AreEqual(AmbushRefusal.NotAwake, AmbushRules.Check(c)); + c.State = State(DayPhase.Evening, WeatherType.Clear); + Assert.AreEqual(AmbushRefusal.NotAwake, AmbushRules.Check(c), "어스름은 아직이다(좋아하는 날씨가 없다)"); + } + + [Test] + public void Check_DislikedWeather_KeepsItQuietEvenAtNight() + { + AmbushRules.Context c = Ready(); + c.State = State(DayPhase.Night, WeatherType.Rain); + Assert.AreEqual(AmbushRefusal.NotAwake, AmbushRules.Check(c)); + } + + [Test] + public void Check_Guardian_NeverAmbushes() + { + AmbushRules.Context c = Ready(); + c.IsGuardian = true; + Assert.AreEqual(AmbushRefusal.Guardian, AmbushRules.Check(c)); + } + + [Test] + public void Check_AlreadyEngagedInsect_DoesNotAmbush() + { + AmbushRules.Context c = Ready(); + c.IsEngaged = true; + Assert.AreEqual(AmbushRefusal.Engaged, AmbushRules.Check(c)); + } + + [Test] + public void Check_DuringTheDream_DoesNotAmbush() + { + AmbushRules.Context c = Ready(); + c.DreamActive = true; + Assert.AreEqual(AmbushRefusal.Dream, AmbushRules.Check(c)); + } + + [Test] + public void Check_InsideASubArea_DoesNotAmbush() + { + AmbushRules.Context c = Ready(); + c.InSubArea = true; + Assert.AreEqual(AmbushRefusal.SubArea, AmbushRules.Check(c)); + } + + [Test] + public void Check_PlayerAlreadyInAnEncounter_DoesNotAmbush() + { + AmbushRules.Context c = Ready(); + c.PlayerInEncounter = true; + Assert.AreEqual(AmbushRefusal.PlayerInEncounter, AmbushRules.Check(c)); + } + + [Test] + public void Check_PlayerFrozen_DoesNotAmbush() + { + AmbushRules.Context c = Ready(); + c.PlayerFrozen = true; + Assert.AreEqual(AmbushRefusal.PlayerFrozen, AmbushRules.Check(c)); + } + + [Test] + public void Check_ModalOpen_DoesNotAmbush() + { + AmbushRules.Context c = Ready(); + c.ModalOpen = true; + Assert.AreEqual(AmbushRefusal.ModalOpen, AmbushRules.Check(c)); + } + + [Test] + public void Check_NoInsectToFightWith_DoesNotAmbush() + { + AmbushRules.Context c = Ready(); + // 튜토리얼 초반 — 배틀팀이 비었다. 습격 창엔 닫는 길이 없어 이 경우 열면 갇힌다. + c.CanFight = AmbushRules.CanFight(false, 0, 0); + Assert.AreEqual(AmbushRefusal.NoFighter, AmbushRules.Check(c)); + // 팀은 있지만 전원 기절. + c.CanFight = AmbushRules.CanFight(false, 3, 0); + Assert.AreEqual(AmbushRefusal.NoFighter, AmbushRules.Check(c)); + } + + // ── 쿨다운 경계 ── + + [Test] + public void EntityCooldown_BlocksUntilItRunsOut() + { + AmbushRules.Context c = Ready(); + c.EntityCooldownLeft = 0.01f; + Assert.AreEqual(AmbushRefusal.EntityCooldown, AmbushRules.Check(c)); + c.EntityCooldownLeft = 0f; + Assert.AreEqual(AmbushRefusal.None, AmbushRules.Check(c)); + c.EntityCooldownLeft = -3f; + Assert.AreEqual(AmbushRefusal.None, AmbushRules.Check(c)); + } + + [Test] + public void GlobalCooldown_HoldsForItsFullLength_AfterAnAmbush() + { + AmbushRules.Context c = Ready(); + c.SinceLastAmbushEnded = 0f; + Assert.AreEqual(AmbushRefusal.GlobalCooldown, AmbushRules.Check(c), "습격이 막 끝났는데 또 덤벼든다"); + c.SinceLastAmbushEnded = AmbushRules.GlobalCooldownSeconds - 0.01f; + Assert.AreEqual(AmbushRefusal.GlobalCooldown, AmbushRules.Check(c)); + c.SinceLastAmbushEnded = AmbushRules.GlobalCooldownSeconds; + Assert.AreEqual(AmbushRefusal.None, AmbushRules.Check(c)); + } + + [Test] + public void AfterAnyEncounter_ThereIsAShortBreather() + { + AmbushRules.Context c = Ready(); + c.SinceLastEncounterEnded = AmbushRules.AfterEncounterGraceSeconds - 0.01f; + Assert.AreEqual(AmbushRefusal.GlobalCooldown, AmbushRules.Check(c), "결과 창을 닫자마자 덤벼든다"); + c.SinceLastEncounterEnded = AmbushRules.AfterEncounterGraceSeconds; + Assert.AreEqual(AmbushRefusal.None, AmbushRules.Check(c)); + } + + [Test] + public void Cooldowns_AreInTheIntendedBand() + { + // 근거는 AmbushRules의 주석(밤에 걸으면 분당 약 1회 마주친다) — "60초 안팎"을 벗어나면 그 계산부터 다시 볼 것. + Assert.That(AmbushRules.GlobalCooldownSeconds, Is.InRange(45f, 90f)); + Assert.Less(AmbushRules.AfterEncounterGraceSeconds, AmbushRules.GlobalCooldownSeconds); + Assert.Greater(AmbushRules.EntityCooldownSeconds, 0f); + } + + [Test] + public void Remaining_CountsDownToZero_AndNeverGoesNegative() + { + Assert.AreEqual(2f, AmbushRules.Remaining(10f, 12f), 1e-5f); + Assert.AreEqual(0f, AmbushRules.Remaining(12f, 10f), 1e-5f); + Assert.AreEqual(0f, AmbushRules.Remaining(5f, 0f), 1e-5f); + } + + // ── 싸울 수 있나 ── + + [Test] + public void CanFight_OneOnOne_NeedsOneConsciousInsect() + { + Assert.IsFalse(AmbushRules.CanFight(false, 0, 0)); + Assert.IsFalse(AmbushRules.CanFight(false, 5, 0), "전원 기절인데 싸우라고 한다"); + Assert.IsTrue(AmbushRules.CanFight(false, 1, 1)); + } + + [Test] + public void CanFight_RaidTarget_NeedsAFullTeamWithSomeoneStanding() + { + Assert.IsFalse(AmbushRules.CanFight(true, BattleTeamManager.MaxSlots - 1, BattleTeamManager.MaxSlots - 1), + "5칸이 안 찼는데 레이드 습격이 열린다 — [레이드로 맞서기]가 막혀 창에 갇힌다"); + Assert.IsFalse(AmbushRules.CanFight(true, BattleTeamManager.MaxSlots, 0)); + Assert.IsTrue(AmbushRules.CanFight(true, BattleTeamManager.MaxSlots, 1)); + } + + [Test] + public void RaidRarity_IsEpicAndLegendaryOnly() + { + // 습격 판정과 포획 선택 창이 같은 술어를 읽는다 — 어긋나면 습격 창이 막힌 버튼으로 뜬다. + Assert.IsFalse(CaptureChoiceUI.IsRaidRarity(InsectRarity.Common)); + Assert.IsFalse(CaptureChoiceUI.IsRaidRarity(InsectRarity.Uncommon)); + Assert.IsFalse(CaptureChoiceUI.IsRaidRarity(InsectRarity.Rare)); + Assert.IsTrue(CaptureChoiceUI.IsRaidRarity(InsectRarity.Epic)); + Assert.IsTrue(CaptureChoiceUI.IsRaidRarity(InsectRarity.Legendary)); + } + + // ── 기다릴까 물러날까 ── + + [Test] + public void IsPauseOnly_IsFrozenAndModal_AndNothingElse() + { + foreach (AmbushRefusal r in (AmbushRefusal[])System.Enum.GetValues(typeof(AmbushRefusal))) + { + bool expected = r == AmbushRefusal.PlayerFrozen || r == AmbushRefusal.ModalOpen; + Assert.AreEqual(expected, AmbushRules.IsPauseOnly(r), r.ToString()); + } + } + + [Test] + public void Check_AnotherEncounterOutranksFrozenAndModal_SoOtherChasersRetreat() + { + // 습격 창·전투도 플레이어를 멈추고 모달을 연다. 그때 「멈춤」이 먼저 걸리면 다가오던 다른 곤충이 기다렸다가 + // 전투가 끝나자마자 덮친다 — 「교전 중」이 먼저 걸려 물러나야 한다. + AmbushRules.Context c = Ready(); + c.PlayerInEncounter = true; + c.PlayerFrozen = true; + c.ModalOpen = true; + AmbushRefusal r = AmbushRules.Check(c); + Assert.AreEqual(AmbushRefusal.PlayerInEncounter, r); + Assert.IsFalse(AmbushRules.IsPauseOnly(r)); + } + + // ── 거리·속도 ── + + private static float PlayerWalkSpeed() + { + // 진짜 기본 걸음 — 비활성 오브젝트에 붙여 Awake 없이 필드 초기값만 읽는다. + var go = new GameObject("AmbushRulesTests_Player"); + go.SetActive(false); + var pm = go.AddComponent(); + float speed = (float)typeof(PlayerMovement).GetField("moveSpeed", Inst).GetValue(pm); + Object.DestroyImmediate(go); + return speed; + } + + [Test] + public void ApproachSpeed_IsBelowThePlayersWalk_ButStillAThreat() + { + float walk = PlayerWalkSpeed(); + Assert.Greater(walk, 0f); + Assert.Less(AmbushRules.ApproachSpeed, walk, "걸음보다 빠르면 보고 달아나도 피할 수 없다"); + Assert.GreaterOrEqual(AmbushRules.ApproachSpeed, walk * 0.6f, "너무 느리면 위협이 안 된다"); + } + + [Test] + public void StandingStill_GetsReached_BeforeTheChaseGivesUp() + { + float timeToReach = AmbushRules.HesitateSeconds + + (AmbushRules.NoticeRadius - AmbushRules.ReachDistance) / AmbushRules.ApproachSpeed; + Assert.Less(timeToReach, AmbushRules.MaxChaseSeconds); + Assert.Less(AmbushRules.ReachDistance, AmbushRules.NoticeRadius); + } + + [Test] + public void RunningAwayAtOnce_ShakesItOff_BeforeTheChaseLimit() + { + // 알아챈 순간 반대로 달리면 멈칫 동안 걸음만큼 벌어지고, 그 뒤 (걸음 − 접근 속도)씩 벌어진다. + float walk = PlayerWalkSpeed(); + float gapAfterHesitation = AmbushRules.NoticeRadius + walk * AmbushRules.HesitateSeconds; + float timeToLeash = Mathf.Max(0f, AmbushRules.LeashDistance - gapAfterHesitation) + / (walk - AmbushRules.ApproachSpeed); + Assert.LessOrEqual(timeToLeash, AmbushRules.MaxChaseSeconds); + Assert.Greater(AmbushRules.LeashDistance, AmbushRules.NoticeRadius, "경계 반경 언저리에서 들락날락한다"); + Assert.IsTrue(AmbushRules.ShouldGiveUp(AmbushRules.LeashDistance + 0.1f, 0f)); + Assert.IsTrue(AmbushRules.ShouldGiveUp(2f, AmbushRules.MaxChaseSeconds + 0.1f)); + Assert.IsFalse(AmbushRules.ShouldGiveUp(2f, 1f)); + Assert.IsTrue(AmbushRules.HasReached(AmbushRules.ReachDistance)); + Assert.IsFalse(AmbushRules.HasReached(AmbushRules.ReachDistance + 0.01f)); + } + + // ── 다가갈 길 ── + + [Test] + public void ChooseApproach_OpenGround_GoesStraight_AndStopsShortOfThePlayer() + { + Vector3 dir = AmbushRules.ChooseApproach(Vector3.forward * 5f, 5f, 1f, _ => 100f, out float allowed); + Assert.AreEqual(0f, Vector3.Angle(Vector3.forward, dir), 0.01f); + Assert.AreEqual(AmbushRules.MaxLegDistance, allowed, 1e-4f, "한 걸음은 최대 길이로 자른다"); + + AmbushRules.ChooseApproach(Vector3.forward * 2f, 2f, 1f, _ => 100f, out float close); + Assert.AreEqual(2f - AmbushRules.ReachDistance * 0.5f, close, 1e-4f, "플레이어를 지나쳐 가면 안 된다"); + Assert.Less(2f - close, AmbushRules.ReachDistance, "걸음을 다 가면 닿는 거리 안이어야 한다"); + } + + [Test] + public void ChooseApproach_WallAhead_GoesAroundIt_InsteadOfThrough() + { + // 정면 1m에 벽, 다른 방향은 뚫렸다. + Vector3 toward = Vector3.forward; + System.Func clearance = d => Vector3.Angle(toward, d) < 1f ? 1f : 100f; + Vector3 one = AmbushRules.ChooseApproach(toward * 6f, 6f, 1f, clearance, out float allowed); + float oneAngle = Vector3.SignedAngle(toward, one, Vector3.up); + Assert.AreEqual(AmbushRules.ApproachAngles[1], Mathf.Abs(oneAngle), 0.5f, "가장 가까운 비낀 방향으로 돌아가야 한다"); + Assert.AreEqual(AmbushRules.MaxLegDistance, allowed, 1e-4f); + + Vector3 other = AmbushRules.ChooseApproach(toward * 6f, 6f, -1f, clearance, out _); + float otherAngle = Vector3.SignedAngle(toward, other, Vector3.up); + Assert.AreEqual(-Mathf.Sign(oneAngle), Mathf.Sign(otherAngle), "좌우 부호를 뒤집으면 반대쪽으로 돌아야 한다"); + } + + [Test] + public void ChooseApproach_Boxed_StopsAtTheWall_NeverThroughIt() + { + Vector3 dir = AmbushRules.ChooseApproach(Vector3.forward * 6f, 6f, 1f, _ => 1.2f, out float allowed); + Assert.AreEqual(1.2f - FleePath.WallMargin, allowed, 1e-4f, "벽 앞 여유만큼 앞에서 멈춰야 한다"); + Assert.AreEqual(1f, dir.magnitude, 1e-4f); + + AmbushRules.ChooseApproach(Vector3.forward * 6f, 6f, 1f, _ => 0f, out float none); + Assert.AreEqual(0f, none, 1e-6f, "다 막혔으면 제자리 — 호출부가 잠시 뒤 쫓기를 접는다"); + } + + // ── 한 줄 문구 ── + + [Test] + public void ReasonLine_SaysWhatWokeIt() + { + StringAssert.Contains("밤", AmbushRules.ReasonLine(NightHunter, State(DayPhase.Night, WeatherType.Clear))); + var fogStalker = new InsectHabit(InsectActivity.Nocturnal, WeatherSet.Fog, WeatherSet.None, InsectTemperament.Ambusher); + StringAssert.Contains("안개", AmbushRules.ReasonLine(fogStalker, State(DayPhase.Evening, WeatherType.Fog))); + Assert.AreEqual(AmbushRules.FallbackReason, + AmbushRules.ReasonLine(InsectHabit.Neutral, State(DayPhase.Day, WeatherType.Clear))); + } + + [Test] + public void ReasonLine_EveryAwakeMoment_HasItsOwnLine() + { + foreach (string id in InsectHabits.AllIds) + { + InsectHabit h = InsectHabits.Of(id); + if (h.Temperament != InsectTemperament.Ambusher) continue; + foreach (DayPhase p in Phases) + foreach (WeatherType w in Weathers) + { + if (!InsectHabits.IsAmbusher(h, State(p, w))) continue; + string line = AmbushRules.ReasonLine(h, State(p, w)); + Assert.IsFalse(string.IsNullOrEmpty(line), $"{id} {p}/{w}"); + Assert.AreNotEqual(AmbushRules.FallbackReason, line, $"{id} {p}/{w}: 깨어 있는데 까닭을 모른다"); + } + } + } + + // ── 진짜 DB — 습격형이 언제 깨는가 ── + + /// + /// 필드에 나오는 종(spawnWeight > 0)으로 시간대 × 날씨마다 ①깨어 있는 습격형 종 수 ②필드 한 칸이 「깨어 있는 습격형」일 몫 + /// (전역 등급표 × 그 등급 안의 spawnWeight × 성향 배수 — 리전 풀은 무시한 전체 근사)을 잰다. 표는 로그의 [Ambush] 줄에 남는다. + /// + [Test] + public void RealDb_AmbushersWakeAtNight_AndNeverByDay() + { + var field = new List(); + int ambushSpecies = 0; + foreach (InsectData d in RealDatabase().insects) + { + if (d == null || d.spawnWeight <= 0f) continue; + field.Add(d); + if (InsectHabits.For(d).Temperament == InsectTemperament.Ambusher) ambushSpecies++; + } + Assert.Greater(ambushSpecies, 0, "필드에 습격형이 하나도 없다"); + + var count = new Dictionary<(DayPhase, WeatherType), int>(); + var share = new Dictionary<(DayPhase, WeatherType), float>(); + var log = new StringBuilder(); + log.AppendLine($"[Ambush] 필드 습격형 {ambushSpecies}종 — 깨어 있는 종 수 / 필드 한 칸이 깨어 있는 습격형일 몫"); + foreach (DayPhase p in Phases) + { + log.Append("[Ambush] ").Append(p.ToString().PadRight(8)); + foreach (WeatherType w in Weathers) + { + WorldState s = State(p, w); + int awake = 0; + float total = 0f; + for (int r = 0; r < FieldSpawnRules.RarityCount; r++) + { + float all = 0f, hunting = 0f; + foreach (InsectData d in field) + { + if ((int)d.rarity != r) continue; + InsectHabit h = InsectHabits.For(d); + float weight = d.spawnWeight * InsectHabits.SpawnWeightMultiplier(h, s); + all += weight; + if (InsectHabits.IsAmbusher(h, s)) hunting += weight; + } + if (all > 0f) total += FieldSpawnRules.BaseShare(r) * hunting / all; + } + foreach (InsectData d in field) + if (InsectHabits.IsAmbusher(InsectHabits.For(d), s)) awake++; + count[(p, w)] = awake; + share[(p, w)] = total; + log.Append($" {w}:{awake,2}종/{total * 100f,4:0.0}%"); + } + log.AppendLine(); + } + Debug.Log(log.ToString()); + + // 밤에는 어떤 날씨에도 덤벼드는 곤충이 있다. + float nightSum = 0f; + foreach (WeatherType w in Weathers) + { + Assert.Greater(count[(DayPhase.Night, w)], 0, $"밤 {w}에 깨는 습격형이 없다"); + Assert.GreaterOrEqual(share[(DayPhase.Night, w)], 0.02f, $"밤 {w}의 습격형 몫이 너무 적다"); + nightSum += share[(DayPhase.Night, w)]; + } + float dayHoursSum = 0f; + foreach (WeatherType w in Weathers) + dayHoursSum += share[(DayPhase.Morning, w)] + share[(DayPhase.Day, w)]; + float nightAvg = nightSum / Weathers.Length; + float dayAvg = dayHoursSum / (Weathers.Length * 2); + + // 아침·낮에는 아무도 싸움을 걸지 않는다 — 요청이 "밤에 싸움을 거는 곤충"이다(InsectHabits.IsAmbushHour). + // 처음엔 주행성 말벌·사마귀가 한낮에 깨어 맑은 낮(4.6%)이 맑은 밤(4.1%)만큼 사나웠다. + foreach (WeatherType w in Weathers) + { + Assert.AreEqual(0, count[(DayPhase.Morning, w)], $"아침 {w}에 깨는 습격형이 있다"); + Assert.AreEqual(0, count[(DayPhase.Day, w)], $"낮 {w}에 깨는 습격형이 있다"); + } + Assert.AreEqual(0f, dayAvg, 1e-6f); + // 밤이 저녁보다 사납다 — 저녁은 좋아하는 날씨일 때만 일찍 깬다. + float eveningSum = 0f; + foreach (WeatherType w in Weathers) eveningSum += share[(DayPhase.Evening, w)]; + Assert.Greater(nightAvg, eveningSum / Weathers.Length, + $"밤(평균 {nightAvg * 100f:0.0}%)이 저녁보다 사납지 않다"); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/AmbushRulesTests.cs.meta b/Assets/Tests/EditMode/AmbushRulesTests.cs.meta new file mode 100644 index 0000000..13897f7 --- /dev/null +++ b/Assets/Tests/EditMode/AmbushRulesTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f1f4f272301ed2745a0165bb20e2ba5f \ No newline at end of file diff --git a/Assets/Tests/EditMode/BattleCaptureChanceCalculatorTests.cs b/Assets/Tests/EditMode/BattleCaptureChanceCalculatorTests.cs index fb15e3a..c7126fd 100644 --- a/Assets/Tests/EditMode/BattleCaptureChanceCalculatorTests.cs +++ b/Assets/Tests/EditMode/BattleCaptureChanceCalculatorTests.cs @@ -1,5 +1,6 @@ #if UNITY_EDITOR using InsectGame.Battle; +using InsectGame.Core; using InsectGame.Data; using InsectGame.UI; using NUnit.Framework; @@ -121,11 +122,12 @@ public void Calculate_CaptureBonuses_AddOnlyNonnegativeValues() [Test] public void Calculate_ExtremeInputs_ClampToConfiguredBounds() { + // 최저 보장은 레벨 유예(+5) 안에서의 보장이다 — 그 너머는 아래 테스트가 본다. float minimum = Calculate( InsectRarity.Legendary, 1f, - 1, - 99); + 10, + 10 + GameConstants.TrainerLevel.CaptureGraceLevels); float maximum = Calculate( InsectRarity.Common, 0f, @@ -144,6 +146,35 @@ public void Calculate_ExtremeInputs_ClampToConfiguredBounds() 0.0001f); } + [Test] + public void Calculate_InsectFarAboveTrainer_GoesBelowMinimumGuarantee() + { + // 예전엔 Lv.1 캐릭터가 Lv.99 곤충을 이겨도 최저 10%가 보장됐다. + // .90 - 레벨 상한 .15 = .75 → 레벨 차 +98은 배율 하한(×0.05) → .0375 + float chance = Calculate(InsectRarity.Common, 0f, 1, 99); + + Assert.Less(chance, BattleCaptureChanceCalculator.MinimumSuccessChance); + Assert.AreEqual(0.75f * GameConstants.TrainerLevel.MinCaptureMultiplier, chance, 0.0001f); + } + + [Test] + public void Calculate_AnyGap_AppliesSharedTrainerLevelGapMultiplier() + { + // 미니게임 경로와 같은 TrainerLevelGap 배율을 쓴다 — 경로마다 따로 적으면 한쪽만 고쳐진다. + // Common·난이도 0은 .90에서 덧셈 보정(최대 -.15)만 빠져 clamp에 닿지 않는다. + for (int gap = 0; gap <= 20; gap++) + { + int trainer = 30 - gap; + float unscaled = BattleCaptureChanceCalculator.BaseSuccessChance + + BattleCaptureChanceCalculator.GetLevelModifier(trainer, 30); + Assert.AreEqual( + unscaled * TrainerLevelGap.CaptureMultiplier(trainer, 30), + Calculate(InsectRarity.Common, 0f, trainer, 30), + 0.0001f, + $"레벨 차 +{gap}"); + } + } + [Test] public void IsSuccessful_RollBoundary_SucceedsBelowAndFailsAtChance() { diff --git a/Assets/Tests/EditMode/BattleCriticalTests.cs b/Assets/Tests/EditMode/BattleCriticalTests.cs new file mode 100644 index 0000000..03ffefb --- /dev/null +++ b/Assets/Tests/EditMode/BattleCriticalTests.cs @@ -0,0 +1,757 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using InsectGame.Battle; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Spawning; +using NUnit.Framework; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace InsectGame.Tests +{ + /// + /// 진짜 치명타(1/16 · ×1.5) — 1v1과 레이드 리졸버, 그리고 전투 화면이 그 판정을 그대로 옮기는지. + /// + /// 예전엔 판정이 없었고 화면이 "적 최대 HP의 25% 이상"을 CRITICAL로 표시만 했다. 전투가 3~4라운드라 + /// 거의 매 타격이 그 선을 넘어 상시 CRITICAL이었고, 그 플래그가 아레나의 무거운 타격 연출로도 흘렀다. + /// + /// 치명타는 명중 롤과 다른 줄기다 — 여기 테스트는 줄기를 주입해 경계·배율·적용 범위를 고정한다. + /// + [TestFixture] + public class BattleCriticalTests + { + private const BindingFlags Inst = BindingFlags.Instance | BindingFlags.NonPublic; + private const int Level = 10; + private readonly List objects = new List(); + private InsectBattleStats player; + private InsectBattleStats enemy; + + [TearDown] + public void Cleanup() + { + foreach (Object obj in objects) if (obj != null) Object.DestroyImmediate(obj); + objects.Clear(); + } + + // ── 순수 판정 ── + + [Test] + public void RollCritical_BoundaryIsOneSixteenth() + { + Assert.AreEqual(1f / 16f, GameConstants.Battle.CritChance, 1e-7f); + Assert.IsTrue(InsectBattleController.RollCritical(0.0624f), "1/16 바로 아래는 치명타"); + Assert.IsFalse(InsectBattleController.RollCritical(0.0626f), "1/16 바로 위는 아니다"); + Assert.IsTrue(InsectBattleController.RollCritical(0f)); + Assert.IsFalse(InsectBattleController.RollCritical(0.99999994f)); + } + + [Test] + public void ApplyCritical_MultipliesByOneAndAHalf_AndRollsOnce() + { + var hit = new ScriptedRoll(0.0624f); + Assert.AreEqual(60, InsectBattleController.ApplyCritical(40, hit, out bool critical)); + Assert.IsTrue(critical); + Assert.AreEqual(1, hit.Draws, "피해기 한 번에 한 번만 굴린다"); + Assert.AreEqual(1.5f, GameConstants.Battle.CritMultiplier); + + var miss = new ScriptedRoll(0.0626f); + Assert.AreEqual(40, InsectBattleController.ApplyCritical(40, miss, out critical)); + Assert.IsFalse(critical); + + Assert.AreEqual(2, InsectBattleController.ApplyCritical(1, new ScriptedRoll(0f), out _), "1 × 1.5 → 2(반올림)"); + } + + [Test] + public void ApplyCritical_NullSource_DoesNotRoll_AndIsDeterministic() + { + for (int i = 0; i < 50; i++) + { + Assert.AreEqual(37, InsectBattleController.ApplyCritical(37, null, out bool critical)); + Assert.IsFalse(critical); + } + } + + [Test] + public void ExpectedDamageGain_IsAboutThreePercent() + { + float gain = GameConstants.Battle.CritChance * (GameConstants.Battle.CritMultiplier - 1f); + Assert.AreEqual(0.03125f, gain, 1e-6f, "체감용 변동이지 전투 길이 손잡이가 아니다 — 늘리면 balance.md 전투 길이부터"); + } + + // ── 1v1 ── + + [Test] + public void Skill_CriticalRoll_DealsOneAndAHalfTimes_AndFlagsBothSides() + { + InsectBattleController battle = Duel(new ScriptedRoll(0.0624f)); + int enemyBefore = enemy.CurrentHp; + int playerBefore = player.CurrentHp; + + battle.UseSkill(0); + + // 공격 40 = 방어 40 → 공방 비율 1, 대결이라 야생 페이싱 없음. (30 + Lv10) = 40 → ×1.5 = 60. + Assert.AreEqual(60, enemyBefore - battle.EnemyHpAfterPlayerAction); + Assert.AreEqual(60, playerBefore - battle.PlayerHpAfterEnemyAction); + Assert.IsTrue(battle.LastPlayerHitCritical); + Assert.IsTrue(battle.LastEnemyHitCritical); + } + + [Test] + public void Skill_RollJustAboveChance_IsNotCritical() + { + InsectBattleController battle = Duel(new ScriptedRoll(0.0626f)); + int enemyBefore = enemy.CurrentHp; + + battle.UseSkill(0); + + Assert.AreEqual(40, enemyBefore - battle.EnemyHpAfterPlayerAction); + Assert.IsFalse(battle.LastPlayerHitCritical); + Assert.IsFalse(battle.LastEnemyHitCritical); + } + + [Test] + public void BasicAttack_CanBeCritical() + { + InsectBattleController battle = Duel(new ScriptedRoll(0f)); + StunEnemy(battle); + int enemyBefore = enemy.CurrentHp; + + battle.UseBasicAttack(); + + int plain = Mathf.Max(1, Mathf.RoundToInt(player.Attack * 0.7f)); + Assert.AreEqual(Mathf.RoundToInt(plain * 1.5f), enemyBefore - battle.EnemyHpAfterPlayerAction); + Assert.IsTrue(battle.LastPlayerHitCritical); + Assert.IsFalse(battle.LastEnemyHitCritical, "기절한 상대는 때리지 않았다"); + } + + [Test] + public void NullCritSource_IsDeterministic_AndNeverFlags() + { + InsectBattleController battle = Duel(null); + for (int round = 0; round < 5; round++) + { + int enemyBefore = enemy.CurrentHp; + if (battle.CanUseSkill(0)) battle.UseSkill(0); else battle.UseBasicAttack(); + Assert.IsFalse(battle.LastPlayerHitCritical); + Assert.IsFalse(battle.LastEnemyHitCritical); + Assert.Greater(enemyBefore, enemy.CurrentHp); + } + } + + [TestCase(SkillEffectType.BuffAttack)] + [TestCase(SkillEffectType.DebuffAttack)] + [TestCase(SkillEffectType.DefenseBuff)] + [TestCase(SkillEffectType.Heal)] + [TestCase(SkillEffectType.PoisonDot)] + [TestCase(SkillEffectType.Stun)] + public void NonDamageSkill_DoesNotRollCritical(SkillEffectType type) + { + var crit = new ScriptedRoll(0f); + InsectBattleController battle = Duel(crit); + InsectSkill support = Skill("support_" + type, type, 6); + support.effectValue = 0.3f; + support.effectDurationTurns = 2; + SetPrivate(battle, "playerOverrideSkills", new[] { support }); + SetPrivate(battle, "playerCooldowns", new int[1]); + StunEnemy(battle); + + battle.UseSkill(0); + + Assert.AreEqual(0, crit.Draws, $"{type}는 피해기가 아니다 — 치명타를 굴리면 안 된다"); + Assert.IsFalse(battle.LastPlayerHitCritical); + } + + [Test] + public void MissedSkill_DoesNotRollCritical() + { + var crit = new ScriptedRoll(0f); + InsectBattleController battle = Duel(crit); + player.Data.skills[0].accuracy = 0.5f; + battle.SetRandomSource(new ScriptedRoll(0.99f)); // 명중 줄기: 빗나감 + StunEnemy(battle); + int enemyBefore = enemy.CurrentHp; + + battle.UseSkill(0); + + Assert.AreEqual(enemyBefore, battle.EnemyHpAfterPlayerAction, "빗나갔다"); + Assert.AreEqual(0, crit.Draws, "빗나간 공격은 치명타를 굴리지 않는다"); + Assert.IsFalse(battle.LastPlayerHitCritical); + } + + [Test] + public void CritStream_DoesNotShiftHitRolls() + { + // 같은 명중 줄기에서 치명타를 켜든 끄든 명중 롤 소비가 같아야 한다 — 같은 줄기면 피해기마다 하나씩 밀린다. + int Draws(IRaidRandomSource crit) + { + var hits = new ScriptedRoll(0.1f); + InsectBattleController battle = Duel(crit); + player.Data.skills[0].accuracy = 0.5f; + battle.SetRandomSource(hits); + for (int i = 0; i < 4; i++) + { + if (battle.CanUseSkill(0)) battle.UseSkill(0); else battle.UseBasicAttack(); + } + return hits.Draws; + } + + Assert.AreEqual(Draws(null), Draws(new ScriptedRoll(0f))); + } + + [Test] + public void NextAction_ClearsTheCriticalFlags() + { + var crit = new ScriptedRoll(0f); + InsectBattleController battle = Duel(crit); + battle.UseSkill(0); + Assert.IsTrue(battle.LastPlayerHitCritical); + + crit.Value = 0.5f; + battle.UseBasicAttack(); + Assert.IsFalse(battle.LastPlayerHitCritical, "지난 라운드 치명타가 남으면 다음 타격도 CRITICAL로 뜬다"); + Assert.IsFalse(battle.LastEnemyHitCritical); + } + + [Test] + public void CriticalHit_WithEffectiveness_AddsNoTopLine_TheMatchupGoesToTheNumber() + { + InsectElement attack = InsectElement.None, defend = InsectElement.None; + foreach (InsectElement a in Enum.GetValues(typeof(InsectElement))) + foreach (InsectElement d in Enum.GetValues(typeof(InsectElement))) + { + if (attack == InsectElement.None && InsectTypeChart.GetEffectiveness(a, d, InsectElement.None) > 1.05f) + { + attack = a; + defend = d; + } + } + Assert.AreNotEqual(InsectElement.None, attack, "효과가 굉장한 상성 쌍이 표에 없다"); + + InsectBattleController battle = Duel(new ScriptedRoll(0f)); + battle.DeferPresentation = true; + player.Data.skills[0].element = attack; + enemy.Data.primaryType = defend; + StunEnemy(battle); + + battle.UseSkill(0); + + List texts = PresentationTexts(battle); + Assert.AreEqual(0, texts.Count(t => t.Contains("치명타")), + "「치명타!」는 피해 숫자 위에 뜬다 — 위쪽 효과 문구에도 띄우면 한 타격에 두 번 보인다"); + // 상성도 같다 — 피해 숫자 위 화살표 + 「잘 통했다!」(BattleScreenUI.Feel)가 컨트롤러의 등급 하나를 읽는다. + // 위쪽 효과 문구(예전 「효과가 굉장했다!」·「효과가 별로인 듯하다...」)로도 띄우면 한 타격에 상성 문구가 두 번 보인다. + Assert.AreEqual(0, texts.Count(t => t.Contains("효과가")), "상성 문구가 위쪽 효과 문구에도 떴다"); + Assert.AreEqual(0, texts.Count(t => t.Contains("통했다")), "상성 문구는 화면이 피해 숫자 위에 띄운다"); + Assert.IsTrue(ElementMatchup.IsFavorable(battle.LastPlayerHitMatchup), "화면이 읽을 등급이 남아야 한다"); + Assert.IsNotNull(InsectGame.UI.MatchupHud.ImpactText(battle.LastPlayerHitMatchup)); + } + + [Test] + public void CriticalHit_Neutral_AddsNoTopLine() + { + InsectBattleController battle = Duel(new ScriptedRoll(0f)); + battle.DeferPresentation = true; + StunEnemy(battle); + + battle.UseBasicAttack(); + + Assert.IsTrue(battle.LastPlayerHitCritical, "치명타 롤 0 — 치명타여야 한다"); + Assert.AreEqual(0, PresentationTexts(battle).Count(t => t.Contains("치명타")), + "「치명타!」는 피해 숫자 위에만 뜬다(BattleScreenUI)"); + } + + [Test] + public void Sandbox_NeverRollsCritical() + { + var crit = new ScriptedRoll(0f); + InsectBattleController battle = NewController(crit); + InsectData ace = Species("crit_dream_ace", 150, 40, 30); + InsectData foe = Species("crit_dream_foe", 400, 40, 30); + InsectSkill hit = Skill("crit_dream_hit", SkillEffectType.Damage, 40); + ace.skills = new[] { hit }; + foe.skills = new[] { hit }; + PlayerInsectData champion = DreamPrologueData.BuildChampionInsect(ace); + Assert.IsTrue(battle.StartSandbox(ace, 80, foe, 72, new[] { hit }, champion)); + + bool ended = false; + battle.BattleEnded += _ => ended = true; + for (int i = 0; i < 8 && !ended; i++) + { + if (battle.CanUseSkill(0)) battle.UseSkill(0); else battle.UseBasicAttack(); + Assert.IsFalse(battle.LastPlayerHitCritical); + Assert.IsFalse(battle.LastEnemyHitCritical); + } + Assert.IsTrue(ended); + Assert.AreEqual(0, crit.Draws, "꿈 챔피언전은 SandboxBattleRules가 길이를 지킨다 — 치명타를 굴리지 않는다"); + } + + [Test] + public void SetRandomSeed_SeedsTheCritStreamToo() + { + List Run() + { + // 시드 없는 진짜 줄기로 시작한다 — SetRandomSeed가 치명타 줄기를 안 갈아끼우면 두 실행이 갈린다. + InsectBattleController battle = Duel(new BattleRandomSource()); + battle.SetRandomSeed(4242); + var hp = new List(); + for (int i = 0; i < 12; i++) + { + if (battle.CanUseSkill(0)) battle.UseSkill(0); else battle.UseBasicAttack(); + hp.Add(enemy.CurrentHp); + hp.Add(player.CurrentHp); + } + return hp; + } + + CollectionAssert.AreEqual(Run(), Run(), "시드 고정 전투가 치명타 때문에 실행마다 달라졌다"); + } + + // ── 상태 노출 ── + + [Test] + public void PlayerStunTurns_AreReadableBetweenRounds() + { + // 상대의 기절기가 내 곤충에 걸리면 **다음 라운드의 내 행동**이 날아간다 — HP 카드가 그 사이에 읽는 값이다. + InsectBattleController battle = Duel(null); + InsectSkill stun = Skill("crit_enemy_stun", SkillEffectType.Stun, 1); + stun.cooldownTurns = 5; + enemy.Data.skills = new[] { stun }; + Assert.AreEqual(0, battle.PlayerStunTurns); + + battle.UseBasicAttack(); + Assert.AreEqual(1, battle.PlayerStunTurns, "상대 기절기가 걸렸는데 남은 턴을 읽을 수 없다"); + + battle.UseBasicAttack(); // 기절로 건너뛴 차례 — 상대는 쿨다운이라 기본 공격 + Assert.IsFalse(battle.PlayerActedThisRound); + Assert.AreEqual(0, battle.PlayerStunTurns); + } + + [Test] + public void EnemyStunTurns_AreReadable() + { + InsectBattleController battle = Duel(null); + Assert.AreEqual(0, battle.EnemyStunTurns); + StunEnemy(battle); + Assert.AreEqual(1, battle.EnemyStunTurns); + + battle.UseBasicAttack(); // 기절은 이번 반격에서 소모된다 + + Assert.IsFalse(battle.EnemyActedThisRound); + Assert.AreEqual(0, battle.EnemyStunTurns); + } + + [Test] + public void PoisonTick_IsReportedPerRound_AndClearedByTheNextAction() + { + InsectBattleController battle = Duel(null); + AddEffect(battle, false, 7f, 3, InsectBattleController.EffectKind.Dot); + AddEffect(battle, true, 5f, 1, InsectBattleController.EffectKind.Dot); + + battle.UseSkill(0); + Assert.AreEqual(7, battle.EnemyPoisonDamageThisRound); + Assert.AreEqual(5, battle.PlayerPoisonDamageThisRound); + Assert.AreEqual(battle.EnemyHpAfterEnemyAction - 7, enemy.CurrentHp, "독은 두 공격 스냅샷 뒤에 들어간다"); + + battle.UseBasicAttack(); + Assert.AreEqual(7, battle.EnemyPoisonDamageThisRound, "다음 라운드도 같은 독이 다시 센다(누적 아님)"); + Assert.AreEqual(0, battle.PlayerPoisonDamageThisRound, "지속 1턴짜리 독은 끝났다"); + } + + // ── 전투 화면이 판정을 그대로 옮기는가 ── + + [Test] + public void Screen_BigNonCriticalHit_IsNotShownAsCritical() + { + InsectBattleController battle = Duel(new ScriptedRoll(0.9f)); + player.Data.skills[0].power = 400; // 적 최대 HP의 25%를 훌쩍 넘는 한 방 — 옛 휴리스틱이면 CRITICAL + InsectGame.UI.BattleScreenUI ui = Screen(battle); + StunEnemy(battle); + + Invoke(ui, "TryUseSkill", 0); + + Assert.Greater(enemy.MaxHp - enemy.CurrentHp, enemy.MaxHp / 4); + Assert.IsFalse((bool)Get(ui, "lastWasCritical"), "치명타가 아닌데 CRITICAL로 표시됐다"); + } + + [Test] + public void Screen_CriticalFlags_ComeFromTheController() + { + InsectBattleController battle = Duel(new ScriptedRoll(0f)); + InsectGame.UI.BattleScreenUI ui = Screen(battle); + + Invoke(ui, "TryBasicAttack"); + + Assert.IsTrue((bool)Get(ui, "lastWasCritical")); + Assert.IsTrue((bool)Get(ui, "lastEnemyWasCritical"), "상대의 치명타도 표시 계산에 실려야 한다"); + } + + [Test] + public void Screen_ComboShows_OnSecondUnansweredHit_AndBreaksWhenTheReplyLands() + { + InsectBattleController battle = Duel(null); + InsectGame.UI.BattleScreenUI ui = Screen(battle); + + // 1타 — 상대 기절(반격 없음). 콤보 1이라 아직 안 뜬다. + StunEnemy(battle); + PlayerTurn(ui); + Invoke(ui, "TryBasicAttack"); + Invoke(ui, "RevealImpact", true); + Assert.AreEqual(1, (int)Get(ui, "comboCount")); + Assert.AreEqual(0f, (float)Get(ui, "comboDisplayTimer")); + + // 2타 — 또 반격 없음. 타격이 보이는 순간 콤보가 뜬다. + StunEnemy(battle); + PlayerTurn(ui); + Invoke(ui, "TryBasicAttack"); + Invoke(ui, "RevealImpact", true); + Assert.AreEqual(2, (int)Get(ui, "comboCount")); + Assert.AreEqual(2.5f, (float)Get(ui, "comboDisplayTimer"), 1e-4f); + + // 3타 — 이번엔 반격이 나를 때린다. 내 타격 순간엔 3으로 뜨고, 반격이 보이는 순간 끊긴다. + PlayerTurn(ui); + Invoke(ui, "TryBasicAttack"); + Invoke(ui, "RevealImpact", true); + Assert.AreEqual(3, (int)Get(ui, "comboCount"), "반격 판정만으로 콤보를 미리 끊으면 한 번도 안 뜬다"); + SetPrivate(ui, "impactRevealed", false); + Invoke(ui, "RevealImpact", false); + Assert.AreEqual(0, (int)Get(ui, "comboCount")); + Assert.AreEqual(0f, (float)Get(ui, "comboDisplayTimer")); + } + + // ── 헬퍼 ── + + private InsectBattleController Duel(IRaidRandomSource crit) + { + InsectBattleController battle = NewController(crit); + InsectData p = Species("crit_player", 1000, 20, 30); + InsectData e = Species("crit_enemy", 1000, 20, 30); + p.skills = new[] { Skill("crit_hit_p", SkillEffectType.Damage, 30) }; + e.skills = new[] { Skill("crit_hit_e", SkillEffectType.Damage, 30) }; + Assert.IsTrue(battle.StartDuel(p, Level, e, Level, (a, b) => { player = a; enemy = b; })); + Assert.AreEqual(player.Attack, enemy.Defense, "공방 비율 1이어야 배율이 그대로 보인다"); + return battle; + } + + private InsectBattleController NewController(IRaidRandomSource crit) + { + InsectBattleController battle = Track(new GameObject("CritBattleFixture")).AddComponent(); + battle.SetCritSource(crit); + return battle; + } + + private InsectGame.UI.BattleScreenUI Screen(InsectBattleController battle) + { + var ui = Track(new GameObject("CritScreenFixture")).AddComponent(); + ui.AutoWire(battle, null, null); + Assert.IsTrue(battle.StartDuel(player.Data, Level, enemy.Data, Level, (a, b) => { player = a; enemy = b; })); + PlayerTurn(ui); + return ui; + } + + private static void PlayerTurn(InsectGame.UI.BattleScreenUI ui) + { + FieldInfo phase = typeof(InsectGame.UI.BattleScreenUI).GetField("phase", Inst); + phase.SetValue(ui, Enum.Parse(phase.FieldType, "PlayerTurn")); + } + + private static void StunEnemy(InsectBattleController battle) => SetPrivate(battle, "enemyStunTurns", 1); + + private static void AddEffect(InsectBattleController battle, bool targetIsPlayer, float value, int turns, + InsectBattleController.EffectKind kind) + { + typeof(InsectBattleController).GetMethod("AddEffect", Inst) + .Invoke(battle, new object[] { targetIsPlayer, value, turns, kind }); + } + + private static List PresentationTexts(InsectBattleController battle) + { + var list = (System.Collections.IEnumerable)typeof(InsectBattleController).GetField("presentationTexts", Inst).GetValue(battle); + var texts = new List(); + foreach (object entry in list) + texts.Add((string)entry.GetType().GetField("Item2").GetValue(entry)); + return texts; + } + + private static void Invoke(object target, string method, params object[] args) + { + MethodInfo m = target.GetType().GetMethod(method, Inst); + Assert.IsNotNull(m, method); + m.Invoke(target, args); + } + + private static object Get(object target, string field) + { + FieldInfo f = target.GetType().GetField(field, Inst); + Assert.IsNotNull(f, field); + return f.GetValue(target); + } + + private static void SetPrivate(object target, string field, object value) + { + FieldInfo f = target.GetType().GetField(field, Inst); + Assert.IsNotNull(f, field); + f.SetValue(target, value); + } + + private InsectData Species(string id, int hp, int atk, int def) + { + InsectData d = Track(ScriptableObject.CreateInstance()); + d.insectId = id; + d.displayName = id; + d.baseHp = hp; + d.baseAtk = atk; + d.baseDef = def; + d.primaryType = InsectElement.None; + d.secondaryType = InsectElement.None; + return d; + } + + private InsectSkill Skill(string id, SkillEffectType type, int power) + { + InsectSkill s = Track(ScriptableObject.CreateInstance()); + s.skillId = id; + s.displayName = id; + s.effectType = type; + s.element = InsectElement.None; + s.power = power; + s.accuracy = 1f; + s.cooldownTurns = 0; + return s; + } + + private T Track(T obj) where T : Object + { + objects.Add(obj); + return obj; + } + } + + /// + /// 레이드 쪽 치명타(리졸버의 치명타 줄기)와 쓰러짐 연출 배선(). + /// 쓰러짐은 예전에 정의만 있고 호출이 0건이라 보스·팀원이 HP 0으로도 서 있었다. + /// + [TestFixture] + public class RaidCriticalAndFaintTests + { + private readonly List objects = new List(); + + [TearDown] + public void Cleanup() + { + foreach (Object obj in objects) if (obj != null) Object.DestroyImmediate(obj); + objects.Clear(); + } + + [Test] + public void LeaderSkill_WithoutCritSource_NeverCrits() + { + for (int i = 0; i < 30; i++) + { + InsectBattleStats boss = Stats("boss", 100000, 20, 30); + RaidActionResult r = RaidRoundResolver.ResolveLeaderSkill(0, 0, Stats("a", 500, 20, 30), boss, + new InsectBattleStats[0], Skill(SkillEffectType.Damage, 30), null); + Assert.IsFalse(r.Critical); + Assert.AreEqual(40, r.Damage); + } + } + + [Test] + public void LeaderSkill_CritRoll_FlagsAndMultiplies() + { + InsectBattleStats boss = Stats("boss", 100000, 20, 30); + var crit = new ScriptedRoll(0.0624f); + RaidActionResult r = RaidRoundResolver.ResolveLeaderSkill(0, 0, Stats("a", 500, 20, 30), boss, + new InsectBattleStats[0], Skill(SkillEffectType.Damage, 30), null, crit); + Assert.IsTrue(r.Critical); + Assert.AreEqual(60, r.Damage, "(30 + Lv10) × 1.5 — 공방 비율 1"); + Assert.AreEqual(1, crit.Draws); + + RaidActionResult plain = RaidRoundResolver.ResolveLeaderSkill(0, 0, Stats("a", 500, 20, 30), boss, + new InsectBattleStats[0], Skill(SkillEffectType.Damage, 30), null, new ScriptedRoll(0.0626f)); + Assert.IsFalse(plain.Critical); + Assert.AreEqual(40, plain.Damage); + } + + [Test] + public void SupportAssist_And_BossSingle_CanCrit() + { + InsectBattleStats boss = Stats("boss", 100000, 20, 30); + RaidActionResult assist = RaidRoundResolver.ResolveSupportAssist(1, Stats("a", 500, 20, 30), boss, new ScriptedRoll(0f)); + Assert.IsTrue(assist.Critical); + + InsectBattleStats[] team = { Stats("t0", 100000, 20, 30) }; + var intent = RaidRoundResolver.CreateBossIntent(1, boss, team, 2, null, new ScriptedRoll(0f), false); + int[] bySlot = new int[1]; + RaidActionResult plain = RaidRoundResolver.ResolveBossIntent(intent, boss, team, bySlot); + int plainDamage = bySlot[0]; + RaidActionResult hit = RaidRoundResolver.ResolveBossIntent(intent, boss, team, bySlot, 1f, new ScriptedRoll(0f)); + Assert.IsFalse(plain.Critical); + Assert.IsTrue(hit.Critical); + Assert.AreEqual(Mathf.RoundToInt(plainDamage * 1.5f), bySlot[0], "보스 단일 공격 ×1.5(공방 비율 1)"); + } + + [TestCase(SkillEffectType.BuffAttack)] + [TestCase(SkillEffectType.DefenseBuff)] + [TestCase(SkillEffectType.DebuffAttack)] + [TestCase(SkillEffectType.Heal)] + [TestCase(SkillEffectType.Stun)] + [TestCase(SkillEffectType.PoisonDot)] + public void NonDamageSkill_DoesNotRollCritical(SkillEffectType type) + { + var crit = new ScriptedRoll(0f); + InsectBattleStats caster = Stats("a", 500, 20, 30); + InsectSkill skill = Skill(type, 6); + skill.effectValue = 0.2f; + skill.effectDurationTurns = 2; + RaidActionResult r = RaidRoundResolver.ResolveLeaderSkill(0, 0, caster, Stats("boss", 100000, 20, 30), + new[] { caster }, skill, null, crit); + Assert.AreEqual(0, crit.Draws, $"{type}는 치명타를 굴리지 않는다"); + Assert.IsFalse(r.Critical); + } + + [Test] + public void Controller_ActionText_SaysCritical() + { + RaidBattleController raid = Raid(new ScriptedRoll(0f), null); + raid.ResolveTeamCommand(0); + RaidActionResult action = raid.CurrentRoundResult.TeamActions[0]; + Assert.IsTrue(action.Critical); + StringAssert.Contains("치명타!", raid.LastActionText); + } + + [Test] + public void Controller_SetRandomSourceAlone_KeepsTheCritStreamSeparate() + { + // 테스트 스크립트 난수(0)만 주입했을 때 치명타 줄기가 그걸 따라가면 매 타격이 치명타가 된다. + RaidBattleController raid = Raid(null, null, setCrit: false); + raid.SetCritSource(new ScriptedRoll(0.9f)); + raid.ResolveTeamCommand(0); + Assert.IsFalse(raid.CurrentRoundResult.TeamActions[0].Critical); + } + + [Test] + public void PresentPendingFaints_AnnouncesEachFaintOnce() + { + RaidBattleController raid = Raid(null, null); + var seen = new List(); + raid.FaintPresented += seen.Add; + + Assert.IsFalse(raid.PresentPendingFaints(), "아무도 안 쓰러졌다"); + CollectionAssert.IsEmpty(seen); + + raid.TeamStats[2].ApplyDamage(999999); + raid.BossStats.ApplyDamage(99999999); + Assert.IsFalse(raid.PresentPendingFaints(), "아레나가 없으면 기다릴 쓰러짐도 없다"); + CollectionAssert.AreEquivalent(new[] { -1, 2 }, seen); + + raid.PresentPendingFaints(); + Assert.AreEqual(2, seen.Count, "같은 개체를 두 번 쓰러뜨렸다"); + } + + [Test] + public void PresentPendingFaints_SkipsMembersAlreadyDownAtStart() + { + var pids = new PlayerInsectData[5]; + for (int i = 0; i < 5; i++) + pids[i] = new PlayerInsectData { instanceId = $"inst_{i}", insectId = $"team_{i}", level = 10, currentHp = i == 1 ? 0 : -1 }; + RaidBattleController raid = Raid(null, pids); + var seen = new List(); + raid.FaintPresented += seen.Add; + + raid.PresentPendingFaints(); + + CollectionAssert.IsEmpty(seen, "시작 전부터 기절해 있던 팀원은 지금 쓰러진 게 아니다"); + } + + // ── 헬퍼 ── + + private RaidBattleController Raid(IRaidRandomSource crit, PlayerInsectData[] pids, bool setCrit = true) + { + InsectData bossData = Data("boss", 4000, 20, 30); + InsectEntity boss = Track(new GameObject("CritRaidBoss")).AddComponent(); + SetField(boss, "data", bossData); + SetField(boss, "level", 10); + + RaidBattleController raid = Track(new GameObject("CritRaidController")).AddComponent(); + raid.SetRandomSource(new ScriptedRoll(0f)); + if (setCrit) raid.SetCritSource(crit); + + var team = new InsectData[5]; + var skills = new InsectSkill[5][]; + InsectSkill hit = Skill(SkillEffectType.Damage, 30); + for (int i = 0; i < 5; i++) + { + team[i] = Data($"team_{i}", 300, 20, 30); + skills[i] = new[] { hit }; + } + Assert.IsTrue(raid.StartRaid(boss, team, Enumerable.Repeat(10, 5).ToArray(), pids, skills)); + return raid; + } + + private InsectBattleStats Stats(string id, int hp, int atk, int def) => new InsectBattleStats(Data(id, hp, atk, def), 10); + + private InsectData Data(string id, int hp, int atk, int def) + { + InsectData d = Track(ScriptableObject.CreateInstance()); + d.insectId = id; + d.displayName = id; + d.baseHp = hp; + d.baseAtk = atk; + d.baseDef = def; + d.primaryType = InsectElement.None; + d.secondaryType = InsectElement.None; + return d; + } + + private InsectSkill Skill(SkillEffectType type, int power) + { + InsectSkill s = Track(ScriptableObject.CreateInstance()); + s.skillId = "raid_crit_" + type; + s.displayName = s.skillId; + s.effectType = type; + s.element = InsectElement.None; + s.power = power; + s.accuracy = 1f; + return s; + } + + private static void SetField(object target, string name, object value) + { + FieldInfo f = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(f, name); + f.SetValue(target, value); + } + + private T Track(T obj) where T : Object + { + objects.Add(obj); + return obj; + } + } + + /// 고정 값을 돌려주며 몇 번 뽑혔는지 센다 — 명중·치명타 줄기 주입용. + internal sealed class ScriptedRoll : IRaidRandomSource + { + public float Value; + public int Draws; + + public ScriptedRoll(float value) { Value = value; } + + public float Next01() + { + Draws++; + return Value; + } + + public int NextInt(int minInclusive, int maxExclusive) + { + Draws++; + return minInclusive; + } + } +} +#endif diff --git a/Assets/Tests/EditMode/BattleCriticalTests.cs.meta b/Assets/Tests/EditMode/BattleCriticalTests.cs.meta new file mode 100644 index 0000000..7b9709c --- /dev/null +++ b/Assets/Tests/EditMode/BattleCriticalTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c512d4d384b93a64983b6dcda3eb8e1f \ No newline at end of file diff --git a/Assets/Tests/EditMode/BattleEnvironmentTests.cs b/Assets/Tests/EditMode/BattleEnvironmentTests.cs new file mode 100644 index 0000000..329597b --- /dev/null +++ b/Assets/Tests/EditMode/BattleEnvironmentTests.cs @@ -0,0 +1,685 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using InsectGame.Battle; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Spawning; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 낮·밤·날씨 전투 보정()과 도주 규칙(). + /// + /// 실패가 전부 조용한 계열이다: 보정이 대결·꿈 챔피언전에 새어도, 다음 전투가 이전 칩을 물려받아도, 교체한 곤충이 + /// 보정을 못 받아도 예외 하나 없이 숫자만 10%씩 어긋난다. 그래서 순수 규칙과 함께 진짜 컨트롤러를 진짜 시계·날씨 + /// (··)에 물려 돌린다. + /// 성향은 진짜 표(InsectHabitTable)를 읽는다 — 아래 종 ID의 성향이 바뀌면 이 테스트도 같이 고친다. + /// + [TestFixture] + public class BattleEnvironmentTests + { + private const BindingFlags Inst = BindingFlags.Instance | BindingFlags.NonPublic; + + // 표에서 고른 종 — 성향이 이 주석대로여야 아래 기대값이 맞는다. + private const string NocturnalFogLover = "stag_beetle"; // 야행성 · 안개 ↑ · 싫은 날씨 없음 + private const string DiurnalPlain = "hornet_asian"; // 주행성 · 좋은 날씨 없음 · 비 ↓ + private const string Neutral = "beetle_basic"; // 시간·날씨 무관 + private const string SnowLover = "beetle_rime"; // 시간 무관 · 눈 ↑ + + private const int Level = 10; + + private static readonly DayPhase[] Phases = (DayPhase[])System.Enum.GetValues(typeof(DayPhase)); + private static readonly WeatherType[] Weathers = (WeatherType[])System.Enum.GetValues(typeof(WeatherType)); + + private readonly List objects = new List(); + + [TearDown] + public void Cleanup() + { + foreach (Object o in objects) if (o != null) Object.DestroyImmediate(o); + objects.Clear(); + } + + // ── 노트 계산(순수) ── + + [Test] + public void Note_NocturnalAtNight_IsFavorable_AndNamesTheTime() + { + var habit = new InsectHabit(InsectActivity.Nocturnal, WeatherSet.None, WeatherSet.None, InsectTemperament.Docile); + BattleEnvironmentNote note = BattleEnvironment.NoteFor(habit, State(DayPhase.Night, WeatherType.Clear)); + + Assert.IsTrue(note.HasEffect); + Assert.AreEqual(InsectHabits.FavorableBattleMultiplier, note.Multiplier, 1e-6f); + Assert.AreEqual(10, note.Percent); + Assert.AreEqual("밤 · 야행성", note.Reason); + } + + [Test] + public void Note_DislikedWeather_IsUnfavorable_AndNamesTheWeather() + { + var habit = new InsectHabit(InsectActivity.Any, WeatherSet.None, WeatherSet.Rain, InsectTemperament.Docile); + BattleEnvironmentNote note = BattleEnvironment.NoteFor(habit, State(DayPhase.Day, WeatherType.Rain)); + + Assert.IsTrue(note.HasEffect); + Assert.AreEqual(InsectHabits.UnfavorableBattleMultiplier, note.Multiplier, 1e-6f); + Assert.AreEqual(-10, note.Percent); + Assert.AreEqual("비 · 비를 싫어함", note.Reason); + } + + [Test] + public void Note_TimeAndWeatherAgree_NamesBoth() + { + var habit = new InsectHabit(InsectActivity.Nocturnal, WeatherSet.Fog, WeatherSet.None, InsectTemperament.Docile); + BattleEnvironmentNote note = BattleEnvironment.NoteFor(habit, State(DayPhase.Night, WeatherType.Fog)); + + Assert.AreEqual(InsectHabits.FavorableBattleMultiplier, note.Multiplier, 1e-6f, "유리는 겹쳐도 +10%다(Fit이 −1~+1로 가둔다)"); + Assert.AreEqual("밤 · 야행성 · 안개를 좋아함", note.Reason); + } + + [Test] + public void Note_OnlyTheSideThatMadeTheMultiplier_IsNamed() + { + // 잠잘 시간(−1)에 좋아하는 날씨(+1)면 서로 지워 칩이 없다. + var diurnalClear = new InsectHabit(InsectActivity.Diurnal, WeatherSet.Clear, WeatherSet.None, InsectTemperament.Docile); + BattleEnvironmentNote cancelled = BattleEnvironment.NoteFor(diurnalClear, State(DayPhase.Night, WeatherType.Clear)); + Assert.IsFalse(cancelled.HasEffect); + Assert.AreEqual(1f, cancelled.Multiplier); + Assert.AreEqual(string.Empty, cancelled.Reason); + + // 저녁(시간 0)에 싫은 날씨(−1)면 날씨만 이유다 — "저녁 · 주행성"이라고 적으면 거짓이다. + var diurnalRainHater = new InsectHabit(InsectActivity.Diurnal, WeatherSet.None, WeatherSet.Rain, InsectTemperament.Docile); + BattleEnvironmentNote weatherOnly = BattleEnvironment.NoteFor(diurnalRainHater, State(DayPhase.Evening, WeatherType.Rain)); + Assert.AreEqual(InsectHabits.UnfavorableBattleMultiplier, weatherOnly.Multiplier, 1e-6f); + Assert.AreEqual("비 · 비를 싫어함", weatherOnly.Reason); + + // 낮(+1)에 싫은 날씨(−1)는 0, 밤(−1)에 싫은 날씨(−1)는 둘 다 이유다. + Assert.IsFalse(BattleEnvironment.NoteFor(diurnalRainHater, State(DayPhase.Day, WeatherType.Rain)).HasEffect); + Assert.AreEqual("밤 · 주행성 · 비를 싫어함", + BattleEnvironment.NoteFor(diurnalRainHater, State(DayPhase.Night, WeatherType.Rain)).Reason); + } + + [Test] + public void Note_NeutralHabit_IsNone_InEveryPhaseAndWeather() + { + foreach (DayPhase phase in Phases) + foreach (WeatherType weather in Weathers) + { + BattleEnvironmentNote note = BattleEnvironment.NoteFor(InsectHabit.Neutral, State(phase, weather)); + Assert.IsFalse(note.HasEffect, $"{phase}/{weather}"); + Assert.AreEqual(1f, note.Multiplier, $"{phase}/{weather}"); + Assert.AreEqual(string.Empty, note.Reason, $"{phase}/{weather}"); + } + } + + [Test] + public void Note_FromInsectData_ReadsTheHabitTable_AndNullIsNone() + { + InsectData stag = Insect(NocturnalFogLover); + BattleEnvironmentNote note = BattleEnvironment.NoteFor(stag, State(DayPhase.Night, WeatherType.Clear)); + Assert.AreEqual("밤 · 야행성", note.Reason); + Assert.AreEqual(10, note.Percent); + + BattleEnvironmentNote none = BattleEnvironment.NoteFor((InsectData)null, State(DayPhase.Night, WeatherType.Fog)); + Assert.IsFalse(none.HasEffect); + Assert.AreEqual(string.Empty, none.Reason); + } + + [Test] + public void Note_DefaultAndNone_HaveNoEffect() + { + // 아직 정하지 않은 표지(default — 배수 0)도 "영향 없음"으로 읽혀야 화면이 빈 칩을 안 그린다. + Assert.IsFalse(default(BattleEnvironmentNote).HasEffect); + Assert.IsFalse(BattleEnvironmentNote.None.HasEffect); + Assert.AreEqual(0, BattleEnvironmentNote.None.Percent); + Assert.AreEqual(string.Empty, BattleEnvironmentNote.None.Reason); + } + + [Test] + public void Note_EveryTableHabit_EveryPhaseAndWeather_HasAShortHonestReason() + { + string longest = string.Empty; + int effects = 0; + foreach (string id in InsectHabits.AllIds) + foreach (DayPhase phase in Phases) + foreach (WeatherType weather in Weathers) + { + InsectHabit habit = InsectHabits.Of(id); + WorldState state = State(phase, weather); + BattleEnvironmentNote note = BattleEnvironment.NoteFor(habit, state); + string where = $"{id} {phase}/{weather}"; + + Assert.AreEqual(InsectHabits.BattleStatMultiplier(habit, state), note.Multiplier, 1e-6f, where); + Assert.AreEqual(note.HasEffect, !string.IsNullOrEmpty(note.Reason), $"효과와 문구가 어긋남: {where}"); + if (!note.HasEffect) continue; + + effects++; + Assert.IsFalse(note.Reason.Contains("%") || note.Reason.Contains("+") || note.Reason.Contains("-"), + $"문구에 기호·퍼센트가 들어갔다(칩이 따로 붙인다): {where} \"{note.Reason}\""); + // 유리한데 "싫어함", 불리한데 "좋아함"이면 이유가 거짓이다. + if (note.Percent > 0) Assert.IsFalse(note.Reason.Contains("싫어함"), $"{where} \"{note.Reason}\""); + else Assert.IsFalse(note.Reason.Contains("좋아함"), $"{where} \"{note.Reason}\""); + if (note.Reason.Length > longest.Length) longest = note.Reason; + } + + TestContext.WriteLine($"[BattleEnvironment] effects={effects}, longest=\"{longest}\" ({longest.Length}자)"); + Assert.Greater(effects, 0); + Assert.LessOrEqual(longest.Length, 20, $"칩 문구가 너무 길다: \"{longest}\""); + } + + // ── 적용 범위(순수) ── + + [Test] + public void Applies_OnlyOrdinaryOutdoorWildBattles() + { + foreach (bool duel in new[] { false, true }) + foreach (bool sandbox in new[] { false, true }) + foreach (bool guardian in new[] { false, true }) + foreach (bool indoor in new[] { false, true }) + { + bool expected = !duel && !sandbox && !guardian && !indoor; + Assert.AreEqual(expected, BattleEnvironment.Applies(duel, sandbox, guardian, indoor), + $"duel={duel} sandbox={sandbox} guardian={guardian} indoor={indoor}"); + } + } + + [Test] + public void IsIndoor_CaveYes_IslandAndFieldNo() + { + Assert.IsFalse(BattleEnvironment.IsIndoor(null), "필드"); + Assert.IsTrue(BattleEnvironment.IsIndoor(new SubAreaData { subAreaId = "cave" }), "동굴"); + Assert.IsFalse(BattleEnvironment.IsIndoor(new SubAreaData { subAreaId = "player_island", detached = true }), "나의 섬"); + } + + [Test] + public void WeatherRegionId_FieldUsesRegion_SubAreaUsesWorldWeather() + { + var frost = new RegionData { regionId = "frostline" }; + Assert.AreEqual("frostline", BattleEnvironment.WeatherRegionId(frost, null)); + Assert.IsNull(BattleEnvironment.WeatherRegionId(null, null), "리전 밖은 세계 날씨"); + // 섬에 있는 동안 CurrentRegion은 마지막 리전에 붙어 있다 — 그걸 읽으면 섬에 설산의 눈이 내린다. + Assert.IsNull(BattleEnvironment.WeatherRegionId(frost, new SubAreaData { detached = true })); + } + + // ── 능력치 보정(InsectBattleStats.ApplyEnvironment) ── + + [Test] + public void ApplyEnvironment_ScalesAttackAndDefense_LeavesHpAlone() + { + InsectData data = Insect(Neutral); + var reference = new InsectBattleStats(data, Level); + var stats = new InsectBattleStats(data, Level); + stats.ApplyDamage(25); + int hp = stats.CurrentHp; + + stats.ApplyEnvironment(1.1f); + + Assert.AreEqual(Scaled(reference.Attack, 1.1f), stats.Attack); + Assert.AreEqual(Scaled(reference.Defense, 1.1f), stats.Defense); + Assert.AreEqual(reference.MaxHp, stats.MaxHp, "최대 HP가 바뀌면 HP바가 튄다"); + Assert.AreEqual(hp, stats.CurrentHp); + Assert.AreEqual(1.1f, stats.EnvironmentMultiplier, 1e-6f); + } + + [Test] + public void ApplyEnvironment_CalledAgain_ReplacesInsteadOfCompounding() + { + InsectData data = Insect(Neutral); + var reference = new InsectBattleStats(data, Level); + var stats = new InsectBattleStats(data, Level); + + stats.ApplyEnvironment(1.1f); + stats.ApplyEnvironment(1.1f); + Assert.AreEqual(Scaled(reference.Attack, 1.1f), stats.Attack, "두 번 불러 1.21배가 됐다"); + + stats.ApplyEnvironment(0.9f); + Assert.AreEqual(Scaled(reference.Attack, 0.9f), stats.Attack); + Assert.AreEqual(Scaled(reference.Defense, 0.9f), stats.Defense); + + stats.ApplyEnvironment(1f); + Assert.AreEqual(reference.Attack, stats.Attack, "1을 걸면 원래 값으로 돌아와야 한다"); + Assert.AreEqual(reference.Defense, stats.Defense); + } + + [Test] + public void ApplyEnvironment_LeavesPerTurnBonusesAlone() + { + var stats = new InsectBattleStats(Insect(Neutral), Level) { AttackBonus = 0.3f, DefenseBonus = 0.2f }; + stats.ApplyEnvironment(0.9f); + Assert.AreEqual(0.3f, stats.AttackBonus, 1e-6f, "의상·아이템·버프 경로는 매 턴 다시 계산된다 — 섞으면 지워진다"); + Assert.AreEqual(0.2f, stats.DefenseBonus, 1e-6f); + } + + [Test] + public void ApplyEnvironment_InvalidMultiplier_IsTreatedAsOne_AndNeverBelowOne() + { + InsectData data = Insect(Neutral); + var reference = new InsectBattleStats(data, Level); + foreach (float bad in new[] { 0f, -1f, float.NaN, float.PositiveInfinity }) + { + var stats = new InsectBattleStats(data, Level); + stats.ApplyEnvironment(bad); + Assert.AreEqual(reference.Attack, stats.Attack, $"multiplier={bad}"); + Assert.AreEqual(reference.Defense, stats.Defense, $"multiplier={bad}"); + } + + InsectData weak = Insect(Neutral, atk: -1, def: -1); // 공격·방어가 바닥(1)인 곤충 + var tiny = new InsectBattleStats(weak, 1); + Assert.AreEqual(1, tiny.Attack); + tiny.ApplyEnvironment(0.9f); + Assert.AreEqual(1, tiny.Attack, "보정이 공격력을 0으로 만들면 안 된다"); + Assert.AreEqual(1, tiny.Defense); + } + + // ── 컨트롤러 ── + + [Test] + public void WildBattle_AtNight_EachSideUsesItsOwnHabit_BeforeTheFirstFrame() + { + Sky sky = NewSky(DayPhase.Night, WeatherType.Clear); + InsectBattleController battle = NewController(sky); + InsectData stag = Insect(NocturnalFogLover); + InsectData hornet = Insect(DiurnalPlain); + var playerRef = new InsectBattleStats(stag, Level); + var enemyRef = new InsectBattleStats(hornet, Level); + + BattleEnvironmentNote seenOnStart = default; + BattleEnvironmentNote seenOnUpdate = default; + int seenAttack = 0; + battle.BattleUpdated += (p, e) => { seenOnUpdate = battle.PlayerEnvironment; }; + battle.StartBattle(stag, Level, Wild(hornet, Level), (p, e) => + { + seenOnStart = battle.PlayerEnvironment; + seenAttack = p.Attack; + }); + + Assert.IsTrue(seenOnStart.HasEffect, "onStarted가 칩보다 먼저 울렸다"); + Assert.IsTrue(seenOnUpdate.HasEffect, "BattleUpdated가 칩보다 먼저 울렸다"); + Assert.AreEqual(Scaled(playerRef.Attack, InsectHabits.FavorableBattleMultiplier), seenAttack, + "첫 프레임의 능력치가 보정 전 값이다"); + + Assert.AreEqual(10, battle.PlayerEnvironment.Percent); + Assert.AreEqual("밤 · 야행성", battle.PlayerEnvironment.Reason); + Assert.AreEqual(-10, battle.EnemyEnvironment.Percent); + Assert.AreEqual("밤 · 주행성", battle.EnemyEnvironment.Reason); + + InsectBattleStats player = PlayerStats(battle); + InsectBattleStats enemy = EnemyStats(battle); + Assert.AreEqual(Scaled(playerRef.Defense, InsectHabits.FavorableBattleMultiplier), player.Defense); + Assert.AreEqual(Scaled(enemyRef.Attack, InsectHabits.UnfavorableBattleMultiplier), enemy.Attack); + Assert.AreEqual(Scaled(enemyRef.Defense, InsectHabits.UnfavorableBattleMultiplier), enemy.Defense); + Assert.AreEqual(playerRef.MaxHp, player.MaxHp, "HP는 보정하지 않는다"); + Assert.AreEqual(player.MaxHp, player.CurrentHp); + Assert.AreEqual(enemyRef.MaxHp, enemy.MaxHp); + } + + [Test] + public void WildBattle_NeutralInsects_GetNoNote() + { + Sky sky = NewSky(DayPhase.Night, WeatherType.Fog); + InsectBattleController battle = NewController(sky); + InsectData neutral = Insect(Neutral); + var reference = new InsectBattleStats(neutral, Level); + + battle.StartBattle(neutral, Level, Wild(Insect(Neutral), Level)); + + Assert.IsFalse(battle.PlayerEnvironment.HasEffect); + Assert.IsFalse(battle.EnemyEnvironment.HasEffect); + Assert.AreEqual(string.Empty, battle.PlayerEnvironment.Reason); + Assert.AreEqual(reference.Attack, PlayerStats(battle).Attack); + } + + [Test] + public void Duel_IsNeverAdjusted() + { + Sky sky = NewSky(DayPhase.Night, WeatherType.Fog); + InsectBattleController battle = NewController(sky); + InsectData stag = Insect(NocturnalFogLover); + var reference = new InsectBattleStats(stag, Level); + + Assert.IsTrue(battle.StartDuel(stag, Level, Insect(DiurnalPlain), Level)); + + AssertNoEnvironment(battle, reference); + } + + [Test] + public void Sandbox_IsNeverAdjusted() + { + Sky sky = NewSky(DayPhase.Night, WeatherType.Fog); + InsectBattleController battle = NewController(sky); + InsectData stag = Insect(NocturnalFogLover); + var reference = new InsectBattleStats(stag, Level); + + Assert.IsTrue(battle.StartSandbox(stag, Level, Insect(DiurnalPlain), Level, null, null)); + + AssertNoEnvironment(battle, reference); + } + + [Test] + public void Guardian_IsNeverAdjusted() + { + Sky sky = NewSky(DayPhase.Night, WeatherType.Fog); + InsectBattleController battle = NewController(sky); + InsectData stag = Insect(NocturnalFogLover); + var reference = new InsectBattleStats(stag, Level); + + battle.StartBattle(stag, Level, Wild(Insect(DiurnalPlain), Level, guardianRegion: "meadow")); + + Assert.AreEqual("meadow", battle.EnemyGuardianRegionId); + AssertNoEnvironment(battle, reference); + } + + [Test] + public void IndoorSubArea_IsNeverAdjusted() + { + Sky sky = NewSky(DayPhase.Night, WeatherType.Fog); + SetField(sky.Regions, "currentSubArea", new SubAreaData { subAreaId = "cave" }); + InsectBattleController battle = NewController(sky); + InsectData stag = Insect(NocturnalFogLover); + var reference = new InsectBattleStats(stag, Level); + + battle.StartBattle(stag, Level, Wild(Insect(DiurnalPlain), Level)); + + AssertNoEnvironment(battle, reference); + } + + [Test] + public void FieldBattle_ReadsTheRegionsVisibleWeather() + { + // 세계는 비지만 설산에서는 눈이다 — 눈을 좋아하는 종이 힘을 얻는다(인자 없는 GetWorldState였다면 "비"로 판정). + Sky sky = NewSky(DayPhase.Day, WeatherType.Rain); + SetField(sky.Regions, "currentRegion", new RegionData { regionId = "frostline" }); + InsectBattleController battle = NewController(sky); + InsectData rime = Insect(SnowLover); + + battle.StartBattle(rime, Level, Wild(Insect(Neutral), Level)); + + Assert.AreEqual(10, battle.PlayerEnvironment.Percent); + Assert.AreEqual("눈 · 눈을 좋아함", battle.PlayerEnvironment.Reason); + } + + [Test] + public void Island_IsAdjusted_WithTheWorldWeather_NotTheLastRegions() + { + Sky sky = NewSky(DayPhase.Night, WeatherType.Rain); + SetField(sky.Regions, "currentRegion", new RegionData { regionId = "frostline" }); // 섬에서도 붙어 있는 마지막 리전 + SetField(sky.Regions, "currentSubArea", new SubAreaData { subAreaId = "player_island", detached = true }); + InsectBattleController battle = NewController(sky); + InsectData stag = Insect(NocturnalFogLover); + + battle.StartBattle(stag, Level, Wild(Insect(SnowLover), Level)); + + Assert.AreEqual("밤 · 야행성", battle.PlayerEnvironment.Reason, "섬은 하늘 아래다 — 보정이 걸린다"); + Assert.IsFalse(battle.EnemyEnvironment.HasEffect, "섬에 설산의 눈이 내렸다(세계 날씨는 비)"); + } + + [Test] + public void NextBattle_DoesNotInheritThePreviousAdjustment() + { + Sky sky = NewSky(DayPhase.Night, WeatherType.Clear); + InsectBattleController battle = NewController(sky); + InsectData stag = Insect(NocturnalFogLover); + var reference = new InsectBattleStats(stag, Level); + + battle.StartBattle(stag, Level, Wild(Insect(DiurnalPlain), Level)); + Assert.IsTrue(battle.PlayerEnvironment.HasEffect); + + Assert.IsTrue(battle.StartDuel(stag, Level, Insect(DiurnalPlain), Level)); + AssertNoEnvironment(battle, reference); + + battle.StartBattle(stag, Level, Wild(Insect(DiurnalPlain), Level)); + Assert.IsTrue(battle.StartSandbox(stag, Level, Insect(DiurnalPlain), Level, null, null)); + AssertNoEnvironment(battle, reference); + + // 낮으로 바꾸고 다시 야생 — 이전 +10%에 겹치지 않고 새 하늘의 −10%만 걸린다. + sky.Set(DayPhase.Day, WeatherType.Clear); + battle.StartBattle(stag, Level, Wild(Insect(DiurnalPlain), Level)); + Assert.AreEqual(-10, battle.PlayerEnvironment.Percent); + Assert.AreEqual("낮 · 야행성", battle.PlayerEnvironment.Reason); + Assert.AreEqual(Scaled(reference.Attack, InsectHabits.UnfavorableBattleMultiplier), PlayerStats(battle).Attack); + } + + [Test] + public void Swap_AdjustsTheIncomingInsect_UnderTheBattlesStartingSky() + { + Sky sky = NewSky(DayPhase.Night, WeatherType.Clear); + InsectBattleController battle = NewController(sky); + InsectData stag = Insect(NocturnalFogLover); + InsectData hornet = Insect(DiurnalPlain); + var hornetRef = new InsectBattleStats(hornet, Level); + + battle.StartBattle(stag, Level, Wild(Insect(Neutral), Level)); + Assert.AreEqual("밤 · 야행성", battle.PlayerEnvironment.Reason); + + // 전투 도중 해가 떠도 이번 전투는 시작할 때의 하늘(밤)로 끝까지 잰다 — 상대 표지와 같은 하늘이다. + sky.Set(DayPhase.Day, WeatherType.Clear); + BattleEnvironmentNote seenOnUpdate = default; + battle.BattleUpdated += (p, e) => seenOnUpdate = battle.PlayerEnvironment; + + battle.SwapPlayerInsect(hornet, Level); + + Assert.AreEqual(-10, battle.PlayerEnvironment.Percent); + Assert.AreEqual("밤 · 주행성", battle.PlayerEnvironment.Reason); + Assert.AreEqual("밤 · 주행성", seenOnUpdate.Reason, "교체의 BattleUpdated가 옛 곤충의 칩을 들고 울렸다"); + InsectBattleStats player = PlayerStats(battle); + Assert.AreEqual(Scaled(hornetRef.Attack, InsectHabits.UnfavorableBattleMultiplier), player.Attack); + Assert.AreEqual(hornetRef.MaxHp, player.MaxHp); + } + + [Test] + public void Swap_InADuel_StaysUnadjusted() + { + Sky sky = NewSky(DayPhase.Night, WeatherType.Clear); + InsectBattleController battle = NewController(sky); + InsectData stag = Insect(NocturnalFogLover); + var reference = new InsectBattleStats(stag, Level); + + Assert.IsTrue(battle.StartDuel(Insect(Neutral), Level, Insect(Neutral), Level)); + battle.SwapPlayerInsect(stag, Level); + + AssertNoEnvironment(battle, reference); + } + + [Test] + public void NocturnalPlayer_HitsHarderAtNight_ThanByDay() + { + int Damage(DayPhase phase) + { + Sky sky = NewSky(phase, WeatherType.Clear); + InsectBattleController battle = NewController(sky); + battle.SetRandomSeed(7); + // 상대 방어를 높여 공격/방어 비율이 밤·낮 모두 clamp(0.7~1.5) 안에 들게 한다 — 상한에 붙으면 차이가 묻힌다. + battle.StartBattle(Insect(NocturnalFogLover), Level, Wild(Insect(Neutral, def: 60), Level)); + InsectBattleStats enemy = EnemyStats(battle); + int before = enemy.CurrentHp; + battle.UseBasicAttack(); + return before - battle.EnemyHpAfterPlayerAction; + } + + int night = Damage(DayPhase.Night); + int day = Damage(DayPhase.Day); + TestContext.WriteLine($"[BattleEnvironment] basic attack night={night} day={day}"); + Assert.Greater(night, day); + } + + // ── 도주 규칙 ── + + [Test] + public void EscapeChance_SameLevel_IsHalf() + { + Assert.AreEqual(0.5f, BattleEscapeRules.Chance(15, 15), 1e-6f); + } + + [Test] + public void EscapeChance_HigherPlayerEscapesMore_AndIsClamped() + { + Assert.AreEqual(0.7f, BattleEscapeRules.Chance(14, 10), 1e-5f, "내가 4 높으면 +20%"); + Assert.AreEqual(0.3f, BattleEscapeRules.Chance(10, 14), 1e-5f, "내가 4 낮으면 −20%"); + Assert.AreEqual(0.9f, BattleEscapeRules.Chance(18, 10), 1e-5f, "+8은 정확히 상한"); + Assert.AreEqual(0.9f, BattleEscapeRules.Chance(60, 1), 1e-6f, "상한 90%"); + Assert.AreEqual(0.1f, BattleEscapeRules.Chance(2, 10), 1e-5f, "−8은 정확히 하한"); + Assert.AreEqual(0.1f, BattleEscapeRules.Chance(1, 60), 1e-6f, "하한 10%"); + } + + [Test] + public void EscapeChance_MatchesTheFormerInlineFormulaExactly() + { + for (int player = 1; player <= 60; player++) + for (int enemy = 1; enemy <= 60; enemy++) + { + float former = Mathf.Clamp(0.5f + (player - enemy) * 0.05f, 0.1f, 0.9f); + Assert.AreEqual(former, BattleEscapeRules.Chance(player, enemy), $"player={player} enemy={enemy}"); + } + } + + [Test] + public void TryEscape_UsesTheEscapeRule() + { + // 첫 난수가 0.5~0.8 사이인 시드 — 같은 레벨(50%)이면 실패하고, 6 높으면(80%) 성공해야 한다. + int seed = 0; + for (; seed < 5000; seed++) + { + float roll = new BattleRandomSource(seed).Next01(); + if (roll >= 0.5f && roll < 0.8f) break; + } + Assert.Less(seed, 5000); + + InsectData data = Insect(Neutral); + InsectBattleController even = NewController(null); + Assert.IsTrue(even.StartDuel(data, 15, data, 15)); + even.SetRandomSeed(seed); + Assert.IsFalse(even.TryEscape(), "같은 레벨인데 도망쳤다"); + + InsectBattleController higher = NewController(null); + Assert.IsTrue(higher.StartDuel(data, 21, data, 15)); + higher.SetRandomSeed(seed); + Assert.IsTrue(higher.TryEscape(), "6 높은데 못 도망쳤다 — 레벨 차의 부호가 뒤집혔다"); + } + + // ── 헬퍼 ── + + private static WorldState State(DayPhase phase, WeatherType weather) + { + return new WorldState { DayPhase = phase, Weather = weather, Hour24 = HourOf(phase) }; + } + + private static int HourOf(DayPhase phase) + { + switch (phase) + { + case DayPhase.Morning: return 8; + case DayPhase.Day: return 12; + case DayPhase.Evening: return 19; + default: return 0; + } + } + + private static int Scaled(int value, float multiplier) + { + return Mathf.Max(1, Mathf.RoundToInt(value * multiplier)); + } + + private void AssertNoEnvironment(InsectBattleController battle, InsectBattleStats reference) + { + Assert.IsFalse(battle.PlayerEnvironment.HasEffect, $"내 곤충 칩: \"{battle.PlayerEnvironment.Reason}\""); + Assert.IsFalse(battle.EnemyEnvironment.HasEffect, $"상대 칩: \"{battle.EnemyEnvironment.Reason}\""); + Assert.AreEqual(string.Empty, battle.PlayerEnvironment.Reason); + Assert.AreEqual(string.Empty, battle.EnemyEnvironment.Reason); + Assert.AreEqual(reference.Attack, PlayerStats(battle).Attack, "보정 없는 전투인데 공격력이 바뀌었다"); + Assert.AreEqual(reference.Defense, PlayerStats(battle).Defense); + } + + private T Track(T obj) where T : Object + { + objects.Add(obj); + return obj; + } + + private InsectData Insect(string id, int atk = 40, int def = 30) + { + InsectData data = Track(ScriptableObject.CreateInstance()); + data.insectId = id; + data.displayName = id; + data.baseHp = 4000; // 몇 번 주고받아도 아무도 쓰러지지 않게(쓰러지면 보상·디스폰 경로가 돈다) + data.baseAtk = atk; + data.baseDef = def; + data.primaryType = InsectElement.None; + data.secondaryType = InsectElement.None; + InsectSkill skill = Track(ScriptableObject.CreateInstance()); + skill.effectType = SkillEffectType.Damage; + skill.element = InsectElement.None; + skill.power = 20; + skill.accuracy = 1f; + skill.cooldownTurns = 1; + data.skills = new[] { skill }; + return data; + } + + private InsectEntity Wild(InsectData data, int level, string guardianRegion = null) + { + InsectEntity entity = Track(new GameObject("EnvironmentWildFixture")).AddComponent(); + SetField(entity, "data", data); + SetField(entity, "level", level); + if (guardianRegion != null) SetField(entity, "guardianRegionId", guardianRegion); + return entity; + } + + private InsectBattleController NewController(Sky sky) + { + InsectBattleController controller = + Track(new GameObject("EnvironmentBattleFixture")).AddComponent(); + controller.SetCritSource(null); // 능력치 보정만 재도록 치명타는 끈다 + if (sky != null) controller.AutoWire(sky.Provider, sky.Regions); + return controller; + } + + private Sky NewSky(DayPhase phase, WeatherType weather) + { + GameObject go = Track(new GameObject("EnvironmentSkyFixture")); + var sky = new Sky + { + Clock = go.AddComponent(), + Weather = go.AddComponent(), + Provider = go.AddComponent(), + Regions = go.AddComponent() + }; + sky.Provider.AutoWire(sky.Clock, sky.Weather); + sky.Set(phase, weather); + return sky; + } + + private sealed class Sky + { + public GameClock Clock; + public WeatherSystem Weather; + public WorldStateProvider Provider; + public RegionManager Regions; + + public void Set(DayPhase phase, WeatherType weather) + { + Clock.SetTime01(HourOf(phase) / 24f, hold: true); + Weather.SetWeather(weather, hold: true, instant: true); + } + } + + private static InsectBattleStats PlayerStats(InsectBattleController battle) + { + return (InsectBattleStats)typeof(InsectBattleController).GetField("playerStats", Inst).GetValue(battle); + } + + private static InsectBattleStats EnemyStats(InsectBattleController battle) + { + return (InsectBattleStats)typeof(InsectBattleController).GetField("enemyStats", Inst).GetValue(battle); + } + + private static void SetField(object target, string name, object value) + { + FieldInfo field = target.GetType().GetField(name, Inst); + Assert.IsNotNull(field, $"{target.GetType().Name}.{name} 필드가 없다 — 테스트가 낡았다"); + field.SetValue(target, value); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/BattleEnvironmentTests.cs.meta b/Assets/Tests/EditMode/BattleEnvironmentTests.cs.meta new file mode 100644 index 0000000..3ca6504 --- /dev/null +++ b/Assets/Tests/EditMode/BattleEnvironmentTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8ca2d953971e19443961c5fc53b330d7 \ No newline at end of file diff --git a/Assets/Tests/EditMode/BattleFeelHudTests.cs b/Assets/Tests/EditMode/BattleFeelHudTests.cs new file mode 100644 index 0000000..d4b9a3c --- /dev/null +++ b/Assets/Tests/EditMode/BattleFeelHudTests.cs @@ -0,0 +1,597 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.IO; +using InsectGame.Battle; +using InsectGame.NPC; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 4단계 「전투 체감」의 화면 — 진입 문구·화면 쓸기, 상성(타격 순간 표시·스킬 카드 칩), 전용기 컷인 띠, 승리 화면(보상 줄 시각·「눌러서 계속」), + /// 레이드 팀 줄(내려갈 땐 곧바로·수문장 등장/변신/결과엔 숨김). + /// + /// 자리·문구·시각은 전부 화면 파일의 순수 함수(··· + /// ····)를 + /// 부른다 — 식을 여기 다시 세우지 않는다. IMGUI 그리기 자체는 QA 빌드(-battleScenario duel·victory·signature·raid·guardian-intro·raid-forms)로 본다. + /// 화면은 가로 1280×720·세로 720×1280을 기준으로(가상 1920×1080·1080×1920) 노치·4:3·20:9까지 잰다. + /// + [TestFixture] + public class BattleFeelHudTests + { + private static readonly object[] Screens = + { + new object[] { 1280f, 720f, 0f, 0f, false }, + new object[] { 720f, 1280f, 0f, 0f, true }, + new object[] { 1920f, 1080f, 0f, 0f, false }, + new object[] { 1080f, 1920f, 0f, 0f, true }, + new object[] { 1080f, 2400f, 110f, 60f, true }, + new object[] { 2400f, 1080f, 0f, 0f, true }, + new object[] { 1440f, 1080f, 0f, 0f, false }, + }; + + // ── 진입 문구 ── + + [Test] + public void EntryText_EveryKind_OneShortLine_WithTheRightParticle() + { + Assert.AreEqual("야생 사마귀가 나타났다!", BattleEntryText.For(BattleKind.Wild, "사마귀", null, null)); + Assert.AreEqual("야생 장수풍뎅이가 나타났다!", BattleEntryText.For(BattleKind.Wild, "장수풍뎅이", null, null)); + Assert.AreEqual("야생 왕사슴벌레가 나타났다!", BattleEntryText.For(BattleKind.Wild, "왕사슴벌레", null, null)); + Assert.AreEqual("야생 모래 지네가 나타났다!", BattleEntryText.For(BattleKind.Wild, "모래 지네", null, null)); + Assert.AreEqual("야생 쇠똥구리 왕이 나타났다!", BattleEntryText.For(BattleKind.Wild, "쇠똥구리 왕", null, null)); + Assert.AreEqual("야생 Atlas이(가) 나타났다!", BattleEntryText.For(BattleKind.Wild, "Atlas", null, null), "모르면 둘 다 적는다"); + Assert.AreEqual("초원의 수문장 사마귀!", BattleEntryText.For(BattleKind.Guardian, "사마귀", null, "초원")); + Assert.AreEqual("수문장 사마귀!", BattleEntryText.For(BattleKind.Guardian, "사마귀", null, null), "리전을 모르면 리전 없이"); + Assert.AreEqual("집게가 승부를 걸어왔다!", BattleEntryText.For(BattleKind.BossDuel, "지네", "집게", null)); + Assert.AreEqual("관장 하월이 승부를 걸어왔다!", BattleEntryText.For(BattleKind.BossDuel, "나방", "관장 하월", null)); + Assert.AreEqual("상대가 승부를 걸어왔다!", BattleEntryText.For(BattleKind.BossDuel, "나방", null, null)); + Assert.AreEqual("라온이 승부를 걸어왔다!", BattleEntryText.For(BattleKind.RivalDuel, "사슴벌레", null, null)); + Assert.AreEqual("라온이 승부를 걸어왔다!", BattleEntryText.For(BattleKind.RivalDuel, "사슴벌레", "라온", null)); + Assert.AreEqual("곤충잡이 아이가 승부를 걸어왔다!", BattleEntryText.For(BattleKind.KidDuel, "무당벌레", null, null)); + Assert.IsNull(BattleEntryText.For(BattleKind.Sandbox, "비천룡", null, null), "꿈 챔피언전은 꿈이 자기 카드를 띄운다"); + Assert.AreEqual("야생 곤충이 나타났다!", BattleEntryText.For(BattleKind.Wild, null, null, null)); + Assert.AreEqual("레이드 보스 왕사슴벌레 출현!", BattleEntryText.Raid("왕사슴벌레")); + Assert.AreEqual("레이드 보스 곤충 출현!", BattleEntryText.Raid("")); + } + + [Test] + public void EntryText_RealDuelOpponents_GetAKnownParticle() + { + int checkedCount = 0; + foreach (string id in DuelBanter.Ids) + { + Assert.IsTrue(DuelBanter.TryGet(id, out DuelBanter.Lines lines), id); + bool rival = NpcRivalDuels.TryGetStage(id, out _); + BattleKind kind = rival ? BattleKind.RivalDuel : BattleKind.BossDuel; + string text = BattleEntryText.For(kind, "곤충", lines.name, null); + StringAssert.EndsWith("승부를 걸어왔다!", text, id); + Assert.IsFalse(text.Contains("("), $"{id}: 「{lines.name}」의 조사를 모른다 — {text}"); + if (rival) Assert.AreEqual("라온이 승부를 걸어왔다!", text, id); + checkedCount++; + } + Assert.Greater(checkedCount, 5, "한마디 표가 비었다"); + } + + // ── 진입 시각표 ── + + [Test] + public void EntryWipe_StartsCovered_OpensByItsEnd_AndOnlyMovesOneWay() + { + Assert.AreEqual(0f, BattleEntryStaging.WipeOpen(0f), 1e-5f); + Assert.AreEqual(1f, BattleEntryStaging.WipeOpen(BattleEntryStaging.WipeEnd), 1e-5f); + Assert.AreEqual(1f, BattleEntryStaging.WipeOpen(1f), 1e-5f); + float last = -1f; + for (float p = 0f; p <= 1f; p += 0.02f) + { + float open = BattleEntryStaging.WipeOpen(p); + Assert.GreaterOrEqual(open, last - 1e-6f, "막이 되돌아온다"); + last = open; + } + // 쓸기는 아레나 진입 샷(1초) 안에 끝난다 — 샷이 구도에 내려앉는 걸 막이 가리지 않게. + Assert.Less(BattleEntryStaging.WipeEnd * BattleReadPacing.EntryIntroSeconds, BattleFlourish.OpeningShotSeconds); + } + + [TestCaseSource(nameof(Screens))] + public void EntryWipe_EdgeGeometry_CoversThenClearsTheWholeScreen(float width, float height, float notchTop, float gestureBottom, bool mobile) + { + HudFrame f = HudFrame.ForScreen(width, height, 0f, 0f, notchTop, gestureBottom, mobile); + float closed = BattleEntryStaging.WipeEdgeX(0f, f.Width, f.Height); + // 열림 0 — 모서리(와 앞세운 띠)가 화면 맨 위에서도 왼쪽 밖이라 화면 전체가 막이다. + Assert.LessOrEqual(BattleEntryStaging.EdgeXAt(closed, 0f, f.Height) + BattleEntryStaging.StripeWidth, 0f + 0.01f); + Assert.LessOrEqual(BattleEntryStaging.EdgeXAt(closed, f.Height, f.Height), BattleEntryStaging.EdgeXAt(closed, 0f, f.Height), + "모서리는 위가 오른쪽으로 기운다"); + float open = BattleEntryStaging.WipeEdgeX(1f, f.Width, f.Height); + // 열림 1 — 모서리의 가장 왼쪽(화면 맨 아래)도 화면 오른쪽 밖이다. + Assert.GreaterOrEqual(BattleEntryStaging.EdgeXAt(open, f.Height, f.Height), f.Width - 0.01f); + } + + [Test] + public void EntryTitle_ShowsInsideTheEntry_LeavesBeforeTheCutIn_OrHoldsToTheIntroEnd() + { + float introPlain = BattleReadPacing.IntroSeconds(0f, 2f); + Assert.AreEqual(0f, BattleEntryStaging.TitleAlpha(0.1f, 0.14f, introPlain, false), "쓸기가 한창일 때는 아직"); + float full = BattleEntryStaging.TitleIn + BattleEntryStaging.TitleFade; + Assert.AreEqual(1f, BattleEntryStaging.TitleAlpha(full, full * 1.4f, introPlain, false), 1e-4f); + // 컷인 전투 — 진입 구간 끝(=컷인 시작)에는 사라졌다. 그 전에 다 보이는 순간이 있다. + Assert.AreEqual(0f, BattleEntryStaging.TitleAlpha(1f, 1.4f, 4.6f, true), 1e-4f, "진입 문구가 컷인과 겹친다"); + Assert.Less(full, 1f - BattleEntryStaging.TitleFade, "컷인 전투에서 문구가 다 보이는 순간이 없다"); + Assert.AreEqual(1f, BattleEntryStaging.TitleAlpha(0.6f, 0.84f, 4.6f, true), 1e-4f); + // 평범한 인트로 — 진입 구간 뒤에도 남았다가 인트로 끝에 사라진다. + Assert.AreEqual(1f, BattleEntryStaging.TitleAlpha(1f, 1.6f, introPlain, false), 1e-4f); + Assert.AreEqual(0f, BattleEntryStaging.TitleAlpha(1f, introPlain, introPlain, false), 1e-4f); + } + + [Test] + public void EntryTitlePop_StartsSmall_OvershootsALittle_SettlesToOne() + { + Assert.AreEqual(BattleEntryStaging.PopStart, BattleEntryStaging.Pop(0f, false), 1e-4f); + float max = 0f; + for (float s = 0f; s < 1f; s += 0.01f) max = Mathf.Max(max, BattleEntryStaging.Pop(s, false)); + Assert.LessOrEqual(max, BattleEntryStaging.PopPeak + 1e-4f); + Assert.Greater(max, 1f, "튀어나오지 않는다"); + Assert.AreEqual(1f, BattleEntryStaging.Pop(BattleEntryStaging.PopSettleAt, false), 1e-4f); + Assert.AreEqual(1f, BattleEntryStaging.Pop(0f, true), 1e-4f, "줄인 움직임이면 크기를 바꾸지 않는다"); + // 다 자리 잡는 시각이 진입 구간 안이다(1.4초 장면에서 「나타났다!」가 멈춰 읽힌다). + Assert.Less(BattleEntryStaging.TitleIn * BattleReadPacing.EntryIntroSeconds + BattleEntryStaging.PopSettleAt, + BattleReadPacing.EntryIntroSeconds); + } + + // ── 진입 자리 ── + + [TestCaseSource(nameof(Screens))] + public void DuelEntryTitle_UpperCenter_ClearsTheHpCardsAndTheirStack(float width, float height, float notchTop, float gestureBottom, bool mobile) + { + HudFrame f = HudFrame.ForScreen(width, height, 0f, 0f, notchTop, gestureBottom, mobile); + Rect safe = DuelHudLayout.Safe(f); + Rect title = BattleEntryLayout.DuelTitle(f); + AssertContained(safe, title, $"{width}x{height} 진입 문구"); + Assert.GreaterOrEqual(title.height - 12f, RaidStageLayout.LineHeight(BattleEntryLayout.TitleFont), "큰 글자 한 줄이 안 들어간다"); + Assert.Less(title.center.y, f.Height * 0.4f, "위쪽 가운데가 아니다 — 아레나 진입 샷(가운데)을 가린다"); + Assert.AreEqual(f.Width * 0.5f, title.center.x, 1f, "가운데가 아니다"); + foreach (KeyValuePair p in IntroPieces(f)) + Assert.IsFalse(title.Overlaps(p.Value), $"{width}x{height}: 진입 문구 {title} ↔ {p.Key} {p.Value}"); + // 튀어나올 때(최대 배율) 커진 판도 HP 카드를 덮지 않는다 — 가로 화면의 카드 사이 자리는 카드 높이 안에 있다. + Rect grown = Grow(title, BattleEntryStaging.PopPeak); + Rect player = DuelHudLayout.HpCard(safe, true), enemy = DuelHudLayout.HpCard(safe, false); + Assert.IsFalse(grown.Overlaps(player) || grown.Overlaps(enemy), $"{width}x{height}: 튀어나온 진입 문구가 HP 카드에 닿는다"); + Assert.GreaterOrEqual(grown.x, safe.x - 0.01f, $"{width}x{height}: 튀어나온 진입 문구가 안전 영역 왼쪽 밖"); + Assert.LessOrEqual(grown.xMax, safe.xMax + 0.01f, $"{width}x{height}: 튀어나온 진입 문구가 안전 영역 오른쪽 밖"); + } + + [TestCaseSource(nameof(Screens))] + public void RaidEntryTitle_OnTheLowerStage_ClearsTheBossAndTheTeam(float width, float height, float notchTop, float gestureBottom, bool mobile) + { + HudFrame f = HudFrame.ForScreen(width, height, 0f, 0f, notchTop, gestureBottom, mobile); + Rect title = BattleEntryLayout.RaidTitle(f); + AssertContained(RaidStageLayout.LowerStage(f), title, $"{width}x{height} 레이드 진입 문구"); + Assert.Greater(title.y, f.Height * 0.5f, "위쪽(보스 자리)에 섰다 — 옛 RAID BOSS(30%)가 보스와 겹쳤다"); + Assert.GreaterOrEqual(title.height, RaidStageLayout.LineHeight(BattleEntryLayout.RaidTitleFont) + + RaidStageLayout.LineHeight(BattleEntryLayout.RaidSubFont) + 20f, "두 줄이 안 들어간다"); + Rect grown = Grow(title, BattleEntryStaging.PopPeak); + foreach (KeyValuePair p in RaidPieces(f)) + Assert.IsFalse(grown.Overlaps(p.Value), $"{width}x{height}: 레이드 진입 문구 ↔ {p.Key} {p.Value}"); + Assert.GreaterOrEqual(grown.x, f.ContentLeft - 0.01f, $"{width}x{height}: 튀어나온 레이드 진입 문구가 안전 영역 밖"); + Assert.LessOrEqual(grown.xMax, f.ContentRight + 0.01f, $"{width}x{height}: 튀어나온 레이드 진입 문구가 안전 영역 밖"); + } + + // ── 상성 ── + + [Test] + public void Matchup_Words_Arrows_AndColors_PerGrade() + { + Assert.AreEqual("아주 잘 통했다!", MatchupHud.ImpactText(Matchup.Super)); + Assert.AreEqual("잘 통했다!", MatchupHud.ImpactText(Matchup.Good)); + Assert.AreEqual("별로 안 통했다…", MatchupHud.ImpactText(Matchup.Weak)); + Assert.AreEqual("거의 안 통했다…", MatchupHud.ImpactText(Matchup.Resisted)); + Assert.IsNull(MatchupHud.ImpactText(Matchup.Neutral), "보통이면 아무것도 띄우지 않는다"); + Assert.AreEqual("아주 잘 통해요", MatchupHud.ChipText(Matchup.Super)); + Assert.AreEqual("잘 통해요", MatchupHud.ChipText(Matchup.Good)); + Assert.AreEqual("안 통해요", MatchupHud.ChipText(Matchup.Weak)); + Assert.AreEqual("거의 안 통해요", MatchupHud.ChipText(Matchup.Resisted)); + Assert.IsNull(MatchupHud.ChipText(Matchup.Neutral)); + + Assert.AreEqual(2, MatchupHud.ArrowCount(Matchup.Super)); + Assert.AreEqual(1, MatchupHud.ArrowCount(Matchup.Good)); + Assert.AreEqual(1, MatchupHud.ArrowCount(Matchup.Weak)); + Assert.AreEqual(2, MatchupHud.ArrowCount(Matchup.Resisted)); + Assert.AreEqual(0, MatchupHud.ArrowCount(Matchup.Neutral)); + Assert.IsTrue(MatchupHud.PointsUp(Matchup.Super) && MatchupHud.PointsUp(Matchup.Good)); + Assert.IsFalse(MatchupHud.PointsUp(Matchup.Weak) || MatchupHud.PointsUp(Matchup.Resisted)); + + Assert.AreEqual(MatchupTone.Good, MatchupHud.Tone(Matchup.Super), "아주 잘 — 민트"); + Assert.AreEqual(MatchupTone.Good, MatchupHud.Tone(Matchup.Good), "잘 — 민트"); + Assert.AreEqual(MatchupTone.Bad, MatchupHud.Tone(Matchup.Weak), "안 — 코랄"); + Assert.AreEqual(MatchupTone.Faint, MatchupHud.Tone(Matchup.Resisted), "거의 안 — 회색"); + Assert.AreEqual(MatchupTone.None, MatchupHud.Tone(Matchup.Neutral)); + } + + [Test] + public void Matchup_EveryChartPair_ShowsExactlyWhenTheGradeIsNotNeutral() + { + foreach (InsectGame.Data.InsectElement attack in System.Enum.GetValues(typeof(InsectGame.Data.InsectElement))) + foreach (InsectGame.Data.InsectElement primary in System.Enum.GetValues(typeof(InsectGame.Data.InsectElement))) + foreach (InsectGame.Data.InsectElement secondary in System.Enum.GetValues(typeof(InsectGame.Data.InsectElement))) + { + float e = InsectGame.Data.InsectTypeChart.GetEffectiveness(attack, primary, secondary); + Matchup m = ElementMatchup.Describe(e); + bool shows = m != Matchup.Neutral; + Assert.AreEqual(shows, MatchupHud.ImpactText(m) != null, $"{attack}→{primary}/{secondary} ×{e}"); + Assert.AreEqual(shows, MatchupHud.ChipText(m) != null, $"{attack}→{primary}/{secondary} ×{e}"); + Assert.AreEqual(shows, MatchupHud.ArrowCount(m) > 0); + if (shows) Assert.AreEqual(e > 1f, MatchupHud.PointsUp(m), $"{attack}→{primary}/{secondary} ×{e} 화살표 방향"); + } + } + + [TestCase(38, false)] + [TestCase(64, true)] + [TestCase(96, true)] + [TestCase(48, false)] + public void MatchupCaption_SitsAboveTheNumber_AndAboveTheCritLine(int numberFont, bool crit) + { + const float numberCenterY = 500f; + float cy = MatchupHud.CaptionCenterY(numberCenterY, numberFont, crit); + float captionBottom = cy + MatchupHud.CaptionHeight * 0.5f; + float numberTop = numberCenterY - numberFont * 0.55f; + Assert.LessOrEqual(captionBottom, numberTop + 0.01f, "숫자를 덮는다"); + if (crit) + { + // 「치명타!」 줄 — 1대1 BattleScreenUI.CritCaptionCenterY(가운데 = 윗변 − 22, 높이 40)와 같은 어림. + float critTop = numberTop - 22f - 20f; + Assert.LessOrEqual(captionBottom, critTop + 0.01f, "「치명타!」를 덮는다"); + } + Assert.AreEqual(cy, MatchupHud.CaptionCenterAbove(numberTop - (crit ? MatchupHud.CritRowHeight : 0f)), 1e-3f); + } + + [Test] + public void MatchupCaption_PopsThenSettles_AndStaysInsideTheSafeArea() + { + Assert.AreEqual(MatchupHud.PopPeak, MatchupHud.CaptionScale(0f, false), 1e-4f); + Assert.AreEqual(1f, MatchupHud.CaptionScale(MatchupHud.PopSeconds, false), 1e-4f); + Assert.AreEqual(1f, MatchupHud.CaptionScale(0f, true), 1e-4f); + Assert.Greater(MatchupHud.CaptionWidth(Matchup.Super, 200f), MatchupHud.CaptionWidth(Matchup.Good, 200f), "화살표 둘이 하나보다 넓다"); + + HudFrame f = HudFrame.ForScreen(720f, 1280f, 0f, 0f, 0f, 0f, true); + float w = MatchupHud.CaptionWidth(Matchup.Resisted, 260f); + float left = MatchupHud.ClampCenterX(0f, w * 0.5f, f) - w * 0.5f; + float right = MatchupHud.ClampCenterX(f.Width, w * 0.5f, f) + w * 0.5f; + Assert.GreaterOrEqual(left, f.ContentLeft - 0.01f, "화면 왼쪽 끝 곤충 위의 표시가 밖으로 나간다"); + Assert.LessOrEqual(right, f.ContentRight + 0.01f, "화면 오른쪽 끝 곤충 위의 표시가 밖으로 나간다"); + Assert.AreEqual(500f, MatchupHud.ClampCenterX(500f, w * 0.5f, f), 1e-3f, "가운데 근처는 그대로"); + } + + [TestCase(1280f, 720f, false)] + [TestCase(720f, 1280f, true)] + [TestCase(2400f, 1080f, true)] + public void SkillCardChip_FitsUnderTheLastRow_InsideTheCard(float width, float height, bool mobile) + { + HudFrame f = HudFrame.ForScreen(width, height, 0f, 0f, 0f, 0f, mobile); + Rect deck = DuelHudLayout.SkillDeck(f); + for (int count = 1; count <= 4; count++) + for (int i = 0; i < count; i++) + { + Rect card = DuelHudLayout.SkillCard(deck, f.Portrait, i, count); + // BattleScreenUI.DrawSkillPanel과 같은 줄: 재사용 줄(y+162, 28) 아래 y+192. + Rect row = new Rect(card.x + 14f, card.y + 192f, card.width - 28f, MatchupHud.ChipHeight); + Assert.GreaterOrEqual(row.y, card.y + 162f + 28f, "재사용 줄과 겹친다"); + Assert.LessOrEqual(row.yMax, card.yMax - 2f, "카드 아래 테두리를 밟는다"); + float chip = MatchupHud.ChipWidth(Matchup.Resisted, 150f, row.width); + Assert.LessOrEqual(chip, row.width + 0.01f); + } + Assert.GreaterOrEqual(MatchupHud.ChipHeight, MatchupHud.ChipFont * 1.35f, "칩 글자가 잘린다"); + } + + // ── 전용기 컷인 띠 ── + + [TestCaseSource(nameof(Screens))] + public void SignatureBand_AcrossTheScreen_ClearsEveryBattleHudPiece(float width, float height, float notchTop, float gestureBottom, bool mobile) + { + HudFrame f = HudFrame.ForScreen(width, height, 0f, 0f, notchTop, gestureBottom, mobile); + Rect safe = DuelHudLayout.Safe(f); + foreach (bool mine in new[] { true, false }) + { + SignatureCutInLayout.Plan plan = SignatureCutInLayout.For(f, mine); + string tag = $"{width}x{height} {(mine ? "내" : "상대")} 전용기"; + Assert.AreEqual(0f, plan.Band.x, 0.01f, tag + ": 띠가 화면 왼쪽 끝부터가 아니다"); + Assert.AreEqual(f.Width, plan.Band.width, 0.01f, tag + ": 띠가 화면을 가로지르지 않는다"); + Assert.GreaterOrEqual(plan.Band.y, f.ContentTop - 0.01f, tag); + Assert.LessOrEqual(plan.Band.yMax, f.ContentBottom + 0.01f, tag); + foreach (KeyValuePair row in new[] + { Piece("배지", plan.Badge), Piece("시전자", plan.Caster), Piece("기술 이름", plan.Skill) }) + { + AssertContained(plan.Band, row.Value, tag + " " + row.Key); + Assert.GreaterOrEqual(row.Value.x, safe.x - 0.01f, tag + " " + row.Key + ": 안전 영역 왼쪽 밖"); + Assert.LessOrEqual(row.Value.xMax, safe.xMax + 0.01f, tag + " " + row.Key + ": 안전 영역 오른쪽 밖"); + } + Assert.IsFalse(plan.Badge.Overlaps(plan.Caster), tag + ": 배지와 시전자 이름"); + Assert.IsFalse(plan.Skill.Overlaps(plan.Caster) || plan.Skill.Overlaps(plan.Badge), tag + ": 기술 이름이 윗줄과 겹친다"); + Assert.GreaterOrEqual(plan.Skill.height, RaidStageLayout.LineHeight(SignatureCutInLayout.SkillFont), tag + ": 큰 글자 한 줄이 안 들어간다"); + Assert.Less(mine ? plan.Badge.x : -plan.Badge.xMax, mine ? plan.Caster.x : -plan.Caster.xMax, tag + ": 배지가 들어오는 쪽에 있지 않다"); + foreach (KeyValuePair p in AttackPieces(f)) + Assert.IsFalse(plan.Band.Overlaps(p.Value), $"{tag}: 띠 {plan.Band} ↔ {p.Key} {p.Value}"); + } + } + + [Test] + public void SignatureBand_SlidesInFromTheCastersSide_ThenFadesAfterTheCutIn() + { + Assert.AreEqual(1f, SignatureCutInLayout.Slide(0f, false), 1e-4f, "첫 프레임은 화면 밖에서 시작한다"); + Assert.AreEqual(0f, SignatureCutInLayout.Slide(SignatureCutInLayout.SlideShare, false), 1e-4f); + Assert.AreEqual(0f, SignatureCutInLayout.Slide(1f, false), 1e-4f); + Assert.AreEqual(0f, SignatureCutInLayout.Slide(0f, true), 1e-4f, "줄인 움직임이면 제자리"); + float last = 2f; + for (float p = 0f; p <= 1f; p += 0.02f) + { + float s = SignatureCutInLayout.Slide(p, false); + Assert.LessOrEqual(s, last + 1e-5f, "도로 밀려난다"); + last = s; + } + Assert.Less(SignatureCutInLayout.Offset(1f, true, 1920f), 0f, "내 전용기는 왼쪽에서"); + Assert.Greater(SignatureCutInLayout.Offset(1f, false, 1920f), 0f, "상대 전용기는 오른쪽에서"); + Assert.AreEqual(1f, SignatureCutInLayout.Alpha(-0.2f), 1e-4f); + Assert.AreEqual(0f, SignatureCutInLayout.Alpha(SignatureCutInLayout.TailSeconds), 1e-4f); + // 컷인(아레나 0.5초)의 절반 넘게 제자리에서 읽힌다. + Assert.Less(SignatureCutInLayout.SlideShare, 0.5f); + Assert.Greater(BattleFlourish.SignatureCutInEnd, 0f); + } + + // ── 승리 화면 ── + + [Test] + public void RewardLines_Duel_CandyExpCoinItemThenTheInsect() + { + List lines = BattleRewardLines.Duel(12, 34, 3, "꿀", 2, true, true, "사마귀"); + CollectionAssert.AreEqual(new[] { "캔디 +12", "경험치 +34", "코인 +3", "꿀 ×2", "사마귀를 잡았다!" }, Texts(lines)); + Assert.IsTrue(lines[4].Wide, "곤충 포획은 한 줄 통째로"); + Assert.AreEqual(RewardTone.Caught, lines[4].Tone); + AssertNarrowFirst(lines); + + CollectionAssert.AreEqual(new[] { "캔디 +12", "경험치 +34", "사마귀는 달아났다" }, + Texts(BattleRewardLines.Duel(12, 34, 0, null, 0, true, false, "사마귀")), "0인 보상은 줄이 없다"); + CollectionAssert.AreEqual(new[] { "캔디 +5", "경험치 +9" }, + Texts(BattleRewardLines.Duel(5, 9, 0, "꿀", 0, false, false, "사마귀")), "대결은 포획 줄이 없다"); + CollectionAssert.AreEqual(new[] { "장수풍뎅이를 잡았다!" }, + Texts(BattleRewardLines.Duel(0, 0, 0, null, 0, true, true, "장수풍뎅이"))); + List empty = BattleRewardLines.Duel(0, 0, 0, null, 0, false, false, null); + Assert.AreEqual(1, empty.Count, "보상이 하나도 없어도 빈 판을 띄우지 않는다"); + Assert.AreEqual(RewardTone.Cheer, empty[0].Tone); + + // 야생 드랍 재료는 아이템 데이터베이스에 이름이 없다 — 아이에게 ID(mat_leaf)를 보이지 않는다. + Assert.AreEqual(BattleRewardLines.MaterialName, BattleRewardLines.FallbackItemName("mat_leaf")); + Assert.AreEqual("net_gold", BattleRewardLines.FallbackItemName("net_gold")); + Assert.IsNull(BattleRewardLines.FallbackItemName("")); + var wild = ScriptableObject.CreateInstance(); + try + { + Assert.IsFalse(BattleRewardLines.FallbackItemName(wild.itemRewardId).Contains("_"), "야생 곤충의 기본 드랍이 ID 그대로 보인다"); + } + finally + { + Object.DestroyImmediate(wild); + } + } + + [Test] + public void RewardLines_Raid_RewardsBonusThenTheBoss() + { + List lines = BattleRewardLines.Raid(30, 60, "왕사슴벌레"); + CollectionAssert.AreEqual(new[] { "캔디 +30", "경험치 +60", "레이드 보너스 ×3", "왕사슴벌레를 잡았다!" }, Texts(lines)); + Assert.IsTrue(lines[3].Wide); + AssertNarrowFirst(lines); + Assert.AreEqual(BattleRewardLines.DreamCheer, BattleRewardLines.Cheer(null)[0].Text); + } + + [TestCaseSource(nameof(Screens))] + public void VictoryScreen_TitleOnTop_RewardsAtTheBottom_TheMiddleStaysOpen(float width, float height, float notchTop, float gestureBottom, bool mobile) + { + HudFrame f = HudFrame.ForScreen(width, height, 0f, 0f, notchTop, gestureBottom, mobile); + Rect safe = DuelHudLayout.Safe(f); + Rect title = BattleVictoryLayout.TitleRect(f); + Rect quote = BattleVictoryLayout.QuoteRect(f); + Rect hint = BattleVictoryLayout.HintRect(f); + Assert.GreaterOrEqual(title.height - 12f, RaidStageLayout.LineHeight(BattleVictoryLayout.TitleFont), "「승리!」 한 줄이 안 들어간다"); + Assert.GreaterOrEqual(hint.height - 8f, RaidStageLayout.LineHeight(BattleVictoryLayout.HintFont)); + Assert.LessOrEqual(quote.yMax, f.Height * 0.35f + 0.01f, $"{width}x{height}: 위쪽 묶음이 가운데로 내려온다"); + + foreach (List lines in SampleLines()) + { + BattleVictoryLayout.Grid grid = BattleVictoryLayout.Rewards(f, lines); + string tag = $"{width}x{height} 보상 {lines.Count}줄"; + var pieces = new List> + { + Piece("승리", title), Piece("튀어나온 승리", Grow(title, BattleVictoryLayout.TitlePopPeak)), + Piece("한마디", quote), Piece("눌러서 계속", hint), Piece("보상 판", grid.Panel) + }; + foreach (KeyValuePair p in pieces) + if (p.Key != "튀어나온 승리") AssertContained(safe, p.Value, tag + " " + p.Key); + for (int i = 0; i < pieces.Count; i++) + for (int j = i + 1; j < pieces.Count; j++) + { + if ((pieces[i].Key == "승리" && pieces[j].Key == "튀어나온 승리")) continue; + Assert.IsFalse(pieces[i].Value.Overlaps(pieces[j].Value), $"{tag}: {pieces[i].Key} {pieces[i].Value} ↔ {pieces[j].Key} {pieces[j].Value}"); + } + // 가운데(아레나 승리 연출 — 1대1 포즈·레이드 팀 점프)는 비운다. + Assert.GreaterOrEqual(grid.Panel.y, f.Height * 0.6f - 0.01f, $"{tag}: 보상 판이 가운데로 올라온다"); + + var cells = new List(); + for (int i = 0; i < lines.Count; i++) + { + Rect cell = BattleVictoryLayout.Cell(grid, i, lines); + AssertContained(grid.Panel, cell, $"{tag} {i}번째 줄"); + AssertContained(grid.Panel, Shift(cell, BattleVictoryLayout.RewardRise), $"{tag} {i}번째 줄(올라오는 중)"); + Assert.GreaterOrEqual(cell.height, RaidStageLayout.LineHeight(BattleVictoryLayout.WideRowFont), tag); + foreach (Rect other in cells) Assert.IsFalse(cell.Overlaps(other), $"{tag}: {i}번째 줄이 앞 줄과 겹친다"); + if (lines[i].Wide) Assert.AreEqual(grid.Panel.width - BattleVictoryLayout.PanelPad * 2f, cell.width, 0.01f, tag + " 넓은 줄"); + if (i > 0) Assert.GreaterOrEqual(cell.y + 0.01f, cells[cells.Count - 1].y, $"{tag}: 줄 순서가 위로 거슬러 간다"); + cells.Add(cell); + } + } + } + + [Test] + public void VictoryTiming_TitlePops_RewardsOneByOneFromPointEight_HintOnlyWhenItCanClose() + { + Assert.AreEqual(BattleVictoryLayout.TitlePopStart, BattleVictoryLayout.TitleScale(0f, false), 1e-4f); + Assert.AreEqual(1f, BattleVictoryLayout.TitleScale(BattleVictoryLayout.TitlePopSettleAt, false), 1e-4f); + Assert.AreEqual(1f, BattleVictoryLayout.TitleScale(0f, true), 1e-4f); + float max = 0f; + for (float s = 0f; s < 1f; s += 0.01f) max = Mathf.Max(max, BattleVictoryLayout.TitleScale(s, false)); + Assert.Greater(max, 1f, "「승리!」가 튀어나오지 않는다"); + Assert.LessOrEqual(max, BattleVictoryLayout.TitlePopPeak + 1e-4f); + + Assert.AreEqual(0.8f, BattleVictoryLayout.RewardAppearAt(0), 1e-4f); + Assert.AreEqual(0f, BattleVictoryLayout.RewardAlpha(0.79f, 0), 1e-4f, "0.8초 전에 보상이 뜬다"); + Assert.AreEqual(1f, BattleVictoryLayout.RewardAlpha(0.8f + BattleVictoryLayout.RewardFade, 0), 1e-4f); + for (int i = 1; i < 6; i++) + { + Assert.Greater(BattleVictoryLayout.RewardAppearAt(i), BattleVictoryLayout.RewardAppearAt(i - 1) + BattleVictoryLayout.RewardFade - 1e-4f, + "앞 줄이 다 떠오르기 전에 다음 줄이 뜬다 — 한 줄씩이 아니다"); + } + // 첫 줄은 「승리!」가 자리 잡은 뒤, 아레나 승리 연출(2초) 도중에 뜬다. + Assert.Greater(BattleVictoryLayout.RewardAppearAt(0), BattleVictoryLayout.TitlePopSettleAt); + Assert.Less(BattleVictoryLayout.RewardAppearAt(0), BattleFlourish.VictorySeconds); + + // 「눌러서 계속」 — 결과 화면이 누름을 받을 때(0.6초)부터만. + float lockEnd = BattleResultRules.InputLockSeconds; + Assert.AreEqual(0f, BattleVictoryLayout.HintAlpha(BattleResultRules.CanClose(lockEnd - 0.01f), -0.01f, false), 1e-4f); + Assert.IsTrue(BattleResultRules.CanClose(lockEnd)); + Assert.AreEqual(0f, BattleVictoryLayout.HintAlpha(true, 0f, false), 1e-4f, "닫을 수 있게 된 순간부터 떠오른다"); + for (float s = BattleVictoryLayout.HintFadeSeconds; s < 4f; s += 0.05f) + { + float a = BattleVictoryLayout.HintAlpha(true, s, false); + Assert.GreaterOrEqual(a, 0.55f - 1e-4f, "깜빡이다 안 보일 만큼 옅어진다"); + Assert.LessOrEqual(a, 1f + 1e-4f); + } + Assert.AreEqual(1f, BattleVictoryLayout.HintAlpha(true, 2f, true), 1e-4f, "줄인 움직임이면 깜빡이지 않는다"); + } + + [Test] + public void ResultScreens_NoLongerPromiseAnAutomaticReturn() + { + // 결과 화면은 이제 눌러야 닫힌다(BattleResultRules — 꿈 챔피언전만 예외). 예전 안내문이 남으면 거짓말이 된다. + foreach (string file in new[] { "UI/BattleScreenUI.cs", "UI/RaidBattleUI.Draw.cs", "UI/BattleFeelHud.cs" }) + { + string path = Path.Combine(Application.dataPath, "Scripts", file); + Assert.IsTrue(File.Exists(path), path); + string source = File.ReadAllText(path); + StringAssert.DoesNotContain("잠시 후 탐험으로 돌아갑니다", source, file); + StringAssert.DoesNotContain("잠시 후 자동으로 돌아갑니다", source, file); + } + Assert.AreEqual("눌러서 계속", BattleVictoryLayout.ContinueHint); + } + + // ── 레이드 팀 줄 ── + + [Test] + public void RaidTeamStrip_DropsAtOnce_RisesSmoothly() + { + Assert.AreEqual(1f, RaidTeamStrip.NextDrop(0f, true, 1f / 60f), 1e-4f, "기술을 고른 첫 프레임에 곧바로 아래 — 화면 가운데를 지나지 않는다"); + Assert.AreEqual(1f, RaidTeamStrip.NextDrop(0.3f, true, 0f), 1e-4f); + float half = RaidTeamStrip.NextDrop(1f, false, RaidTeamStrip.SlideSeconds * 0.5f); + Assert.AreEqual(0.5f, half, 1e-3f, "올라올 때는 미끄러진다"); + Assert.AreEqual(0f, RaidTeamStrip.NextDrop(1f, false, RaidTeamStrip.SlideSeconds), 1e-4f); + } + + [Test] + public void RaidTeamStrip_HidesForGuardianIntroTransformAndResult() + { + Assert.IsTrue(RaidTeamStrip.Hidden(true, false, false), "수문장 등장 — 보스 아랫부분을 가렸다"); + Assert.IsTrue(RaidTeamStrip.Hidden(false, true, false), "그림자 변신 — 연기를 가렸다"); + Assert.IsTrue(RaidTeamStrip.Hidden(false, false, true), "결과 — 팀 점프를 가린다"); + Assert.IsFalse(RaidTeamStrip.Hidden(false, false, false)); + Assert.AreEqual(1f, RaidTeamStrip.NextHide(0f, true, true, 0f), 1e-4f, "전투 첫 장면은 곧바로"); + Assert.AreEqual(0.5f, RaidTeamStrip.NextHide(0f, true, false, RaidTeamStrip.FadeSeconds * 0.5f), 1e-3f); + Assert.AreEqual(0f, RaidTeamStrip.NextHide(1f, false, false, RaidTeamStrip.FadeSeconds), 1e-4f); + } + + // ── 도우미 ── + + private static IEnumerable> SampleLines() + { + yield return BattleRewardLines.Cheer(null); + yield return BattleRewardLines.Duel(12, 34, 0, null, 0, false, false, null); + yield return BattleRewardLines.Duel(12, 34, 0, null, 0, true, true, "사마귀"); + yield return BattleRewardLines.Raid(30, 60, "왕사슴벌레"); + yield return BattleRewardLines.Duel(12, 34, 3, "꿀", 2, true, false, "아주 긴 이름의 전설 장수풍뎅이"); + yield return BattleRewardLines.Duel(12, 34, 3, "꿀", 2, false, false, null); + } + + /// 진입 구간에 함께 서는 것 — 머리 띠, HP 카드와 그 밑 쌓기(장부 게이지·보정 칩이 다 선 가장 긴 경우). + private static IEnumerable> IntroPieces(HudFrame f) + { + Rect safe = DuelHudLayout.Safe(f); + Rect player = DuelHudLayout.HpCard(safe, true); + Rect enemy = DuelHudLayout.HpCard(safe, false); + yield return Piece("머리 띠", DuelHudLayout.Heading(f)); + yield return Piece("내 HP 카드", player); + yield return Piece("상대 HP 카드", enemy); + yield return Piece("내 보정 칩", BattleHudStack.EnvironmentChip(player, true, false, player.width)); + yield return Piece("장부 게이지", BattleHudStack.LedgerGauge(enemy)); + yield return Piece("상대 보정 칩", BattleHudStack.EnvironmentChip(enemy, false, true, enemy.width)); + } + + /// 공격 연출 동안 함께 서는 것 — HP 쌓기(말풍선·연속 배지까지), 위쪽 띠·배속, 아래쪽 공격 배너·행동 문구, 전투 문구 줄. + private static IEnumerable> AttackPieces(HudFrame f) + { + foreach (KeyValuePair p in IntroPieces(f)) yield return p; + Rect safe = DuelHudLayout.Safe(f); + Rect player = DuelHudLayout.HpCard(safe, true); + Rect enemy = DuelHudLayout.HpCard(safe, false); + yield return Piece("대결 말풍선", BattleHudStack.Bubble(enemy, true, true)); + yield return Piece("연속 배지", Grow(BattleHudStack.ComboBadge(player, true), BattleHudStack.ComboPulsePeak)); + yield return Piece("배속 버튼", DuelHudLayout.SpeedControl(f)); + yield return Piece("공격 배너", DuelHudLayout.PhaseBanner(f)); + yield return Piece("행동 문구", DuelHudLayout.ActionBar(f)); + yield return Piece("턴 배너", DuelHudLayout.TurnBanner(f)); + for (int i = 0; i < 3; i++) yield return Piece($"전투 문구 {i + 1}줄", BattleEffectTextOverlay.RowRect(f, i)); + } + + private static IEnumerable> RaidPieces(HudFrame f) + { + yield return Piece("보스 HP 카드", RaidStageLayout.BossCard(f)); + yield return Piece("보스 예고", RaidStageLayout.BossIntent(f)); + yield return Piece("팀 줄(쉬는 자리)", RaidStageLayout.TeamStripRest(f)); + yield return Piece("합체 게이지", RaidStageLayout.UniteGauge(f)); + } + + private static void AssertNarrowFirst(List lines) + { + bool sawWide = false; + foreach (RewardLine line in lines) + { + if (line.Wide) sawWide = true; + else Assert.IsFalse(sawWide, "좁은 줄이 넓은 줄 뒤에 왔다 — 보상 판 칸 배치가 이 순서를 믿는다"); + } + } + + private static List Texts(List lines) + { + var texts = new List(lines.Count); + foreach (RewardLine line in lines) texts.Add(line.Text); + return texts; + } + + private static KeyValuePair Piece(string name, Rect rect) => new KeyValuePair(name, rect); + + private static Rect Shift(Rect r, float dy) => new Rect(r.x, r.y + dy, r.width, r.height); + + /// 가운데를 축으로 배 — 행렬로 키우는 그리기(튀어나오기)의 겉모습. + private static Rect Grow(Rect r, float scale) + { + float w = r.width * scale, h = r.height * scale; + return new Rect(r.center.x - w * 0.5f, r.center.y - h * 0.5f, w, h); + } + + private static void AssertContained(Rect outer, Rect inner, string what) + { + const float eps = 0.01f; + Assert.GreaterOrEqual(inner.xMin, outer.xMin - eps, $"{what}: 왼쪽 밖 {inner} / {outer}"); + Assert.LessOrEqual(inner.xMax, outer.xMax + eps, $"{what}: 오른쪽 밖 {inner} / {outer}"); + Assert.GreaterOrEqual(inner.yMin, outer.yMin - eps, $"{what}: 위 밖 {inner} / {outer}"); + Assert.LessOrEqual(inner.yMax, outer.yMax + eps, $"{what}: 아래 밖 {inner} / {outer}"); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/BattleFeelHudTests.cs.meta b/Assets/Tests/EditMode/BattleFeelHudTests.cs.meta new file mode 100644 index 0000000..1a8fd56 --- /dev/null +++ b/Assets/Tests/EditMode/BattleFeelHudTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 350e32f6eac85084e8b6e0e8edc8229c \ No newline at end of file diff --git a/Assets/Tests/EditMode/BattleFlourishTests.cs b/Assets/Tests/EditMode/BattleFlourishTests.cs new file mode 100644 index 0000000..2b2550b --- /dev/null +++ b/Assets/Tests/EditMode/BattleFlourishTests.cs @@ -0,0 +1,426 @@ +#if UNITY_EDITOR +using InsectGame.Battle; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 전투 체감 강화(4단계)의 순수 부분 — 계열 분류(진짜 곤충 ID), 계열 몸짓의 타격 시각, 전용기 컷인 시각표와 샷, 승리 포즈·카메라, + /// 전투 진입 샷, 상태 표시 판정·곡선, 숨쉬기. 화면은 QA 캡처(-battleScenario status-fx|signature|victory|species-motions)로 본다. + /// + [TestFixture] + public class BattleFlourishTests + { + private static readonly Vector3 BasePos = new Vector3(1000f, 2f, 993.5f); + private static readonly Quaternion BaseRot = Quaternion.LookRotation(new Vector3(0f, -1.4f, 6.5f)); + private static readonly Vector3 Attacker = new Vector3(997.9f, 0.6f, 999.65f); + private static readonly Vector3 Target = new Vector3(1002.1f, 0.6f, 1000.35f); + + private static readonly BattleMotion.Kind[] MeleeKinds = + { + BattleMotion.Kind.Charge, BattleMotion.Kind.Slash, BattleMotion.Kind.Flight, + BattleMotion.Kind.Sting, BattleMotion.Kind.Pounce, BattleMotion.Kind.Lunge + }; + + private static readonly BattleMotion.Family[] Families = + { + BattleMotion.Family.Beetle, BattleMotion.Family.Mantis, BattleMotion.Family.Flier, + BattleMotion.Family.Bee, BattleMotion.Family.Crawler, BattleMotion.Family.Other + }; + + // ── 계열 분류 — 모델 빌더(InsectEntity.BuildModel)와 같은 순서 ── + + [TestCase("mantis_green", BattleMotion.Family.Mantis)] + [TestCase("mantis_orchid", BattleMotion.Family.Mantis)] + [TestCase("mantis_ghost", BattleMotion.Family.Mantis)] + [TestCase("gacha_shadow_mantis", BattleMotion.Family.Mantis)] + [TestCase("rhinoceros_beetle", BattleMotion.Family.Beetle)] + [TestCase("stag_beetle_saw", BattleMotion.Family.Beetle)] + [TestCase("beetle_golden_stag", BattleMotion.Family.Beetle)] + [TestCase("beetle_basic", BattleMotion.Family.Beetle)] + [TestCase("scarab_ancient", BattleMotion.Family.Beetle)] + [TestCase("ladybug_seven", BattleMotion.Family.Beetle)] + [TestCase("diving_beetle_great", BattleMotion.Family.Beetle)] + [TestCase("butterfly_swallowtail", BattleMotion.Family.Flier)] + [TestCase("atlas_moth_giant", BattleMotion.Family.Flier)] + [TestCase("dragonfly_ancient", BattleMotion.Family.Flier)] + [TestCase("damselfly_blue", BattleMotion.Family.Flier)] + [TestCase("firefly_blue", BattleMotion.Family.Flier)] + [TestCase("cicada_summer", BattleMotion.Family.Flier)] + [TestCase("fly_hover", BattleMotion.Family.Flier)] + [TestCase("bee_worker", BattleMotion.Family.Bee)] + [TestCase("wasp_paper", BattleMotion.Family.Bee)] + [TestCase("hornet_asian", BattleMotion.Family.Bee)] + [TestCase("gacha_storm_hornet", BattleMotion.Family.Bee)] + [TestCase("spider_garden", BattleMotion.Family.Crawler)] + [TestCase("centipede_red", BattleMotion.Family.Crawler)] + [TestCase("antlion_pit", BattleMotion.Family.Crawler)] + [TestCase("ant_soldier", BattleMotion.Family.Other)] + [TestCase("cricket_field", BattleMotion.Family.Other)] + [TestCase("grasshopper_locust", BattleMotion.Family.Other)] + [TestCase("leaf_insect_phantom", BattleMotion.Family.Other)] + [TestCase("water_strider_pond", BattleMotion.Family.Other)] + [TestCase(null, BattleMotion.Family.Beetle)] + public void FamilyOf_RealIds_FollowModelBuilder(string id, BattleMotion.Family expected) + { + Assert.AreEqual(expected, BattleMotion.FamilyOf(id)); + } + + [Test] + public void FamilyOf_BeetleIdsContainingBee_AreNotBees() + { + // "beetle"에 "bee"가 들어 있다 — 가드가 빠지면 딱정벌레 전부가 찌르기를 한다(모델 빌더의 같은 함정). + foreach (string id in new[] { "beetle_basic", "beetle_dung", "beetle_click", "beetle_longhorn_oak", "rhinoceros_beetle_titan" }) + Assert.AreEqual(BattleMotion.Family.Beetle, BattleMotion.FamilyOf(id), id); + } + + [TestCase(BattleMotion.Family.Mantis, BattleMotion.Kind.Slash)] + [TestCase(BattleMotion.Family.Beetle, BattleMotion.Kind.Charge)] + [TestCase(BattleMotion.Family.Flier, BattleMotion.Kind.Flight)] + [TestCase(BattleMotion.Family.Bee, BattleMotion.Kind.Sting)] + [TestCase(BattleMotion.Family.Crawler, BattleMotion.Kind.Pounce)] + [TestCase(BattleMotion.Family.Other, BattleMotion.Kind.Lunge)] + public void MeleeKindOf_EachFamily_HasItsOwnMotion(BattleMotion.Family family, BattleMotion.Kind expected) + { + Assert.AreEqual(expected, BattleMotion.MeleeKindOf(family)); + } + + // ── 계열 몸짓 ── + + [Test] + public void EveryMotion_HitsWithinOneSecondOfASkill_AndRestsByTheEnd() + { + // 스킬 연출은 2.5초(BattleScreenUI attackDuration) — 전용기 컷인 → 타격이 1초 안쪽이어야 한다. + foreach (BattleMotion.Kind kind in MeleeKinds) + { + float impact = BattleMotion.ImpactOf(kind); + Assert.LessOrEqual(impact * 2.5f, BattleFlourish.SignatureLeadMaxSeconds + 0.0001f, kind.ToString()); + Assert.Less(impact, BattleMotion.RestProgress, kind.ToString()); + foreach (BattleMotion.Family family in Families) + { + for (int s = -5; s <= 110; s++) + { + BattleMotion.Pose pose = BattleMotion.Evaluate(kind, family, s / 100f); + AssertFinite(pose); + Assert.Less(Mathf.Abs(pose.Lift), 1.2f, $"{kind} 너무 높다"); + } + AssertRest(BattleMotion.Evaluate(kind, family, BattleMotion.RestProgress)); + AssertRest(BattleMotion.Evaluate(kind, family, 0f)); + } + } + } + + [Test] + public void ProjectileCast_EachFamily_StaysHomeAndReturnsToRest() + { + foreach (BattleMotion.Family family in Families) + { + for (int s = 0; s <= 100; s++) + { + BattleMotion.Pose pose = BattleMotion.Evaluate(BattleMotion.Kind.Projectile, family, s / 100f); + AssertFinite(pose); + Assert.Less(Mathf.Abs(pose.Travel), 0.15f, $"{family} 원거리 시전이 달려든다"); + } + AssertRest(BattleMotion.Evaluate(BattleMotion.Kind.Projectile, family, BattleMotion.RestProgress)); + } + } + + [Test] + public void Motions_HaveDistinctSilhouettes() + { + float MaxLift(BattleMotion.Kind k) { float m = 0f; for (int i = 0; i <= 100; i++) m = Mathf.Max(m, BattleMotion.Evaluate(k, i / 100f).Lift); return m; } + float MaxAbsRoll(BattleMotion.Kind k) { float m = 0f; for (int i = 0; i <= 100; i++) m = Mathf.Max(m, Mathf.Abs(BattleMotion.Evaluate(k, i / 100f).Roll)); return m; } + // 나는 종은 높이 솟았다 내리꽂고, 딱정벌레는 낮게 들이받고, 사마귀는 휘두르며 몸이 크게 기운다. + Assert.Greater(MaxLift(BattleMotion.Kind.Flight), 0.8f); + Assert.AreEqual(0f, MaxLift(BattleMotion.Kind.Charge), 0.0001f); + Assert.Greater(MaxAbsRoll(BattleMotion.Kind.Slash), 18f); + // 딱정벌레는 들이받는 동안 머리를 숙이고 있다(양수 = 숙임), 나는 종은 내리꽂는 순간 머리가 아래. + Assert.Greater(BattleMotion.Evaluate(BattleMotion.Kind.Charge, 0.3f).Pitch, 10f); + Assert.Greater(BattleMotion.Evaluate(BattleMotion.Kind.Flight, BattleMotion.ImpactProgress - 0.01f).Pitch, 20f); + // 찌르기는 곧은 선 — 들어가는 동안 낮아지기만 한다(포물선이 아니다). + float before = BattleMotion.Evaluate(BattleMotion.Kind.Sting, 0.28f).Lift; + float atHit = BattleMotion.Evaluate(BattleMotion.Kind.Sting, BattleMotion.StingImpactProgress - 0.001f).Lift; + Assert.Less(atHit, before); + // 덮치기는 정점에서 포물선으로 솟는다. + Assert.Greater(BattleMotion.Evaluate(BattleMotion.Kind.Pounce, 0.29f).Lift, 0.5f); + } + + [Test] + public void Slash_SwingsTwice_FirstBeforeTheHit() + { + Assert.Less(BattleMotion.SlashFirstSwing, BattleMotion.ImpactProgress); + BattleMotion.Pose first = BattleMotion.Evaluate(BattleMotion.Kind.Slash, BattleMotion.SlashFirstSwing); + BattleMotion.Pose second = BattleMotion.Evaluate(BattleMotion.Kind.Slash, BattleMotion.ImpactProgress); + // 두 번째는 반대 대각선이다. + Assert.Greater(first.Roll, 10f); + Assert.Less(second.Roll, -10f); + Assert.IsTrue(BattleMotion.HasPreStrike(BattleMotion.Kind.Slash)); + Assert.IsFalse(BattleMotion.HasPreStrike(BattleMotion.Kind.Charge)); + } + + // ── 전용기 ── + + [Test] + public void SignatureCutIn_EndsBeforeTheHit_ForEveryMotionAndLength() + { + foreach (BattleMotion.Kind kind in MeleeKinds) + foreach (float duration in new[] { 0.6f, 1.8f, 2.5f }) + { + float impact = BattleMotion.ImpactOf(kind) * duration; + float cut = BattleFlourish.SignatureCutInEndFor(impact); + Assert.Less(cut, impact, $"{kind} {duration}s — 컷인이 타격 뒤까지 간다"); + Assert.LessOrEqual(cut, BattleFlourish.SignatureCutInEnd); + } + // 레이드 볼리(0.46초 끝에 타격)도 컷인이 타격 전에 끝난다. + Assert.Less(BattleFlourish.SignatureCutInEndFor(BattleArenaController.RaidVolleyImpactSeconds), + BattleArenaController.RaidVolleyImpactSeconds); + } + + [Test] + public void SignatureShot_CutsToCasterFirst_ThenTargetAtHit_ThenReleases() + { + BattleCameraDirector.Style saved = BattleCameraDirector.Current; + try + { + BattleCameraDirector.Current = BattleCameraDirector.Style.Cinematic; + const float Duration = 2.5f; + float impact = BattleMotion.ImpactProgress; + BattleCameraDirector.Shot cut = BattleCameraDirector.EvaluateSignature(0.1f / Duration, Duration, BasePos, BaseRot, + Attacker, Target, 0.5f, false, impact); + Assert.AreEqual(1f, cut.Weight, 0.001f, "컷인은 0.08초 안에 끊어 들어간다"); + Assert.Less(Vector3.Distance(cut.Position, Attacker), Vector3.Distance(BasePos, Attacker)); + BattleCameraDirector.Shot hit = BattleCameraDirector.EvaluateSignature(impact + 0.01f, Duration, BasePos, BaseRot, + Attacker, Target, 0.5f, false, impact); + Assert.Less(Vector3.Distance(hit.Position, Target), Vector3.Distance(BasePos, Target) * 0.75f); + Assert.AreEqual(0f, BattleCameraDirector.EvaluateSignature(0.9f, Duration, BasePos, BaseRot, + Attacker, Target, 0.5f, false, impact).Weight); + + BattleCameraDirector.Current = BattleCameraDirector.Style.Off; + Assert.AreEqual(0f, BattleCameraDirector.EvaluateSignature(0.1f, Duration, BasePos, BaseRot, + Attacker, Target, 0.5f, false, impact).Weight); + } + finally + { + BattleCameraDirector.Current = saved; + } + } + + [Test] + public void HitCue_WithSignature_KeepsTheHitAndAddsABoundedPause() + { + var plain = new BattleArenaController.HitCue("돌진", 0.4f, false, false, false); + BattleArenaController.HitCue sig = plain.WithSignature(true); + Assert.IsTrue(sig.Signature); + Assert.AreEqual(plain.SkillName, sig.SkillName); + Assert.AreEqual(plain.Weight, sig.Weight, 0.0001f); + Assert.Greater(sig.HitStopSeconds, plain.HitStopSeconds); + var heaviest = new BattleArenaController.HitCue("돌진", 1f, true, false, true, true); + Assert.LessOrEqual(heaviest.HitStopSeconds, BattleArenaController.HitCue.MaxHitStop + 0.0001f); + Assert.AreEqual(0f, new BattleArenaController.HitCue("돌진", 0f, false, true, false, true).HitStopSeconds); + } + + // ── 승리 ── + + [Test] + public void VictoryPose_EveryFamily_JumpsAndSettlesBeforeTheEnd() + { + Assert.LessOrEqual(BattleFlourish.VictoryPoseSeconds, BattleFlourish.VictorySeconds); + foreach (BattleMotion.Family family in Families) + { + float maxLift = 0f; + for (int i = 0; i <= 200; i++) + { + BattleMotion.Pose pose = BattleFlourish.VictoryPose(family, i / 100f); + AssertFinite(pose); + Assert.GreaterOrEqual(pose.Lift, -0.0001f, $"{family} 땅속으로 들어간다"); + maxLift = Mathf.Max(maxLift, pose.Lift); + } + Assert.Greater(maxLift, 0.2f, $"{family} 뛰어오르지 않는다"); + AssertRest(BattleFlourish.VictoryPose(family, 0f)); + AssertRest(BattleFlourish.VictoryPose(family, BattleFlourish.VictoryPoseSeconds)); + } + } + + [Test] + public void VictoryOrbit_TurnsTowardTheFace_AndStaysInsideTheWalls() + { + BattleCameraDirector.Style saved = BattleCameraDirector.Current; + try + { + BattleCameraDirector.Current = BattleCameraDirector.Style.Cinematic; + Vector3 face = Vector3.right; // 내 곤충은 상대(+x)를 본다 + Vector3 pivot = Attacker; + Assert.AreEqual(0f, BattleCameraDirector.EvaluateVictoryOrbit(0f, BasePos, BaseRot, pivot, face, + BattleFlourish.VictoryOrbitDegrees, 3f, 0.2f).Weight, 0.001f, "첫 프레임은 원래 구도"); + BattleCameraDirector.Shot end = BattleCameraDirector.EvaluateVictoryOrbit(BattleFlourish.VictorySeconds, BasePos, BaseRot, + pivot, face, BattleFlourish.VictoryOrbitDegrees, 3f, 0.2f); + Assert.AreEqual(1f, end.Weight, 0.001f); + Vector3 startDir = Flat(BasePos - pivot); + Vector3 endDir = Flat(end.Position - pivot); + Assert.AreEqual(BattleFlourish.VictoryOrbitDegrees, Vector3.Angle(startDir, endDir), 2f); + Assert.Greater(Vector3.Dot(endDir, face), Vector3.Dot(startDir, face), "얼굴 반대쪽으로 돌았다"); + // 다 돈 뒤에도 마지막 구도에 머문다(결과 화면 뒤). + BattleCameraDirector.Shot hold = BattleCameraDirector.EvaluateVictoryOrbit(BattleFlourish.VictorySeconds + 3f, BasePos, BaseRot, + pivot, face, BattleFlourish.VictoryOrbitDegrees, 3f, 0.2f); + Assert.Less(Vector3.Distance(hold.Position, end.Position), 0.001f); + for (float t = 0f; t <= BattleFlourish.VictorySeconds; t += 0.05f) + { + Vector3 p = BattleCameraDirector.EvaluateVictoryOrbit(t, BasePos, BaseRot, pivot, face, + BattleFlourish.VictoryOrbitDegrees, 3f, 0.2f).Position; + Assert.Less(Mathf.Abs(p.x - 1000f), BattleArenaController.ArenaWallSpan - 1f); + Assert.Less(Mathf.Abs(p.z - 1000f), BattleArenaController.ArenaWallSpan - 1f); + } + } + finally + { + BattleCameraDirector.Current = saved; + } + } + + [Test] + public void FitDistance_PortraitNeedsMoreRoomThanLandscape() + { + float landscape = BattleFlourish.FitDistance(3f, 1f, 42f, 16f / 9f); + float portrait = BattleFlourish.FitDistance(3f, 1f, 42f, 9f / 16f); + Assert.Greater(portrait, landscape); + Assert.Greater(landscape, 3f); + } + + // ── 전투 진입 샷 ── + + [Test] + public void OpeningShot_FitsTheEntryWindow_AndLandsExactlyOnTheBattleFraming() + { + Assert.LessOrEqual(BattleFlourish.OpeningShotSeconds, 1f + 0.0001f, "요구: 1초 안쪽"); + Assert.LessOrEqual(BattleFlourish.OpeningShotSeconds, BattleReadPacing.EntryIntroSeconds, "진입 구간 안에서 끝난다"); + BattleCameraDirector.Style saved = BattleCameraDirector.Current; + try + { + BattleCameraDirector.Current = BattleCameraDirector.Style.Cinematic; + Vector3 focus = Vector3.Lerp(Attacker, Target, 0.5f); + BattleCameraDirector.Shot first = BattleCameraDirector.EvaluateOpening(0f, BasePos, BaseRot, focus, Target); + Assert.AreEqual(1f, first.Weight, "전투가 이 샷으로 열린다"); + Assert.Less(Vector3.Distance(first.Position, Target), Vector3.Distance(BasePos, Target) * 0.6f, "상대 옆에서 시작"); + BattleCameraDirector.Shot last = BattleCameraDirector.EvaluateOpening(BattleFlourish.OpeningShotSeconds - 0.0001f, + BasePos, BaseRot, focus, Target); + Assert.Less(Vector3.Distance(last.Position, BasePos), 0.01f); + Assert.Less(Quaternion.Angle(last.Rotation, BaseRot), 1f); + Assert.AreEqual(0f, BattleCameraDirector.EvaluateOpening(BattleFlourish.OpeningShotSeconds, BasePos, BaseRot, focus, Target).Weight); + // 크게 휘돈다 — 중간 어디선가 상대 쪽(+x)으로 원래 자리보다 많이 나가 있다. + float maxX = float.MinValue; + for (float t = 0f; t < BattleFlourish.OpeningShotSeconds; t += 0.05f) + maxX = Mathf.Max(maxX, BattleCameraDirector.EvaluateOpening(t, BasePos, BaseRot, focus, Target).Position.x); + Assert.Greater(maxX, BasePos.x + 2f); + + BattleCameraDirector.Current = BattleCameraDirector.Style.Off; + Assert.AreEqual(0f, BattleCameraDirector.EvaluateOpening(0.2f, BasePos, BaseRot, focus, Target).Weight); + } + finally + { + BattleCameraDirector.Current = saved; + } + } + + // ── 상태 표시 ── + + [Test] + public void StatusFromDuel_SplitsSidesAndKinds_LikeTheHpCard() + { + var effects = new[] + { + new InsectBattleController.EffectSnapshot { targetIsPlayer = true, value = 0.3f, remainingTurns = 2, kind = InsectBattleController.EffectKind.AtkBuff }, + new InsectBattleController.EffectSnapshot { targetIsPlayer = false, value = 6f, remainingTurns = 3, kind = InsectBattleController.EffectKind.Dot }, + new InsectBattleController.EffectSnapshot { targetIsPlayer = false, value = 0.3f, remainingTurns = 3, kind = InsectBattleController.EffectKind.DefBuff }, + new InsectBattleController.EffectSnapshot { targetIsPlayer = false, value = -0.2f, remainingTurns = 1, kind = InsectBattleController.EffectKind.AtkBuff }, + new InsectBattleController.EffectSnapshot { targetIsPlayer = true, value = -0.2f, remainingTurns = 0, kind = InsectBattleController.EffectKind.DefBuff }, + }; + Assert.AreEqual(BattleStatusFlags.Stun | BattleStatusFlags.AttackUp, BattleStatusLook.FromDuel(2, effects, true)); + Assert.AreEqual(BattleStatusFlags.Poison | BattleStatusFlags.DefenseUp | BattleStatusFlags.AttackDown, + BattleStatusLook.FromDuel(0, effects, false)); + Assert.AreEqual(BattleStatusFlags.None, BattleStatusLook.FromDuel(0, null, true)); + } + + [Test] + public void StatusFromStacks_SignIsDirection() + { + Assert.AreEqual(BattleStatusFlags.AttackUp | BattleStatusFlags.DefenseDown, BattleStatusLook.FromStacks(2, -1, false)); + Assert.AreEqual(BattleStatusFlags.Stun | BattleStatusFlags.AttackDown, BattleStatusLook.FromStacks(-3, 0, true)); + Assert.AreEqual(BattleStatusFlags.None, BattleStatusLook.FromStacks(0, 0, false)); + } + + [Test] + public void RaidBossDazed_OnlyAfterTheSkippedResponse() + { + var round = new RaidRoundResult(1, 0, 0, false, null, 5); + Assert.IsFalse(BattleStatusLook.RaidBossDazed(round)); + round.BossResponseSkipped = true; + round.Stage = RaidRoundStage.TeamResolved; + Assert.IsFalse(BattleStatusLook.RaidBossDazed(round), "건너뛰기가 정해지기 전(팀 차례)에는 별이 없다"); + round.Stage = RaidRoundStage.BossResolved; + Assert.IsTrue(BattleStatusLook.RaidBossDazed(round)); + round.Stage = RaidRoundStage.Completed; + Assert.IsTrue(BattleStatusLook.RaidBossDazed(round)); + Assert.IsFalse(BattleStatusLook.RaidBossDazed(null)); + } + + [Test] + public void StatusCurves_FadeAtBothEnds_AndStayGentle() + { + BattleStatusLook.Bubble(0f, out _, out _, out float a0); + BattleStatusLook.Bubble(1f, out _, out float s1, out float a1); + BattleStatusLook.Bubble(0.5f, out _, out _, out float aMid); + Assert.AreEqual(0f, a0, 0.001f); + Assert.AreEqual(0f, a1, 0.001f); + Assert.AreEqual(1f, aMid, 0.001f); + Assert.Greater(s1, 1f, "끝에서 톡 부푼다"); + BattleStatusLook.Streak(0f, out float st0, out _); + BattleStatusLook.Streak(1f, out float st1, out _); + Assert.AreEqual(0f, st0, 0.001f); + Assert.AreEqual(0f, st1, 0.001f); + for (float t = 0f; t < 10f; t += 0.1f) + { + float shield = BattleStatusLook.ShieldAlpha(t, 1.3f, false); + Assert.That(shield, Is.InRange(0.18f, 0.41f), "육각 막은 은은하다"); + Assert.AreEqual(0.26f, BattleStatusLook.ShieldAlpha(t, 1.3f, true), 0.0001f, "섬광 줄이기면 일렁이지 않는다"); + } + } + + // ── 숨쉬기 ── + + [Test] + public void Breathing_IsSmallAndBounded() + { + for (float t = 0f; t < 12f; t += 0.07f) + { + float b = BattleFlourish.Breath(t, BattleFlourish.BreathPeriod, 0.3f); + Assert.That(b, Is.InRange(-1f, 1f)); + float nod = BattleFlourish.AntennaNod(t, 0.3f); + Assert.That(nod, Is.InRange(-4.01f, 18.01f)); + } + Assert.Less(BattleFlourish.BreathAmplitude, 0.06f, "숨쉬기가 크면 맞은 것처럼 보인다"); + } + + private static Vector3 Flat(Vector3 v) + { + v.y = 0f; + return v.normalized; + } + + private static void AssertFinite(BattleMotion.Pose pose) + { + foreach (float v in new[] { pose.Travel, pose.Lift, pose.Pitch, pose.Roll, pose.Yaw }) + Assert.IsFalse(float.IsNaN(v) || float.IsInfinity(v)); + } + + private static void AssertRest(BattleMotion.Pose pose) + { + Assert.AreEqual(0f, pose.Travel, 0.0001f); + Assert.AreEqual(0f, pose.Lift, 0.0001f); + Assert.AreEqual(0f, pose.Pitch, 0.0001f); + Assert.AreEqual(0f, pose.Roll, 0.0001f); + Assert.AreEqual(0f, Mathf.Repeat(pose.Yaw + 180f, 360f) - 180f, 0.01f); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/BattleFlourishTests.cs.meta b/Assets/Tests/EditMode/BattleFlourishTests.cs.meta new file mode 100644 index 0000000..acf8234 --- /dev/null +++ b/Assets/Tests/EditMode/BattleFlourishTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 69a74b79e7541834f9bd79e8f53636e7 \ No newline at end of file diff --git a/Assets/Tests/EditMode/BattleFramingTests.cs b/Assets/Tests/EditMode/BattleFramingTests.cs new file mode 100644 index 0000000..92dcd20 --- /dev/null +++ b/Assets/Tests/EditMode/BattleFramingTests.cs @@ -0,0 +1,83 @@ +#if UNITY_EDITOR +using InsectGame.Battle; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + [TestFixture] + public class BattleFramingTests + { + [TestCase(1f, 1f)] + [TestCase(5f, 3f)] + [TestCase(0.1f, 6f)] + public void DuelSpacing_PreservesSilhouetteGap(float allyWidth, float opponentWidth) + { + Bounds ally = new Bounds(Vector3.zero, new Vector3(allyWidth, 1f, 1f)); + Bounds enemy = new Bounds(Vector3.zero, new Vector3(opponentWidth, 1f, 1f)); + float half = BattleFraming.DuelHalfSeparation(ally, enemy); + Assert.GreaterOrEqual(half, 1.55f); + Assert.GreaterOrEqual(half * 2f - ally.extents.x - enemy.extents.x, 0.899f); + } + [TestCase(1.33333f)] + [TestCase(2.33333f)] + public void Framing_AsymmetricNotch_KeepsAllCornersInSafeBattleArea(float aspect) + { + var go = new GameObject("Safe battle framing"); + try + { + Camera cam = go.AddComponent(); + cam.aspect = aspect; + cam.fieldOfView = 40f; + Rect safe = new Rect(0.08f, 0.035f, 0.9f, 0.94f); + Bounds bounds = new Bounds(new Vector3(1000f, 1.2f, 1000f), new Vector3(8f, 4f, 5f)); + BattleFraming.Compute(bounds, aspect, cam.fieldOfView, Quaternion.Euler(12f, -12f, 0f), safe, + out Vector3 pos, out Vector3 target); + cam.transform.position = pos; + cam.transform.LookAt(target); + for (int i = 0; i < 8; i++) + { + Vector3 corner = bounds.center + Vector3.Scale(bounds.extents, new Vector3( + (i & 1) == 0 ? -1 : 1, (i & 2) == 0 ? -1 : 1, (i & 4) == 0 ? -1 : 1)); + Vector3 v = cam.WorldToViewportPoint(corner); + Assert.That(v.x, Is.InRange(Mathf.Lerp(safe.xMin, safe.xMax, 0.079f), Mathf.Lerp(safe.xMin, safe.xMax, 0.921f))); + Assert.That(v.y, Is.InRange(Mathf.Lerp(safe.yMin, safe.yMax, 0.399f), Mathf.Lerp(safe.yMin, safe.yMax, 0.841f))); + } + } + finally { Object.DestroyImmediate(go); } + } + + [TestCase(1.33333f, 1f)] + [TestCase(1.77778f, 1f)] + [TestCase(2.33333f, 1f)] + [TestCase(1.33333f, 2.5f)] + [TestCase(2.33333f, 2.5f)] + public void Framing_AllCornersAboveControlsAndBelowHud(float aspect, float size) + { + GameObject go = new GameObject("FramingTest"); + try + { + Camera cam = go.AddComponent(); + cam.aspect = aspect; + cam.fieldOfView = 60f; + Bounds bounds = new Bounds(new Vector3(1000f, 0.8f, 1000f), new Vector3(6f, 2f, 3f) * size); + BattleFraming.Compute(bounds, aspect, cam.fieldOfView, out Vector3 pos, out Vector3 target); + cam.transform.position = pos; + cam.transform.LookAt(target); + for (int i = 0; i < 8; i++) + { + Vector3 corner = bounds.center + Vector3.Scale(bounds.extents, new Vector3( + (i & 1) == 0 ? -1 : 1, (i & 2) == 0 ? -1 : 1, (i & 4) == 0 ? -1 : 1)); + Vector3 viewport = cam.WorldToViewportPoint(corner); + Assert.That(viewport.x, Is.InRange(0.079f, 0.921f)); + Assert.That(viewport.y, Is.InRange(0.339f, 0.771f)); + Assert.Greater(viewport.z, 0f); + } + Assert.Less(cam.WorldToViewportPoint(bounds.center + Vector3.left * size).x, + cam.WorldToViewportPoint(bounds.center + Vector3.right * size).x); + } + finally { Object.DestroyImmediate(go); } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/BattleFramingTests.cs.meta b/Assets/Tests/EditMode/BattleFramingTests.cs.meta new file mode 100644 index 0000000..0660b43 --- /dev/null +++ b/Assets/Tests/EditMode/BattleFramingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c1b6a6882a7364e4684c3e0bf27f6d58 \ No newline at end of file diff --git a/Assets/Tests/EditMode/BattleImpactFeelTests.cs b/Assets/Tests/EditMode/BattleImpactFeelTests.cs new file mode 100644 index 0000000..7a89b34 --- /dev/null +++ b/Assets/Tests/EditMode/BattleImpactFeelTests.cs @@ -0,0 +1,444 @@ +#if UNITY_EDITOR +using System.Reflection; +using InsectGame.Battle; +using InsectGame.Core; +using InsectGame.Data; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 전투 타격감의 순수 부분 — 연출 카메라 샷, 히트스톱·슬로모션 시계, 외침 분류, 타격 세기(평타·치명타·마무리 구간), + /// 레이드 쓰러짐 대기. 화면은 QA 캡처(BattleVisualCapture -battleCamStyle)로 보고, 여기선 "원위치로 돌아오는가" + /// "멈춘 시계가 풀리는가" "평타가 치명타만큼 무겁지 않은가"처럼 눈으로 놓치기 쉬운 계약만 고정한다. + /// 하나만() 진짜 아레나를 세운다 — 렌더 없이 모델 활성만 본다. + /// + [TestFixture] + public class BattleImpactFeelTests + { + private static readonly Vector3 BasePos = new Vector3(0f, 2f, -8f); + private static readonly Quaternion BaseRot = Quaternion.LookRotation(new Vector3(0f, -2f, 8f)); + private static readonly Vector3 Attacker = new Vector3(-2f, 0.6f, 0f); + private static readonly Vector3 Target = new Vector3(2f, 0.6f, 0f); + + private static BattleCameraDirector.Shot Eval(BattleCameraDirector.Style style, float p, + float weight = 0f, bool support = false) + { + return BattleCameraDirector.Evaluate(style, p, 2.5f, BasePos, BaseRot, Attacker, Target, weight, support); + } + + // ── 연출 카메라 ── + + [Test] + public void CameraShot_Off_NeverMovesCamera() + { + for (float p = 0f; p <= 1f; p += 0.05f) + Assert.AreEqual(0f, Eval(BattleCameraDirector.Style.Off, p).Weight); + } + + [TestCase(BattleCameraDirector.Style.Punch)] + [TestCase(BattleCameraDirector.Style.Cinematic)] + public void CameraShot_TimelineEnd_ReturnsToBaseFraming(BattleCameraDirector.Style style) + { + // 연출이 끝났는데 샷이 남으면 다음 턴 버튼을 누르는 동안에도 카메라가 붙어 있다. + Assert.AreEqual(0f, Eval(style, 0.85f, 1f).Weight); + Assert.AreEqual(0f, Eval(style, 1f, 1f).Weight); + } + + [Test] + public void CameraShot_Cinematic_WindupFramesAttacker_ImpactFramesTarget() + { + BattleCameraDirector.Shot windup = Eval(BattleCameraDirector.Style.Cinematic, 0.16f); + BattleCameraDirector.Shot impact = Eval(BattleCameraDirector.Style.Cinematic, BattleMotion.ImpactProgress + 0.02f); + Assert.AreEqual(1f, windup.Weight, 0.001f); + Assert.Less(Vector3.Distance(windup.Position, Attacker), Vector3.Distance(BasePos, Attacker)); + Assert.AreEqual(1f, impact.Weight, 0.001f); + Assert.Less(Vector3.Distance(impact.Position, Target), Vector3.Distance(BasePos, Target)); + } + + [Test] + public void CameraShot_Cinematic_HeavyHitMovesCloser() + { + float p = 0.5f; // 반동 진동이 0을 지나는 순간을 피해 유지 구간 중간 + float light = Vector3.Distance(Eval(BattleCameraDirector.Style.Cinematic, p, 0f).Position, Target); + float heavy = Vector3.Distance(Eval(BattleCameraDirector.Style.Cinematic, p, 1f).Position, Target); + Assert.Less(heavy, light); + } + + [Test] + public void CameraShot_Punch_OnlyAfterImpact_AndDecays() + { + Assert.AreEqual(0f, Eval(BattleCameraDirector.Style.Punch, 0.3f, 1f).Weight); + float early = Eval(BattleCameraDirector.Style.Punch, BattleMotion.ImpactProgress + 0.01f, 1f).Weight; + float late = Eval(BattleCameraDirector.Style.Punch, BattleMotion.ImpactProgress + 0.1f, 1f).Weight; + Assert.Greater(early, 0.5f); + Assert.Less(late, early); + } + + [Test] + public void CameraShot_Support_NeverCutsToTarget() + { + for (float p = 0f; p < 1f; p += 0.05f) + { + BattleCameraDirector.Shot shot = Eval(BattleCameraDirector.Style.Cinematic, p, 1f, support: true); + if (shot.Weight <= 0f) continue; + Assert.Less(Vector3.Distance(shot.Position, Attacker), Vector3.Distance(shot.Position, Target), + $"p={p}: 강화 연출이 상대 쪽으로 넘어갔다"); + } + } + + // ── 레이드 합체공격 ── + + [Test] + public void UniteTimeline_FullTeamHitsBeforeFinalStrike() + { + // 5인 팀의 마지막 타격이 "합동 일격"보다 늦으면 일격 뒤에 혼자 때리는 팀원이 생긴다 + // (옛 오버레이 숫자로는 1.55초 > 1.5초였다). + Assert.Less(RaidUniteTimeline.MemberHit(4), RaidUniteTimeline.FinalStrike); + Assert.Greater(RaidUniteTimeline.TotalReveal, RaidUniteTimeline.FinalStrike); + Assert.GreaterOrEqual(RaidUniteTimeline.Total, RaidUniteTimeline.TotalReveal + 0.4f, + "TOTAL이 뜨고 읽힐 시간 없이 연출이 끝난다"); + } + + [Test] + public void UniteShot_StartsAndEndsOnBaseFraming_AndRushesInAtFinalStrike() + { + Vector3 team = new Vector3(0f, 0.5f, -2f); + Vector3 boss = new Vector3(0f, 2.2f, 3f); + Vector3 camPos = new Vector3(0f, 1.6f, -9.5f); + Quaternion camRot = Quaternion.LookRotation(boss - camPos); + Assert.AreEqual(0f, BattleCameraDirector.EvaluateUnite(0f, camPos, camRot, team, boss).Weight); + Assert.AreEqual(0f, BattleCameraDirector.EvaluateUnite(RaidUniteTimeline.Total, camPos, camRot, team, boss).Weight); + BattleCameraDirector.Shot strike = BattleCameraDirector.EvaluateUnite( + RaidUniteTimeline.FinalStrike - 0.001f, camPos, camRot, team, boss); + Assert.AreEqual(1f, strike.Weight, 0.001f); + Assert.Less(Vector3.Distance(strike.Position, boss), Vector3.Distance(camPos, boss)); + // 붙더라도 팀 줄을 넘지 않는다 — 넘으면 팀원이 카메라 뒤로 사라진다. + Assert.Less(strike.Position.z, team.z); + } + + [Test] + public void Kick_ZeroBeforeImpact_AndSettles() + { + Assert.AreEqual(Vector3.zero, BattleCameraDirector.Kick(Vector3.right, -0.1f, 1f)); + Assert.Less(BattleCameraDirector.Kick(Vector3.right, 0.8f, 1f).magnitude, 0.01f); + } + + // ── 히트스톱·슬로모션 ── + + private float fakeNow; + private System.Func savedClock; + private float savedSpeed; + private bool savedReducedMotion; + private static readonly FieldInfo SpeedField = typeof(BattlePresentation).GetField("speed", BindingFlags.Static | BindingFlags.NonPublic); + private static readonly FieldInfo MotionField = typeof(BattlePresentation).GetField("reducedMotion", BindingFlags.Static | BindingFlags.NonPublic); + + [SetUp] + public void FakeClock() + { + savedClock = BattlePresentation.Clock; + savedSpeed = (float)SpeedField.GetValue(null); + savedReducedMotion = (bool)MotionField.GetValue(null); + // 세터는 PlayerPrefs에 쓰므로 필드를 직접 바꾼다(사용자 설정을 건드리지 않게). + SpeedField.SetValue(null, 1f); + MotionField.SetValue(null, false); + fakeNow = 100f; + BattlePresentation.Clock = () => fakeNow; + BattlePresentation.ClearTimeEffects(); + } + + [TearDown] + public void RestoreClock() + { + BattlePresentation.ClearTimeEffects(); + BattlePresentation.Clock = savedClock; + SpeedField.SetValue(null, savedSpeed); + MotionField.SetValue(null, savedReducedMotion); + } + + [Test] + public void HitStop_FreezesPresentation_ThenReleasesOnRealTime() + { + BattlePresentation.HitStop(0.1f); + Assert.AreEqual(0f, BattlePresentation.TimeScale); + fakeNow += 0.11f; + Assert.AreEqual(1f, BattlePresentation.TimeScale, "멈춘 시계로 만료를 세면 영영 안 풀린다"); + } + + [Test] + public void HitStop_Overlapping_ExtendsNotAccumulates() + { + BattlePresentation.HitStop(0.1f); + fakeNow += 0.05f; + BattlePresentation.HitStop(0.1f); // 남은 0.05 + 0.1이 아니라 지금부터 0.1 + fakeNow += 0.09f; + Assert.AreEqual(0f, BattlePresentation.TimeScale); + fakeNow += 0.02f; + Assert.AreEqual(1f, BattlePresentation.TimeScale); + } + + [Test] + public void HitStop_DoubleSpeed_IsHalved() + { + SpeedField.SetValue(null, 2f); + BattlePresentation.HitStop(0.1f); + fakeNow += 0.06f; + Assert.AreEqual(1f, BattlePresentation.TimeScale); + } + + [Test] + public void SlowMotion_ScalesThenReleases_AndHitStopWinsWhileBothActive() + { + BattlePresentation.SlowMotion(0.3f, 0.5f); + Assert.AreEqual(0.3f, BattlePresentation.TimeScale, 0.0001f); + BattlePresentation.HitStop(0.05f); + Assert.AreEqual(0f, BattlePresentation.TimeScale); + fakeNow += 0.1f; + Assert.AreEqual(0.3f, BattlePresentation.TimeScale, 0.0001f); + fakeNow += 0.5f; + Assert.AreEqual(1f, BattlePresentation.TimeScale); + } + + [Test] + public void SlowMotion_ReducedMotion_IsSkipped() + { + MotionField.SetValue(null, true); + BattlePresentation.SlowMotion(0.3f, 0.5f); + Assert.AreEqual(1f, BattlePresentation.TimeScale); + } + + [Test] + public void ClearTimeEffects_DropsPendingStop() + { + BattlePresentation.HitStop(1f); + BattlePresentation.SlowMotion(0.3f, 1f); + BattlePresentation.ClearTimeEffects(); + Assert.AreEqual(1f, BattlePresentation.TimeScale); + } + + // ── 외침 ── + + [TestCase("mantis", BattleShout.Cry.Mantis)] + [TestCase("mantis_ember", BattleShout.Cry.Mantis)] + [TestCase("dragonfly_jade", BattleShout.Cry.Flyer)] + [TestCase("firefly_marsh", BattleShout.Cry.Flyer)] + [TestCase("moth_pale", BattleShout.Cry.Flyer)] + [TestCase("fly_hover", BattleShout.Cry.Buzzer)] + [TestCase("bee_queen", BattleShout.Cry.Buzzer)] + [TestCase("hornet_emperor", BattleShout.Cry.Buzzer)] + [TestCase("cricket_tomb", BattleShout.Cry.Chirper)] + [TestCase("cicada_ancient", BattleShout.Cry.Chirper)] + [TestCase("ant", BattleShout.Cry.Crawler)] + [TestCase("antlion_pit", BattleShout.Cry.Crawler)] + [TestCase("spider_tomb", BattleShout.Cry.Crawler)] + [TestCase("rhinoceros_beetle", BattleShout.Cry.Beetle)] + [TestCase("stag_beetle_iron", BattleShout.Cry.Beetle)] + [TestCase("pill_bug_rock", BattleShout.Cry.Beetle)] + [TestCase(null, BattleShout.Cry.Beetle)] + public void CryFor_MatchesWholeTokensOnly(string id, BattleShout.Cry expected) + { + // 부분 문자열로 보면 mantis에 ant가, dragonfly에 fly가 걸린다. + Assert.AreEqual(expected, BattleShout.CryFor(id)); + } + + [Test] + public void Sound_EveryElementHasLightAndHeavyWord() + { + foreach (InsectElement element in System.Enum.GetValues(typeof(InsectElement))) + { + string light = BattleShout.Sound(element, 0f); + string heavy = BattleShout.Sound(element, 1f); + Assert.IsNotEmpty(light, element.ToString()); + Assert.IsTrue(heavy.EndsWith("!!"), $"{element}: 센 타격은 '!!'로 끝나야 오버레이가 크게 그린다"); + Assert.AreNotEqual(light, heavy, element.ToString()); + } + } + + [Test] + public void Hurt_EveryCryHasLines() + { + foreach (BattleShout.Cry cry in System.Enum.GetValues(typeof(BattleShout.Cry))) + { + Assert.IsNotEmpty(BattleShout.Hurt(cry, 0f, 1), cry.ToString()); + Assert.IsNotEmpty(BattleShout.Hurt(cry, 1f, 1), cry.ToString()); + } + } + + [TestCase("전력 돌진", "전력 돌진!!")] + [TestCase("전력 돌진!", "전력 돌진!!")] + [TestCase(" 날카로운 일격 ", "날카로운 일격!!")] + [TestCase("", "간다!!")] + [TestCase(null, "간다!!")] + public void Callout_NormalizesExclamation(string skill, string expected) + { + Assert.AreEqual(expected, BattleShout.Callout(skill)); + } + + // ── 타격 세기 ── + // + // 2026-10-04 값이 바뀌었다(진짜 치명타 도입과 짝). 예전 식은 피해 ÷ 최대 HP의 40%를 그대로 세기로 써서 + // 평타(최대 HP의 25~35%)가 0.63~0.88 — "무거운 타격"선 0.6 위 — 을 받아 매 타격이 치명타처럼 연출됐다. + // 지금은 같은 피해 몫이 평타면 [0.25, 0.5], 치명타면 [0.85, 1]로 놓인다. + + [TestCase(0, 200, false, 0f)] + [TestCase(50, 200, false, 0.40625f)] // 최대 HP의 25% 평타 — 예전 0.625(무거운 타격)였다 + [TestCase(80, 200, false, 0.5f)] // 40%면 평타 천장 + [TestCase(200, 200, false, 0.5f)] // 아무리 세도 평타는 천장에서 멈춘다 + [TestCase(10, 200, true, 0.86875f)] // 치명타는 적게 들어가도 무겁게 + [TestCase(80, 200, true, 1f)] + [TestCase(10, 0, false, 0f)] + public void HitCueWeight_ScalesWithDamageShare(int damage, int maxHp, bool critical, float expected) + { + Assert.AreEqual(expected, BattleArenaController.HitCue.WeightFor(damage, maxHp, critical), 0.001f); + } + + private static BattleArenaController.HitCue Cue(int damage, int maxHp, bool critical, bool finisher = false) + { + return new BattleArenaController.HitCue(null, + BattleArenaController.HitCue.WeightFor(damage, maxHp, critical), finisher, false, critical); + } + + [Test] + public void HitCueWeight_StrongestNormal_StaysBelowWeakestCritical() + { + const int maxHp = 200; + float strongestNormal = 0f; + float weakestCritical = 1f; + for (int damage = 1; damage <= maxHp; damage++) + { + strongestNormal = Mathf.Max(strongestNormal, BattleArenaController.HitCue.WeightFor(damage, maxHp, false)); + weakestCritical = Mathf.Min(weakestCritical, BattleArenaController.HitCue.WeightFor(damage, maxHp, true)); + } + Assert.Less(strongestNormal, BattleShout.HeavyWeight, "평타가 큰 비명·센 의성어 문턱에 닿는다"); + Assert.Less(strongestNormal, weakestCritical, "가장 센 평타가 가장 약한 치명타보다 무겁다"); + Assert.GreaterOrEqual(weakestCritical, BattleArenaController.HitCue.CriticalFloor); + } + + [Test] + public void HitCue_NormalHit_GetsLightWordsAndNoHeavyFlag() + { + // 3~4라운드 전투의 평타 = 최대 HP의 25~35%. 예전엔 이게 전부 CriticalHit 소리·"크아악!!"·"콰광!!"이었다. + BattleArenaController.HitCue normal = Cue(70, 200, false); + Assert.IsFalse(normal.Heavy); + foreach (InsectElement element in System.Enum.GetValues(typeof(InsectElement))) + Assert.IsFalse(BattleShout.Sound(element, normal.Weight).EndsWith("!!"), $"{element}: 평타에 센 의성어"); + Assert.AreEqual(BattleShout.Hurt(BattleShout.Cry.Beetle, 0f, 1), + BattleShout.Hurt(BattleShout.Cry.Beetle, normal.Weight, 1), "평타에 큰 비명"); + + Assert.IsTrue(Cue(20, 200, true).Heavy, "약한 치명타도 치명타 연출을 받는다"); + Assert.IsTrue(Cue(5, 200, false, finisher: true).Heavy, "마무리는 평타로 끝내도 크게"); + } + + [Test] + public void HitCue_Tiers_NormalLighterThanCritical_FinisherHeaviest() + { + BattleArenaController.HitCue strongestNormal = Cue(200, 200, false); + BattleArenaController.HitCue weakestCritical = Cue(1, 200, true); + BattleArenaController.HitCue strongestCritical = Cue(200, 200, true); + BattleArenaController.HitCue weakFinisher = Cue(5, 200, false, finisher: true); + + Assert.Less(strongestNormal.HitStopSeconds, weakestCritical.HitStopSeconds, "평타가 치명타만큼 멈춘다"); + Assert.Less(strongestNormal.ShakeAmplitude(), weakestCritical.ShakeAmplitude(), "평타가 치명타만큼 흔든다"); + Assert.Less(strongestNormal.ShakeSeconds(), weakestCritical.ShakeSeconds()); + Assert.Less(strongestNormal.Weight, weakestCritical.Weight, "평타가 치명타만큼 밀려난다(넉백은 세기를 따른다)"); + + // 마무리는 지금처럼 가장 크게 — 평타로 끝냈어도 가장 센 치명타 이상. + Assert.AreEqual(1f, weakFinisher.Weight); + Assert.GreaterOrEqual(weakFinisher.HitStopSeconds, strongestCritical.HitStopSeconds); + Assert.GreaterOrEqual(weakFinisher.ShakeAmplitude(), strongestCritical.ShakeAmplitude()); + + // 평타의 멈춤은 짧게 — 히트스톱이 0.1초를 넘으면 평타마다 끊겨 보인다. + Assert.Less(strongestNormal.HitStopSeconds, 0.1f); + BattleArenaController.HitCue critFinisher = Cue(200, 200, true, finisher: true); + Assert.LessOrEqual(critFinisher.HitStopSeconds, BattleArenaController.HitCue.MaxHitStop); + } + + [Test] + public void HitCue_AreaNormal_ShakesLessThanSingleCritical() + { + // 레이드 보스 전체 공격은 큰 기술이라 흔들림을 얹지만, 치명타 한 방의 몫을 빼앗지 않는다. + // 예전엔 전체 공격이면 늘 흔들림 0.55·CriticalHit 소리라 치명타 전체 공격과 구별이 없었다. + Assert.Less(Cue(200, 200, false).ShakeAmplitude(area: true), Cue(1, 200, true).ShakeAmplitude()); + Assert.Greater(Cue(70, 200, false).ShakeAmplitude(area: true), Cue(70, 200, false).ShakeAmplitude()); + Assert.Greater(Cue(1, 200, true).ShakeAmplitude(area: true), Cue(1, 200, true).ShakeAmplitude()); + } + + [Test] + public void HitCue_Missed_NeitherStopsShakesNorCountsAsCritical() + { + var missed = new BattleArenaController.HitCue("스킬", 0f, false, true, true); + Assert.IsFalse(missed.Critical, "빗나간 치명타는 없다"); + Assert.IsFalse(missed.Heavy); + Assert.AreEqual(0f, missed.HitStopSeconds); + Assert.AreEqual(0f, missed.ShakeAmplitude()); + Assert.AreEqual(0f, BattleArenaController.HitCue.None.Weight); + Assert.IsFalse(BattleArenaController.HitCue.None.Heavy); + } + + [Test] + public void UniteFinalStrike_StopsLongerThanAnySingleCritical() + { + // 다섯이 함께 친 정점이 한 마리의 치명타보다 짧게 멈추면 순서가 뒤집혀 보인다. + Assert.Greater(BattleArenaController.UniteFinalHitStop, Cue(200, 200, true).HitStopSeconds); + } + + [Test] + public void CriticalAccent_GoldForOurs_RedForTheirs() + { + Color ours = BattleArenaController.CriticalAccent(true); + Color theirs = BattleArenaController.CriticalAccent(false); + Assert.Greater(ours.g, 0.7f, "내 치명타는 금빛(초록 성분이 높다)"); + Assert.Less(theirs.g, 0.45f, "맞은 치명타는 붉은빛"); + Assert.Greater(theirs.r, 0.9f); + } + + // ── 레이드 쓰러짐 ── + + [Test] + public void RaidFaintHold_CoversArenaFaint_WithoutStalling() + { + // 레이드 UI가 쓰러짐 동안 다음 전이(결과·다음 차례)를 미루는 길이. 아레나 쓰러짐보다 짧으면 RAID CLEAR가 + // 눕는 보스를 덮고, 길면 매 쓰러짐마다 화면이 멈춘다. 예전엔 보스 공격 최소 길이(0.8초)를 빌려 썼다. + FieldInfo field = typeof(InsectGame.UI.RaidBattleUI).GetField("FaintHoldDuration", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.IsNotNull(field, "RaidBattleUI.FaintHoldDuration이 없다"); + float hold = (float)field.GetRawConstantValue(); + Assert.Greater(hold, BattleArenaController.FaintSeconds); + Assert.LessOrEqual(hold, BattleArenaController.FaintSeconds + 0.3f); + } + + [Test] + public void RaidSetup_MembersDownAtStart_AreNotStanding() + { + // 레이드를 시작할 때 이미 기절해 있던 팀원이 아레나에 멀쩡히 서 있었다. 컨트롤러는 그 슬롯을 처음부터 + // "쓰러짐을 보여 줬다"로 쳐서 레이드 내내 아무도 눕히지 않는다 — 셋업이 처음부터 세우지 않아야 한다. + var data = ScriptableObject.CreateInstance(); + data.insectId = "beetle_basic"; + data.displayName = "검사용"; + var root = new GameObject("RaidDownAtStartTest"); + try + { + var arena = root.AddComponent(); + arena.SetupRaidBattle(data, 10, false, new[] { data, data, data }, new[] { 5, 5, 5 }, Vector3.zero, + new[] { false, true, false }); + Assert.AreEqual(3, arena.TeamModelCount); + Assert.IsTrue(arena.GetTeamModel(0).activeInHierarchy); + Assert.IsFalse(arena.GetTeamModel(1).activeInHierarchy, "시작 때부터 기절한 팀원이 서 있다"); + Assert.IsTrue(arena.GetTeamModel(2).activeInHierarchy); + + // 옛 호출(기절 정보 없음)은 전원을 세운다. + arena.SetupRaidBattle(data, 10, false, new[] { data, data }, new[] { 5, 5 }, Vector3.zero); + Assert.IsTrue(arena.GetTeamModel(0).activeInHierarchy); + Assert.IsTrue(arena.GetTeamModel(1).activeInHierarchy); + } + finally + { + // 아레나 루트는 CleanupArena(OnDisable)가 지운다. + Object.DestroyImmediate(root); + Object.DestroyImmediate(data); + } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/BattleImpactFeelTests.cs.meta b/Assets/Tests/EditMode/BattleImpactFeelTests.cs.meta new file mode 100644 index 0000000..b457d88 --- /dev/null +++ b/Assets/Tests/EditMode/BattleImpactFeelTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e171267e9ea61814b84d2b1cf04c4767 \ No newline at end of file diff --git a/Assets/Tests/EditMode/BattleMotionTests.cs b/Assets/Tests/EditMode/BattleMotionTests.cs new file mode 100644 index 0000000..a41e91b --- /dev/null +++ b/Assets/Tests/EditMode/BattleMotionTests.cs @@ -0,0 +1,107 @@ +#if UNITY_EDITOR +using InsectGame.Battle; +using NUnit.Framework; + +namespace InsectGame.Tests +{ + [TestFixture] + public class BattleMotionTests + { + [TestCase("rhinoceros_beetle", true, false, BattleMotion.Kind.Charge)] + [TestCase("stag_beetle", true, false, BattleMotion.Kind.Charge)] + [TestCase("bee_queen", true, false, BattleMotion.Kind.Sting)] // 4단계 — 벌은 찌르기 돌진(나는 종의 내리꽂기와 가른다) + [TestCase("spider_garden", true, false, BattleMotion.Kind.Pounce)] + [TestCase("ant_soldier", true, false, BattleMotion.Kind.Lunge)] + [TestCase("RHINOCEROS_BEETLE", true, false, BattleMotion.Kind.Charge)] + [TestCase(null, true, false, BattleMotion.Kind.Charge)] + [TestCase("mantis", true, false, BattleMotion.Kind.Slash)] + [TestCase("dragonfly", true, false, BattleMotion.Kind.Flight)] + [TestCase("monarch_butterfly", true, false, BattleMotion.Kind.Flight)] + [TestCase("ant", false, false, BattleMotion.Kind.Projectile)] + [TestCase("mantis", false, false, BattleMotion.Kind.Projectile)] + [TestCase("dragonfly", false, false, BattleMotion.Kind.Projectile)] + [TestCase("mantis", true, true, BattleMotion.Kind.Support)] + [TestCase("dragonfly", false, true, BattleMotion.Kind.Support)] + public void Resolve_SpeciesAndAction_SelectsExpectedProfile(string species, bool melee, bool support, BattleMotion.Kind expected) + { + Assert.AreEqual(expected, BattleMotion.Resolve(species, melee, support)); + } + + [TestCase(BattleMotion.Kind.Charge)] + [TestCase(BattleMotion.Kind.Slash)] + [TestCase(BattleMotion.Kind.Flight)] + [TestCase(BattleMotion.Kind.Projectile)] + [TestCase(BattleMotion.Kind.Support)] + [TestCase(BattleMotion.Kind.Sting)] + [TestCase(BattleMotion.Kind.Pounce)] + [TestCase(BattleMotion.Kind.Lunge)] + public void Evaluate_NormalizedSequence_IsFiniteAndReturnsHome(BattleMotion.Kind kind) + { + for (int sample = -10; sample <= 110; sample++) + { + var pose = BattleMotion.Evaluate(kind, sample / 100f); + AssertFinite(pose.Travel); + AssertFinite(pose.Lift); + AssertFinite(pose.Pitch); + AssertFinite(pose.Roll); + } + AssertAtRest(kind, 0f); + AssertAtRest(kind, 1f); + AssertAtRest(kind, 1.1f); + } + + [Test] + public void MeleeProfiles_ImpactMatchesSharedTimingAndDistinctSilhouettes() + { + Assert.AreEqual(0.4f, BattleMotion.ImpactProgress, 0.00001f); + var charge = BattleMotion.Evaluate(BattleMotion.Kind.Charge, BattleMotion.ImpactProgress); + var slash = BattleMotion.Evaluate(BattleMotion.Kind.Slash, BattleMotion.ImpactProgress); + Assert.Greater(charge.Travel, 0f); + Assert.Greater(slash.Travel, 0f); + Assert.Less(slash.Travel, charge.Travel, "Slash stays within a shorter contact range than charge."); + float chargeLift = 0f; + float flightLift = 0f; + for (int i = 0; i <= 100; i++) + { + chargeLift = System.Math.Max(chargeLift, BattleMotion.Evaluate(BattleMotion.Kind.Charge, i / 100f).Lift); + flightLift = System.Math.Max(flightLift, BattleMotion.Evaluate(BattleMotion.Kind.Flight, i / 100f).Lift); + } + Assert.Greater(flightLift, chargeLift, "Flight has a visibly higher arc than ground charge."); + } + + [TestCase(BattleMotion.Kind.Charge)] + [TestCase(BattleMotion.Kind.Slash)] + [TestCase(BattleMotion.Kind.Flight)] + [TestCase(BattleMotion.Kind.Sting)] + [TestCase(BattleMotion.Kind.Pounce)] + [TestCase(BattleMotion.Kind.Lunge)] + public void MeleeMotion_PreservesAnticipationAndRecovery(BattleMotion.Kind kind) + { + Assert.Less(BattleMotion.Evaluate(kind, 0.12f).Travel, 0f, + "Anticipation moves away before contact; the arena must use LerpUnclamped."); + Assert.Greater(BattleMotion.Evaluate(kind, 0.55f).Travel, 0f); + AssertAtRest(kind, 0.84f); + } + + [Test] + public void SupportProfile_NeverRushesOrTilts() + { + for (int i = 0; i <= 100; i++) AssertAtRest(BattleMotion.Kind.Support, i / 100f); + } + + private static void AssertAtRest(BattleMotion.Kind kind, float progress) + { + var pose = BattleMotion.Evaluate(kind, progress); + Assert.AreEqual(0f, pose.Travel, 0.0001f); + Assert.AreEqual(0f, pose.Lift, 0.0001f); + Assert.AreEqual(0f, pose.Pitch, 0.0001f); + Assert.AreEqual(0f, pose.Roll, 0.0001f); + } + + private static void AssertFinite(float value) + { + Assert.IsFalse(float.IsNaN(value) || float.IsInfinity(value)); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/BattleMotionTests.cs.meta b/Assets/Tests/EditMode/BattleMotionTests.cs.meta new file mode 100644 index 0000000..c002b4d --- /dev/null +++ b/Assets/Tests/EditMode/BattleMotionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6734a39f723d69b488fefb3d2517c451 \ No newline at end of file diff --git a/Assets/Tests/EditMode/BattlePacingTests.cs b/Assets/Tests/EditMode/BattlePacingTests.cs new file mode 100644 index 0000000..76a8ca5 --- /dev/null +++ b/Assets/Tests/EditMode/BattlePacingTests.cs @@ -0,0 +1,414 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Reflection; +using InsectGame.Battle; +using InsectGame.Core; +using InsectGame.Data; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + [TestFixture] + public class BattlePacingTests + { + private readonly List objects = new List(); + private InsectBattleStats player; + private InsectBattleStats enemy; + + [TearDown] + public void Cleanup() + { + foreach (var obj in objects) if (obj != null) UnityEngine.Object.DestroyImmediate(obj); + objects.Clear(); + } + + private InsectBattleController CreateBattle(int rarity, int level, int power, bool wild = true) + { + var data = ScriptableObject.CreateInstance(); + objects.Add(data); + data.insectId = "pacing_fixture"; + data.baseHp = 49 + rarity * 18; + data.baseAtk = 19 + rarity * 9; + data.baseDef = 14 + rarity * 7; + data.primaryType = InsectElement.None; + var skill = ScriptableObject.CreateInstance(); + objects.Add(skill); + skill.effectType = SkillEffectType.Damage; + skill.element = InsectElement.None; + skill.power = power; + skill.accuracy = 1f; + skill.cooldownTurns = 2; + data.skills = new[] { skill }; + var go = new GameObject("BattlePacingFixture"); + objects.Add(go); + var controller = go.AddComponent(); + controller.SetCritSource(null); // 치명타(1/16)가 끼면 정확한 피해·라운드 단언이 들쭉날쭉해진다 + controller.StartDuel(data, level, data, level, (p, e) => { player = p; enemy = e; }); + if (wild) typeof(InsectBattleController).GetField("duelMode", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(controller, false); + return controller; + } + + [Test] + public void NeutralMatrix_EqualStats_MedianBetweenFourAndSixRounds() + { + var rounds = new List(); + foreach (int rarity in new[] { 0, 1, 2, 3, 4 }) + foreach (int level in new[] { 5, 15, 30, 50 }) + foreach (int power in new[] { 18, 30, 45 }) + { + var battle = CreateBattle(rarity, level, power); + int count = 0; + while (player.CurrentHp > 0 && enemy.CurrentHp > 0 && count < 30) + { + if (battle.CanUseSkill(0)) battle.UseSkill(0); else battle.UseBasicAttack(); + count++; + } + rounds.Add(count); + } + rounds.Sort(); + float median = (rounds[29] + rounds[30]) / 2f; + TestContext.WriteLine($"60 neutral fixtures: min={rounds[0]}, median={median}, max={rounds[59]}"); + Assert.That(median, Is.InRange(4f, 6f)); + Assert.Less(rounds[59], 12, "Neutral fights must not become attrition stalls."); + } + + [Test] + public void WildPacing_LeavesDuelDamageAndMaximumHpUnchanged() + { + var wild = CreateBattle(2, 15, 30); + int maximum = enemy.MaxHp; + wild.UseSkill(0); + int wildDamage = maximum - wild.EnemyHpAfterPlayerAction; + var duel = CreateBattle(2, 15, 30, false); + Assert.AreEqual(maximum, enemy.MaxHp); + duel.UseSkill(0); + int duelDamage = maximum - duel.EnemyHpAfterPlayerAction; + Assert.Greater(duelDamage, wildDamage); + Assert.AreEqual(Mathf.RoundToInt(45f * 1.5f), duelDamage); + } + + [Test] + public void Resolution_SnapshotsPlayerActionBeforeEnemyReply() + { + var battle = CreateBattle(2, 15, 30); + int hp = player.CurrentHp; + battle.UseSkill(0); + Assert.IsTrue(battle.EnemyActedThisRound); + Assert.AreEqual(hp, battle.PlayerHpAfterPlayerAction); + Assert.Less(player.CurrentHp, battle.PlayerHpAfterPlayerAction); + Assert.AreEqual(enemy.CurrentHp, battle.EnemyHpAfterPlayerAction); + } + + [Test] + public void LethalPlayerAction_DoesNotInventEnemyReply() + { + var battle = CreateBattle(0, 5, 1000); + battle.UseSkill(0); + Assert.AreEqual(0, enemy.CurrentHp); + Assert.IsFalse(battle.EnemyActedThisRound); + } + + [Test] + public void StunnedEnemy_DoesNotCreateAnAttackPresentation() + { + var battle = CreateBattle(2, 15, 30); + typeof(InsectBattleController).GetField("enemyStunTurns", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(battle, 1); + int hp = player.CurrentHp; + battle.UseSkill(0); + Assert.IsFalse(battle.EnemyActedThisRound); + Assert.AreEqual(hp, player.CurrentHp); + } + + [Test] + public void GuardianDamage_IsExcludedFromWildPacing() + { + var battle = CreateBattle(2, 15, 30); + typeof(InsectBattleController).GetProperty("EnemyGuardianRegionId").SetValue(battle, "forest"); + int hp = enemy.CurrentHp; + battle.UseSkill(0); + Assert.AreEqual(Mathf.RoundToInt(45f * 1.5f), hp - battle.EnemyHpAfterPlayerAction); + } + + [Test] + public void FatalEnemyReply_IsPresentedBeforeResult() + { + var battle = CreateBattle(2, 15, 1000, false); + var uiObject = new GameObject("BattlePhaseFixture"); + objects.Add(uiObject); + var ui = uiObject.AddComponent(); + ui.AutoWire(battle, null, null); + battle.StartDuel(player.Data, 15, enemy.Data, 15); + var flags = BindingFlags.NonPublic | BindingFlags.Instance; + var phase = typeof(InsectGame.UI.BattleScreenUI).GetField("phase", flags); + phase.SetValue(ui, Enum.Parse(phase.FieldType, "PlayerTurn")); + typeof(InsectGame.UI.BattleScreenUI).GetMethod("TryBasicAttack", flags).Invoke(ui, null); + Assert.AreEqual("PlayerAttack", phase.GetValue(ui).ToString()); + typeof(InsectGame.UI.BattleScreenUI).GetField("phaseTimer", flags).SetValue(ui, 3f); + typeof(InsectGame.UI.BattleScreenUI).GetMethod("Update", flags).Invoke(ui, null); + Assert.AreEqual("TurnAnnounce", phase.GetValue(ui).ToString(), "Fatal reply must precede result."); + typeof(InsectGame.UI.BattleScreenUI).GetMethod("FinishTurnAnnounce", flags).Invoke(ui, null); + Assert.AreEqual("EnemyAttack", phase.GetValue(ui).ToString()); + } + + private static void SetPrivate(object target, string name, object value) + { + target.GetType().GetField(name, BindingFlags.NonPublic | BindingFlags.Instance).SetValue(target, value); + } + + [Test] + public void PoisonAfterEnemyAction_IsNotCountedAsEnemyHit() + { + var battle = CreateBattle(2, 15, 30); + typeof(InsectBattleController).GetMethod("AddEffect", BindingFlags.NonPublic | BindingFlags.Instance) + .Invoke(battle, new object[] { true, 1000f, 2, InsectBattleController.EffectKind.Dot }); + battle.UseSkill(0); + Assert.Greater(battle.PlayerHpAfterEnemyAction, 0, "Enemy hit itself is survivable."); + Assert.AreEqual(0, player.CurrentHp, "The KO belongs to end-of-round poison."); + Assert.Greater(battle.PlayerHpAfterPlayerAction, battle.PlayerHpAfterEnemyAction); + } + + // 상대가 자기에게 건 버프는 상대 것이다 — 상태 줄(DrawHpBox)이 targetIsPlayer로 편을 가르고, + // 가운데 문구는 자리로 편을 알 수 없으니 "상대"를 붙인다. + [Test] + public void EnemySelfBuff_BelongsToTheEnemy_AndSaysSo() + { + var battle = CreateBattle(2, 15, 30); + var buff = ScriptableObject.CreateInstance(); + objects.Add(buff); + buff.effectType = SkillEffectType.BuffAttack; + buff.effectValue = 0.3f; + buff.effectDurationTurns = 3; + typeof(InsectBattleController).GetMethod("ApplySkill", BindingFlags.NonPublic | BindingFlags.Instance) + .Invoke(battle, new object[] { enemy, player, buff, false }); + + InsectBattleController.EffectSnapshot[] effects = battle.GetActiveEffects(); + Assert.AreEqual(1, effects.Length); + Assert.IsFalse(effects[0].targetIsPlayer, "상대의 자기 버프가 내 곤충에 걸렸다"); + Assert.AreEqual(InsectBattleController.EffectKind.AtkBuff, effects[0].kind); + Assert.Greater(effects[0].value, 0f); + + Assert.AreEqual("공격력 상승!", InsectBattleController.SidedText(true, "공격력 상승!")); + Assert.AreEqual("상대 공격력 상승!", InsectBattleController.SidedText(false, "공격력 상승!")); + } + + [Test] + public void PlayerStun_SkipsAnimationAndDoesNotSpendSkillCooldown() + { + var battle = CreateBattle(2, 15, 30); + SetPrivate(battle, "playerStunTurns", 1); + int enemyHp = enemy.CurrentHp; + battle.UseSkill(0); + Assert.IsFalse(battle.PlayerActedThisRound); + Assert.IsTrue(battle.EnemyActedThisRound); + Assert.AreEqual(enemyHp, battle.EnemyHpAfterPlayerAction); + Assert.IsTrue(battle.CanUseSkill(0)); + } + + [Test] + public void InvalidSkill_DoesNotResolveRoundOrChangeSnapshots() + { + var battle = CreateBattle(2, 15, 30); + int hp = player.CurrentHp; + int enemyHp = enemy.CurrentHp; + int updates = 0; + battle.BattleUpdated += (p, e) => updates++; + battle.UseSkill(-1); + battle.UseSkill(99); + Assert.AreEqual(0, updates); + Assert.AreEqual(hp, player.CurrentHp); + Assert.AreEqual(enemyHp, enemy.CurrentHp); + Assert.IsFalse(battle.PlayerActedThisRound); + Assert.IsFalse(battle.EnemyActedThisRound); + } + + [Test] + public void FailedEscape_OnlyResolvesEnemyReply() + { + int seed = 0; + for (; seed < 1000; seed++) + if (new BattleRandomSource(seed).Next01() >= 0.5f) break; + var battle = CreateBattle(2, 15, 30); + int before = enemy.CurrentHp; + battle.SetRandomSeed(seed); + Assert.IsFalse(battle.TryEscape()); + Assert.IsFalse(battle.DidEscape); + Assert.IsFalse(battle.PlayerActedThisRound); + Assert.IsTrue(battle.EnemyActedThisRound); + Assert.AreEqual(before, battle.EnemyHpAfterPlayerAction); + Assert.AreEqual(before, enemy.CurrentHp); + } + + [Test] + public void SuccessfulEscape_FlagAvailableToEventAndResetByNextBattle() + { + var battle = CreateBattle(2, 15, 30); + int seed = 0; + for (; seed < 1000; seed++) + if (new BattleRandomSource(seed).Next01() < 0.5f) break; + battle.SetRandomSeed(seed); + bool observedEscape = false; + int events = 0; + battle.BattleEnded += won => + { + observedEscape = battle.DidEscape; + events++; + Assert.IsFalse(won); + }; + Assert.IsTrue(battle.TryEscape()); + Assert.IsTrue(observedEscape, "Subscribers must distinguish escape from defeat in the callback."); + Assert.IsTrue(battle.DidEscape); + Assert.AreEqual(1, events); + Assert.IsFalse(battle.TryEscape(), "Ended battle cannot raise escape twice."); + Assert.AreEqual(1, events); + Assert.IsTrue(battle.StartDuel(player.Data, 15, enemy.Data, 15)); + Assert.IsFalse(battle.DidEscape, "A new battle cannot inherit the escape outcome."); + } + + [Test] + public void OrdinaryDefeat_DoesNotReportEscape() + { + var battle = CreateBattle(2, 15, 1000); + bool ended = false; + battle.BattleEnded += won => + { + ended = true; + Assert.IsFalse(won); + Assert.IsFalse(battle.DidEscape); + }; + battle.UseBasicAttack(); + Assert.AreEqual(0, player.CurrentHp); + Assert.IsTrue(ended); + Assert.IsFalse(battle.DidEscape); + } + + [TestCase(SkillEffectType.Heal)] + [TestCase(SkillEffectType.DefenseBuff)] + public void RepeatedRecoveryOrDefense_WithDamageFallback_DoesNotStall(SkillEffectType type) + { + var battle = CreateBattle(2, 15, 30); + var support = ScriptableObject.CreateInstance(); + objects.Add(support); + support.effectType = type; + support.effectValue = type == SkillEffectType.Heal ? 0.3f : 0.35f; + support.effectDurationTurns = 3; + support.cooldownTurns = 3; + support.accuracy = 1f; + SetPrivate(battle, "playerOverrideSkills", new[] { player.Data.skills[0], support }); + SetPrivate(battle, "playerCooldowns", new int[2]); + int rounds = 0; + int supportUses = 0; + while (player.CurrentHp > 0 && enemy.CurrentHp > 0 && rounds < 30) + { + bool useSupport = battle.CanUseSkill(1) && + (type == SkillEffectType.DefenseBuff || player.CurrentHp < player.MaxHp * 0.7f); + if (useSupport) { battle.UseSkill(1); supportUses++; } + else if (battle.CanUseSkill(0)) battle.UseSkill(0); + else battle.UseBasicAttack(); + rounds++; + } + TestContext.WriteLine($"{type}: rounds={rounds}, supportUses={supportUses}"); + Assert.Greater(supportUses, 0); + Assert.Less(rounds, 20, "A support rotation must not turn this neutral matchup into a stall."); + Assert.IsTrue(player.CurrentHp <= 0 || enemy.CurrentHp <= 0); + } + + [Test] + public void HealingSnapshot_PrecedesEnemyDamageAndHasNoDamageToEnemy() + { + var battle = CreateBattle(2, 15, 30); + var heal = ScriptableObject.CreateInstance(); + objects.Add(heal); + heal.effectType = SkillEffectType.Heal; + heal.effectValue = 0.3f; + SetPrivate(battle, "playerOverrideSkills", new[] { heal }); + player.ApplyDamage(60); + int before = player.CurrentHp; + int enemyBefore = enemy.CurrentHp; + battle.UseSkill(0); + Assert.Greater(battle.PlayerHpAfterPlayerAction, before); + Assert.Less(battle.PlayerHpAfterEnemyAction, battle.PlayerHpAfterPlayerAction); + Assert.AreEqual(enemyBefore, battle.EnemyHpAfterPlayerAction); + } + + [Test] + public void DisableEnableDuringAction_RestoresOwnershipAndCanFinishReply() + { + var battle = CreateBattle(2, 15, 30, false); + var go = new GameObject("BattleResumeFixture"); + objects.Add(go); + var ui = go.AddComponent(); + ui.AutoWire(battle, null, null); + battle.StartDuel(player.Data, 15, enemy.Data, 15); + var flags = BindingFlags.NonPublic | BindingFlags.Instance; + var phase = ui.GetType().GetField("phase", flags); + phase.SetValue(ui, Enum.Parse(phase.FieldType, "PlayerTurn")); + ui.GetType().GetMethod("TryBasicAttack", flags).Invoke(ui, null); + go.SetActive(false); + Assert.IsFalse(battle.DeferPresentation); + go.SetActive(true); + Assert.IsTrue(battle.DeferPresentation); + SetPrivate(ui, "phaseTimer", 3f); + ui.GetType().GetMethod("Update", flags).Invoke(ui, null); + Assert.AreEqual("TurnAnnounce", phase.GetValue(ui).ToString()); + Assert.IsTrue((bool)ui.GetType().GetField("impactRevealed", flags).GetValue(ui)); + } + + [Test] + public void MissesAndEscape_AreUnaffectedByPresentationRandomDraws() + { + var original = UnityEngine.Random.state; + try + { + for (int seed = 0; seed < 20; seed++) + { + var first = CreateBattle(2, 15, 30); + first.SetRandomSeed(seed); + player.Data.skills[0].accuracy = 0.55f; + first.UseSkill(0); + int expectedPlayer = player.CurrentHp; + int expectedEnemy = enemy.CurrentHp; + bool expectedEscape = first.TryEscape(); + int expectedAfterEscape = player.CurrentHp; + var second = CreateBattle(2, 15, 30); + second.SetRandomSeed(seed); + player.Data.skills[0].accuracy = 0.55f; + for (int draw = 0; draw < 127; draw++) _ = UnityEngine.Random.value; + second.UseSkill(0); + Assert.AreEqual(expectedPlayer, player.CurrentHp); + Assert.AreEqual(expectedEnemy, enemy.CurrentHp); + for (int draw = 0; draw < 251; draw++) _ = UnityEngine.Random.value; + Assert.AreEqual(expectedEscape, second.TryEscape()); + Assert.AreEqual(expectedAfterEscape, player.CurrentHp); + } + } + finally { UnityEngine.Random.state = original; } + } + + [Test] + public void PresentationSpeed_DoesNotChangeResolvedDamageOrCooldowns() + { + // Exercise the presentation rate without creating/changing persisted user settings. + var speedField = typeof(BattlePresentation).GetField("speed", BindingFlags.Static | BindingFlags.NonPublic); + float previous = (float)speedField.GetValue(null); + try + { + speedField.SetValue(null, 1f); + var first = CreateBattle(2, 15, 30); + first.UseSkill(0); + int expectedPlayer = player.CurrentHp; + int expectedEnemy = enemy.CurrentHp; + bool expectedSkill = first.CanUseSkill(0); + speedField.SetValue(null, 2f); + var second = CreateBattle(2, 15, 30); + second.UseSkill(0); + Assert.AreEqual(expectedPlayer, player.CurrentHp); + Assert.AreEqual(expectedEnemy, enemy.CurrentHp); + Assert.AreEqual(expectedSkill, second.CanUseSkill(0)); + } + finally { speedField.SetValue(null, previous); } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/BattlePacingTests.cs.meta b/Assets/Tests/EditMode/BattlePacingTests.cs.meta new file mode 100644 index 0000000..c3705c9 --- /dev/null +++ b/Assets/Tests/EditMode/BattlePacingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2fa721767fcfb8f48938c9dca845cecd \ No newline at end of file diff --git a/Assets/Tests/EditMode/BattleRandomSourceTests.cs b/Assets/Tests/EditMode/BattleRandomSourceTests.cs new file mode 100644 index 0000000..7553e01 --- /dev/null +++ b/Assets/Tests/EditMode/BattleRandomSourceTests.cs @@ -0,0 +1,83 @@ +#if UNITY_EDITOR +using InsectGame.Battle; +using InsectGame.Data; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + [TestFixture] + public class BattleRandomSourceTests + { + [Test] + public void CombatStream_VisualRandomConsumptionDoesNotChangeRolls() + { + var original = Random.state; + try + { + var baseline = new BattleRandomSource(8173); + var noisy = new BattleRandomSource(8173); + for (int i = 0; i < 256; i++) + { + for (int draw = 0; draw < i % 17; draw++) _ = Random.value; + Assert.AreEqual(baseline.Next01(), noisy.Next01()); + Assert.AreEqual(baseline.NextInt(0, 5), noisy.NextInt(0, 5)); + } + } + finally { Random.state = original; } + } + + [Test] + public void CombatStream_DoesNotAdvanceVisualRandomState() + { + var original = Random.state; + try + { + Random.InitState(25); + float expected = Random.value; + Random.InitState(25); + var source = new BattleRandomSource(8173); + for (int i = 0; i < 100; i++) { source.Next01(); source.NextInt(0, 5); } + Assert.AreEqual(expected, Random.value); + } + finally { Random.state = original; } + } + + [Test] + public void RaidHitAndBossTarget_WithVisualNoise_KeepSameSequence() + { + var original = Random.state; + InsectSkill skill = ScriptableObject.CreateInstance(); + try + { + skill.effectType = SkillEffectType.Damage; + skill.element = InsectElement.None; + skill.power = 10; + skill.accuracy = 0.55f; + var baseline = new BattleRandomSource(8173); + var noisy = new BattleRandomSource(8173); + var team = new InsectBattleStats[5]; + for (int i = 0; i < team.Length; i++) team[i] = new InsectBattleStats(null, 20); + var bossA = new InsectBattleStats(null, 20); + var bossB = new InsectBattleStats(null, 20); + int hits = 0, misses = 0; + for (int round = 1; round <= 50; round++) + { + bossA.ResetHp(); bossB.ResetHp(); + var expected = RaidRoundResolver.ResolveLeaderSkill(0, 0, team[0], bossA, team, skill, baseline); + var expectedIntent = RaidRoundResolver.CreateBossIntent(round, bossA, team, 3, null, baseline, false); + for (int draw = 0; draw < round * 3; draw++) _ = Random.value; + var actual = RaidRoundResolver.ResolveLeaderSkill(0, 0, team[0], bossB, team, skill, noisy); + var actualIntent = RaidRoundResolver.CreateBossIntent(round, bossB, team, 3, null, noisy, false); + Assert.AreEqual(expected.Damage, actual.Damage); + Assert.AreEqual(expectedIntent.TargetSlot, actualIntent.TargetSlot); + if (actual.Damage > 0) hits++; else misses++; + } + Assert.Greater(hits, 0); + Assert.Greater(misses, 0); + } + finally { Object.DestroyImmediate(skill); Random.state = original; } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/BattleRandomSourceTests.cs.meta b/Assets/Tests/EditMode/BattleRandomSourceTests.cs.meta new file mode 100644 index 0000000..5d3b74b --- /dev/null +++ b/Assets/Tests/EditMode/BattleRandomSourceTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7db7a175767997f449b2f92a020ca0a0 \ No newline at end of file diff --git a/Assets/Tests/EditMode/BattleReadabilityTests.cs b/Assets/Tests/EditMode/BattleReadabilityTests.cs new file mode 100644 index 0000000..bea8941 --- /dev/null +++ b/Assets/Tests/EditMode/BattleReadabilityTests.cs @@ -0,0 +1,652 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using InsectGame.Battle; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace InsectGame.Tests +{ + /// + /// 전투 체감 강화(초등 고학년) — 읽을 시간과 판정 도우미. + /// + /// ① 결과 화면은 눌러서 닫는다(처음 0.6초 잠금, 꿈 챔피언전만 저절로) ② 읽는 단계(진입 인트로·컷인·상대 교체·그림자 변신·수문장 등장)는 + /// 2배속이어도 최소 실제 시간을 지킨다 ③ 상성 등급()과 전용기 판별()이 + /// 1대1·레이드에서 같은 기준으로 마지막 행동에 남는다 ④ 전투 종류·전투 곡 고르기. + /// + /// 실패는 전부 조용하다 — 결과 화면이 다시 저절로 닫혀도, 2배속 문구가 0.6초에 사라져도 예외 하나 없다. + /// + [TestFixture] + public class BattleReadabilityTests + { + private const BindingFlags Inst = BindingFlags.Instance | BindingFlags.NonPublic; + private const float Frame = 1f / 60f; + private readonly List objects = new List(); + + [TearDown] + public void Cleanup() + { + foreach (Object o in objects) if (o != null) Object.DestroyImmediate(o); + objects.Clear(); + } + + // ── ① 결과 화면 닫기 ── + + [Test] + public void ResultPress_IsIgnoredForTheFirstSixTenths() + { + Assert.AreEqual(0.6f, BattleResultRules.InputLockSeconds, 1e-6f); + Assert.IsFalse(BattleResultRules.AcceptsPress(0f, true), "마지막 공격 연타가 결과를 바로 닫는다"); + Assert.IsFalse(BattleResultRules.AcceptsPress(0.59f, true)); + Assert.IsTrue(BattleResultRules.AcceptsPress(0.6f, true)); + Assert.IsTrue(BattleResultRules.AcceptsPress(120f, true)); + Assert.IsFalse(BattleResultRules.AcceptsPress(120f, false), "누르지 않으면 받을 것도 없다"); + Assert.IsFalse(BattleResultRules.CanClose(0.59f)); + Assert.IsTrue(BattleResultRules.CanClose(0.6f)); + } + + [Test] + public void Result_NeverClosesByItself_OutsideTheDream() + { + // 예전 자동 닫힘 시각(1대1 4초·레이드 5초)과 그 훨씬 뒤까지 — 보상·한마디를 다 읽을 때까지 기다린다. + foreach (float t in new[] { 0f, 4.01f, 5.01f, 60f, 3600f }) + Assert.IsFalse(BattleResultRules.ShouldAutoClose(t, sandbox: false), $"{t}초에 저절로 닫혔다"); + } + + [Test] + public void DreamChampionResult_StillClosesByItself_AfterFourSeconds() + { + // 꿈 지휘자는 결과 화면이 닫히는 것(IsBattleActive)을 신호로 섬에 넘어가고, 그 상한은 14초다. + Assert.IsFalse(BattleResultRules.ShouldAutoClose(BattleResultRules.SandboxAutoCloseSeconds, sandbox: true)); + Assert.IsTrue(BattleResultRules.ShouldAutoClose(BattleResultRules.SandboxAutoCloseSeconds + 0.01f, sandbox: true)); + Assert.Less(BattleResultRules.SandboxAutoCloseSeconds, 14f, "꿈 지휘자의 상한보다 먼저 닫혀야 결과 화면 위로 섬이 열리지 않는다"); + } + + [Test] + public void AcceptedPress_ClosesOnRelease_OrAfterAShortHold() + { + Assert.IsTrue(BattleResultRules.ReleaseComplete(inputHeld: false, secondsSincePress: 0f)); + Assert.IsFalse(BattleResultRules.ReleaseComplete(inputHeld: true, secondsSincePress: 0.49f), + "누른 프레임에 닫으면 그 탭이 필드 클릭-이동으로 샌다"); + Assert.IsTrue(BattleResultRules.ReleaseComplete(true, BattleResultRules.ReleaseGraceSeconds), + "뗌을 못 받아도(포커스 잃음) 갇히지 않는다"); + } + + [Test] + public void Screen_Result_WaitsForATap_IgnoresEarlyTaps_ThenClosesOnTheKey() + { + InsectBattleController battle = NewController(); + BattleScreenUI ui = NewScreen(battle); + Assert.IsTrue(battle.StartDuel(Species("res_me", InsectElement.Bug), 10, Species("res_foe", InsectElement.Bug), 10)); + ShowResult(ui); + Assert.AreEqual(0f, ui.ResultShownSeconds, 1e-6f); + + SetField(ui, "resultTimer", 30f); + TickResult(ui); + Assert.AreEqual("Result", PhaseOf(ui), "30초가 지나도 저절로 닫히지 않는다"); + Assert.AreEqual(30f, ui.ResultShownSeconds, 1e-6f); + + SetField(ui, "resultTimer", 0.3f); + SetField(ui, "wantMouseClick", true); + Assert.IsFalse(ui.ResultCanClose); + TickResult(ui); + Assert.AreEqual("Result", PhaseOf(ui), "잠금 중 탭은 버린다"); + Assert.IsFalse((bool)GetField(ui, "resultCloseArmed"), "잠금 중 탭을 쌓아 두면 잠금이 풀리는 순간 닫힌다"); + + SetField(ui, "resultTimer", 0.7f); + Assert.IsTrue(ui.ResultCanClose); + SetField(ui, "wantResultClose", true); // Space/Enter — OnGUI가 세운다 + TickResult(ui); + Assert.AreEqual("None", PhaseOf(ui), "잠금 뒤 누름은 손을 뗀 프레임에 닫힌다(배치 러너엔 누른 손가락이 없다)"); + Assert.IsFalse(ui.ResultCanClose); + Assert.AreEqual(0f, ui.ResultShownSeconds, 1e-6f); + } + + [Test] + public void Screen_DreamChampionResult_ClosesByItself() + { + InsectBattleController battle = NewController(); + BattleScreenUI ui = NewScreen(battle); + InsectData ace = Species("dream_ace", InsectElement.Bug); + ace.skills = new[] { Skill("dream_hit", InsectElement.Bug, false) }; + Assert.IsTrue(battle.StartSandbox(ace, 30, Species("dream_foe", InsectElement.Bug), 30, null, null)); + ShowResult(ui); + + SetField(ui, "resultTimer", 3.9f); + TickResult(ui); + Assert.AreEqual("Result", PhaseOf(ui)); + SetField(ui, "resultTimer", 4.1f); + TickResult(ui); + Assert.AreEqual("None", PhaseOf(ui), "꿈 흐름은 결과 화면이 닫히는 신호로 섬에 넘어간다"); + } + + // ── ② 읽는 단계의 배속 하한 ── + + [Test] + public void ClockScale_AtNormalSpeed_LeavesEveryPhaseAsAuthored() + { + foreach ((float nominal, float min, string what) in ReadPhases()) + { + Assert.AreEqual(1f, BattleReadPacing.ClockScale(nominal, min, 1f), 1e-6f, what); + Assert.AreEqual(nominal, BattleReadPacing.RealSeconds(nominal, min, 1f), 1e-4f, what); + Assert.AreEqual(nominal, BattleReadPacing.StagingSeconds(nominal, min, 1f), 1e-4f, what); + } + } + + [Test] + public void ReadPhases_AtDoubleSpeed_LastAtLeastTheirFloor() + { + foreach ((float nominal, float min, string what) in ReadPhases()) + { + Assert.GreaterOrEqual(nominal, min, $"{what}: 1배속 길이가 하한보다 짧으면 1배속도 늘어난다"); + float expected = Mathf.Max(min, nominal / 2f); + Assert.AreEqual(expected, BattleReadPacing.RealSeconds(nominal, min, 2f), 1e-4f, what); + + // 실제 화면처럼 프레임마다 단계 시계를 올려 끝나는 실제 시간을 잰다. + float scale = BattleReadPacing.ClockScale(nominal, min, 2f); + float real = Simulate(nominal, _ => scale, 2f); + Assert.GreaterOrEqual(real, min - Frame, $"{what}: 2배속에서 {real:0.00}초 — 문구를 못 읽는다"); + Assert.LessOrEqual(real, expected + Frame * 2f, what); + } + } + + [Test] + public void ReadPhase_StillSpeedsUp_DownToItsFloor() + { + // 배속을 고른 사람에게 1배속을 강요하지 않는다 — 하한까지만 늦춘다. + Assert.AreEqual(1.0f, BattleReadPacing.RealSeconds(BattleScreenUI.EnemySwitchSeconds, BattleReadPacing.EnemySwitchMinSeconds, 2f), 1e-4f); + Assert.Less(BattleReadPacing.RealSeconds(BattleScreenUI.EnemySwitchSeconds, BattleReadPacing.EnemySwitchMinSeconds, 2f), + BattleScreenUI.EnemySwitchSeconds); + // 하한보다 넉넉히 긴 단계는 배속을 그대로 탄다. + Assert.AreEqual(1f, BattleReadPacing.ClockScale(3f, 1f, 2f), 1e-6f); + Assert.AreEqual(1.5f, BattleReadPacing.RealSeconds(3f, 1f, 2f), 1e-4f); + } + + [Test] + public void StagingSeconds_EndsTogetherWithThePhase() + { + // 아레나 연출은 배속 시계로 돈다 — 넘긴 길이 ÷ 배속 = 단계의 실제 길이여야 박자(교체 착지·변신 울부짖음)가 문구와 맞는다. + foreach ((float nominal, float min, string what) in ReadPhases()) + { + float staging = BattleReadPacing.StagingSeconds(nominal, min, 2f); + Assert.AreEqual(BattleReadPacing.RealSeconds(nominal, min, 2f), staging / 2f, 1e-4f, what); + } + Assert.AreEqual(2.0f, BattleReadPacing.StagingSeconds(BattleScreenUI.EnemySwitchSeconds, BattleReadPacing.EnemySwitchMinSeconds, 2f), 1e-4f); + Assert.AreEqual(2.0f, BattleReadPacing.StagingSeconds(RaidBattleUI.BossTransformDuration, BattleReadPacing.BossTransformMinSeconds, 2f), 1e-4f); + } + + [Test] + public void EntryIntro_RunsInRealTime_AtAnySpeed() + { + Assert.AreEqual(1.4f, BattleReadPacing.EntryIntroSeconds, 1e-6f); + foreach (float speed in new[] { 1f, 2f }) + { + float real = Simulate(BattleReadPacing.EntryIntroSeconds, + clock => BattleReadPacing.IntroClockScale(clock, 0f, speed), speed); + Assert.AreEqual(BattleReadPacing.EntryIntroSeconds, real, Frame * 1.5f, $"{speed}배속 — 진입 샷·「나타났다!」 자리"); + } + Assert.AreEqual(0f, BattleReadPacing.EntryProgress(0f)); + Assert.AreEqual(0.5f, BattleReadPacing.EntryProgress(0.7f), 1e-5f); + Assert.AreEqual(1f, BattleReadPacing.EntryProgress(5f)); + } + + [Test] + public void IntroWithCutIn_PlaysTheEntryFirst_ThenTheCutIn() + { + const float CutIn = 3.2f; // BattleScreenUI.Duel.CutInSeconds + Assert.AreEqual(CutIn, (float)typeof(BattleScreenUI).GetField("CutInSeconds", BindingFlags.Static | BindingFlags.NonPublic).GetValue(null), 1e-6f); + float total = BattleReadPacing.IntroSeconds(CutIn, 2f); + Assert.AreEqual(BattleReadPacing.EntryIntroSeconds + CutIn, total, 1e-5f); + Assert.Less(BattleReadPacing.CutInElapsed(BattleReadPacing.EntryIntroSeconds - 0.01f), 0f, "진입 구간엔 컷인을 그리지 않는다"); + Assert.AreEqual(0f, BattleReadPacing.CutInElapsed(BattleReadPacing.EntryIntroSeconds), 1e-6f); + + float atOne = Simulate(total, clock => BattleReadPacing.IntroClockScale(clock, CutIn, 1f), 1f); + float atTwo = Simulate(total, clock => BattleReadPacing.IntroClockScale(clock, CutIn, 2f), 2f); + Assert.AreEqual(total, atOne, Frame * 2f, "1배속은 그대로"); + Assert.AreEqual(BattleReadPacing.EntryIntroSeconds + BattleReadPacing.CutInMinSeconds, atTwo, Frame * 3f, + "2배속 — 진입 1.4초(실제) + 컷인 하한 2.0초"); + } + + [Test] + public void PlainIntro_HoldsTheEntry_AndKeepsItsOneTimesLength() + { + Assert.AreEqual(2f, BattleReadPacing.IntroSeconds(0f, 2f), 1e-6f, "야생 인트로는 1배속 2초 그대로 — 앞 1.4초가 진입 구간"); + Assert.AreEqual(BattleReadPacing.EntryIntroSeconds, BattleReadPacing.IntroSeconds(0f, 1f), 1e-6f, "진입 구간보다 짧아지지 않는다"); + float atTwo = Simulate(2f, clock => BattleReadPacing.IntroClockScale(clock, 0f, 2f), 2f); + Assert.AreEqual(BattleReadPacing.EntryIntroSeconds + (2f - BattleReadPacing.EntryIntroSeconds) / 2f, atTwo, Frame * 3f, + "진입 구간 뒤의 나머지는 배속을 탄다"); + } + + [Test] + public void Screen_IntroProgress_TracksTheEntryAndTheWholeIntro() + { + InsectBattleController battle = NewController(); + BattleScreenUI ui = NewScreen(battle); + Assert.AreEqual(0f, ui.IntroProgress); + Assert.IsTrue(battle.StartDuel(Species("intro_me", InsectElement.Bug), 10, Species("intro_foe", InsectElement.Bug), 10)); + Assert.IsTrue(ui.IsIntroPlaying); + Assert.AreEqual(0f, ui.IntroProgress, 1e-6f); + Assert.AreEqual(0f, ui.EntryIntroProgress, 1e-6f); + SetField(ui, "introTimer", 0.7f); + Assert.AreEqual(0.5f, ui.EntryIntroProgress, 1e-5f); + Assert.AreEqual(0.35f, ui.IntroProgress, 1e-5f, "야생 인트로 2초 중 0.7초"); + } + + // ── ③ 상성 등급 ── + + [Test] + public void Describe_RealChartValues_LandInTheirGrades() + { + Assert.AreEqual(Matchup.Super, ElementMatchup.Describe(InsectTypeChart.GetEffectiveness(InsectElement.Wind, InsectElement.Bug, InsectElement.Leaf)), "강×강 2.25"); + Assert.AreEqual(Matchup.Good, ElementMatchup.Describe(InsectTypeChart.StrongMultiplier), "강 1.5"); + Assert.AreEqual(Matchup.Neutral, ElementMatchup.Describe(1f)); + Assert.AreEqual(Matchup.Neutral, ElementMatchup.Describe(InsectTypeChart.GetEffectiveness(InsectElement.Bug, InsectElement.Leaf, InsectElement.Wind)), "강×약 1.005는 보통"); + Assert.AreEqual(Matchup.Weak, ElementMatchup.Describe(InsectTypeChart.ResistMultiplier), "약 0.67"); + Assert.AreEqual(Matchup.Resisted, ElementMatchup.Describe(InsectTypeChart.GetEffectiveness(InsectElement.Bug, InsectElement.Wind, InsectElement.Metal)), "약×약 0.45"); + } + + [Test] + public void Describe_Boundaries() + { + Assert.AreEqual(Matchup.Super, ElementMatchup.Describe(2.0f)); + Assert.AreEqual(Matchup.Good, ElementMatchup.Describe(1.9999f)); + Assert.AreEqual(Matchup.Good, ElementMatchup.Describe(1.0501f)); + Assert.AreEqual(Matchup.Neutral, ElementMatchup.Describe(1.05f)); + Assert.AreEqual(Matchup.Neutral, ElementMatchup.Describe(0.95f)); + Assert.AreEqual(Matchup.Weak, ElementMatchup.Describe(0.9499f)); + Assert.AreEqual(Matchup.Weak, ElementMatchup.Describe(0.5001f)); + Assert.AreEqual(Matchup.Resisted, ElementMatchup.Describe(0.5f)); + Assert.AreEqual(Matchup.Neutral, ElementMatchup.Describe(float.NaN)); + Assert.AreEqual(Matchup.Neutral, default(Matchup), "기본값이 보통이어야 표지를 안 채운 행동이 보통으로 읽힌다"); + } + + [Test] + public void Describe_AgreesWithTheBattleText_ForEveryPairInTheChart() + { + // 잘 통함(> 1.05)·안 통함(< 0.95) 경계가 피해 숫자 위 상성 표시·스킬 카드 칩(MatchupHud)과 갈리지 않는다. 등급마다 실제로 나오는 배수도 하나다. + var seen = new Dictionary>(); + foreach (InsectElement attack in Enum.GetValues(typeof(InsectElement))) + foreach (InsectElement primary in Enum.GetValues(typeof(InsectElement))) + foreach (InsectElement secondary in Enum.GetValues(typeof(InsectElement))) + { + float e = InsectTypeChart.GetEffectiveness(attack, primary, secondary); + Matchup m = ElementMatchup.Describe(e); + Assert.AreEqual(e > 1.05f, ElementMatchup.IsFavorable(m), $"{attack}→{primary}/{secondary} ×{e}"); + Assert.AreEqual(e < 0.95f, ElementMatchup.IsUnfavorable(m), $"{attack}→{primary}/{secondary} ×{e}"); + if (!seen.TryGetValue(m, out HashSet values)) seen[m] = values = new HashSet(); + values.Add(Mathf.Round(e * 1000f) / 1000f); + } + Assert.AreEqual(5, seen.Count, "실제 표에서 다섯 등급이 모두 나온다"); + Assert.AreEqual(1, seen[Matchup.Super].Count); + Assert.AreEqual(1, seen[Matchup.Good].Count); + Assert.AreEqual(1, seen[Matchup.Weak].Count); + Assert.AreEqual(1, seen[Matchup.Resisted].Count); + } + + [Test] + public void Of_ReadsBothDefenderTypes() + { + Assert.AreEqual(Matchup.Super, ElementMatchup.Of(InsectElement.Wind, Species("of_bugleaf", InsectElement.Bug, InsectElement.Leaf))); + Assert.AreEqual(Matchup.Neutral, ElementMatchup.Of(InsectElement.Wind, null)); + Assert.AreEqual(Matchup.Neutral, ElementMatchup.Of(InsectElement.None, Species("of_leaf", InsectElement.Leaf)), "무속성은 상성을 안 탄다"); + } + + // ── ③ 전용기 판별 — 실제 곤충 DB ── + + [Test] + public void RealDatabase_Signatures_AreRecognisedOnlyOnTheirOwner() + { + InsectDatabase db = RealDatabase(); + var owners = new List(); + foreach (InsectData d in db.insects) + { + if (d == null || d.learnset == null) continue; + InsectSkill signature = SignatureSkills.FindSignature(d); + foreach (InsectLearnableSkill l in d.learnset) + { + if (l == null || l.skill == null) continue; + if (SignatureSkills.IsSignatureEntry(l)) + { + Assert.IsTrue(SignatureSkills.IsSignature(d, l.skill), $"{d.insectId}의 전용기 {l.skill.skillId}"); + Assert.IsTrue(SignatureSkills.IsUnlocked(l, l.learnLevel)); + Assert.IsFalse(SignatureSkills.IsUnlocked(l, l.learnLevel - 1), "해금 레벨 전에는 레이드 보스도 못 쓴다"); + } + else + { + Assert.IsFalse(SignatureSkills.IsSignature(d, l.skill), $"{d.insectId}의 {l.skill.skillId}는 종족기다"); + } + } + if (signature != null) owners.Add(d); + if (d.rarity >= InsectRarity.Epic) + Assert.IsNotNull(signature, $"{d.insectId}({d.rarity})에 전용기가 없다 — 전용기 연출이 영영 안 뜬다"); + } + + Assert.GreaterOrEqual(owners.Count, 5, "실제 DB에 전용기를 가진 곤충이 너무 적다"); + // 남의 전용기는 내 전용기가 아니다(같은 기술을 남이 들고 있어도 그 곤충 learnset에 없으면 false). + InsectSkill first = SignatureSkills.FindSignature(owners[0]); + InsectSkill second = SignatureSkills.FindSignature(owners[1]); + Assert.AreNotEqual(first.skillId, second.skillId); + Assert.IsFalse(SignatureSkills.IsSignature(owners[1], first)); + Assert.IsFalse(SignatureSkills.IsSignature(owners[0], second)); + } + + [Test] + public void RealDatabase_DreamChampion_SignatureIsTheirs_EvenAsAnotherInstance() + { + InsectDatabase db = RealDatabase(); + InsectData ace = db.GetById(DreamPrologueData.AceInsectId); + Assert.IsNotNull(ace); + InsectSkill signature = SignatureSkills.FindSignature(ace); + Assert.IsNotNull(signature, "꿈의 챔피언(전설)이 전용기를 든다"); + // 장착 기술이 learnset과 다른 인스턴스로 와도 ID로 알아본다. + InsectSkill copy = Track(ScriptableObject.CreateInstance()); + copy.skillId = signature.skillId; + copy.isSignatureSkill = true; + Assert.IsTrue(SignatureSkills.IsSignature(ace, copy)); + copy.isSignatureSkill = false; + Assert.IsFalse(SignatureSkills.IsSignature(ace, copy), "전용기 표시가 없는 기술은 전용기가 아니다"); + Assert.IsFalse(SignatureSkills.IsSignature(null, signature)); + Assert.IsFalse(SignatureSkills.IsSignature(ace, null)); + } + + [Test] + public void RealDatabase_RaidBossPicksASignature_TheSameRuleSaysIsTheirs() + { + InsectDatabase db = RealDatabase(); + MethodInfo pick = typeof(RaidBattleController).GetMethod("GetUnlockedBossSignature", BindingFlags.Static | BindingFlags.NonPublic); + Assert.IsNotNull(pick, "레이드 보스의 전용기 고르기가 이름을 바꿨다 — 이 테스트를 따라 고칠 것"); + int checkedBosses = 0; + foreach (InsectData d in db.insects) + { + if (d == null || d.rarity < InsectRarity.Epic) continue; + var skill = (InsectSkill)pick.Invoke(null, new object[] { d, 80, 0 }); + Assert.IsNotNull(skill, d.insectId); + Assert.IsTrue(SignatureSkills.IsSignature(d, skill), $"{d.insectId}: 보스가 고른 기술을 전용기 판별이 모른다"); + Assert.IsNull(pick.Invoke(null, new object[] { d, 1, 0 }), $"{d.insectId}: Lv1 보스가 전용기를 쓴다"); + checkedBosses++; + } + Assert.Greater(checkedBosses, 0); + } + + // ── ③ 마지막 행동에 남는 표지 ── + + [Test] + public void OneVsOne_LastAction_ReportsSignatureAndMatchup() + { + // 내 곤충: 땅 — 전용기 풀(물에 강하다 ×1.5 = Good). 상대: 물 — 전용기 벌레(땅에는 보통). + InsectData me = Species("sig_me", InsectElement.Earth); + InsectSkill mySig = Skill("sig_me_signature", InsectElement.Leaf, true); + me.learnset = new[] { Learn(mySig, 1) }; + me.skills = new[] { mySig }; + InsectData foe = Species("sig_foe", InsectElement.Water); + InsectSkill foeSig = Skill("sig_foe_signature", InsectElement.Bug, true); + foe.learnset = new[] { Learn(foeSig, 1) }; + foe.skills = new[] { foeSig }; + + InsectBattleController battle = NewController(); + Assert.IsTrue(battle.StartDuel(me, 10, foe, 10)); + battle.UseSkill(0); + + Assert.AreSame(mySig, battle.LastPlayerSkill); + Assert.IsTrue(battle.LastPlayerSkillIsSignature); + Assert.AreEqual(Matchup.Good, battle.LastPlayerHitMatchup); + Assert.AreSame(foeSig, battle.LastEnemySkill); + Assert.IsTrue(battle.LastEnemySkillIsSignature); + Assert.AreEqual(Matchup.Neutral, battle.LastEnemyHitMatchup); + + // 다음 라운드(기본 공격)는 새로 시작한다 — 지난 라운드의 전용기·상성이 남지 않는다. + battle.UseBasicAttack(); + Assert.IsNull(battle.LastPlayerSkill); + Assert.IsFalse(battle.LastPlayerSkillIsSignature); + Assert.AreEqual(Matchup.Neutral, battle.LastPlayerHitMatchup); + } + + [Test] + public void OneVsOne_SpeciesSkill_IsNotASignature_AndAMissLeavesNoMatchup() + { + InsectData me = Species("plain_me", InsectElement.Earth); + InsectSkill jab = Skill("plain_jab", InsectElement.Leaf, false); + jab.accuracy = 0.31f; + me.learnset = new[] { Learn(jab, 1) }; + me.skills = new[] { jab }; + InsectBattleController battle = NewController(); + battle.SetRandomSource(new FixedRoll(0.99f)); // 명중 롤 0.99 ≥ 0.31 → 빗나감 + Assert.IsTrue(battle.StartDuel(me, 10, Species("plain_foe", InsectElement.Water), 10)); + battle.UseSkill(0); + Assert.IsFalse(battle.LastPlayerSkillIsSignature); + Assert.AreEqual(Matchup.Neutral, battle.LastPlayerHitMatchup, "빗나간 기술에 「잘 통해요」가 뜨면 안 된다"); + } + + [Test] + public void Raid_ActionResults_ReportSignatureAndMatchup() + { + InsectData me = Species("raid_me", InsectElement.Earth); + InsectSkill mySig = Skill("raid_me_signature", InsectElement.Leaf, true); + me.learnset = new[] { Learn(mySig, 1) }; + InsectData bossData = Species("raid_boss", InsectElement.Water); + InsectSkill bossSig = Skill("raid_boss_signature", InsectElement.Bug, true); + bossData.learnset = new[] { Learn(bossSig, 1) }; + + var attacker = new InsectBattleStats(me, 10); + var boss = new InsectBattleStats(bossData, 10); + var team = new[] { attacker }; + + RaidActionResult leader = RaidRoundResolver.ResolveLeaderSkill(0, 0, attacker, boss, team, mySig, null); + Assert.IsTrue(leader.IsSignature); + Assert.AreEqual(Matchup.Good, leader.Matchup, "풀 → 물"); + + RaidActionResult assist = RaidRoundResolver.ResolveSupportAssist(0, attacker, boss); + Assert.IsFalse(assist.IsSignature, "지원 공격엔 기술이 없다"); + Assert.AreEqual(Matchup.Weak, assist.Matchup, "땅(주속성) → 물"); + + var single = new RaidBossIntent + { + Kind = RaidBossIntentKind.SignatureSkill, TargetSlot = 0, Skill = bossSig, + Element = bossSig.element, EffectType = SkillEffectType.Damage, DisplayName = bossSig.displayName + }; + RaidActionResult bossHit = RaidRoundResolver.ResolveBossIntent(single, boss, team, new int[1]); + Assert.IsTrue(bossHit.IsSignature, "보스 전용기는 보스의 지금 모습(CombatData)으로 잰다"); + Assert.AreEqual(Matchup.Neutral, bossHit.Matchup, "벌레 → 땅"); + + var area = new RaidBossIntent { Kind = RaidBossIntentKind.AreaAttack, Element = InsectElement.Water, DisplayName = "전체" }; + RaidActionResult areaHit = RaidRoundResolver.ResolveBossIntent(area, boss, team, new int[1]); + Assert.IsFalse(areaHit.IsSignature); + Assert.AreEqual(Matchup.Neutral, areaHit.Matchup, "전체공격은 상성을 안 탄다"); + } + + // ── ④ 전투 종류·곡 ── + + [Test] + public void BattleKinds_Classify_PicksTheRightKind() + { + Assert.AreEqual(BattleKind.Sandbox, BattleKinds.Classify(true, true, true, true, true), "꿈이 무엇보다 먼저"); + Assert.AreEqual(BattleKind.Guardian, BattleKinds.Classify(false, true, false, false, false)); + Assert.AreEqual(BattleKind.Wild, BattleKinds.Classify(false, false, false, false, false)); + Assert.AreEqual(BattleKind.KidDuel, BattleKinds.Classify(false, false, true, false, false)); + Assert.AreEqual(BattleKind.BossDuel, BattleKinds.Classify(false, false, true, true, false)); + Assert.AreEqual(BattleKind.RivalDuel, BattleKinds.Classify(false, false, true, true, true)); + Assert.IsTrue(BattleKinds.IsTrainerDuel(BattleKind.KidDuel)); + Assert.IsFalse(BattleKinds.IsTrainerDuel(BattleKind.Guardian)); + } + + [Test] + public void Screen_CurrentBattleKind_FollowsTheControllerMarks() + { + Assert.AreEqual(BattleKind.KidDuel, KindAfter(b => b.StartDuel(Species("k1", InsectElement.Bug), 5, Species("k2", InsectElement.Bug), 5))); + Assert.AreEqual(BattleKind.RivalDuel, KindAfter(b => + { + b.StartDuel(Species("r1", InsectElement.Bug), 5, Species("r2", InsectElement.Bug), 5); + b.SetDuelOpponent("rival_final"); + })); + Assert.AreEqual(BattleKind.BossDuel, KindAfter(b => + { + b.StartDuel(Species("b1", InsectElement.Bug), 5, Species("b2", InsectElement.Bug), 5); + b.SetDuelOpponent("ledger_grip"); + })); + Assert.AreEqual(BattleKind.Sandbox, KindAfter(b => + b.StartSandbox(Species("s1", InsectElement.Bug), 5, Species("s2", InsectElement.Bug), 5, null, null))); + } + + [Test] + public void BattleMusic_GuardianRaid_Rival_AndLedger() + { + Assert.AreEqual(BgmType.RaidBattle, BattleMusic.Raid(null)); + Assert.AreEqual(BgmType.RaidBattle, BattleMusic.Raid(string.Empty)); + Assert.AreEqual(BgmType.Guardian, BattleMusic.Raid("meadow")); + Assert.AreEqual(BgmType.BossFinal, BattleMusic.BossDuel(true)); + Assert.AreEqual(BgmType.BossLedger, BattleMusic.BossDuel(false)); + Assert.AreEqual(BgmType.Rival, BattleMusic.RivalDuel); + } + + [Test] + public void BattleMusic_OneVsOneGuardian_UsesTheGuardianTheme() + { + // 초원 사마귀처럼 1대1로 맞서는 수문장도 레이드 수문장과 같은 곡이다. + Assert.AreEqual(BgmType.Battle, BattleMusic.OneVsOne(null)); + Assert.AreEqual(BgmType.Battle, BattleMusic.OneVsOne(string.Empty)); + Assert.AreEqual(BgmType.Guardian, BattleMusic.OneVsOne("meadow")); + } + + [Test] + public void Wiring_RivalDuelAndRaidStart_SwitchTheMusic() + { + // 배선 누락은 무증상이다(일반 전투 곡이 그대로 흐를 뿐) — 소스로 고정한다. + string duel = Source("Scripts/NPC/NpcDuelController.cs"); + int rival = duel.IndexOf("public bool TryStartRivalDuel", StringComparison.Ordinal); + int nextMember = duel.IndexOf("private void OnRivalDuelEnded", StringComparison.Ordinal); + Assert.Greater(rival, 0); + Assert.Greater(nextMember, rival); + StringAssert.Contains("BattleMusic.RivalDuel", duel.Substring(rival, nextMember - rival), "라온 대결이 라온 곡으로 안 바뀐다"); + StringAssert.Contains("BattleMusic.BossDuel(", duel, "간부 대결 곡 분기가 사라졌다"); + StringAssert.Contains("BattleMusic.Raid(raidController.BossGuardianRegionId)", Source("Scripts/UI/RaidBattleUI.cs"), + "수문장 레이드가 수문장 곡으로 안 바뀐다"); + } + + // ── 도우미 ── + + private static IEnumerable<(float nominal, float min, string what)> ReadPhases() + { + float raidIntro = (float)typeof(RaidBattleUI).GetField("RaidIntroSeconds", BindingFlags.Static | BindingFlags.NonPublic).GetValue(null); + yield return (BattleScreenUI.EnemySwitchSeconds, BattleReadPacing.EnemySwitchMinSeconds, "상대 교체"); + yield return (RaidBattleUI.BossTransformDuration, BattleReadPacing.BossTransformMinSeconds, "그림자 변신"); + yield return (BattleStaging.GuardianIntroSeconds, BattleReadPacing.GuardianIntroMinSeconds, "수문장 등장"); + yield return (raidIntro, BattleReadPacing.RaidIntroMinSeconds, "일반 레이드 인트로"); + } + + /// 실제 화면처럼 60fps로 단계 시계를 올려 에 닿기까지의 실제 초. + private static float Simulate(float nominal, Func scaleAt, float speed) + { + float clock = 0f, real = 0f; + while (clock < nominal && real < 60f) + { + clock += Frame * speed * scaleAt(clock); + real += Frame; + } + return real; + } + + private BattleKind KindAfter(Action start) + { + InsectBattleController battle = NewController(); + BattleScreenUI ui = NewScreen(battle); + start(battle); + return ui.CurrentBattleKind; + } + + private InsectBattleController NewController() + { + InsectBattleController battle = Track(new GameObject("ReadabilityBattle")).AddComponent(); + battle.SetCritSource(null); // 피해·HP를 보는 테스트는 치명타를 끈다(balance.md) + return battle; + } + + private BattleScreenUI NewScreen(InsectBattleController battle) + { + BattleScreenUI ui = Track(new GameObject("ReadabilityScreen")).AddComponent(); + ui.AutoWire(battle, null, null); + return ui; + } + + private static void ShowResult(BattleScreenUI ui) + { + SetField(ui, "lastWon", true); // 패배면 EnterResult가 컨트롤러 종료(ConcludeDefeatWithoutSwap)까지 부른다 — 여기선 화면만 본다 + typeof(BattleScreenUI).GetMethod("EnterResult", Inst).Invoke(ui, null); + Assert.AreEqual("Result", PhaseOf(ui)); + } + + // Update는 실제 프레임 시간(unscaledDeltaTime)을 결과 시계에 더한다 — 경계를 정확히 보려고 닫기 판정만 직접 부른다. + private static void TickResult(BattleScreenUI ui) => typeof(BattleScreenUI).GetMethod("TickResultClose", Inst).Invoke(ui, null); + + private static string PhaseOf(BattleScreenUI ui) => GetField(ui, "phase").ToString(); + + private static object GetField(object target, string name) => target.GetType().GetField(name, Inst).GetValue(target); + + private static void SetField(object target, string name, object value) => target.GetType().GetField(name, Inst).SetValue(target, value); + + private static string Source(string relative) => File.ReadAllText(Path.Combine(Application.dataPath, relative)); + + private InsectDatabase RealDatabase() + { + var host = Track(new GameObject("BattleReadabilityTests_RealDb")); + host.SetActive(false); // Awake(월드 생성)를 막고 private 생성 함수만 부른다 + var bootstrap = host.AddComponent(); + var db = (InsectDatabase)typeof(PlaySceneBootstrap) + .GetMethod("EnsureExpandedDatabase", Inst).Invoke(bootstrap, null); + Assert.IsNotNull(db); + Track(db); + return db; + } + + private InsectData Species(string id, InsectElement primary, InsectElement secondary = InsectElement.None) + { + InsectData d = Track(ScriptableObject.CreateInstance()); + d.insectId = id; + d.displayName = id; + d.rarity = InsectRarity.Common; + d.baseHp = 1000; + d.baseAtk = 30; + d.baseDef = 30; + d.primaryType = primary; + d.secondaryType = secondary; + return d; + } + + private InsectSkill Skill(string id, InsectElement element, bool signature) + { + InsectSkill s = Track(ScriptableObject.CreateInstance()); + s.skillId = id; + s.displayName = id; + s.element = element; + s.effectType = SkillEffectType.Damage; + s.power = 30; + s.accuracy = 1f; + s.cooldownTurns = 0; + s.isSignatureSkill = signature; + return s; + } + + private static InsectLearnableSkill Learn(InsectSkill skill, int level) + => new InsectLearnableSkill { skillId = skill.skillId, learnLevel = level, skill = skill }; + + private T Track(T obj) where T : Object + { + objects.Add(obj); + return obj; + } + + private sealed class FixedRoll : IRaidRandomSource + { + private readonly float value; + public FixedRoll(float value) { this.value = value; } + public float Next01() => value; + public int NextInt(int minInclusive, int maxExclusive) => minInclusive; + } + } +} +#endif diff --git a/Assets/Tests/EditMode/BattleReadabilityTests.cs.meta b/Assets/Tests/EditMode/BattleReadabilityTests.cs.meta new file mode 100644 index 0000000..7bb08c7 --- /dev/null +++ b/Assets/Tests/EditMode/BattleReadabilityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3c62da701d21a7044bc79898866355c1 \ No newline at end of file diff --git a/Assets/Tests/EditMode/BattleStagingTests.cs b/Assets/Tests/EditMode/BattleStagingTests.cs new file mode 100644 index 0000000..f62c9ea --- /dev/null +++ b/Assets/Tests/EditMode/BattleStagingTests.cs @@ -0,0 +1,329 @@ +#if UNITY_EDITOR +using InsectGame.Battle; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 이야기 전투의 등장·변신 연출 — 시각표·곡선·카메라 샷·그림자 색(·). + /// 화면 단계(교체 1.2초·변신 1.5초·수문장 인트로)와 어긋나면 연출이 단계 밖으로 넘치거나 다음 차례가 연출 구도에서 열린다. + /// + [TestFixture] + public class BattleStagingTests + { + private BattleCameraDirector.Style savedStyle; + + [SetUp] + public void SetUp() + { + savedStyle = BattleCameraDirector.Current; + BattleCameraDirector.Current = BattleCameraDirector.Style.Cinematic; + } + + [TearDown] + public void TearDown() + { + BattleCameraDirector.Current = savedStyle; + } + + private static void AssertNear(Vector3 expected, Vector3 actual, float tolerance, string message = null) + { + Assert.LessOrEqual(Vector3.Distance(expected, actual), tolerance, message ?? $"{expected} vs {actual}"); + } + + // ───────────── ① 상대 교체 등장 ───────────── + + [Test] + public void EntranceTimeline_LeapsLandsAndSettles_InsideTheSwitchPhase() + { + Assert.Greater(BattleStaging.EntranceLeapStart, 0f); + Assert.Less(BattleStaging.EntranceLeapStart, BattleStaging.EntranceLand); + Assert.Less(BattleStaging.EntranceLand, BattleStaging.EntranceSettleEnd); + Assert.Less(BattleStaging.EntranceSettleEnd, 1f, "착지 반동이 교체 단계 안에서 끝나야 한다"); + Assert.Less(BattleStaging.EntranceBeamEnd, BattleStaging.EntranceLand, "빛살은 착지 전에 착지점을 알린다"); + Assert.Less(BattleStaging.EntranceCamIn, BattleStaging.EntranceLand, "착지는 클로즈업이 자리 잡은 뒤"); + Assert.Less(BattleStaging.EntranceLand, BattleStaging.EntranceCamHoldEnd); + Assert.Less(BattleStaging.EntranceCamReleaseEnd, 1f, "「당신의 턴」은 원래 구도에서 열린다"); + } + + [Test] + public void LeapPosition_StartsAtLaunch_EndsAtRest_AndArcsAboveBoth() + { + var launch = new Vector3(5.1f, 0.5f, 1.95f); + var rest = new Vector3(2.1f, 0.5f, 0.35f); + float apex = BattleStaging.EntranceApex; + AssertNear(launch, BattleStaging.LeapPosition(launch, rest, apex, 0f), 1e-4f); + AssertNear(rest, BattleStaging.LeapPosition(launch, rest, apex, 1f), 1e-4f); + Assert.Greater(BattleStaging.LeapPosition(launch, rest, apex, 0.5f).y, rest.y + apex * 0.9f); + + // 수평으로는 착지점 쪽으로만 다가간다(되돌아가지 않는다). + float previous = float.MaxValue; + for (int i = 0; i <= 20; i++) + { + Vector3 p = BattleStaging.LeapPosition(launch, rest, apex, i / 20f); + float horizontal = new Vector2(p.x - rest.x, p.z - rest.z).magnitude; + Assert.LessOrEqual(horizontal, previous + 1e-4f); + previous = horizontal; + } + } + + [Test] + public void LandingSquash_StartsSquashed_Overshoots_AndSettlesAtOne() + { + Assert.AreEqual(0.78f, BattleStaging.LandingSquash(0f), 1e-4f); + Assert.AreEqual(1f, BattleStaging.LandingSquash(1f), 1e-4f); + float max = 0f; + for (int i = 0; i <= 50; i++) + { + float s = BattleStaging.LandingSquash(i / 50f); + Assert.GreaterOrEqual(s, 0.77f); + Assert.LessOrEqual(s, 1.12f); + max = Mathf.Max(max, s); + } + Assert.Greater(max, 1.03f, "눌렸다가 튀어 올라야 착지 반동으로 읽힌다"); + Assert.Greater(BattleStaging.SquashWidth(0.8f), 1f, "눌리면 퍼진다"); + Assert.AreEqual(1f, BattleStaging.SquashWidth(1f), 1e-6f); + } + + [Test] + public void EvaluateEntrance_StartsFromThePreviousFraming_AndReleasesToTheNewOne() + { + var fromPos = new Vector3(0.2f, 2.1f, -6.4f); + Quaternion fromRot = Quaternion.Euler(12f, -12f, 0f); + var basePos = new Vector3(0.5f, 2.3f, -7.1f); + Quaternion baseRot = Quaternion.Euler(12f, -12f, 0f); + var landing = new Vector3(2.3f, 0.8f, 0.35f); + var opponent = new Vector3(-2.3f, 0.8f, -0.35f); + + BattleCameraDirector.Shot first = BattleCameraDirector.EvaluateEntrance(0f, 1.2f, fromPos, fromRot, basePos, baseRot, landing, opponent); + Assert.AreEqual(1f, first.Weight, 1e-5f, "첫 프레임은 샷이 화면을 쥔다 — 새 구도로 튀지 않게"); + AssertNear(fromPos, first.Position, 1e-4f, "첫 프레임은 교체 전 구도"); + Assert.Less(Quaternion.Angle(fromRot, first.Rotation), 0.01f); + + BattleCameraDirector.Shot landed = BattleCameraDirector.EvaluateEntrance(BattleStaging.EntranceLand, 1.2f, + fromPos, fromRot, basePos, baseRot, landing, opponent); + Assert.AreEqual(1f, landed.Weight, 1e-5f); + Assert.Less(Vector3.Distance(landed.Position, landing), Vector3.Distance(basePos, landing), "착지는 다가가서 본다"); + + float previous = 1f; + for (int i = 0; i <= 20; i++) + { + float p = Mathf.Lerp(BattleStaging.EntranceCamHoldEnd, 1f, i / 20f); + float w = BattleCameraDirector.EvaluateEntrance(p, 1.2f, fromPos, fromRot, basePos, baseRot, landing, opponent).Weight; + Assert.LessOrEqual(w, previous + 1e-5f, "풀리는 동안 가중치는 줄기만 한다"); + previous = w; + } + Assert.AreEqual(0f, BattleCameraDirector.EvaluateEntrance(BattleStaging.EntranceCamReleaseEnd, 1.2f, + fromPos, fromRot, basePos, baseRot, landing, opponent).Weight); + } + + [Test] + public void StagingShots_OffStyle_LeaveTheFramingAlone() + { + BattleCameraDirector.Current = BattleCameraDirector.Style.Off; + Assert.AreEqual(0f, BattleCameraDirector.EvaluateEntrance(0.5f, 1.2f, Vector3.zero, Quaternion.identity, + Vector3.zero, Quaternion.identity, Vector3.one, Vector3.zero).Weight); + Assert.AreEqual(0f, BattleCameraDirector.EvaluateBossTransform(0.5f, 1.5f, Vector3.zero, Quaternion.identity, Vector3.one).Weight); + Assert.AreEqual(0f, BattleCameraDirector.EvaluateGuardianIntro(0.5f, Vector3.zero, Vector3.one, Quaternion.identity).Weight); + } + + // ───────────── ② 그림자 변신 ───────────── + + [Test] + public void TransformTimeline_SwapsUnderFullCover_AndClearsBeforeTheNextTurn() + { + Assert.AreEqual(1f, BattleStaging.SmokeCover(BattleStaging.TransformSwap), 1e-5f, "모델은 연기가 가장 짙을 때 바뀐다"); + Assert.AreEqual(BattleStaging.TransformVeilPeak, BattleStaging.TransformVeil(BattleStaging.TransformSwap), 1e-5f); + Assert.AreEqual(0f, BattleStaging.SmokeCover(0f), 1e-5f); + Assert.AreEqual(0f, BattleStaging.SmokeCover(1f), 1e-5f); + Assert.AreEqual(0f, BattleStaging.TransformVeil(0f), 1e-5f); + Assert.AreEqual(0f, BattleStaging.TransformVeil(1f), 1e-5f, "단계가 끝나면 먹빛이 다 걷힌다"); + Assert.Less(BattleStaging.TransformEngulfEnd, BattleStaging.TransformSwap); + Assert.Less(BattleStaging.TransformSwap, BattleStaging.TransformRoar); + Assert.Less(BattleStaging.TransformRoar, BattleStaging.TransformRevealEnd); + Assert.Less(BattleStaging.TransformRevealEnd, 1f); + Assert.Less(BattleStaging.TransformCamReleaseEnd, 1f, "다음 차례는 원래 구도에서 열린다"); + } + + [Test] + public void TransformScale_IsContinuousAcrossTheSwap_AndEndsAtFullSize() + { + Assert.AreEqual(1f, BattleStaging.TransformScale(0f), 1e-5f); + Assert.AreEqual(1f, BattleStaging.TransformScale(1f), 1e-5f); + Assert.AreEqual(BattleStaging.TransformShrink, BattleStaging.TransformScale(BattleStaging.TransformSwap), 1e-5f); + Assert.AreEqual(BattleStaging.TransformScale(BattleStaging.TransformSwap - 1e-4f), + BattleStaging.TransformScale(BattleStaging.TransformSwap), 0.005f, "갈아끼우는 순간 크기가 튀지 않는다"); + for (int i = 0; i <= 60; i++) + { + float s = BattleStaging.TransformScale(i / 60f); + Assert.GreaterOrEqual(s, BattleStaging.TransformShrink - 1e-5f); + Assert.LessOrEqual(s, BattleStaging.TransformOvershoot + 1e-5f); + } + } + + [Test] + public void EvaluateBossTransform_NeverPassesTheTeamLine_AndReturnsToTheRaidFraming() + { + var team = new Vector3(0f, 0.5f, -2f); + var bossPos = new Vector3(0f, 2.2f, 3f); + BattleArenaController.ComputeRaidCameraFraming(team, bossPos, out Vector3 basePos, out Vector3 look); + Quaternion baseRot = Quaternion.LookRotation(look - basePos); + var bossCenter = new Vector3(0f, 2.4f, 3f); + + Assert.AreEqual(0f, BattleCameraDirector.EvaluateBossTransform(0f, 1.5f, basePos, baseRot, bossCenter).Weight, 1e-5f, + "원래 구도에서 출발한다"); + Assert.AreEqual(1f, BattleCameraDirector.EvaluateBossTransform(BattleStaging.TransformSwap, 1.5f, basePos, baseRot, bossCenter).Weight, 1e-5f); + Assert.AreEqual(0f, BattleCameraDirector.EvaluateBossTransform(BattleStaging.TransformCamReleaseEnd, 1.5f, basePos, baseRot, bossCenter).Weight); + for (int i = 0; i <= 40; i++) + { + BattleCameraDirector.Shot shot = BattleCameraDirector.EvaluateBossTransform(i / 40f, 1.5f, basePos, baseRot, bossCenter); + if (shot.Weight <= 0f) continue; + Assert.Less(shot.Position.z, team.z - 1f, $"진행률 {i / 40f}: 카메라가 팀 줄을 넘었다"); + } + } + + // ───────────── ③ 수문장 등장 ───────────── + + [Test] + public void GuardianIntro_AddsUnderASecond_AndReleasesBeforeTheFirstTurn() + { + const float OrdinaryRaidIntro = 2f; // RaidBattleUI.RaidIntroSeconds + Assert.Greater(BattleStaging.GuardianIntroSeconds, OrdinaryRaidIntro); + Assert.LessOrEqual(BattleStaging.GuardianIntroSeconds - OrdinaryRaidIntro, 1f, "레이드 진행 시간을 1초 넘게 늘리지 않는다"); + Assert.Less(BattleStaging.GuardianRoarAt, BattleStaging.GuardianApproachEnd); + Assert.Less(BattleStaging.GuardianApproachEnd, BattleStaging.GuardianReleaseStart); + Assert.Less(BattleStaging.GuardianReleaseEnd, BattleStaging.GuardianIntroSeconds, "첫 차례는 원래 구도에서 열린다"); + Assert.AreEqual(0f, BattleStaging.RoarPose(BattleStaging.GuardianReleaseStart), 1e-5f, "풀리기 전에 포효 자세가 끝난다"); + + Quaternion rot = Quaternion.Euler(-5f, 0f, 0f); + Assert.AreEqual(1f, BattleCameraDirector.EvaluateGuardianIntro(0f, Vector3.back * 10f, Vector3.back * 6f, rot).Weight, 1e-5f, + "인트로 첫 장면부터 등장 컷이다"); + Assert.AreEqual(0f, BattleCameraDirector.EvaluateGuardianIntro(BattleStaging.GuardianReleaseEnd, + Vector3.back * 10f, Vector3.back * 6f, rot).Weight); + } + + [Test] + public void GuardianApproachStart_StaysInsideTheWalls_AndBehindTheEnd() + { + var end = new Vector3(0f, 1.4f, -9f); + Quaternion rot = Quaternion.Euler(-2f, 0f, 0f); + Vector3 start = BattleStaging.GuardianApproachStart(end, rot, 12f, Vector3.zero, 13f, 0f); + Assert.LessOrEqual(Mathf.Abs(start.z), 13f - BattleStaging.GuardianWallMargin + 1e-4f, "벽 밖에 서면 벽의 바깥 면이 화면을 막는다"); + Assert.LessOrEqual(start.z, end.z, "출발점은 끝 자리보다 뒤다"); + Assert.GreaterOrEqual(start.y, BattleStaging.GuardianMinCameraHeight - 1e-4f); + } + + // 받침대(2.2m) 위 수문장 — 사마귀·큰 뿔(키 큼)·아틀라스나방(날개 넓음)·나비·반딧불이·작은 곤충. 레이드 배율 2.3배 기준의 대략값. + private static readonly Bounds[] GuardianBodies = + { + new Bounds(new Vector3(0f, 2.25f, 3f), new Vector3(1.6f, 1.9f, 3.6f)), + new Bounds(new Vector3(0f, 2.45f, 3f), new Vector3(2.0f, 2.5f, 3.4f)), + new Bounds(new Vector3(0f, 2.3f, 3f), new Vector3(4.6f, 1.4f, 2.6f)), + new Bounds(new Vector3(0f, 2.35f, 3f), new Vector3(3.8f, 2.0f, 2.0f)), + new Bounds(new Vector3(0f, 2.2f, 3f), new Vector3(1.8f, 1.1f, 2.0f)), + new Bounds(new Vector3(0f, 2.2f, 3f), new Vector3(1.0f, 0.8f, 1.2f)), + }; + + /// + /// 등장 컷 내내 수문장이 화면 위쪽 2/3 안에 있다 — 아래 1/3은 ui-dev의 「○○의 수문장 · 별칭 / 이름」 배너 자리다. + /// FOV는 CameraFollower.ApplyAspectFov와 같은 식(세로 60°, 가로 60/√비율, 32~60°). + /// + [TestCase(1280, 720)] + [TestCase(720, 1280)] + [TestCase(2400, 1080)] + [TestCase(1080, 2400)] + public void GuardianShot_KeepsTheGuardianInTheUpperTwoThirds(int width, int height) + { + float aspect = (float)width / height; + float fov = height >= width ? 60f : Mathf.Clamp(60f / Mathf.Sqrt(aspect), 32f, 60f); + float tanV = Mathf.Tan(fov * Mathf.Deg2Rad * 0.5f); + float tanH = tanV * aspect; + var team = new Vector3(0f, 0.5f, -2f); + var window = new Rect(0f, BattleStaging.GuardianWindowBottom, 1f, + BattleStaging.GuardianWindowTop - BattleStaging.GuardianWindowBottom); + float[] times = { 0f, 0.3f, BattleStaging.GuardianRoarAt + 0.04f, 0.9f, BattleStaging.GuardianApproachEnd, + BattleStaging.GuardianReleaseStart - 0.01f }; + + foreach (Bounds body in GuardianBodies) + { + Bounds fit = body; + fit.Expand(0.3f); + BattleStaging.GuardianEndShot(fit, aspect, fov, 0f, window, team, 0f, + out Vector3 endPos, out Vector3 endTarget, out Quaternion rotation); + Assert.GreaterOrEqual(endPos.y, BattleStaging.GuardianMinCameraHeight - 1e-4f, "카메라가 팀 머리 위에 선다"); + Assert.LessOrEqual(endPos.z, team.z - BattleStaging.GuardianTeamClearance + 1e-3f, "카메라가 팀 줄 뒤에 선다"); + float span = Mathf.Max(BattleArenaController.ArenaWallSpan, Mathf.Abs(endPos.z) + 3f); + Vector3 startPos = BattleStaging.GuardianApproachStart(endPos, rotation, Vector3.Distance(endPos, endTarget), + Vector3.zero, span, 0f); + + foreach (float t in times) + { + BattleCameraDirector.Shot shot = BattleCameraDirector.EvaluateGuardianIntro(t, startPos, endPos, rotation); + Assert.AreEqual(1f, shot.Weight, 1e-5f); + Quaternion inverse = Quaternion.Inverse(shot.Rotation); + for (int i = 0; i < 8; i++) + { + Vector3 corner = body.center + Vector3.Scale(body.extents, new Vector3( + (i & 1) == 0 ? -1f : 1f, (i & 2) == 0 ? -1f : 1f, (i & 4) == 0 ? -1f : 1f)); + Vector3 local = inverse * (corner - shot.Position); + Assert.Greater(local.z, 0.1f, "수문장이 카메라 앞에 있다"); + float vx = 0.5f + 0.5f * local.x / (local.z * tanH); + float vy = 0.5f + 0.5f * local.y / (local.z * tanV); + string where = $"{width}x{height} t={t:F2} 몸 {body.size} 모서리 {i}: ({vx:F3}, {vy:F3})"; + Assert.GreaterOrEqual(vy, 1f / 3f, "아래 1/3(배너 자리)로 내려왔다 — " + where); + Assert.LessOrEqual(vy, 1f, "화면 위로 잘렸다 — " + where); + Assert.GreaterOrEqual(vx, 0f, "화면 왼쪽으로 잘렸다 — " + where); + Assert.LessOrEqual(vx, 1f, "화면 오른쪽으로 잘렸다 — " + where); + } + } + } + } + + // ───────────── 그림자 색 ───────────── + + [Test] + public void ShadowTint_ZeroStrength_IsIdentity_AndAlphaIsKept() + { + var wing = new Color(0.96f, 0.83f, 0.18f, 0.3f); + Color same = BattleStaging.ShadowTint(wing, 0f); + Assert.AreEqual(wing.r, same.r, 1e-5f); + Assert.AreEqual(wing.g, same.g, 1e-5f); + Assert.AreEqual(wing.b, same.b, 1e-5f); + Assert.AreEqual(0.3f, BattleStaging.ShadowTint(wing, BattleStaging.ShadowBorrowedStrength).a, 1e-5f, "날개 막의 투명도는 그대로"); + Assert.AreEqual(0.3f, BattleStaging.ShadowGlowTint(wing).a, 1e-5f); + } + + [Test] + public void ShadowTint_BorrowedForm_IsDarkPurple_ButKeepsThePattern() + { + var yellow = new Color(0.96f, 0.83f, 0.18f); // 호랑나비 노랑 + var stripe = new Color(0.08f, 0.07f, 0.06f); // 검은 줄 + Color y = BattleStaging.ShadowTint(yellow, BattleStaging.ShadowBorrowedStrength); + Color s = BattleStaging.ShadowTint(stripe, BattleStaging.ShadowBorrowedStrength); + Assert.Less(Luma(y), Luma(yellow) * 0.6f, "빌린 모습은 진짜보다 확실히 어둡다"); + Assert.Greater(y.b, y.g, "검보라 — 노랑이 남지 않는다"); + Assert.Greater(Luma(y), Luma(s), "밝고 어두운 무늬 차는 남는다"); + // 원래 모습(사마귀)은 같은 톤이되 덜 덮는다. + var green = new Color(0.35f, 0.62f, 0.28f); + Color original = BattleStaging.ShadowTint(green, BattleStaging.ShadowOriginalStrength); + Color borrowed = BattleStaging.ShadowTint(green, BattleStaging.ShadowBorrowedStrength); + Assert.Greater(original.g - original.b, borrowed.g - borrowed.b, "원래 모습엔 제 색이 조금 더 남는다"); + Assert.Less(BattleStaging.ShadowOriginalStrength, BattleStaging.ShadowBorrowedStrength); + } + + [Test] + public void ShadowBoss_IsExactlyTheFormChangingBoss() + { + Assert.IsTrue(BattleStaging.IsShadowBoss("mantis_unnamed")); + Assert.IsFalse(BattleStaging.IsShadowBoss("mantis_green")); + Assert.IsFalse(BattleStaging.IsShadowBoss(null)); + } + + private static float Luma(Color c) + { + return c.r * 0.299f + c.g * 0.587f + c.b * 0.114f; + } + } +} +#endif diff --git a/Assets/Tests/EditMode/BattleStagingTests.cs.meta b/Assets/Tests/EditMode/BattleStagingTests.cs.meta new file mode 100644 index 0000000..59a4c55 --- /dev/null +++ b/Assets/Tests/EditMode/BattleStagingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 389bacc68de36764fae82f6f0c93f25f \ No newline at end of file diff --git a/Assets/Tests/EditMode/BattleSupportEffectSideTests.cs b/Assets/Tests/EditMode/BattleSupportEffectSideTests.cs new file mode 100644 index 0000000..294fc75 --- /dev/null +++ b/Assets/Tests/EditMode/BattleSupportEffectSideTests.cs @@ -0,0 +1,76 @@ +#if UNITY_EDITOR +using System.Threading.Tasks; +using InsectGame.Battle; +using InsectGame.Data; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 버프·약화 연출이 맞는 쪽 곤충 위에 뜨는가 — 진짜 로 기술 연출을 돌려 + /// 이펙트 오브젝트가 생긴 자리를 잰다. + /// + /// 예전엔 버프 자리를 진영 bool로 다시 골랐는데 상대 쪽 분기도 내 곤충이라, 상대가 자기에게 건 버프의 고리가 + /// 내 곤충 위에 떴다(2026-10-02 기기 보고). 효과 자체는 옳게 상대에게 걸려 있어 숫자로는 안 보이던 결함이다. + /// + [TestFixture] + public class BattleSupportEffectSideTests + { + [TestCase(false, SkillEffectType.BuffAttack, false, "BuffRing_0")] // 상대의 자기 버프 → 상대 위 + [TestCase(false, SkillEffectType.DefenseBuff, false, "BuffRing_0")] + [TestCase(true, SkillEffectType.BuffAttack, true, "BuffRing_0")] // 내 버프 → 내 곤충 위 + [TestCase(false, SkillEffectType.DebuffAttack, true, "DebuffOrb")] // 상대의 약화 → 내 곤충 위 + [TestCase(true, SkillEffectType.DebuffAttack, false, "DebuffOrb")] // 내 약화 → 상대 위 + [Timeout(60000)] + public async Task SupportEffect_AppearsOverTheInsectItAffects( + bool playerActs, SkillEffectType effectType, bool expectOnPlayer, string effectName) + { + var data = ScriptableObject.CreateInstance(); + data.insectId = "beetle_basic"; + data.displayName = "검사용"; + var root = new GameObject("SupportEffectSideTest"); + try + { + var arena = root.AddComponent(); + arena.SetupNormalBattle(data, 5, data, 5, false, Vector3.zero, Vector3.right); + Assert.IsNotNull(arena.PlayerModel); + Assert.IsNotNull(arena.EnemyModel); + Vector3 playerPos = arena.PlayerModel.transform.position; + Vector3 enemyPos = arena.EnemyModel.transform.position; + Assert.Greater(Vector3.Distance(playerPos, enemyPos), 2f, "두 곤충이 한 자리에 섰다"); + + arena.PlaySkillEffect(playerActs, InsectElement.None, effectType, null, false, 0.6f); + + // 프레임 수가 아니라 시간으로 기다린다 — 이펙트는 연출 시간이 타격 시점에 닿아야 생기는데, 배치 러너는 렌더가 없어 + // 600프레임이 0.23초에 끝나 그 직전에 포기하곤 했다(통과 0.25초 · 실패 0.23초, 2026-10-03). + GameObject effect = null; + float waitUntil = Time.realtimeSinceStartup + 10f; + while (effect == null && Time.realtimeSinceStartup < waitUntil) + { + await Task.Yield(); + effect = GameObject.Find(effectName); + } + Assert.IsNotNull(effect, effectName + " 이펙트가 생기지 않았다"); + + // 이펙트는 대상의 수직선 위에서 오르내린다 — 수평 거리로 어느 쪽인지 가린다. + Vector3 at = effect.transform.position; + float toPlayer = Vector2.Distance(new Vector2(at.x, at.z), new Vector2(playerPos.x, playerPos.z)); + float toEnemy = Vector2.Distance(new Vector2(at.x, at.z), new Vector2(enemyPos.x, enemyPos.z)); + if (expectOnPlayer) + Assert.Less(toPlayer, toEnemy, $"이펙트가 상대 쪽에 떴다 (내 곤충까지 {toPlayer:F2}m, 상대까지 {toEnemy:F2}m)"); + else + Assert.Less(toEnemy, toPlayer, $"이펙트가 내 곤충 쪽에 떴다 (내 곤충까지 {toPlayer:F2}m, 상대까지 {toEnemy:F2}m)"); + } + finally + { + Object.DestroyImmediate(root); + Object.DestroyImmediate(data); + // 이펙트·아레나는 아레나 루트 밑이라 CleanupArena(OnDisable)가 함께 치운다. 남았으면 다음 케이스가 그걸 찾는다. + GameObject leftover = GameObject.Find(effectName); + if (leftover != null) Object.DestroyImmediate(leftover); + } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/BattleSupportEffectSideTests.cs.meta b/Assets/Tests/EditMode/BattleSupportEffectSideTests.cs.meta new file mode 100644 index 0000000..e65919d --- /dev/null +++ b/Assets/Tests/EditMode/BattleSupportEffectSideTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5fd9014159eece54da1b5cbe064861cc \ No newline at end of file diff --git a/Assets/Tests/EditMode/BlightPolicyTests.cs b/Assets/Tests/EditMode/BlightPolicyTests.cs index ee8c364..92f0325 100644 --- a/Assets/Tests/EditMode/BlightPolicyTests.cs +++ b/Assets/Tests/EditMode/BlightPolicyTests.cs @@ -27,7 +27,7 @@ public class BlightPolicyTests /// /// 하한은 리전이 허용하는 만큼까지다: min(MinActive, baseMax). /// 절대 불변식(1 이상)은 그대로 못 박는다 — 캠페인 정지는 거기서 난다. - /// 도달 가능한 값이라 1도 함께 본다(GameplayTuningProfile이 [Range(1, 15)]). + /// 정의역 전체에서 지키는 규칙이라 1도 함께 본다(지금 스포너는 슬롯 수 8~40을 넘긴다 — FieldSpawnRules). /// [TestCase(1)] [TestCase(2)] @@ -73,13 +73,12 @@ public void MaxActiveFor_Blighted_IsScarcerThanClean() /// Mathf.Max(MinActive, 1/3)이 2라, 오염된 리전이 멀쩡한 리전(1)보다 /// **곤충이 많았다.** 하한을 보장 수량으로 쓴 탓이다. /// - /// 범위는 GameplayTuningProfile.maxActivePerRegion의 [Range(1, 15)]다 — - /// 인스펙터에서 넣을 수 있는 값 전부가 정의역이므로 전부 본다. + /// 범위는 1부터 스포너가 넘길 수 있는 최대 슬롯 수(FieldSpawnRules.MaxRegionSlots)까지 전부다. /// [Test] public void MaxActiveFor_Blighted_NeverExceedsClean_AcrossTuningRange() { - for (int baseMax = 1; baseMax <= 15; baseMax++) + for (int baseMax = 1; baseMax <= InsectGame.Spawning.FieldSpawnRules.MaxRegionSlots; baseMax++) { int clean = BlightPolicy.MaxActiveFor(false, baseMax); int blighted = BlightPolicy.MaxActiveFor(true, baseMax); @@ -91,8 +90,8 @@ public void MaxActiveFor_Blighted_NeverExceedsClean_AcrossTuningRange() } /// - /// 스포너는 maxActivePerRegion <= 0을 "리전 상한 없음"으로 쓴다(InsectSpawner:409). - /// 그 의미를 오염이 뒤집으면 안 된다 — 갑자기 상한 2가 생겨 전 리전이 조인다. + /// 0 이하는 "상한 없음"으로 통과시킨다 — 옛 스포너가 리전 상한 0 이하를 그렇게 썼다. 지금 스포너는 슬롯 수 + /// 8 이상만 넘기지만, 이 의미를 오염이 뒤집어 갑자기 상한 2가 생기는 일은 정의역 전체에서 막아 둔다. /// [TestCase(0)] [TestCase(-1)] diff --git a/Assets/Tests/EditMode/BossDuelRegionGateTests.cs b/Assets/Tests/EditMode/BossDuelRegionGateTests.cs new file mode 100644 index 0000000..157e15e --- /dev/null +++ b/Assets/Tests/EditMode/BossDuelRegionGateTests.cs @@ -0,0 +1,96 @@ +#if UNITY_EDITOR +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.NPC; +using NUnit.Framework; + +namespace InsectGame.Tests +{ + /// + /// 명부회 하수 대결의 리전 제한 — 오염 거점 보스()는 자기 거점 리전에서만 싸운다. + /// + /// 하수 둘은 거점이 없는 리전에도 서 있다. 연못(플레이어 Lv6~20)에서 ch2_watchers를 본 뒤 검은 옷의 사내 + /// (ledger_thug_cord, Lv34)에게 다시 말을 걸면 확인 없이 대결이 열렸고, 숲의 검은 옷의 여자(Lv32)도 같았다. + /// 거점을 맡지 않은 간부(집게·저울·하월)는 지금처럼 리전과 무관하다. + /// + [TestFixture] + public class BossDuelRegionGateTests + { + private static RegionData[] Fixture() + { + return new[] + { + new RegionData { regionId = "pond" }, + new RegionData { regionId = "forest", blightBossNpcId = "ledger_thug_pin" }, + new RegionData { regionId = "mountain", blightBossNpcId = "ledger_thug_rule" }, + new RegionData { regionId = "ruins", blightBossNpcId = "ledger_thug_cord" }, + }; + } + + [Test] + public void SiteBoss_OnlyInItsOwnRegion() + { + RegionData[] regions = Fixture(); + Assert.IsFalse(NpcDuelController.BossDuelAllowedInRegion("ledger_thug_cord", "pond", regions), "연못에서 Lv34 하수와 붙었다"); + Assert.IsFalse(NpcDuelController.BossDuelAllowedInRegion("ledger_thug_cord", "mountain", regions), "다른 거점 리전도 아니다"); + Assert.IsTrue(NpcDuelController.BossDuelAllowedInRegion("ledger_thug_cord", "ruins", regions)); + + Assert.IsFalse(NpcDuelController.BossDuelAllowedInRegion("ledger_thug_rule", "forest", regions), "숲의 검은 옷의 여자"); + Assert.IsTrue(NpcDuelController.BossDuelAllowedInRegion("ledger_thug_rule", "mountain", regions)); + } + + [Test] + public void SiteBoss_OnTheIsland_IsNotAllowed() + { + // 나의 섬에서는 ActionRegionId가 null이다 — 섬은 어느 리전도 아니다(rules/island.md). + Assert.IsFalse(NpcDuelController.BossDuelAllowedInRegion("ledger_thug_cord", null, Fixture())); + } + + [Test] + public void NonSiteBoss_IsAllowedAnywhere() + { + RegionData[] regions = Fixture(); + foreach (string exec in new[] { "ledger_grip", "ledger_scale", "ledger_chief" }) + { + Assert.IsTrue(NpcDuelController.BossDuelAllowedInRegion(exec, "pond", regions), exec); + Assert.IsTrue(NpcDuelController.BossDuelAllowedInRegion(exec, "ruins", regions), exec); + Assert.IsTrue(NpcDuelController.BossDuelAllowedInRegion(exec, null, regions), exec); + } + } + + [Test] + public void UnknownRegionTable_KeepsTheOldBehaviour() + { + Assert.IsTrue(NpcDuelController.BossDuelAllowedInRegion("ledger_thug_cord", "pond", null)); + } + + /// + /// 실제 리전 표로 — 거점 보스가 정확히 거점 리전 하나에서만 열리고, 대결 표의 다른 상대는 어디서나 열린다. + /// 거점 데이터(RegionDefinitions)를 옮기면 이 테스트가 그 인물의 대결 자리를 따라 옮겨 확인한다. + /// + [Test] + public void RealRegions_EverySiteBoss_HasExactlyOneDuelRegion() + { + RegionData[] regions = RegionDefinitions.CreateAll(); + int siteBosses = 0; + foreach (RegionData site in regions) + { + if (site == null || !site.HasBlightSite) continue; + siteBosses++; + int allowed = 0; + foreach (RegionData here in regions) + if (here != null && NpcDuelController.BossDuelAllowedInRegion(site.blightBossNpcId, here.regionId, regions)) + allowed++; + Assert.AreEqual(1, allowed, $"{site.blightBossNpcId}의 대결이 {allowed}개 리전에서 열린다"); + Assert.IsTrue(NpcDuelController.BossDuelAllowedInRegion(site.blightBossNpcId, site.regionId, regions)); + } + Assert.Greater(siteBosses, 0, "실제 리전 표에 오염 거점이 하나도 없다 — 파서·데이터를 확인할 것"); + + Assert.IsTrue(NpcBossDuels.TryGet("ledger_grip", out _), "간부 표가 바뀌었다 — 아래 단언을 갱신할 것"); + foreach (RegionData here in regions) + if (here != null) + Assert.IsTrue(NpcDuelController.BossDuelAllowedInRegion("ledger_grip", here.regionId, regions)); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/BossDuelRegionGateTests.cs.meta b/Assets/Tests/EditMode/BossDuelRegionGateTests.cs.meta new file mode 100644 index 0000000..578ce29 --- /dev/null +++ b/Assets/Tests/EditMode/BossDuelRegionGateTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8706b120af8bc9b4fb488b37703b88f0 \ No newline at end of file diff --git a/Assets/Tests/EditMode/CaptureChanceCalculatorTests.cs b/Assets/Tests/EditMode/CaptureChanceCalculatorTests.cs index 2f0b620..747fa2d 100644 --- a/Assets/Tests/EditMode/CaptureChanceCalculatorTests.cs +++ b/Assets/Tests/EditMode/CaptureChanceCalculatorTests.cs @@ -96,13 +96,16 @@ public void Calculate_PlayerLevelAdvantage_ChanceNeverDecreases() [Test] public void Calculate_CoreBelowFloor_PositiveBonusesAreNotSwallowed() { + // 레벨 차는 유예(+5) 안에 둔다 — 이 테스트는 floor 위에 보너스가 얹히는지를 보고, + // 그 너머의 레벨 제한 배율은 Calculate_InsectFarAboveTrainer_*가 본다. + // 코어: .60 - .32 - .40 - .15 = -.27 → floor .04 float noBonus = Calculate( - InsectRarity.Legendary, 1f, 1, 50, comboHits: 0); + InsectRarity.Legendary, 1f, 1, 6, comboHits: 0); float itemBonus = Calculate( - InsectRarity.Legendary, 1f, 1, 50, comboHits: 0, + InsectRarity.Legendary, 1f, 1, 6, comboHits: 0, activeItemBonus: 0.10f); float oneHit = Calculate( - InsectRarity.Legendary, 1f, 1, 50, comboHits: 1); + InsectRarity.Legendary, 1f, 1, 6, comboHits: 1); Assert.AreEqual(0.04f, noBonus, 0.0001f); Assert.AreEqual(0.14f, itemBonus, 0.0001f); @@ -183,6 +186,51 @@ public void Calculate_HugeLevelAdvantage_DoesNotGuaranteeLegendaryCapture() Assert.AreEqual(0.052f, chance, 0.0001f); } + [Test] + public void Calculate_InsectFarAboveTrainer_PerfectMinigameCannotCatch() + { + // 예전엔 덧셈 보정이 ±5에서 잘리고 최저 보장(.30) 위에 퍼펙트 보너스(+.30)가 얹혀 + // 캐릭터보다 15레벨 높은 일반 곤충이 66%로 잡혔다. + float chance = Calculate( + InsectRarity.Common, captureDifficulty: 0.22f, + playerLevel: 10, insectLevel: 25, comboHits: 3, + activeItemBonus: 0.10f, outfitBonus: 0.05f); + + Assert.LessOrEqual(chance, 0.05f, "레벨 차 +15는 보너스를 다 얹어도 사실상 포획 불가여야 한다"); + Assert.Greater(chance, 0f, "불가능이 아니라 극히 낮은 확률이다"); + } + + [Test] + public void Calculate_PastGrace_ChanceStrictlyDecreasesUntilMinimum() + { + int grace = GameConstants.TrainerLevel.CaptureGraceLevels; + float previous = Calculate(InsectRarity.Legendary, 0.82f, 20, 20 + grace, comboHits: 3); + for (int gap = grace + 1; gap <= grace + 9; gap++) + { + float current = Calculate(InsectRarity.Legendary, 0.82f, 20, 20 + gap, comboHits: 3); + Assert.Less(current, previous, $"레벨 차 +{gap}에서 확률이 줄지 않았다"); + previous = current; + } + } + + [Test] + public void Calculate_WithinGrace_UnaffectedByLevelGapMultiplier() + { + // 유예 안(+5까지)은 옛 공식 그대로 — 정상 진행의 포획률을 바꾸지 않는다. + int grace = GameConstants.TrainerLevel.CaptureGraceLevels; + float chance = Calculate(InsectRarity.Rare, 0.50f, 20, 20 + grace, comboHits: 2); + // base .60 - 등급 .16 - 난이도 .20 - 레벨 .15 = .09 → 하한 .14 + 타이밍 .15 + 콤보 .10 = .39 + Assert.AreEqual(0.39f, chance, 0.0001f); + } + + [Test] + public void CaptureGraceLevels_MatchesAdditiveLevelCap() + { + // 둘이 어긋나면 +5와 유예 사이에 확률이 평평한 구간이 생긴다(예전엔 +5 너머 전부가 그랬다). + Assert.AreEqual(CaptureChanceCalculator.MaximumLevelDelta, + GameConstants.TrainerLevel.CaptureGraceLevels); + } + /// /// 프로파일 에셋을 새로 만들었을 때 공식 기본값과 어긋나지 않는가. /// diff --git a/Assets/Tests/EditMode/CaptureMinigameTests.cs b/Assets/Tests/EditMode/CaptureMinigameTests.cs new file mode 100644 index 0000000..d3f1f0a --- /dev/null +++ b/Assets/Tests/EditMode/CaptureMinigameTests.cs @@ -0,0 +1,291 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Capture; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 포획 미니게임 세 종의 규칙. 화면은 IMGUI라 테스트로 못 보므로, 점수가 어떻게 나오는지만 고정한다. + /// + /// 셋 다 결과가 0~3점이고 그 값이 CaptureMinigameProbability로 들어간다. 그래서 여기서 지킬 것은 + /// ①잘하면 3점이 실제로 가능하고(시간이 모자라 불가능한 판이 없다) ②아무것도 안 하면 0점이라는 것이다. + /// 어느 한쪽이 깨지면 포획 확률이 게임 종류에 따라 조용히 갈린다. + /// + [TestFixture] + public class CaptureMinigameTests + { + private const float Step = 1f / 60f; + private const int MaxTicks = 60 * 60; // 어떤 판도 1분 안에는 끝나야 한다 + + private static CaptureMinigameTuning Tuning(int rarity, float speed = 1f, float zone = 1f, float time = 1f) + => new CaptureMinigameTuning(rarity, speed, zone, time); + + private static int RunUntilDone(CaptureMinigame game, System.Func play) + { + int ticks = 0; + while (!game.Done && ticks < MaxTicks) + { + game.Tick(Step, play(game)); + ticks++; + } + return ticks; + } + + private static CaptureMinigameInput Idle(CaptureMinigame game) => new CaptureMinigameInput(); + + // ── 게임 고르기 ── + + [Test] + public void PickNext_NeverRepeatsThePreviousGame() + { + var random = new System.Random(7); + CaptureMinigameKind last = CaptureMinigame.PickNext(null, random); + for (int i = 0; i < 300; i++) + { + CaptureMinigameKind next = CaptureMinigame.PickNext(last, random); + Assert.AreNotEqual(last, next, $"{i}번째 추첨이 직전 게임을 반복했다"); + last = next; + } + } + + [Test] + public void PickNext_ReachesEveryGame() + { + var random = new System.Random(11); + var seen = new HashSet(); + CaptureMinigameKind? last = null; + for (int i = 0; i < 60; i++) + { + last = CaptureMinigame.PickNext(last, random); + seen.Add(last.Value); + } + Assert.AreEqual(3, seen.Count); + } + + [TestCase(CaptureMinigameKind.Sneak)] + [TestCase(CaptureMinigameKind.Track)] + [TestCase(CaptureMinigameKind.Toss)] + public void Create_ReturnsTheRequestedGame(CaptureMinigameKind kind) + { + Assert.AreEqual(kind, CaptureMinigame.Create(kind, Tuning(0), new System.Random(1)).Kind); + } + + [Test] + public void Tuning_NonPositiveMultipliers_FallBackToOne() + { + // 0이 그대로 들어가면 제한 시간이 0이 되어 판이 열리자마자 끝난다. + CaptureMinigameTuning t = new CaptureMinigameTuning(9, 0f, -1f, 0f); + Assert.AreEqual(4, t.Rarity); + Assert.AreEqual(1f, t.SpeedMult); + Assert.AreEqual(1f, t.ZoneMult); + Assert.AreEqual(1f, t.TimeMult); + } + + // ── 살금살금 ── + + // 조심스러운 사람: 곤충이 가만히 있을 때만 다가간다(예고가 뜨면 바로 멈춘다). + private static CaptureMinigameInput Careful(CaptureMinigame game) + { + var sneak = (SneakMinigame)game; + return new CaptureMinigameInput { Down = sneak.State == SneakMinigame.Watch.Calm }; + } + + [Test] + public void Sneak_CarefulPlay_ScoresThreeAtEveryRarity() + { + for (int rarity = 0; rarity <= 4; rarity++) + { + for (int seed = 0; seed < 25; seed++) + { + var game = new SneakMinigame(Tuning(rarity), new System.Random(seed)); + game.Tick(Step, new CaptureMinigameInput()); // 손을 뗀 상태에서 시작 + RunUntilDone(game, Careful); + + Assert.IsTrue(game.Done); + Assert.AreEqual(0, game.Spots, $"등급 {rarity} 시드 {seed}: 가만히 있을 때만 움직였는데 들켰다"); + Assert.AreEqual(3, game.Hits, $"등급 {rarity} 시드 {seed}: 한 번도 안 들켰는데 시간이 모자랐다"); + } + } + } + + [Test] + public void Sneak_HoldingThroughEverything_GetsSpottedTwiceAndInsectFlees() + { + // 전설 등급으로 잰다 — 걸음이 느려 두 번째로 돌아보기 전에는 절대 닿지 못한다 + // (일반 등급은 운이 좋으면 한 번만 들키고 도착한다). + var game = new SneakMinigame(Tuning(4), new System.Random(3)); + game.Tick(Step, new CaptureMinigameInput()); + // 들킨 뒤엔 손을 한 번 떼야 다시 움직인다 — 굳은 시간이 끝나면 떼었다가 다시 누르는 사람. + bool release = false; + RunUntilDone(game, g => + { + var sneak = (SneakMinigame)g; + if (sneak.FreezeLeft > 0f) { release = true; return new CaptureMinigameInput { Down = true }; } + if (release) { release = false; return new CaptureMinigameInput(); } + return new CaptureMinigameInput { Down = true }; + }); + + Assert.IsTrue(game.Done); + Assert.IsTrue(game.Fled); + Assert.AreEqual(SneakMinigame.MaxSpots, game.Spots); + Assert.Less(game.Hits, 3); + } + + [Test] + public void Sneak_Spotted_PushesBackButNeverAcrossTheFlagBehind() + { + var game = new SneakMinigame(Tuning(0), new System.Random(5)); + game.Tick(Step, new CaptureMinigameInput()); + int ticks = 0; + while (game.Spots == 0 && !game.Done && ticks++ < MaxTicks) + game.Tick(Step, new CaptureMinigameInput { Down = true }); + + Assert.AreEqual(1, game.Spots); + // 어느 구간에서 들켰든 그 구간의 출발 깃발보다 뒤로는 안 간다. + float top = game.Distance > SneakMinigame.FirstFlag ? 1f + : (game.Distance > SneakMinigame.SecondFlag ? SneakMinigame.FirstFlag : SneakMinigame.SecondFlag); + Assert.Less(game.Distance, top); + } + + [Test] + public void Sneak_HeldFromBeforeTheGameOpened_DoesNotMoveUntilReleased() + { + // 채집망을 고른 그 손가락이 눌린 채로 판이 열린다 — 그대로 걸어가면 안 된다. + var game = new SneakMinigame(Tuning(0), new System.Random(2)); + for (int i = 0; i < 20; i++) game.Tick(Step, new CaptureMinigameInput { Down = true }); + Assert.AreEqual(1f, game.Distance); + } + + [Test] + public void Sneak_NoInput_TimesOutWithZero() + { + var game = new SneakMinigame(Tuning(0), new System.Random(1)); + RunUntilDone(game, Idle); + Assert.IsTrue(game.Done); + Assert.AreEqual(0, game.Hits); + Assert.AreEqual(0f, game.TimeRatio); + } + + // ── 가두기 ── + + [Test] + public void Track_FollowingTheInsect_FillsTheGauge() + { + for (int rarity = 0; rarity <= 2; rarity++) + { + var game = new TrackMinigame(Tuning(rarity), new System.Random(rarity + 1)); + RunUntilDone(game, g => new CaptureMinigameInput { Down = true, Point = ((TrackMinigame)g).Insect }); + + Assert.IsTrue(game.Done); + Assert.AreEqual(3, game.Hits, $"등급 {rarity}: 곤충을 정확히 따라갔는데 게이지가 안 찼다"); + Assert.Greater(game.TimeLeft, 0f); + } + } + + [Test] + public void Track_NoInput_ScoresNothingEvenIfInsectWandersIntoTheNet() + { + for (int seed = 0; seed < 20; seed++) + { + var game = new TrackMinigame(Tuning(0), new System.Random(seed)); + RunUntilDone(game, Idle); + Assert.AreEqual(0, game.Hits, $"시드 {seed}: 손을 대지 않았는데 별이 생겼다"); + } + } + + [Test] + public void Track_HitsAreLatched_LosingTheInsectDoesNotTakeThemBack() + { + var game = new TrackMinigame(Tuning(0), new System.Random(4)); + int ticks = 0; + while (game.Hits < 1 && !game.Done && ticks++ < MaxTicks) + game.Tick(Step, new CaptureMinigameInput { Down = true, Point = game.Insect }); + Assert.AreEqual(1, game.Hits); + + RunUntilDone(game, Idle); // 손을 떼면 게이지는 빠진다 + Assert.Less(game.Gauge, 1f / 3f); + Assert.AreEqual(1, game.Hits); + } + + [TestCase(0f, 0)] + [TestCase(0.33f, 0)] + [TestCase(0.34f, 1)] + [TestCase(0.67f, 2)] + [TestCase(0.99f, 2)] + [TestCase(1f, 3)] + public void Track_HitsForGauge_ThirdsOfTheGauge(float gauge, int expected) + { + Assert.AreEqual(expected, TrackMinigame.HitsForGauge(gauge)); + } + + [Test] + public void Track_BetterNet_HasBiggerRadius() + { + float basic = new TrackMinigame(Tuning(1), new System.Random(1)).NetRadius; + float gold = new TrackMinigame(Tuning(1, 0.55f, 1.6f, 1.6f), new System.Random(1)).NetRadius; + Assert.Greater(gold, basic); + } + + // ── 던지기 ── + + [Test] + public void Toss_ThrowingWhereTheInsectIs_HitsWhenItBarelyMoves() + { + // 속도 배율을 거의 0으로 — 날아가는 0.45초 동안 곤충이 그물 반경을 못 벗어난다. + var game = new TossMinigame(Tuning(0, 0.02f), new System.Random(1)); + RunUntilDone(game, g => + { + var toss = (TossMinigame)g; + return new CaptureMinigameInput { Pressed = !toss.InFlight, Point = toss.Insect }; + }); + + Assert.IsTrue(game.Done); + Assert.AreEqual(0, game.ThrowsLeft); + Assert.AreEqual(3, game.Hits); + } + + [Test] + public void Toss_ThrowingAtAnEmptyCorner_Misses() + { + var game = new TossMinigame(Tuning(0), new System.Random(1)); + RunUntilDone(game, g => new CaptureMinigameInput { Pressed = true, Point = Vector2.zero }); + + Assert.IsTrue(game.Done); + Assert.AreEqual(0, game.ThrowsLeft); + Assert.AreEqual(0, game.Hits); + } + + [Test] + public void Toss_OnlyOneNetInTheAirAtATime() + { + var game = new TossMinigame(Tuning(0), new System.Random(1)); + for (int i = 0; i < 5; i++) + game.Tick(Step, new CaptureMinigameInput { Pressed = true, Point = Vector2.zero }); + Assert.AreEqual(TossMinigame.Throws - 1, game.ThrowsLeft); + } + + [Test] + public void Toss_NoThrows_TimesOutWithZero() + { + var game = new TossMinigame(Tuning(0), new System.Random(1)); + RunUntilDone(game, Idle); + Assert.IsTrue(game.Done); + Assert.AreEqual(0, game.Hits); + Assert.AreEqual(TossMinigame.Throws, game.ThrowsLeft); + } + + [Test] + public void Toss_PathStaysInsideTheBoard() + { + for (float t = 0f; t < 40f; t += 0.05f) + { + Vector2 p = TossMinigame.PathPoint(t); + Assert.IsTrue(p.x > 0f && p.x < CaptureMinigame.BoardWidth, $"t={t}: x {p.x}"); + Assert.IsTrue(p.y > 0f && p.y < CaptureMinigame.BoardHeight, $"t={t}: y {p.y}"); + } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/CaptureMinigameTests.cs.meta b/Assets/Tests/EditMode/CaptureMinigameTests.cs.meta new file mode 100644 index 0000000..0efb56b --- /dev/null +++ b/Assets/Tests/EditMode/CaptureMinigameTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b4cccefb52207e143ab6bc36329beaa8 \ No newline at end of file diff --git a/Assets/Tests/EditMode/CaptureNetChoiceTests.cs b/Assets/Tests/EditMode/CaptureNetChoiceTests.cs new file mode 100644 index 0000000..c2d3cf4 --- /dev/null +++ b/Assets/Tests/EditMode/CaptureNetChoiceTests.cs @@ -0,0 +1,77 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Data; +using InsectGame.UI; +using NUnit.Framework; + +namespace InsectGame.Tests +{ + /// + /// 포획 선택 창의 「바로 쓸 채집망」. 포획 버튼이 채집망 선택 창을 건너뛰게 되면서, 무엇을 쓸지를 + /// 사람이 고르지 않는 경우가 생겼다 — 틀리면 귀한 채집망이 말없이 소모된다. + /// + [TestFixture] + public class CaptureNetChoiceTests + { + private static readonly CaptureItemData[] Nets = + { + new CaptureItemData { itemId = "net_basic", displayName = "기본 채집망" }, + new CaptureItemData { itemId = "net_silver", displayName = "은빛 채집망" }, + new CaptureItemData { itemId = "net_gold", displayName = "황금 채집망" }, + }; + + private static System.Func Bag(params (string id, int count)[] owned) + { + var map = new Dictionary(); + foreach ((string id, int count) in owned) map[id] = count; + return id => map.TryGetValue(id, out int c) ? c : 0; + } + + [Test] + public void Resolve_NothingRemembered_PicksTheCommonestOwnedNet() + { + // 처음 포획하는 사람 — 황금 채집망이 있어도 기본부터 쓴다. + CaptureItemData net = CaptureNetChoice.Resolve(Nets, string.Empty, + Bag(("net_basic", 5), ("net_silver", 2), ("net_gold", 1))); + Assert.AreEqual("net_basic", net.itemId); + } + + [Test] + public void Resolve_RememberedNetStillOwned_IsReused() + { + CaptureItemData net = CaptureNetChoice.Resolve(Nets, "net_silver", + Bag(("net_basic", 5), ("net_silver", 2))); + Assert.AreEqual("net_silver", net.itemId); + } + + [Test] + public void Resolve_RememberedNetRanOut_FallsBackToTheCommonestOwned_NotTheNextRarer() + { + // 은빛이 떨어졌다고 황금으로 올라가면 안 된다 — 가진 것 중 맨 앞(기본)으로 물러난다. + CaptureItemData net = CaptureNetChoice.Resolve(Nets, "net_silver", + Bag(("net_basic", 3), ("net_silver", 0), ("net_gold", 4))); + Assert.AreEqual("net_basic", net.itemId); + } + + [Test] + public void Resolve_OnlyRareNetOwned_UsesIt() + { + CaptureItemData net = CaptureNetChoice.Resolve(Nets, "net_basic", Bag(("net_gold", 1))); + Assert.AreEqual("net_gold", net.itemId); + } + + [Test] + public void Resolve_NothingOwned_IsNull() + { + Assert.IsNull(CaptureNetChoice.Resolve(Nets, "net_basic", Bag())); + } + + [Test] + public void Resolve_NullInput_IsSafe() + { + Assert.IsNull(CaptureNetChoice.Resolve(null, "net_basic", Bag(("net_basic", 1)))); + Assert.IsNull(CaptureNetChoice.Resolve(Nets, "net_basic", null)); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/CaptureNetChoiceTests.cs.meta b/Assets/Tests/EditMode/CaptureNetChoiceTests.cs.meta new file mode 100644 index 0000000..ac2e54a --- /dev/null +++ b/Assets/Tests/EditMode/CaptureNetChoiceTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f665ed74c1fe8d94195a428d5156f45e \ No newline at end of file diff --git a/Assets/Tests/EditMode/CharacterBodyTests.cs b/Assets/Tests/EditMode/CharacterBodyTests.cs new file mode 100644 index 0000000..ff3e2da --- /dev/null +++ b/Assets/Tests/EditMode/CharacterBodyTests.cs @@ -0,0 +1,228 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Core; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 캐릭터 본체(몸통·팔·얼굴·머리) — 2026-09-30 개편에서 새로 생긴 **계약**을 고정한다. + /// + /// 모양 자체는 OutfitRenderProbe -outfitLooks로 눈으로 본다. 여기서는 모양이 틀려도 + /// 예외 없이 조용히 어긋나는 것들만 본다: 걷기 관절, 모자와의 간섭, 정점 예산, 표정의 구분. + /// 외형은 로 주입해 PlayerPrefs에 기대지 않는다. + /// + [TestFixture] + public class CharacterBodyTests + { + private readonly List built = new List(); + + [TearDown] + public void TearDown() + { + foreach (GameObject go in built) if (go != null) Object.DestroyImmediate(go); + built.Clear(); + } + + private GameObject Build(int gender, int hairStyle, int faceType = 0) + { + GameObject go = new GameObject("CharacterBodyTests"); + go.SetActive(false); // Awake 억제 — PlayerPrefs 외형으로 먼저 지어지지 않게 + go.AddComponent().BuildForPreview( + new AppearanceSpec { gender = gender, hairStyle = hairStyle, faceType = faceType }); + built.Add(go); + return go; + } + + private static IEnumerable<(int g, int h)> AllLooks() + { + for (int g = 0; g < 2; g++) + for (int h = 0; h < 4; h++) + yield return (g, h); + } + + // ── 팔 관절 ── + + /// + /// 걷기(PlayerMovement.AnimateWalk)는 팔을 **제자리에서** 돌리고 손·도구를 + /// RotateAttachment(쉬는 자리, arm.localPosition, 각)로 따라 돌린다 — 팔의 원점이 곧 관절이다. + /// 원점이 캡슐 가운데(y 0.78)였을 땐 팔 윗부분이 흔들 때마다 몸통 뒤로 빠졌다. + /// 원점은 팔 윗부분에 있어야 하고, 쉬는 자세의 팔 범위(0.566~1.026)는 예전과 같아야 한다. + /// + [Test] + public void Arm_PivotsAtShoulder_WithUnchangedRestShape() + { + GameObject go = Build(0, 0); + foreach (string name in new[] { "ArmL", "ArmR" }) + { + Transform arm = go.transform.Find(name); + Assert.IsNotNull(arm, name); + Bounds local = arm.GetComponent().sharedMesh.bounds; + float top = arm.localPosition.y + local.max.y * arm.localScale.y; + float bottom = arm.localPosition.y + local.min.y * arm.localScale.y; + + Assert.AreEqual(1.026f, top, 0.01f, name + " 윗끝이 바뀌었다"); + Assert.AreEqual(0.566f, bottom, 0.01f, name + " 아랫끝이 바뀌었다"); + Assert.Greater(arm.localPosition.y, 0.88f, name + "의 원점이 어깨가 아니다(가운데면 팔이 한가운데서 돈다)"); + } + } + + /// 손 쉬는 자리는 PlayerMovement·NpcWalkAnimator·도구 레시피가 상수(±0.29, 0.52)로 들고 있다. + [Test] + public void Hands_RestWhereTheWalkCycleExpectsThem() + { + GameObject go = Build(1, 2); + Assert.AreEqual(new Vector3(-0.29f, 0.52f, 0f), go.transform.Find("HandL").localPosition); + Assert.AreEqual(new Vector3(0.29f, 0.52f, 0f), go.transform.Find("HandR").localPosition); + } + + /// + /// 걷기에서 **몸통만** 위아래로 튄다(PlayerMovement bob). 몸 앞뒤에 붙은 것 — 셔츠 판·라펠·옷깃·가방 — 은 + /// 몸통의 자식이어야 함께 움직인다. 루트 자식이던 때는 걸을 때 셔츠 판과 가방이 몸통 앞뒤에서 미끄러졌다. + /// + [Test] + public void TorsoAttachments_RideOnTheBody() + { + GameObject go = Build(0, 0); + foreach (string name in new[] { "Shirt", "LapelL", "LapelR", "Collar", "Backpack" }) + { + Transform t = OutfitShapeLibrary.FindDeep(go.transform, name); + Assert.IsNotNull(t, name + "가 없다"); + Assert.AreEqual("Body", t.parent.name, name + "가 몸통의 자식이 아니다 — 걸을 때 몸통과 따로 논다"); + } + } + + /// 어깨 캡은 팔의 자식이어야 흔들어도 관절에 붙어 있다. 이름은 ApplyToCharacter가 칠하는 계약이다. + [Test] + public void ShoulderCaps_RideOnTheArms() + { + GameObject go = Build(0, 1); + Assert.IsNotNull(go.transform.Find("ArmL/ShoulderL")); + Assert.IsNotNull(go.transform.Find("ArmR/ShoulderR")); + } + + // ── 모자와의 간섭 ── + + /// + /// 모자 선 위로 솟는 머리는 HairCrown에만 둔다 — 모자 레시피의 hideNodes와 ApplyToCharacter가 이름으로 찾는다. + /// 스타일과 무관하게 항상 있어야 한다(없으면 hideNodes가 조용히 무시된다). + /// + [Test] + public void HairCrown_ExistsForEveryLook_AndHoldsTheUpdo() + { + foreach ((int g, int h) in AllLooks()) + { + Transform crown = OutfitShapeLibrary.FindDeep(Build(g, h).transform, OutfitShapeLibrary.HairCrownNode); + Assert.IsNotNull(crown, $"성별{g} 머리{h}: HairCrown이 없다"); + if (h == 3) Assert.Greater(crown.childCount, 0, $"성별{g} 올림머리: 솟는 부분이 HairCrown 밖에 있다 — 모자를 뚫는다"); + } + } + + /// + /// 정수리 덮개(HairTop)는 기본 캡(중심 y 0.24, 반지름 y 0.17 → 꼭대기 0.41) 안에 들어가야 한다 — + /// 넘으면 모든 모자가 머리카락에 뚫린다. 크게 만든 여자 머리도 마찬가지다. + /// + [Test] + public void HairTop_StaysUnderTheCap() + { + foreach ((int g, int h) in AllLooks()) + { + Transform top = OutfitShapeLibrary.FindDeep(Build(g, h).transform, "HairTop"); + Assert.IsNotNull(top); + float crownY = top.localPosition.y + top.localScale.y * 0.5f; + Assert.LessOrEqual(crownY, 0.41f, $"성별{g} 머리{h}: 정수리 덮개 꼭대기 {crownY:0.000}"); + } + } + + // ── 얼굴 ── + + /// 네 표정이 서로 다른 입이어야 한다 — 예전엔 폭 0.02~0.05의 상자 하나라 화면에서 구분이 안 됐다. + [Test] + public void MouthShapes_AreAllDistinct() + { + HashSet seen = new HashSet(); + for (int f = 0; f < 4; f++) + { + PlayerVisualBuilder.MouthShape(f, out PlayerVisualBuilder.MouthKind kind, out Vector2 size, out float y); + Assert.IsTrue(seen.Add($"{kind}:{size.x:0.000}x{size.y:0.000}"), $"표정 {f}의 입이 다른 표정과 같다"); + Assert.Less(y, -0.12f, $"표정 {f}: 입이 코(y −0.10) 위로 올라왔다"); + } + } + + /// + /// 얼굴 부품은 머리 표면 **앞**에 있어야 보인다. 옛 입은 z를 상수로 박아 머리가 큰 여자 얼굴에서 반쯤 파묻혔다. + /// 입(노드 "Mouth")과 눈썹을 두 성별 모두 확인한다. 머리 반지름은 Head 노드 스케일에서 읽는다(지름 1 구). + /// + [Test] + public void FaceParts_SitInFrontOfTheHeadSurface() + { + for (int g = 0; g < 2; g++) + { + for (int f = 0; f < 4; f++) + { + Transform root = Build(g, 0, f).transform; + Transform head = OutfitShapeLibrary.FindDeep(root, "Head"); + Vector3 r = head.localScale * 0.5f; + foreach (string part in new[] { "Mouth", "BrowL", "BrowR" }) + { + Transform t = OutfitShapeLibrary.FindDeep(root, part); + Assert.IsNotNull(t, part); + Vector3 p = t.localPosition; + float u = p.x / r.x, v = p.y / r.y; + float surface = r.z * Mathf.Sqrt(Mathf.Max(0f, 1f - u * u - v * v)); + Assert.Greater(p.z, surface, $"성별{g} 표정{f}: {part}가 머리 속(z {p.z:0.000} ≤ 표면 {surface:0.000})"); + } + } + } + } + + [Test] + public void BrowTone_IsDarkerThanTheHair() + { + for (int i = 0; i < CharacterPalette.HairCount; i++) + { + Color hair = CharacterPalette.Hair(i); + Color brow = PlayerVisualBuilder.BrowTone(hair); + Assert.Less(brow.grayscale, hair.grayscale + 0.05f, $"머리색 {i}: 눈썹이 머리보다 밝다"); + } + } + + // ── 비용 ── + + /// + /// 한 캐릭터의 정점 수. 에디터 로그(LogVertexBudget)는 경고만 하므로 여기서 상한을 건다. + /// 프리미티브 시절 10,400 → 1,938로 줄였다가, 머리 스타일·표정·열린 자켓·잠자리채 그물을 살리며 4,200까지 허용했다 + /// (PlayerVisualBuilder.LogVertexBudget과 같은 값). + /// + [Test] + public void EveryLook_StaysUnderTheVertexBudget() + { + foreach ((int g, int h) in AllLooks()) + { + int total = 0; + foreach (MeshFilter mf in Build(g, h, 1).GetComponentsInChildren(true)) + if (mf.sharedMesh != null) total += mf.sharedMesh.vertexCount; + Assert.LessOrEqual(total, 4200, $"성별{g} 머리{h}: 정점 {total}"); + } + } + + // ── 몸통 ── + + /// + /// 몸통 위(어깨) 배율은 1이어야 한다 — 팔이 ±0.29에 묶여 있어 어깨가 좁아지면 팔과 몸 사이가 뜬다. + /// 성별 실루엣 차이는 허리·골반에서 낸다. + /// + [Test] + public void TorsoProfile_KeepsShouldersFull_AndDiffersByGender() + { + PlayerVisualBuilder.GetTorsoProfile(0, out float mb, out float mm, out float mt); + PlayerVisualBuilder.GetTorsoProfile(1, out float fb, out float fm, out float ft); + Assert.AreEqual(1f, mt, 1e-5f); + Assert.AreEqual(1f, ft, 1e-5f); + Assert.Less(fm, fb, "여자는 허리가 골반보다 좁아야 한다"); + Assert.Less(mb, mt, "남자는 허리 아래가 어깨보다 좁아야 한다"); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/CharacterBodyTests.cs.meta b/Assets/Tests/EditMode/CharacterBodyTests.cs.meta new file mode 100644 index 0000000..9e7185e --- /dev/null +++ b/Assets/Tests/EditMode/CharacterBodyTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bf299d671b9416b4594f309b148334cd \ No newline at end of file diff --git a/Assets/Tests/EditMode/CharacterCreateFlowTests.cs b/Assets/Tests/EditMode/CharacterCreateFlowTests.cs index 2852610..9fc428f 100644 --- a/Assets/Tests/EditMode/CharacterCreateFlowTests.cs +++ b/Assets/Tests/EditMode/CharacterCreateFlowTests.cs @@ -164,6 +164,17 @@ public void Presets_ProduceDifferentAppearanceHashes() Assert.Greater(seen.Count, 1, "모든 프리셋의 외형 해시가 같다 — 어느 것을 골라도 3D 프리뷰가 안 바뀐다"); } + + /// 입력란을 비우고 진행하면 빈 이름이 저장되던 결함 — 기본 이름으로 물러난다. + [Test] + public void SanitizeCharacterName_BlankFallsBackToDefault_ElseTrimmed() + { + string fallback = LoginUI.SanitizeCharacterName(null); + Assert.IsFalse(string.IsNullOrWhiteSpace(fallback)); + Assert.AreEqual(fallback, LoginUI.SanitizeCharacterName("")); + Assert.AreEqual(fallback, LoginUI.SanitizeCharacterName(" ")); + Assert.AreEqual("나비", LoginUI.SanitizeCharacterName(" 나비 ")); + } } } #endif diff --git a/Assets/Tests/EditMode/CharacterPaletteTests.cs b/Assets/Tests/EditMode/CharacterPaletteTests.cs index a62c9ef..b995917 100644 --- a/Assets/Tests/EditMode/CharacterPaletteTests.cs +++ b/Assets/Tests/EditMode/CharacterPaletteTests.cs @@ -123,15 +123,28 @@ public void ApplySurface_NullMaterial_DoesNotThrow() // ── 의상 프리셋 ── /// - /// 2D 폴백(3D 프리뷰가 아직 없는 첫 프레임·미배선 시)의 대표색이 프리셋 수와 맞아야 한다. - /// 짧으면 뒤쪽 프리셋이 clamp돼 앞 것과 같은 색으로 그려진다 — 프리셋이 3→5로 늘었을 때 - /// 실제로 그 상태였다. + /// 2D 폴백(3D 프리뷰가 아직 없는 첫 프레임·미배선 시)의 색은 **프리셋이 실제로 입히는 아이템**에서 나온다. + /// 예전엔 대표색 배열을 따로 적어 자켓 색이 실제와 달랐고, 프리셋이 늘 때 배열이 짧으면 앞 것과 같은 색이 됐다. + /// 몸통은 겉옷을 입으면 겉옷 색, 벗으면 상의 색(ApplyToCharacter와 같은 규칙). /// [Test] - public void PortraitFallbackColors_CoverEveryPreset() + public void PortraitFallbackColors_ComeFromThePresetItems() { - Assert.AreEqual(CharacterPresetLibrary.Count, CharacterPortraitRenderer.PresetTopColors.Length, - "2D 폴백 색 배열이 프리셋 수와 다르면 뒤쪽 프리셋이 앞 것과 같은 색이 된다"); + Dictionary byId = new Dictionary(); + foreach (OutfitItem item in CharacterOutfitManager.BuildCatalog()) byId[item.itemId] = item; + + for (int i = 0; i < CharacterPresetLibrary.Count; i++) + { + CharacterPresetLibrary.Preset p = CharacterPresetLibrary.Get(i); + Color top = default, outer = new Color(0f, 0f, 0f, 0f); + foreach (string id in p.OutfitItemIds) + { + if (byId[id].slot == OutfitSlot.Top) top = byId[id].primaryColor; + if (byId[id].slot == OutfitSlot.Outerwear) outer = byId[id].primaryColor; + } + CharacterPortraitRenderer.PresetColors(i, out Color body, out _, out _, out _, out _); + Assert.AreEqual(outer.a > 0.01f ? outer : top, body, p.DisplayName + ": 2D 폴백 몸통 색이 실제 옷과 다르다"); + } } [Test] diff --git a/Assets/Tests/EditMode/CharacterPreviewTests.cs b/Assets/Tests/EditMode/CharacterPreviewTests.cs index 96c6a89..28db237 100644 --- a/Assets/Tests/EditMode/CharacterPreviewTests.cs +++ b/Assets/Tests/EditMode/CharacterPreviewTests.cs @@ -229,6 +229,84 @@ public void AppearanceSpec_Tones_FollowPaletteIndices() Assert.AreEqual(CharacterPalette.Skin(3), spec.SkinTone); Assert.AreEqual(CharacterPalette.Hair(2), spec.HairTone); } + + // ── 큰 패널 재렌더 판정 ── + + /// + /// 생성 화면의 세부 조정(성별·머리·피부·표정)은 로드아웃도 각도도 안 바꾼다. 판정이 그 둘만 보던 때는 + /// 외형을 바꿔도 드래그하기 전까지 그림이 그대로였다. + /// + [Test] + public void NeedsPanelRender_AppearanceDirtyAlone_Renders() + { + Assert.IsTrue(CharacterModelPreviewRenderer.NeedsPanelRender(42, 42, 200f, 200f, true)); + } + + [Test] + public void NeedsPanelRender_NothingChanged_Skips() + { + // 멈춰 있을 때 매 프레임 다시 찍지 않는다. + Assert.IsFalse(CharacterModelPreviewRenderer.NeedsPanelRender(42, 42, 200f, 200f, false)); + } + + [Test] + public void NeedsPanelRender_LoadoutOrAngle_Renders() + { + Assert.IsTrue(CharacterModelPreviewRenderer.NeedsPanelRender(43, 42, 200f, 200f, false)); + Assert.IsTrue(CharacterModelPreviewRenderer.NeedsPanelRender(42, 42, 201f, 200f, false)); + Assert.IsTrue(CharacterModelPreviewRenderer.NeedsPanelRender(42, 42, 200f, float.NaN, false), + "InvalidatePreview가 각도를 NaN으로 두면 다음 프레임에 반드시 찍혀야 한다"); + } + + /// + /// 생성 화면은 오버라이드를 OnGUI 패스마다 넘긴다 — 같은 값이면 dirty를 세우지 않아야 + /// 멈춰 있을 때 매 프레임 재렌더하지 않는다. + /// + [Test] + public void OverrideChanged_SameSpec_IsFalse_DifferentSpec_IsTrue() + { + AppearanceSpec a = new AppearanceSpec { gender = 1, hairStyle = 2 }; + AppearanceSpec same = a; + AppearanceSpec other = a; other.skinColor = 3; + + Assert.IsFalse(CharacterModelPreviewRenderer.OverrideChanged(a, same)); + Assert.IsTrue(CharacterModelPreviewRenderer.OverrideChanged(a, other)); + Assert.IsTrue(CharacterModelPreviewRenderer.OverrideChanged(null, a), "PlayerPrefs → 편집 중 외형"); + Assert.IsTrue(CharacterModelPreviewRenderer.OverrideChanged(a, null), "편집 중 외형 → PlayerPrefs"); + Assert.IsFalse(CharacterModelPreviewRenderer.OverrideChanged(null, null)); + } + + // ── 카드 썸네일 조합 ── + + /// + /// 상의 카드는 겉옷을 벗긴 채 구워야 한다. 겉옷이 비면 몸통·팔이 기본 자켓 색이라 + /// 상의 14장이 전부 "파란 자켓"으로 구워졌다. + /// + [Test] + public void ThumbLoadout_Top_RemovesOuterwear() + { + OutfitLoadout lo = new OutfitLoadout(); + CharacterModelPreviewRenderer.BuildThumbLoadout(lo, OutfitSlot.Top, "top_galaxy"); + + Assert.AreEqual("top_galaxy", lo.Get(OutfitSlot.Top)); + Assert.AreEqual(CharacterModelPreviewRenderer.OuterNoneId, lo.Get(OutfitSlot.Outerwear)); + Assert.IsNotNull(Array.Find(CharacterOutfitManager.BuildCatalog(), + i => i.itemId == CharacterModelPreviewRenderer.OuterNoneId), "outer_none이 카탈로그에서 빠졌다"); + } + + [Test] + public void ThumbLoadout_OtherSlots_AreSolo() + { + OutfitLoadout lo = new OutfitLoadout(); + lo.Set(OutfitSlot.Hat, "leftover"); + CharacterModelPreviewRenderer.BuildThumbLoadout(lo, OutfitSlot.Shoes, "shoe_rocket"); + + for (int i = 0; i < OutfitLoadout.SlotCount; i++) + { + OutfitSlot slot = (OutfitSlot)i; + Assert.AreEqual(slot == OutfitSlot.Shoes ? "shoe_rocket" : null, lo.Get(slot), slot.ToString()); + } + } } } #endif diff --git a/Assets/Tests/EditMode/CombatMusicTests.cs b/Assets/Tests/EditMode/CombatMusicTests.cs new file mode 100644 index 0000000..d4da10b --- /dev/null +++ b/Assets/Tests/EditMode/CombatMusicTests.cs @@ -0,0 +1,191 @@ +#if UNITY_EDITOR +using System; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using InsectGame.Core; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 전투 계열 곡(ProceduralAudioGenerator.Music.cs) — 등록 4지점, 길이·정수 마디, 클리핑, 폰 스피커 대역 에너지, + /// 루프 이음매, 작업 스레드 합성. + /// + /// 소리가 좋은지는 귀로 검수한다(.claude/cache/stage4-audio/의 WAV). 여기서는 조용히 깨지는 것만 잡는다 — + /// 전투 계열인데 긴장 램프가 빠지거나(2026-08-08에 보스 2종이 그렇게 샜다), 곡이 다시 짧아지거나, 루프가 마디 중간에서 + /// 끊기거나(예전 1대1 곡은 12초 = 150bpm 7.5마디였다), 에너지가 다시 저음으로 몰려 폰에서 안 들리게 되는 것. + /// + [TestFixture] + public class CombatMusicTests + { + private static readonly BgmType[] CombatTypes = + { + BgmType.Battle, BgmType.RaidBattle, BgmType.BossLedger, BgmType.BossFinal, BgmType.Guardian, BgmType.Rival + }; + + private static readonly BgmType[] NonCombatTypes = + { + BgmType.Explore, BgmType.Victory, BgmType.Defeat, BgmType.Menu, BgmType.ExploreMeadow + }; + + private static string KeyOf(BgmType type) + { + MethodInfo m = typeof(AudioManager).GetMethod("BgmTypeToString", BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(m, "AudioManager.BgmTypeToString을 못 찾음 — 이 테스트가 무의미해졌다"); + return (string)m.Invoke(null, new object[] { type }); + } + + private static bool IsCombat(BgmType type) + { + MethodInfo m = typeof(AudioManager).GetMethod("IsCombatBgm", BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(m, "AudioManager.IsCombatBgm을 못 찾음 — 이 테스트가 무의미해졌다"); + return (bool)m.Invoke(null, new object[] { type }); + } + + [Test] + public void NewThemes_HaveTheirOwnKeys() + { + // default로 떨어지면 "explore"가 나와 수문장전·라온 대결에서 탐험 곡이 흐른다(예외 없이). + Assert.AreEqual("guardian", KeyOf(BgmType.Guardian)); + Assert.AreEqual("rival", KeyOf(BgmType.Rival)); + } + + [Test] + public void IsCombatBgm_CombatThemes_GetIntensityRamp() + { + foreach (BgmType t in CombatTypes) + Assert.IsTrue(IsCombat(t), $"{t}이 IsCombatBgm에 없다 — 체력이 낮을 때 긴장 램프(피치)가 안 걸린다"); + foreach (BgmType t in NonCombatTypes) + Assert.IsFalse(IsCombat(t), $"{t}은 전투 곡이 아닌데 긴장 램프가 걸린다"); + } + + [Test] + public void EveryCombatBgm_IsRenderedByTheCombatComposer() + { + foreach (BgmType t in CombatTypes) + Assert.IsTrue(ProceduralAudioGenerator.IsCombatSong(KeyOf(t)), + $"{t}({KeyOf(t)})이 전투 곡 작곡기에 없다 — 예전 16초 생성기로 떨어지거나 무음이 된다"); + } + + [Test] + public void CombatSongKeys_EachHasACaseInGetBgm() + { + // 키 목록이 둘이다(GetBGM의 case와 Music.cs의 CombatSongKeys). 한쪽만 늘리면 무음이거나 영영 안 불린다. + string path = Path.Combine(Application.dataPath, "Scripts/Core/ProceduralAudioGenerator.cs"); + Assert.IsTrue(File.Exists(path), "소스를 못 찾음: " + path); + string src = File.ReadAllText(path); + src = Regex.Replace(src, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline); + src = Regex.Replace(src, @"//[^\n]*", string.Empty); + foreach (string key in ProceduralAudioGenerator.CombatSongKeys) + { + StringAssert.Contains($"case \"{key}\":", src, $"GetBGM에 \"{key}\" case가 없다"); + Assert.Greater(ProceduralAudioGenerator.GetCombatSongShape(key).Bars, 0, $"\"{key}\"를 작곡하지 못한다"); + } + } + + [TestCase("battle")] + [TestCase("rival")] + [TestCase("guardian")] + [TestCase("raid")] + [TestCase("boss_ledger")] + [TestCase("boss_final")] + public void CombatSong_Shape_IsWholeBarsBetween45And64Seconds(string key) + { + ProceduralAudioGenerator.SongShape shape = ProceduralAudioGenerator.GetCombatSongShape(key); + Assert.AreEqual((int)Math.Round(shape.Bars * shape.SamplesPerBar), shape.Samples, + "곡 길이가 정수 마디가 아니다 — 루프가 마디 중간에서 끊긴다"); + Assert.GreaterOrEqual(shape.Seconds, 45.0, "전투곡이 다시 짧아졌다(예전 12~16초 반복은 금방 질렸다)"); + Assert.LessOrEqual(shape.Seconds, 64.5, "곡이 길수록 클립 메모리가 커진다(22.05kHz 모노 64초 ≈ 5.6MB)"); + } + + [TestCase("battle")] + [TestCase("rival")] + [TestCase("guardian")] + [TestCase("raid")] + [TestCase("boss_ledger")] + [TestCase("boss_final")] + public void GetBGM_CombatKey_ReturnsAudibleMonoClipWithoutClipping(string key) + { + AudioClip clip = ProceduralAudioGenerator.GetBGM(key); + Assert.IsNotNull(clip, $"GetBGM(\"{key}\")이 null — 그 전투는 무음이다"); + Assert.AreEqual(1, clip.channels); + Assert.AreEqual(ProceduralAudioGenerator.MusicRate, clip.frequency); + Assert.GreaterOrEqual(clip.length, 45f); + + float[] data = new float[clip.samples]; + Assert.IsTrue(clip.GetData(data, 0), "클립 데이터를 읽지 못했다"); + double sum = 0; + int pinned = 0; + foreach (float x in data) + { + sum += x * x; + if (Math.Abs(x) >= 0.999f) pinned++; + } + double rms = Math.Sqrt(sum / data.Length); + Assert.Greater(rms, 0.02, "클립이 거의 비어 있다"); + Assert.AreEqual(0, pinned, "±1에 붙은 샘플이 있다 — 잘려서(클리핑) 찢어지는 소리가 난다"); + } + + [TestCase("battle")] + [TestCase("rival")] + [TestCase("guardian")] + [TestCase("raid")] + [TestCase("boss_ledger")] + [TestCase("boss_final")] + public void RenderCombatSong_FullSong_NoClipPhoneBandAndSeamlessLoop(string key) + { + var sw = Stopwatch.StartNew(); + float[] d = ProceduralAudioGenerator.RenderCombatSong(key); + sw.Stop(); + UnityEngine.Debug.Log($"[Music] {key}: {d.Length / (double)ProceduralAudioGenerator.MusicRate:F1}s, 합성 {sw.ElapsedMilliseconds}ms"); + + double total = 0, band = 0, maxJump = 0; + float peak = 0f; + int overKnee = 0; + // 폰 스피커 대역 — 300Hz 1차 고역통과 두 번(엔진의 음량 측정과 같은 어림) + double hp = Math.Exp(-2.0 * Math.PI * 300.0 / ProceduralAudioGenerator.MusicRate); + double h1 = 0, h1x = 0, h2 = 0, h2x = 0; + for (int i = 0; i < d.Length; i++) + { + float x = d[i]; + Assert.IsFalse(float.IsNaN(x) || float.IsInfinity(x), $"{i}번째 샘플이 NaN/무한대"); + total += x * x; + peak = Math.Max(peak, Math.Abs(x)); + if (Math.Abs(x) > ProceduralAudioGenerator.MusicLimiterKnee) overKnee++; + h1 = hp * (h1 + x - h1x); h1x = x; + h2 = hp * (h2 + h1 - h2x); h2x = h1; + band += h2 * h2; + if (i > 0) maxJump = Math.Max(maxJump, Math.Abs(d[i] - d[i - 1])); + } + + Assert.Less(peak, 1f, "합성 결과가 1을 넘었다 — MasterGain 전 단계에서 이미 잘린다"); + Assert.Less(overKnee / (double)d.Length, 0.005, + "리미터 무릎을 넘는 샘플이 0.5%를 넘는다 — 봉우리가 눌려 북이 뭉개진다(믹스 음량을 낮출 것)"); + // 실측(2026-10-04): 새 곡 29~37%, 예전 보스 곡(리전 곡 생성기) 약 8%. + Assert.GreaterOrEqual(band / total, 0.2, + "에너지가 300Hz 아래로 몰렸다 — 폰 스피커에서는 거의 안 들린다(배음으로 무게를 낼 것)"); + + // 끝 → 처음이 곡 안의 어떤 두 샘플 사이보다 크게 튀면 루프마다 딸깍 소리가 난다. + double seam = Math.Abs(d[0] - d[d.Length - 1]); + Assert.LessOrEqual(seam, maxJump, "루프 이음매가 곡 안의 어떤 순간보다도 크게 튄다"); + } + + [Test] + public void RenderCombatSong_OnWorkerThread_MatchesMainThread() + { + // 1대1 전투곡은 작업 스레드에서 미리 굽는다(StartCombatPrewarm). UnityEngine을 부르거나 공유 상태를 건드리면 + // 여기서 예외가 나거나 결과가 갈린다. + Task worker = Task.Run(() => ProceduralAudioGenerator.RenderCombatSong("rival")); + float[] main = ProceduralAudioGenerator.RenderCombatSong("rival"); + float[] off = worker.Result; + Assert.AreEqual(main.Length, off.Length); + for (int i = 0; i < main.Length; i++) + if (main[i] != off[i]) Assert.Fail($"{i}번째 샘플이 다르다({main[i]} vs {off[i]}) — 합성이 결정적이지 않다"); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/CombatMusicTests.cs.meta b/Assets/Tests/EditMode/CombatMusicTests.cs.meta new file mode 100644 index 0000000..1517569 --- /dev/null +++ b/Assets/Tests/EditMode/CombatMusicTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 59ccc828a36e3f243b0a38dd997b43e7 \ No newline at end of file diff --git a/Assets/Tests/EditMode/CutsceneTimelineTests.cs b/Assets/Tests/EditMode/CutsceneTimelineTests.cs index 178b58c..fe58258 100644 --- a/Assets/Tests/EditMode/CutsceneTimelineTests.cs +++ b/Assets/Tests/EditMode/CutsceneTimelineTests.cs @@ -176,7 +176,9 @@ public void Library_IdCollection_IsNotEmpty() { // **리플렉션이 빈 배열을 내면 아래 검사들이 전부 "통과"한다** — 0건 통과는 통과가 // 아니라 검사기 고장이다(rules/testing.md의 "0건 보고는 실패다"와 같은 이야기). - Assert.GreaterOrEqual(AllCutscenes.Length, 5, + // 2026-10-05에 이야기 장면 넷(이름 벽·울타리·그림자·결말)을 대사 앞 영상으로 옮겨 컷신은 둘이 남았다 + // (정화 cs_bl_cleanse · 폴백 cs_story_prologue). 하한은 "하나도 못 읽었다"를 잡는 값이다. + Assert.GreaterOrEqual(AllCutscenes.Length, 2, "CutsceneLibrary의 const 문자열을 못 읽었다 — 추출이 낡았다"); CollectionAssert.AllItemsAreNotNull(AllCutscenes); CollectionAssert.AllItemsAreUnique(AllCutscenes); @@ -188,7 +190,7 @@ public void Library_IdCollection_IsNotEmpty() public void Library_Cutscenes_ChainWithoutJumpCut(string cutsceneId) { // 컷 N+1의 camFrom이 컷 N의 camTo와 다르면 그 경계에서 카메라가 **순간이동한다.** - // 지금 5종은 전부 이어져 있지만 아무도 그걸 지키게 하고 있지 않았다 — 좌표 한 줄만 + // 저작된 컷신은 전부 이어져 있지만 아무도 그걸 지키게 하고 있지 않았다 — 좌표 한 줄만 // 고쳐도 조용히 점프 컷이 된다(컴파일도 되고 예외도 없다). Assert.IsTrue(CutsceneLibrary.TryGet(cutsceneId, out CutsceneShot[] shots)); diff --git a/Assets/Tests/EditMode/DreamIntroVideoTests.cs b/Assets/Tests/EditMode/DreamIntroVideoTests.cs new file mode 100644 index 0000000..b754a60 --- /dev/null +++ b/Assets/Tests/EditMode/DreamIntroVideoTests.cs @@ -0,0 +1,192 @@ +#if UNITY_EDITOR +using System; +using System.IO; +using System.Text.RegularExpressions; +using InsectGame.Core; +using InsectGame.Story; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 「챔피언의 꿈」 도입 영상(경기장 입장 8초) — 디코더 없이 파일 헤더로 고정한다. + /// + /// 실제 재생은 배치모드·PlayMode 러너로 못 본다(rules/testing.md). 그래서 재생이 아니라 재생이 성립할 조건을 본다: + /// 파일이 플레이어가 찾는 자리에 있는가, 길이가 게임의 시간표와 같은가, 기기 디코더가 받는 부호화인가. + /// 어긋나면 전부 조용히 실패한다 — 영상이 안 뜨고 글자 카드로 넘어갈 뿐이라 화면으로는 티가 안 난다. + /// + [TestFixture] + public class DreamIntroVideoTests + { + private static string VideoPath => Path.GetFullPath(Path.Combine( + Application.dataPath, "StreamingAssets", "Video", DreamIntroVideo.FileName)); + + private static string AudioPath => Path.GetFullPath(Path.Combine( + Application.dataPath, "Resources", DreamIntroVideo.AudioResourcePath + ".wav")); + + private static string ScriptPath(string name) => Path.GetFullPath(Path.Combine( + Application.dataPath, "..", "Tools", "Video", name)); + + // ── mp4 상자 읽기(필요한 것만) ── + + private static int IndexOf(byte[] data, string tag, int from = 0) + { + byte[] needle = System.Text.Encoding.ASCII.GetBytes(tag); + for (int i = from; i <= data.Length - needle.Length; i++) + { + bool hit = true; + for (int k = 0; k < needle.Length && hit; k++) hit = data[i + k] == needle[k]; + if (hit) return i; + } + return -1; + } + + private static uint U32(byte[] d, int at) => (uint)((d[at] << 24) | (d[at + 1] << 16) | (d[at + 2] << 8) | d[at + 3]); + private static int U16(byte[] d, int at) => (d[at] << 8) | d[at + 1]; + + // faststart라 moov가 맨 앞이다 — 첫 일치가 진짜 상자다. + private static byte[] ReadVideo() + { + Assert.IsTrue(File.Exists(VideoPath), "도입 영상이 없다: " + VideoPath + " — Tools/Video/dream_arena.py"); + return File.ReadAllBytes(VideoPath); + } + + [Test] + public void Video_IsPlacedWhereThePlayerLooks_CaseExact() + { + // Android의 jar 경로는 대소문자를 구분한다 — Windows 파일시스템은 안 하므로 이름을 직접 비교한다. + string[] names = Directory.GetFiles(Path.GetDirectoryName(VideoPath)); + bool exact = false; + foreach (string n in names) if (Path.GetFileName(n) == DreamIntroVideo.FileName) exact = true; + Assert.IsTrue(exact, DreamIntroVideo.FileName + "이 StreamingAssets/Video에 정확한 이름으로 있어야 한다"); + } + + [Test] + public void Video_Duration_MatchesTheGameTimeline() + { + byte[] d = ReadVideo(); + int mvhd = IndexOf(d, "mvhd"); + Assert.GreaterOrEqual(mvhd, 0, "mvhd 상자를 못 찾았다(faststart가 아니거나 손상)"); + // 태그 뒤: version(1) flags(3) creation(4) modification(4) timescale(4) duration(4) — version 0 + Assert.AreEqual(0, d[mvhd + 4], "mvhd version 0만 읽는다"); + uint timescale = U32(d, mvhd + 16); + uint duration = U32(d, mvhd + 20); + float seconds = (float)duration / timescale; + Assert.AreEqual(DreamPrologueData.IntroSeconds, seconds, 0.12f, + "영상 길이가 DreamPrologueData.IntroSeconds와 다르다 — Tools/Video/dream_arena.py의 T_TOTAL과 함께 고칠 것"); + } + + [Test] + public void Video_Encoding_FitsTheDeviceDecoder() + { + byte[] d = ReadVideo(); + Assert.LessOrEqual(d.Length, 4 * 1024 * 1024, "편당 4MB 이하(Docs/StoryVideos.md §1)"); + + int avcC = IndexOf(d, "avcC"); + Assert.GreaterOrEqual(avcC, 0, "H.264(avcC)가 아니다"); + Assert.AreEqual(77, d[avcC + 5], "H.264 Main 프로파일(77)이어야 한다 — High는 minSdk 25 하드웨어 디코더가 못 받는 기기가 있다"); + + // "avc1"은 ftyp의 호환 브랜드 목록에도 나온다 — 표본 설명(stsd) 뒤의 것이 진짜 상자다. + int stsd = IndexOf(d, "stsd"); + Assert.GreaterOrEqual(stsd, 0); + int avc1 = IndexOf(d, "avc1", stsd); + Assert.GreaterOrEqual(avc1, 0); + Assert.AreEqual(1280, U16(d, avc1 + 28), "가로 1280"); + Assert.AreEqual(720, U16(d, avc1 + 30), "세로 720 — 세로 화면은 이 마스터를 가운데 cover-crop한다"); + } + + [Test] + public void Audio_MatchesTheVideoLength_AndIsStereo44k() + { + Assert.IsTrue(File.Exists(AudioPath), "도입 음원이 없다: " + AudioPath + " — Tools/Video/dream_arena_audio.py"); + using (var reader = new BinaryReader(File.OpenRead(AudioPath))) + { + Assert.AreEqual("RIFF", new string(reader.ReadChars(4))); + reader.ReadInt32(); + Assert.AreEqual("WAVE", new string(reader.ReadChars(4))); + + int channels = 0, rate = 0, bits = 0; + long dataBytes = 0; + while (reader.BaseStream.Position + 8 <= reader.BaseStream.Length) + { + string id = new string(reader.ReadChars(4)); + int size = reader.ReadInt32(); + long next = reader.BaseStream.Position + size + (size & 1); + if (id == "fmt ") + { + reader.ReadInt16(); // 형식(PCM) + channels = reader.ReadInt16(); + rate = reader.ReadInt32(); + reader.ReadInt32(); + reader.ReadInt16(); + bits = reader.ReadInt16(); + } + else if (id == "data") dataBytes = size; + reader.BaseStream.Position = next; + } + + Assert.AreEqual(2, channels); + Assert.AreEqual(44100, rate); + Assert.AreEqual(16, bits); + float seconds = (float)dataBytes / (channels * (bits / 8) * rate); + Assert.AreEqual(DreamPrologueData.IntroSeconds, seconds, 0.05f, "음원 길이가 영상과 같아야 한다"); + } + } + + [Test] + public void Audio_LoadsFromResources() + { + // 경로 오타·임포트 누락은 "소리만 안 나는" 조용한 실패다 — 재생기는 클립이 없으면 무음으로 간다. + AudioClip clip = Resources.Load(DreamIntroVideo.AudioResourcePath); + Assert.IsNotNull(clip, "Resources/" + DreamIntroVideo.AudioResourcePath); + Assert.AreEqual(DreamPrologueData.IntroSeconds, clip.length, 0.05f); + } + + [TestCase("dream_arena.py")] + [TestCase("dream_arena_audio.py")] + public void Scripts_ExistAndShareTheTimeline(string script) + { + Assert.IsTrue(File.Exists(ScriptPath(script)), script); + } + + [Test] + public void VideoScript_TotalSeconds_MatchesTheGameConstant() + { + // 시각표의 단일 출처는 스크립트다 — 게임 상수가 따로 놀면 카드 시점이 어긋난다. + string source = File.ReadAllText(ScriptPath("dream_arena.py")); + Match m = Regex.Match(source, @"^T_TOTAL\s*=\s*([0-9.]+)", RegexOptions.Multiline); + Assert.IsTrue(m.Success, "T_TOTAL을 못 찾았다"); + Assert.AreEqual(DreamPrologueData.IntroSeconds, float.Parse(m.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture), 0.001f); + } + + // ── 재생기 ── + + [Test] + public void Player_WithoutPlay_IsInertAndSafeToStopAndDispose() + { + var host = new GameObject("DreamIntroTestHost"); + try + { + var video = new DreamIntroVideo(host); + Assert.AreEqual(DreamIntroVideo.State.Idle, video.Current); + Assert.IsFalse(video.IsActive); + + video.Tick(0.5f); + video.Pause(true); + video.Stop(); + video.Stop(); // 두 번 불려도 안전하다 + Assert.AreEqual(DreamIntroVideo.State.Idle, video.Current); + + video.Dispose(); + video.Dispose(); + Assert.AreEqual(DreamIntroVideo.State.Idle, video.Current); + } + finally + { + UnityEngine.Object.DestroyImmediate(host); + } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/DreamIntroVideoTests.cs.meta b/Assets/Tests/EditMode/DreamIntroVideoTests.cs.meta new file mode 100644 index 0000000..aae7d0b --- /dev/null +++ b/Assets/Tests/EditMode/DreamIntroVideoTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b6158b2b9dc28bd4ba90e4ee66c1ed71 \ No newline at end of file diff --git a/Assets/Tests/EditMode/DreamPrologueIntegrationTests.cs b/Assets/Tests/EditMode/DreamPrologueIntegrationTests.cs new file mode 100644 index 0000000..b423712 --- /dev/null +++ b/Assets/Tests/EditMode/DreamPrologueIntegrationTests.cs @@ -0,0 +1,222 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using InsectGame.Battle; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Story; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace InsectGame.Tests +{ + /// + /// 「챔피언의 꿈」을 실제 PlayScene에서 끝까지 돌린다 — 부트스트랩 배선, 진짜 전투 화면, 진짜 섬(방문 모드), + /// 깨어남까지. 단위 테스트는 규칙을 고정하지만 "지휘자가 실제로 이어 붙는가"와 "꿈 밖이 정말 그대로인가"는 + /// 진짜 씬에서만 보인다 — 배선 하나(예: 부트스트랩의 AutoWire)만 빠져도 꿈이 조용히 안 뜬다. + /// + /// 약속은 하나다: 꿈이 끝났을 때 이 계정의 어떤 기록도 시작 전과 같다. + /// + [TestFixture] + public class DreamPrologueIntegrationTests + { + private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + + [Test] + [Timeout(300000)] + public async Task FullDream_RunsThroughTheRealScene_AndLeavesTheGameUntouched() + { + int previousSceneHandle = SceneManager.GetActiveScene().handle; + SceneManager.LoadScene(GameConstants.Scenes.Play, LoadSceneMode.Single); + await WaitForBootAsync(previousSceneHandle); + for (int i = 0; i < 10; i++) await Task.Yield(); + + var director = Object.FindFirstObjectByType(); + var battle = Object.FindFirstObjectByType(); + var battleScreen = Object.FindFirstObjectByType(); + var island = Object.FindFirstObjectByType(); + var player = Object.FindFirstObjectByType(); + var quests = Object.FindFirstObjectByType(); + var candy = Object.FindFirstObjectByType(); + var progress = Object.FindFirstObjectByType(); + var wallet = Object.FindFirstObjectByType(); + var collection = Object.FindFirstObjectByType(); + var story = Object.FindFirstObjectByType(); + Assert.IsNotNull(director, "부트스트랩이 DreamPrologueDirector를 만들지 않았다 — 꿈이 영영 안 뜬다"); + Assert.IsNotNull(battle); + Assert.IsNotNull(battleScreen); + Assert.IsNotNull(island); + Assert.IsNotNull(player); + + // ── 시작 전 상태 ── + string playedKey = SaveScope.PrefsKey(DreamPrologueRules.PlayedPrefsKey); + int playedBefore = PlayerPrefs.GetInt(playedKey, 0); + int candyBefore = candy != null ? candy.Candies : 0; + int levelBefore = progress != null ? progress.Level : 0; + int xpBefore = progress != null ? progress.CurrentXp : 0; + int coinsBefore = wallet != null ? wallet.Coins : 0; + int ownedBefore = collection != null ? collection.GetAllOwned().Count : 0; + string questProgressBefore = QuestProgressSnapshot(quests); + Vector3 positionBefore = player.transform.position; + Assert.IsFalse(DreamPrologueState.Active); + + try + { + // 이 PC의 로그인 화면·월드 선택이 모달/프리즈를 쥐고 있을 수 있다 — 꿈을 시작할 수 있는 상태로 만든다. + ClearBlockers(player); + Assert.IsTrue(director.CanReplay, "다시 보기가 막혀 있다(모달·프리즈·서브에리어)"); + Assert.IsTrue(director.TryReplay()); + Assert.IsTrue(DreamPrologueState.Active, "꿈이 시작됐는데 표지가 안 켜졌다 — 퀘스트가 꿈속 행동을 센다"); + Assert.IsTrue(director.IsRunning); + + // ── 챔피언전 ── + bool sawBattle = false; + int actions = 0; + float limit = Time.realtimeSinceStartup + 90f; + while (Phase(director) != "Island" && Time.realtimeSinceStartup < limit) + { + await Task.Yield(); + if (Phase(director) == "Battle") sawBattle = true; + if (BattlePhase(battleScreen) != "PlayerTurn") continue; + for (int i = 0; i < 4; i++) + { + if (!battle.CanUseSkill(i)) continue; + typeof(BattleScreenUI).GetMethod("TryUseSkill", Private).Invoke(battleScreen, new object[] { i }); + actions++; + break; + } + } + Assert.IsTrue(sawBattle, "챔피언전 단계가 없었다"); + Assert.IsTrue(battle.IsSandbox, "방금 끝난 전투가 샌드박스가 아니다"); + Assert.AreEqual("Island", Phase(director), "챔피언전 뒤 섬으로 넘어가지 못했다"); + Assert.GreaterOrEqual(actions, SandboxBattleRules.MinPlayerActions); + Assert.LessOrEqual(actions, SandboxBattleRules.MaxPlayerActions + 2); + + // ── 챔피언의 섬 ── + Assert.IsTrue(island.IsOnIsland, "섬에 들어가지 못했다"); + Assert.IsTrue(island.DreamMode); + Assert.IsTrue(island.IsVisiting, "꾸며진 섬은 방문 모드의 스냅샷으로 열린다"); + + // 걷기 단계를 건너뛰고 곤충 → 분수 순서로 걸어간다(순간이동은 걸음으로 세지 않으므로 몇 걸음에 나눈다). + await WalkTo(director, island, player, target: null); + Assert.AreEqual("Fountain", Step(director), "곤충에게 다가가는 단계가 끝나지 않았다"); + await WalkTo(director, island, player, target: DreamPrologueData.FountainWorldPosition()); + + // ── 깨어남 ── + limit = Time.realtimeSinceStartup + 40f; + while (director.IsRunning && Time.realtimeSinceStartup < limit) await Task.Yield(); + Assert.IsFalse(director.IsRunning, "꿈이 끝나지 않았다"); + } + finally + { + // 이 PC의 기록을 돌려 둔다 — 다시 보기도 "봤다"를 적는다. + if (playedBefore == 0) PlayerPrefs.DeleteKey(playedKey); + else PlayerPrefs.SetInt(playedKey, playedBefore); + } + + // ── 끝난 뒤 ── + Assert.IsFalse(DreamPrologueState.Active, "꿈이 끝났는데 표지가 켜진 채다 — 이후 퀘스트가 영영 안 올라간다"); + Assert.IsFalse(island.IsOnIsland, "섬에 갇혔다"); + Assert.IsFalse(island.DreamMode); + Assert.IsFalse(player.IsFrozen, "플레이어가 얼어 있다"); + Assert.Less(Vector3.Distance(Planar(player.transform.position), Planar(positionBefore)), 3f, "원래 자리로 돌아오지 못했다"); + + if (candy != null) Assert.AreEqual(candyBefore, candy.Candies, "캔디가 바뀌었다"); + if (progress != null) + { + Assert.AreEqual(levelBefore, progress.Level, "레벨이 바뀌었다"); + Assert.AreEqual(xpBefore, progress.CurrentXp, "경험치가 바뀌었다"); + } + if (wallet != null) Assert.AreEqual(coinsBefore, wallet.Coins, "코인이 바뀌었다"); + if (collection != null) Assert.AreEqual(ownedBefore, collection.GetAllOwned().Count, "곤충이 컬렉션에 들어갔다"); + Assert.AreEqual(questProgressBefore, QuestProgressSnapshot(quests), "꿈속의 행동이 퀘스트에 들어갔다"); + if (story != null) Assert.IsFalse(story.HasSeen("ch1_first_battle"), "꿈속의 승리가 1막의 '첫 전투' 비트를 열었다"); + } + + // ── 도우미 ── + + private static string Phase(DreamPrologueDirector d) + => typeof(DreamPrologueDirector).GetField("phase", Private).GetValue(d).ToString(); + + private static string Step(DreamPrologueDirector d) + => typeof(DreamPrologueDirector).GetField("step", Private).GetValue(d).ToString(); + + private static string BattlePhase(BattleScreenUI ui) + => typeof(BattleScreenUI).GetField("phase", Private).GetValue(ui).ToString(); + + private static Vector3 Planar(Vector3 v) => new Vector3(v.x, 0f, v.z); + + private static string QuestProgressSnapshot(TutorialQuestManager quests) + { + if (quests == null) return string.Empty; + var progress = (Dictionary)typeof(TutorialQuestManager).GetField("questProgress", Private).GetValue(quests); + var side = (Dictionary)typeof(TutorialQuestManager).GetField("sideProgress", Private).GetValue(quests); + var keys = new List(); + foreach (var kv in progress) keys.Add($"{kv.Key}={kv.Value}"); + foreach (var kv in side) keys.Add($"s:{kv.Key}={kv.Value}"); + keys.Sort(); + return string.Join(",", keys) + "|" + string.Join(",", new List(quests.CompletedQuestIds)); + } + + private static void ClearBlockers(PlayerMovement player) + { + // 로그인·월드 선택 화면이 남긴 모달 — 테스트 PC에는 로그인 세션이 없다. + var stack = (System.Collections.IList)typeof(ModalUIRegistry) + .GetField("stack", BindingFlags.Static | BindingFlags.NonPublic).GetValue(null); + stack.Clear(); + player.SetFrozen(false); + } + + // 목표(null이면 가장 가까운 곤충)까지 몇 걸음에 나눠 걸어간다 — 한 프레임 5m 이상은 순간이동으로 쳐서 세지 않는다. + private static async Task WalkTo(DreamPrologueDirector director, IslandWorldBuilder island, PlayerMovement player, Vector3? target) + { + string phaseWanted = target.HasValue ? "Linger" : "Fountain"; + float limit = Time.realtimeSinceStartup + 40f; + // 먼저 걷기 단계(8m)를 채운다 — 입구에서 같은 자리를 오가며 걷는다. + while (Step(director) == "Move" && Time.realtimeSinceStartup < limit) + { + float swing = Mathf.Sin(Time.realtimeSinceStartup * 6f) * 1.5f; + Vector3 p = player.transform.position; + player.transform.position = new Vector3(p.x, p.y, p.z + (swing > 0 ? 0.6f : -0.6f)); + await Task.Yield(); + } + + while (Step(director) != phaseWanted && Time.realtimeSinceStartup < limit) + { + Vector3 goal; + if (target.HasValue) goal = target.Value; + else if (!island.TryGetNearestInsect(player.transform.position, out goal, out _, out _)) { await Task.Yield(); continue; } + + Vector3 from = player.transform.position; + Vector3 toward = goal - from; + toward.y = 0f; + // 도착 반경 안쪽까지 들어간다(곤충은 움직이므로 매 프레임 다시 읽는다). + Vector3 next = toward.magnitude <= 1.2f ? from : from + toward.normalized * Mathf.Min(1.5f, toward.magnitude - 1.0f); + player.transform.position = new Vector3(next.x, from.y, next.z); + await Task.Yield(); + } + Assert.AreEqual(phaseWanted, Step(director), "목표 지점에 닿지 못했다"); + } + + private static async Task WaitForBootAsync(int previousSceneHandle) + { + const int maxFrames = 900; + for (int frame = 0; frame < maxFrames; frame++) + { + Scene scene = SceneManager.GetSceneByName(GameConstants.Scenes.Play); + PlaySceneBootstrap bootstrap = Object.FindFirstObjectByType(); + if (scene.IsValid() && scene.isLoaded && scene.handle != previousSceneHandle + && bootstrap != null && bootstrap.gameObject.scene == scene + && Object.FindFirstObjectByType() != null + && Object.FindFirstObjectByType() != null) + return; + await Task.Yield(); + } + Assert.Fail("PlayScene Bootstrap 초기화가 제한 시간 안에 끝나지 않았습니다(또는 DreamPrologueDirector가 안 만들어졌습니다)."); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/DreamPrologueIntegrationTests.cs.meta b/Assets/Tests/EditMode/DreamPrologueIntegrationTests.cs.meta new file mode 100644 index 0000000..29d7f33 --- /dev/null +++ b/Assets/Tests/EditMode/DreamPrologueIntegrationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 15bec634ca0a9234e90a1e5d12908b03 \ No newline at end of file diff --git a/Assets/Tests/EditMode/DreamPrologueTests.cs b/Assets/Tests/EditMode/DreamPrologueTests.cs new file mode 100644 index 0000000..551648d --- /dev/null +++ b/Assets/Tests/EditMode/DreamPrologueTests.cs @@ -0,0 +1,537 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using InsectGame.Battle; +using InsectGame.Core; +using InsectGame.Data; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 「챔피언의 꿈」 — 규칙(샌드박스 피해·시작 조건)과 꿈 밖에 아무것도 남기지 않는다는 약속. + /// + /// 이 연출의 실패는 전부 조용하다: 챔피언전 승리가 퀘스트·스토리·재화를 슬쩍 올려도, 섬 배치에서 물건 하나가 + /// 빠져도, 곤충 ID 하나가 오타여도 예외도 경고도 없다. 그래서 화면이 아니라 상태를 고정한다. + /// + [TestFixture] + public class DreamPrologueTests + { + private readonly List objects = new List(); + + [TearDown] + public void Cleanup() + { + DreamPrologueState.End(); + foreach (Object o in objects) if (o != null) Object.DestroyImmediate(o); + objects.Clear(); + } + + // ── 샌드박스 피해 규칙 ── + + [Test] + public void PlayerDamage_IsScaledUp() + { + Assert.AreEqual(Mathf.RoundToInt(100 * SandboxBattleRules.PlayerDamageScale), + SandboxBattleRules.PlayerDamage(100, 100000, SandboxBattleRules.MinPlayerActions)); + } + + [Test] + public void PlayerDamage_BeforeMinActions_NeverKills() + { + // 첫 일격이 적을 쓰러뜨리면 스킬 하나 눌러 보고 끝난다 — 적 HP가 1은 남아야 한다. + for (int action = 1; action < SandboxBattleRules.MinPlayerActions; action++) + Assert.AreEqual(49, SandboxBattleRules.PlayerDamage(1000, 50, action), $"{action}번째 행동"); + } + + [Test] + public void PlayerDamage_OnTheLastAction_AlwaysKills() + { + // 공식이 낸 피해가 아무리 작아도 마지막 행동에서는 끝난다 — 끝나지 않는 꿈은 없다. + Assert.AreEqual(5000, SandboxBattleRules.PlayerDamage(1, 5000, SandboxBattleRules.MaxPlayerActions)); + Assert.AreEqual(5000, SandboxBattleRules.PlayerDamage(1, 5000, SandboxBattleRules.MaxPlayerActions + 3)); + } + + [Test] + public void PlayerDamage_AgainstDeadEnemy_IsZero() + { + Assert.AreEqual(0, SandboxBattleRules.PlayerDamage(100, 0, 2)); + } + + [Test] + public void EnemyDamage_IsScaledDown() + { + Assert.AreEqual(Mathf.RoundToInt(100 * SandboxBattleRules.EnemyDamageScale), + SandboxBattleRules.EnemyDamage(100, 10000, 10000)); + } + + [Test] + public void EnemyDamage_NeverTakesTheChampionBelowTheFloor() + { + int max = 1000; + int floor = SandboxBattleRules.HpFloor(max); + Assert.AreEqual(300, floor); + Assert.AreEqual(10, SandboxBattleRules.EnemyDamage(500, floor + 10, max), "하한까지만 깎인다"); + Assert.AreEqual(0, SandboxBattleRules.EnemyDamage(500, floor, max), "이미 하한이면 피해가 없다"); + Assert.AreEqual(0, SandboxBattleRules.EnemyDamage(500, floor - 50, max)); + } + + [Test] + public void MinActions_IsBelowMaxActions() + { + // 거꾸로 되면 "적이 못 죽는 구간"과 "반드시 죽는 구간"이 겹쳐 규칙이 서로를 지운다. + Assert.Less(SandboxBattleRules.MinPlayerActions, SandboxBattleRules.MaxPlayerActions); + } + + // ── 시작 조건 ── + + [Test] + public void ShouldAutoStart_NewAccountAtFirstQuest_Starts() + { + Assert.IsTrue(DreamPrologueRules.ShouldAutoStart("q_move", 0, false, false, false, false)); + } + + [TestCase("q_talk_elder", 0, false, false, false, false, TestName = "AfterFirstQuest")] + [TestCase("q_move", 1, false, false, false, false, TestName = "FirstQuestStarted")] + [TestCase("q_move", 0, true, false, false, false, TestName = "AlreadyPlayed")] + [TestCase("q_move", 0, false, true, false, false, TestName = "ModalOpen_LoginOrDialogue")] + [TestCase("q_move", 0, false, false, true, false, TestName = "PlayerFrozen")] + [TestCase("q_move", 0, false, false, false, true, TestName = "InSubArea")] + [TestCase(null, 0, false, false, false, false, TestName = "NoActiveQuest")] + public void ShouldAutoStart_OtherSituations_DoesNot(string quest, int progress, bool played, + bool modal, bool frozen, bool subArea) + { + Assert.IsFalse(DreamPrologueRules.ShouldAutoStart(quest, progress, played, modal, frozen, subArea)); + } + + [Test] + public void CanReplay_IgnoresAlreadyPlayed_ButNotRunningOrBusy() + { + Assert.IsTrue(DreamPrologueRules.CanReplay(false, false, false, false)); + Assert.IsFalse(DreamPrologueRules.CanReplay(true, false, false, false)); + Assert.IsFalse(DreamPrologueRules.CanReplay(false, true, false, false)); + Assert.IsFalse(DreamPrologueRules.CanReplay(false, false, true, false)); + Assert.IsFalse(DreamPrologueRules.CanReplay(false, false, false, true)); + } + + [Test] + public void StartQuestId_IsTheFirstStoryQuestOfTheRealChain() + { + // 시작 조건이 "첫 퀘스트가 진행 0으로 활성"이라는 전제다 — 첫 퀘스트가 바뀌면 프롤로그가 영영 안 뜬다. + var host = new GameObject("DreamPrologueTests_Quests"); + objects.Add(host); + var mgr = host.AddComponent(); + typeof(TutorialQuestManager).GetMethod("Initialize", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(mgr, null); + TutorialQuest first = null; + foreach (TutorialQuest q in mgr.GetAllQuests()) + if (q.category == QuestCategory.Story) { first = q; break; } + Assert.AreEqual(DreamPrologueRules.StartQuestId, first.questId); + } + + // ── 저작 데이터 ── + + [Test] + public void IslandSnapshot_KeepsEveryAuthoredObjectAfterSanitize() + { + // 남이 만든 섬을 받을 때와 같은 정리를 거친다 — 겹침·경계 밖·모르는 ID는 조용히 버려지는데, + // 그러면 분수 광장에 분수가 없는 꿈이 된다. + IslandSnapshot snapshot = DreamPrologueData.BuildIslandSnapshot("하늘"); + int authored = snapshot.placed.Count; + IslandSaveRules.SanitizeSnapshot(snapshot); + Assert.AreEqual(authored, snapshot.placed.Count, "배치가 정리에서 빠졌다 — 겹침·경계·카탈로그 ID를 확인"); + Assert.AreEqual(DreamPrologueData.IslandObjects.Length, snapshot.placed.Count); + } + + [Test] + public void IslandSnapshot_KeepsEveryInsect_AndStaysWithinTheSlotLimit() + { + IslandSnapshot snapshot = DreamPrologueData.BuildIslandSnapshot("하늘"); + IslandSaveRules.SanitizeSnapshot(snapshot); + Assert.AreEqual(DreamPrologueData.IslandInsects.Length, snapshot.insects.Count); + Assert.LessOrEqual(DreamPrologueData.IslandInsects.Length, GameConstants.Island.MaxInsectSlots); + } + + [Test] + public void IslandSnapshot_LeavesTheArrivalCorridorOpen() + { + // 입구에서 분수까지 가운데 두 칸이 막히면 꿈의 마지막 목표에 닿을 수 없다. + IslandSnapshot snapshot = DreamPrologueData.BuildIslandSnapshot("하늘"); + int half = IslandGrid.GridSize(snapshot.sizeLevel) / 2; + for (int z = -half + 2; z < DreamPrologueData.FountainZ; z++) + for (int x = -1; x <= 0; x++) + foreach (IslandPlacedRecord p in snapshot.placed) + { + IslandObjectDef def = IslandCatalog.Get(p.id); + if (!def.blocksMovement) continue; + IslandGrid.Footprint(def, p.rot, out int w, out int d); + Assert.IsFalse(IslandGrid.RectsOverlap(x, z, 1, 1, p.x, p.z, w, d), + $"{p.id}가 입구→분수 통로 ({x},{z})를 막는다"); + } + } + + [Test] + public void IslandSnapshot_FountainIsWhereTheGoalMarkerPoints() + { + IslandSnapshot snapshot = DreamPrologueData.BuildIslandSnapshot("하늘"); + IslandPlacedRecord fountain = snapshot.placed.Find(p => p.id == "f_fountain"); + Assert.IsNotNull(fountain); + Assert.AreEqual(DreamPrologueData.FountainX, fountain.x); + Assert.AreEqual(DreamPrologueData.FountainZ, fountain.z); + } + + [Test] + public void ChampionInsect_IsMaxedOutAndStartsAtFullHealth() + { + InsectData data = NewSpecies(DreamPrologueData.AceInsectId, 150, 40, 30); + PlayerInsectData pid = DreamPrologueData.BuildChampionInsect(data); + Assert.AreEqual(15, pid.ivHp); + Assert.AreEqual(15, pid.ivAtk); + Assert.AreEqual(15, pid.ivDef); + Assert.AreEqual(DreamPrologueData.AceLevel, pid.level); + Assert.AreEqual(pid.GetTotalHp(data.baseHp), pid.currentHp, "-1(미초기화)로 두면 기절한 채 시작한다"); + } + + [Test] + public void ChampionSkills_PickStrongestDamageFirst_AndKeepOneBuff() + { + InsectData data = NewSpecies("skill_probe", 100, 30, 20); + var all = new List + { + Learn(NewSkill("jab", 20, SkillEffectType.Damage), 1), + Learn(NewSkill("boost", 0, SkillEffectType.BuffAttack), 5), + Learn(NewSkill("trait", 35, SkillEffectType.Damage), 9), + Learn(NewSkill("burst", 55, SkillEffectType.Damage), 13), + Learn(NewSkill("storm", 70, SkillEffectType.Damage), 17), + Learn(NewSkill("signature", 90, SkillEffectType.Damage), 20), + }; + data.learnset = all.ToArray(); + + InsectSkill[] picked = DreamPrologueData.PickChampionSkills(data, DreamPrologueData.AceLevel); + + Assert.AreEqual(DreamPrologueData.SkillCount, picked.Length); + Assert.AreEqual("signature", picked[0].skillId, "필살기가 맨 앞"); + Assert.AreEqual("storm", picked[1].skillId); + Assert.AreEqual("burst", picked[2].skillId); + Assert.AreEqual(SkillEffectType.BuffAttack, picked[3].effectType, "마지막 칸은 능력 상승기"); + } + + [Test] + public void ChampionSkills_WithoutBuff_FillsWithDamage_AndUnlearnedAreSkipped() + { + InsectData data = NewSpecies("skill_probe2", 100, 30, 20); + data.learnset = new[] + { + Learn(NewSkill("a", 10, SkillEffectType.Damage), 1), + Learn(NewSkill("b", 20, SkillEffectType.Damage), 5), + Learn(NewSkill("c", 30, SkillEffectType.Damage), 9), + Learn(NewSkill("d", 40, SkillEffectType.Damage), 13), + Learn(NewSkill("late", 99, SkillEffectType.Damage), 90), // 아직 못 배운 기술 + }; + InsectSkill[] picked = DreamPrologueData.PickChampionSkills(data, 80); + Assert.AreEqual(4, picked.Length); + foreach (InsectSkill s in picked) Assert.AreNotEqual("late", s.skillId); + } + + [Test] + public void ChampionSkills_NoLearnset_IsEmpty_SoTheSpeciesOwnSkillsAreUsed() + { + InsectData data = NewSpecies("skill_probe3", 100, 30, 20); + Assert.AreEqual(0, DreamPrologueData.PickChampionSkills(data, 80).Length); + Assert.AreEqual(0, DreamPrologueData.PickChampionSkills(null, 80).Length); + } + + // ── 샌드박스 전투는 아무것도 남기지 않는다 ── + + private sealed class Counts + { + public int battleEnded; + public int duelEnded; + public bool won; + } + + private InsectBattleController NewController(out PlayerCandyInventory candy, out PlayerProgressController progress, + out PlayerCurrencyWallet wallet, out PlayerInsectCollection collection, out Counts counts) + { + // 보상 지급처를 전부 붙여 둔다 — 샌드박스가 건드리면 그 값이 바뀌어 잡힌다. + var go = new GameObject("DreamSandboxFixture"); + go.SetActive(false); // Awake의 디스크 로드를 막는다 + objects.Add(go); + candy = go.AddComponent(); + progress = go.AddComponent(); + wallet = go.AddComponent(); + collection = go.AddComponent(); + typeof(PlayerCandyInventory).GetField("data", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(candy, new PlayerCandyData()); + typeof(PlayerProgressController).GetField("data", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(progress, new PlayerProgressData { level = 3, currentXp = 7 }); + typeof(PlayerCurrencyWallet).GetField("data", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(wallet, new PlayerCurrencyData { coins = 11, gems = 2 }); + + var controller = go.AddComponent(); + controller.AutoWire(collection, candy, progress, null); + controller.AutoWire(wallet); + var c = new Counts(); + controller.BattleEnded += won => { c.battleEnded++; c.won = won; }; + controller.DuelEnded += won => c.duelEnded++; + counts = c; + return controller; + } + + private InsectBattleController StartSandbox(out InsectBattleStats player, out InsectBattleStats enemy, + out PlayerCandyInventory candy, out PlayerProgressController progress, out PlayerCurrencyWallet wallet, + out PlayerInsectCollection collection, out Counts counts, out PlayerInsectData champion) + { + InsectBattleController controller = NewController(out candy, out progress, out wallet, out collection, out counts); + InsectData ace = NewSpecies("dream_ace_probe", 150, 40, 30); + InsectData foe = NewSpecies("dream_foe_probe", 400, 40, 30); + InsectSkill hit = NewSkill("hit", 40, SkillEffectType.Damage); + ace.skills = new[] { hit }; + foe.skills = new[] { hit }; + champion = DreamPrologueData.BuildChampionInsect(ace); + + InsectBattleStats p = null, e = null; + bool started = controller.StartSandbox(ace, 80, foe, 72, new[] { hit }, champion, (a, b) => { p = a; e = b; }); + Assert.IsTrue(started); + player = p; + enemy = e; + return controller; + } + + [Test] + public void Sandbox_PlayedToTheEnd_PaysNothingAndTouchesNoSaveState() + { + var controller = StartSandbox(out var player, out var enemy, out var candy, out var progress, + out var wallet, out var collection, out var counts, out var champion); + int candyBefore = candy.Candies; + int levelBefore = progress.Level; + int xpBefore = progress.CurrentXp; + int coinsBefore = wallet.Coins; + int ownedBefore = collection.GetAllOwned().Count; + int hpBefore = champion.currentHp; + Assert.IsTrue(controller.IsSandbox); + + for (int i = 0; i < 12 && counts.battleEnded == 0; i++) + { + if (controller.CanUseSkill(0)) controller.UseSkill(0); else controller.UseBasicAttack(); + } + + Assert.AreEqual(1, counts.battleEnded, "꿈의 전투가 끝나지 않았다"); + Assert.IsTrue(counts.won, "챔피언이 졌다"); + Assert.AreEqual(0, counts.duelEnded, "DuelEnded를 쏘면 NPC 대결 보상·쿨다운·간부 격파 기록이 움직인다"); + Assert.AreEqual(candyBefore, candy.Candies, "캔디가 올랐다"); + Assert.AreEqual(levelBefore, progress.Level, "레벨이 올랐다"); + Assert.AreEqual(xpBefore, progress.CurrentXp, "경험치가 올랐다"); + Assert.AreEqual(coinsBefore, wallet.Coins, "코인이 올랐다"); + Assert.AreEqual(ownedBefore, collection.GetAllOwned().Count, "곤충이 컬렉션에 들어갔다"); + Assert.AreEqual(0, controller.GetLastCandyReward()); + Assert.AreEqual(0, controller.GetLastExpReward()); + Assert.IsFalse(controller.GetLastCaptureAttempted(), "승리 뒤 포획 판정이 돌았다"); + Assert.AreEqual(hpBefore, champion.currentHp, "꿈속의 피해가 개체의 저장 HP에 새었다"); + } + + [Test] + public void Sandbox_EndsWithinTheActionCap_AndTheChampionNeverFalls() + { + var controller = StartSandbox(out var player, out var enemy, out _, out _, out _, out _, out var counts, out _); + int floor = SandboxBattleRules.HpFloor(player.MaxHp); + int actions = 0; + while (counts.battleEnded == 0 && actions < 20) + { + if (controller.CanUseSkill(0)) controller.UseSkill(0); else controller.UseBasicAttack(); + actions++; + Assert.GreaterOrEqual(player.CurrentHp, floor, $"{actions}번째 행동 뒤 챔피언이 하한 밑으로 내려갔다"); + } + Assert.IsTrue(counts.won); + Assert.GreaterOrEqual(actions, SandboxBattleRules.MinPlayerActions, "너무 일찍 끝났다 — 스킬을 눌러 볼 틈이 없다"); + Assert.LessOrEqual(actions, SandboxBattleRules.MaxPlayerActions + 2, "끝나지 않는 꿈 — 쿨다운으로 기본 공격이 낀 턴을 감안해도 이 안에는 끝나야 한다"); + } + + [Test] + public void Sandbox_CannotEscape() + { + var controller = StartSandbox(out _, out _, out _, out _, out _, out _, out var counts, out _); + Assert.IsFalse(controller.TryEscape()); + Assert.AreEqual(0, counts.battleEnded, "도주로 꿈이 끝났다"); + } + + [Test] + public void Sandbox_ForceVictory_EndsThroughTheNormalPath() + { + var controller = StartSandbox(out _, out _, out var candy, out _, out _, out _, out var counts, out _); + int candyBefore = candy.Candies; + controller.ForceSandboxVictory(); + Assert.AreEqual(1, counts.battleEnded); + Assert.IsTrue(counts.won); + Assert.AreEqual(candyBefore, candy.Candies); + controller.ForceSandboxVictory(); // 두 번째는 무시된다 + Assert.AreEqual(1, counts.battleEnded); + } + + [Test] + public void NextOrdinaryBattle_DoesNotInheritTheSandboxFlag() + { + // 표지가 남으면 이후 모든 전투가 보상 0·BattleWin 없음이 된다 — 가장 조용하고 가장 큰 사고다. + var controller = StartSandbox(out _, out _, out _, out _, out _, out _, out var counts, out _); + controller.ForceSandboxVictory(); + Assert.IsTrue(controller.IsSandbox); + + InsectData a = NewSpecies("after_a", 100, 30, 20); + InsectData b = NewSpecies("after_b", 100, 30, 20); + Assert.IsTrue(controller.StartDuel(a, 5, b, 5)); + Assert.IsFalse(controller.IsSandbox); + } + + // ── 꿈 중에는 꿈 밖이 움직이지 않는다 ── + + [Test] + public void QuestProgress_IsSuspendedWhileTheDreamRuns() + { + var host = new GameObject("DreamPrologueTests_QuestSuspend"); + objects.Add(host); + var mgr = host.AddComponent(); + typeof(TutorialQuestManager).GetMethod("Initialize", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(mgr, null); + typeof(TutorialQuestManager).GetField("tutorialSessionStarted", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(mgr, true); + var progress = (Dictionary)typeof(TutorialQuestManager).GetField("questProgress", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(mgr); + progress.Clear(); + + DreamPrologueState.Begin(); + mgr.NotifyAction(QuestType.Battle); + mgr.NotifyCapture(InsectRarity.Common); + Assert.AreEqual(0, progress.Count, "꿈속의 행동이 퀘스트에 들어갔다"); + + DreamPrologueState.End(); + // 꿈이 끝나면 다시 센다 — 영영 멈춰 있으면 안 된다. + typeof(TutorialQuestManager).GetField("activeQuestId", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(mgr, "q_capture3"); + mgr.NotifyCapture(InsectRarity.Common); + Assert.Greater(progress.Count, 0); + } + + [Test] + public void State_BeginAndEnd_Toggle() + { + Assert.IsFalse(DreamPrologueState.Active); + DreamPrologueState.Begin(); + Assert.IsTrue(DreamPrologueState.Active); + DreamPrologueState.End(); + Assert.IsFalse(DreamPrologueState.Active); + } + + // ── 실제 곤충 DB로 ── + // + // 위 테스트는 가짜 종으로 규칙을 본다. 아래는 부트스트랩이 만드는 **진짜 DB**로 저작 데이터를 확인한다 — + // 종 ID 오타는 "그 곤충만 조용히 안 나오는" 결함이고, 실제 능력치·기술에서 꿈의 전투가 정말 + // 3~4턴에 이기고 끝나는지는 가짜 종으로는 알 수 없다. + + private InsectDatabase RealDatabase() + { + var host = new GameObject("DreamPrologueTests_RealDb"); + host.SetActive(false); // Awake(월드 생성)를 막고 private 생성 함수만 부른다 + objects.Add(host); + var bootstrap = host.AddComponent(); + var db = (InsectDatabase)typeof(PlaySceneBootstrap) + .GetMethod("EnsureExpandedDatabase", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(bootstrap, null); + Assert.IsNotNull(db); + objects.Add(db); + return db; + } + + [Test] + public void EverySpeciesId_ExistsInTheRealDatabase() + { + InsectDatabase db = RealDatabase(); + var ids = new List { DreamPrologueData.AceInsectId, DreamPrologueData.ChallengerInsectId }; + foreach (var s in DreamPrologueData.IslandInsects) ids.Add(s.insectId); + foreach (string id in ids) + Assert.IsNotNull(db.GetById(id), $"'{id}'가 곤충 DB에 없다 — 그 곤충이 꿈에서 조용히 빠진다"); + } + + [Test] + public void IslandInsects_AreAllHighRarity() + { + // "제일 좋은 곤충들"이 섬의 약속이다 — 흔한 곤충이 섞이면 꿈의 값어치가 떨어진다. + InsectDatabase db = RealDatabase(); + foreach (var s in DreamPrologueData.IslandInsects) + Assert.GreaterOrEqual((int)db.GetById(s.insectId).rarity, (int)InsectRarity.Epic, s.insectId); + Assert.GreaterOrEqual((int)db.GetById(DreamPrologueData.AceInsectId).rarity, (int)InsectRarity.Legendary); + } + + [Test] + public void RealChampion_HasFourUsableSkills_SignatureFirst() + { + InsectDatabase db = RealDatabase(); + InsectData ace = db.GetById(DreamPrologueData.AceInsectId); + InsectSkill[] skills = DreamPrologueData.PickChampionSkills(ace, DreamPrologueData.AceLevel); + + Assert.AreEqual(DreamPrologueData.SkillCount, skills.Length, "전투 화면의 스킬 카드가 비는 칸이 생긴다"); + Assert.IsTrue(skills[0].isSignatureSkill || skills[0].power >= skills[1].power, "가장 강한 기술이 맨 앞이어야 한다"); + } + + [Test] + public void RealSandboxBattle_WinsInThreeToFourActions_WithoutFalling() + { + InsectDatabase db = RealDatabase(); + InsectData ace = db.GetById(DreamPrologueData.AceInsectId); + InsectData foe = db.GetById(DreamPrologueData.ChallengerInsectId); + InsectBattleController controller = NewController(out _, out _, out _, out _, out var counts); + PlayerInsectData champion = DreamPrologueData.BuildChampionInsect(ace); + InsectSkill[] skills = DreamPrologueData.PickChampionSkills(ace, DreamPrologueData.AceLevel); + + InsectBattleStats player = null; + Assert.IsTrue(controller.StartSandbox(ace, DreamPrologueData.AceLevel, foe, DreamPrologueData.ChallengerLevel, + skills, champion, (p, e) => player = p)); + + int floor = SandboxBattleRules.HpFloor(player.MaxHp); + int actions = 0; + while (counts.battleEnded == 0 && actions < 12) + { + bool acted = false; + for (int i = 0; i < skills.Length && !acted; i++) + if (controller.CanUseSkill(i)) { controller.UseSkill(i); acted = true; } + if (!acted) controller.UseBasicAttack(); + actions++; + Assert.GreaterOrEqual(player.CurrentHp, floor, $"{actions}번째 행동 뒤 챔피언이 하한 밑으로 내려갔다"); + } + + Assert.IsTrue(counts.won, "실제 곤충으로는 챔피언이 이기지 못했다"); + Assert.GreaterOrEqual(actions, SandboxBattleRules.MinPlayerActions); + Assert.LessOrEqual(actions, SandboxBattleRules.MaxPlayerActions, + "스킬을 매 턴 쓸 수 있는 실제 구성에서는 마지막 일격 상한(" + SandboxBattleRules.MaxPlayerActions + ")에 끝나야 한다"); + } + + // ── 도우미 ── + + private InsectData NewSpecies(string id, int hp, int atk, int def) + { + var d = ScriptableObject.CreateInstance(); + objects.Add(d); + d.insectId = id; + d.displayName = id; + d.rarity = InsectRarity.Legendary; + d.baseHp = hp; + d.baseAtk = atk; + d.baseDef = def; + d.primaryType = InsectElement.None; + return d; + } + + private InsectSkill NewSkill(string id, int power, SkillEffectType type) + { + var s = ScriptableObject.CreateInstance(); + objects.Add(s); + s.skillId = id; + s.displayName = id; + s.power = Mathf.Max(1, power); + s.effectType = type; + s.element = InsectElement.None; + s.accuracy = 1f; + s.cooldownTurns = 0; + s.effectValue = 0.3f; + s.effectDurationTurns = 2; + return s; + } + + private static InsectLearnableSkill Learn(InsectSkill skill, int level) + { + return new InsectLearnableSkill { skillId = skill.skillId, learnLevel = level, skill = skill }; + } + } +} +#endif diff --git a/Assets/Tests/EditMode/DreamPrologueTests.cs.meta b/Assets/Tests/EditMode/DreamPrologueTests.cs.meta new file mode 100644 index 0000000..0ad9176 --- /dev/null +++ b/Assets/Tests/EditMode/DreamPrologueTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 97faccdaa885c014691bd06d1d190826 \ No newline at end of file diff --git a/Assets/Tests/EditMode/DuelBanterTests.cs b/Assets/Tests/EditMode/DuelBanterTests.cs new file mode 100644 index 0000000..6f325a7 --- /dev/null +++ b/Assets/Tests/EditMode/DuelBanterTests.cs @@ -0,0 +1,189 @@ +#if UNITY_EDITOR +using InsectGame.NPC; +using InsectGame.UI; +using NUnit.Framework; + +namespace InsectGame.Tests +{ + /// + /// 대결 연출 대사 — 순간 판정()과 표의 무결성. + /// 그리기(BattleScreenUI.Duel)는 IMGUI라 QA 캡처 빌드(-battleScenario boss|guardian)로 본다. + /// + [TestFixture] + public class DuelBanterTests + { + [Test] + public void Next_EnemyCrossesHalfThenCrisis_EachOnce() + { + var t = new DuelBanter.Tracker(); + Assert.AreEqual(DuelBanter.Moment.None, DuelBanter.Next(ref t, 0.8f, 1f)); + Assert.AreEqual(DuelBanter.Moment.Half, DuelBanter.Next(ref t, 0.45f, 1f)); + Assert.AreEqual(DuelBanter.Moment.None, DuelBanter.Next(ref t, 0.40f, 1f)); + Assert.AreEqual(DuelBanter.Moment.Crisis, DuelBanter.Next(ref t, 0.15f, 1f)); + Assert.AreEqual(DuelBanter.Moment.None, DuelBanter.Next(ref t, 0.05f, 1f)); + } + + [Test] + public void Next_OneHitThroughBothLines_SaysCrisisOnly() + { + var t = new DuelBanter.Tracker(); + Assert.AreEqual(DuelBanter.Moment.Crisis, DuelBanter.Next(ref t, 0.1f, 1f)); + Assert.AreEqual(DuelBanter.Moment.None, DuelBanter.Next(ref t, 0.1f, 1f), "절반 대사가 뒤늦게 나오면 안 된다"); + } + + [Test] + public void Next_KnockedOut_SaysNothing() + { + // 쓰러진 자리는 결과 한마디가 맡는다 — 여기서 "이럴 리가"가 나오면 결과 대사와 겹친다. + var t = new DuelBanter.Tracker(); + Assert.AreEqual(DuelBanter.Moment.None, DuelBanter.Next(ref t, 0f, 1f)); + } + + [Test] + public void Next_PlayerLow_PressingOnce_AfterEnemyMoment() + { + var t = new DuelBanter.Tracker(); + // 같은 교환에서 둘 다 넘으면 상대 쪽이 먼저, 내 쪽은 다음 호출에서. + Assert.AreEqual(DuelBanter.Moment.Half, DuelBanter.Next(ref t, 0.4f, 0.2f)); + Assert.AreEqual(DuelBanter.Moment.Pressing, DuelBanter.Next(ref t, 0.4f, 0.2f)); + Assert.AreEqual(DuelBanter.Moment.None, DuelBanter.Next(ref t, 0.4f, 0.1f)); + } + + [Test] + public void Next_PlayerFainted_NoPressing() + { + var t = new DuelBanter.Tracker(); + Assert.AreEqual(DuelBanter.Moment.None, DuelBanter.Next(ref t, 1f, 0f)); + } + + [Test] + public void LineFor_MapsEachMoment() + { + Assert.IsTrue(DuelBanter.TryGet("ledger_grip", out DuelBanter.Lines l)); + Assert.AreEqual(l.half, DuelBanter.LineFor(l, DuelBanter.Moment.Half)); + Assert.AreEqual(l.crisis, DuelBanter.LineFor(l, DuelBanter.Moment.Crisis)); + Assert.AreEqual(l.pressing, DuelBanter.LineFor(l, DuelBanter.Moment.Pressing)); + Assert.IsNull(DuelBanter.LineFor(l, DuelBanter.Moment.None)); + } + + [Test] + public void EveryBossDuel_HasCompleteBanter() + { + // 보스 표에 사람을 늘리고 대사를 안 쓰면 그 인물만 말없이 싸운다(예외도 경고도 없다). + foreach (NpcBossDuels.BossDuel d in NpcBossDuels.All()) + { + Assert.IsTrue(DuelBanter.TryGet(d.storyNpcId, out DuelBanter.Lines l), $"{d.storyNpcId}: 대결 대사 없음"); + Assert.AreEqual(d.storyNpcId, l.npcId, $"{d.storyNpcId}: 초상 인물이 다르다"); + Assert.AreEqual(d.displayName, l.name, $"{d.storyNpcId}: 컷인 이름이 보스 표와 다르다"); + } + } + + [Test] + public void EveryBanter_AllLinesPresent_NameMatchesDialogue_HasPortrait() + { + foreach (string id in DuelBanter.Ids) + { + DuelBanter.TryGet(id, out DuelBanter.Lines l); + foreach (string line in new[] { l.title, l.intro, l.half, l.crisis, l.pressing, l.defeat, l.victory }) + { + Assert.IsFalse(string.IsNullOrEmpty(line), $"{id}: 빈 대사"); + // 말풍선·컷인 판은 한두 줄 크기다 — 넘치면 LabelFit이 글자를 읽기 어렵게 줄인다. + Assert.LessOrEqual(line.Length, 40, $"{id}: 너무 긴 대사 — {line}"); + Assert.IsFalse(line.Contains("무명"), $"{id}: 「무명」을 부르면 안 된다"); + } + Assert.AreEqual(NpcDialogueDatabase.StorySpeakerName(l.npcId), l.name, + $"{id}: 대사창 이름표와 컷인 이름이 다르다"); + Assert.IsTrue(NpcDialogueUI.HasStoryPortrait(l.npcId), $"{id}: 초상 없음 — 컷인에 얼굴이 빈다"); + } + } + + [Test] + public void TeamBosses_HaveSendOutLines() + { + // 간부는 여럿을 데리고 싸운다(집게·저울 셋, 하월 다섯) — 교체 순간 한마디가 없으면 말없이 다음 곤충이 나온다. + var expectedAtLeast = new System.Collections.Generic.Dictionary + { + ["ledger_grip"] = 2, ["ledger_scale"] = 2, ["ledger_chief"] = 4, + }; + foreach (var pair in expectedAtLeast) + { + Assert.IsTrue(DuelBanter.TryGet(pair.Key, out DuelBanter.Lines l), pair.Key); + Assert.IsNotNull(l.sendOut, $"{pair.Key}: 교체 한마디 없음"); + Assert.GreaterOrEqual(l.sendOut.Length, pair.Value, pair.Key); + foreach (string line in l.sendOut) + { + Assert.IsFalse(string.IsNullOrWhiteSpace(line), $"{pair.Key}: 빈 교체 한마디"); + Assert.LessOrEqual(line.Length, 40, $"{pair.Key}: 너무 긴 교체 한마디 — {line}"); + Assert.IsFalse(line.Contains("무명"), pair.Key); + } + } + } + + [Test] + public void SendOutLine_FirstInsectSilent_ThenInOrder_ThenRepeatsLast() + { + Assert.IsTrue(DuelBanter.TryGet("ledger_grip", out DuelBanter.Lines grip)); + Assert.IsNull(DuelBanter.SendOutLine(grip, 0), "첫 곤충의 말은 컷인 도발이 맡는다"); + Assert.AreEqual(grip.sendOut[0], DuelBanter.SendOutLine(grip, 1)); + Assert.AreEqual(grip.sendOut[1], DuelBanter.SendOutLine(grip, 2)); + Assert.AreEqual(grip.sendOut[grip.sendOut.Length - 1], DuelBanter.SendOutLine(grip, 9), + "팀이 준비한 줄보다 길면 마지막 줄을 되풀이한다"); + + Assert.IsTrue(DuelBanter.TryGet("rival_meadow", out DuelBanter.Lines raon)); + Assert.IsNull(DuelBanter.SendOutLine(raon, 1), "한 마리만 내는 상대는 교체 한마디가 없다"); + } + + [TestCase(1, 0, true)] // 한 마리 대결 — 늘 말한다 + [TestCase(0, 0, true)] + [TestCase(3, 0, false)] // 집게·저울의 첫 곤충 + [TestCase(3, 1, false)] + [TestCase(3, 2, true)] // 에이스 + [TestCase(5, 3, false)] + [TestCase(5, 4, true)] // 하월의 이름 잃은 나방 + public void EnemyMomentsAllowed_OnlyTheAceInTeamDuels(int size, int index, bool expected) + { + Assert.AreEqual(expected, DuelBanter.EnemyMomentsAllowed(size, index)); + } + + [Test] + public void Next_TeamDuel_FirstInsectsStayQuiet_AceStartsFresh() + { + var t = new DuelBanter.Tracker(); + // 첫 곤충이 위기까지 떨어져도 상대는 무너지는 소리를 하지 않는다 — 아직 둘이 남았다. + Assert.AreEqual(DuelBanter.Moment.None, DuelBanter.Next(ref t, 0.1f, 1f, 3, 0)); + // 내 곤충이 몰리는 순간은 팀과 무관하다. + Assert.AreEqual(DuelBanter.Moment.Pressing, DuelBanter.Next(ref t, 0.1f, 0.2f, 3, 1)); + // 에이스가 나오면 처음부터 센다 — 절반, 그다음 위기. + Assert.AreEqual(DuelBanter.Moment.None, DuelBanter.Next(ref t, 1f, 0.2f, 3, 2)); + Assert.AreEqual(DuelBanter.Moment.Half, DuelBanter.Next(ref t, 0.45f, 0.2f, 3, 2)); + Assert.AreEqual(DuelBanter.Moment.Crisis, DuelBanter.Next(ref t, 0.15f, 0.2f, 3, 2)); + } + + [Test] + public void TeamBosses_SendOutLinesCoverTheirTeams() + { + // 교체 한마디가 팀의 교체 횟수만큼 있어야 말이 되풀이되지 않는다(모자라면 마지막 줄을 되풀이한다). + foreach (NpcBossDuels.BossDuel d in NpcBossDuels.All()) + { + if (d.teamInsectIds == null || d.teamInsectIds.Length <= 1) continue; + Assert.IsTrue(DuelBanter.TryGet(d.storyNpcId, out DuelBanter.Lines l), d.storyNpcId); + Assert.IsNotNull(l.sendOut, $"{d.storyNpcId}: 팀 대결인데 교체 한마디가 없다"); + Assert.GreaterOrEqual(l.sendOut.Length, d.teamInsectIds.Length - 1, + $"{d.storyNpcId}: 곤충 {d.teamInsectIds.Length}마리 — 교체 {d.teamInsectIds.Length - 1}번"); + } + } + + [Test] + public void ActOneThugs_DoNotNameTheOrganization() + { + // 1막 하수는 정체가 밝혀지기 전이다(NpcDialogueDatabase.StorySpeakerName 주석) — 칭호로도 새면 안 된다. + foreach (string id in new[] { "ledger_thug_pin", "ledger_thug_rule", "ledger_thug_cord" }) + { + Assert.IsTrue(DuelBanter.TryGet(id, out DuelBanter.Lines l), id); + foreach (string line in new[] { l.title, l.intro, l.half, l.crisis, l.pressing, l.defeat, l.victory }) + Assert.IsFalse(line.Contains("명부회"), $"{id}: {line}"); + } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/DuelBanterTests.cs.meta b/Assets/Tests/EditMode/DuelBanterTests.cs.meta new file mode 100644 index 0000000..80b7af3 --- /dev/null +++ b/Assets/Tests/EditMode/DuelBanterTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8ae837e6a197078479e761baa8322d68 \ No newline at end of file diff --git a/Assets/Tests/EditMode/DuelHudLayoutTests.cs b/Assets/Tests/EditMode/DuelHudLayoutTests.cs new file mode 100644 index 0000000..70dc3db --- /dev/null +++ b/Assets/Tests/EditMode/DuelHudLayoutTests.cs @@ -0,0 +1,247 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Battle; +using InsectGame.Data; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + [TestFixture] + public class DuelHudLayoutTests + { + [TestCase(1920f, 1080f, false)] + [TestCase(1440f, 1080f, false)] + [TestCase(2560f, 1080f, false)] + [TestCase(1080f, 1920f, true)] + public void CombatCards_SafeAspectRatios_StayInsideWithoutOverlap(float width, float height, bool portrait) + { + var safe = new Rect(48f, 48f, width - 96f, height - 96f); + Rect left = DuelHudLayout.HpCard(safe, true); + Rect right = DuelHudLayout.HpCard(safe, false); + AssertContained(safe, left); + AssertContained(safe, right); + Assert.Less(left.center.x, right.center.x); + Assert.IsFalse(left.Overlaps(right)); + float deckHeight = portrait ? 610f : 300f; + var deck = new Rect(safe.x, safe.yMax - deckHeight, safe.width, deckHeight); + Assert.IsFalse(left.Overlaps(deck)); + Assert.IsFalse(right.Overlaps(deck)); + Rect basic = DuelHudLayout.UtilityCard(deck, portrait, false); + Rect escape = DuelHudLayout.UtilityCard(deck, portrait, true); + AssertContained(deck, basic); + AssertContained(deck, escape); + Assert.IsFalse(basic.Overlaps(escape)); + for (int count = 1; count <= 4; count++) + for (int i = 0; i < count; i++) + { + Rect card = DuelHudLayout.SkillCard(deck, portrait, i, count); + AssertContained(deck, card); + Assert.GreaterOrEqual(card.height, UIScale.MinTouchHeight); + Assert.GreaterOrEqual(card.width, 200f); + Assert.IsFalse(card.Overlaps(basic)); + Assert.IsFalse(card.Overlaps(escape)); + for (int j = 0; j < i; j++) + Assert.IsFalse(card.Overlaps(DuelHudLayout.SkillCard(deck, portrait, j, count))); + } + } + + [TestCase(SkillEffectType.Heal, true)] + [TestCase(SkillEffectType.BuffAttack, true)] + [TestCase(SkillEffectType.DefenseBuff, true)] + [TestCase(SkillEffectType.Damage, false)] + [TestCase(SkillEffectType.DebuffAttack, false)] + [TestCase(SkillEffectType.Stun, false)] + [TestCase(SkillEffectType.PoisonDot, false)] + public void SkillTargetLabel_EffectType_IdentifiesCorrectSide(SkillEffectType type, bool self) + { + Assert.AreEqual(self, DuelHudLayout.TargetsSelf(type)); + } + + // ── 연속 공격 배지 — 화면 한 장(HudFrame)에서 전투 HUD의 다른 자리와 겹치지 않는다 ── + // + // 예전 배지는 오른쪽 위 고정 좌표(y 100, 폭 180)라 가로·세로 모두 상대 HP 카드를, 세로에선 배속 버튼까지 덮었다. + // 자리는 전부 화면 파일의 순수 함수에서 받는다(식을 여기 다시 세우지 않는다). + + [TestCase(1280f, 720f, 0f, 0f, false)] + [TestCase(720f, 1280f, 0f, 0f, true)] + [TestCase(1920f, 1080f, 0f, 0f, false)] + [TestCase(1080f, 1920f, 0f, 0f, true)] + [TestCase(1080f, 2400f, 110f, 60f, true)] + [TestCase(2400f, 1080f, 0f, 0f, true)] + [TestCase(1440f, 1080f, 0f, 0f, false)] + public void ComboBadge_UnderMyHpCard_ClearsEveryBattleHudPiece(float width, float height, + float notchTop, float gestureBottom, bool mobilePlatform) + { + HudFrame f = HudFrame.ForScreen(width, height, 0f, 0f, notchTop, gestureBottom, mobilePlatform); + Rect safe = DuelHudLayout.Safe(f); + Rect player = DuelHudLayout.HpCard(safe, true); + Rect enemy = DuelHudLayout.HpCard(safe, false); + var fixedPieces = new List> + { + Piece("내 HP 카드", player), + Piece("상대 HP 카드", enemy), + Piece("머리 띠", DuelHudLayout.Heading(f)), + Piece("배속 버튼", DuelHudLayout.SpeedControl(f)), + Piece("행동 패널", DuelHudLayout.SkillDeck(f)), + Piece("턴 배너", DuelHudLayout.TurnBanner(f)), + Piece("공격 배너", DuelHudLayout.PhaseBanner(f)), + Piece("행동 문구", DuelHudLayout.ActionBar(f)), + Piece("전투 문구 1줄", BattleEffectTextOverlay.RowRect(f, 0)), + Piece("전투 문구 2줄", BattleEffectTextOverlay.RowRect(f, 1)), + Piece("전투 문구 3줄", BattleEffectTextOverlay.RowRect(f, 2)), + }; + + foreach (bool playerChip in new[] { false, true }) + foreach (bool enemyChip in new[] { false, true }) + foreach (bool ledger in new[] { false, true }) + { + Rect badge = BattleHudStack.ComboBadge(player, playerChip); + // 뜰 때 커진 순간까지 잰다 — 가운데 축으로 ComboPulsePeak배. + Rect pulsed = Grow(badge, BattleHudStack.ComboPulsePeak); + AssertContained(safe, badge); + Assert.GreaterOrEqual(pulsed.xMin, 0f); + Assert.LessOrEqual(pulsed.xMax, f.Width); + + var pieces = new List>(fixedPieces); + // 칩 폭은 문구가 정한다 — 가장 넓은 경우(카드 폭 전체)로 잰다. + if (playerChip) pieces.Add(Piece("내 보정 칩", BattleHudStack.EnvironmentChip(player, true, false, player.width))); + if (ledger) pieces.Add(Piece("장부 게이지", BattleHudStack.LedgerGauge(enemy))); + if (enemyChip) pieces.Add(Piece("상대 보정 칩", BattleHudStack.EnvironmentChip(enemy, false, ledger, enemy.width))); + pieces.Add(Piece("대결 말풍선", BattleHudStack.Bubble(enemy, ledger, enemyChip))); + + foreach (KeyValuePair piece in pieces) + Assert.IsFalse(pulsed.Overlaps(piece.Value), + $"{width}x{height} 칩{playerChip}/{enemyChip} 장부{ledger}: 연속 배지 {pulsed} ↔ {piece.Key} {piece.Value}"); + } + } + + [Test] + public void ComboBadge_WithMyEnvironmentChip_SitsBelowTheChip() + { + Rect card = new Rect(24f, 100f, 460f, DuelHudLayout.HpCardHeight); + Rect chip = BattleHudStack.EnvironmentChip(card, true, false, 200f); + Rect plain = BattleHudStack.ComboBadge(card, false); + Rect stacked = BattleHudStack.ComboBadge(card, true); + Assert.AreEqual(card.x, plain.x); + Assert.Greater(plain.y, card.yMax); + Assert.Greater(stacked.y, chip.yMax); + Assert.AreEqual(BattleHudStack.ComboBadgeHeight, stacked.height); + } + + [Test] + public void ComboPulse_Appearing_ShrinksFromPeakToOne() + { + Assert.AreEqual(BattleHudStack.ComboPulsePeak, BattleHudStack.ComboPulse(0f, false), 1e-4f); + float mid = BattleHudStack.ComboPulse(BattleHudStack.ComboPulseSeconds * 0.5f, false); + Assert.Less(mid, BattleHudStack.ComboPulsePeak); + Assert.Greater(mid, 1f); + Assert.AreEqual(1f, BattleHudStack.ComboPulse(BattleHudStack.ComboPulseSeconds, false)); + Assert.AreEqual(1f, BattleHudStack.ComboPulse(2f, false)); + Assert.AreEqual(1f, BattleHudStack.ComboPulse(0f, true), "줄인 움직임이면 커지지 않는다"); + } + + // ── HP 카드 상태 줄(BattleStatusLine) ── + + [Test] + public void StatusTally_SameKindTwice_KeepsLongestAndIgnoresOtherSide() + { + var effects = new[] + { + Effect(true, InsectBattleController.EffectKind.Dot, 5f, 2), + Effect(true, InsectBattleController.EffectKind.Dot, 5f, 3), + Effect(true, InsectBattleController.EffectKind.AtkBuff, 0.2f, 1), + Effect(true, InsectBattleController.EffectKind.AtkBuff, -0.2f, 2), + Effect(true, InsectBattleController.EffectKind.DefBuff, 0.3f, 0), + Effect(false, InsectBattleController.EffectKind.AtkBuff, 0.5f, 4), + }; + BattleStatusLine.Turns mine = BattleStatusLine.Tally(1, effects, true); + Assert.AreEqual(1, mine.Stun); + Assert.AreEqual(3, mine.Poison); + Assert.AreEqual(1, mine.AttackUp); + Assert.AreEqual(2, mine.AttackDown); + Assert.AreEqual(0, mine.DefenseUp, "남은 턴 0은 이미 끝난 효과"); + + BattleStatusLine.Turns theirs = BattleStatusLine.Tally(0, effects, false); + Assert.AreEqual(4, theirs.AttackUp); + Assert.AreEqual(0, theirs.Poison); + Assert.AreEqual(0, theirs.Stun); + } + + [Test] + public void StatusCompose_NoEffects_ShowsHpOrLowHpOnly() + { + var none = new BattleStatusLine.Turns(); + Assert.AreEqual("HP", BattleStatusLine.Compose(false, none, Chars10, 300f)); + Assert.AreEqual("체력 위험", BattleStatusLine.Compose(true, none, Chars10, 300f)); + } + + [Test] + public void StatusCompose_StunFirst_ThenPoisonThenDebuffThenBuff() + { + var t = new BattleStatusLine.Turns { Stun = 1, Poison = 3, AttackUp = 2, AttackDown = 1 }; + Assert.AreEqual("기절 1턴 중독 3턴 공격↓ 1턴 공격↑ 2턴", BattleStatusLine.Compose(false, t, Chars10, 1000f)); + Assert.AreEqual("체력 위험 기절 1턴 중독 3턴 공격↓ 1턴 공격↑ 2턴", BattleStatusLine.Compose(true, t, Chars10, 1000f)); + } + + [Test] + public void StatusCompose_TooNarrow_FoldsTailIntoCount() + { + var t = new BattleStatusLine.Turns { Stun = 1, Poison = 3, AttackUp = 2 }; + // 글자당 10px — "기절 1턴 중독 3턴 공격↑ 2턴"은 20글자 = 200px. + Assert.AreEqual("기절 1턴 중독 3턴 공격↑ 2턴", BattleStatusLine.Compose(false, t, Chars10, 200f)); + Assert.AreEqual("기절 1턴 중독 3턴 외 1", BattleStatusLine.Compose(false, t, Chars10, 160f)); + Assert.AreEqual("기절 1턴 외 2", BattleStatusLine.Compose(false, t, Chars10, 150f)); + } + + [Test] + public void StatusCompose_LowHpAndNarrow_DropsHeadOnlyWhenNoTagFitsBesideIt() + { + var t = new BattleStatusLine.Turns { Stun = 1, Poison = 3, AttackUp = 2 }; + Assert.AreEqual("체력 위험 기절 1턴 외 2", BattleStatusLine.Compose(true, t, Chars10, 160f)); + Assert.AreEqual("기절 1턴 외 2", BattleStatusLine.Compose(true, t, Chars10, 100f)); + } + + [Test] + public void StatusKey_DifferentContent_DifferentKey() + { + var stun = new BattleStatusLine.Turns { Stun = 1 }; + var poison = new BattleStatusLine.Turns { Poison = 1 }; + Assert.AreNotEqual(stun.Key(false), poison.Key(false)); + Assert.AreNotEqual(stun.Key(false), stun.Key(true)); + Assert.AreEqual(stun.Key(false), new BattleStatusLine.Turns { Stun = 1 }.Key(false)); + } + + private static float Chars10(string s) => s.Length * 10f; + + private static KeyValuePair Piece(string name, Rect rect) => new KeyValuePair(name, rect); + + private static Rect Grow(Rect r, float scale) + { + Vector2 c = r.center; + return new Rect(c.x - r.width * scale * 0.5f, c.y - r.height * scale * 0.5f, r.width * scale, r.height * scale); + } + + private static InsectBattleController.EffectSnapshot Effect(bool player, InsectBattleController.EffectKind kind, + float value, int turns) + { + return new InsectBattleController.EffectSnapshot + { + targetIsPlayer = player, + kind = kind, + value = value, + remainingTurns = turns + }; + } + + private static void AssertContained(Rect outer, Rect inner) + { + Assert.GreaterOrEqual(inner.xMin, outer.xMin); + Assert.GreaterOrEqual(inner.yMin, outer.yMin); + Assert.LessOrEqual(inner.xMax, outer.xMax + 0.01f); + Assert.LessOrEqual(inner.yMax, outer.yMax + 0.01f); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/DuelHudLayoutTests.cs.meta b/Assets/Tests/EditMode/DuelHudLayoutTests.cs.meta new file mode 100644 index 0000000..68cd844 --- /dev/null +++ b/Assets/Tests/EditMode/DuelHudLayoutTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a988cb64e8940e74ba60f6312d57f224 \ No newline at end of file diff --git a/Assets/Tests/EditMode/ExplorationNavigationTests.cs b/Assets/Tests/EditMode/ExplorationNavigationTests.cs new file mode 100644 index 0000000..725cda0 --- /dev/null +++ b/Assets/Tests/EditMode/ExplorationNavigationTests.cs @@ -0,0 +1,145 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using InsectGame.UI; +using InsectGame.Core; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + [TestFixture] + public class ExplorationNavigationTests + { + [TestCase(false)] + [TestCase(true)] + public void StopNavigation_DiscardsClickAndAutomaticDestination(bool automatic) + { + var host = new GameObject("Navigation reset fixture"); + try + { + var movement = host.AddComponent(); + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + typeof(PlayerMovement).GetField("movingToClick", flags).SetValue(movement, true); + typeof(PlayerMovement).GetField("autoRunning", flags).SetValue(movement, automatic); + movement.StopNavigation(); + Assert.IsFalse((bool)typeof(PlayerMovement).GetField("movingToClick", flags).GetValue(movement)); + Assert.IsFalse(movement.IsAutoRunning); + } + finally { Object.DestroyImmediate(host); } + } + [Test] + public void Movement_OpenMenu_CancelsLatchedMovementWithoutFreezingMenu() + { + var go = new GameObject("Modal movement test"); + var movement = go.AddComponent(); + var menu = go.AddComponent(); + try + { + typeof(QuickAccessBarUI).GetMethod("TryNavigate", BindingFlags.Instance | BindingFlags.NonPublic) + .Invoke(menu, new object[] { QuickAccessBarUI.Destination.Menu }); + foreach (string field in new[] { "movingToClick", "autoRunning", "joystickActive", "guiKeyW", "guiClickRequest" }) + typeof(PlayerMovement).GetField(field, BindingFlags.Instance | BindingFlags.NonPublic).SetValue(movement, true); + typeof(PlayerMovement).GetMethod("Update", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(movement, null); + foreach (string field in new[] { "movingToClick", "autoRunning", "joystickActive", "guiKeyW", "guiClickRequest" }) + Assert.IsFalse((bool)typeof(PlayerMovement).GetField(field, BindingFlags.Instance | BindingFlags.NonPublic).GetValue(movement)); + Assert.IsTrue(menu.IsOpen); + Assert.IsFalse(movement.IsFrozen); + } + finally { menu.CloseModal(); Object.DestroyImmediate(go); } + } + + [Test] + public void Shortcuts_Field_HasSevenPrimaryDestinations() + { + CollectionAssert.AreEqual(new[] { QuickAccessBarUI.Destination.Dex, QuickAccessBarUI.Destination.Collection, + QuickAccessBarUI.Destination.Team, QuickAccessBarUI.Destination.Inventory, + QuickAccessBarUI.Destination.Quest, QuickAccessBarUI.Destination.Map, + QuickAccessBarUI.Destination.Menu }, QuickAccessBarUI.Shortcuts); + } + + [TestCase(QuickAccessBarUI.Destination.Dex, KeyCode.N)] + [TestCase(QuickAccessBarUI.Destination.Team, KeyCode.T)] + [TestCase(QuickAccessBarUI.Destination.Training, KeyCode.G)] + [TestCase(QuickAccessBarUI.Destination.Collection, KeyCode.C)] + [TestCase(QuickAccessBarUI.Destination.Quest, KeyCode.Q)] + [TestCase(QuickAccessBarUI.Destination.Map, KeyCode.M)] + [TestCase(QuickAccessBarUI.Destination.Outfit, KeyCode.P)] + [TestCase(QuickAccessBarUI.Destination.Shop, KeyCode.F4)] + [TestCase(QuickAccessBarUI.Destination.Pvp, KeyCode.F6)] + [TestCase(QuickAccessBarUI.Destination.Story, KeyCode.J)] + [TestCase(QuickAccessBarUI.Destination.Inventory, KeyCode.I)] + [TestCase(QuickAccessBarUI.Destination.Badges, KeyCode.K)] + [TestCase(QuickAccessBarUI.Destination.Island, KeyCode.H)] + public void Hotkey_ExistingDestination_PreservesBinding(QuickAccessBarUI.Destination id, KeyCode expected) + { + Assert.AreEqual(expected, QuickAccessBarUI.GetHotkey(id)); + CollectionAssert.Contains(QuickAccessBarUI.Destinations, id); + } + + [Test] + public void Destinations_Menu_HasUniqueCompleteRoutes() + { + var ids = new HashSet(QuickAccessBarUI.Destinations); + Assert.AreEqual(14, ids.Count); + foreach (QuickAccessBarUI.Destination id in System.Enum.GetValues(typeof(QuickAccessBarUI.Destination))) + if (id != QuickAccessBarUI.Destination.Menu) Assert.IsTrue(ids.Contains(id)); + } + + [Test] + public void Navigation_DuplicateInputSameFrame_OpensMenuOnlyOnce() + { + var go = new GameObject("Navigation debounce test"); + var bar = go.AddComponent(); + MethodInfo navigate = typeof(QuickAccessBarUI).GetMethod("TryNavigate", BindingFlags.Instance | BindingFlags.NonPublic); + try + { + navigate.Invoke(bar, new object[] { QuickAccessBarUI.Destination.Menu }); + navigate.Invoke(bar, new object[] { QuickAccessBarUI.Destination.Menu }); + Assert.IsTrue(bar.IsOpen, "Update and OnGUI must not cancel each other."); + Assert.AreSame(bar, ModalUIRegistry.TopModal); + typeof(QuickAccessBarUI).GetMethod("Update", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(bar, null); + Assert.IsTrue(bar.IsOpen, "The menu registry must not count as a battle freeze."); + bar.enabled = false; + Assert.IsFalse(bar.IsOpen); + Assert.AreNotSame(bar, ModalUIRegistry.TopModal); + } + finally + { + bar.CloseModal(); + Object.DestroyImmediate(go); + } + } + + [Test] + public void Navigation_MenuToSettings_ReplacesModalAndBlocksUnrelatedModal() + { + var go = new GameObject("Navigation test"); + var bar = go.AddComponent(); + var settings = go.AddComponent(); + bar.AutoWire(settings); + MethodInfo navigate = typeof(QuickAccessBarUI).GetMethod("TryNavigate", BindingFlags.Instance | BindingFlags.NonPublic); + FieldInfo frame = typeof(QuickAccessBarUI).GetField("lastToggleFrame", BindingFlags.Instance | BindingFlags.NonPublic); + try + { + Assert.IsTrue((bool)navigate.Invoke(bar, new object[] { QuickAccessBarUI.Destination.Menu })); + Assert.AreSame(bar, ModalUIRegistry.TopModal); + frame.SetValue(bar, -1); + Assert.IsTrue((bool)navigate.Invoke(bar, new object[] { QuickAccessBarUI.Destination.Settings })); + Assert.IsFalse(bar.IsOpen); + Assert.IsTrue(settings.IsOpen); + Assert.AreSame(settings, ModalUIRegistry.TopModal); + frame.SetValue(bar, -1); + Assert.IsFalse((bool)navigate.Invoke(bar, new object[] { QuickAccessBarUI.Destination.Menu })); + Assert.IsFalse(bar.IsOpen); + } + finally + { + bar.CloseModal(); + settings.CloseModal(); + Object.DestroyImmediate(go); + } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/ExplorationNavigationTests.cs.meta b/Assets/Tests/EditMode/ExplorationNavigationTests.cs.meta new file mode 100644 index 0000000..332f7da --- /dev/null +++ b/Assets/Tests/EditMode/ExplorationNavigationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d0d385197daf85d4a9c2412ce4268b9e \ No newline at end of file diff --git a/Assets/Tests/EditMode/FieldFenceIntegrationTests.cs b/Assets/Tests/EditMode/FieldFenceIntegrationTests.cs new file mode 100644 index 0000000..bb17462 --- /dev/null +++ b/Assets/Tests/EditMode/FieldFenceIntegrationTests.cs @@ -0,0 +1,137 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using InsectGame.Core; +using InsectGame.Data; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace InsectGame.Tests +{ + /// + /// 실제 PlayScene에서 초원 울타리를 한 바퀴 돌며 걸어서 빠져나갈 수 있는 자리를 잰다. + /// + /// 초원은 목장 울타리(기둥 + 가로대 두 줄)로 둘러 그려지는데 가로대는 합친 메시라 콜라이더가 없었다 — + /// 눈에는 막힌 울타리인데 기둥 사이(약 7.7m) 어디로든 걸어 나갔다. 겹친 습지 쪽은 기둥이 빠져서 + /// 울타리 끝과 습지 잠금 원 사이에 실제 틈도 있었다(2026-10-02 기기 보고). + /// + /// 판정은 게임의 진짜 이동 차단(PlayerMovement.IsBlockedPosition)을 그대로 부른다. 다른 리전의 원 안은 + /// 잠긴 것으로 친다 — 이 PC의 PlayerPrefs에 해금 기록이 남아 있어도 "첫 지역만 열린 새 게임"을 재도록. + /// + [TestFixture] + public class FieldFenceIntegrationTests + { + private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + + /// 반지름 방향으로 훑는 구간(울타리 줄 기준 m)과 간격. + private const float ProbeInside = 3f, ProbeOutside = 3f, ProbeStep = 0.15f; + /// 각도 간격(도). 울타리 줄에서 약 0.26m — 플레이어 검사 구(반경 0.4m)보다 촘촘하다. + private const float AngleStep = 0.2f; + + [Test] + [Timeout(180000)] + public async Task Meadow_FenceRing_OpensOnlyAtTheGateway() + { + int previousSceneHandle = SceneManager.GetActiveScene().handle; + SceneManager.LoadScene(GameConstants.Scenes.Play, LoadSceneMode.Single); + await WaitForBootAsync(previousSceneHandle); + + RegionManager regionManager = Object.FindFirstObjectByType(); + PlayerMovement player = Object.FindFirstObjectByType(); + Assert.IsNotNull(regionManager); + Assert.IsNotNull(player); + RegionData[] regions = regionManager.Regions; + RegionData meadow = WorldRouteLayout.Find(regions, "meadow"); + Assert.IsNotNull(meadow); + + // 지형은 부트 중에 지어졌다 — 물리 쪽 좌표를 맞춘 뒤 잰다. + for (int i = 0; i < 5; i++) await Task.Yield(); + Physics.SyncTransforms(); + + MethodInfo isBlocked = typeof(PlayerMovement).GetMethod("IsBlockedPosition", Private); + Assert.IsNotNull(isBlocked, "IsBlockedPosition"); + var args = new object[1]; + + float fenceRadius = meadow.radius - 1f; + int steps = Mathf.RoundToInt(360f / AngleStep); + var open = new bool[steps]; + for (int s = 0; s < steps; s++) + { + float rad = s * AngleStep * Mathf.Deg2Rad; + Vector3 dir = new Vector3(Mathf.Cos(rad), 0f, Mathf.Sin(rad)); + bool blocked = false; + for (float r = fenceRadius - ProbeInside; r <= fenceRadius + ProbeOutside && !blocked; r += ProbeStep) + { + Vector3 p = meadow.centerPosition + dir * r; + p.y = FieldGround.SurfaceY(p.x, p.z); + if (InsideOtherRegion(regions, meadow, p)) { blocked = true; break; } + args[0] = p; + blocked = (bool)isBlocked.Invoke(player, args); + } + open[s] = !blocked; + } + + // 열린 각도를 구간으로 묶는다(0°를 넘는 구간은 이어 붙인다). + var spans = new List<(float from, float to)>(); + int start = -1; + for (int s = 0; s < steps; s++) + { + if (open[s] && start < 0) start = s; + if (!open[s] && start >= 0) { spans.Add((start * AngleStep, (s - 1) * AngleStep)); start = -1; } + } + if (start >= 0) spans.Add((start * AngleStep, (steps - 1) * AngleStep)); + if (spans.Count > 1 && open[0] && open[steps - 1]) + { + var first = spans[0]; + var last = spans[spans.Count - 1]; + spans.RemoveAt(spans.Count - 1); + spans[0] = (last.from - 360f, first.to); + } + + Vector3 gatewayDir = WorldRouteLayout.GetGateway(meadow, regions) - meadow.centerPosition; + float gatewayAngle = Mathf.Atan2(gatewayDir.z, gatewayDir.x) * Mathf.Rad2Deg; + + var report = new StringBuilder(); + report.Append($"[FENCE] meadow r={meadow.radius:F1} gateway={Mathf.Repeat(gatewayAngle, 360f):F1}° 열린 구간 {spans.Count}개"); + foreach (var span in spans) + { + float width = (span.to - span.from + AngleStep) * Mathf.Deg2Rad * fenceRadius; + report.Append($"\n[FENCE] {span.from:F1}°~{span.to:F1}° (폭 {width:F1}m)"); + } + Debug.Log(report.ToString()); + + Assert.AreEqual(1, spans.Count, "초원 울타리가 통로 말고도 열려 있다(또는 통로가 막혔다)\n" + report); + float mid = (spans[0].from + spans[0].to) * 0.5f; + Assert.Less(Mathf.Abs(Mathf.DeltaAngle(mid, gatewayAngle)), 8f, "열린 자리가 통로(길) 쪽이 아니다\n" + report); + float gateWidth = (spans[0].to - spans[0].from + AngleStep) * Mathf.Deg2Rad * fenceRadius; + Assert.GreaterOrEqual(gateWidth, 4f, "통로가 길 폭(2.5m)에 여유를 둔 것보다 좁다\n" + report); + } + + private static bool InsideOtherRegion(RegionData[] regions, RegionData self, Vector3 p) + { + foreach (RegionData other in regions) + if (other != null && other != self && other.ContainsPoint(p)) return true; + return false; + } + + private static async Task WaitForBootAsync(int previousSceneHandle) + { + const int maxFrames = 600; + for (int frame = 0; frame < maxFrames; frame++) + { + Scene scene = SceneManager.GetSceneByName(GameConstants.Scenes.Play); + PlaySceneBootstrap bootstrap = Object.FindFirstObjectByType(); + if (scene.IsValid() && scene.isLoaded && scene.handle != previousSceneHandle + && bootstrap != null && bootstrap.gameObject.scene == scene + && Object.FindFirstObjectByType() != null) + return; + await Task.Yield(); + } + Assert.Fail("PlayScene Bootstrap 초기화가 제한 시간 안에 끝나지 않았습니다."); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/FieldFenceIntegrationTests.cs.meta b/Assets/Tests/EditMode/FieldFenceIntegrationTests.cs.meta new file mode 100644 index 0000000..38c1e25 --- /dev/null +++ b/Assets/Tests/EditMode/FieldFenceIntegrationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b88bca404b233dc4f9180f2348db8013 \ No newline at end of file diff --git a/Assets/Tests/EditMode/FieldGroundPlacementTests.cs b/Assets/Tests/EditMode/FieldGroundPlacementTests.cs new file mode 100644 index 0000000..02e6992 --- /dev/null +++ b/Assets/Tests/EditMode/FieldGroundPlacementTests.cs @@ -0,0 +1,579 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using NUnit.Framework; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.NPC; +using InsectGame.Spawning; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 둔덕()·물(RegionDressingBuilder의 물 판정) 위에 서는 것들 — 곤충 스폰 자리 + /// (InsectSpawner.PickSpawnPosition)·곤충 도주/배회 높이(InsectEntity.GroundRise)·필드 아이템 + /// (CaptureItemSpawner.PickItemPosition)·NPC 발 높이(VillagerNpc.StandHeight)·서브에리어 방 안 스폰 + /// (InsectSpawner.PickContainedPosition)·플레이어 접지(PlayerMovement.GroundHeight·FollowGround). + /// 둔덕은 콜라이더가 없어 전부 좌표로 높이를 묻는다. + /// + [TestFixture] + public class FieldGroundPlacementTests + { + // 꼭대기 0.7(바닥 위 0.6) — 모래언덕 사구 높이. 구 중심이 바닥 아래라 가장자리가 완만하게 솟는다. + private static readonly Vector3 DuneCenter = new Vector3(500f, -0.5f, 500f); + private static readonly Vector3 DuneAxes = new Vector3(6f, 1.2f, 6f); + + // 재 더미(ms 2.4) — 구 중심이 바닥 위(0.432)라 가장자리에 0.33m 턱이 있다(RegionTerrainBuilder 잿불). + private static readonly Vector3 AshCenter = new Vector3(-500f, 0.432f, -500f); + private static readonly Vector3 AshAxes = new Vector3(2.16f, 0.72f, 2.16f); + + private const float FrameDt = 1f / 60f; + + [SetUp] + public void SetUp() + { + FieldGround.Clear(); + RegionDressingBuilder.ClearWater(); + } + + [TearDown] + public void TearDown() + { + FieldGround.Clear(); + RegionDressingBuilder.ClearWater(); + } + + private static RegionDressingBuilder.PondLakeLayout PondLake() + { + foreach (RegionData r in RegionDefinitions.CreateAll()) + { + if (r != null && r.regionId == "pond") + return RegionDressingBuilder.PondLake(r.centerPosition, r.radius); + } + Assert.Fail("pond 리전이 없다"); + return default; + } + + // ── 곤충 스폰 높이 ── + + [Test] + public void PickSpawnPosition_OnDune_StandsOnSurfaceY_NotAddedToCandidateY() + { + FieldGround.AddDome(DuneCenter, DuneAxes, 0f); + // 재배치된 스폰 포인트는 플레이어 y를 복사해 두고, 플레이어는 이제 둔덕 높이만큼 올라서 있다 — + // 후보 y에 둔덕 높이를 더하면 두 번 오른다. + Vector3 candidate = new Vector3(DuneCenter.x + 1f, 0.7f, DuneCenter.z - 0.5f); + + Vector3 p = InsectSpawner.PickSpawnPosition(() => candidate); + + Assert.AreEqual(FieldGround.SurfaceY(candidate.x, candidate.z), p.y, 1e-5f, "사구 윗면에 서지 않았다"); + Assert.Greater(p.y, FieldGround.FloorY + 0.5f, "사구 위에서 바닥 높이에 섰다 — 몸이 묻힌다"); + Assert.AreEqual(candidate.x, p.x, 1e-6f); + Assert.AreEqual(candidate.z, p.z, 1e-6f); + } + + [Test] + public void PickSpawnPosition_OffDune_StandsOnFloor() + { + FieldGround.AddDome(DuneCenter, DuneAxes, 0f); + // 리전 링 원위치 포인트는 y 0, 재배치 포인트는 플레이어 y — 평지에선 둘 다 바닥 높이로 모인다 + foreach (float candidateY in new[] { 0f, 0.09f, 0.58f }) + { + Vector3 candidate = new Vector3(DuneCenter.x + 30f, candidateY, DuneCenter.z); + Vector3 p = InsectSpawner.PickSpawnPosition(() => candidate); + Assert.AreEqual(FieldGround.FloorY, p.y, 1e-6f, $"후보 y {candidateY}"); + } + } + + // ── 물 위 스폰 거르기 — 호수(기록 전에도 안다) ── + + [Test] + public void PickSpawnPosition_WaterCandidate_IsRerolled() + { + RegionDressingBuilder.PondLakeLayout lake = PondLake(); + Vector3 wet = lake.water.center; + Vector3 dry = lake.water.center + new Vector3(200f, 0f, 0f); + Assert.IsTrue(InsectSpawner.IsOnWater(wet), "호수 중심이 물이 아니다"); + Assert.IsFalse(InsectSpawner.IsOnWater(dry)); + + int calls = 0; + Vector3 p = InsectSpawner.PickSpawnPosition(() => ++calls == 1 ? wet : dry); + + Assert.AreEqual(2, calls, "물 위 후보를 버리고 한 번 더 굴려야 한다"); + Assert.AreEqual(dry.x, p.x, 1e-4f); + Assert.AreEqual(dry.z, p.z, 1e-4f); + Assert.AreEqual(FieldGround.FloorY, p.y, 1e-6f); + } + + [Test] + public void PickSpawnPosition_AlwaysWater_PushedToShoreWithoutSkipping() + { + // 스폰 포인트 원이 통째로 물이어도 스폰을 건너뛰지 않는다 — 초기 채우기는 한 번 헛돌면 멈춘다 + RegionDressingBuilder.PondLakeLayout lake = PondLake(); + Vector3 center = lake.water.center; + Vector3 wet = center + new Vector3(3f, 0.3f, -4f); + int calls = 0; + + Vector3 p = InsectSpawner.PickSpawnPosition(() => { calls++; return wet; }); + + Assert.AreEqual(InsectSpawner.SpawnPositionRolls, calls, "재추첨 상한을 지키지 않았다"); + Assert.IsFalse(InsectSpawner.IsOnWater(p), "물가로 밀어냈는데 아직 물 위다"); + Vector3 outward = new Vector3(wet.x - center.x, 0f, wet.z - center.z).normalized; + Assert.IsTrue(InsectSpawner.IsOnWater(p - outward * 0.6f), "물가를 한참 지나쳐 풀밭 멀리 갔다"); + Vector3 moved = new Vector3(p.x - center.x, 0f, p.z - center.z).normalized; + Assert.Greater(Vector3.Dot(moved, outward), 0.999f, "스폰 포인트 쪽이 아닌 물가로 밀었다"); + Assert.AreEqual(FieldGround.FloorY, p.y, 1e-6f); + } + + [Test] + public void PushOutOfWater_AtLakeCenter_StillLeavesWater() + { + // 중심에서는 방향이 없다 — 무한 루프 없이 한쪽 물가로 나가야 한다 + RegionDressingBuilder.PondLakeLayout lake = PondLake(); + Vector3 p = InsectSpawner.PushOutOfWater(lake.water.center); + Assert.IsFalse(InsectSpawner.IsOnWater(p)); + } + + [Test] + public void IsOnWater_CountsShoreMarginAsWater() + { + // 물가 바로 밖(풀 더미가 수면 위로 걸치는 거리)도 물로 친다 + RegionDressingBuilder.PondLakeLayout lake = PondLake(); + for (int i = 0; i < 12; i++) + { + float a = i * Mathf.PI * 2f / 12f; + Vector3 edge = lake.water.EdgePoint(a, 1f); + Vector3 outward = new Vector3(edge.x - lake.water.center.x, 0f, edge.z - lake.water.center.z).normalized; + Assert.IsTrue(InsectSpawner.IsOnWater(edge - outward * 0.5f), $"물 안쪽 ({i})"); + Assert.IsTrue(InsectSpawner.IsOnWater(edge + outward * (InsectSpawner.WaterShoreMargin * 0.5f)), + $"물가 여유 안쪽 ({i})"); + Assert.IsFalse(InsectSpawner.IsOnWater(edge + outward * (InsectSpawner.WaterShoreMargin + 0.5f)), + $"물가 여유 밖인데 물로 쳤다 ({i})"); + } + } + + [Test] + public void PickSpawnPosition_DryFirstRoll_UsesFirstRoll() + { + int calls = 0; + Vector3 p = InsectSpawner.PickSpawnPosition(() => { calls++; return new Vector3(4f, 0f, 7f); }); + Assert.AreEqual(1, calls); + Assert.AreEqual(4f, p.x, 1e-6f); + Assert.AreEqual(7f, p.z, 1e-6f); + } + + // ── 물 판정 — 빌드가 기록한 물(나루터 물가·습지 웅덩이·강·원기둥 물) ── + + private static readonly Vector3 PuddleCenter = new Vector3(-300f, FieldGround.FloorY, 260f); + + /// 습지 웅덩이 한 장 — RegionDressingBuilder.Swamp가 b.Add(patchMesh …, new Vector3(s, 1f, s * 0.85f), pool)로 그리는 모양. + private static RegionDressingBuilder.DiscPlacement Puddle(Vector3 center, float s, float yaw) + => new RegionDressingBuilder.DiscPlacement(center, s, s * 0.85f, yaw, RegionDressingBuilder.PatchSegments); + + [Test] + public void IsOnWater_BeforeRecord_KnowsOnlyTheLake() + { + // 빌드가 물을 다 적기 전엔 호수(리전 데이터의 순수 함수)만 안다 — 반쯤 적힌 웅덩이는 아직 없는 것으로 친다 + RegionDressingBuilder.AddWaterDisc(Puddle(PuddleCenter, 5f, 20f)); + Assert.IsFalse(RegionDressingBuilder.WaterRecorded); + Assert.IsFalse(InsectSpawner.IsOnWater(PuddleCenter), "기록이 끝나기 전 웅덩이를 물로 쳤다"); + Assert.IsTrue(InsectSpawner.IsOnWater(PondLake().water.center), "기록 전인데 호수를 모른다"); + } + + [Test] + public void IsOnWater_AfterRecord_SeesPuddlesRiverAndCylinders_AndOnlyWhatWasRecorded() + { + var puddle = Puddle(PuddleCenter, 5f, 20f); + RegionDressingBuilder.AddWaterDisc(puddle); + Vector3 river = new Vector3(40f, 0.14f, -600f); + RegionDressingBuilder.AddWaterStrip(river, new Vector3(1f, 0f, 1f), 13f, 2.5f); // 26×5m, 45° 기울어짐 + Vector3 barrel = new Vector3(-700f, 0.02f, 80f); + RegionDressingBuilder.AddWaterCircle(barrel, 1.5f); + RegionDressingBuilder.MarkWaterRecorded(); + Assert.AreEqual(3, RegionDressingBuilder.WaterShapeCount); + + float m = InsectSpawner.WaterShoreMargin; + // 웅덩이 — 윤곽 + 물가 여유 + for (int i = 0; i < 8; i++) + { + float a = i * Mathf.PI * 0.25f; + Vector3 edge = puddle.EdgePoint(a, 1f); + Vector3 outward = new Vector3(edge.x - puddle.center.x, 0f, edge.z - puddle.center.z).normalized; + Assert.IsTrue(InsectSpawner.IsOnWater(edge + outward * (m * 0.5f)), $"웅덩이 물가 여유 안 ({i})"); + Assert.IsFalse(InsectSpawner.IsOnWater(edge + outward * (m + 0.3f)), $"웅덩이 물가 여유 밖 ({i})"); + } + // 강 — 길이 방향 끝과 옆 둑 + Vector3 along = new Vector3(1f, 0f, 1f).normalized, across = new Vector3(along.z, 0f, -along.x); + Assert.IsTrue(InsectSpawner.IsOnWater(river + along * 12f)); + Assert.IsTrue(InsectSpawner.IsOnWater(river + across * (2.5f + m * 0.5f))); + Assert.IsFalse(InsectSpawner.IsOnWater(river + across * (2.5f + m + 0.3f))); + Assert.IsFalse(InsectSpawner.IsOnWater(river + along * (13f + m + 0.3f))); + // 원기둥 물(부트스트랩 Pond_Water·Swamp_Puddle_* 등) + Assert.IsTrue(InsectSpawner.IsOnWater(barrel + Vector3.right * (1.5f + m * 0.5f))); + Assert.IsFalse(InsectSpawner.IsOnWater(barrel + Vector3.right * (1.5f + m + 0.3f))); + // 기록 뒤에는 기록만 본다 — 호수는 빌드(Pond)가 스스로 적는다. 여기선 안 적었으니 물이 아니다. + Assert.IsFalse(InsectSpawner.IsOnWater(PondLake().water.center)); + } + + [Test] + public void PickSpawnPosition_RecordedPuddle_IsRerolled() + { + RegionDressingBuilder.AddWaterDisc(Puddle(PuddleCenter, 5f, 20f)); + RegionDressingBuilder.MarkWaterRecorded(); + Vector3 dry = PuddleCenter + new Vector3(0f, 0f, 12f); + + int calls = 0; + Vector3 p = InsectSpawner.PickSpawnPosition(() => ++calls == 1 ? PuddleCenter : dry); + + Assert.AreEqual(2, calls, "습지 웅덩이 위 후보를 버리지 않았다"); + Assert.AreEqual(dry.z, p.z, 1e-5f); + } + + [Test] + public void PushOutOfWater_OverlappingPuddles_KeepsOneDirectionAndLeavesBoth() + { + // 겹친 두 웅덩이 사이에서 도형마다 방향을 새로 잡으면 번갈아 밀리며 제자리를 돈다 — 처음 방향을 끝까지 지킨다 + Vector3 a = new Vector3(600f, 0f, -300f), b = a + new Vector3(3f, 0f, 0f); + RegionDressingBuilder.AddWaterCircle(a, 2f); + RegionDressingBuilder.AddWaterCircle(b, 2f); + RegionDressingBuilder.MarkWaterRecorded(); + Vector3 start = a + new Vector3(0.5f, 0f, 0f); // a 안(겹친 쪽) — a 중심에서 +x 방향 + + Vector3 p = InsectSpawner.PushOutOfWater(start); + + Assert.IsFalse(InsectSpawner.IsOnWater(p), "겹친 웅덩이 밖으로 못 나갔다"); + Assert.AreEqual(start.z, p.z, 1e-5f, "처음 방향(+x)을 벗어났다"); + Assert.Greater(p.x, b.x + 2f, "b를 건너 반대편 물가로 나가야 한다"); + } + + [Test] + public void PushOutOfWater_River_ExitsTowardNearBank() + { + Vector3 river = new Vector3(-40f, 0f, 700f); + RegionDressingBuilder.AddWaterStrip(river, Vector3.right, 13f, 2.5f); + RegionDressingBuilder.MarkWaterRecorded(); + Vector3 start = river + new Vector3(4f, 0f, -1f); // 남쪽 둑에 가까운 물 위 + + Vector3 p = InsectSpawner.PushOutOfWater(start); + + Assert.IsFalse(InsectSpawner.IsOnWater(p)); + Assert.AreEqual(start.x, p.x, 1e-5f, "강을 따라 흘러갔다 — 둑 쪽 수직으로 나가야 한다"); + Assert.Less(p.z, river.z - 2.5f, "먼 둑으로 건너갔다"); + } + + [Test] + public void RecordWaterPatch_MirrorsEveryDrawnWaterPatch() + { + // 나루터 물가·습지 웅덩이는 난수·전초기지 자리로 정해져 빌드가 그린 자리를 그 자리에서 적는다. + // 적는 인자가 그린 인자와 어긋나면 물 판정이 조용히 옛 모양을 본다 — 소스에서 짝을 고정한다. + string src = ReadSource("Assets/Scripts/Core/RegionDressingBuilder.cs"); + MatchCollection drawn = Regex.Matches(src, + @"b\.Add\(patchMesh, (\w+) \+ Vector3\.up \* [\d.]+f, (.+?), (new Vector3\([^)]*\)), (shallow|deep|pool)\);"); + Assert.GreaterOrEqual(drawn.Count, 3, "물 원반(나루터 여울·깊은 물, 습지 웅덩이)을 못 찾았다 — 이 검사가 무의미해졌다"); + foreach (Match m in drawn) + { + string record = $"RecordWaterPatch({m.Groups[1].Value}, {m.Groups[2].Value}, {m.Groups[3].Value});"; + StringAssert.Contains(record, src, $"그린 물({m.Groups[4].Value})을 적지 않았거나 인자가 다르다: {m.Value}"); + } + StringAssert.Contains("AddWaterDisc(lake.water);", src, "호수를 적지 않는다 — 기록 뒤 판정에서 호수가 빠진다"); + StringAssert.Contains("RecordSceneWater();", src, "다른 빌더의 물(강·원기둥 물)을 읽지 않는다"); + } + + // ── 곤충 도주·배회 높이 ── + + [Test] + public void GroundRise_FollowsMoundsFromTheSpawnSpot() + { + FieldGround.AddDome(DuneCenter, DuneAxes, 0f); + float top = FieldGround.SurfaceY(DuneCenter.x, DuneCenter.z); // 0.7 + + // 평지에서 스폰해 사구로 도망 — 사구 높이만큼 오른다(옛 도주는 basePosition.y 고정이라 사구 속을 지났다) + float flatBase = FieldGround.SurfaceY(DuneCenter.x + 30f, DuneCenter.z); + Assert.AreEqual(0f, InsectEntity.GroundRise(flatBase, DuneCenter.x + 30f, DuneCenter.z), 1e-6f); + Assert.AreEqual(top - FieldGround.FloorY, InsectEntity.GroundRise(flatBase, DuneCenter.x, DuneCenter.z), 1e-5f); + + // 사구 꼭대기에서 스폰해 평지로 — 내려온다(옛 도주는 사구 높이에 뜬 채 갔다) + Assert.AreEqual(FieldGround.FloorY - top, InsectEntity.GroundRise(top, DuneCenter.x + 30f, DuneCenter.z), 1e-5f); + + // 서브에리어 — (2000,·,2000) 너머엔 둔덕이 없다 + float subBase = FieldGround.SurfaceY(2000f, 1992f); + Assert.AreEqual(0f, InsectEntity.GroundRise(subBase, 2006f, 1985f), 1e-6f); + } + + [Test] + public void GroundRise_FleeAcrossAshMound_NeverBuriesTheBody() + { + // 기어다니는 도주(발 위 0.05~0.12)를 재 더미(턱 0.33m) 가로질러 한 프레임씩 — 몸 바닥이 지면 아래로 안 들어간다 + FieldGround.AddDome(AshCenter, AshAxes, 0f); + Vector3 start = AshCenter + new Vector3(-5f, 0f, 0f); + // 스폰 자리 = 그 자리 지면(InsectSpawner.PickSpawnPosition) — basePosition.y와 baseSurfaceY가 같다 + Vector3 spawn = InsectSpawner.PickSpawnPosition(() => start); + float baseSurface = FieldGround.SurfaceY(spawn.x, spawn.z); + float deepestOld = 0f; + for (float x = start.x; x <= AshCenter.x + 5f; x += 6f * FrameDt) + { + float ground = FieldGround.SurfaceY(x, start.z); + float y = spawn.y + InsectEntity.GroundRise(baseSurface, x, start.z) + 0.05f; + Assert.AreEqual(ground + 0.05f, y, 1e-5f, $"x {x - AshCenter.x:F2}: 지면 위 0.05에 있지 않다"); + deepestOld = Mathf.Max(deepestOld, ground - (spawn.y + 0.05f)); // 옛 식: basePosition.y 고정 + } + Assert.Greater(deepestOld, 0.9f, "시나리오가 옛 결함(재 더미 속을 지나감)을 재현하지 못한다"); + } + + // ── 필드 아이템 ── + + [Test] + public void PickItemPosition_HoversAboveSurface_AndSkipsWater() + { + FieldGround.AddDome(AshCenter, AshAxes, 0f); + // 평지 — 옛 절대 높이 0.8 그대로 + Vector3 flat = CaptureItemSpawner.PickItemPosition(() => new Vector3(AshCenter.x + 20f, 0f, AshCenter.z)); + Assert.AreEqual(0.8f, flat.y, 1e-5f, "평지 높이가 옛 값(0.8)에서 바뀌었다"); + // 재 더미 꼭대기(바닥 위 1.05) — 옛 0.8은 그 속에 묻혔다 + Vector3 onAsh = CaptureItemSpawner.PickItemPosition(() => new Vector3(AshCenter.x, 0f, AshCenter.z)); + Assert.AreEqual(FieldGround.SurfaceY(AshCenter.x, AshCenter.z) + CaptureItemSpawner.HoverHeight, onAsh.y, 1e-5f); + Assert.Greater(onAsh.y, FieldGround.SurfaceY(AshCenter.x, AshCenter.z) + 0.5f, "재 더미에 묻혔다"); + + // 호수 위 후보는 곤충과 같은 규칙으로 버린다 + RegionDressingBuilder.PondLakeLayout lake = PondLake(); + Vector3 dry = lake.water.center + new Vector3(200f, 0f, 0f); + int calls = 0; + Vector3 p = CaptureItemSpawner.PickItemPosition(() => ++calls == 1 ? lake.water.center : dry); + Assert.AreEqual(2, calls, "호수 위에 아이템을 놓았다"); + Assert.AreEqual(dry.x, p.x, 1e-4f); + } + + // ── NPC 발 높이 ── + + [Test] + public void StandHeight_WithoutMounds_KeepsTheRayHeight() + { + // 마을·서브에리어·평지 — 둔덕이 없는 곳에선 옛 높이(레이가 맞은 콜라이더 윗면)에서 한 치도 안 바뀐다 + FieldGround.AddDome(DuneCenter, DuneAxes, 0f); // 멀리 있는 둔덕은 상관없다 + foreach (float c in new[] { 0f, 0.08f, 0.093f, 0.1f, 0.3f, 2.8f }) + { + Assert.AreEqual(c, VillagerNpc.StandHeight(c, 0f, 0f), 1e-6f, $"평지 콜라이더 {c}"); + Assert.AreEqual(c, VillagerNpc.StandHeight(c, 2003f, 1994f), 1e-6f, $"서브에리어 콜라이더 {c}"); + } + } + + [Test] + public void StandHeight_OnDune_FollowsTheSurface_WithoutDoubleLift() + { + FieldGround.AddDome(DuneCenter, DuneAxes, 0f); + float lift = FieldGround.LiftAt(DuneCenter.x, DuneCenter.z); // 0.6 + float x = DuneCenter.x, z = DuneCenter.z; + + // 리전 평면(0.08) 위 — 평면 + 둔덕 높이 + Assert.AreEqual(RegionPlaneY + lift, VillagerNpc.StandHeight(RegionPlaneY, x, z), 1e-5f); + // 사구 속에 묻힌 바위(0.45) — 둔덕 윗면이 이긴다 + Assert.AreEqual(FieldGround.SurfaceY(x, z), VillagerNpc.StandHeight(0.45f, x, z), 1e-5f); + // 사구 위로 삐져나온 바위(0.9) — 바위 윗면. 둔덕 높이를 한 번 더 얹지 않는다(0.9 + 0.6으로 뜨지 않는다) + Assert.AreEqual(0.9f, VillagerNpc.StandHeight(0.9f, x, z), 1e-6f); + } + + [Test] + public void StandHeight_KidWalkingOverDune_ClimbsAndComesBackDown() + { + // 잡기 아이가 사구 위 곤충을 쫓아 가로지른다 — 레이(groundY)는 늘 리전 평면만 맞힌다 + FieldGround.AddDome(DuneCenter, DuneAxes, 0f); + float peak = float.MinValue, last = 0f; + for (float x = DuneCenter.x - 10f; x <= DuneCenter.x + 10f; x += 3.5f * FrameDt) + { + last = VillagerNpc.StandHeight(RegionPlaneY, x, DuneCenter.z); + peak = Mathf.Max(peak, last); + } + Assert.AreEqual(RegionPlaneY + (FieldGround.SurfaceY(DuneCenter.x, DuneCenter.z) - FieldGround.FloorY), peak, 0.01f, + "사구 꼭대기에 오르지 못했다"); + Assert.AreEqual(RegionPlaneY, last, 1e-6f, "사구를 지난 뒤 옛 높이로 내려오지 않았다"); + } + + // ── 서브에리어 방 안 스폰 ── + + // 가장 작은 방(반쪽 9m, 벽 두께 1m → 안쪽 면 8.5m) — 입구는 방 로컬 z −8 + private static readonly Vector3 RoomCenter = new Vector3(2000f, 0f, 2000f); + private const float RoomInner = 8.5f; + + private static bool InsideRoom(Vector3 p) + => Mathf.Abs(p.x - RoomCenter.x) < RoomInner && Mathf.Abs(p.z - RoomCenter.z) < RoomInner; + + [Test] + public void PickContainedPosition_RejectsCandidatesBeyondTheWall() + { + Vector3 anchor = RoomCenter + new Vector3(0f, 0.5f, -8f); + var rolls = new Queue(new[] + { + anchor + new Vector3(0f, 0f, -6f), // 남쪽 벽 너머(옛 스폰이 실제로 떨어지던 곳) + anchor + new Vector3(-9f, 0f, 3f), // 서쪽 벽 너머 + anchor + new Vector3(3f, 0f, 5f), // 방 안 + }); + int calls = 0; + + Vector3 p = InsectSpawner.PickContainedPosition(() => { calls++; return rolls.Dequeue(); }, anchor, InsideRoom, + InsectSpawner.SubAreaSpawnRolls); + + Assert.AreEqual(3, calls); + Assert.IsTrue(InsideRoom(p)); + Assert.AreEqual(anchor.x + 3f, p.x, 1e-5f); + Assert.AreEqual(anchor.z + 5f, p.z, 1e-5f); + } + + [Test] + public void PickContainedPosition_AllOutside_PullsTowardThePlayerWithoutSkipping() + { + // 굴린 자리가 전부 벽 너머여도 스폰을 건너뛰지 않는다 — 플레이어 쪽으로 반씩 당겨 들어오는 첫 자리 + Vector3 anchor = RoomCenter + new Vector3(0f, 0.5f, -5f); // z 1995 + Vector3 outside = new Vector3(anchor.x, 0.5f, anchor.z - 10f); // z 1985 — 남벽 안쪽 면(1991.5) 너머 + int calls = 0; + + Vector3 p = InsectSpawner.PickContainedPosition(() => { calls++; return outside; }, anchor, InsideRoom, + InsectSpawner.SubAreaSpawnRolls); + + Assert.AreEqual(InsectSpawner.SubAreaSpawnRolls, calls, "재추첨 상한을 지키지 않았다"); + Assert.IsTrue(InsideRoom(p), "당기고도 방 밖이다"); + Assert.AreEqual(anchor.z - 2.5f, p.z, 1e-5f, "1/2(1990)은 밖, 1/4(1992.5)이 첫 방 안 자리다"); + Assert.AreEqual(anchor.x, p.x, 1e-5f); + } + + [Test] + public void PickContainedPosition_NothingInside_FallsBackToThePlayerSpot() + { + Vector3 anchor = RoomCenter + new Vector3(0f, 0.5f, -8f); + Vector3 p = InsectSpawner.PickContainedPosition(() => anchor + new Vector3(0f, 0f, -30f), anchor, _ => false, + InsectSpawner.SubAreaSpawnRolls); + Assert.AreEqual(anchor.x, p.x, 1e-6f); + Assert.AreEqual(anchor.z, p.z, 1e-6f); + } + + // ── 플레이어 접지 ── + + // 리전 바닥 평면 콜라이더 윗면(PlaySceneBootstrap: 0.08 + 순번 × 0.001) + private const float RegionPlaneY = 0.08f; + + /// PlayerMovement.Update의 접지를 프레임마다 흉내 낸다 — 레이가 맞는 콜라이더는 늘 바닥 평면. + private static float WalkAcross(Vector3 domeCenter, float halfSpan, float speed, List trace) + { + float y = FieldGround.FloorY; + for (float x = domeCenter.x - halfSpan; x <= domeCenter.x + halfSpan; x += speed * FrameDt) + { + float ground = PlayerMovement.GroundHeight(RegionPlaneY, x, domeCenter.z, false); + y = PlayerMovement.FollowGround(y, ground, FrameDt); + trace.Add(y); + } + return y; + } + + [Test] + public void Grounding_WalkOverDune_ClimbsAndComesBackDown() + { + // 옛 접지 Max(pos.y, hit.y)는 올라가기만 했다 — 둔덕을 지나면 그 높이에 뜬 채 남았다 + FieldGround.AddDome(DuneCenter, DuneAxes, 0f); + var trace = new List(); + float end = WalkAcross(DuneCenter, 10f, 16f, trace); // 의상 배율 2배 최고 속도 + + float top = FieldGround.SurfaceY(DuneCenter.x, DuneCenter.z); + float peak = Mathf.Max(trace.ToArray()); + Assert.AreEqual(top, peak, 0.03f, "사구 꼭대기에 오르지 못했다"); + Assert.AreEqual(FieldGround.FloorY, end, 1e-5f, "사구를 지난 뒤 바닥으로 내려오지 않았다"); + } + + [Test] + public void Grounding_AshMoundRimStep_IsSpreadOverFrames() + { + // 재 더미 가장자리 0.33m 턱 — 한 프레임에 몸·카메라 시선이 튀지 않게 속도 상한으로 나눈다 + FieldGround.AddDome(AshCenter, AshAxes, 0f); + var trace = new List(); + float end = WalkAcross(AshCenter, 5f, 8f, trace); + + float maxStep = 0f; + for (int i = 1; i < trace.Count; i++) maxStep = Mathf.Max(maxStep, Mathf.Abs(trace[i] - trace[i - 1])); + Assert.LessOrEqual(maxStep, PlayerMovement.GroundFollowSpeed * FrameDt + 1e-4f, "턱에서 한 프레임에 튄다"); + Assert.Greater(Mathf.Max(trace.ToArray()), FieldGround.FloorY + 0.8f, "재 더미 위로 오르지 못했다"); + Assert.AreEqual(FieldGround.FloorY, end, 1e-5f, "재 더미를 지난 뒤 내려오지 않았다"); + } + + [Test] + public void Grounding_OverLowRoundRock_ComesBackDown() + { + // 물가 바위(Pond_ShoreRock_, rs 0.9): 구 콜라이더 반경 0.6rs = 0.54, 중심 y 0.2rs = 0.18 → 꼭대기 0.72. + // 몸통 검사(발 위 1.0~1.8m)엔 안 걸리고, 접지 레이(발 위 한 걸음)가 가장자리부터 차례로 맞혀 타고 오른다. + // 옛 식 Max(pos.y, hit.y)는 거기서 내려오지 않아 바위를 지난 뒤에도 0.72에 떠 있었다. + const float radius = 0.54f, centerY = 0.18f; + float newY = FieldGround.FloorY, oldY = FieldGround.FloorY; + float peak = 0f; + for (float x = -3f; x <= 3f; x += 8f * FrameDt) + { + float rockTop = Mathf.Abs(x) < radius ? centerY + Mathf.Sqrt(radius * radius - x * x) : float.NegativeInfinity; + // 레이는 발 위 한 걸음에서 출발한다 — 그보다 높은 윗면은 레이가 콜라이더 안에서 출발해 못 본다 + float hitNew = rockTop <= newY + PlayerMovement.StepHeight ? Mathf.Max(rockTop, RegionPlaneY) : RegionPlaneY; + float hitOld = rockTop <= oldY + PlayerMovement.StepHeight ? Mathf.Max(rockTop, RegionPlaneY) : RegionPlaneY; + + newY = PlayerMovement.FollowGround(newY, PlayerMovement.GroundHeight(hitNew, x, 0f, false), FrameDt); + oldY = Mathf.Max(oldY, hitOld); + peak = Mathf.Max(peak, newY); + } + + Assert.Greater(oldY, 0.6f, "시나리오가 옛 결함(바위 높이에 뜬 채 남음)을 재현하지 못한다"); + Assert.Greater(peak, FieldGround.FloorY + 0.3f, "바위를 타고 오르지 못했다 — 한 걸음 높이 안의 콜라이더는 밟는다"); + Assert.AreEqual(FieldGround.FloorY, newY, 1e-5f, "바위를 지난 뒤 바닥으로 내려오지 않았다"); + } + + [Test] + public void GroundHeight_StandingOnDune_MatchesInsectSpawnHeight() + { + // 같은 자리에서 플레이어 발과 곤충이 같은 지면을 본다(둘 다 FieldGround) + FieldGround.AddDome(DuneCenter, DuneAxes, 0f); + Vector3 spot = new Vector3(DuneCenter.x - 2f, 0f, DuneCenter.z + 1f); + float playerY = PlayerMovement.GroundHeight(RegionPlaneY, spot.x, spot.z, false); + Vector3 insect = InsectSpawner.PickSpawnPosition(() => spot); + Assert.AreEqual(insect.y, playerY, 1e-5f); + Assert.Greater(playerY, FieldGround.FloorY + 0.4f); + } + + [Test] + public void GroundHeight_CollidersAndMoundsCompareInWorldFrame() + { + FieldGround.AddDome(DuneCenter, DuneAxes, 0f); + float onDune = FieldGround.SurfaceY(DuneCenter.x, DuneCenter.z); // 0.7 + + // 평지 — 리전 평면(0.08)보다 FieldGround 바닥(0.1)이 높다: 옛 시작 높이 그대로 + Assert.AreEqual(FieldGround.FloorY, PlayerMovement.GroundHeight(RegionPlaneY, 0f, 0f, false), 1e-6f); + // 콜라이더를 못 맞혔을 때(음의 무한대)도 둔덕·바닥은 남는다 + Assert.AreEqual(onDune, PlayerMovement.GroundHeight(float.NegativeInfinity, DuneCenter.x, DuneCenter.z, false), 1e-6f); + // 평지 위 낮은 콜라이더(0.3)는 밟는다 + Assert.AreEqual(0.3f, PlayerMovement.GroundHeight(0.3f, 0f, 0f, false), 1e-6f); + // 사구 속에 묻힌 바위 윗면(0.45)은 사구 윗면이 이긴다 — 둔덕 높이를 한 번 더 얹어 뜨지 않는다 + Assert.AreEqual(onDune, PlayerMovement.GroundHeight(0.45f, DuneCenter.x, DuneCenter.z, false), 1e-6f); + // 사구 위로 삐져나온 바위(0.9)는 밟는다 + Assert.AreEqual(0.9f, PlayerMovement.GroundHeight(0.9f, DuneCenter.x, DuneCenter.z, false), 1e-6f); + // 서브에리어 — 둔덕도 FieldGround 바닥(0.1)도 없다. 바닥 y=0에 선다(예전엔 입구 텔레포트 y 0.5에 뜬 채 남았다) + Assert.AreEqual(0f, PlayerMovement.GroundHeight(0f, 2000f, 1992f, true), 1e-6f); + } + + [Test] + public void FollowGround_LimitsRateBothWaysAndConverges() + { + // 재 더미 가장자리 턱(0.33m)을 한 프레임에 넘지 않는다 + float y = PlayerMovement.FollowGround(0.1f, 0.43f, FrameDt); + Assert.Less(y, 0.43f, "턱을 한 프레임에 넘었다"); + for (int i = 0; i < 10; i++) y = PlayerMovement.FollowGround(y, 0.43f, FrameDt); + Assert.AreEqual(0.43f, y, 1e-6f, "오르막 목표에 수렴하지 않는다"); + + // 내리막도 따라간다(옛 접지는 여기서 멈췄다) + for (int i = 0; i < 10; i++) y = PlayerMovement.FollowGround(y, 0.1f, FrameDt); + Assert.AreEqual(0.1f, y, 1e-6f, "내리막을 따라 내려오지 않는다"); + + Assert.AreEqual(0.43f, PlayerMovement.FollowGround(0.43f, 0.1f, 0f), 1e-6f, "일시정지(dt 0)에 움직였다"); + } + + private static string ReadSource(string relativePath) + { + string full = Path.Combine(Application.dataPath, "..", relativePath); + Assert.IsTrue(File.Exists(full), $"소스를 못 찾음: {relativePath}"); + return File.ReadAllText(full); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/FieldGroundPlacementTests.cs.meta b/Assets/Tests/EditMode/FieldGroundPlacementTests.cs.meta new file mode 100644 index 0000000..f1a9096 --- /dev/null +++ b/Assets/Tests/EditMode/FieldGroundPlacementTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5551ae719182c9b4fa983f72101556a8 \ No newline at end of file diff --git a/Assets/Tests/EditMode/FieldMomentTests.cs b/Assets/Tests/EditMode/FieldMomentTests.cs new file mode 100644 index 0000000..e26ec8d --- /dev/null +++ b/Assets/Tests/EditMode/FieldMomentTests.cs @@ -0,0 +1,202 @@ +#if UNITY_EDITOR +using InsectGame.Core; +using InsectGame.Spawning; +using InsectGame.Story; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 도입부의 "터지는 순간" 장치들 — 소식 대기열, 라온과의 내기, 첫 색다른 조우, 이야기 보상 문구. + /// 화면(IMGUI)은 테스트로 못 보므로 규칙만 고정한다. + /// + [TestFixture] + public class FieldMomentTests + { + // ── 소식 대기열 ── + + private static FieldMomentFeed NewFeed(out GameObject host) + { + host = new GameObject("FieldMomentTests"); + return host.AddComponent(); + } + + [Test] + public void Feed_Dequeues_InTheOrderPushed() + { + FieldMomentFeed feed = NewFeed(out GameObject host); + try + { + feed.Push(FieldMomentKind.Reward, "하나", "a"); + feed.Push(FieldMomentKind.Rival, "둘"); + + Assert.IsTrue(feed.TryDequeue(out FieldMoment first)); + Assert.AreEqual("하나", first.Title); + Assert.AreEqual("a", first.Body); + Assert.IsTrue(feed.TryDequeue(out FieldMoment second)); + Assert.AreEqual(FieldMomentKind.Rival, second.Kind); + Assert.AreEqual(string.Empty, second.Body, "본문이 없으면 빈 문자열 — 화면이 null을 그리지 않는다"); + Assert.IsFalse(feed.TryDequeue(out _)); + } + finally { Object.DestroyImmediate(host); } + } + + [Test] + public void Feed_Overflow_DropsTheOldest() + { + // 대사·영상에 오래 가려진 뒤 한참 지난 소식이 줄줄이 뜨면 안 된다. + FieldMomentFeed feed = NewFeed(out GameObject host); + try + { + for (int i = 0; i < FieldMomentFeed.MaxPending + 3; i++) + feed.Push(FieldMomentKind.Reward, "소식 " + i); + + Assert.AreEqual(FieldMomentFeed.MaxPending, feed.PendingCount); + Assert.IsTrue(feed.TryDequeue(out FieldMoment oldest)); + Assert.AreEqual("소식 3", oldest.Title); + } + finally { Object.DestroyImmediate(host); } + } + + [Test] + public void Feed_EmptyTitle_IsIgnored() + { + FieldMomentFeed feed = NewFeed(out GameObject host); + try + { + feed.Push(FieldMomentKind.Reward, string.Empty, "본문만"); + feed.Push(FieldMomentKind.Reward, null); + Assert.AreEqual(0, feed.PendingCount); + } + finally { Object.DestroyImmediate(host); } + } + + [Test] + public void Feed_ItemName_FallsBackToId_WhenDatabaseIsMissing() + { + FieldMomentFeed feed = NewFeed(out GameObject host); + try + { + Assert.AreEqual("net_silver", feed.ItemName("net_silver")); + Assert.AreEqual(string.Empty, feed.ItemName(null)); + } + finally { Object.DestroyImmediate(host); } + } + + // ── 라온과의 내기 ── + + [TestCase(0f, 0)] + [TestCase(24.9f, 0)] + [TestCase(25f, 1)] + [TestCase(54.9f, 1)] + [TestCase(55f, 2)] + [TestCase(89.9f, 2)] + [TestCase(90f, 3)] + [TestCase(600f, 3)] + public void RivalCountAt_FollowsTheSchedule(float fieldSeconds, int expected) + { + Assert.AreEqual(expected, RivalRaceRules.RivalCountAt(fieldSeconds)); + } + + [Test] + public void RivalRace_IsWinnableAtAnUnhurriedPace() + { + // 곤충 한 마리에 필드 시간 25초를 써도(찾아 걷는 시간만 센다) 세 번째를 잡을 때 라온은 아직 둘이다. + // 이 여유가 사라지면 처음 하는 사람이 첫 내기를 거의 반드시 진다. + float thirdCaptureAt = 25f * RivalRaceRules.Target; + Assert.Less(RivalRaceRules.RivalCountAt(thirdCaptureAt - 0.1f), RivalRaceRules.Target); + } + + [Test] + public void IsDecided_PlayerReachingTarget_Wins_EvenOnATie() + { + Assert.IsTrue(RivalRaceRules.IsDecided(RivalRaceRules.Target, RivalRaceRules.Target, out bool won)); + Assert.IsTrue(won, "같은 순간이면 직접 잡은 쪽이 이긴다"); + } + + [Test] + public void IsDecided_RivalReachingTargetFirst_PlayerLoses() + { + Assert.IsTrue(RivalRaceRules.IsDecided(2, RivalRaceRules.Target, out bool won)); + Assert.IsFalse(won); + } + + [Test] + public void IsDecided_NeitherAtTarget_IsOpen() + { + Assert.IsFalse(RivalRaceRules.IsDecided(2, 2, out _)); + } + + [Test] + public void RivalRace_WinningPaysMoreThanLosing_AndNeverDiamonds() + { + // 다이아는 보상으로 줄 수 없다(서버 규칙 — rules/island.md). 보상은 캔디와 채집망뿐이다. + Assert.Greater(RivalRaceRules.WinCandy, RivalRaceRules.LoseCandy); + Assert.Greater(RivalRaceRules.LoseCandy, 0, "져도 빈손은 아니다"); + StringAssert.StartsWith("net_", RivalRaceRules.WinItemId); + } + + [Test] + public void RivalRace_StartBeat_ExistsInTheStory() + { + // 내기를 여는 비트 ID가 저작 데이터에서 사라지면 내기가 영영 안 열린다(예외도 경고도 없다). + Assert.IsTrue(StoryService.TryGetBeat(RivalRaceController.StartBeatId, out _)); + } + + // ── 첫 색다른 조우 ── + + [TestCase("q_capture3", 2, true)] + [TestCase("q_capture3", 3, true)] + [TestCase("q_capture3", 1, false)] + [TestCase("q_approach", 5, false)] + [TestCase("q_battle", 2, false)] + [TestCase(null, 2, false)] + public void FirstShiny_TriggersOnlyBeforeTheThirdCapture(string activeQuestId, int progress, bool expected) + { + Assert.AreEqual(expected, FirstShinyRules.ShouldTrigger(activeQuestId, progress)); + } + + // ── 이야기 보상 문구 ── + + [Test] + public void StoryRewardText_ListsEverythingGranted_WithTheActualInsectName() + { + var reward = new StoryReward + { + rewardCandy = 5, rewardItemId = "net_silver", rewardItemCount = 2, + rewardInsectId = "longhorn_beetle", rewardInsectDisplayName = "첫 파트너", rewardInsectLevel = 6 + }; + + // 첫 파트너는 고른 종으로 바뀐다 — 데이터의 이름("첫 파트너")이 아니라 실제로 준 이름이 떠야 한다. + string text = StoryRewardText.Format(reward, id => id == "net_silver" ? "은빛 채집망" : null, "호수 잠자리"); + Assert.AreEqual("캔디 +5 · 은빛 채집망 ×2 · 호수 잠자리 Lv.6", text); + } + + [Test] + public void StoryRewardText_NothingGranted_IsEmpty() + { + Assert.AreEqual(string.Empty, StoryRewardText.Format(new StoryReward(), null, null)); + Assert.AreEqual(string.Empty, StoryRewardText.Format(null, null, null)); + } + + [Test] + public void StoryRewardText_ItemWithZeroCount_AndInsectThatWasNotGranted_AreLeftOut() + { + // 지급부와 같은 조건이어야 한다 — 수량 0인 아이템·컬렉션이 없어 못 준 곤충은 "받았다"고 뜨면 안 된다. + var reward = new StoryReward + { + rewardExp = 15, rewardItemId = "net_gold", rewardItemCount = 0, rewardInsectId = "stag_beetle" + }; + Assert.AreEqual("경험치 +15", StoryRewardText.Format(reward, null, null)); + } + + [Test] + public void StoryRewardText_UnknownItemName_FallsBackToId() + { + var reward = new StoryReward { rewardItemId = "mystery_box", rewardItemCount = 1 }; + Assert.AreEqual("mystery_box ×1", StoryRewardText.Format(reward, id => null, null)); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/FieldMomentTests.cs.meta b/Assets/Tests/EditMode/FieldMomentTests.cs.meta new file mode 100644 index 0000000..e5c454a --- /dev/null +++ b/Assets/Tests/EditMode/FieldMomentTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fe105a6f3ad06904f97d5a927d119826 \ No newline at end of file diff --git a/Assets/Tests/EditMode/FieldPopulationTests.cs b/Assets/Tests/EditMode/FieldPopulationTests.cs new file mode 100644 index 0000000..c5b0784 --- /dev/null +++ b/Assets/Tests/EditMode/FieldPopulationTests.cs @@ -0,0 +1,357 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Spawning; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 필드 개체군 기록부() — 기록이 곧 개체라는 약속을 고정한다. + /// + /// 사라지는 방식이 둘이다: ①게임플레이 퇴장(포획·전투·도주) — 자리가 비고 재생 지연 뒤에 찬다. + /// ②스포너가 거둠(멀어짐·서브에리어 전환) — 몸만 풀로 가고 개체는 남는다. 둘이 섞이면 "리전을 오가면 새 곤충" + /// (옛 리롤)으로 돌아가거나 잡은 곤충이 영영 안 사라진다. 몸()은 빈 GameObject에 붙여 + /// 세우기만 하고 모델은 짓지 않는다(Update는 프레임이 안 돌아 호출되지 않는다). + /// + [TestFixture] + public class FieldPopulationTests + { + private readonly List bodies = new List(); + + [TearDown] + public void TearDown() + { + foreach (GameObject go in bodies) + if (go != null) Object.DestroyImmediate(go); + bodies.Clear(); + } + + private InsectEntity Body() + { + var go = new GameObject("TestInsectBody"); + bodies.Add(go); + return go.AddComponent(); + } + + private static FieldSlot FilledSlot(FieldPopulation pop, string key, float now, float expiresAt = 300f) + { + pop.EnsureSlotCount(key, 1, now, isSubArea: false); + FieldSlot slot = pop.SlotsOf(key)[0]; + FieldPopulation.Fill(slot, "stag_beetle", null, 17, true, false, new Vector3(3f, 0.1f, 4f), expiresAt); + return slot; + } + + // ── 거둠 vs 퇴장 ── + + [Test] + public void RelocateAfterFlee_KeepsIndividual_MovesItAndDropsTheBody() + { + // 도주는 개체가 죽은 게 아니라 달아난 것 — 자리를 비우면 멈춰 선 플레이어 둘레가 1~2분씩 빈 들판이 된다. + var pop = new FieldPopulation(); + FieldSlot slot = FilledSlot(pop, "meadow", 0f); + slot.Entity = Body(); + Vector3 away = new Vector3(40f, 0.1f, -25f); + + FieldPopulation.RelocateAfterFlee(slot, away); + + Assert.IsTrue(slot.IsAlive, "달아난 곤충의 자리가 비었다"); + Assert.IsFalse(slot.IsMaterialized, "몸이 그대로 붙어 있다"); + Assert.AreEqual("stag_beetle", slot.InsectId); + Assert.AreEqual(17, slot.Level); + Assert.IsTrue(slot.Shiny); + Assert.AreEqual(away, slot.Position); + } + + [Test] + public void RelocateAfterFlee_OnEmptySlot_DoesNothing() + { + var pop = new FieldPopulation(); + pop.EnsureSlotCount("meadow", 1, 0f, isSubArea: false); + FieldSlot slot = pop.SlotsOf("meadow")[0]; + Vector3 before = slot.Position; + + FieldPopulation.RelocateAfterFlee(slot, new Vector3(9f, 0f, 9f)); + + Assert.IsFalse(slot.IsAlive, "빈 자리가 도주 이동으로 살아났다"); + Assert.AreEqual(before, slot.Position); + } + + [Test] + public void MarkRecalled_KeepsTheSameIndividual() + { + var pop = new FieldPopulation(); + FieldSlot slot = FilledSlot(pop, "forest", 0f); + slot.Entity = Body(); + + FieldPopulation.MarkRecalled(slot); + + Assert.IsFalse(slot.IsMaterialized, "몸이 떨어지지 않았다"); + Assert.IsTrue(slot.IsAlive, "멀어졌다고 자리가 비면 돌아올 때 새 곤충이다(옛 리롤)"); + Assert.AreEqual("stag_beetle", slot.InsectId); + Assert.AreEqual(17, slot.Level); + Assert.IsTrue(slot.Shiny, "색다름도 기록이다 — 다시 세울 때 다시 굴리지 않는다"); + Assert.AreEqual(new Vector3(3f, 0.1f, 4f), slot.Position); + Assert.IsFalse(FieldPopulation.IsDueForRoll(slot, 10000f), "산 자리는 다시 굴리지 않는다"); + } + + [Test] + public void MarkRemovedByGameplay_RefillsOnlyAfterTheDelay() + { + var pop = new FieldPopulation(); + FieldSlot slot = FilledSlot(pop, "pond", 0f); + slot.Entity = Body(); + + FieldPopulation.MarkRemovedByGameplay(slot, 100f, 90f); + + Assert.IsFalse(slot.IsAlive); + Assert.IsFalse(slot.IsMaterialized); + Assert.IsNull(slot.InsectId, "잡힌 개체의 기록이 남아 있다"); + Assert.IsFalse(FieldPopulation.IsDueForRoll(slot, 189.9f), "지연 전에 다시 찼다"); + Assert.IsTrue(FieldPopulation.IsDueForRoll(slot, 190f), "지연이 지나도 안 찬다"); + } + + [Test] + public void Fill_RaisesGenerationEachNewIndividual() + { + var pop = new FieldPopulation(); + FieldSlot slot = FilledSlot(pop, "meadow", 0f); + Assert.AreEqual(0, slot.Generation, "첫 개체는 0세대"); + FieldPopulation.MarkRemovedByGameplay(slot, 0f, 0f); + FieldPopulation.Fill(slot, "ant", null, 2, false, false, Vector3.zero, 999f); + Assert.AreEqual(1, slot.Generation); + } + + // ── 수명 순환 ── + + [Test] + public void CanRotate_ExpiredWithoutBody_True_NotExpired_False() + { + var pop = new FieldPopulation(); + FieldSlot slot = FilledSlot(pop, "swamp", 0f, expiresAt: 300f); + Assert.IsFalse(FieldPopulation.CanRotate(slot, 299f, float.PositiveInfinity, false), "수명 전에 바뀐다"); + Assert.IsTrue(FieldPopulation.CanRotate(slot, 300f, 1f, false), + "몸이 없으면(멀리 있으면) 플레이어 거리와 무관하게 바꾼다"); + } + + [Test] + public void CanRotate_WithBodyNearPlayer_Waits() + { + var pop = new FieldPopulation(); + FieldSlot slot = FilledSlot(pop, "swamp", 0f, expiresAt: 300f); + slot.Entity = Body(); + float near = FieldSpawnRules.RotateMinPlayerDistance - 1f; + float far = FieldSpawnRules.RotateMinPlayerDistance + 1f; + + Assert.IsFalse(FieldPopulation.CanRotate(slot, 400f, near, false), "플레이어 눈앞에서 딴 곤충으로 바뀐다"); + Assert.IsTrue(FieldPopulation.CanRotate(slot, 400f, far, false)); + Assert.IsFalse(FieldPopulation.CanRotate(slot, 400f, far, true), "포획·경계·도주 중인 몸을 바꿨다"); + } + + // ── 시간대 갈아입기 — 만료만 당긴다 ── + + [Test] + public void PullExpiryForward_BringsExpiryCloser_NeverPushesItBack() + { + var pop = new FieldPopulation(); + FieldSlot slot = FilledSlot(pop, "meadow", 0f, expiresAt: 1000f); + + Assert.IsTrue(FieldPopulation.PullExpiryForward(slot, 100f, 20f)); + Assert.AreEqual(120f, slot.ExpiresAt); + Assert.IsFalse(FieldPopulation.PullExpiryForward(slot, 100f, 50f), "이미 더 일찍 만료인 것을 뒤로 밀었다"); + Assert.AreEqual(120f, slot.ExpiresAt); + Assert.IsTrue(slot.IsAlive, "개체는 그대로여야 한다 — 만료만 당긴다"); + Assert.AreEqual("stag_beetle", slot.InsectId); + } + + [Test] + public void PullExpiryForward_IgnoresEmptyAndSubAreaSlots() + { + var pop = new FieldPopulation(); + pop.EnsureSlotCount("meadow", 1, 0f, isSubArea: false); + FieldSlot empty = pop.SlotsOf("meadow")[0]; + Assert.IsFalse(FieldPopulation.PullExpiryForward(empty, 100f, 10f), "빈 자리의 만료를 당겼다"); + + pop.EnsureSlotCount("sub:cave", 1, 0f, isSubArea: true); + FieldSlot room = pop.SlotsOf("sub:cave")[0]; + FieldPopulation.Fill(room, "centipede_common", null, 5, false, false, Vector3.zero, float.PositiveInfinity); + Assert.IsFalse(FieldPopulation.PullExpiryForward(room, 100f, 10f), "서브에리어 전용종은 시간대와 무관하다"); + Assert.IsTrue(float.IsPositiveInfinity(room.ExpiresAt)); + Assert.IsFalse(FieldPopulation.PullExpiryForward(null, 100f, 10f)); + } + + [Test] + public void PullExpiryForward_KeepsTheNearPlayerRule_TheBodyStaysUntilThePlayerIsFar() + { + // 시간대가 바뀌어 만료가 당겨져도 플레이어 25m 안의 몸은 그대로다 — 눈앞에서 바뀌면 리롤과 같다. + var pop = new FieldPopulation(); + FieldSlot slot = FilledSlot(pop, "meadow", 0f, expiresAt: 1000f); + slot.Entity = Body(); + FieldPopulation.PullExpiryForward(slot, 100f, FieldSpawnRules.PhaseSwapDelayMin); + + float near = FieldSpawnRules.RotateMinPlayerDistance - 1f; + float far = FieldSpawnRules.RotateMinPlayerDistance + 1f; + Assert.IsTrue(FieldPopulation.IsExpired(slot, 105f)); + Assert.IsFalse(FieldPopulation.CanRotate(slot, 200f, near, false), "눈앞에서 갈아입었다"); + Assert.IsTrue(FieldPopulation.CanRotate(slot, 200f, far, false), "멀어졌는데도 못 바꾼다"); + Assert.IsFalse(FieldPopulation.CanRotate(slot, 200f, far, true), "경계·포획 중인 몸을 갈아입었다"); + Assert.IsTrue(slot.IsAlive && slot.IsMaterialized, "당기기만 했는데 몸이나 기록이 바뀌었다"); + } + + // ── 실체화 히스테리시스 ── + + [Test] + public void MaterializeAndRecall_UseSeparatedRadii() + { + var pop = new FieldPopulation(); + FieldSlot slot = FilledSlot(pop, "ruins", 0f); + float between = (FieldSpawnRules.MaterializeRadius + FieldSpawnRules.RecallRadius) * 0.5f; + + Assert.IsTrue(FieldPopulation.ShouldMaterialize(slot, FieldSpawnRules.MaterializeRadius)); + Assert.IsFalse(FieldPopulation.ShouldMaterialize(slot, between), "경계 사이에서 세웠다"); + + slot.Entity = Body(); + Assert.IsFalse(FieldPopulation.ShouldMaterialize(slot, 1f), "이미 선 몸을 또 세운다"); + Assert.IsFalse(FieldPopulation.ShouldRecall(slot, between, false), "경계 사이에서 거뒀다 — 서성이면 깜빡인다"); + Assert.IsTrue(FieldPopulation.ShouldRecall(slot, FieldSpawnRules.RecallRadius + 0.1f, false)); + Assert.IsFalse(FieldPopulation.ShouldRecall(slot, 999f, true), "전투 중인 몸을 거뒀다"); + } + + // ── 슬롯 수(오염 상한) ── + + [Test] + public void EnsureSlotCount_Grow_AddsEmptySlotsDueNow() + { + var pop = new FieldPopulation(); + Assert.AreEqual(3, pop.EnsureSlotCount("forest", 3, 12f, isSubArea: false)); + foreach (FieldSlot s in pop.SlotsOf("forest")) + { + Assert.IsFalse(s.IsAlive); + Assert.IsTrue(FieldPopulation.IsDueForRoll(s, 12f), "늘어난 자리를 바로 채우지 않는다(정화 직후 빈 들판)"); + } + } + + [Test] + public void EnsureSlotCount_Shrink_RemovesEmptyThenBodilessButNeverAStandingBody() + { + var pop = new FieldPopulation(); + pop.EnsureSlotCount("mountain", 4, 0f, isSubArea: false); + List slots = pop.SlotsOf("mountain"); + FieldPopulation.Fill(slots[0], "a", null, 1, false, false, Vector3.zero, 999f); + FieldPopulation.Fill(slots[1], "b", null, 1, false, false, Vector3.zero, 999f); + FieldPopulation.Fill(slots[2], "c", null, 1, false, false, Vector3.zero, 999f); + slots[0].Entity = Body(); + slots[1].Entity = Body(); + // slots[3]은 비었다 + + Assert.AreEqual(-2, pop.EnsureSlotCount("mountain", 2, 0f, isSubArea: false)); + Assert.AreEqual(2, slots.Count); + Assert.IsTrue(slots.TrueForAll(s => s.IsMaterialized), "눈앞에 선 곤충을 뺐다"); + + // 몸이 선 자리는 이번엔 못 뺀다 — 거둔 뒤 다음 호출에서 빠진다. + Assert.AreEqual(0, pop.EnsureSlotCount("mountain", 1, 0f, isSubArea: false)); + FieldPopulation.MarkRecalled(slots[1]); + Assert.AreEqual(-1, pop.EnsureSlotCount("mountain", 1, 0f, isSubArea: false)); + Assert.AreEqual(1, slots.Count); + } + + [Test] + public void HasAliveAndCollectAliveIds_SeeRecordsNotBodies() + { + var pop = new FieldPopulation(); + FieldSlot slot = FilledSlot(pop, "pond", 0f); + Assert.IsTrue(pop.HasAlive("pond", "stag_beetle"), "몸이 없어도 산 개체다"); + Assert.IsFalse(pop.HasAlive("forest", "stag_beetle")); + var ids = new HashSet(); + pop.CollectAliveIds("pond", ids); + CollectionAssert.AreEquivalent(new[] { "stag_beetle" }, ids); + + FieldPopulation.MarkRemovedByGameplay(slot, 0f, 60f); + Assert.IsFalse(pop.HasAlive("pond", "stag_beetle")); + } + + // ── 서브에리어 재진입 ── + + /// + /// 나갔다 다시 들어오면 같은 개체가 같은 자리에 있다. 옛 스포너는 들어올 때마다 새로 굴렸다. + /// 잡힌 자리만 슬롯별 지연(서브에리어 45초) 뒤에 다음 차례 종으로 찬다. + /// + [Test] + public void SubArea_ReEntry_KeepsIndividuals_CaughtSlotRefillsWithNextSpecies() + { + string[] ids = { "centipede_common", "earwig_common", "pill_bug_garden" }; + var pop = new FieldPopulation(); + int count = FieldSpawnRules.SubAreaSlotCount(ids.Length, 2); + pop.EnsureSlotCount("sub:meadow_cave", count, 0f, isSubArea: true); + List slots = pop.SlotsOf("sub:meadow_cave"); + for (int i = 0; i < slots.Count; i++) + { + string id = FieldSpawnRules.SubAreaSpecies(ids, slots[i].Index, count, slots[i].Generation + 1); + FieldPopulation.Fill(slots[i], id, null, 5 + i, false, false, new Vector3(2000f + i, 0f, 2000f), + float.PositiveInfinity); + slots[i].Entity = Body(); + Assert.IsTrue(slots[i].IsSubArea); + } + Assert.AreEqual("centipede_common", slots[0].InsectId); + Assert.AreEqual("earwig_common", slots[1].InsectId); + + // 나간다 — 몸만 거둔다. + foreach (FieldSlot s in slots) FieldPopulation.MarkRecalled(s); + // 한참 뒤 다시 들어온다 — 새로 굴릴 자리가 없다(같은 개체). + foreach (FieldSlot s in slots) + { + Assert.IsTrue(s.IsAlive); + Assert.IsFalse(FieldPopulation.IsDueForRoll(s, 10000f)); + Assert.IsFalse(FieldPopulation.IsExpired(s, 10000f), "서브에리어 전용종은 수명이 없다"); + } + Assert.AreEqual("earwig_common", slots[1].InsectId); + Assert.AreEqual(6, slots[1].Level); + Assert.AreEqual(new Vector3(2001f, 0f, 2000f), slots[1].Position); + + // 첫 자리의 곤충을 잡는다 — 45초 뒤 다음 차례(세 번째 전용종)로 찬다. + slots[0].Entity = Body(); + FieldPopulation.MarkRemovedByGameplay(slots[0], 20000f, 45f); + Assert.IsFalse(FieldPopulation.IsDueForRoll(slots[0], 20044f)); + Assert.IsTrue(FieldPopulation.IsDueForRoll(slots[0], 20045f)); + Assert.AreEqual("pill_bug_garden", + FieldSpawnRules.SubAreaSpecies(ids, slots[0].Index, count, slots[0].Generation + 1), + "옛 규칙에선 세 번째 전용종이 영영 안 나왔다"); + } + + // ── 몸 — 기록 그대로 세우기 · 조용히 거두기 ── + + [Test] + public void Entity_InitializeRecorded_UsesGivenShinyAndErased_AndBumpsSerial() + { + InsectEntity body = Body(); + body.Initialize(null, 9, "hollow", null, true, true); + int first = body.SpawnSerial; + Assert.IsTrue(body.IsShiny); + Assert.IsTrue(body.IsErased); + Assert.AreEqual("hollow", body.RegionId); + + body.Initialize(null, 9, "hollow", null, false, false); + Assert.IsFalse(body.IsShiny, "기록된 값 대신 다시 굴렸다"); + Assert.AreNotEqual(first, body.SpawnSerial, "다시 지은 몸이 같은 번호다 — 지도 레이드 표식이 옛 개체로 붙는다"); + } + + [Test] + public void Entity_Recall_DoesNotReportGameplayRemoval_AndLatchesLateDespawn() + { + int despawnCalls = 0; + InsectEntity body = Body(); + body.Initialize(null, 3, "meadow", _ => despawnCalls++, false, false); + + Assert.IsTrue(body.Recall()); + Assert.AreEqual(0, despawnCalls, "거리 회수가 '잡혔다'로 보고됐다 — 자리가 비어 돌아오면 새 곤충이 된다"); + body.Despawn(); // 참조를 쥔 쪽(아이 NPC 등)의 뒤늦은 호출 + Assert.AreEqual(0, despawnCalls, "거둔 몸의 뒤늦은 Despawn이 자리를 비웠다"); + Assert.IsFalse(body.Recall(), "두 번 거둬 풀에 두 번 들어간다"); + + body.Initialize(null, 3, "meadow", _ => despawnCalls++, false, false); + body.Despawn(); + Assert.AreEqual(1, despawnCalls, "게임플레이 퇴장은 한 번 보고돼야 한다"); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/FieldPopulationTests.cs.meta b/Assets/Tests/EditMode/FieldPopulationTests.cs.meta new file mode 100644 index 0000000..92a7068 --- /dev/null +++ b/Assets/Tests/EditMode/FieldPopulationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9832e22324269be47b4edf6c470c4d28 \ No newline at end of file diff --git a/Assets/Tests/EditMode/FieldSpawnRulesTests.cs b/Assets/Tests/EditMode/FieldSpawnRulesTests.cs new file mode 100644 index 0000000..10e0a37 --- /dev/null +++ b/Assets/Tests/EditMode/FieldSpawnRulesTests.cs @@ -0,0 +1,494 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Spawning; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 필드 스폰 규칙() — 희귀도는 리전과 무관하고 레벨만 리전을 따른다. + /// + /// 예전엔 리전 풀의 종 가중치가 곧 등급 분포라 초원은 희귀 이상 0%, 유적은 일반 0%·희귀 50%였다. + /// 등급을 먼저 전역 표로 굴리고 종은 그 등급 안에서 고르는 순서가 무너지면 풀 구성이 다시 등급을 정하게 된다 — + /// 그걸 여기서 고정한다. 난수는 0~1을 고르게 훑어 넣는다(결정적). + /// + [TestFixture] + public class FieldSpawnRulesTests + { + private const int Sweep = 20000; + private readonly List created = new List(); + + [TearDown] + public void TearDown() + { + foreach (InsectData d in created) + if (d != null) Object.DestroyImmediate(d); + created.Clear(); + } + + private InsectData Insect(string id, InsectRarity rarity, float weight) + { + InsectData d = ScriptableObject.CreateInstance(); + d.insectId = id; + d.rarity = rarity; + d.spawnWeight = weight; + created.Add(d); + return d; + } + + private static bool[] Available(params InsectRarity[] rarities) + { + var a = new bool[FieldSpawnRules.RarityCount]; + foreach (InsectRarity r in rarities) a[(int)r] = true; + return a; + } + + private static readonly bool[] AllFive = Available(InsectRarity.Common, InsectRarity.Uncommon, + InsectRarity.Rare, InsectRarity.Epic, InsectRarity.Legendary); + + private static float TableTotal() + { + float t = 0f; + for (int r = 0; r < FieldSpawnRules.RarityCount; r++) t += FieldSpawnRules.BaseShare(r); + return t; + } + + /// 풀에서 스포너와 같은 순서로 고른다 — 등급 먼저(전역 표), 그다음 그 등급 안에서 spawnWeight. + private static float[] RarityHistogram(List pool, float rareBoost) + { + var available = new bool[FieldSpawnRules.RarityCount]; + foreach (InsectData d in pool) available[(int)d.rarity] = true; + var counts = new float[FieldSpawnRules.RarityCount]; + var sub = new List(); + for (int i = 0; i < Sweep; i++) + { + float roll = (i + 0.5f) / Sweep; + int rarity = FieldSpawnRules.PickRarity(roll, rareBoost, available); + sub.Clear(); + foreach (InsectData d in pool) if ((int)d.rarity == rarity) sub.Add(d); + // 종 고르기 난수는 등급 난수와 따로 — 같은 roll을 쓰면 서로 묶인다. + InsectData chosen = InsectDatabase.PickWeighted(sub, Mathf.Repeat(roll * 7.31f, 1f)); + counts[(int)chosen.rarity] += 1f / Sweep; + } + return counts; + } + + // ── 등급표 ── + + [Test] + public void EffectiveShares_FullPool_EqualsNormalizedTable() + { + float[] shares = FieldSpawnRules.EffectiveShares(1f, AllFive); + float total = TableTotal(); + float sum = 0f; + for (int r = 0; r < FieldSpawnRules.RarityCount; r++) + { + Assert.AreEqual(FieldSpawnRules.BaseShare(r) / total, shares[r], 1e-6f, $"등급 {(InsectRarity)r}"); + sum += shares[r]; + } + Assert.AreEqual(1f, sum, 1e-5f); + } + + /// + /// 급소 — 풀 구성이 정반대인 두 리전(일반 투성이 / 전설 투성이)이 같은 등급 분포를 낸다. + /// 옛 규칙(풀 전체를 spawnWeight로 한 번에 굴림)이라면 두 번째 풀은 전설이 대부분이었다. + /// + [Test] + public void RarityDistribution_IsIndependentOfPoolComposition() + { + var commonHeavy = new List(); + for (int i = 0; i < 10; i++) commonHeavy.Add(Insect("c" + i, InsectRarity.Common, 5f)); + commonHeavy.Add(Insect("u", InsectRarity.Uncommon, 0.5f)); + commonHeavy.Add(Insect("r", InsectRarity.Rare, 0.3f)); + commonHeavy.Add(Insect("e", InsectRarity.Epic, 0.1f)); + commonHeavy.Add(Insect("l", InsectRarity.Legendary, 0.05f)); + + var legendHeavy = new List(); + legendHeavy.Add(Insect("c'", InsectRarity.Common, 0.1f)); + legendHeavy.Add(Insect("u'", InsectRarity.Uncommon, 0.1f)); + legendHeavy.Add(Insect("r'", InsectRarity.Rare, 0.1f)); + legendHeavy.Add(Insect("e'", InsectRarity.Epic, 0.1f)); + for (int i = 0; i < 10; i++) legendHeavy.Add(Insect("l" + i, InsectRarity.Legendary, 5f)); + + float[] a = RarityHistogram(commonHeavy, 1f); + float[] b = RarityHistogram(legendHeavy, 1f); + float total = TableTotal(); + for (int r = 0; r < FieldSpawnRules.RarityCount; r++) + { + float expect = FieldSpawnRules.BaseShare(r) / total; + Assert.AreEqual(expect, a[r], 0.002f, $"일반 투성이 풀의 {(InsectRarity)r}"); + Assert.AreEqual(expect, b[r], 0.002f, $"전설 투성이 풀의 {(InsectRarity)r}"); + } + } + + [Test] + public void SpeciesWithinRarity_FollowsSpawnWeight() + { + // 같은 등급 안에서는 여전히 spawnWeight가 종을 정한다(3:1). + var pool = new List { Insect("heavy", InsectRarity.Common, 3f), Insect("light", InsectRarity.Common, 1f) }; + int heavy = 0; + for (int i = 0; i < Sweep; i++) + if (InsectDatabase.PickWeighted(pool, (i + 0.5f) / Sweep).insectId == "heavy") heavy++; + Assert.AreEqual(0.75f, heavy / (float)Sweep, 0.001f); + } + + // ── 대체 ── + + [Test] + public void Fallback_MissingRarity_PrefersNearestLowerThenUpper() + { + bool[] commonUncommon = Available(InsectRarity.Common, InsectRarity.Uncommon); + Assert.AreEqual((int)InsectRarity.Uncommon, FieldSpawnRules.Fallback((int)InsectRarity.Legendary, commonUncommon), + "전설은 가장 가까운 아래(고급)로 내려와야 한다 — 위로 올려 채우면 희귀가 흔해진다"); + Assert.AreEqual((int)InsectRarity.Uncommon, FieldSpawnRules.Fallback((int)InsectRarity.Rare, commonUncommon)); + + bool[] noCommon = Available(InsectRarity.Uncommon, InsectRarity.Rare); + Assert.AreEqual((int)InsectRarity.Uncommon, FieldSpawnRules.Fallback((int)InsectRarity.Common, noCommon), + "아래가 통째로 없을 때만 위로 간다"); + + bool[] gap = Available(InsectRarity.Common, InsectRarity.Legendary); + Assert.AreEqual((int)InsectRarity.Common, FieldSpawnRules.Fallback((int)InsectRarity.Epic, gap), + "위에 있어도 아래가 있으면 아래가 먼저다"); + Assert.AreEqual((int)InsectRarity.Rare, FieldSpawnRules.Fallback((int)InsectRarity.Rare, AllFive)); + } + + [Test] + public void Fallback_NothingAvailable_ReturnsMinusOne() + { + Assert.AreEqual(-1, FieldSpawnRules.Fallback(2, new bool[FieldSpawnRules.RarityCount])); + Assert.AreEqual(-1, FieldSpawnRules.PickRarity(0.5f, 1f, new bool[FieldSpawnRules.RarityCount])); + } + + [Test] + public void EffectiveShares_MissingRarities_MoveShareDownAndStillSumToOne() + { + float[] s = FieldSpawnRules.EffectiveShares(1f, Available(InsectRarity.Common, InsectRarity.Uncommon)); + float total = TableTotal(); + Assert.AreEqual(FieldSpawnRules.CommonShare / total, s[(int)InsectRarity.Common], 1e-6f, "일반 몫은 그대로"); + Assert.AreEqual(1f - FieldSpawnRules.CommonShare / total, s[(int)InsectRarity.Uncommon], 1e-5f, + "희귀·영웅·전설 몫이 전부 고급으로 내려온다"); + Assert.AreEqual(0f, s[(int)InsectRarity.Rare] + s[(int)InsectRarity.Epic] + s[(int)InsectRarity.Legendary], 1e-6f); + } + + // ── 레어 부스트 ── + + [Test] + public void RareBoost_MultipliesRarePlusThenRenormalizes() + { + float[] plain = FieldSpawnRules.EffectiveShares(1f, AllFive); + float[] boosted = FieldSpawnRules.EffectiveShares(2f, AllFive); + + float sum = 0f; + foreach (float v in boosted) sum += v; + Assert.AreEqual(1f, sum, 1e-5f, "재정규화 안 됨"); + + // 희귀 이상은 늘고 일반·고급은 준다. 같은 무리 안의 비율은 그대로다. + for (int r = (int)InsectRarity.Rare; r < FieldSpawnRules.RarityCount; r++) + Assert.Greater(boosted[r], plain[r], $"{(InsectRarity)r}이 안 늘었다"); + Assert.Less(boosted[(int)InsectRarity.Common], plain[(int)InsectRarity.Common]); + Assert.AreEqual(plain[0] / plain[1], boosted[0] / boosted[1], 1e-4f, "일반:고급 비가 바뀌었다"); + Assert.AreEqual(plain[2] / plain[4], boosted[2] / boosted[4], 1e-4f, "희귀:전설 비가 바뀌었다"); + } + + [Test] + public void RareBoost_AtOrBelowOne_ChangesNothing() + { + float[] plain = FieldSpawnRules.EffectiveShares(1f, AllFive); + float[] low = FieldSpawnRules.EffectiveShares(0.5f, AllFive); + for (int r = 0; r < FieldSpawnRules.RarityCount; r++) Assert.AreEqual(plain[r], low[r], 1e-6f); + } + + [Test] + public void PickRarity_Sweep_MatchesEffectiveShares_WithBoost() + { + float[] expect = FieldSpawnRules.EffectiveShares(1.5f, AllFive); + var counts = new float[FieldSpawnRules.RarityCount]; + for (int i = 0; i < Sweep; i++) + counts[FieldSpawnRules.PickRarity((i + 0.5f) / Sweep, 1.5f, AllFive)] += 1f / Sweep; + for (int r = 0; r < FieldSpawnRules.RarityCount; r++) + Assert.AreEqual(expect[r], counts[r], 0.001f, $"{(InsectRarity)r}"); + } + + // ── 레벨 ── + + [TestCase(1, 10)] + [TestCase(36, 50)] + [TestCase(62, 70)] + [TestCase(5, 5)] + public void RollFieldLevel_AlwaysInsideRegionBand(int min, int max) + { + for (int i = 0; i <= 1000; i++) + { + int level = FieldSpawnRules.RollFieldLevel(min, max, i / 1000f); + Assert.GreaterOrEqual(level, min); + Assert.LessOrEqual(level, max); + } + Assert.AreEqual(min, FieldSpawnRules.RollFieldLevel(min, max, 0f), "대역 바닥에 닿지 못한다"); + Assert.AreEqual(max, FieldSpawnRules.RollFieldLevel(min, max, 1f), "대역 천장에 닿지 못한다"); + } + + /// + /// 옛 규칙은 대역을 버리고 Lv.1부터 굴렸다 — 유적(Lv.36~50)에서도 절반 가까이가 Lv.10 아래였다. + /// 지금은 대역 안에서 낮은 쪽이 조금 더 흔할 뿐이다. 등급은 인자로 받지도 않는다. + /// + [Test] + public void RollFieldLevel_LowEndSomewhatMoreCommon() + { + int low = 0, high = 0; + const int min = 36, max = 50; // 15단계 — 아래 5단계 / 위 5단계 + for (int i = 0; i < Sweep; i++) + { + int level = FieldSpawnRules.RollFieldLevel(min, max, (i + 0.5f) / Sweep); + if (level <= min + 4) low++; + else if (level >= max - 4) high++; + } + Assert.Greater(low, high, "낮은 쪽이 더 흔해야 한다"); + Assert.Greater(high, Sweep / 10, "높은 쪽이 거의 안 나오면 대역을 쓰는 의미가 없다"); + } + + // ── 슬롯 수 ── + + [Test] + public void SlotCountFor_ScalesWithLandAndClamps() + { + Assert.AreEqual(FieldSpawnRules.MinRegionSlots, FieldSpawnRules.SlotCountFor(0f)); + Assert.AreEqual(FieldSpawnRules.MaxRegionSlots, FieldSpawnRules.SlotCountFor(1e7f)); + float r60 = Mathf.PI * 60f * 60f; + Assert.AreEqual(Mathf.RoundToInt(r60 / FieldSpawnRules.SquareMetersPerInsect), FieldSpawnRules.SlotCountFor(r60)); + Assert.Greater(FieldSpawnRules.SlotCountFor(Mathf.PI * 80f * 80f), FieldSpawnRules.SlotCountFor(r60)); + } + + [Test] + public void Radii_KeepHysteresisAndPopInOutsideSpawnGap() + { + // 세우기/거두기 사이가 벌어져 있어야 경계에서 매 틱 깜빡이지 않는다. + Assert.Greater(FieldSpawnRules.RecallRadius, FieldSpawnRules.MaterializeRadius); + // 수명 교체는 새 개체가 생겨도 되는 거리보다 멀어야 "눈앞에서 바뀜"이 없다. + Assert.GreaterOrEqual(FieldSpawnRules.RotateMinPlayerDistance, FieldSpawnRules.SpawnMinPlayerDistance); + Assert.GreaterOrEqual(FieldSpawnRules.RespawnDelayMin, 30f, "재생이 너무 빠르면 리전 이동 리롤과 다를 게 없다"); + } + + // ── 서브에리어 종 순환 ── + + [Test] + public void SubAreaSpecies_FirstFillMatchesLegacy_ThenRotatesThroughEveryExclusive() + { + string[] ids = { "a", "b", "c" }; + int slots = FieldSpawnRules.SubAreaSlotCount(ids.Length, 2); + Assert.AreEqual(2, slots); + Assert.AreEqual("a", FieldSpawnRules.SubAreaSpecies(ids, 0, slots, 0), "첫 판은 옛 ids[i % n]"); + Assert.AreEqual("b", FieldSpawnRules.SubAreaSpecies(ids, 1, slots, 0)); + + // 옛 규칙은 슬롯 번호만 봐서 "c"가 영영 안 나왔다. + var seen = new HashSet(); + for (int gen = 0; gen < 3; gen++) + for (int s = 0; s < slots; s++) + seen.Add(FieldSpawnRules.SubAreaSpecies(ids, s, slots, gen)); + CollectionAssert.AreEquivalent(ids, seen); + } + + [Test] + public void SubAreaSlotCount_FollowsLegacyRule() + { + Assert.AreEqual(0, FieldSpawnRules.SubAreaSlotCount(0, 2)); + Assert.AreEqual(2, FieldSpawnRules.SubAreaSlotCount(1, 2)); + Assert.AreEqual(2, FieldSpawnRules.SubAreaSlotCount(4, 2)); + Assert.AreEqual(3, FieldSpawnRules.SubAreaSlotCount(2, 5)); + } + + // ── 스토리 포획 보조 ── + + [Test] + public void PickStoryTarget_WantedSpeciesAbsent_PicksItWithinChance() + { + var wanted = new List { "dragonfly_emperor" }; + var candidates = new HashSet { "dragonfly_emperor", "frog" }; + var alive = new HashSet { "frog" }; + Assert.AreEqual("dragonfly_emperor", + FieldSpawnRules.PickStoryTarget(wanted, candidates, alive, 0f, 0.5f)); + Assert.IsNull(FieldSpawnRules.PickStoryTarget(wanted, candidates, alive, FieldSpawnRules.StoryAssistChance, 0.5f), + "확률 밖이면 평소대로 굴린다"); + } + + [Test] + public void PickStoryTarget_AlreadyAliveOrNotCandidate_ReturnsNull() + { + var wanted = new List { "stag_beetle" }; + Assert.IsNull(FieldSpawnRules.PickStoryTarget(wanted, new HashSet { "stag_beetle" }, + new HashSet { "stag_beetle" }, 0f, 0f), "이미 그 리전에 살아 있으면 돕지 않는다"); + Assert.IsNull(FieldSpawnRules.PickStoryTarget(wanted, new HashSet { "other" }, + new HashSet(), 0f, 0f), "지금 나올 수 없는(풀·시간대 밖) 종은 주지 않는다"); + Assert.IsNull(FieldSpawnRules.PickStoryTarget(new List(), null, null, 0f, 0f)); + } + + [Test] + public void PickStoryTarget_SeveralEligible_SpreadsByPickRoll() + { + var wanted = new List { "x", "y" }; + var cand = new HashSet { "x", "y" }; + Assert.AreEqual("x", FieldSpawnRules.PickStoryTarget(wanted, cand, new HashSet(), 0f, 0.1f)); + Assert.AreEqual("y", FieldSpawnRules.PickStoryTarget(wanted, cand, new HashSet(), 0f, 0.9f)); + Assert.AreEqual("y", FieldSpawnRules.PickStoryTarget(wanted, cand, new HashSet { "x" }, 0f, 0.1f), + "살아 있는 쪽을 빼고 고른다"); + } + + // ── 시간·날씨 보너스 슬롯 ── + + private static WorldState State(DayPhase phase, WeatherType weather) + => new WorldState { DayPhase = phase, Weather = weather, Hour24 = 12 }; + + [Test] + public void BonusSlots_Table_NightOne_WetOne_BothTwo_CappedAtTwo() + { + Assert.AreEqual(0, FieldSpawnRules.BonusSlots(State(DayPhase.Day, WeatherType.Clear))); + Assert.AreEqual(0, FieldSpawnRules.BonusSlots(State(DayPhase.Morning, WeatherType.Clear))); + Assert.AreEqual(0, FieldSpawnRules.BonusSlots(State(DayPhase.Evening, WeatherType.Clear)), "저녁은 밤이 아니다"); + Assert.AreEqual(1, FieldSpawnRules.BonusSlots(State(DayPhase.Night, WeatherType.Clear)), "밤 +1"); + Assert.AreEqual(1, FieldSpawnRules.BonusSlots(State(DayPhase.Day, WeatherType.Rain)), "비 +1"); + Assert.AreEqual(1, FieldSpawnRules.BonusSlots(State(DayPhase.Day, WeatherType.Fog)), "안개 +1"); + Assert.AreEqual(2, FieldSpawnRules.BonusSlots(State(DayPhase.Night, WeatherType.Rain)), "밤 + 비 = +2"); + Assert.AreEqual(2, FieldSpawnRules.BonusSlots(State(DayPhase.Night, WeatherType.Fog)), "밤 + 안개 = +2"); + Assert.AreEqual(0, FieldSpawnRules.BonusSlots(State(DayPhase.Day, WeatherType.Wind)), "센바람은 늘리지 않는다"); + Assert.AreEqual(0, FieldSpawnRules.BonusSlots(State(DayPhase.Day, WeatherType.Snow)), "눈은 늘리지 않는다"); + Assert.AreEqual(1, FieldSpawnRules.BonusSlots(State(DayPhase.Night, WeatherType.Snow)), "밤의 눈은 밤 몫만"); + + Assert.AreEqual(2, FieldSpawnRules.MaxBonusSlots, "사용자 요청 — 한두 개 정도 더"); + foreach (DayPhase p in System.Enum.GetValues(typeof(DayPhase))) + foreach (WeatherType w in System.Enum.GetValues(typeof(WeatherType))) + { + int bonus = FieldSpawnRules.BonusSlots(State(p, w)); + Assert.GreaterOrEqual(bonus, 0); + Assert.LessOrEqual(bonus, FieldSpawnRules.MaxBonusSlots, $"{p}/{w}"); + } + } + + /// + /// 스포너 RegionCap은 보너스를 먼저 더하고 오염 감소를 그 합에 얹는다 — 같은 합성을 순수 함수로 재현한다. + /// 거꾸로(오염 상한 + 보너스)면 황폐한 땅이 밤·비에 줄인 몫을 도로 채운다. + /// + [Test] + public void BonusAddedBeforeBlightScaling_NeverBeatsAHealthyRegion_NorLiftsTheFloor() + { + for (int baseSlots = FieldSpawnRules.MinRegionSlots; baseSlots <= FieldSpawnRules.MaxRegionSlots; baseSlots++) + for (int bonus = 0; bonus <= FieldSpawnRules.MaxBonusSlots; bonus++) + { + int blighted = BlightPolicy.MaxActiveFor(true, baseSlots + bonus); + string at = $"기본 {baseSlots} 보너스 {bonus}"; + Assert.LessOrEqual(blighted, BlightPolicy.MaxActiveFor(true, baseSlots) + 1, $"{at}: 보너스가 오염 감소를 되돌린다"); + Assert.Less(blighted, baseSlots + bonus, $"{at}: 오염 리전이 같은 상태의 멀쩡한 리전만큼 많다"); + Assert.GreaterOrEqual(blighted, BlightPolicy.MinActive, $"{at}: 하한 아래로 내려갔다"); + Assert.AreEqual(baseSlots + bonus, BlightPolicy.MaxActiveFor(false, baseSlots + bonus), $"{at}: 멀쩡한 리전은 그대로"); + } + } + + // ── 성향 배수를 곱한 종 고르기 ── + + [Test] + public void PickWeighted_AllMultipliersOne_MatchesTheDatabaseFormula() + { + // 두 곳의 가중식(0.01 하한 포함)이 갈라지지 않게 — 배수가 전부 1이면 InsectDatabase.PickWeighted와 같은 개체를 고른다. + var pool = new List + { + Insect("a", InsectRarity.Common, 3f), Insect("b", InsectRarity.Common, 1f), + Insect("c", InsectRarity.Common, 0f), Insect("d", InsectRarity.Common, 0.4f) + }; + var ones = new List { 1f, 1f, 1f, 1f }; + for (int i = 0; i < Sweep; i++) + { + float roll = (i + 0.5f) / Sweep; + Assert.AreSame(InsectDatabase.PickWeighted(pool, roll), FieldSpawnRules.PickWeighted(pool, ones, roll), $"roll {roll}"); + Assert.AreSame(InsectDatabase.PickWeighted(pool, roll), FieldSpawnRules.PickWeighted(pool, null, roll), "배수 없음은 ×1"); + } + } + + [Test] + public void PickWeighted_Multiplier_ShiftsOnlyTheRelativeOdds() + { + var pool = new List { Insect("heavy", InsectRarity.Common, 3f), Insect("light", InsectRarity.Common, 1f) }; + + // 3 : 1 이던 것이 3 : (1×3) = 1 : 1이 된다. + int heavy = 0; + var even = new List { 1f, 3f }; + for (int i = 0; i < Sweep; i++) + if (FieldSpawnRules.PickWeighted(pool, even, (i + 0.5f) / Sweep).insectId == "heavy") heavy++; + Assert.AreEqual(0.5f, heavy / (float)Sweep, 0.001f); + + heavy = 0; + var damped = new List { 1f, 0.2f }; + for (int i = 0; i < Sweep; i++) + if (FieldSpawnRules.PickWeighted(pool, damped, (i + 0.5f) / Sweep).insectId == "heavy") heavy++; + Assert.AreEqual(3f / 3.2f, heavy / (float)Sweep, 0.001f); + + Assert.AreEqual(3f, pool[0].spawnWeight, "spawnWeight 자체를 바꾸면 안 된다 — 배수는 고를 때만 곱한다"); + } + + [Test] + public void PickWeighted_NonEmptyPool_AlwaysPicksSomething_EvenAtTheSmallestMultiplier() + { + var pool = new List { Insect("x", InsectRarity.Rare, 0.2f), Insect("y", InsectRarity.Rare, 0.2f) }; + var tiny = new List { InsectHabits.MinSpawnMultiplier, InsectHabits.MinSpawnMultiplier }; + for (int i = 0; i <= 100; i++) Assert.IsNotNull(FieldSpawnRules.PickWeighted(pool, tiny, i / 100f)); + Assert.IsNull(FieldSpawnRules.PickWeighted(new List(), tiny, 0.5f)); + Assert.IsNull(FieldSpawnRules.PickWeighted(null, tiny, 0.5f)); + } + + /// + /// 급소 — 성향 배수가 아무리 극단적이어도(하한·상한을 번갈아) 등급 분포는 그대로다. 배수는 한 등급 후보 안에서만 + /// 곱해지고 등급은 그 앞에서 전역 표로 굴려지기 때문이다. 순서가 바뀌면(배수를 먼저 곱해 풀 전체에서 고르면) 야행성이 많은 + /// 밤에 등급 분포가 움직인다. + /// + [Test] + public void RarityDistribution_IsUnmovedByExtremeHabitMultipliers() + { + var pool = new List(); + for (int i = 0; i < 10; i++) pool.Add(Insect("c" + i, InsectRarity.Common, 5f)); + pool.Add(Insect("u", InsectRarity.Uncommon, 0.5f)); + pool.Add(Insect("r", InsectRarity.Rare, 0.3f)); + pool.Add(Insect("e", InsectRarity.Epic, 0.1f)); + pool.Add(Insect("l", InsectRarity.Legendary, 0.05f)); + + var available = new bool[FieldSpawnRules.RarityCount]; + foreach (InsectData d in pool) available[(int)d.rarity] = true; + float[] expect = FieldSpawnRules.EffectiveShares(1f, available); + + var counts = new float[FieldSpawnRules.RarityCount]; + var sub = new List(); + var mult = new List(); + for (int i = 0; i < Sweep; i++) + { + float roll = (i + 0.5f) / Sweep; + int rarity = FieldSpawnRules.PickRarity(roll, 1f, available); + sub.Clear(); + mult.Clear(); + foreach (InsectData d in pool) + { + if ((int)d.rarity != rarity) continue; + sub.Add(d); + mult.Add(sub.Count % 2 == 0 ? InsectHabits.MinSpawnMultiplier : InsectHabits.MaxSpawnMultiplier); + } + counts[(int)FieldSpawnRules.PickWeighted(sub, mult, Mathf.Repeat(roll * 7.31f, 1f)).rarity] += 1f / Sweep; + } + for (int r = 0; r < FieldSpawnRules.RarityCount; r++) + Assert.AreEqual(expect[r], counts[r], 0.002f, $"{(InsectRarity)r}"); + } + + [Test] + public void PhaseSwapDelay_StaysInsideFiveToSixtySeconds() + { + Assert.AreEqual(5f, FieldSpawnRules.PhaseSwapDelayMin); + Assert.AreEqual(60f, FieldSpawnRules.PhaseSwapDelayMax); + Assert.AreEqual(FieldSpawnRules.PhaseSwapDelayMin, FieldSpawnRules.RollPhaseSwapDelay(0f), 1e-5f); + Assert.AreEqual(FieldSpawnRules.PhaseSwapDelayMax, FieldSpawnRules.RollPhaseSwapDelay(1f), 1e-5f); + for (int i = 0; i <= 100; i++) + { + float d = FieldSpawnRules.RollPhaseSwapDelay(i / 100f); + Assert.GreaterOrEqual(d, 5f); + Assert.LessOrEqual(d, 60f); + } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/FieldSpawnRulesTests.cs.meta b/Assets/Tests/EditMode/FieldSpawnRulesTests.cs.meta new file mode 100644 index 0000000..f2ca141 --- /dev/null +++ b/Assets/Tests/EditMode/FieldSpawnRulesTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3bc5f7723942a9b49ab6091b8a550eea \ No newline at end of file diff --git a/Assets/Tests/EditMode/FieldThemeTests.cs b/Assets/Tests/EditMode/FieldThemeTests.cs new file mode 100644 index 0000000..5dc7f4e --- /dev/null +++ b/Assets/Tests/EditMode/FieldThemeTests.cs @@ -0,0 +1,661 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using NUnit.Framework; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.NPC; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 필드 테마(바닥 팔레트·리전 대기·서브에리어 입구·테마 방·NPC 옷차림·연못 호수·겹친 리전 울타리·둔덕 높이)의 + /// 데이터 차원 불변식. + /// + /// 전부 증상이 조용한 것들이다. 팔레트에 리전을 빠뜨리면 옛 공식색(서릿길 회녹색)으로 떨어지고, + /// 연무가 짙으면 게임 카메라에서 캐릭터가 뿌옇게 묻히고, 입구 표식을 빠뜨리면 그 서브에리어는 필드에서 + /// 아무 표시 없이 근접 프롬프트로만 존재한다. 호수가 NPC 자리를 삼키거나 울타리 기둥이 이웃 마을 건물에 박히거나 + /// 둔덕이 높이 표에서 빠져도 마찬가지다 — 예외도 경고도 없다. 눈으로 보는 건 FieldDesignTour가 한다. + /// + [TestFixture] + public class FieldThemeTests + { + // ── 바닥 팔레트 ── + + [Test] + public void RegionPalette_CoversEveryRegion() + { + var missing = new List(); + foreach (RegionData r in RegionDefinitions.CreateAll()) + if (!RegionPalette.Has(r.regionId)) missing.Add(r.regionId); + CollectionAssert.IsEmpty(missing, "바닥색이 옛 테마색 공식으로 떨어지는 리전"); + } + + [Test] + public void RegionPalette_GroundIsNotBlownOut() + { + // 조명이 약 1.3배라 반사율 0.8을 넘으면 화면에서 흰색으로 날아간다(서릿길이 실제로 그랬다) + foreach (RegionData r in RegionDefinitions.CreateAll()) + { + Color g = RegionPalette.Ground(r.regionId, r.themeColor); + Assert.LessOrEqual(g.maxColorComponent, 0.8f, $"{r.regionId}: 바닥이 너무 밝다 {g}"); + Assert.GreaterOrEqual(g.maxColorComponent, 0.12f, $"{r.regionId}: 바닥이 너무 어둡다 {g}"); + } + } + + [Test] + public void RegionPalette_PatchTonesStayNearGround() + { + // 얼룩은 단색 판을 깨는 용도다 — 바닥에서 크게 벗어나면 "진흙 웅덩이"처럼 읽힌다(꽃밭 갈색 얼룩) + foreach (RegionData r in RegionDefinitions.CreateAll()) + { + Color g = RegionPalette.Ground(r.regionId, r.themeColor); + RegionPalette.PatchTones(r.regionId, r.themeColor, out Color light, out Color dark); + Assert.Less(Distance(g, light), 0.2f, $"{r.regionId}: 밝은 얼룩이 바닥에서 너무 멀다"); + Assert.Less(Distance(g, dark), 0.2f, $"{r.regionId}: 짙은 얼룩이 바닥에서 너무 멀다"); + } + } + + // ── 리전 대기 ── + + [Test] + public void RegionAtmosphere_CoversEveryRegion() + { + var missing = new List(); + foreach (RegionData r in RegionDefinitions.CreateAll()) + if (!RegionAtmosphere.TryGet(r.regionId, out _)) missing.Add(r.regionId); + CollectionAssert.IsEmpty(missing, "대기 프로필이 없는 리전"); + } + + [Test] + public void RegionAtmosphere_FogNeverHidesThePlayer() + { + // 게임 카메라는 플레이어에서 약 11m — 투과율이 98% 아래로 내려가면 캐릭터가 뿌옇다. + // 모드는 런타임(SubAreaEnvironment)이 쓰는 바로 그 상수다 — 모드가 Exp로 바뀌면 이 계산도 Exp로 바뀐다. + float dist = RegionAtmosphere.PlayerCameraDistance; + foreach (RegionData r in RegionDefinitions.CreateAll()) + { + Assert.IsTrue(RegionAtmosphere.TryGet(r.regionId, out RegionAtmosphere.Profile p)); + Assert.LessOrEqual(p.fogDensity, RegionAtmosphere.MaxFogDensity, r.regionId); + float transmittance = RegionAtmosphere.FogTransmittance(RegionAtmosphere.FieldFogMode, p.fogDensity, dist); + Assert.GreaterOrEqual(transmittance, RegionAtmosphere.MinPlayerTransmittance, + $"{r.regionId}: {RegionAtmosphere.FieldFogMode} {dist}m 투과율 {transmittance:F3}"); + Assert.Greater(p.fogDensity, 0f, $"{r.regionId}: 연무가 없으면 원경 산맥이 하늘에서 떠 보인다"); + } + // 런타임은 밀도를 상한으로 자른다(SubAreaEnvironment.BuildRegionProfile) — 상한 자체도 같은 모드로 통과해야 한다 + float atCap = RegionAtmosphere.FogTransmittance(RegionAtmosphere.FieldFogMode, RegionAtmosphere.MaxFogDensity, dist); + Assert.GreaterOrEqual(atCap, RegionAtmosphere.MinPlayerTransmittance, $"밀도 상한의 {dist}m 투과율 {atCap:F3}"); + } + + [Test] + public void FogTransmittance_MatchesUnityFogFormulas() + { + // 식이 틀리면 위 검사가 무엇이든 통과시킨다 — 셰이더(UNITY_CALC_FOG_FACTOR)와 같은 값인지 알려진 점으로 고정 + Assert.AreEqual(Mathf.Exp(-1f), RegionAtmosphere.FogTransmittance(FogMode.Exponential, 0.1f, 10f), 1e-5f); + Assert.AreEqual(Mathf.Exp(-2f), RegionAtmosphere.FogTransmittance(FogMode.Exponential, 0.1f, 20f), 1e-5f); + Assert.AreEqual(Mathf.Exp(-1f), RegionAtmosphere.FogTransmittance(FogMode.ExponentialSquared, 0.1f, 10f), 1e-5f); + Assert.AreEqual(Mathf.Exp(-4f), RegionAtmosphere.FogTransmittance(FogMode.ExponentialSquared, 0.1f, 20f), 1e-5f); + Assert.AreEqual(0.5f, RegionAtmosphere.FogTransmittance(FogMode.Linear, 0f, 150f), 1e-5f); + Assert.AreEqual(1f, RegionAtmosphere.FogTransmittance(FogMode.ExponentialSquared, 0.05f, 0f), 1e-6f); + } + + [Test] + public void FieldFog_RuntimeUsesTheSharedModeConstant() + { + // 런타임이 모드를 리터럴로 적으면 위 가시거리 검사가 다른 모드를 계산하게 된다 — 상수 경유를 소스에서 고정 + string src = ReadSource("Assets/Scripts/Core/SubAreaEnvironment.cs"); + StringAssert.Contains("RegionAtmosphere.FieldFogMode", src, "메인 필드 안개 모드를 상수로 쓰지 않는다"); + StringAssert.Contains("RegionAtmosphere.SubAreaFogMode", src, "서브에리어 안개 모드를 상수로 쓰지 않는다"); + Assert.IsFalse(Regex.IsMatch(src, @"FogMode\.(Linear|Exponential|ExponentialSquared)\b"), + "SubAreaEnvironment에 안개 모드 리터럴이 있다 — RegionAtmosphere 상수를 거칠 것"); + } + + // ── 연못 호수 ── + + [Test] + public void PondLake_DockEndsOverWaterAndLandsOnMud() + { + // 부두(나루터 널판)의 호수 쪽(북쪽) 끝 몇 m가 물 위, 나머지는 진흙 — 옛 호수는 부두 끝에서 1.2m 모자라 부두가 풀밭에 섰다 + RegionData pond = FindRegion("pond"); + RegionDressingBuilder.PondLakeLayout lake = RegionDressingBuilder.PondLake(pond.centerPosition, pond.radius); + RegionTerrainBuilder.PondDockFootprint(pond.centerPosition, pond.radius, out Vector3 dock, out Vector2 size); + Vector3 north = dock + Vector3.forward * (size.y * 0.5f); + for (float t = 0f; t <= 2f; t += 0.25f) + Assert.IsTrue(lake.water.Contains(north + Vector3.back * t, 1f, 0f), $"부두 북쪽 끝에서 {t}m가 물 밖이다"); + Assert.IsFalse(lake.water.Contains(north + Vector3.back * 4f, 1f, 0f), "부두 대부분이 물 한가운데로 들어갔다"); + for (float t = 0f; t <= size.y; t += 0.25f) + Assert.IsTrue(lake.mud.Contains(north + Vector3.back * t, 1f, 0.3f), $"부두 북쪽 끝에서 {t}m가 풀밭이다 — 물가에서 끊겼다"); + } + + [Test] + public void PondLake_KeepsSubAreaGatesWhereTheyBelong() + { + RegionData pond = FindRegion("pond"); + RegionDressingBuilder.PondLakeLayout lake = RegionDressingBuilder.PondLake(pond.centerPosition, pond.radius); + foreach (SubAreaData sub in pond.subAreas) + { + if (sub.subAreaId == "pond_deep") + { + // 연못 깊은 곳 — 물속 입구(돌 고리 반경 2.2m + 돌 0.25m)가 통째로 물 안, 물가에서 1m 넘게 안쪽 + for (int i = 0; i < 16; i++) + { + float a = i * Mathf.PI * 2f / 16f; + Vector3 p = sub.centerPosition + new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * 2.45f; + Assert.IsTrue(lake.water.Contains(p, 1f, -1f), $"연못 깊은 곳 입구가 물가에 걸린다 ({i})"); + } + } + else + { + // 갈대 밀림 등 뭍 입구 — 아치 기둥(중심에서 ±2.3m)이 진흙 가장자리에서 2m 넘게 밖 + foreach (float side in new[] { -2.3f, 0f, 2.3f }) + Assert.IsFalse(lake.mud.Contains(sub.centerPosition + Vector3.right * side, 1f, 2f), + $"{sub.subAreaId} 입구가 호수에 잠긴다"); + } + } + } + + [Test] + public void PondLake_LeavesPondNpcsAndOutpostOnDryLand() + { + // 옛 호수 중심에서 20~24m 고리에 NPC·모닥불이 둘러서 있어 호수를 고르게 키울 수 없었다(PondLake 주석). + // 자리는 VillageBuilder가 단일 출처라 사본을 두지 않고 실제로 지어 본다. + RegionData[] regions = RegionDefinitions.CreateAll(); + RegionData pond = FindRegion(regions, "pond"); + RegionDressingBuilder.PondLakeLayout lake = RegionDressingBuilder.PondLake(pond.centerPosition, pond.radius); + var host = new GameObject("PondLakeVillageTestHost"); + try + { + VillageBuildResult result = host.AddComponent().Build(regions); + int checkedAnchors = 0; + foreach (NpcSpawnAnchor anchor in result.npcAnchors) + { + if (anchor.regionId != "pond") continue; + checkedAnchors++; + Assert.IsFalse(lake.mud.Contains(anchor.position, 1f, 2.4f), + $"{anchor.kind} {anchor.storyNpcId}: 진흙 가장자리에서 2m 안쪽이다(발자리 0.4m 포함)"); + } + Assert.GreaterOrEqual(checkedAnchors, 4, "연못 NPC 자리를 못 찾았다 — 이 검사가 무의미해졌다"); + + Transform outpost = GameObject.Find("Village/Outpost_pond").transform; + Vector3 fire = outpost.Find("Campfire").position; + Assert.IsFalse(lake.mud.Contains(fire, 1f, 1.25f + 2f), "전초기지 모닥불(돌 고리 1.25m)이 물가 2m 안이다"); + + // 전초기지 앞 작은 물가(RegionDressingBuilder.Pond의 cove, 진흙 원반 10.8×9.6)와 큰 호수가 붙어 한 덩어리가 되지 않는다 + var cove = new RegionDressingBuilder.DiscPlacement(outpost.TransformPoint(new Vector3(-3.2f, 0f, -2.5f)), + 10.8f, 9.6f, outpost.eulerAngles.y, RegionDressingBuilder.PatchSegments); + for (int i = 0; i < 36; i++) + Assert.IsFalse(lake.mud.Contains(cove.EdgePoint(i * Mathf.PI * 2f / 36f, 1f), 1f, 2f), + "전초기지 물가와 큰 호수의 진흙이 2m 안으로 붙는다"); + } + finally + { + // Build가 만든 "Village" 루트는 host의 자식이 아니다 — 따로 치운다 + GameObject village = GameObject.Find("Village"); + if (village != null) Object.DestroyImmediate(village); + Object.DestroyImmediate(host); + } + } + + [Test] + public void PondShoreRocks_NeverSitInTheLake() + { + // 부트스트랩 물가 바위(AddPondScenery의 Pond_ShoreRock_, 콜라이더 있음) — 옛 호수 중심(리전 중심)에서 0.35R ± 0.5m, + // 60° 간격 ± 0.3rad, 크기 rs 0.4~0.9(몸 반경 0.6rs). 호수가 남동으로 옮겨 커진 뒤 0°·300° 두 개가 난수에 따라 + // 물 한가운데에 섰다. 고리 식은 부트스트랩에만 있어 소스에서 식과 호출을 고정하고, 난수 범위 전체를 훑는다. + string src = ReadSource("Assets/Scripts/Core/PlaySceneBootstrap.cs"); + Match block = Regex.Match(src, @"// --- Existing shore rocks ---(.*?)// --- NEW: Wooden bridge", RegexOptions.Singleline); + Assert.IsTrue(block.Success, "물가 바위 배치를 못 찾았다 — 이 테스트가 무의미해졌다"); + StringAssert.Contains("RegionDressingBuilder.PondShoreSpot(", block.Value, "물가 바위가 호수 윤곽을 안 본다"); + StringAssert.Contains("Mathf.PI * 2f * i / 6f + Random.Range(-0.3f, 0.3f)", block.Value, "고리 식이 바뀌었다 — 아래 사본도 고칠 것"); + StringAssert.Contains("rad * 0.5f + Random.Range(-0.5f, 0.5f)", block.Value, "고리 식이 바뀌었다 — 아래 사본도 고칠 것"); + StringAssert.Contains("Random.Range(0.4f, 0.9f)", block.Value, "크기 식이 바뀌었다 — 아래 사본도 고칠 것"); + + RegionData pond = FindRegion("pond"); + RegionDressingBuilder.PondLakeLayout lake = RegionDressingBuilder.PondLake(pond.centerPosition, pond.radius); + Vector3 c = pond.centerPosition; + float rad = pond.radius * 0.7f; // AddRegionScenery가 넘기는 배치 반경 + int moved = 0; + for (int i = 0; i < 6; i++) + for (int ai = 0; ai <= 24; ai++) + for (int di = 0; di <= 8; di++) + foreach (float rs in new[] { 0.4f, 0.9f }) + { + float a = Mathf.PI * 2f * i / 6f - 0.3f + 0.6f * ai / 24f; + float d = rad * 0.5f - 0.5f + di / 8f; + Vector3 p = c + new Vector3(Mathf.Cos(a) * d, 0f, Mathf.Sin(a) * d); + float body = rs * 0.6f; + Vector3 q = RegionDressingBuilder.PondShoreSpot(c, pond.radius, p, body); + string at = $"바위 {i} ({p.x - c.x:F1}, {p.z - c.z:F1}) rs {rs}"; + Assert.IsFalse(lake.water.Contains(q, 1f, body), $"{at}: 옮긴 자리 ({q.x - c.x:F1}, {q.z - c.z:F1})가 물에 닿는다"); + Assert.AreEqual(p.y, q.y, 1e-6f, $"{at}: 높이를 바꿨다"); + if ((q - p).sqrMagnitude < 1e-8f) continue; + + moved++; + Assert.IsTrue(lake.water.Contains(p, 1f, body), $"{at}: 물에 안 닿는 바위를 옮겼다"); + // 같은 방위(호수 중심에서 본 반직선)의 진흙 띠 바깥 가장자리 위 + Vector2 rockDir = new Vector2(p.x - lake.water.center.x, p.z - lake.water.center.z).normalized; + Vector2 shoreDir = new Vector2(q.x - lake.water.center.x, q.z - lake.water.center.z).normalized; + Assert.Less(Mathf.Abs(rockDir.x * shoreDir.y - rockDir.y * shoreDir.x), 1e-3f, $"{at}: 방위가 바뀌었다"); + Assert.Greater(Vector2.Dot(rockDir, shoreDir), 0f, $"{at}: 호수 반대편으로 옮겼다"); + Assert.IsTrue(lake.mud.Contains(q, 1f, 0.05f) && !lake.mud.Contains(q, 1f, -0.05f), + $"{at}: 진흙 가장자리가 아니다 ({q.x - c.x:F1}, {q.z - c.z:F1})"); + } + Assert.Greater(moved, 0, "물에 잠기는 경우를 한 번도 안 만났다 — 호수나 바위 고리가 바뀌었다면 이 검사를 다시 볼 것"); + } + + [Test] + public void PatchEdgeRadius_MatchesTheDiscMesh() + { + // 소품 배치·회피가 보는 윤곽과 실제로 그려지는 메시가 같은 테두리여야 갈대가 물가를 따라 선다 + foreach (int segments in new[] { RegionDressingBuilder.PatchSegments, RegionDressingBuilder.LakeSegments }) + { + Mesh mesh = RegionDressingBuilder.IrregularDisc("EdgeTest", segments, RegionDressingBuilder.PatchRadius); + try + { + Vector3[] v = mesh.vertices; + for (int i = 1; i <= segments; i++) + { + Vector3 a = v[i], b = v[i % segments + 1]; + Vector3 mid = (a + b) * 0.5f; + Assert.AreEqual(a.magnitude, RegionDressingBuilder.PatchEdgeRadius(Mathf.Atan2(a.z, a.x), segments), 1e-4f, $"{segments}분할 꼭짓점 {i}"); + Assert.AreEqual(mid.magnitude, RegionDressingBuilder.PatchEdgeRadius(Mathf.Atan2(mid.z, mid.x), segments), 1e-4f, $"{segments}분할 변 {i}"); + } + } + finally + { + Object.DestroyImmediate(mesh); + } + } + } + + // ── 울타리 ── + + [Test] + public void RegionFences_DoNotStandInsideAnotherRegion() + { + // 초원·습지는 약 42m 겹친다 — 습지 둔덕 기둥이 본 마을 가챠 오두막 한가운데(1.0m)에 박혀 있었다 + var before = new HashSet(); + foreach (GameObject existing in Object.FindObjectsByType(FindObjectsSortMode.None)) + before.Add(existing.GetInstanceID()); + var host = new GameObject("FenceOverlapTestHost"); + try + { + RegionData[] regions = RegionDefinitions.CreateAll(); + host.AddComponent().BuildBoundaries(regions); + int posts = 0; + foreach (GameObject go in Object.FindObjectsByType(FindObjectsSortMode.None)) + { + if (before.Contains(go.GetInstanceID()) || !go.name.StartsWith("Fence") || go.GetComponent() == null) continue; + posts++; + Vector3 p = go.transform.position; + foreach (RegionData o in regions) + { + float d = new Vector2(p.x - o.centerPosition.x, p.z - o.centerPosition.z).magnitude; + // 제 리전의 기둥은 반경 − 1m에 선다 — 그보다 0.5m 넘게 안쪽이면 남의 리전 안에 선 기둥이다 + Assert.GreaterOrEqual(d, o.radius - 1.5f, $"{go.name} ({p.x:F1}, {p.z:F1})이 {o.regionId} 안 {o.radius - d:F1}m에 섰다"); + } + } + Assert.Greater(posts, 600, "울타리 기둥을 못 찾았다 — 이 검사가 무의미해졌다"); + } + finally + { + foreach (GameObject generated in Object.FindObjectsByType(FindObjectsSortMode.None)) + if (generated != null && !before.Contains(generated.GetInstanceID())) Object.DestroyImmediate(generated); + } + } + + [Test] + public void BootstrapBarriers_SkipOtherRegionsWithTheFenceRule() + { + // 부트스트랩 Barrier_ 고리(0.85R · 8개, 바위는 콜라이더 있음)도 울타리와 같은 판정으로 남의 리전 안을 건너뛴다. + // 초원 바위 하나가 습지 안에, 습지 말뚝 둘이 초원 안에 섰다. 고리는 부트스트랩에만 있어 호출을 소스에서 고정한다. + string src = ReadSource("Assets/Scripts/Core/PlaySceneBootstrap.cs"); + Match loop = Regex.Match(src, @"// --- Region boundary barriers ---(.*?)// WorldTerrainBuilder owns", RegexOptions.Singleline); + Assert.IsTrue(loop.Success, "Barrier 배치를 못 찾았다 — 이 테스트가 무의미해졌다"); + StringAssert.Contains("RegionTerrainBuilder.IsInsideOtherRegionFence(regionDefs, region, bPos)", loop.Value, + "Barrier가 울타리와 다른 판정을 쓴다(또는 안 쓴다)"); + StringAssert.Contains("float bRad = region.radius * 0.85f;", loop.Value, "고리 반경이 바뀌었다 — 아래 사본도 고칠 것"); + // 전역 난수 줄기 — 바위 크기를 건너뛰기 판정보다 먼저 뽑아야 뒤따르는 필드 소품이 안 밀린다 + int draw = loop.Value.IndexOf("Random.Range(1.2f, 2f)", System.StringComparison.Ordinal); + int skip = loop.Value.IndexOf("if (inOtherRegion) continue;", System.StringComparison.Ordinal); + Assert.IsTrue(draw >= 0 && skip >= 0, "바위 크기 난수 또는 건너뛰기를 못 찾았다"); + Assert.Less(draw, skip, "바위를 건너뛸 때 크기 난수를 안 뽑는다 — 전역 난수 호출 수가 바뀐다"); + + RegionData[] regions = RegionDefinitions.CreateAll(); + RegionData meadow = FindRegion(regions, "meadow"), swamp = FindRegion(regions, "swamp"); + Vector3 Ring(RegionData r, int bi) + { + float ba = Mathf.PI * 2f * bi / 8f; + return r.centerPosition + new Vector3(Mathf.Cos(ba), 0f, Mathf.Sin(ba)) * (r.radius * 0.85f); + } + // 초원·습지 겹침(약 42m) — 초원 5(말뚝, 길 위라 실제로는 IsOnRoute가 먼저 뺀다)·6(바위), 습지 1·2(말뚝) + foreach ((RegionData r, int bi) in new[] { (meadow, 5), (meadow, 6), (swamp, 1), (swamp, 2) }) + Assert.IsTrue(RegionTerrainBuilder.IsInsideOtherRegionFence(regions, r, Ring(r, bi)), $"{r.regionId} {bi}: 남의 리전 안인데 짓는다"); + + int flagged = 0; + foreach (RegionData r in regions) + for (int bi = 0; bi < 8; bi++) + { + Vector3 p = Ring(r, bi); + if (RegionTerrainBuilder.IsInsideOtherRegionFence(regions, r, p)) { flagged++; continue; } + // 남긴 것은 어느 리전의 울타리 줄 안쪽에도 들지 않는다(제 리전 제외) — 제 리전 원만 보고 자기를 잡으면 전부 사라진다 + foreach (RegionData o in regions) + { + if (o == r) continue; + float dist = new Vector2(p.x - o.centerPosition.x, p.z - o.centerPosition.z).magnitude; + Assert.GreaterOrEqual(dist, o.radius - 1f, $"{r.regionId} {bi}: {o.regionId} 안 {o.radius - dist:F1}m"); + } + } + Assert.AreEqual(4, flagged, "겹친 리전의 Barrier 수가 바뀌었다 — 리전 배치가 바뀌었다면 위 목록도 다시 잴 것"); + } + + // ── 둔덕 높이 ── + + [Test] + public void FieldGround_PlaceMound_SurfaceMatchesTheSphereMesh() + { + // 콜라이더 없는 둔덕의 높이 표가 실제로 그려지는 구와 같은 윗면을 내야 곤충·플레이어가 둔덕 위에 선다 + FieldGround.Clear(); + GameObject mound = GameObject.CreatePrimitive(PrimitiveType.Sphere); + try + { + var pos = new Vector3(40f, 0f, -25f); + RegionTerrainBuilder.PlaceMound(mound, pos, 18f, 8f, 0.55f, 35f); + Assert.AreEqual(1, FieldGround.Count); + Assert.AreEqual(FieldGround.FloorY + 0.55f, FieldGround.SurfaceY(pos.x, pos.z), 1e-3f, "꼭대기가 rise만큼 솟지 않는다"); + + Matrix4x4 m = mound.transform.localToWorldMatrix; + int compared = 0; + foreach (Vector3 local in mound.GetComponent().sharedMesh.vertices) + { + if (local.y < 0.02f) continue; // 윗반구만 — 아랫면은 땅속이다 + Vector3 w = m.MultiplyPoint3x4(local); + if (w.y < FieldGround.FloorY + 0.01f) continue; // 바닥 아래로 묻힌 테두리 + // 허용 5mm — 내장 구 메시 정점은 반지름 0.5에서 조금씩 벗어나 있고 그 오차가 둔덕 배율만큼 커진다 + // (실측 최대 2.1mm). 발·곤충이 둔덕에 서는 데 의미 있는 차이는 cm 단위다. + Assert.AreEqual(w.y, FieldGround.SurfaceY(w.x, w.z), 5e-3f, $"구 표면 ({w.x:F2}, {w.z:F2})"); + compared++; + } + Assert.Greater(compared, 20, "비교할 윗면 정점이 없다"); + + // 지면 자국 밖(긴 축 반지름 9m 너머)은 바닥 그대로 + Vector3 longAxis = Quaternion.Euler(0f, 35f, 0f) * Vector3.right; + Vector3 outside = pos + longAxis * 9.3f; + Assert.AreEqual(FieldGround.FloorY, FieldGround.SurfaceY(outside.x, outside.z), 1e-5f); + Assert.AreEqual(0f, FieldGround.LiftAt(pos.x + 30f, pos.z), 1e-6f); + } + finally + { + FieldGround.Clear(); + Object.DestroyImmediate(mound); + } + } + + [Test] + public void FieldGround_OverlapTakesHigher_BuriedDomeIgnored() + { + FieldGround.Clear(); + try + { + FieldGround.AddDome(new Vector3(0f, -0.5f, 0f), new Vector3(5f, 0.8f, 5f), 0f); // 꼭대기 0.3 + FieldGround.AddDome(new Vector3(1f, -1f, 0f), new Vector3(3f, 1.6f, 3f), 0f); // 꼭대기 0.6 + FieldGround.AddDome(new Vector3(20f, -2f, 0f), new Vector3(6f, 1f, 6f), 0f); // 완전히 묻힘 — 무시 + Assert.AreEqual(2, FieldGround.Count); + Assert.AreEqual(0.6f, FieldGround.SurfaceY(1f, 0f), 1e-4f, "겹치면 높은 쪽"); + // 첫 둔덕만 걸치는 자리 — 반축 비율 0.6에서 윗면은 −0.5 + 0.8 × √(1 − 0.36) = 0.14 + Assert.AreEqual(0.14f, FieldGround.SurfaceY(-3f, 0f), 1e-4f, "타원체 윗면 식"); + Assert.AreEqual(FieldGround.FloorY, FieldGround.SurfaceY(20f, 0f), 1e-6f, "묻힌 둔덕이 바닥을 올렸다"); + Assert.AreEqual(0f, FieldGround.LiftAt(20f, 0f), 1e-6f); + } + finally + { + FieldGround.Clear(); + } + Assert.AreEqual(0, FieldGround.Count); + Assert.AreEqual(FieldGround.FloorY, FieldGround.SurfaceY(1f, 0f), 1e-6f, "Clear 뒤에도 옛 둔덕이 남았다"); + } + + [Test] + public void RegionTerrain_RegistersEveryWalkThroughMound() + { + // 둔덕 계열(PlaceMound + 재 더미·이끼 둔덕)이 전부 높이 표에 올라야 한다 — 하나라도 빠지면 그 위의 곤충이 묻힌다 + string[] families = { "Scenery_MeadowHill_", "Scenery_Snow_", "Scenery_HollowRidge_", "Scenery_Dune_", + "Scenery_SnowDrift_", "Scenery_LavaFlow_", "Scenery_AshMound_", "Scenery_MossMound_", "Scenery_VoidPlate_" }; + var before = new HashSet(); + foreach (GameObject existing in Object.FindObjectsByType(FindObjectsSortMode.None)) + before.Add(existing.GetInstanceID()); + Random.State randomBefore = Random.state; + var host = new GameObject("MoundRegistryTestHost"); + try + { + host.AddComponent().BuildAllRegions(RegionDefinitions.CreateAll()); + var seen = new Dictionary(); + foreach (GameObject go in Object.FindObjectsByType(FindObjectsSortMode.None)) + { + if (before.Contains(go.GetInstanceID())) continue; + foreach (string family in families) + { + if (!go.name.StartsWith(family)) continue; + seen[family] = seen.TryGetValue(family, out int n) ? n + 1 : 1; + Vector3 p = go.transform.position; + float top = p.y + go.transform.lossyScale.y * 0.5f; + if (top <= FieldGround.FloorY) continue; + Assert.GreaterOrEqual(FieldGround.SurfaceY(p.x, p.z), top - 0.01f, $"{go.name}: 꼭대기 {top:F2}m가 높이 표에 없다"); + } + } + foreach (string family in families) + Assert.IsTrue(seen.ContainsKey(family), $"{family} 둔덕이 하나도 안 지어졌다 — 이름이 바뀌었다면 이 표도 고칠 것"); + Assert.IsTrue(randomBefore.Equals(Random.state), "지형 빌드가 전역 난수 상태를 되돌리지 않았다"); + } + finally + { + foreach (GameObject generated in Object.FindObjectsByType(FindObjectsSortMode.None)) + if (generated != null && !before.Contains(generated.GetInstanceID())) Object.DestroyImmediate(generated); + FieldGround.Clear(); + } + } + + // ── 서브에리어 입구 ── + + [Test] + public void EverySubArea_HasFieldEntrance() + { + // 부트스트랩(CreateSubAreaEntries)이 environmentType으로 짓는 입구 + SubAreaGateBuilder 테마 입구. + // 둘 다 아니면 그 서브에리어는 필드에 아무 표식이 없다(배치 캡처에서 15곳이 그랬다). + string bootstrap = ReadSource("Assets/Scripts/Core/PlaySceneBootstrap.cs"); + Match body = Regex.Match(bootstrap, @"private void CreateSubAreaEntries\(.*?\n \}", RegexOptions.Singleline); + Assert.IsTrue(body.Success, "CreateSubAreaEntries를 못 찾았다 — 이 테스트가 무의미해졌다"); + var byEnv = new HashSet(); + foreach (Match m in Regex.Matches(body.Value, @"case\s+""(\w+)""\s*:\s*\n\s*Create\w+\(")) + byEnv.Add(m.Groups[1].Value); + Assert.Greater(byEnv.Count, 0, "입구 switch 추출 실패"); + + var bare = new List(); + foreach (RegionData r in RegionDefinitions.CreateAll()) + { + if (r.subAreas == null) continue; + foreach (SubAreaData s in r.subAreas) + if (!SubAreaGateBuilder.Handles(s.subAreaId) && !byEnv.Contains(s.environmentType)) + bare.Add($"{s.subAreaId}({s.environmentType})"); + } + CollectionAssert.IsEmpty(bare, "필드 입구 표식이 없는 서브에리어"); + } + + [Test] + public void SubAreaGateBuilder_OnlyNamesRealSubAreas() + { + // 오타면 부트스트랩도 테마 입구도 안 짓는 게 아니라, 테마 입구 쪽만 조용히 빠진다 + // 목록은 설계 표(Designs) 하나다 — 소스를 정규식으로 긁지 않고 표 자체를 읽는다 + var ids = new HashSet(); + foreach (RegionData r in RegionDefinitions.CreateAll()) + if (r.subAreas != null) foreach (SubAreaData s in r.subAreas) ids.Add(s.subAreaId); + int designed = 0; + foreach (string id in SubAreaGateBuilder.DesignedIds) + { + designed++; + Assert.IsTrue(ids.Contains(id), $"존재하지 않는 서브에리어: {id}"); + } + Assert.Greater(designed, 0, "설계 표가 비었다 — 이 테스트가 무의미해졌다"); + } + + [Test] + public void SubAreaGateHandles_DesignedOrUnknownId_TrueOnlyForDesigned() + { + // 부트스트랩은 Handles가 참이면 기본 입구를 건너뛴다 — 설계 없는 id에 참이면 그 입구는 조용히 사라진다 + foreach (string id in SubAreaGateBuilder.DesignedIds) + Assert.IsTrue(SubAreaGateBuilder.Handles(id), $"{id}: 설계가 있는데 부트스트랩이 기본 입구까지 짓는다"); + Assert.IsFalse(SubAreaGateBuilder.Handles(null)); + Assert.IsFalse(SubAreaGateBuilder.Handles("")); + Assert.IsFalse(SubAreaGateBuilder.Handles("no_such_sub_area"), "설계 없는 id를 맡겠다고 답하면 입구가 안 선다"); + } + + [Test] + public void SubAreaGateBuild_AllRegions_EveryDesignPlacesParts() + { + // 표에 id가 있어도 설계가 비어 있으면(부품 0개) 결과는 같다 — 부트스트랩이 건너뛰고 아무것도 안 선다. + // 실제로 지어 보고 id마다 SubArea_{id}_Gate_ 부품이 하나 이상인지 본다(BuildGate의 LogError도 여기서 드러난다). + var host = new GameObject("SubAreaGateBuilderTestHost"); + try + { + host.AddComponent().Build(RegionDefinitions.CreateAll()); + var counts = new Dictionary(); + foreach (Transform child in host.transform) + foreach (string id in SubAreaGateBuilder.DesignedIds) + if (child.name.StartsWith($"SubArea_{id}_Gate_")) + counts[id] = counts.TryGetValue(id, out int n) ? n + 1 : 1; + + var empty = new List(); + foreach (string id in SubAreaGateBuilder.DesignedIds) + if (!counts.ContainsKey(id)) empty.Add(id); + CollectionAssert.IsEmpty(empty, "설계는 있는데 필드에 부품이 하나도 안 선 입구"); + } + finally + { + UnityEngine.Object.DestroyImmediate(host); + } + } + + // ── 테마 방 ── + + [Test] + public void ThemedRooms_KeepTheirParentBuilderSize() + { + // story_lint 21은 environmentType → 빌더(본 파일 switch)로 방 크기를 읽는다. 빌더 안에서 subAreaId로 + // 갈라진 방이 크기를 바꾸면 연출 좌표 검사가 거짓이 된다 — 배우가 벽 밖에 서도 PASS가 뜬다. + string themes = ReadSource("Assets/Scripts/Core/SubAreaWorldBuilder.Themes.cs"); + AssertRoom(themes, "BuildAntlionPit", 14f); // cave → BuildCave(14) + AssertRoom(themes, "BuildBlankCore", 14f); // underground → BuildCave(14) + AssertRoom(themes, "BuildHiddenPuddle", 14f); // pond → BuildUnderwater(14) + AssertRoom(themes, "BuildCanopyCrown", 11f); // peak → BuildMountainPeak(11) + } + + private static void AssertRoom(string src, string builder, float half) + { + Match m = Regex.Match(src, $@"private void {builder}\(SubAreaData[^)]*\)\s*\{{(.*?)\n \}}", RegexOptions.Singleline); + Assert.IsTrue(m.Success, $"{builder}를 못 찾았다"); + Match walls = Regex.Match(m.Groups[1].Value, @"CreateBoundaryWalls\([^;]*?,\s*([\d.]+)f\s*,\s*[\d.]+f\s*\)"); + Assert.IsTrue(walls.Success, $"{builder}: CreateBoundaryWalls가 없다(방이 안 막힌다)"); + Assert.AreEqual(half, float.Parse(walls.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture), 0.001f, + $"{builder}: 원래 빌더와 방 크기가 다르다"); + } + + // ── NPC 옷차림 ── + + [Test] + public void RegionalDress_DoesNotChangeFaces() + { + // 옷차림은 별도 난수로 뽑는다 — 기존 색·머리 추첨을 건드리면 주민 얼굴이 통째로 바뀐다 + string[] regions = { null, "meadow", "frostline", "dunes", "emberfall", "canopy" }; + for (int seed = 1; seed < 40; seed++) + { + NpcAppearance plain = NpcVisualBuilder.RandomVillager(seed); + foreach (string region in regions) + { + NpcAppearance dressed = NpcVisualBuilder.RandomVillager(seed, region); + Assert.AreEqual(plain.hair, dressed.hair, $"seed {seed} {region}: 머리색"); + Assert.AreEqual(plain.skin, dressed.skin, $"seed {seed} {region}: 피부색"); + Assert.AreEqual(plain.hairStyle, dressed.hairStyle, $"seed {seed} {region}: 머리 모양"); + } + } + } + + [Test] + public void RegionalDress_FitsTheClimate() + { + for (int seed = 1; seed < 30; seed++) + { + NpcAppearance frost = NpcVisualBuilder.RandomVillager(seed, "frostline"); + Assert.AreEqual(NpcWear.Coat, frost.wear, "서릿길 주민은 코트"); + Assert.IsTrue(frost.scarf && frost.hasHat && frost.hatStyle == NpcHatStyle.Beanie, "서릿길 주민은 털모자·목도리"); + + NpcAppearance sand = NpcVisualBuilder.RandomVillager(seed, "dunes"); + Assert.AreEqual(NpcWear.Robe, sand.wear, "모래언덕 주민은 로브"); + Assert.AreEqual(NpcHatStyle.Headwrap, sand.hatStyle, "모래언덕 주민은 두건"); + + NpcAppearance miner = NpcVisualBuilder.RandomVillager(seed, "emberfall"); + Assert.AreEqual(NpcHatStyle.Helmet, miner.hatStyle, "잿불 골짜기 주민은 안전모"); + + // 코트·로브는 상체도 덮는다 — 몸통색이 겉옷색이어야 위아래가 한 벌로 읽힌다 + Assert.AreEqual(frost.wearColor, frost.top); + Assert.AreEqual(sand.wearColor, sand.top); + } + } + + [Test] + public void StoryNpcs_WearWhatTheirNotesPromise() + { + // 주석으로만 있던 약속들 — 전부 같은 야구모자 모양이라 색 말고는 구분이 안 됐다 + Assert.AreEqual(NpcHatStyle.Straw, NpcVisualBuilder.StoryNpcAppearance("town_pond").hatStyle, "물결 할머니 — 밀짚모자"); + Assert.AreEqual(NpcHatStyle.Wide, NpcVisualBuilder.StoryNpcAppearance("town_garden").hatStyle, "누리 — 챙 넓은 모자"); + Assert.AreEqual(NpcHatStyle.Beanie, NpcVisualBuilder.StoryNpcAppearance("town_frostline").hatStyle, "서리 — 털모자"); + NpcAppearance miner = NpcVisualBuilder.StoryNpcAppearance("town_emberfall"); + Assert.AreEqual(NpcHatStyle.Helmet, miner.hatStyle, "숯 — 안전모"); + Assert.AreEqual(NpcTool.Pickaxe, miner.tool, "숯 — 곡괭이"); + Assert.AreEqual(NpcTool.Axe, NpcVisualBuilder.StoryNpcAppearance("town_forest").tool, "솔 — 나무꾼의 도끼"); + Assert.AreEqual(NpcTool.Book, NpcVisualBuilder.StoryNpcAppearance("town_meadow").tool, "달래 — 그림책"); + Assert.IsTrue(NpcVisualBuilder.StoryNpcAppearance("ruins_scholar").glasses, "세라 — 안경"); + } + + [Test] + public void LedgerSociety_SharesUniformMarkAndIvoryTop() + { + // 1막 하수가 간부와 같은 아이보리 상의를 입는 게 "저 옷을 어디서 봤더라"의 단서다 — 옷차림을 더해도 유지 + string[] members = { "ledger_chief", "ledger_grip", "ledger_scale", "ledger_ink", + "ledger_thug_cord", "ledger_thug_rule", "ledger_thug_pin" }; + Color ivory = NpcVisualBuilder.StoryNpcAppearance("ledger_chief").top; + foreach (string id in members) + { + NpcAppearance a = NpcVisualBuilder.StoryNpcAppearance(id); + Assert.IsTrue(a.ledgerMark, $"{id}: 완장·배지가 없다"); + Assert.AreEqual(ivory, a.top, $"{id}: 상의색이 제복과 다르다"); + } + } + + // ── 유틸 ── + + private static float Distance(Color a, Color b) + { + float dr = a.r - b.r, dg = a.g - b.g, db = a.b - b.b; + return Mathf.Sqrt(dr * dr + dg * dg + db * db); + } + + private static string ReadSource(string relativePath) + { + string full = Path.Combine(Application.dataPath, "..", relativePath); + Assert.IsTrue(File.Exists(full), $"소스를 못 찾음: {relativePath}"); + return File.ReadAllText(full); + } + + private static RegionData FindRegion(string regionId) => FindRegion(RegionDefinitions.CreateAll(), regionId); + + private static RegionData FindRegion(RegionData[] regions, string regionId) + { + RegionData r = WorldRouteLayout.Find(regions, regionId); + Assert.IsNotNull(r, $"리전 없음: {regionId}"); + return r; + } + } +} +#endif diff --git a/Assets/Tests/EditMode/FieldThemeTests.cs.meta b/Assets/Tests/EditMode/FieldThemeTests.cs.meta new file mode 100644 index 0000000..d7b9f99 --- /dev/null +++ b/Assets/Tests/EditMode/FieldThemeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 53848566163c3984a903f1e1ee2f034c \ No newline at end of file diff --git a/Assets/Tests/EditMode/FirestoreDocParserTests.cs b/Assets/Tests/EditMode/FirestoreDocParserTests.cs new file mode 100644 index 0000000..0480131 --- /dev/null +++ b/Assets/Tests/EditMode/FirestoreDocParserTests.cs @@ -0,0 +1,116 @@ +#if UNITY_EDITOR +using InsectGame.Core; +using NUnit.Framework; + +namespace InsectGame.Tests +{ + /// + /// — 클라우드 복원이 읽는 Firestore REST 문서 파서. + /// 예전 구현은 공백 없는 마커만 찾아서, 응답이 줄바꿈·들여쓰기돼 오면 전 필드가 기본값이 됐다 + /// (레벨 0·캔디 0으로 복원). 두 형식을 함께 고정한다. + /// + [TestFixture] + public class FirestoreDocParserTests + { + private const string Compact = + "{\"fields\":{\"displayName\":{\"stringValue\":\"탐험가\"},\"playerLevel\":{\"integerValue\":\"27\"}," + + "\"islandData\":{\"stringValue\":\"{\\\"sizeLevel\\\":2,\\\"owned\\\":[]}\"}}}"; + + private const string Pretty = + "{\n \"name\": \"projects/p/databases/(default)/documents/users/u1\",\n \"fields\": {\n" + + " \"displayName\": {\n \"stringValue\": \"탐험가\"\n },\n" + + " \"playerLevel\": {\n \"integerValue\": \"27\"\n },\n" + + " \"islandData\": {\n \"stringValue\": \"{\\\"sizeLevel\\\":2,\\\"owned\\\":[]}\"\n }\n },\n" + + " \"createTime\": \"2026-01-01T00:00:00Z\"\n}"; + + [TestCase(Compact)] + [TestCase(Pretty)] + public void TryGetString_ReadsBothFormats(string json) + { + Assert.IsTrue(FirestoreDocParser.TryGetString(json, "displayName", out string name)); + Assert.AreEqual("탐험가", name); + } + + [TestCase(Compact)] + [TestCase(Pretty)] + public void TryGetInt_ReadsBothFormats(string json) + { + Assert.IsTrue(FirestoreDocParser.TryGetInt(json, "playerLevel", out int level)); + Assert.AreEqual(27, level); + } + + [TestCase(Compact)] + [TestCase(Pretty)] + public void TryGetString_NestedJsonBlob_IsUnescaped(string json) + { + Assert.IsTrue(FirestoreDocParser.TryGetString(json, "islandData", out string blob)); + Assert.AreEqual("{\"sizeLevel\":2,\"owned\":[]}", blob); + } + + [Test] + public void MissingField_ReturnsFalseAndEmpty() + { + Assert.IsFalse(FirestoreDocParser.TryGetString(Compact, "nope", out string s)); + Assert.AreEqual(string.Empty, s); + Assert.IsFalse(FirestoreDocParser.TryGetInt(Compact, "nope", out int i)); + Assert.AreEqual(0, i); + } + + [Test] + public void WrongType_IsNotRead() + { + // 정수 필드를 문자열로, 문자열 필드를 정수로 읽지 않는다. + Assert.IsFalse(FirestoreDocParser.TryGetString(Compact, "playerLevel", out _)); + Assert.IsFalse(FirestoreDocParser.TryGetInt(Compact, "displayName", out _)); + } + + [Test] + public void FieldNameInsideABlob_IsNotMistakenForTheField() + { + // 곤충 블롭 안에 "gems"라는 키가 있어도(이스케이프돼 있다) 최상위 gems로 읽으면 안 된다. + string json = "{\"fields\":{\"ownedInsects\":{\"stringValue\":\"{\\\"gems\\\":{\\\"integerValue\\\":\\\"999\\\"}}\"}," + + "\"gems\":{\"integerValue\":\"12\"}}}"; + Assert.IsTrue(FirestoreDocParser.TryGetInt(json, "gems", out int gems)); + Assert.AreEqual(12, gems); + } + + [Test] + public void Escapes_AreDecodedCharacterByCharacter() + { + // 원문이 `\` 뒤에 `n`인 경우(JSON으로는 `\\n`) — 순차 Replace는 이걸 줄바꿈으로 잘못 되돌렸다. + string json = "{\"fields\":{\"a\":{\"stringValue\":\"x\\\\ny\"},\"b\":{\"stringValue\":\"l1\\nl2\\t\\u00e9\\/\"}}}"; + Assert.IsTrue(FirestoreDocParser.TryGetString(json, "a", out string a)); + Assert.AreEqual("x\\ny", a); + Assert.IsTrue(FirestoreDocParser.TryGetString(json, "b", out string b)); + Assert.AreEqual("l1\nl2\té/", b); + } + + [Test] + public void EmptyString_IsReadAsEmpty() + { + string json = "{\"fields\":{\"activeQuest\":{\"stringValue\":\"\"}}}"; + Assert.IsTrue(FirestoreDocParser.TryGetString(json, "activeQuest", out string s)); + Assert.AreEqual(string.Empty, s); + } + + [Test] + public void NegativeAndHugeIntegers_AreHandled() + { + string json = "{\"fields\":{\"a\":{\"integerValue\":\"-1\"},\"b\":{\"integerValue\":\"99999999999\"},\"c\":{\"integerValue\":\"x\"}}}"; + Assert.IsTrue(FirestoreDocParser.TryGetInt(json, "a", out int a)); + Assert.AreEqual(-1, a); + Assert.IsTrue(FirestoreDocParser.TryGetInt(json, "b", out int b)); + Assert.AreEqual(int.MaxValue, b); + Assert.IsFalse(FirestoreDocParser.TryGetInt(json, "c", out _)); + } + + [Test] + public void NullOrEmptyInput_DoesNotThrow() + { + Assert.IsFalse(FirestoreDocParser.TryGetString(null, "a", out _)); + Assert.IsFalse(FirestoreDocParser.TryGetString("", "a", out _)); + Assert.IsFalse(FirestoreDocParser.TryGetString("{\"fields\":{\"a\":{\"stringValue\":\"unterminated", "a", out _)); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/FirestoreDocParserTests.cs.meta b/Assets/Tests/EditMode/FirestoreDocParserTests.cs.meta new file mode 100644 index 0000000..0712fb6 --- /dev/null +++ b/Assets/Tests/EditMode/FirestoreDocParserTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b5df45eb9335f9644b3a1d69134b0f21 \ No newline at end of file diff --git a/Assets/Tests/EditMode/FleePathTests.cs b/Assets/Tests/EditMode/FleePathTests.cs new file mode 100644 index 0000000..efa6757 --- /dev/null +++ b/Assets/Tests/EditMode/FleePathTests.cs @@ -0,0 +1,93 @@ +#if UNITY_EDITOR +using InsectGame.Spawning; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 도주 방향·거리() — 곤충이 벽·건물·바위·나무 줄기와 서브에리어 방 벽을 뚫고 달아나던 결함. + /// + /// 곤충은 몸 콜라이더가 없어 물리가 막아 주지 않는다. 그래서 도주를 시작할 때 방향마다 막힘 거리를 재고 + /// (InsectEntity.MeasureFleeClearance — 씬이 필요해 여기선 가짜 측정기를 쓴다) 이 순수 판정이 방향과 + /// 갈 거리를 정한다. + /// + [TestFixture] + public class FleePathTests + { + private static readonly Vector3 Away = Vector3.forward; // 플레이어 반대쪽 + + private static float AngleFromAway(Vector3 dir) => Vector3.SignedAngle(Away, dir, Vector3.up); + + [Test] + public void Choose_OpenField_RunsStraightAwayFullDistance() + { + Vector3 dir = FleePath.Choose(Away, 1f, _ => FleePath.ProbeLength, out float allowed); + Assert.AreEqual(0f, AngleFromAway(dir), 0.01f); + Assert.AreEqual(FleePath.MaxDistance, allowed, 1e-5f); + } + + [Test] + public void Choose_WallStraightBehind_VeersToFirstOpenSideCandidate() + { + // 정면(반대쪽) ±20° 안은 2m 앞이 벽이다. + System.Func probe = d => Mathf.Abs(AngleFromAway(d)) < 20f ? 2f : FleePath.ProbeLength; + + Vector3 right = FleePath.Choose(Away, 1f, probe, out float allowedR); + Assert.AreEqual(35f, AngleFromAway(right), 0.5f, "첫 뚫린 후보(+35°)를 안 골랐다"); + Assert.AreEqual(FleePath.MaxDistance, allowedR, 1e-5f); + + Vector3 left = FleePath.Choose(Away, -1f, probe, out _); + Assert.AreEqual(-35f, AngleFromAway(left), 0.5f, "좌우 뒤집기(sideSign)가 안 먹는다"); + } + + [Test] + public void Choose_Cornered_TakesFarthestAndStopsBeforeTheWall() + { + // 사방이 막혔다 — +70° 쪽이 가장 멀다(5m). + System.Func probe = d => Mathf.Abs(AngleFromAway(d) - 70f) < 1f ? 5f : 1.5f; + Vector3 dir = FleePath.Choose(Away, 1f, probe, out float allowed); + Assert.AreEqual(70f, AngleFromAway(dir), 0.5f); + Assert.AreEqual(5f - FleePath.WallMargin, allowed, 1e-5f, "장애물 앞 여유만큼 덜 가야 벽면에 안 파묻힌다"); + } + + [Test] + public void Choose_PressedAgainstWalls_DoesNotMoveAtAll() + { + // 방 구석에 붙어 모든 방향이 여유보다 가깝다 — 제자리에서 타이머가 끝나 사라진다. + FleePath.Choose(Away, 1f, _ => FleePath.WallMargin * 0.5f, out float allowed); + Assert.AreEqual(0f, allowed, 1e-6f); + } + + [Test] + public void CandidateAngles_NeverTurnBackTowardThePlayer() + { + for (int i = 0; i < FleePath.CandidateAngles.Length; i++) + { + Vector3 d = FleePath.DirectionFor(Away, i, 1f); + Assert.AreEqual(0f, d.y, 1e-6f, "수평이 아니다"); + Assert.AreEqual(1f, d.magnitude, 1e-5f); + Assert.Less(Mathf.Abs(AngleFromAway(d)), 120f, "플레이어 쪽으로 뛰어드는 후보가 있다"); + } + } + + [Test] + public void StepDistance_SumNeverExceedsAllowed() + { + const float allowed = 3.2f; + float travelled = 0f; + for (int frame = 0; frame < 120; frame++) + travelled += FleePath.StepDistance(7.5f / 60f, allowed, travelled); + Assert.AreEqual(allowed, travelled, 1e-4f, "벽 앞에서 멈추지 않거나 덜 갔다"); + Assert.AreEqual(0f, FleePath.StepDistance(1f, allowed, allowed), 1e-6f); + } + + [Test] + public void MaxDistance_CoversFastestFleeMotion() + { + // 비행 도주 7.5m/s × 1.1초 — 이보다 짧으면 뚫린 방향인데도 중간에 멈춘다. + Assert.GreaterOrEqual(FleePath.MaxDistance, 7.5f * 1.1f); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/FleePathTests.cs.meta b/Assets/Tests/EditMode/FleePathTests.cs.meta new file mode 100644 index 0000000..6891307 --- /dev/null +++ b/Assets/Tests/EditMode/FleePathTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a58049e808706a54db2d791c91a49f68 \ No newline at end of file diff --git a/Assets/Tests/EditMode/GameConstantsTests.cs b/Assets/Tests/EditMode/GameConstantsTests.cs index bd08351..b3d6ee3 100644 --- a/Assets/Tests/EditMode/GameConstantsTests.cs +++ b/Assets/Tests/EditMode/GameConstantsTests.cs @@ -38,6 +38,7 @@ public void SaveFiles_AllNotEmpty() Assert.IsFalse(string.IsNullOrEmpty(GameConstants.SaveFiles.BattleTeam)); Assert.IsFalse(string.IsNullOrEmpty(GameConstants.SaveFiles.DexSave)); Assert.IsFalse(string.IsNullOrEmpty(GameConstants.SaveFiles.StoryProgress)); + Assert.IsFalse(string.IsNullOrEmpty(GameConstants.SaveFiles.Island)); } [Test] @@ -48,6 +49,7 @@ public void SaveFiles_AllEndWithJson() Assert.IsTrue(GameConstants.SaveFiles.BattleTeam.EndsWith(".json")); Assert.IsTrue(GameConstants.SaveFiles.DexSave.EndsWith(".json")); Assert.IsTrue(GameConstants.SaveFiles.StoryProgress.EndsWith(".json")); + Assert.IsTrue(GameConstants.SaveFiles.Island.EndsWith(".json")); } [Test] @@ -137,7 +139,8 @@ public void Battle_RaidSupportSkillPowerMultiplier_IsBelowLeader() [Test] public void Battle_RaidBossHpMultiplier_MatchesCurrentDamageMath() { - Assert.AreEqual(4.5f, GameConstants.Battle.RaidBossHpMultiplier); + // 4.5(전투 길이 3종에서 파생) × 치명타 기대 이득 1.031 → 4.65. 치명타를 바꾸면 함께 다시 잰다. + Assert.AreEqual(4.65f, GameConstants.Battle.RaidBossHpMultiplier); } // ── 전투 길이 3종 ── diff --git a/Assets/Tests/EditMode/GuardianBadgeTests.cs b/Assets/Tests/EditMode/GuardianBadgeTests.cs new file mode 100644 index 0000000..7009c73 --- /dev/null +++ b/Assets/Tests/EditMode/GuardianBadgeTests.cs @@ -0,0 +1,208 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using InsectGame.Core; +using InsectGame.Data; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 수문장 배지 — 표의 무결성(리전·그림·보상 아이템), 이정표 계산, 그리고 서비스가 + /// 첫 격파에서만 배지를 알리고 이정표를 한 번만 주는지. + /// + /// 배지 표가 리전과 어긋나도 런타임엔 아무 말이 없다 — 그 리전 수문장을 이겨도 연출이 안 뜨고, + /// 케이스엔 영영 빈 칸이 남는다. 그림이 빠져도 원판으로 대신 그려질 뿐 경고 한 줄이다. + /// + [TestFixture] + public class GuardianBadgeTests + { + private const string UnlockKey = "InsectGame.UnlockedRegions"; + private const string GuardianKey = "InsectGame.DefeatedGuardians"; + + // ── 표 ── + + [Test] + public void Table_OneBadgePerGuardianRegion_InLevelOrder() + { + var guarded = new Dictionary(); + foreach (RegionData r in RegionDefinitions.CreateAll()) + if (!string.IsNullOrEmpty(r.guardianInsectId)) guarded[r.regionId] = r; + + GuardianBadges.Badge[] all = GuardianBadges.All(); + Assert.AreEqual(guarded.Count, all.Length, "수문장이 있는 리전마다 배지가 하나"); + var seen = new HashSet(); + int prevLevel = 0; + foreach (GuardianBadges.Badge b in all) + { + Assert.IsTrue(seen.Add(b.regionId), $"중복 {b.regionId}"); + Assert.IsTrue(guarded.TryGetValue(b.regionId, out RegionData r), $"{b.regionId}: 수문장 리전 아님"); + Assert.GreaterOrEqual(r.requiredLevel, prevLevel, $"{b.regionId}: 표시 순서는 리전 요구 레벨 순서"); + prevLevel = r.requiredLevel; + } + } + + [Test] + public void Table_NamesAndFlavor_ShortAndClean() + { + var names = new HashSet(); + foreach (GuardianBadges.Badge b in GuardianBadges.All()) + { + Assert.IsFalse(string.IsNullOrEmpty(b.name), b.regionId); + Assert.IsTrue(b.name.EndsWith("배지"), b.name); + Assert.LessOrEqual(b.name.Length, 8, b.name); + Assert.IsTrue(names.Add(b.name), $"이름 중복 {b.name}"); + Assert.IsFalse(string.IsNullOrEmpty(b.flavor), b.regionId); + Assert.LessOrEqual(b.flavor.Length, 24, $"{b.regionId}: 새김글은 한 줄에 들어가야 한다"); + // 「무명」은 이름으로 부르지 않는다(StoryBible 2장). + StringAssert.DoesNotContain("무명", b.name + b.flavor); + } + } + + [Test] + public void Art_EveryBadgeAndEffectTextureExists() + { + string dir = Path.Combine(Application.dataPath, "Resources", GuardianBadges.ArtFolder); + foreach (GuardianBadges.Badge b in GuardianBadges.All()) + Assert.IsTrue(File.Exists(Path.Combine(dir, "badge_" + b.regionId + ".png")), + $"그림 없음: {b.regionId} — Tools/Badges/guardian_badges.py를 돌릴 것"); + foreach (string fx in new[] { "badge_rays", "badge_glow", "badge_sparkle" }) + Assert.IsTrue(File.Exists(Path.Combine(dir, fx + ".png")), fx); + } + + [Test] + public void Milestones_AscendReachTotal_RewardsExist() + { + ItemDatabase items = ItemDatabase.CreateRuntimeDefault(); + try + { + int prev = 0; + GuardianBadges.Milestone[] all = GuardianBadges.AllMilestones(); + Assert.AreEqual(all.Length, GuardianBadges.MilestoneCount); + foreach (GuardianBadges.Milestone m in all) + { + Assert.Greater(m.count, prev, "이정표는 오름차순"); + Assert.LessOrEqual(m.count, GuardianBadges.Total); + Assert.IsFalse(string.IsNullOrEmpty(m.title), m.count.ToString()); + Assert.IsNotNull(m.rewards); + Assert.Greater(m.rewards.Length, 0, m.count.ToString()); + foreach (GuardianBadges.Reward r in m.rewards) + { + Assert.IsNotNull(items.FindById(r.itemId), $"{m.count}: 아이템 {r.itemId} 없음"); + Assert.Greater(r.count, 0); + } + prev = m.count; + } + Assert.AreEqual(GuardianBadges.Total, prev, "마지막 이정표는 전부 모으기"); + } + finally { Object.DestroyImmediate(items); } + } + + // ── 계산 ── + + [Test] + public void Claimable_OnlyReachedAndUnclaimed_Ascending() + { + CollectionAssert.IsEmpty(GuardianBadges.Claimable(3, new HashSet())); + CollectionAssert.AreEqual(new[] { 4 }, GuardianBadges.Claimable(4, new HashSet())); + CollectionAssert.AreEqual(new[] { 8 }, GuardianBadges.Claimable(9, new HashSet { 4 })); + CollectionAssert.AreEqual(new[] { 4, 8, 13 }, GuardianBadges.Claimable(13, null)); + CollectionAssert.IsEmpty(GuardianBadges.Claimable(13, new HashSet { 4, 8, 13 })); + } + + [Test] + public void ParseClaimed_DropsGarbage_FormatSorts() + { + HashSet set = GuardianBadges.ParseClaimed(" 8, 4,x,5,,13,4"); + CollectionAssert.AreEquivalent(new[] { 4, 8, 13 }, set); + Assert.AreEqual("4,8,13", GuardianBadges.FormatClaimed(set)); + CollectionAssert.IsEmpty(GuardianBadges.ParseClaimed(null)); + } + + [Test] + public void CountEarned_CountsOnlyTableRegions() + { + Assert.AreEqual(GuardianBadges.Total, GuardianBadges.CountEarned(_ => true)); + Assert.AreEqual(1, GuardianBadges.CountEarned(id => id == "pond" || id == "not_a_region")); + Assert.AreEqual(0, GuardianBadges.CountEarned(null)); + } + + // ── 저장 경로 ── + + [Test] + public void CloudDto_CarriesClaimedField_WithEmptyDefault() + { + FieldInfo f = typeof(GameSaveData).GetField("badgeMilestonesClaimed"); + Assert.IsNotNull(f, "클라우드에 안 오르면 기기를 바꿀 때마다 같은 보상을 다시 받는다"); + Assert.AreEqual("", f.GetValue(new GameSaveData())); + + FieldInfo keys = typeof(SaveScope).GetField("ScopedStringPrefsKeys", BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(keys); + CollectionAssert.Contains((string[])keys.GetValue(null), GameConstants.PrefsKeys.BadgeMilestonesClaimed, + "계정 삭제 뒤 같은 uid로 돌아오면 수령 기록이 부활한다"); + } + + // ── 서비스 ── + + [Test] + public void Service_AwardsOnFirstDefeatOnly_AndGrantsMilestoneOnce() + { + string[] keys = { UnlockKey, GuardianKey, GameConstants.PrefsKeys.BadgeMilestonesClaimed }; + var backup = new Dictionary(); + foreach (string k in keys) + { + string scoped = SaveScope.PrefsKey(k); + if (PlayerPrefs.HasKey(scoped)) backup[scoped] = PlayerPrefs.GetString(scoped); + PlayerPrefs.DeleteKey(scoped); + } + + var host = new GameObject("GuardianBadgeTests"); + try + { + RegionManager regions = host.AddComponent(); + regions.Initialize(RegionDefinitions.CreateAll()); + GuardianBadgeService service = host.AddComponent(); + service.AutoWire(regions, null); + + var awards = new List(); + service.BadgeAwarded += awards.Add; + + foreach (string id in new[] { "meadow", "pond", "forest" }) regions.DefeatGuardian(id); + Assert.AreEqual(3, awards.Count); + Assert.AreEqual(3, awards[2].earned); + CollectionAssert.IsEmpty(awards[2].milestones); + + regions.DefeatGuardian("garden"); + Assert.AreEqual(4, awards.Count); + CollectionAssert.AreEqual(new[] { 4 }, awards[3].milestones, "4번째 배지와 함께 이정표를 준다"); + Assert.IsTrue(service.IsClaimed(4)); + Assert.IsFalse(service.TryClaim(4), "한 번만 받는다"); + + // 이미 깬 수문장을 필드에서 다시 이긴 경우 — 봉인만 걷고 배지는 다시 안 뜬다. + Assert.IsFalse(regions.TryDefeatGuardian("meadow", "test")); + regions.DefeatGuardian("meadow"); + Assert.AreEqual(4, awards.Count); + Assert.AreEqual(4, service.EarnedCount); + Assert.AreEqual(0, service.ClaimableCount); + + // 수령 기록은 다시 읽어도 남는다(클라우드 적용 뒤 경로). + service.ReloadFromDisk(); + Assert.IsTrue(service.IsClaimed(4)); + } + finally + { + Object.DestroyImmediate(host); + foreach (string k in keys) + { + string scoped = SaveScope.PrefsKey(k); + if (backup.TryGetValue(scoped, out string v)) PlayerPrefs.SetString(scoped, v); + else PlayerPrefs.DeleteKey(scoped); + } + PlayerPrefs.Save(); + } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/GuardianBadgeTests.cs.meta b/Assets/Tests/EditMode/GuardianBadgeTests.cs.meta new file mode 100644 index 0000000..79312bf --- /dev/null +++ b/Assets/Tests/EditMode/GuardianBadgeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0c235030cccf2944b91a88fc21067330 \ No newline at end of file diff --git a/Assets/Tests/EditMode/GuardianIntroTests.cs b/Assets/Tests/EditMode/GuardianIntroTests.cs new file mode 100644 index 0000000..8dc6b0c --- /dev/null +++ b/Assets/Tests/EditMode/GuardianIntroTests.cs @@ -0,0 +1,64 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Core; +using InsectGame.Data; +using NUnit.Framework; + +namespace InsectGame.Tests +{ + /// + /// 수문장 등장 화면의 글() — 수문장이 있는 리전을 빠짐없이 덮는지, 아이가 읽을 길이인지. + /// 빠진 리전은 예외 없이 별칭·등장 줄만 비어서 뜬다(그 수문장만 밋밋하다). 그리기는 ui-dev(레이드 시작 화면). + /// + [TestFixture] + public class GuardianIntroTests + { + private static readonly string[] RetiredWords = { "무명", "봉인", "지워진 개체", "예비 울타리" }; + + [Test] + public void EveryGuardianRegion_HasAnIntro_AndNoStrays() + { + var guardianRegions = new HashSet(); + foreach (RegionData r in RegionDefinitions.CreateAll()) + if (r != null && !string.IsNullOrEmpty(r.guardianInsectId)) guardianRegions.Add(r.regionId); + Assume.That(guardianRegions.Count, Is.GreaterThan(0), "리전 정의를 못 읽었다"); + + var covered = new HashSet(); + foreach (GuardianIntros.Intro intro in GuardianIntros.All()) + { + Assert.IsTrue(covered.Add(intro.regionId), $"{intro.regionId}: 두 번 적혔다"); + Assert.IsTrue(guardianRegions.Contains(intro.regionId), $"{intro.regionId}: 수문장이 없는 리전이다(오타?)"); + } + CollectionAssert.AreEquivalent(guardianRegions, covered, "수문장 리전과 등장 글이 1:1이어야 한다"); + } + + [Test] + public void Intros_AreShortAndPlain() + { + foreach (GuardianIntros.Intro intro in GuardianIntros.All()) + { + Assert.IsFalse(string.IsNullOrWhiteSpace(intro.epithet), $"{intro.regionId}: 별칭 없음"); + Assert.IsFalse(string.IsNullOrWhiteSpace(intro.line), $"{intro.regionId}: 등장 줄 없음"); + Assert.LessOrEqual(intro.epithet.Length, GuardianIntros.MaxEpithetChars, $"{intro.regionId}: {intro.epithet}"); + Assert.LessOrEqual(intro.line.Length, GuardianIntros.MaxLineChars, $"{intro.regionId}: {intro.line}"); + foreach (string word in RetiredWords) + { + StringAssert.DoesNotContain(word, intro.epithet, intro.regionId); + StringAssert.DoesNotContain(word, intro.line, intro.regionId); + } + // 「그림자」는 울타리 밖의 그것에만 쓴다(StoryBible 2장) — 수문장 묘사에 섞으면 아이가 적과 헷갈린다. + StringAssert.DoesNotContain("그림자", intro.epithet + intro.line, intro.regionId); + } + } + + [Test] + public void TryGet_KnownAndUnknown() + { + Assert.IsTrue(GuardianIntros.TryGet("meadow", out GuardianIntros.Intro meadow)); + Assert.AreEqual("meadow", meadow.regionId); + Assert.IsFalse(GuardianIntros.TryGet("nowhere", out _)); + Assert.IsFalse(GuardianIntros.TryGet(null, out _)); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/GuardianIntroTests.cs.meta b/Assets/Tests/EditMode/GuardianIntroTests.cs.meta new file mode 100644 index 0000000..c5c9ffc --- /dev/null +++ b/Assets/Tests/EditMode/GuardianIntroTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d4097c8c78ef40c459931b3c08345dce \ No newline at end of file diff --git a/Assets/Tests/EditMode/GuardianLocationTests.cs b/Assets/Tests/EditMode/GuardianLocationTests.cs index bb64cd6..297aae8 100644 --- a/Assets/Tests/EditMode/GuardianLocationTests.cs +++ b/Assets/Tests/EditMode/GuardianLocationTests.cs @@ -15,10 +15,9 @@ namespace InsectGame.Tests /// 종 ID + 레벨: 리전 13곳 중 9곳은 수문장 종이 자기 리전 야생 풀에도 있고, /// 그중 8곳은 야생 스폰 상한이 옛 임계(guardianLevel - 2)를 넘었다. 야생을 이기면 /// 리전이 열렸다. - /// 수문장 자리 15m 반경: InsectSpawner.RelocateSpawnPoints가 현재 리전 - /// 스폰포인트를 플레이어로부터 10~43m 나선 위로 끌어오고 거기서 SpawnPoint.radius(5m)만큼 - /// 흩어지므로, 수문장 앞에 선 순간 야생이 5m까지 들어온다. 반경을 좁혀도 스폰 링이 - /// 플레이어를 따라오는 한 같은 결함이 남는다. + /// 수문장 자리 15m 반경: 당시 스포너가 현재 리전 스폰포인트를 플레이어로부터 10~43m + /// 나선 위로 끌어오고 거기서 5m만큼 흩었으므로, 수문장 앞에 선 순간 야생이 5m까지 들어왔다. + /// 지금은 야생이 리전 원판 전체에 기록돼 흩어지지만 수문장 앞을 비켜 두지 않으니 같은 결함이 남는다. /// /// /// 그래서 판정 근거를 개체 표식()으로 옮겼다. diff --git a/Assets/Tests/EditMode/HudOverlapSweepTests.cs b/Assets/Tests/EditMode/HudOverlapSweepTests.cs new file mode 100644 index 0000000..5a27c6b --- /dev/null +++ b/Assets/Tests/EditMode/HudOverlapSweepTests.cs @@ -0,0 +1,585 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using InsectGame.Capture; +using InsectGame.Core; +using InsectGame.Story; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 필드·동굴·나의 섬·남의 섬·「챔피언의 꿈」 섬 위에 함께 뜰 수 있는 모든 IMGUI 요소의 모든 쌍이 겹치지 않는지 — + /// 화면 18장(데스크톱 6, 모바일 세로 2·가로 2 × 배율 0.667/1/1.333)에서 잰다. + /// + /// 자리는 전부 그 화면 파일의 순수 배치 함수를 부른다(식을 여기서 다시 세우지 않는다) — 배치를 고치면 이 검사가 따라온다. + /// 함께 뜰 수 없는 쌍만 이 뺀다. 뺄 때는 그 근거가 된 코드(숨김 조건)를 적는다 — + /// 근거가 사라지면(숨김 조건을 지우면) 그 행도 지울 것. + /// + /// 2026-10-03 전수 검사 전엔 같은 화면들에서 204쌍이 겹쳤다(포획 결과 카드·퀘스트 완료·소식 카드·동굴 출입 알림이 화면 위 가운데와 + /// 가운데 줄에 저마다 자리를 잡았고, 필드 멀티 판은 픽셀 좌표로 우상단을 덮었다). 잠깐 뜨는 카드는 이제 가운데 무대() + /// 한 자리에 차례로 선다. 무대는 섬 HUD가 서 있을 때만 그것을 피하므로() 무대에 서는 것은 섬과 그 밖에서 + /// 따로 잰다 — 한때 필드에서도 섬 열을 피해 세로 화면의 카드가 왼쪽 절반으로 밀렸다. + /// + [TestFixture] + public class HudOverlapSweepTests + { + [Flags] + private enum Ctx + { + None = 0, + Field = 1, // 필드(본 마을·리전) + Cave = 2, // 동굴·숨겨진 장소(서브에리어) + IslandOwn = 4, // 나의 섬 + IslandVisit = 8, // 남의 섬 구경 + Dream = 16, // 「챔피언의 꿈」의 섬 걷기 + Island = IslandOwn | IslandVisit, + Play = Field | Cave | IslandOwn | IslandVisit, + } + + private enum StageKind { None, Queued, Fixed } + + // 쌍 제외 규칙이 알아보는 요소. + private enum Kind + { + Other, StatusTab, StatusPanel, Minimap, Quest, Coach, IslandGuide, CatchButton, CatchRing, + JoystickZone, JoystickHint, Talk, Prompt, Nearby, Gate, YieldsToStatusPanel, + } + + private sealed class El + { + public string Name; + public Rect Rect; + public Ctx Ctx; + public Kind Kind; + public StageKind Stage; + /// 무대에 선 카드와 겹치면 그리지 않는다(). + public bool YieldsToStage; + /// 조작이 묶였을 때(IsFrozen)만 뜬다. + public bool FrozenOnly; + /// 조작이 묶이면 숨는다. + public bool HiddenWhenFrozen; + /// 같은 묶음의 다른 변형과는 동시에 뜨지 않는다(한 번에 하나). + public string Group; + public int Variant; + /// 무대 항목 — 섬 HUD가 서 있는 판(의 true)으로 잰 자리. + public bool IslandHud; + } + + // ── 화면 ── + + private static IEnumerable Screens() + { + yield return Screen("desktop 1280x720", 1280f, 720f, 0f, 0f, 0f, 0f, false); + yield return Screen("desktop 1366x768", 1366f, 768f, 0f, 0f, 0f, 0f, false); + yield return Screen("desktop 1920x1080", 1920f, 1080f, 0f, 0f, 0f, 0f, false); + yield return Screen("desktop 1920x1200", 1920f, 1200f, 0f, 0f, 0f, 0f, false); + yield return Screen("desktop 2560x1080", 2560f, 1080f, 0f, 0f, 0f, 0f, false); + yield return Screen("desktop 2560x1440", 2560f, 1440f, 0f, 0f, 0f, 0f, false); + foreach (float s in new[] { 2f / 3f, 1f, 4f / 3f }) + { + string k = s.ToString("0.###"); + yield return Screen($"portrait 16x9 s{k}", 1080f * s, 1920f * s, 0f, 0f, 0f, 0f, true); + yield return Screen($"portrait 20x9 notch s{k}", 1080f * s, 2400f * s, 0f, 0f, 100f * s, 40f * s, true); + yield return Screen($"landscape 16x9 s{k}", 1920f * s, 1080f * s, 0f, 0f, 0f, 0f, true); + yield return Screen($"landscape 20x9 notch s{k}", 2400f * s, 1080f * s, 100f * s, 100f * s, 0f, 30f * s, true); + } + } + + private static IEnumerable MobileScreens() => Screens().Where(c => (bool)c.Arguments[7]); + + private static TestCaseData Screen(string name, float pw, float ph, float safeLeft, float safeRight, float safeTop, + float safeBottom, bool mobilePlatform) + => new TestCaseData(name, pw, ph, safeLeft, safeRight, safeTop, safeBottom, mobilePlatform); + + private static HudFrame Frame(float pw, float ph, float safeLeft, float safeRight, float safeTop, float safeBottom, bool mobile) + => HudFrame.ForScreen(pw, ph, safeLeft, safeRight, safeTop, safeBottom, mobile); + + // ── 요소 — 전부 화면 파일의 순수 배치 함수 ── + + private static List Elements(HudFrame f) + { + var e = new List(); + // 가운데 무대는 섬 HUD가 서 있을 때만 섬 HUD를 피한다(HudFrame.IslandHud) — 무대에 기대는 자리는 섬과 그 밖에서 따로 잰다. + HudFrame onField = f.WithIslandHud(false); + HudFrame onIsland = f.WithIslandHud(true); + void Add(string name, Rect rect, Ctx ctx, Kind kind = Kind.Other, bool frozenHidden = false, bool yields = false, + string group = null, int variant = 0) + { + e.Add(new El + { + Name = name, Rect = rect, Ctx = ctx, Kind = kind, HiddenWhenFrozen = frozenHidden, YieldsToStage = yields, + Group = group, Variant = variant, + }); + } + + // 왼쪽 위 — 상태 탭·패널(PlayerStatusHUD), 미니맵(MinimapUI). + Add("상태 탭", PlayerStatusHUD.TabRect(f), Ctx.Play, Kind.StatusTab); + Add("상태 패널(펼침)", PlayerStatusHUD.PanelRect(f), Ctx.Play, Kind.StatusPanel); + Add("미니맵", MinimapUI.PanelRect(f), Ctx.Play, Kind.Minimap); + + // 단축 바(QuickAccessBarUI — IsInputBlocked: 조작이 묶이면 숨는다). + Add("단축 바", QuickAccessBarUI.ShortcutBarRectFor(f), Ctx.Play, frozenHidden: true); + + // 퀘스트 칩·목표 행·복원 버튼(TutorialQuestUI) — 한 번에 한 모양. 목표 행은 「왜」(트래커의 이유 한 줄)가 있으면 두 줄 높이다 + // (QuestChipLayout.RowHeightFor) — 데스크톱은 칩이 그만큼 더 올라가고 모바일은 행이 아래로 길어지므로 두 높이를 다 잰다. + float rowH = QuestChipLayout.RowHeight; + float whyRowH = QuestChipLayout.RowHeightWithWhy; + var chips = new (string name, float height, float row)[] + { + ("펼친 칩", QuestChipLayout.ExpandedHeight, rowH), ("펼친 칩·이유", QuestChipLayout.ExpandedHeight, whyRowH), + ("펼친 칩·행 없음", QuestChipLayout.ExpandedHeight, 0f), + ("완료 칩", QuestChipLayout.DoneHeight, rowH), ("완료 칩·이유", QuestChipLayout.DoneHeight, whyRowH), + ("완료 칩·행 없음", QuestChipLayout.DoneHeight, 0f), + }; + for (int i = 0; i < chips.Length; i++) + { + Rect chip = QuestChipLayout.ChipRect(f, chips[i].height, chips[i].row); + Add("퀘스트 " + chips[i].name, chip, Ctx.Play, Kind.Quest, group: "퀘스트", variant: i); + if (chips[i].row > 0f) + Add("목표 행(" + chips[i].name + ")", QuestChipLayout.Row(chip, QuestChipLayout.StackWidth(f), chips[i].row), Ctx.Play, + Kind.Quest, group: "퀘스트", variant: i); + } + foreach ((string tag, float h, int variant) in new[] { ("", rowH, 10), ("·이유", whyRowH, 12) }) + { + Rect restore = QuestChipLayout.RestoreRect(f, h); + Add("퀘스트 복원 버튼" + tag, restore, Ctx.Play, Kind.Quest, group: "퀘스트", variant: variant); + Add("목표 행(복원 버튼" + tag + ")", QuestChipLayout.Row(restore, QuestChipLayout.StackWidth(f), h), Ctx.Play, Kind.Quest, + group: "퀘스트", variant: variant); + } + Add("퀘스트 복원 버튼·행 없음", QuestChipLayout.RestoreRect(f, 0f), Ctx.Play, Kind.Quest, group: "퀘스트", variant: 11); + + // 위 가운데 — 리전 배너(KeyGuideHUD)·내기 점수판(FieldMomentsUI — Hidden: 조작이 묶이면 숨는다). + Add("리전 배너", KeyGuideHUD.RegionBannerRect(f), Ctx.Play, Kind.YieldsToStatusPanel); + Add("내기 점수판", FieldMomentsUI.RaceChipRect(f), Ctx.Play, Kind.YieldsToStatusPanel, frozenHidden: true); + if (!f.Mobile) Add("포획 아이템 패널", KeyGuideHUD.CaptureItemsRectFor(f), Ctx.Play); + + // 시각·날씨 칩과 변화 알림(WorldClockHUD — 조작이 묶이면 숨는다. 동굴엔 알림이 없다(Indoors)). + // 세로 모바일 섬은 섬 HUD 열 아래로 옮긴다 — 아래 섬 쪽에서 따로 넣는다. + Ctx clockOnIsland = f.Mobile && f.Portrait ? Ctx.None : Ctx.Island; + Rect fieldChip = WorldClockRules.FieldChip(f); + Add("시각 칩", fieldChip, Ctx.Field | Ctx.Cave | clockOnIsland, frozenHidden: true); + Add("변화 알림", WorldClockRules.NoticeBelow(f, fieldChip), Ctx.Field | clockOnIsland, frozenHidden: true); + + // 안내 배너 — 첫 가이드 코치(GuidedTutorialController)·섬 안내(IslandGuideUI, 내 섬). 둘 다 무대에 카드가 서면 비켜선다. + Add("코치 배너", GuidedTutorialController.CoachRect(f), Ctx.Play, Kind.Coach, yields: true); + Add("섬 안내 배너", IslandGuideUI.CoachRect(f, false), Ctx.IslandOwn, Kind.IslandGuide, yields: true); + + // 섬 HUD(IslandHudUI — 조작이 묶이면 숨는다). + Add("섬 HUD 판", IslandHudLayout.OwnPanel(f), Ctx.IslandOwn, frozenHidden: true); + Add("섬 HUD 판(구경)", IslandHudLayout.VisitPanel(f), Ctx.IslandVisit, frozenHidden: true); + if (!f.Mobile) Add("섬 정보 줄", IslandHudLayout.DesktopInfo(IslandHudLayout.OwnPanel(f)), Ctx.IslandOwn, frozenHidden: true); + if (f.Mobile && f.Portrait) + { + foreach (bool visiting in new[] { false, true }) + { + Ctx ctx = visiting ? Ctx.IslandVisit : Ctx.IslandOwn; + string tag = visiting ? "(구경)" : ""; + Rect chip = WorldClockRules.Chip(f, true, visiting); + Add("섬 시각 칩" + tag, chip, ctx, frozenHidden: true); + Add("섬 변화 알림" + tag, WorldClockRules.NoticeBelow(f, chip), ctx, frozenHidden: true); + } + } + + // 잡기 버튼·고리·글자(CaptureInputController — anyUI: 조작이 묶이면 숨는다. 꿈 섬에서도 뜬다). 글자는 한 번에 하나(feedbackMessage). + Ctx catchCtx = Ctx.Play | Ctx.Dream; + Add("잡기 버튼", CatchButtonLayout.ButtonRect(f), catchCtx, Kind.CatchButton, frozenHidden: true); + Add("잡기 고리(최대)", CatchButtonLayout.SwingRect(f), catchCtx, Kind.CatchRing, frozenHidden: true); + Add("잡기 놓침 글자", CatchButtonLayout.FeedbackRect(f, true), catchCtx, frozenHidden: true, group: "잡기 글자", variant: 0); + Add("잡기 안내 글자", CatchButtonLayout.FeedbackRect(f, false), catchCtx, frozenHidden: true, group: "잡기 글자", variant: 1); + Add("습격 경고 글자", CatchButtonLayout.WarnRect(f), catchCtx, frozenHidden: true, group: "잡기 글자", variant: 2); + + // 마을 상호작용(WorldInteractionController) — 대화 버튼·안내 글자는 무대에 카드가 서면 비켜선다. 마을 건물·주민은 본 월드에만 있다. + Add("대화 버튼", WorldInteractionController.TalkRect(f), Ctx.Field, Kind.Talk, yields: true); + Add("상호작용 안내 글자", WorldInteractionController.PromptRect(f), Ctx.Field, Kind.Prompt, yields: true); + Add("상호작용 버튼", WorldInteractionController.InteractButtonRect(f), Ctx.Field); + + // 동굴 입구·나가기(SubAreaWorldBuilder — IsSubAreaActionBlocked: 조작이 묶이면 숨는다. 섬은 걸러진다(detached)). + Add("동굴 입구 버튼", SubAreaWorldBuilder.GateRect(f), Ctx.Field, Kind.Gate, frozenHidden: true); + Add("동굴 나가기 버튼", SubAreaWorldBuilder.GateRect(f), Ctx.Cave, Kind.Gate, frozenHidden: true); + + // 설정 버튼(AccountSettingsUI — 꿈에서는 숨는다). + Add("설정 버튼", AccountSettingsUI.OpenButtonRect(f), Ctx.Play); + + // 이동 잠금 안내(PlayerHintOverlay — 조작이 묶였을 때만, 꿈에서는 숨는다). + e.Add(new El { Name = "이동 잠금 글자", Rect = PlayerHintOverlay.FrozenRect(f), Ctx = Ctx.Play, FrozenOnly = true }); + + // 가상 조이스틱(VirtualJoystickUI — 모바일 배치만, 조작이 묶이면 꺼진다). + if (VirtualJoystickUI.EnabledFor(f)) + { + Add("조이스틱 자리", VirtualJoystickUI.ZoneRect(f), Ctx.Play | Ctx.Dream, Kind.JoystickZone, frozenHidden: true); + Add("조이스틱 안내 원", VirtualJoystickUI.HintRect(f), Ctx.Play | Ctx.Dream, Kind.JoystickHint, frozenHidden: true); + } + + // 필드 멀티(WorldFieldMultiplayerUI.FieldVisible — 섬·꿈·창·조작 잠금에서 물러난다). + Ctx net = Ctx.Field | Ctx.Cave; + Add("멀티 필드 상태", WorldFieldMultiplayerUI.StatusRect(f), net, frozenHidden: true); + Add("멀티 대화 기록", WorldFieldMultiplayerUI.MessagesRect(onField), net, Kind.YieldsToStatusPanel, frozenHidden: true, + yields: f.Mobile && f.Portrait); // 세로는 무대 윗변에 맞춘다 — 섬에서는 뜨지 않으니 필드 무대 + Add("멀티 근처 탐험가", WorldFieldMultiplayerUI.NearbyRect(f), net, Kind.Nearby, frozenHidden: true, yields: true); + + // 가운데 무대(HudStage) — 차례 항목은 한 번에 하나, 고정 항목은 정해진 칸. 섬(내 섬·남의 섬)에서는 섬 HUD가 서 있는 판으로, + // 그 밖(필드·동굴)에서는 섬 HUD가 없는 판으로 잰다 — 섬 쪽 이름에는 "(섬)"을 붙인다(한 장소만이면 붙이지 않는다). + void OnStage(string name, Func place, Ctx ctx, StageKind kind) + { + Ctx outside = ctx & ~Ctx.Island; + Ctx inside = ctx & Ctx.Island; + if (outside != Ctx.None) + e.Add(new El { Name = name, Rect = place(onField), Ctx = outside, Stage = kind }); + if (inside != Ctx.None) + e.Add(new El + { + Name = outside != Ctx.None ? name + "(섬)" : name, Rect = place(onIsland), Ctx = inside, Stage = kind, + IslandHud = true, + }); + } + void Queued(string name, Func place, Ctx ctx) => OnStage(name, place, ctx, StageKind.Queued); + void Fixed(string name, Func place, Ctx ctx) => OnStage(name, place, ctx, StageKind.Fixed); + Queued("포획 성공 카드", CapturePopupUI.SuccessRect, Ctx.Play); + Queued("포획 실패 카드", CapturePopupUI.FailRect, Ctx.Play); + Queued("퀘스트 완료 알림", x => TutorialQuestUI.QuestDoneRect(x, true), Ctx.Play); + Queued("다음 퀘스트 알림", x => TutorialQuestUI.QuestNextRect(x, true), Ctx.Play); + Queued("소식 카드", FieldMomentsUI.ToastFootprint, Ctx.Play); + Queued("섬 결과 토스트", IslandHudUI.ToastRect, Ctx.Island); + Queued("대결 결과 글자", WorldInteractionController.DuelResultRect, Ctx.Field); + Queued("계정 알림", AccountSettingsUI.FieldMessageRect, Ctx.Play); + Queued("멀티 초대", WorldFieldMultiplayerUI.InviteRect, net); + Queued("멀티 안내", WorldFieldMultiplayerUI.ToastRect, net); + Queued("잠긴 리전 글자", PlayerHintOverlay.BlockedRect, Ctx.Field); + Fixed("미니게임 결과 칸", MinigameSlot, Ctx.Play); + Fixed("장소 진입 알림", PlayerStatusHUD.SubAreaAlertRect, Ctx.Cave | Ctx.Island); + Fixed("동굴 출입 토스트", SubAreaWorldBuilder.ToastRect, Ctx.Field | Ctx.Cave); + + // 「챔피언의 꿈」 섬 걷기(DreamPrologueDirector) — 필드 HUD는 다 숨고 잡기 버튼·조이스틱만 남는다. + Add("꿈 안내 알약", DreamPrologueDirector.HintPillRect(f, DreamPrologueDirector.IslandPillTop), Ctx.Dream); + Add("꿈 섬 카드", DreamPrologueDirector.IslandCardRect(f), Ctx.Dream); + Add("꿈 건너뛰기", DreamPrologueDirector.SkipRect(f, false), Ctx.Dream); + return e; + } + + private static Rect MinigameSlot(HudFrame f) + => HudStage.Place(f, HudStageItem.MinigameResult, HudStage.MinigameSlotWidth, HudStage.MinigameSlotHeight); + + // ── 함께 뜰 수 없는 쌍 ── + + /// + /// 두 요소가 함께 뜰 수 없으면 그 근거, 뜰 수 있으면 null. 근거는 전부 코드의 숨김 조건이다. + /// + private static string Exclusion(El a, El b, HudFrame f) + { + if ((a.Ctx & b.Ctx) == 0) return "다른 장소"; + if (a.Group != null && a.Group == b.Group && a.Variant != b.Variant) return "한 번에 한 모양"; + if (a.FrozenOnly && b.HiddenWhenFrozen || b.FrozenOnly && a.HiddenWhenFrozen) return "조작 잠금"; + + // 무대 — HudStage.Granted: 차례 항목은 한 번에 하나, 고정 항목이 서 있으면 차례 항목은 기다린다. + if (a.Stage != StageKind.None && b.Stage != StageKind.None && !(a.Stage == StageKind.Fixed && b.Stage == StageKind.Fixed)) + return "무대 차례"; + // 무대 안에 서는 가운데 것들은 서 있는 카드와 겹치면 비켜선다(HudStage.OccupiedOver) — 겹치는 쌍은 함께 보이지 않는다. + if (a.Stage != StageKind.None && b.YieldsToStage || b.Stage != StageKind.None && a.YieldsToStage) return "무대에 비켜섬"; + + if (Pair(a, b, Kind.StatusTab, Kind.StatusPanel)) return "상태 탭↔패널(IsExpanded로 하나만)"; + if (Pair(a, b, Kind.StatusPanel, Kind.Minimap)) return "펼친 패널 아래 미니맵(LeftStackOccluded)"; + if (Pair(a, b, Kind.StatusPanel, Kind.YieldsToStatusPanel)) return "펼친 패널과 겹치면 비켜섬"; + if (f.Mobile && Pair(a, b, Kind.StatusPanel, Kind.Quest)) return "모바일 퀘스트 칩은 펼친 패널 밑에서 숨음"; + if (Pair(a, b, Kind.Coach, Kind.IslandGuide)) return "코치가 섬 안내에 비켜섬(HudPresence)"; + if (Pair(a, b, Kind.CatchRing, Kind.CatchButton)) return "고리는 잡기 버튼의 탭 효과"; + if (Pair(a, b, Kind.JoystickZone, Kind.JoystickHint)) return "안내 원은 조이스틱 자리 표시"; + // 조이스틱 자리는 그리는 것이 아니라 입력 영역이다 — 등록된 HUD 위에서는 시작하지 않는다(CanBeginAt). 따로 잰다. + if (a.Kind == Kind.JoystickZone || b.Kind == Kind.JoystickZone) return "입력 영역"; + + // 코치 배너는 대화 버튼·근처 탐험가·동굴 버튼이 서 있고 그 자리와 겹치면 비켜선다(GuidedTutorialController.YieldsNow). + El coach = a.Kind == Kind.Coach ? a : b.Kind == Kind.Coach ? b : null; + if (coach != null) + { + El other = coach == a ? b : a; + if ((other.Kind == Kind.Talk || other.Kind == Kind.Prompt) && coach.Rect.Overlaps(WorldInteractionController.TalkRect(f))) + return "코치가 대화 버튼에 비켜섬"; + if (other.Kind == Kind.Nearby && coach.Rect.Overlaps(WorldFieldMultiplayerUI.NearbyRect(f))) + return "코치가 근처 탐험가에 비켜섬"; + if (other.Kind == Kind.Gate && coach.Rect.Overlaps(SubAreaWorldBuilder.GateRect(f))) + return "코치가 동굴 버튼에 비켜섬"; + } + // 근처 탐험가 판은 대화 버튼과 같은 자리라 대화 버튼이 서면 비켜선다(HudPresence.Talk). + if (Pair(a, b, Kind.Nearby, Kind.Talk) || Pair(a, b, Kind.Nearby, Kind.Prompt)) return "근처 탐험가가 대화 버튼에 비켜섬"; + return null; + } + + private static bool Pair(El a, El b, Kind x, Kind y) => a.Kind == x && b.Kind == y || a.Kind == y && b.Kind == x; + + // ── 검사 ── + + [TestCaseSource(nameof(Screens))] + public void EveryPairThatCanShowTogether_DoesNotOverlap(string name, float pw, float ph, float safeLeft, float safeRight, + float safeTop, float safeBottom, bool mobile) + { + HudFrame f = Frame(pw, ph, safeLeft, safeRight, safeTop, safeBottom, mobile); + List els = Elements(f); + var failures = new List(); + var excluded = new Dictionary(); + int measured = 0; + for (int i = 0; i < els.Count; i++) + for (int j = 0; j < i; j++) + { + string why = Exclusion(els[i], els[j], f); + if (why != null) + { + excluded.TryGetValue(why, out int n); + excluded[why] = n + 1; + continue; + } + measured++; + if (els[i].Rect.Overlaps(els[j].Rect)) + failures.Add($"{els[i].Name} {els[i].Rect} ↔ {els[j].Name} {els[j].Rect}"); + } + + TestContext.WriteLine($"[HudSweep] {name}: 요소 {els.Count}, 잰 쌍 {measured}, 뺀 쌍 " + + string.Join(", ", excluded.OrderByDescending(kv => kv.Value).Select(kv => $"{kv.Key} {kv.Value}"))); + Assert.IsEmpty(failures, $"{name}: {failures.Count}쌍이 겹친다\n" + string.Join("\n", failures)); + } + + [TestCaseSource(nameof(Screens))] + public void EveryElement_StaysInsideTheSafeArea(string name, float pw, float ph, float safeLeft, float safeRight, + float safeTop, float safeBottom, bool mobile) + { + HudFrame f = Frame(pw, ph, safeLeft, safeRight, safeTop, safeBottom, mobile); + Rect safe = Rect.MinMaxRect(f.SafeLeft, f.SafeTop, f.Width - f.SafeRight, f.Height - f.SafeBottom); + var outside = new StringBuilder(); + foreach (El el in Elements(f)) + { + Rect r = el.Rect; + if (r.width < 1f || r.height < 1f) outside.AppendLine($"{el.Name} 크기가 없다 {r}"); + if (r.xMin < safe.xMin - 0.5f || r.yMin < safe.yMin - 0.5f || r.xMax > safe.xMax + 0.5f || r.yMax > safe.yMax + 0.5f) + outside.AppendLine($"{el.Name} {r} — 안전 영역 {safe}"); + } + Assert.IsTrue(outside.Length == 0, name + ":\n" + outside); + } + + [TestCaseSource(nameof(Screens))] + public void Stage_HoldsTheFixedSlots_AndEveryCardStaysOnIt(string name, float pw, float ph, float safeLeft, float safeRight, + float safeTop, float safeBottom, bool mobile) + { + HudFrame f = Frame(pw, ph, safeLeft, safeRight, safeTop, safeBottom, mobile); + List els = Elements(f); + // 무대는 섬 HUD가 서 있을 때(섬)와 없을 때(필드·동굴)가 다르다 — 둘 다 고정 칸을 담고, 그 맥락의 카드가 그 안에 선다. + foreach (bool islandHud in new[] { false, true }) + { + HudFrame g = f.WithIslandHud(islandHud); + string tag = name + (islandHud ? " 섬" : " 필드"); + Rect area = HudStage.Area(g); + Assert.AreEqual(HudStage.Area(f, islandHud), area, tag + ": Area(f)는 화면이 든 맥락(IslandHud)을 따른다"); + TestContext.WriteLine($"[HudStage] {tag}: 무대 {area}"); + Assert.GreaterOrEqual(area.width, HudStage.MinigameSlotWidth, $"{tag}: 무대 폭이 미니게임 결과 빛보다 좁다 {area}"); + Assert.GreaterOrEqual(area.height, HudStage.PlaceToastTop + SubAreaWorldBuilder.ToastHeight, + $"{tag}: 고정 칸 셋이 무대에 다 안 들어간다 {area}"); + Assert.LessOrEqual(area.width, HudStage.MaxWidth + 0.01f); + + // 고정 칸은 줄지 않는다(차례 항목만 무대 크기에 맞춰 줄어든다). + Rect slot = MinigameSlot(g); + Assert.AreEqual(HudStage.MinigameSlotWidth, slot.width, 0.01f); + Assert.AreEqual(HudStage.MinigameSlotHeight, slot.height, 0.01f); + Assert.AreEqual(HudStage.PlaceAlertHeight, PlayerStatusHUD.SubAreaAlertRect(g).height, 0.01f); + Assert.AreEqual(SubAreaWorldBuilder.ToastHeight, SubAreaWorldBuilder.ToastRect(g).height, 0.01f); + + // 결과 글자와 그 둘레로 퍼지는 빛(최대 448×224, 글자 가운데 기준)이 칸 안에 든다. + Rect label = CaptureMinigameController.ResultLabelRect(g); + AssertInside(slot, label, tag + " 미니게임 결과 글자"); + AssertInside(slot, new Rect(label.center.x - 224f, label.center.y - 112f, 448f, 224f), tag + " 미니게임 결과 빛"); + + foreach (El el in els) + if (el.Stage != StageKind.None && el.IslandHud == islandHud) + AssertInside(area, el.Rect, $"{tag} {el.Name}"); + + // 성공 카드는 정사각형 그대로 줄어든다(안의 배치가 640×640 절대 좌표다). + Rect success = CapturePopupUI.SuccessRect(g); + Assert.AreEqual(success.width, success.height, 0.01f, tag + ": 성공 카드는 정사각형"); + } + } + + // ── 무대는 섬 HUD가 서 있을 때만 그것을 피한다 ── + + private static IEnumerable PortraitScreens() => Screens().Where(c => (float)c.Arguments[2] > (float)c.Arguments[1]); + + [TestCaseSource(nameof(PortraitScreens))] + public void Stage_OnTheField_StandsInTheMiddle_AndOnTheIsland_ClearsTheIslandColumn(string name, float pw, float ph, + float safeLeft, float safeRight, float safeTop, float safeBottom, bool mobile) + { + // 2026-10-03 QA 실측(720×1280): 필드에서 포획 성공·실패 카드와 소식 카드가 화면 가운데가 아니라 왼쪽 절반(x 18~378)에 붙었다. + // 무대가 섬에서만 뜨는 섬 HUD 열(우측, 단축 바 아래)과 그 아래 섬 시각 알림을 필드에서도 피했다. + HudFrame f = Frame(pw, ph, safeLeft, safeRight, safeTop, safeBottom, mobile); + Assert.IsTrue(f.Mobile && f.Portrait, name); + Assert.IsFalse(f.IslandHud, "테스트가 세운 화면은 필드(섬 HUD 없음)다"); + Assert.AreEqual(HudStage.Area(f, false), HudStage.Area(f)); + + // 필드 — 화면 가로 가운데. 오른쪽 열(단축 바·시각 칩·알림·필드 멀티 상태)과는 겹치지 않는다(그 아래에서 시작한다). + float mid = f.Width * 0.5f; + Rect quick = QuickAccessBarUI.ShortcutBarRectFor(f); + Rect chip = WorldClockRules.FieldChip(f); + var rightColumn = new[] { quick, chip, WorldClockRules.NoticeBelow(f, chip), WorldFieldMultiplayerUI.StatusRect(f) }; + var onField = new[] + { + ("포획 성공 카드", CapturePopupUI.SuccessRect(f)), ("포획 실패 카드", CapturePopupUI.FailRect(f)), + ("소식 카드", FieldMomentsUI.ToastFootprint(f)), ("퀘스트 완료 알림", TutorialQuestUI.QuestDoneRect(f, true)), + }; + foreach ((string what, Rect r) in onField) + { + Assert.AreEqual(mid, r.center.x, 2f, $"{name}: 필드의 {what} {r}이(가) 화면 가로 가운데({mid})에 서지 않는다"); + foreach (Rect col in rightColumn) + Assert.IsFalse(r.Overlaps(col), $"{name}: 필드의 {what} {r}이(가) 오른쪽 열 {col}을 덮는다"); + } + + // 섬 — 섬 HUD 열(내 섬·남의 섬)과 그 아래 섬 시각 칩·알림을 피한다. + HudFrame isle = f.WithIslandHud(true); + var onIsland = new[] + { + ("포획 성공 카드", CapturePopupUI.SuccessRect(isle)), ("포획 실패 카드", CapturePopupUI.FailRect(isle)), + ("소식 카드", FieldMomentsUI.ToastFootprint(isle)), ("섬 결과 토스트", IslandHudUI.ToastRect(isle)), + }; + foreach (bool visiting in new[] { false, true }) + { + Rect panel = visiting ? IslandHudLayout.VisitPanel(f) : IslandHudLayout.OwnPanel(f); + Rect islandChip = WorldClockRules.Chip(f, true, visiting); + Rect islandNotice = WorldClockRules.NoticeBelow(f, islandChip); + foreach ((string what, Rect r) in onIsland) + foreach (Rect hud in new[] { panel, islandChip, islandNotice }) + Assert.IsFalse(r.Overlaps(hud), $"{name}: 섬{(visiting ? "(구경)" : "")}의 {what} {r}이(가) 섬 HUD {hud}를 덮는다"); + } + } + + [Test] + public void StageGranted_FixedAlwaysStand_QueuedStandOneAtATimeByPriority() + { + int Mask(params HudStageItem[] items) => items.Aggregate(0, (m, i) => m | 1 << (int)i); + + // 고정은 늘 선다 — 다른 담당 파일이 시간을 쥐고 있어 기다리게 할 수 없다. + Assert.IsTrue(HudStage.Granted(HudStageItem.MinigameResult, Mask(HudStageItem.MinigameResult, HudStageItem.CaptureResult))); + Assert.IsTrue(HudStage.Granted(HudStageItem.PlaceToast, Mask(HudStageItem.PlaceAlert, HudStageItem.PlaceToast))); + // 고정이 서 있으면 차례 항목은 기다린다 — 미니게임 결과 글자 다음에 포획 결과 카드. + Assert.IsFalse(HudStage.Granted(HudStageItem.CaptureResult, Mask(HudStageItem.MinigameResult, HudStageItem.CaptureResult))); + // 차례 항목끼리는 우선순위 — 포획 결과 → 퀘스트 완료 → 다음 퀘스트 → 소식. + int capture = Mask(HudStageItem.CaptureResult, HudStageItem.QuestDone, HudStageItem.Moment); + Assert.IsTrue(HudStage.Granted(HudStageItem.CaptureResult, capture)); + Assert.IsFalse(HudStage.Granted(HudStageItem.QuestDone, capture)); + Assert.IsFalse(HudStage.Granted(HudStageItem.Moment, capture)); + Assert.IsTrue(HudStage.Granted(HudStageItem.QuestDone, Mask(HudStageItem.QuestDone, HudStageItem.Moment))); + Assert.IsTrue(HudStage.Granted(HudStageItem.Moment, Mask(HudStageItem.Moment, HudStageItem.RegionLock))); + Assert.IsFalse(HudStage.Granted(HudStageItem.RegionLock, Mask(HudStageItem.Moment, HudStageItem.RegionLock))); + Assert.IsTrue(HudStage.Granted(HudStageItem.RegionLock, Mask(HudStageItem.RegionLock))); + + // 고정 셋은 서로 다른 칸 — 위에서부터 미니게임 결과, 장소 진입 알림, 동굴 출입 토스트. + var area = new Rect(100f, 200f, 800f, 700f); + Rect mg = HudStage.Place(area, HudStageItem.MinigameResult, HudStage.MinigameSlotWidth, HudStage.MinigameSlotHeight); + Rect alert = HudStage.Place(area, HudStageItem.PlaceAlert, 900f, HudStage.PlaceAlertHeight); + Rect toast = HudStage.Place(area, HudStageItem.PlaceToast, 560f, 56f); + Assert.IsFalse(mg.Overlaps(alert)); + Assert.IsFalse(alert.Overlaps(toast)); + Assert.IsFalse(mg.Overlaps(toast)); + Assert.AreEqual(area.width, alert.width, 0.01f, "넓은 것은 무대 폭으로 줄어든다"); + Assert.AreEqual(area.center.x, toast.center.x, 0.01f, "가운데 정렬"); + Assert.AreEqual(area.y, HudStage.Place(area, HudStageItem.QuestDone, 640f, 172f).y, 0.01f, "차례 항목은 무대 맨 위"); + Assert.AreEqual(0.5f, HudStage.FitScale(new Rect(0f, 0f, 320f, 900f), 640f, 640f), 0.0001f); + Assert.AreEqual(1f, HudStage.FitScale(new Rect(0f, 0f, 2000f, 2000f), 640f, 640f), 0.0001f, "키우지는 않는다"); + } + + // ── 조이스틱 자리 — 늘 떠 있는 HUD는 비워 두고, 잠깐 뜨는 것을 다 띄워도 절반 넘게 빈다 ── + + [TestCaseSource(nameof(MobileScreens))] + public void JoystickZone_NoStandingHudInside_AndPassingOverlaysLeaveMostOfItFree(string name, float pw, float ph, + float safeLeft, float safeRight, float safeTop, float safeBottom, bool mobile) + { + // 조이스틱은 등록된 HUD 위에서 시작하지 않는다(VirtualJoystickUI.CanBeginAt). 그래서 사분면을 늘 덮는 HUD가 있으면 + // 그만큼 조작 자리가 줄어든다 — 늘 떠 있는 것(필드·섬)은 사분면에 하나도 없어야 하고, 한 장소에서 잠깐 뜨는 것(안내 배너· + // 무대의 카드·대화 버튼·동굴 입구·꿈 카드·펼친 상태 패널)을 한꺼번에 다 띄워도 사분면의 절반 넘게 비어야 한다. + // 장소마다 따로 센다 — 무대 자리가 섬과 그 밖에서 달라(섬 HUD가 서 있을 때만 피한다) 섞어 합치면 함께 뜰 수 없는 두 자리를 함께 센다. + HudFrame f = Frame(pw, ph, safeLeft, safeRight, safeTop, safeBottom, mobile); + Assert.IsTrue(VirtualJoystickUI.EnabledFor(f)); + Rect zone = VirtualJoystickUI.ZoneRect(f); + Rect hint = VirtualJoystickUI.HintRect(f); + List els = Elements(f); + + var standingNames = new HashSet + { + "상태 탭", "미니맵", "단축 바", "퀘스트 펼친 칩", "목표 행(펼친 칩)", "퀘스트 펼친 칩·이유", "목표 행(펼친 칩·이유)", + "리전 배너", "내기 점수판", "시각 칩", "변화 알림", + "섬 HUD 판", "섬 HUD 판(구경)", "섬 시각 칩", "섬 변화 알림", "섬 시각 칩(구경)", "섬 변화 알림(구경)", "잡기 버튼", + "설정 버튼", "멀티 필드 상태", + }; + if (!f.Portrait) standingNames.Add("멀티 대화 기록"); // 가로는 상태 판 아래에 늘 선다(세로는 무대에 비켜선다) + foreach (El el in els.Where(x => standingNames.Contains(x.Name))) + { + Assert.IsFalse(el.Rect.Overlaps(zone), $"{name}: 늘 떠 있는 {el.Name} {el.Rect}이(가) 조이스틱 자리 {zone}를 덮는다"); + Assert.IsFalse(el.Rect.Contains(hint.center), $"{name}: 조이스틱 안내 원 가운데가 {el.Name}에 덮인다"); + } + + var passingNames = new HashSet + { + "코치 배너", "섬 안내 배너", "대화 버튼", "상호작용 안내 글자", "상호작용 버튼", "동굴 입구 버튼", "멀티 근처 탐험가", + "멀티 대화 기록", "꿈 섬 카드", "꿈 안내 알약", "상태 패널(펼침)", + }; + foreach (Ctx place in new[] { Ctx.Field, Ctx.Cave, Ctx.IslandOwn, Ctx.IslandVisit, Ctx.Dream }) + { + List covers = els + .Where(x => (x.Ctx & place) != 0 && (passingNames.Contains(x.Name) || x.Stage != StageKind.None)) + .Select(x => x.Rect).ToList(); + float free = FreeFraction(zone, covers); + TestContext.WriteLine($"[Joystick] {name} {place}: 잠깐 뜨는 것을 전부 띄워도 비는 비율 {free:P0}"); + Assert.Greater(free, 0.5f, $"{name} {place}: 잠깐 뜨는 HUD가 조이스틱 자리의 절반 넘게 덮는다"); + } + } + + [Test] + public void Joystick_DesktopLayout_IsOff() + { + // 데스크톱 배치는 좌하단에 퀘스트 칩이 선다 — 조이스틱(과 안내 원)은 모바일 배치에서만 켠다. + Assert.IsFalse(VirtualJoystickUI.EnabledFor(Frame(1920f, 1080f, 0f, 0f, 0f, 0f, false))); + Assert.IsTrue(VirtualJoystickUI.EnabledFor(Frame(1920f, 1080f, 0f, 0f, 0f, 0f, true))); + Assert.IsTrue(VirtualJoystickUI.EnabledFor(Frame(1080f, 1920f, 0f, 0f, 0f, 0f, false)), "세로로 긴 창은 모바일 배치"); + } + + [Test] + public void JoystickRects_MatchTheTouchRule() + { + // 픽셀(Y-up) 규칙(CanBeginAt·HintCenter)과 가상(Y-down) 사각형이 같은 자리를 가리킨다. + HudFrame f = Frame(1280f, 720f, 40f, 0f, 0f, 20f, true); + Rect hint = VirtualJoystickUI.HintRect(f); + Vector2 up = VirtualJoystickUI.HintCenter(1280f, 720f, 40f, 20f); + Assert.AreEqual(up.x / f.Scale, hint.center.x, 0.01f); + Assert.AreEqual((720f - up.y) / f.Scale, hint.center.y, 0.01f); + Assert.AreEqual(VirtualJoystickUI.HintRadius(1280f, 720f) * 2f / f.Scale, hint.width, 0.01f); + Rect zone = VirtualJoystickUI.ZoneRect(f); + Assert.AreEqual(40f / f.Scale, zone.xMin, 0.01f); + Assert.AreEqual(640f / f.Scale, zone.xMax, 0.01f); + Assert.AreEqual(360f / f.Scale, zone.yMin, 0.01f); + Assert.AreEqual(700f / f.Scale, zone.yMax, 0.01f); + } + + // ── 도우미 ── + + private static float FreeFraction(Rect zone, List covers) + { + int total = 0, free = 0; + for (float y = zone.yMin + 2f; y < zone.yMax; y += 4f) + for (float x = zone.xMin + 2f; x < zone.xMax; x += 4f) + { + total++; + var p = new Vector2(x, y); + bool covered = false; + foreach (Rect r in covers) + if (r.Contains(p)) { covered = true; break; } + if (!covered) free++; + } + return total > 0 ? (float)free / total : 1f; + } + + private static void AssertInside(Rect outer, Rect inner, string what) + { + Assert.IsTrue(inner.xMin >= outer.xMin - 0.01f && inner.yMin >= outer.yMin - 0.01f + && inner.xMax <= outer.xMax + 0.01f && inner.yMax <= outer.yMax + 0.01f, + $"{what} {inner}이(가) {outer} 밖으로 나간다"); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/HudOverlapSweepTests.cs.meta b/Assets/Tests/EditMode/HudOverlapSweepTests.cs.meta new file mode 100644 index 0000000..2c6a904 --- /dev/null +++ b/Assets/Tests/EditMode/HudOverlapSweepTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e384cd49a85f372429081a58bab814a9 \ No newline at end of file diff --git a/Assets/Tests/EditMode/InsectDetailNavigationTests.cs b/Assets/Tests/EditMode/InsectDetailNavigationTests.cs new file mode 100644 index 0000000..c47fbff --- /dev/null +++ b/Assets/Tests/EditMode/InsectDetailNavigationTests.cs @@ -0,0 +1,67 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using InsectGame.Core; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + [TestFixture] + public class InsectDetailNavigationTests + { + private GameObject host; + private PlayerInsectCollection insects; + private CollectionUI collectionUI; + + [SetUp] + public void SetUp() + { + host = new GameObject("InsectDetailNavigationTests"); + host.SetActive(false); // 세이브 로드를 실행하지 않는다. + insects = host.AddComponent(); + collectionUI = host.AddComponent(); + collectionUI.AutoWire(insects, null, null); + } + + [TearDown] + public void TearDown() + { + if (collectionUI != null) collectionUI.CloseModal(); + if (host != null) Object.DestroyImmediate(host); + } + + [Test] + public void OpenDetailForInstance_UnknownId_DoesNotOpenCollection() + { + Assert.IsFalse(collectionUI.OpenDetailForInstance("missing")); + Assert.IsFalse(collectionUI.IsOpen); + } + + [Test] + public void OpenDetailForInstance_SameSpecies_SelectsExactOwnedInstance() + { + var first = new PlayerInsectData { instanceId = "owned-first", insectId = "beetle" }; + var second = new PlayerInsectData { instanceId = "owned-second", insectId = "beetle" }; + FieldInfo lookupField = typeof(PlayerInsectCollection).GetField( + "lookup", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(lookupField); + var lookup = (Dictionary)lookupField.GetValue(insects); + lookup.Add(first.instanceId, first); + lookup.Add(second.instanceId, second); + + Assert.IsTrue(collectionUI.OpenDetailForInstance(second.instanceId)); + Assert.IsTrue(collectionUI.IsOpen); + Assert.AreSame(collectionUI, ModalUIRegistry.TopModal); + FieldInfo selectedField = typeof(CollectionUI).GetField( + "selectedInstanceId", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(selectedField); + Assert.AreEqual(second.instanceId, selectedField.GetValue(collectionUI)); + + Assert.IsTrue(collectionUI.OpenDetailForInstance(first.instanceId)); + Assert.AreEqual(first.instanceId, selectedField.GetValue(collectionUI)); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/InsectDetailNavigationTests.cs.meta b/Assets/Tests/EditMode/InsectDetailNavigationTests.cs.meta new file mode 100644 index 0000000..1211547 --- /dev/null +++ b/Assets/Tests/EditMode/InsectDetailNavigationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 58c15043f1966c748ab01a7ec7a73883 \ No newline at end of file diff --git a/Assets/Tests/EditMode/InsectHabitsTests.cs b/Assets/Tests/EditMode/InsectHabitsTests.cs new file mode 100644 index 0000000..960f0dc --- /dev/null +++ b/Assets/Tests/EditMode/InsectHabitsTests.cs @@ -0,0 +1,654 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Spawning; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 곤충 시간·날씨 성향() — 표의 정합, 분포 대역, 순수 규칙, 스폰 연동. + /// + /// 실패가 전부 조용한 계열이다: 표에서 종 하나가 빠지면 그 종만 조용히 성향이 없어지고(중립), 배수가 0이 되면 후보가 + /// 소리 없이 줄고, 한 날씨에 풀 대부분이 불리해져도 예외가 없다. 그래서 진짜 곤충 DB·진짜 리전 풀로 센다. + /// + [TestFixture] + public class InsectHabitsTests + { + private const BindingFlags Inst = BindingFlags.Instance | BindingFlags.NonPublic; + private const int Sweep = 4000; + + private static readonly DayPhase[] Phases = (DayPhase[])System.Enum.GetValues(typeof(DayPhase)); + private static readonly WeatherType[] Weathers = (WeatherType[])System.Enum.GetValues(typeof(WeatherType)); + + // 진짜 DB는 한 번만 만든다(종 194개의 기술표까지 짓는 일이라 테스트마다 짓기엔 무겁다). + private readonly List shared = new List(); + private readonly List perTest = new List(); + private InsectDatabase db; + + [TearDown] + public void TearDown() + { + foreach (Object o in perTest) if (o != null) Object.DestroyImmediate(o); + perTest.Clear(); + } + + [OneTimeTearDown] + public void ReleaseSharedDatabase() + { + foreach (Object o in shared) if (o != null) Object.DestroyImmediate(o); + shared.Clear(); + db = null; + } + + private InsectDatabase RealDatabase() + { + if (db != null) return db; + var host = new GameObject("InsectHabitsTests_RealDb"); + host.SetActive(false); // Awake(월드 생성)를 막고 private 생성 함수만 부른다 + shared.Add(host); + var bootstrap = host.AddComponent(); + db = (InsectDatabase)typeof(PlaySceneBootstrap).GetMethod("EnsureExpandedDatabase", Inst).Invoke(bootstrap, null); + Assert.IsNotNull(db); + shared.Add(db); + return db; + } + + private static InsectHabit Habit(InsectActivity activity, WeatherSet like = WeatherSet.None, + WeatherSet dislike = WeatherSet.None, InsectTemperament temperament = InsectTemperament.Docile) + => new InsectHabit(activity, like, dislike, temperament); + + private static WorldState State(DayPhase phase, WeatherType weather) + => new WorldState { DayPhase = phase, Weather = weather, Hour24 = 12 }; + + // ── 표 정합 ── + + [Test] + public void Table_CoversEverySpeciesInTheRealDatabase_AndNothingElse() + { + var dbIds = new HashSet(); + foreach (InsectData d in RealDatabase().insects) dbIds.Add(d.insectId); + + var missing = new List(); + foreach (string id in dbIds) if (!InsectHabits.TryGet(id, out _)) missing.Add(id); + var stale = new List(); + foreach (string id in InsectHabits.AllIds) if (!dbIds.Contains(id)) stale.Add(id); + + Assert.AreEqual(0, missing.Count, "성향이 없는 종(조용히 중립이 된다): " + string.Join(", ", missing)); + Assert.AreEqual(0, stale.Count, "DB에 없는 종의 줄(ID 오타·삭제된 종): " + string.Join(", ", stale)); + Assert.AreEqual(dbIds.Count, InsectHabits.Count); + } + + [Test] + public void Table_HasNoDuplicateRows() + { + // 같은 종을 두 줄 적으면 뒤 줄이 앞 줄을 조용히 덮는다. + Assert.AreEqual(InsectHabits.Count, InsectHabits.AuthoredRows, "같은 종이 두 줄 이상 적혔다"); + } + + [Test] + public void SeedDefinitions_AllHaveHabits_WithoutTheDatabase() + { + // 진짜 DB 없이도 도는 빠른 검사 — 확장 시드(1막·2막)의 ID 전부. + foreach (string id in InsectExpansionDefinitions.AllNewIds()) + Assert.IsTrue(InsectHabits.TryGet(id, out _), $"1막 확장 시드 '{id}'의 성향이 없다"); + foreach (string id in InsectExpansion2Definitions.AllNewIds()) + Assert.IsTrue(InsectHabits.TryGet(id, out _), $"2막 확장 시드 '{id}'의 성향이 없다"); + } + + [Test] + public void UnknownSpecies_IsNeutral_AndNeverLosesItsSpawnWeight() + { + Assert.IsFalse(InsectHabits.TryGet("no_such_insect", out _)); + Assert.IsFalse(InsectHabits.TryGet(null, out _)); + InsectHabit h = InsectHabits.Of("no_such_insect"); + Assert.AreEqual(InsectActivity.Any, h.Activity); + Assert.AreEqual(InsectTemperament.Docile, h.Temperament); + foreach (DayPhase p in Phases) + foreach (WeatherType w in Weathers) + { + Assert.AreEqual(1f, InsectHabits.SpawnWeightMultiplier(h, State(p, w)), 1e-6f); + Assert.AreEqual(0, InsectHabits.Fit(h, State(p, w))); + Assert.AreEqual(1f, InsectHabits.BattleStatMultiplier(h, State(p, w)), 1e-6f); + Assert.IsFalse(InsectHabits.IsAmbusher(h, State(p, w))); + } + } + + [Test] + public void Of_AndFor_AgreeWithTryGet() + { + foreach (string id in InsectHabits.AllIds) + { + Assert.IsTrue(InsectHabits.TryGet(id, out InsectHabit h)); + Assert.AreEqual(h.Activity, InsectHabits.Of(id).Activity, id); + Assert.AreEqual(h.LikedWeather, InsectHabits.Of(id).LikedWeather, id); + } + Assert.AreEqual(InsectHabit.Neutral.Activity, InsectHabits.For(null).Activity); + } + + // ── 분포 대역 ── + + [Test] + public void ActivityShares_StayInBand() + { + int diurnal = 0, nocturnal = 0, any = 0, total = 0; + foreach (InsectData d in RealDatabase().insects) + { + total++; + switch (InsectHabits.For(d).Activity) + { + case InsectActivity.Diurnal: diurnal++; break; + case InsectActivity.Nocturnal: nocturnal++; break; + default: any++; break; + } + } + Assert.That(diurnal / (float)total, Is.InRange(0.30f, 0.45f), $"주행성 {diurnal}/{total}"); + Assert.That(nocturnal / (float)total, Is.InRange(0.25f, 0.40f), $"야행성 {nocturnal}/{total}"); + Assert.That(any / (float)total, Is.InRange(0.15f, 0.35f), $"시간 무관 {any}/{total}"); + } + + [Test] + public void Ambushers_AreAboutOneInEight_AndGrowWithRarity() + { + int rarities = System.Enum.GetValues(typeof(InsectRarity)).Length; + var total = new int[rarities]; + var ambush = new int[rarities]; + int allTotal = 0, allAmbush = 0, fieldTotal = 0, fieldAmbush = 0; + foreach (InsectData d in RealDatabase().insects) + { + bool a = InsectHabits.For(d).Temperament == InsectTemperament.Ambusher; + int r = (int)d.rarity; + total[r]++; + allTotal++; + if (a) { ambush[r]++; allAmbush++; } + if (d.spawnWeight > 0f) { fieldTotal++; if (a) fieldAmbush++; } + } + + Assert.That(allAmbush / (float)allTotal, Is.InRange(0.10f, 0.15f), $"습격형 {allAmbush}/{allTotal}"); + Assert.That(fieldAmbush / (float)fieldTotal, Is.InRange(0.10f, 0.15f), + $"필드에 나오는 종 중 습격형 {fieldAmbush}/{fieldTotal}"); + for (int r = 1; r < rarities; r++) + Assert.GreaterOrEqual(ambush[r] / (float)total[r], ambush[r - 1] / (float)total[r - 1], + $"등급이 높을수록 습격형이 많아야 한다 — {(InsectRarity)r}가 {(InsectRarity)(r - 1)}보다 적다"); + } + + [Test] + public void EveryAmbusher_HasAnActivityWindow_AndGachaOnlySpeciesAreDocile() + { + foreach (InsectData d in RealDatabase().insects) + { + InsectHabit h = InsectHabits.For(d); + if (h.Temperament == InsectTemperament.Ambusher) + Assert.AreNotEqual(InsectActivity.Any, h.Activity, + $"{d.insectId}: 시간 무관 습격형은 깨는 때가 좋아하는 날씨뿐이다 — 활동 시간대를 정할 것"); + if (d.spawnWeight <= 0f) + Assert.AreEqual(InsectTemperament.Docile, h.Temperament, $"{d.insectId}: 필드에 안 나오는 종이 습격형이다"); + } + } + + // ── 날씨 균형 ── + + [Test] + public void LikedAndDislikedWeather_NeverOverlap() + { + foreach (string id in InsectHabits.AllIds) + { + InsectHabit h = InsectHabits.Of(id); + Assert.AreEqual(WeatherSet.None, h.LikedWeather & h.DislikedWeather, $"{id}: 같은 날씨를 좋아하면서 싫어한다"); + } + } + + [Test] + public void NoWeather_IsUnfavorableToMoreThanAThirdOfAllSpecies_AndEachHasFriends() + { + int total = InsectHabits.Count; + foreach (WeatherType w in Weathers) + { + int liked = 0, disliked = 0; + foreach (string id in InsectHabits.AllIds) + { + InsectHabit h = InsectHabits.Of(id); + if (InsectHabits.Contains(h.LikedWeather, w)) liked++; + if (InsectHabits.Contains(h.DislikedWeather, w)) disliked++; + } + Assert.LessOrEqual(disliked / (float)total, 1f / 3f, $"{w}: 싫어하는 종 {disliked}/{total}"); + Assert.GreaterOrEqual(liked / (float)total, 0.08f, $"{w}: 좋아하는 종 {liked}/{total} — 이 날씨가 아무에게도 좋지 않다"); + } + } + + [Test] + public void NoRegionPool_HasAWeatherThatHurtsMostOfIt() + { + // 전체로는 균형이어도 풀 단위로 쏠릴 수 있다 — 정원 풀(나비·벌 17종)은 처음에 15종이 비에 불리했다. + foreach (RegionData region in RegionDefinitions.CreateAll()) + { + if (region.insectIds == null || region.insectIds.Length == 0) continue; + foreach (WeatherType w in Weathers) + { + int disliked = 0; + foreach (string id in region.insectIds) + if (InsectHabits.Contains(InsectHabits.Of(id).DislikedWeather, w)) disliked++; + Assert.Less(disliked / (float)region.insectIds.Length, 0.5f, + $"{region.regionId} 풀: {w}이 {disliked}/{region.insectIds.Length}종에 불리하다(절반 이상)"); + } + } + } + + [Test] + public void WaterTypesNeverDislikeRain_WindTypesLikeStrongWind() + { + // 사용자 근거 예: 물 속성은 비를 좋아하고 바람 속성은 센바람. 속성은 부트스트랩이 이름·서식지로 정한다 — + // 속성이 바뀌거나 표가 어긋나면 "물 곤충이 비에 약해지는" 일이 조용히 생긴다. + foreach (InsectData d in RealDatabase().insects) + { + InsectHabit h = InsectHabits.For(d); + if (d.primaryType == InsectElement.Water) + Assert.IsFalse(InsectHabits.Contains(h.DislikedWeather, WeatherType.Rain), $"{d.insectId}: 물 속성이 비를 싫어한다"); + if (d.primaryType == InsectElement.Wind) + { + Assert.IsTrue(InsectHabits.Contains(h.LikedWeather, WeatherType.Wind), $"{d.insectId}: 바람 속성이 센바람을 좋아하지 않는다"); + Assert.IsFalse(InsectHabits.Contains(h.DislikedWeather, WeatherType.Wind), $"{d.insectId}: 바람 속성이 센바람을 싫어한다"); + } + } + } + + [Test] + public void SnowHabitats_FrostlineAndMountain_LikeSnow() + { + foreach (string habitat in new[] { "Frostline", "Mountain" }) + { + int species = 0, liking = 0; + foreach (InsectData d in RealDatabase().insects) + { + if (d.habitatHint != habitat) continue; + species++; + InsectHabit h = InsectHabits.For(d); + if (InsectHabits.Contains(h.LikedWeather, WeatherType.Snow)) liking++; + Assert.IsFalse(InsectHabits.Contains(h.DislikedWeather, WeatherType.Snow), $"{d.insectId}: 설산 종이 눈을 싫어한다"); + } + Assert.Greater(species, 0, habitat); + Assert.GreaterOrEqual(liking / (float)species, 0.7f, $"{habitat} 서식종 중 눈을 좋아하는 종 {liking}/{species}"); + } + } + + // ── 순수 규칙 ── + + [Test] + public void WeatherSet_BitsFollowWeatherTypeValues() + { + foreach (WeatherType w in Weathers) + { + Assert.AreEqual((WeatherSet)(1 << (int)w), InsectHabits.SetOf(w), $"{w}"); + Assert.IsTrue(InsectHabits.Contains(InsectHabits.SetOf(w), w)); + foreach (WeatherType other in Weathers) + if (other != w) Assert.IsFalse(InsectHabits.Contains(InsectHabits.SetOf(w), other)); + } + // 날씨를 늘리면 집합 비트도 함께 — 정의된 비트 수가 날씨 수와 같아야 한다. + int bits = 0; + foreach (WeatherSet s in System.Enum.GetValues(typeof(WeatherSet))) if (s != WeatherSet.None) bits++; + Assert.AreEqual(Weathers.Length, bits, "WeatherType과 WeatherSet이 어긋났다"); + Assert.AreEqual(WeatherSet.Clear, InsectHabits.SetOf(WeatherType.Clear)); + Assert.AreEqual(WeatherSet.Snow, InsectHabits.SetOf(WeatherType.Snow)); + } + + [Test] + public void TimeMultiplier_Table_MatchesTheRequestedRanges() + { + Assert.That(InsectHabits.TimeMultiplier(InsectActivity.Nocturnal, DayPhase.Night), Is.InRange(2.0f, 2.5f)); + Assert.That(InsectHabits.TimeMultiplier(InsectActivity.Nocturnal, DayPhase.Day), Is.InRange(0.3f, 0.4f)); + Assert.Greater(InsectHabits.TimeMultiplier(InsectActivity.Diurnal, DayPhase.Day), 1f); + Assert.Less(InsectHabits.TimeMultiplier(InsectActivity.Diurnal, DayPhase.Night), 1f); + foreach (DayPhase p in Phases) + Assert.AreEqual(1f, InsectHabits.TimeMultiplier(InsectActivity.Any, p), $"시간 무관이 {p}에 배수를 받는다"); + Assert.AreEqual(1.5f, InsectHabits.FavorableWeatherMultiplier); + Assert.AreEqual(0.6f, InsectHabits.UnfavorableWeatherMultiplier); + } + + [Test] + public void SpawnWeightMultiplier_IsNeverZero_AndStaysInsideTheClamp() + { + foreach (string id in InsectHabits.AllIds) + foreach (DayPhase p in Phases) + foreach (WeatherType w in Weathers) + { + float m = InsectHabits.SpawnWeightMultiplier(InsectHabits.Of(id), State(p, w)); + Assert.Greater(m, 0f, $"{id} @ {p}/{w}: 배수가 0이다 — 후보에서 빠진다"); + Assert.GreaterOrEqual(m, InsectHabits.MinSpawnMultiplier, $"{id} @ {p}/{w}"); + Assert.LessOrEqual(m, InsectHabits.MaxSpawnMultiplier, $"{id} @ {p}/{w}"); + } + Assert.Greater(InsectHabits.MinSpawnMultiplier, 0f); + } + + [Test] + public void SpawnWeightMultiplier_NocturnalSurgesAtNightAndSleepsByDay_WeatherStacksOnTop() + { + InsectHabit moth = Habit(InsectActivity.Nocturnal, WeatherSet.Fog, WeatherSet.Rain); + Assert.AreEqual(2.5f, InsectHabits.SpawnWeightMultiplier(moth, State(DayPhase.Night, WeatherType.Clear)), 1e-5f); + Assert.AreEqual(0.35f, InsectHabits.SpawnWeightMultiplier(moth, State(DayPhase.Day, WeatherType.Clear)), 1e-5f); + Assert.AreEqual(2.5f * 1.5f, InsectHabits.SpawnWeightMultiplier(moth, State(DayPhase.Night, WeatherType.Fog)), 1e-5f); + Assert.AreEqual(0.35f * 0.6f, InsectHabits.SpawnWeightMultiplier(moth, State(DayPhase.Day, WeatherType.Rain)), 1e-5f); + Assert.AreEqual(1.5f, InsectHabits.SpawnWeightMultiplier(Habit(InsectActivity.Any, WeatherSet.Fog), + State(DayPhase.Day, WeatherType.Fog)), 1e-5f); + } + + [Test] + public void Fit_CombinesTimeAndWeather_IntoMinusOneZeroPlusOne() + { + InsectHabit moth = Habit(InsectActivity.Nocturnal, WeatherSet.Fog, WeatherSet.Rain); + Assert.AreEqual(1, InsectHabits.Fit(moth, State(DayPhase.Night, WeatherType.Fog)), "제 시간 + 좋은 날씨"); + Assert.AreEqual(1, InsectHabits.Fit(moth, State(DayPhase.Night, WeatherType.Clear)), "제 시간 + 보통 날씨"); + Assert.AreEqual(0, InsectHabits.Fit(moth, State(DayPhase.Night, WeatherType.Rain)), "제 시간 + 싫은 날씨는 서로 지운다"); + Assert.AreEqual(0, InsectHabits.Fit(moth, State(DayPhase.Day, WeatherType.Fog)), "잘 시간 + 좋은 날씨는 서로 지운다"); + Assert.AreEqual(-1, InsectHabits.Fit(moth, State(DayPhase.Day, WeatherType.Rain)), "잘 시간 + 싫은 날씨"); + Assert.AreEqual(-1, InsectHabits.Fit(moth, State(DayPhase.Morning, WeatherType.Clear)), "잘 시간"); + Assert.AreEqual(0, InsectHabits.Fit(moth, State(DayPhase.Evening, WeatherType.Clear)), "어스름은 보통"); + Assert.AreEqual(1, InsectHabits.Fit(moth, State(DayPhase.Evening, WeatherType.Fog)), "어스름 + 좋은 날씨"); + } + + [Test] + public void BattleStatMultiplier_IsPlusTenMinusTenOrOne() + { + InsectHabit moth = Habit(InsectActivity.Nocturnal, WeatherSet.Fog, WeatherSet.Rain); + Assert.AreEqual(1.10f, InsectHabits.BattleStatMultiplier(moth, State(DayPhase.Night, WeatherType.Fog)), 1e-6f); + Assert.AreEqual(0.90f, InsectHabits.BattleStatMultiplier(moth, State(DayPhase.Day, WeatherType.Rain)), 1e-6f); + Assert.AreEqual(1f, InsectHabits.BattleStatMultiplier(moth, State(DayPhase.Night, WeatherType.Rain)), 1e-6f); + Assert.AreEqual(1f, InsectHabits.BattleStatMultiplier(InsectHabit.Neutral, State(DayPhase.Night, WeatherType.Fog)), 1e-6f); + } + + [Test] + public void IsAmbusher_WakesOnlyAfterDark_AndStaysQuietInWeatherItHates() + { + InsectHabit nightHunter = Habit(InsectActivity.Nocturnal, WeatherSet.Fog, WeatherSet.Rain, InsectTemperament.Ambusher); + Assert.IsTrue(InsectHabits.IsAmbusher(nightHunter, State(DayPhase.Night, WeatherType.Clear))); + Assert.IsFalse(InsectHabits.IsAmbusher(nightHunter, State(DayPhase.Day, WeatherType.Clear)), "낮에 야행성 습격형이 깨어 있다"); + Assert.IsFalse(InsectHabits.IsAmbusher(nightHunter, State(DayPhase.Day, WeatherType.Fog)), "좋아하는 날씨여도 낮에는 덤비지 않는다"); + Assert.IsFalse(InsectHabits.IsAmbusher(nightHunter, State(DayPhase.Evening, WeatherType.Clear)), "어스름은 아직이다"); + Assert.IsTrue(InsectHabits.IsAmbusher(nightHunter, State(DayPhase.Evening, WeatherType.Fog)), "안개 낀 저녁에는 일찍 깬다"); + Assert.IsFalse(InsectHabits.IsAmbusher(nightHunter, State(DayPhase.Night, WeatherType.Rain)), "싫은 날씨엔 제 시간이어도 가만히 있는다"); + + // 주행성 습격형(말벌류)은 한낮에 덤비지 않는다 — 해 질 녘에 좋아하는 날씨일 때만이다. + InsectHabit dayHunter = Habit(InsectActivity.Diurnal, WeatherSet.Wind, WeatherSet.None, InsectTemperament.Ambusher); + foreach (WeatherType w in Weathers) + { + Assert.IsFalse(InsectHabits.IsAmbusher(dayHunter, State(DayPhase.Morning, w)), $"아침 {w}에 싸움을 건다"); + Assert.IsFalse(InsectHabits.IsAmbusher(dayHunter, State(DayPhase.Day, w)), $"낮 {w}에 싸움을 건다"); + Assert.IsFalse(InsectHabits.IsAmbusher(dayHunter, State(DayPhase.Night, w)), $"잠든 밤 {w}에 싸움을 건다"); + } + Assert.IsTrue(InsectHabits.IsAmbusher(dayHunter, State(DayPhase.Evening, WeatherType.Wind))); + Assert.IsFalse(InsectHabits.IsAmbusher(dayHunter, State(DayPhase.Evening, WeatherType.Clear))); + + InsectHabit docile = Habit(InsectActivity.Nocturnal, WeatherSet.Fog); + foreach (DayPhase p in Phases) + foreach (WeatherType w in Weathers) + Assert.IsFalse(InsectHabits.IsAmbusher(docile, State(p, w)), "온순한 종이 습격한다"); + + InsectHabit anyHunter = Habit(InsectActivity.Any, WeatherSet.Fog, WeatherSet.None, InsectTemperament.Ambusher); + Assert.IsFalse(InsectHabits.IsAmbusher(anyHunter, State(DayPhase.Day, WeatherType.Fog)), "시간 무관이어도 낮에는 덤비지 않는다"); + Assert.IsTrue(InsectHabits.IsAmbusher(anyHunter, State(DayPhase.Night, WeatherType.Fog)), "시간 무관 습격형은 어두울 때 좋아하는 날씨에만 깬다"); + Assert.IsFalse(InsectHabits.IsAmbusher(anyHunter, State(DayPhase.Night, WeatherType.Clear))); + } + + [Test] + public void IsAmbushHour_IsEveningAndNightOnly() + { + Assert.IsFalse(InsectHabits.IsAmbushHour(DayPhase.Morning)); + Assert.IsFalse(InsectHabits.IsAmbushHour(DayPhase.Day)); + Assert.IsTrue(InsectHabits.IsAmbushHour(DayPhase.Evening)); + Assert.IsTrue(InsectHabits.IsAmbushHour(DayPhase.Night)); + } + + [Test] + public void IsOutOfPhase_MarksOnlyTheSleepers() + { + InsectHabit night = Habit(InsectActivity.Nocturnal); + InsectHabit day = Habit(InsectActivity.Diurnal); + Assert.IsTrue(InsectHabits.IsOutOfPhase(night, DayPhase.Morning)); + Assert.IsTrue(InsectHabits.IsOutOfPhase(night, DayPhase.Day)); + Assert.IsFalse(InsectHabits.IsOutOfPhase(night, DayPhase.Evening)); + Assert.IsFalse(InsectHabits.IsOutOfPhase(night, DayPhase.Night)); + Assert.IsTrue(InsectHabits.IsOutOfPhase(day, DayPhase.Night)); + Assert.IsFalse(InsectHabits.IsOutOfPhase(day, DayPhase.Morning)); + Assert.IsFalse(InsectHabits.IsOutOfPhase(day, DayPhase.Day)); + Assert.IsFalse(InsectHabits.IsOutOfPhase(day, DayPhase.Evening)); + foreach (DayPhase p in Phases) + Assert.IsFalse(InsectHabits.IsOutOfPhase(InsectHabit.Neutral, p), "시간 무관 종이 잘 시간이다"); + } + + // ── 스폰 구성 — 진짜 리전 풀 ── + + private List PoolOf(RegionData region) + { + var pool = new List(); + foreach (string id in region.insectIds) + { + InsectData d = RealDatabase().GetById(id); + Assert.IsNotNull(d, $"{region.regionId} 풀의 '{id}'가 DB에 없다"); + pool.Add(d); + } + return pool; + } + + [Test] + public void EveryRegionPool_KeepsEveryCandidate_AtEveryTimeAndWeather() + { + // 어떤 리전 풀·어떤 시간·날씨에서도 후보가 비지 않고(성향 배수가 0이 아니다), 풀에 있는 모든 등급이 남는다. + // 한 등급이 통째로 비면 등급 대체가 돌아 등급 분포가 시간대를 따라 움직인다. + InsectDatabase database = RealDatabase(); + foreach (RegionData region in RegionDefinitions.CreateAll()) + { + if (region.insectIds == null || region.insectIds.Length == 0) continue; + List pool = PoolOf(region); + foreach (DayPhase p in Phases) + foreach (WeatherType raw in Weathers) + { + // 스포너와 같다 — 그 리전에서 보이는 날씨(설산은 비가 눈, 사막은 눈이 센바람). + WorldState state = State(p, WeatherForecast.EffectiveIn(raw, region.regionId)); + List stateCandidates = database.GetCandidates(state); + var rarityKept = new HashSet(); + var rarityInPool = new HashSet(); + int candidates = 0; + foreach (InsectData d in pool) + { + rarityInPool.Add(d.rarity); + if (!stateCandidates.Contains(d)) continue; + candidates++; + rarityKept.Add(d.rarity); + Assert.Greater(InsectHabits.SpawnWeightMultiplier(InsectHabits.For(d), state), 0f, + $"{region.regionId} {p}/{state.Weather}: {d.insectId}의 배수가 0이다"); + } + Assert.Greater(candidates, 0, $"{region.regionId} {p}/{state.Weather}: 후보가 비었다"); + Assert.AreEqual(rarityInPool.Count, rarityKept.Count, $"{region.regionId} {p}/{state.Weather}: 등급 하나가 통째로 빠졌다"); + } + } + } + + /// 스포너와 같은 순서 — 등급을 먼저 굴리고(전역 표), 그 등급 후보 안에서 spawnWeight × 성향 배수. + private static InsectData PickLikeTheSpawner(List pool, WorldState state, bool[] available, + float roll, List scratch, List multipliers) + { + int rarity = FieldSpawnRules.PickRarity(roll, 1f, available); + scratch.Clear(); + multipliers.Clear(); + foreach (InsectData d in pool) + { + if ((int)d.rarity != rarity) continue; + scratch.Add(d); + multipliers.Add(InsectHabits.SpawnWeightMultiplier(InsectHabits.For(d), state)); + } + return FieldSpawnRules.PickWeighted(scratch, multipliers, Mathf.Repeat(roll * 7.31f, 1f)); + } + + [Test] + public void RarityShares_DoNotMoveWithTimeOrWeather_WhenPickingLikeTheSpawner() + { + var scratch = new List(); + var multipliers = new List(); + foreach (RegionData region in RegionDefinitions.CreateAll()) + { + if (region.regionId != "forest" && region.regionId != "garden" && region.regionId != "swamp") continue; + List pool = PoolOf(region); + var available = new bool[FieldSpawnRules.RarityCount]; + foreach (InsectData d in pool) available[(int)d.rarity] = true; + float[] expect = FieldSpawnRules.EffectiveShares(1f, available); + + foreach (DayPhase p in Phases) + foreach (WeatherType w in Weathers) + { + var counts = new float[FieldSpawnRules.RarityCount]; + for (int i = 0; i < Sweep; i++) + counts[(int)PickLikeTheSpawner(pool, State(p, w), available, (i + 0.5f) / Sweep, scratch, multipliers).rarity] + += 1f / Sweep; + for (int r = 0; r < FieldSpawnRules.RarityCount; r++) + Assert.AreEqual(expect[r], counts[r], 0.002f, $"{region.regionId} {p}/{w}: {(InsectRarity)r}"); + } + } + } + + private static float NocturnalPickShare(List pool, WorldState state) + { + var scratch = new List(); + var multipliers = new List(); + var available = new bool[FieldSpawnRules.RarityCount]; + foreach (InsectData d in pool) available[(int)d.rarity] = true; + int nocturnal = 0; + for (int i = 0; i < Sweep; i++) + { + InsectData picked = PickLikeTheSpawner(pool, state, available, (i + 0.5f) / Sweep, scratch, multipliers); + if (InsectHabits.For(picked).Activity == InsectActivity.Nocturnal) nocturnal++; + } + return nocturnal / (float)Sweep; + } + + [Test] + public void NightShiftsTheForestAndMeadowMix_TowardNocturnalSpecies() + { + foreach (RegionData region in RegionDefinitions.CreateAll()) + { + if (region.regionId != "forest" && region.regionId != "meadow") continue; + List pool = PoolOf(region); + float night = NocturnalPickShare(pool, State(DayPhase.Night, WeatherType.Clear)); + float day = NocturnalPickShare(pool, State(DayPhase.Day, WeatherType.Clear)); + Assert.Greater(night - day, 0.3f, $"{region.regionId}: 야행성 비율이 밤 {night:P0} / 낮 {day:P0} — 시간을 못 탄다"); + } + } + + // ── 스포너 연동 — 시계 신호 · 보너스 슬롯 ── + + private InsectSpawner NewSpawner(out GameClock clock, out WeatherSystem weather) + { + var world = new GameObject("InsectHabitsTests_World"); + perTest.Add(world); + clock = world.AddComponent(); + weather = world.AddComponent(); + var provider = world.AddComponent(); + provider.AutoWire(clock, weather); + + var host = new GameObject("InsectHabitsTests_Spawner"); + perTest.Add(host); + InsectSpawner spawner = host.AddComponent(); + spawner.AutoWire(null, provider, null); + return spawner; + } + + private static FieldPopulation PopulationOf(InsectSpawner spawner) + => (FieldPopulation)typeof(InsectSpawner).GetField("population", Inst).GetValue(spawner); + + /// 리전 표 한 줄(스포너 안쪽 클래스)을 만들어 스포너에 단다. + private static object AddRegion(InsectSpawner spawner, string regionId, int baseSlots) + { + System.Type infoType = typeof(InsectSpawner).GetNestedType("RegionSpawnInfo", BindingFlags.NonPublic); + object info = System.Activator.CreateInstance(infoType, true); + infoType.GetField("RegionId").SetValue(info, regionId); + infoType.GetField("BaseSlots").SetValue(info, baseSlots); + ((System.Collections.IList)typeof(InsectSpawner).GetField("regionInfos", Inst).GetValue(spawner)).Add(info); + return info; + } + + private static int RegionCap(InsectSpawner spawner, object info) + => (int)typeof(InsectSpawner).GetMethod("RegionCap", Inst).Invoke(spawner, new[] { info }); + + [Test] + public void Spawner_PullsOnlyOutOfPhaseSlotsForward_WhenTheClockChangesPhase() + { + InsectSpawner spawner = NewSpawner(out GameClock clock, out _); + AddRegion(spawner, "meadow", 10); + clock.SetTime01(0.5f, hold: true); // 낮 — 슬롯을 채우기 전에 시각을 맞춘다(아침→낮 전환도 신호를 쏜다) + + FieldPopulation pop = PopulationOf(spawner); + pop.EnsureSlotCount("meadow", 2, 0f, isSubArea: false); + List slots = pop.SlotsOf("meadow"); + float far = Time.time + 1000f; + FieldPopulation.Fill(slots[0], "bee_worker", null, 3, false, false, Vector3.zero, far); // 주행성 + FieldPopulation.Fill(slots[1], "moth_night", null, 3, false, false, Vector3.zero, far); // 야행성 + + float t0 = Time.time; + clock.SetTime01(0.95f, hold: true); // 밤 — 주행성이 잠든다 + Assert.GreaterOrEqual(slots[0].ExpiresAt, t0 + FieldSpawnRules.PhaseSwapDelayMin, "5초보다 일찍 바뀐다"); + Assert.LessOrEqual(slots[0].ExpiresAt, t0 + FieldSpawnRules.PhaseSwapDelayMax + 0.01f, "60초 안에 안 바뀐다"); + Assert.AreEqual(far, slots[1].ExpiresAt, "밤에 깨어나는 야행성의 수명을 당겼다"); + Assert.IsTrue(slots[0].IsAlive && slots[1].IsAlive, "개체를 지우면 안 된다 — 만료만 당긴다"); + + // 아침이 오면 거꾸로다 — 야행성이 잠들고 주행성은 그대로. + FieldPopulation.Fill(slots[0], "bee_worker", null, 3, false, false, Vector3.zero, far); + t0 = Time.time; + clock.SetTime01(0.3f, hold: true); + Assert.AreEqual(far, slots[0].ExpiresAt, "아침에 주행성을 당겼다"); + Assert.GreaterOrEqual(slots[1].ExpiresAt, t0 + FieldSpawnRules.PhaseSwapDelayMin); + Assert.LessOrEqual(slots[1].ExpiresAt, t0 + FieldSpawnRules.PhaseSwapDelayMax + 0.01f); + } + + [Test] + public void Spawner_StopsListeningWhenDisabled_AndListensAgainWhenEnabled() + { + InsectSpawner spawner = NewSpawner(out GameClock clock, out _); + AddRegion(spawner, "meadow", 10); + clock.SetTime01(0.95f, hold: true); // 밤 + FieldPopulation pop = PopulationOf(spawner); + pop.EnsureSlotCount("meadow", 1, 0f, isSubArea: false); + FieldSlot slot = pop.SlotsOf("meadow")[0]; + float far = Time.time + 1000f; + FieldPopulation.Fill(slot, "moth_night", null, 3, false, false, Vector3.zero, far); // 야행성 + + spawner.enabled = false; + clock.SetTime01(0.3f, hold: true); // 아침 — 구독이 없으니 당기지 않는다 + Assert.AreEqual(far, slot.ExpiresAt, "꺼진 스포너가 시계 신호를 받았다(해지 누락)"); + + spawner.enabled = true; // OnEnable이 시계를 다시 구독한다 + clock.SetTime01(0.95f, hold: true); // 밤 + clock.SetTime01(0.3f, hold: true); // 아침 + Assert.Less(slot.ExpiresAt, far, "다시 켠 스포너가 시계 신호를 못 받는다(재구독 누락)"); + } + + [Test] + public void Spawner_RegionCap_AddsBonusSlotsFromTheRegionsOwnWeather() + { + InsectSpawner spawner = NewSpawner(out GameClock clock, out WeatherSystem weather); + object meadow = AddRegion(spawner, "meadow", 20); + object frostline = AddRegion(spawner, "frostline", 20); + + clock.SetTime01(0.5f, hold: true); // 낮 + weather.SetWeather(WeatherType.Clear, hold: true, instant: true); + Assert.AreEqual(20, RegionCap(spawner, meadow), "낮·맑음은 보너스가 없다"); + + clock.SetTime01(0.95f, hold: true); // 밤 + Assert.AreEqual(21, RegionCap(spawner, meadow), "밤 +1"); + + weather.SetWeather(WeatherType.Rain, hold: true, instant: true); + Assert.AreEqual(22, RegionCap(spawner, meadow), "밤 + 비 = +2"); + Assert.AreEqual(21, RegionCap(spawner, frostline), "설산에서는 비가 눈으로 보인다 — 비 보너스는 없고 밤 +1만"); + + clock.SetTime01(0.5f, hold: true); // 낮 + 비 + Assert.AreEqual(21, RegionCap(spawner, meadow), "비 +1"); + Assert.AreEqual(20, RegionCap(spawner, frostline), "설산의 낮은 눈이라 보너스가 없다"); + + weather.SetWeather(WeatherType.Fog, hold: true, instant: true); + Assert.AreEqual(21, RegionCap(spawner, meadow), "안개 +1"); + weather.SetWeather(WeatherType.Snow, hold: true, instant: true); + Assert.AreEqual(20, RegionCap(spawner, meadow), "눈은 슬롯을 늘리지 않는다"); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/InsectHabitsTests.cs.meta b/Assets/Tests/EditMode/InsectHabitsTests.cs.meta new file mode 100644 index 0000000..a81e9ec --- /dev/null +++ b/Assets/Tests/EditMode/InsectHabitsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a5a48370eeb5646449c62034fa2fba8c \ No newline at end of file diff --git a/Assets/Tests/EditMode/InsectSculptureMeshTests.cs b/Assets/Tests/EditMode/InsectSculptureMeshTests.cs new file mode 100644 index 0000000..7b8347d --- /dev/null +++ b/Assets/Tests/EditMode/InsectSculptureMeshTests.cs @@ -0,0 +1,115 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using InsectGame.Spawning; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + public class InsectSculptureMeshTests + { + private static IEnumerable Shapes => + (InsectSculptureMeshes.Shape[])Enum.GetValues(typeof(InsectSculptureMeshes.Shape)); + + [TestCaseSource(nameof(Shapes))] + public void Sculpture_IsClosedOutwardFiniteAndWithinBudget(InsectSculptureMeshes.Shape shape) + { + Mesh mesh = InsectSculptureMeshes.Get(shape); + Vector3[] vertices = mesh.vertices; + int[] triangles = mesh.triangles; + Assert.LessOrEqual(vertices.Length, 180); + Assert.Greater(mesh.bounds.size.sqrMagnitude, .001f); + var edgeUses = new Dictionary(); + var edgeDirections = new Dictionary(); + double volume = 0; + foreach (Vector3 v in vertices) + Assert.IsFalse(float.IsNaN(v.x) || float.IsNaN(v.y) || float.IsNaN(v.z) + || float.IsInfinity(v.x) || float.IsInfinity(v.y) || float.IsInfinity(v.z)); + for (int i = 0; i < triangles.Length; i += 3) + { + int a = triangles[i], b = triangles[i + 1], c = triangles[i + 2]; + Assert.That(a, Is.InRange(0, vertices.Length - 1)); + Assert.That(b, Is.InRange(0, vertices.Length - 1)); + Assert.That(c, Is.InRange(0, vertices.Length - 1)); + Assert.Greater(Vector3.Cross(vertices[b] - vertices[a], vertices[c] - vertices[a]).sqrMagnitude, 1e-14f); + volume += Vector3.Dot(vertices[a], Vector3.Cross(vertices[b], vertices[c])) / 6.0; + AddEdge(a, b, edgeUses, edgeDirections); AddEdge(b, c, edgeUses, edgeDirections); AddEdge(c, a, edgeUses, edgeDirections); + } + foreach (long edge in edgeUses.Keys) + { + Assert.AreEqual(2, edgeUses[edge], "Open or non-manifold edge: " + shape); + Assert.AreEqual(0, edgeDirections[edge], "Inconsistent winding: " + shape); + } + Assert.Greater(volume, 0.0, "Inside-out mesh: " + shape); + foreach (Vector3 normal in mesh.normals) Assert.AreEqual(1f, normal.magnitude, .001f); + } + + [TestCase(InsectSculptureMeshes.Shape.RhinoHorn)] + [TestCase(InsectSculptureMeshes.Shape.MantisFemurLeft)] + [TestCase(InsectSculptureMeshes.Shape.MantisBladeRight)] + [TestCase(InsectSculptureMeshes.Shape.ElytronRight)] + public void RoundedPoles_HaveAxialDepthInsteadOfExposedFlatDisks(InsectSculptureMeshes.Shape shape) + { + Vector3[] vertices = InsectSculptureMeshes.Get(shape).vertices; + const int sides = 10, rings = 17; + for (int end = 0; end < 2; end++) + { + int offset = end == 0 ? 0 : (rings - 1) * sides; + Vector3 pole = vertices[rings * sides + end]; + Vector3 center = Vector3.zero; + for (int j = 0; j < sides; j++) center += vertices[offset + j] / sides; + Vector3 axis = center - pole; + Assert.Greater(axis.magnitude, .005f, "A ring centered on its cap produces a visible cut face"); + for (int j = 0; j < sides; j++) + Assert.Greater(Vector3.Dot(vertices[offset + j] - pole, axis.normalized), 0f, + "Cap must taper toward a terminal pole"); + } + } + + [Test] + public void MantisHead_RoundedSurface_HasWidthForEyesWithoutPrismFaces() + { + Mesh head = InsectSculptureMeshes.Get(InsectSculptureMeshes.Shape.MantisHead); + Assert.Greater(head.vertexCount, 100, "The old six-point prism produced a block-like side silhouette"); + Assert.Greater(head.bounds.size.x, .25f, "Crown must support the eyes at x +/- .18"); + Assert.Less(head.bounds.size.z, .20f); + } + + [Test] + public void MantisBlades_StayInsideCompactFoldedForelegEnvelope() + { + Bounds blade = InsectSculptureMeshes.Get(InsectSculptureMeshes.Shape.MantisBladeRight).bounds; + Assert.Greater(blade.min.y, .20f, "An overlong tip droops below the folded foreleg"); + Assert.Greater(blade.min.z, .40f); + Assert.Less(blade.size.y, .16f); + } + + private static void AddEdge(int from, int to, Dictionary uses, Dictionary directions) + { + long key = ((long)Math.Min(from, to) << 32) | (uint)Math.Max(from, to); + uses.TryGetValue(key, out int count); uses[key] = count + 1; + directions.TryGetValue(key, out int direction); directions[key] = direction + (from < to ? 1 : -1); + } + + [Test] + public void MirroredMandibles_HaveMatchingBoundsAndSeparateTips() + { + Mesh left = InsectSculptureMeshes.Get(InsectSculptureMeshes.Shape.StagJawLeft); + Mesh right = InsectSculptureMeshes.Get(InsectSculptureMeshes.Shape.StagJawRight); + Assert.AreEqual(-left.bounds.center.x, right.bounds.center.x, .0001f); + Assert.AreEqual(left.bounds.size, right.bounds.size); + Assert.Less(left.bounds.max.x, 0f); + Assert.Greater(right.bounds.min.x, 0f); + } + + [Test] + public void Catalogue_ReusesMeshesAcrossInstances_AndHasFiniteSize() + { + foreach (InsectSculptureMeshes.Shape shape in Shapes) + Assert.AreSame(InsectSculptureMeshes.Get(shape), InsectSculptureMeshes.Get(shape)); + Assert.AreEqual(Enum.GetValues(typeof(InsectSculptureMeshes.Shape)).Length, InsectSculptureMeshes.CachedCount); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/InsectSculptureMeshTests.cs.meta b/Assets/Tests/EditMode/InsectSculptureMeshTests.cs.meta new file mode 100644 index 0000000..118af19 --- /dev/null +++ b/Assets/Tests/EditMode/InsectSculptureMeshTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 42214e30cee8d5441b895150f101717e \ No newline at end of file diff --git a/Assets/Tests/EditMode/InsectVisualTests.cs b/Assets/Tests/EditMode/InsectVisualTests.cs index 7353ffc..645a338 100644 --- a/Assets/Tests/EditMode/InsectVisualTests.cs +++ b/Assets/Tests/EditMode/InsectVisualTests.cs @@ -1,4 +1,4 @@ -#if UNITY_EDITOR +#if UNITY_EDITOR using System.Collections.Generic; using InsectGame.Dex; using InsectGame.UI; @@ -14,6 +14,157 @@ namespace InsectGame.Tests [TestFixture] public class InsectVisualTests { + [Test] + public void BattleModel_UpdateMovement_PreservesArenaOwnedPose() + { + GameObject model = new GameObject("ArenaOwnedPose"); + var data = ScriptableObject.CreateInstance(); + data.insectId = "rhinoceros_beetle"; + try + { + var entity = model.AddComponent(); + entity.BuildForBattle(data, 1, false); + Vector3 position = new Vector3(7f, 2f, -3f); + Quaternion rotation = Quaternion.Euler(0f, 61f, 0f); + model.transform.SetPositionAndRotation(position, rotation); + typeof(InsectGame.Spawning.InsectEntity).GetMethod("UpdateMovement", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).Invoke(entity, null); + Assert.AreEqual(position, model.transform.position); + Assert.AreEqual(rotation, model.transform.rotation); + } + finally { Object.DestroyImmediate(model); Object.DestroyImmediate(data); } + } + + [TestCase("monarch_butterfly", "SpotL1")] + [TestCase("dragonfly", "VeinFL")] + public void WingDecoration_FollowsHinge_WithoutChangingLocalAttachment(string id, string decoration) + { + GameObject model = new GameObject("WingContract"); + var data = ScriptableObject.CreateInstance(); + data.insectId = id; + try + { + model.AddComponent().BuildForBattle(data, 1, false); + Transform hinge = model.transform.Find("WingL"); + Transform detail = hinge.Find(decoration); + Assert.IsNotNull(detail, "Wing decoration must be attached to animated hinge"); + float radius = Vector3.Distance(hinge.position, detail.position); + Vector3 before = detail.position; + hinge.localRotation = Quaternion.Euler(0f, 0f, 40f); + Assert.AreEqual(radius, Vector3.Distance(hinge.position, detail.position), 0.0001f); + Assert.Greater(Vector3.Distance(before, detail.position), 0.05f); + Assert.IsNotNull(hinge.Find("WingLB"), "Hindwing must follow forewing hinge"); + } + finally + { + Object.DestroyImmediate(model); + Object.DestroyImmediate(data); + } + } + + [TestCase("butterfly_monarch")] + [TestCase("moth_night")] + public void Lepidoptera_HasAttachedAntennaeHindwingsAndSixLegs(string id) + { + GameObject model = new GameObject("LepidopteraSilhouette"); + var species = ScriptableObject.CreateInstance(); + species.insectId = id; + try + { + model.AddComponent().BuildForBattle(species, 1, false); + Transform head = model.transform.Find("Head"); + Transform baseAntenna = model.transform.Find("AntBaseL"); + Transform midAntenna = model.transform.Find("AntMidL"); + Assert.IsNotNull(head); + Assert.IsNotNull(baseAntenna); + Assert.IsNotNull(midAntenna); + Assert.IsTrue(head.GetComponent().bounds.Contains(SegmentEnd(baseAntenna, false)), + "Antenna must grow from the head instead of floating above it"); + Assert.Less(Vector3.Distance(SegmentEnd(baseAntenna, true), SegmentEnd(midAntenna, false)), .003f, + "Antenna joints must meet in the side portrait"); + Assert.IsNotNull(model.transform.Find("WingL/WingLB").GetComponent()); + for (int i = 0; i < 3; i++) + foreach (string side in new[] { "L", "R" }) + Assert.IsNotNull(model.transform.Find("LegU" + side + i), "Missing flight leg " + side + i); + } + finally { Object.DestroyImmediate(model); Object.DestroyImmediate(species); } + } + + [Test] + public void Dragonfly_HindwingsAreMembranesAttachedToThorax() + { + GameObject model = new GameObject("DragonflySilhouette"); + var species = ScriptableObject.CreateInstance(); + species.insectId = "dragonfly_lake"; + try + { + model.AddComponent().BuildForBattle(species, 1, false); + Bounds thorax = model.transform.Find("Thorax").GetComponent().bounds; + foreach (string side in new[] { "L", "R" }) + { + Transform hinge = model.transform.Find("Wing" + side); + Transform front = hinge.Find("WingSurface" + side); + Transform hind = hinge.Find("Wing" + side + "B"); + // The original node names remain available under the hinge. + Assert.IsNotNull(front); + Assert.IsNotNull(hind); + Assert.IsNotNull(hind.GetComponent(), "Hindwing was a sphere, not a membrane"); + Assert.IsTrue(front.GetComponent().bounds.Intersects(thorax)); + Assert.IsTrue(hind.GetComponent().bounds.Intersects(thorax)); + } + } + finally { Object.DestroyImmediate(model); Object.DestroyImmediate(species); } + } + + [Test] + public void Ant_MandiblesAreConnectedHooksGrowingFromHead() + { + GameObject model = new GameObject("AntSilhouette"); + var species = ScriptableObject.CreateInstance(); + species.insectId = "ant_soldier"; + try + { + model.AddComponent().BuildForBattle(species, 1, false); + Bounds head = model.transform.Find("Head").GetComponent().bounds; + foreach (string side in new[] { "L", "R" }) + { + Transform baseJaw = model.transform.Find("Mandible" + side); + Transform tipJaw = model.transform.Find("MandibleTip" + side); + Assert.IsNotNull(baseJaw); + Assert.IsNotNull(tipJaw); + Assert.IsTrue(head.Contains(SegmentEnd(baseJaw, false))); + Assert.Less(Vector3.Distance(SegmentEnd(baseJaw, true), SegmentEnd(tipJaw, false)), .003f); + Assert.Greater(Mathf.Abs(SegmentEnd(baseJaw, true).x - SegmentEnd(tipJaw, true).x), .06f, + "The mandible should curve inward rather than project as a box"); + } + } + finally { Object.DestroyImmediate(model); Object.DestroyImmediate(species); } + } + + [Test] + public void Stag_FrontClawsStartAtFeetRatherThanFloatUnderFace() + { + GameObject model = new GameObject("StagSilhouette"); + var species = ScriptableObject.CreateInstance(); + species.insectId = "stag_beetle"; + try + { + model.AddComponent().BuildForBattle(species, 1, false); + foreach (string side in new[] { "L", "R" }) + { + Transform foot = model.transform.Find("Foot" + side + "2"); + Transform claw = model.transform.Find("Claw" + side); + Assert.IsNotNull(foot); + Assert.IsNotNull(claw); + Assert.Less(Vector3.Distance(foot.position, SegmentEnd(claw, false)), .005f); + } + } + finally { Object.DestroyImmediate(model); Object.DestroyImmediate(species); } + } + + private static Vector3 SegmentEnd(Transform segment, bool upper) => + segment.position + segment.up * segment.localScale.y * (upper ? 1f : -1f); + // ── 썸네일 캐시 키 ── [Test] diff --git a/Assets/Tests/EditMode/IslandCatalogTests.cs b/Assets/Tests/EditMode/IslandCatalogTests.cs new file mode 100644 index 0000000..3b9a954 --- /dev/null +++ b/Assets/Tests/EditMode/IslandCatalogTests.cs @@ -0,0 +1,153 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Core; +using NUnit.Framework; + +namespace InsectGame.Tests +{ + /// + /// — 섬 물건 정의의 정합. 카탈로그는 코드 정의라 오타가 런타임까지 간다: + /// 가격이 0이면 살 수 없는 물건이 상점에 뜨고, 모델 switch에서 빠지면 상자 모양으로 놓인다. + /// + [TestFixture] + public class IslandCatalogTests + { + [Test] + public void Ids_AreUniqueAndWellFormed() + { + var seen = new HashSet(); + foreach (IslandObjectDef def in IslandCatalog.All) + { + Assert.IsFalse(string.IsNullOrEmpty(def.id)); + Assert.IsTrue(seen.Add(def.id), $"섬 물건 id 중복: {def.id}"); + // 서버(functions/island.js)가 받는 형식 — 여기 안 맞으면 공개할 때 그 물건이 조용히 빠진다. + Assert.IsTrue(System.Text.RegularExpressions.Regex.IsMatch(def.id, "^[a-z][a-z0-9_]{1,31}$"), + $"섬 물건 id 형식: {def.id}"); + Assert.AreSame(def, IslandCatalog.Get(def.id)); + } + } + + [Test] + public void EveryObject_HasNameDescriptionAndFootprint() + { + foreach (IslandObjectDef def in IslandCatalog.All) + { + Assert.IsFalse(string.IsNullOrEmpty(def.displayName), def.id); + Assert.IsFalse(string.IsNullOrEmpty(def.description), def.id); + Assert.GreaterOrEqual(def.width, 1, def.id); + Assert.GreaterOrEqual(def.depth, 1, def.id); + Assert.GreaterOrEqual(def.comfort, 0, def.id); + } + } + + [Test] + public void EveryObject_HasExactlyOnePrice() + { + foreach (IslandObjectDef def in IslandCatalog.All) + { + bool coin = def.coinPrice > 0; + bool gem = def.gemPrice > 0; + Assert.IsTrue(coin ^ gem, $"{def.id}: 코인가와 다이아가 중 정확히 하나만 양수여야 한다"); + } + } + + [Test] + public void EffectObjects_HavePositiveValue_AndPlainOnesHaveNone() + { + foreach (IslandObjectDef def in IslandCatalog.All) + { + if (def.effect == IslandEffectKind.None) Assert.AreEqual(0f, def.effectValue, def.id); + else Assert.Greater(def.effectValue, 0f, def.id); + } + } + + [Test] + public void EveryObject_FitsOnTheSizeLevelThatUnlocksIt() + { + foreach (IslandObjectDef def in IslandCatalog.All) + { + Assert.LessOrEqual(def.requiredSizeLevel, GameConstants.Island.MaxSizeLevel, def.id); + // 그 물건이 풀리는 섬 크기에서 실제로 놓을 자리가 있어야 한다(가운데 근처). + Assert.AreEqual(IslandPlaceResult.Ok, + IslandGrid.CanPlace(null, -1, def, -def.width, 0, 0, def.requiredSizeLevel), def.id); + } + } + + [Test] + public void EachCategory_HasObjects() + { + var counts = new Dictionary(); + foreach (IslandObjectDef def in IslandCatalog.All) + { + counts.TryGetValue(def.category, out int n); + counts[def.category] = n + 1; + } + foreach (IslandObjectCategory category in System.Enum.GetValues(typeof(IslandObjectCategory))) + Assert.IsTrue(counts.ContainsKey(category) && counts[category] > 0, $"상점 탭이 비었다: {category}"); + } + + [Test] + public void EveryObject_HasItsOwnModel() + { + // 모델 switch에서 빠진 id는 상자 모양 폴백으로 놓인다 — 예외도 경고도 없다. + foreach (IslandObjectDef def in IslandCatalog.All) + Assert.IsTrue(IslandObjectBuilder.HasModel(def.id), $"전용 모델이 없다: {def.id}"); + Assert.IsFalse(IslandObjectBuilder.HasModel("x_from_future")); + } + + [Test] + public void StarterKit_PointsAtRealObjects_AndIncludesTheGuideBench() + { + bool hasBench = false; + foreach ((string id, int count) in IslandCatalog.StarterKit) + { + Assert.IsNotNull(IslandCatalog.Get(id), $"스타터 키트의 물건이 카탈로그에 없다: {id}"); + Assert.Greater(count, 0); + if (id == "f_bench") hasBench = true; + } + // 가이드의 「물건 놓기」가 벤치를 놓아 보라고 말한다. + Assert.IsTrue(hasBench); + } + + [Test] + public void SizePrice_EveryLevelBelowMax_HasBothPrices() + { + for (int level = 0; level < GameConstants.Island.MaxSizeLevel; level++) + { + Assert.IsTrue(IslandCatalog.SizePrice(level, out int coins, out int gems)); + Assert.Greater(coins, 0); + Assert.Greater(gems, 0); + } + Assert.IsFalse(IslandCatalog.SizePrice(GameConstants.Island.MaxSizeLevel, out _, out _)); + Assert.IsFalse(IslandCatalog.SizePrice(-1, out _, out _)); + } + + [Test] + public void SlotPrice_CoversEverySlotUpToMax_AndRises() + { + int max = GameConstants.Island.MaxInsectSlots - GameConstants.Island.BaseInsectSlots; + int previousCoins = 0; + for (int extra = 0; extra < max; extra++) + { + Assert.IsTrue(IslandCatalog.SlotPrice(extra, out int coins, out int gems), $"슬롯 {extra}의 가격이 없다"); + Assert.Greater(coins, previousCoins, "슬롯 값은 살수록 올라야 한다"); + Assert.Greater(gems, 0); + previousCoins = coins; + } + Assert.IsFalse(IslandCatalog.SlotPrice(max, out _, out _)); + } + + [Test] + public void QuestIslandRewards_PointAtRealObjects() + { + // 퀘스트 보상의 섬 물건 id가 오타면 "보이지도 않고 주지도 않는" 보상이 된다(포맷터·지급이 같은 술어를 쓴다). + string source = System.IO.File.ReadAllText( + System.IO.Path.Combine(UnityEngine.Application.dataPath, "Scripts/Core/TutorialQuestManager.cs")); + var matches = System.Text.RegularExpressions.Regex.Matches(source, "rewardIslandObjectId\\s*=\\s*\"([^\"]+)\""); + Assert.Greater(matches.Count, 0, "섬 물건을 주는 퀘스트가 하나도 없다 — 정규식이 소스와 어긋났는지 확인"); + foreach (System.Text.RegularExpressions.Match m in matches) + Assert.IsNotNull(IslandCatalog.Get(m.Groups[1].Value), $"퀘스트 보상 섬 물건이 카탈로그에 없다: {m.Groups[1].Value}"); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/IslandCatalogTests.cs.meta b/Assets/Tests/EditMode/IslandCatalogTests.cs.meta new file mode 100644 index 0000000..108a13c --- /dev/null +++ b/Assets/Tests/EditMode/IslandCatalogTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e7860eef421883a4d8a4b1a345884eb7 \ No newline at end of file diff --git a/Assets/Tests/EditMode/IslandGridTests.cs b/Assets/Tests/EditMode/IslandGridTests.cs new file mode 100644 index 0000000..b05e01c --- /dev/null +++ b/Assets/Tests/EditMode/IslandGridTests.cs @@ -0,0 +1,176 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Core; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// — 섬 격자의 차지 칸·경계·겹침·도착 칸 보호. + /// 배치 규칙이 틀리면 증상이 조용하다: 건물이 겹쳐 놓이거나, 나루터 앞이 막혀 섬에 들어오자마자 갇힌다. + /// + [TestFixture] + public class IslandGridTests + { + private static IslandObjectDef Def(string id) => IslandCatalog.Get(id); + + private static IslandPlacedRecord Placed(string id, int x, int z, int rot = 0) + => new IslandPlacedRecord { id = id, x = x, z = z, rot = rot }; + + [TestCase(0, 10)] + [TestCase(1, 14)] + [TestCase(2, 18)] + [TestCase(3, 22)] + public void GridSize_PerLevel_GrowsByFourAndStaysEven(int level, int expected) + { + Assert.AreEqual(expected, IslandGrid.GridSize(level)); + Assert.AreEqual(0, IslandGrid.GridSize(level) % 2, "한 변이 홀수면 섬 중심이 칸 한가운데로 가 좌표가 어긋난다"); + } + + [Test] + public void GridSize_OutOfRangeLevel_IsClamped() + { + Assert.AreEqual(IslandGrid.GridSize(0), IslandGrid.GridSize(-5)); + Assert.AreEqual(IslandGrid.GridSize(GameConstants.Island.MaxSizeLevel), IslandGrid.GridSize(99)); + } + + [TestCase(0, 2, 1)] + [TestCase(1, 1, 2)] + [TestCase(2, 2, 1)] + [TestCase(3, 1, 2)] + [TestCase(-1, 1, 2)] + public void Footprint_Rotation_SwapsWidthAndDepthOnOddTurns(int rot, int width, int depth) + { + IslandGrid.Footprint(Def("f_bench"), rot, out int w, out int d); + Assert.AreEqual(width, w); + Assert.AreEqual(depth, d); + } + + [Test] + public void CanPlace_EmptyIsland_AcceptsInteriorCell() + { + Assert.AreEqual(IslandPlaceResult.Ok, + IslandGrid.CanPlace(new List(), -1, Def("f_bench"), 0, 0, 0, 0)); + } + + [Test] + public void CanPlace_UnknownObject_IsRejected() + { + Assert.AreEqual(IslandPlaceResult.UnknownObject, + IslandGrid.CanPlace(new List(), -1, null, 0, 0, 0, 0)); + } + + [Test] + public void CanPlace_FootprintCrossingEdge_IsOutOfBounds() + { + // 10칸 섬의 칸 번호는 -5 ~ 4. 2칸짜리를 x=4에 두면 5번 칸까지 뻗는다. + Assert.AreEqual(IslandPlaceResult.OutOfBounds, + IslandGrid.CanPlace(null, -1, Def("f_bench"), 4, 0, 0, 0)); + Assert.AreEqual(IslandPlaceResult.Ok, + IslandGrid.CanPlace(null, -1, Def("f_bench"), 3, 0, 0, 0)); + Assert.AreEqual(IslandPlaceResult.OutOfBounds, + IslandGrid.CanPlace(null, -1, Def("f_bench"), -6, 0, 0, 0)); + } + + [Test] + public void CanPlace_OnArrivalCells_IsReserved() + { + // 도착 칸은 남쪽 끝 가운데 2×2(x -1~0, z -5~-4). 여길 막으면 들어오자마자 갇힌다. + Assert.AreEqual(IslandPlaceResult.Reserved, + IslandGrid.CanPlace(null, -1, Def("t_rock"), 0, -5, 0, 0)); + Assert.AreEqual(IslandPlaceResult.Reserved, + IslandGrid.CanPlace(null, -1, Def("t_rock"), -1, -4, 0, 0)); + Assert.AreEqual(IslandPlaceResult.Ok, + IslandGrid.CanPlace(null, -1, Def("t_rock"), 1, -5, 0, 0)); + } + + [Test] + public void ArrivalPoint_IsInsideTheReservedCells() + { + for (int level = 0; level <= GameConstants.Island.MaxSizeLevel; level++) + { + IslandGrid.CellAt(IslandGrid.ArrivalPoint(level) + new Vector3(0.01f, 0f, 0.01f), out int x, out int z); + Assert.IsTrue(IslandGrid.TouchesArrival(x, z, 1, 1, IslandGrid.GridSize(level)), + $"섬 크기 {level}: 도착 자리가 보호된 칸 밖이다 — 그 자리에 물건을 놓을 수 있게 된다"); + } + } + + [Test] + public void CanPlace_OverlappingExisting_IsOverlap() + { + var placed = new List { Placed("b_cabin", 0, 0) }; // 3×3 → (0..2, 0..2) + Assert.AreEqual(IslandPlaceResult.Overlap, IslandGrid.CanPlace(placed, -1, Def("t_rock"), 2, 2, 0, 0)); + Assert.AreEqual(IslandPlaceResult.Ok, IslandGrid.CanPlace(placed, -1, Def("t_rock"), 3, 2, 0, 0)); + } + + [Test] + public void CanPlace_MovingObject_IgnoresItsOwnFootprint() + { + var placed = new List { Placed("b_cabin", 0, 0) }; + // 한 칸 옆으로 옮기면 자기 옛 자리와 겹친다 — 자기 자신은 빼야 옮길 수 있다. + Assert.AreEqual(IslandPlaceResult.Ok, IslandGrid.CanPlace(placed, 0, Def("b_cabin"), 1, 0, 0, 0)); + Assert.AreEqual(IslandPlaceResult.Overlap, IslandGrid.CanPlace(placed, -1, Def("b_cabin"), 1, 0, 0, 0)); + } + + [Test] + public void CanPlace_UnknownExistingRecord_DoesNotBlock() + { + // 더 새 버전에서 산 물건(카탈로그에 없는 id)이 자리를 영영 막으면 안 된다. + var placed = new List { Placed("x_from_future", 0, 0) }; + Assert.AreEqual(IslandPlaceResult.Ok, IslandGrid.CanPlace(placed, -1, Def("t_rock"), 0, 0, 0, 0)); + } + + [Test] + public void CanPlace_AfterExpansion_OldCoordinatesStayValid() + { + // 좌표가 섬 중심 기준이라 넓혀도 기존 배치가 그대로 유효하다(마이그레이션 없음). + var def = Def("b_cabin"); + Assert.AreEqual(IslandPlaceResult.Ok, IslandGrid.CanPlace(null, -1, def, -5, 2, 0, 0)); + Assert.AreEqual(IslandPlaceResult.Ok, IslandGrid.CanPlace(null, -1, def, -5, 2, 0, 3)); + // 넓힌 섬에서만 되는 자리 + Assert.AreEqual(IslandPlaceResult.OutOfBounds, IslandGrid.CanPlace(null, -1, def, -7, 2, 0, 0)); + Assert.AreEqual(IslandPlaceResult.Ok, IslandGrid.CanPlace(null, -1, def, -7, 2, 0, 1)); + } + + [Test] + public void FindAt_ReturnsIndexCoveringTheCell() + { + var placed = new List { Placed("t_rock", -3, -3), Placed("f_bench", 1, 1, 1) }; + Assert.AreEqual(0, IslandGrid.FindAt(placed, -3, -3)); + // 벤치(2×1)를 90° 돌리면 1×2 → (1,1)과 (1,2) + Assert.AreEqual(1, IslandGrid.FindAt(placed, 1, 2)); + Assert.AreEqual(-1, IslandGrid.FindAt(placed, 2, 1)); + } + + [Test] + public void CellAt_FootprintCenter_RoundTrips() + { + for (int x = -5; x < 5; x++) + { + IslandGrid.CellAt(IslandGrid.FootprintCenter(x, -2, 1, 1), out int cx, out int cz); + Assert.AreEqual(x, cx); + Assert.AreEqual(-2, cz); + } + } + + [Test] + public void CollectFreeCells_SkipsBlockersAndArrival_ButKeepsWalkableObjects() + { + var placed = new List + { + Placed("t_boulder", 0, 0), // 2×2, 막는다 + Placed("t_flowerbed", -4, 0), // 2×2, 지나다닐 수 있다 + }; + var cells = new List(); + IslandGrid.CollectFreeCells(placed, 0, cells); + + int total = 10 * 10; + Assert.AreEqual(total - 4 /*바위*/ - 4 /*도착 칸*/, cells.Count); + CollectionAssert.DoesNotContain(cells, new Vector2Int(1, 1)); + CollectionAssert.DoesNotContain(cells, new Vector2Int(0, -5)); + CollectionAssert.Contains(cells, new Vector2Int(-4, 0)); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/IslandGridTests.cs.meta b/Assets/Tests/EditMode/IslandGridTests.cs.meta new file mode 100644 index 0000000..243ed28 --- /dev/null +++ b/Assets/Tests/EditMode/IslandGridTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7d6e222d1c90493459500576b476e7ce \ No newline at end of file diff --git a/Assets/Tests/EditMode/IslandGuestRulesTests.cs b/Assets/Tests/EditMode/IslandGuestRulesTests.cs new file mode 100644 index 0000000..5861528 --- /dev/null +++ b/Assets/Tests/EditMode/IslandGuestRulesTests.cs @@ -0,0 +1,406 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Spawning; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 나의 섬 손님 곤충() — 손님 수 표, 들어설 수 있는 곳, 종 고르기(해금 1곳에서도 비지 않음·등급 상한), + /// 레벨 대역, 떠나는 규칙, 빈 칸 위 도주 거리, 들어설 칸. + /// + /// 실패가 조용한 계열이다: 해금 판정이 뒤집히면 잠긴 리전의 종이 섬으로 새고, 등급 상한이 빠지면 집에서 전설 레이드가 열리고, + /// 빈 칸 거리가 틀리면 손님이 꽃밭 너머·바다로 달아난다. 예외도 경고도 없다. + /// + [TestFixture] + public class IslandGuestRulesTests + { + private const BindingFlags Inst = BindingFlags.Instance | BindingFlags.NonPublic; + + private static readonly DayPhase[] Phases = (DayPhase[])System.Enum.GetValues(typeof(DayPhase)); + private static readonly WeatherType[] Weathers = (WeatherType[])System.Enum.GetValues(typeof(WeatherType)); + + private readonly List shared = new List(); + private readonly List perTest = new List(); + private InsectDatabase db; + + [TearDown] + public void TearDown() + { + foreach (Object o in perTest) if (o != null) Object.DestroyImmediate(o); + perTest.Clear(); + } + + [OneTimeTearDown] + public void ReleaseSharedDatabase() + { + foreach (Object o in shared) if (o != null) Object.DestroyImmediate(o); + shared.Clear(); + db = null; + } + + private InsectDatabase RealDatabase() + { + if (db != null) return db; + var host = new GameObject("IslandGuestRulesTests_RealDb"); + host.SetActive(false); // Awake(월드 생성)를 막고 private 생성 함수만 부른다 + shared.Add(host); + var bootstrap = host.AddComponent(); + db = (InsectDatabase)typeof(PlaySceneBootstrap).GetMethod("EnsureExpandedDatabase", Inst).Invoke(bootstrap, null); + Assert.IsNotNull(db); + shared.Add(db); + return db; + } + + private static WorldState State(DayPhase phase, WeatherType weather, int hour = 12) + => new WorldState { DayPhase = phase, Weather = weather, Hour24 = hour }; + + private static int HourOf(DayPhase phase) + { + switch (phase) + { + case DayPhase.Morning: return 8; + case DayPhase.Day: return 14; + case DayPhase.Evening: return 19; + default: return 23; + } + } + + /// 진짜 리전 정의의 풀 — 레벨 대역은 리전의 requiredLevel부터(대역 폭은 이 테스트가 보지 않는다). + private List RealTables() + { + var tables = new List(); + foreach (RegionData region in RegionDefinitions.CreateAll()) + { + if (region.insectIds == null || region.insectIds.Length == 0) continue; + var pool = new List(); + foreach (string id in region.insectIds) + { + InsectData d = RealDatabase().GetById(id); + Assert.IsNotNull(d, $"{region.regionId} 풀의 '{id}'가 DB에 없다"); + if (!pool.Contains(d)) pool.Add(d); + } + tables.Add(new FieldRegionTable(region.regionId, region.requiredLevel, region.requiredLevel + 9, pool)); + } + return tables; + } + + private InsectData Fake(string id, InsectRarity rarity, float weight = 1f) + { + var d = ScriptableObject.CreateInstance(); + d.insectId = id; + d.rarity = rarity; + d.spawnWeight = weight; + perTest.Add(d); + return d; + } + + // ── 손님 수 ── + + [Test] + public void WantedGuests_IsNightPlusWetWeather_CappedAtTwo() + { + foreach (DayPhase p in Phases) + foreach (WeatherType w in Weathers) + { + int expected = (p == DayPhase.Night ? 1 : 0) + + (w == WeatherType.Rain || w == WeatherType.Fog ? 1 : 0); + Assert.AreEqual(expected, IslandGuestRules.WantedGuests(State(p, w)), $"{p}/{w}"); + } + Assert.AreEqual(0, IslandGuestRules.WantedGuests(State(DayPhase.Day, WeatherType.Clear)), "맑은 낮엔 아무도 안 온다"); + Assert.AreEqual(2, IslandGuestRules.WantedGuests(State(DayPhase.Night, WeatherType.Fog))); + Assert.AreEqual(2, IslandGuestRules.MaxGuests); + } + + [Test] + public void WantedGuests_IsTheFieldBonusSlotRule() + { + // 단일 출처 — 필드 보너스 슬롯과 갈라지면 섬만 다른 규칙이 된다. + foreach (DayPhase p in Phases) + foreach (WeatherType w in Weathers) + Assert.AreEqual(FieldSpawnRules.BonusSlots(State(p, w)), IslandGuestRules.WantedGuests(State(p, w))); + } + + // ── 어디서 오나 ── + + [Test] + public void CanHostGuests_OnlyOnMyOwnIsland_NotDreaming_NotDecorating() + { + Assert.IsTrue(IslandGuestRules.CanHostGuests(IslandMode.Own, false, false)); + Assert.IsFalse(IslandGuestRules.CanHostGuests(IslandMode.Visit, false, false), "남의 섬 구경에 손님이 온다"); + Assert.IsFalse(IslandGuestRules.CanHostGuests(IslandMode.None, false, false), "섬 밖에서 손님이 선다"); + Assert.IsFalse(IslandGuestRules.CanHostGuests(IslandMode.Own, true, false), "꿈속 섬에 손님이 온다"); + Assert.IsFalse(IslandGuestRules.CanHostGuests(IslandMode.Visit, true, false)); + Assert.IsFalse(IslandGuestRules.CanHostGuests(IslandMode.Own, false, true), "꾸미기 중에 손님이 들어선다"); + } + + // ── 종 고르기 ── + + [Test] + public void PickSpecies_WithOnlyTheMeadowUnlocked_NeverComesUpEmpty() + { + List all = RealTables(); + var accessible = new List(); + IslandGuestRules.FilterAccessible(all, id => id == "meadow", accessible); + Assert.AreEqual(1, accessible.Count, "초원만 해금인데 다른 리전 표가 남았다"); + var meadowPool = new HashSet(accessible[0].Pool); + + var candidates = new List(); + var scratch = new List(); + var multipliers = new List(); + var available = new bool[FieldSpawnRules.RarityCount]; + float[] rolls = { 0f, 0.13f, 0.5f, 0.87f, 0.999f }; + foreach (DayPhase p in Phases) + foreach (WeatherType w in Weathers) + { + WorldState s = State(p, w, HourOf(p)); + IslandGuestRules.CollectCandidates(accessible, s, candidates); + Assert.Greater(candidates.Count, 0, $"{p}/{w}: 초원 후보가 비었다"); + foreach (float rr in rolls) + foreach (float sr in rolls) + { + InsectData d = IslandGuestRules.PickSpecies(candidates, s, rr, sr, scratch, multipliers, available); + Assert.IsNotNull(d, $"{p}/{w} 굴림 {rr}/{sr}: 손님 종이 없다"); + Assert.IsTrue(meadowPool.Contains(d), $"{d.insectId}: 잠긴 리전의 종이 섬으로 샜다"); + Assert.LessOrEqual(d.rarity, IslandGuestRules.MaxGuestRarity); + Assert.IsTrue(IslandGuestRules.TryLevelBand(d, accessible, out int lo, out int hi)); + Assert.AreEqual(1, lo, "초원 손님은 초원 대역이다"); + Assert.GreaterOrEqual(hi, lo); + } + } + } + + [Test] + public void PickSpecies_EverythingUnlocked_NeverSendsARaidBoss() + { + List all = RealTables(); + var accessible = new List(); + IslandGuestRules.FilterAccessible(all, _ => true, accessible); + var candidates = new List(); + var scratch = new List(); + var multipliers = new List(); + var available = new bool[FieldSpawnRules.RarityCount]; + WorldState s = State(DayPhase.Night, WeatherType.Fog, 23); + IslandGuestRules.CollectCandidates(accessible, s, candidates); + + // 등급표의 맨 끝(전설 몫)을 굴려도 희귀로 내려온다. + for (int i = 0; i <= 200; i++) + { + InsectData d = IslandGuestRules.PickSpecies(candidates, s, i / 200f, 0.5f, scratch, multipliers, available); + Assert.IsNotNull(d); + Assert.IsFalse(CaptureChoiceUI.IsRaidRarity(d.rarity), $"{d.insectId}({d.rarity}): 섬에서 레이드 창이 열린다"); + } + } + + [Test] + public void GuestRarityCap_SitsBelowEveryRaidRarity_AndFoldsIntoRare() + { + for (int r = 0; r <= (int)IslandGuestRules.MaxGuestRarity; r++) + Assert.IsFalse(CaptureChoiceUI.IsRaidRarity((InsectRarity)r), $"{(InsectRarity)r}: 손님 등급 안에 레이드 등급이 있다"); + + // 영웅·전설 몫은 기존 대체 규칙(가까운 아래)대로 희귀로 내려온다 — 일반·고급 몫은 그대로다. + var available = new bool[FieldSpawnRules.RarityCount]; + for (int r = 0; r <= (int)IslandGuestRules.MaxGuestRarity; r++) available[r] = true; + float[] shares = FieldSpawnRules.EffectiveShares(1f, available); + Assert.AreEqual(FieldSpawnRules.CommonShare, shares[(int)InsectRarity.Common], 1e-4f); + Assert.AreEqual(FieldSpawnRules.UncommonShare, shares[(int)InsectRarity.Uncommon], 1e-4f); + Assert.AreEqual(FieldSpawnRules.RareShare + FieldSpawnRules.EpicShare + FieldSpawnRules.LegendaryShare, + shares[(int)InsectRarity.Rare], 1e-4f); + Assert.AreEqual(0f, shares[(int)InsectRarity.Epic], 1e-6f); + Assert.AreEqual(0f, shares[(int)InsectRarity.Legendary], 1e-6f); + } + + [Test] + public void CollectCandidates_IsAUnionWithoutDuplicates_AndFallsBackWhenNothingMatches() + { + InsectData shared1 = Fake("shared", InsectRarity.Common); + InsectData onlyA = Fake("only_a", InsectRarity.Uncommon); + InsectData nightOnly = Fake("night_only", InsectRarity.Common); + nightOnly.spawnCondition = new InsectSpawnCondition + { + limitByDayPhase = true, + allowedDayPhases = new[] { DayPhase.Night } + }; + var a = new FieldRegionTable("a", 1, 10, new List { shared1, onlyA }); + var b = new FieldRegionTable("b", 6, 16, new List { shared1, nightOnly }); + var into = new List(); + + IslandGuestRules.CollectCandidates(new List { a, b }, State(DayPhase.Day, WeatherType.Clear), into); + CollectionAssert.AreEquivalent(new[] { shared1, onlyA }, into, "낮인데 밤에만 나오는 종이 끼었거나 겹친 종이 두 번 들었다"); + + IslandGuestRules.CollectCandidates(new List { a, b }, State(DayPhase.Night, WeatherType.Clear, 23), into); + CollectionAssert.AreEquivalent(new[] { shared1, onlyA, nightOnly }, into); + + // 지금 나올 수 있는 종이 하나도 없으면 합집합 전체로 물러난다(손님이 영영 안 오지 않게). + var c = new FieldRegionTable("c", 1, 10, new List { nightOnly }); + IslandGuestRules.CollectCandidates(new List { c }, State(DayPhase.Day, WeatherType.Clear), into); + CollectionAssert.AreEquivalent(new[] { nightOnly }, into); + } + + // ── 레벨 대역 ── + + [Test] + public void FilterAccessible_DropsLockedRegions_AndSortsByLevel() + { + InsectData x = Fake("x", InsectRarity.Common); + var all = new List + { + new FieldRegionTable("ruins", 36, 50, new List { x }), + new FieldRegionTable("meadow", 1, 10, new List { x }), + new FieldRegionTable("pond", 6, 16, new List { x }), + new FieldRegionTable("empty", 3, 9, new List()), + }; + var into = new List(); + IslandGuestRules.FilterAccessible(all, id => id != "pond", into); + Assert.AreEqual(2, into.Count, "잠긴 리전·빈 풀이 남았다"); + Assert.AreEqual("meadow", into[0].RegionId); + Assert.AreEqual("ruins", into[1].RegionId); + } + + [Test] + public void TryLevelBand_UsesTheLowestUnlockedRegionThatHasTheSpecies() + { + InsectData dragonfly = Fake("dragonfly", InsectRarity.Rare); + InsectData other = Fake("other", InsectRarity.Common); + var pond = new FieldRegionTable("pond", 6, 16, new List { dragonfly, other }); + var swamp = new FieldRegionTable("swamp", 20, 32, new List { dragonfly }); + var ruins = new FieldRegionTable("ruins", 36, 50, new List { dragonfly }); + + // 늪·유적까지 열려 있어도 연못 대역 — 가장 높은 해금 리전으로 올라가지 않는다(섬이 고레벨 사냥터가 되지 않게). + Assert.IsTrue(IslandGuestRules.TryLevelBand(dragonfly, new List { pond, swamp, ruins }, out int lo, out int hi)); + Assert.AreEqual(6, lo); + Assert.AreEqual(16, hi); + + // 연못이 잠겼으면 그 종이 사는 다음 리전 — 뒤 리전 종을 앞 리전 대역으로 거저 주지 않는다. + Assert.IsTrue(IslandGuestRules.TryLevelBand(dragonfly, new List { swamp, ruins }, out lo, out hi)); + Assert.AreEqual(20, lo); + Assert.AreEqual(32, hi); + + Assert.IsFalse(IslandGuestRules.TryLevelBand(other, new List { swamp, ruins }, out _, out _)); + Assert.IsFalse(IslandGuestRules.TryLevelBand(null, new List { pond }, out _, out _)); + } + + // ── 떠나기 ── + + [Test] + public void Leaving_OnlyWhenSurplusOrExpired_AndOnlyOutOfSight() + { + Assert.IsFalse(IslandGuestRules.ShouldLeave(false, false)); + Assert.IsTrue(IslandGuestRules.ShouldLeave(true, false), "아침이 와 손님 수가 줄었다"); + Assert.IsTrue(IslandGuestRules.ShouldLeave(false, true), "수명이 다했다"); + + Assert.IsTrue(IslandGuestRules.CanLeaveNow(false, false)); + Assert.IsFalse(IslandGuestRules.CanLeaveNow(false, true), "눈앞에서 사라진다"); + Assert.IsFalse(IslandGuestRules.CanLeaveNow(true, false), "포획·전투 중에 떠난다"); + } + + [Test] + public void IsInView_ScreenAndMargin_AndNothingBehindTheCamera() + { + Assert.IsTrue(IslandGuestRules.IsInView(new Vector3(0.5f, 0.5f, 5f))); + Assert.IsTrue(IslandGuestRules.IsInView(new Vector3(1.05f, 0.5f, 5f)), "가장자리에 걸친 몸도 보인다"); + Assert.IsFalse(IslandGuestRules.IsInView(new Vector3(1.2f, 0.5f, 5f))); + Assert.IsFalse(IslandGuestRules.IsInView(new Vector3(0.5f, -0.2f, 5f))); + Assert.IsFalse(IslandGuestRules.IsInView(new Vector3(0.5f, 0.5f, -1f)), "카메라 뒤"); + } + + // ── 빈 칸 위 도주 ── + + private static HashSet Block(int minX, int maxX, int minZ, int maxZ) + { + var set = new HashSet(); + for (int x = minX; x <= maxX; x++) + for (int z = minZ; z <= maxZ; z++) + set.Add(new Vector2Int(x, z)); + return set; + } + + private static Vector3 CellCenter(int x, int z) => IslandGrid.FootprintCenter(x, z, 1, 1); + + [Test] + public void FreeRun_StopsAtTheEdgeOfTheFreeCells() + { + float cs = GameConstants.Island.CellSize; + HashSet free = Block(0, 2, 0, 0); // 가로 세 칸 + float run = IslandGuestRules.FreeRun(CellCenter(0, 0), Vector3.right, free, 20f); + // 첫 칸 가운데(0.5칸)에서 세 칸 끝(3칸)까지 = 2.5칸 — 한 걸음(0.25m) 오차 안. + Assert.AreEqual(2.5f * cs, run, IslandGuestRules.FreeRunStep + 1e-3f); + Assert.Less(run, 2.5f * cs + 1e-3f, "빈 칸 밖으로 한 걸음이라도 나갔다"); + + float back = IslandGuestRules.FreeRun(CellCenter(0, 0), Vector3.left, free, 20f); + Assert.Less(back, 0.5f * cs + 1e-3f, "왼쪽은 반 칸 뒤가 끝이다"); + } + + [Test] + public void FreeRun_ZeroWhenStartingOffTheFreeCells_AndCappedByMaxDistance() + { + HashSet free = Block(0, 20, 0, 0); + Assert.AreEqual(0f, IslandGuestRules.FreeRun(CellCenter(-3, 0), Vector3.right, free, 20f), 1e-6f); + Assert.AreEqual(4f, IslandGuestRules.FreeRun(CellCenter(0, 0), Vector3.right, free, 4f), 1e-6f); + Assert.AreEqual(0f, IslandGuestRules.FreeRun(CellCenter(0, 0), Vector3.zero, free, 4f), 1e-6f); + Assert.AreEqual(0f, IslandGuestRules.FreeRun(CellCenter(0, 0), Vector3.right, null, 4f), 1e-6f); + } + + [Test] + public void FreeRun_BlockedCellInTheMiddle_IsAWall() + { + float cs = GameConstants.Island.CellSize; + HashSet free = Block(0, 4, 0, 0); + free.Remove(new Vector2Int(2, 0)); // 꽃밭이 아니라 막는 물건이 놓인 칸 + float run = IslandGuestRules.FreeRun(CellCenter(0, 0), Vector3.right, free, 20f); + Assert.Less(run, 1.5f * cs + 1e-3f, "막힌 칸을 뛰어넘었다"); + } + + // ── 들어설 칸 ── + + [Test] + public void PickArrivalCell_PrefersCellsOutOfSight_AwayFromThePlayer() + { + var free = new List(); + for (int x = -5; x < 5; x++) free.Add(new Vector2Int(x, 0)); + var player = new Vector2Int(0, 0); + System.Func inView = c => c.x >= -2 && c.x <= 2; // 가운데 다섯 칸만 보인다 + + for (int i = 0; i <= 20; i++) + { + Assert.IsTrue(IslandGuestRules.PickArrivalCell(free, inView, player, null, i / 20f, out Vector2Int cell)); + Assert.IsFalse(inView(cell), $"{cell}: 화면 안에 들어섰다"); + Assert.GreaterOrEqual(Mathf.Abs(cell.x), IslandGuestRules.ArrivalMinCells, $"{cell}: 플레이어 코앞이다"); + } + } + + [Test] + public void PickArrivalCell_EverythingVisible_TakesTheFarthestFreeCell_AndSkipsTakenOnes() + { + var free = new List { new Vector2Int(0, 0), new Vector2Int(1, 0), new Vector2Int(6, 0), new Vector2Int(5, 0) }; + System.Func all = _ => true; + Assert.IsTrue(IslandGuestRules.PickArrivalCell(free, all, Vector2Int.zero, null, 0.3f, out Vector2Int cell)); + Assert.AreEqual(new Vector2Int(6, 0), cell); + + var taken = new HashSet { new Vector2Int(6, 0) }; + Assert.IsTrue(IslandGuestRules.PickArrivalCell(free, all, Vector2Int.zero, taken, 0.3f, out cell)); + Assert.AreEqual(new Vector2Int(5, 0), cell, "다른 손님이 선 칸에 겹쳐 섰다"); + + Assert.IsFalse(IslandGuestRules.PickArrivalCell(new List(), all, Vector2Int.zero, null, 0f, out _)); + } + + [Test] + public void ArrivalCells_ExistOnTheSmallestIsland_AwayFromTheArrivalPoint() + { + // 가장 작은 섬(10칸)·빈 섬 — 도착 자리(남쪽 가운데)에서 최소 거리 밖 칸이 남는다. + var free = new List(); + IslandGrid.CollectFreeCells(new List(), 0, free); + Assert.Greater(free.Count, 0); + IslandGrid.CellAt(IslandGrid.ArrivalPoint(0), out int ax, out int az); + Assert.IsTrue(IslandGuestRules.PickArrivalCell(free, _ => false, new Vector2Int(ax, az), null, 0.5f, out Vector2Int cell)); + int dx = cell.x - ax, dz = cell.y - az; + Assert.GreaterOrEqual(dx * dx + dz * dz, IslandGuestRules.ArrivalMinCells * IslandGuestRules.ArrivalMinCells); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/IslandGuestRulesTests.cs.meta b/Assets/Tests/EditMode/IslandGuestRulesTests.cs.meta new file mode 100644 index 0000000..eb23aba --- /dev/null +++ b/Assets/Tests/EditMode/IslandGuestRulesTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bf682495d60c865428db61bb23dbbd3f \ No newline at end of file diff --git a/Assets/Tests/EditMode/IslandInsectMoodTests.cs b/Assets/Tests/EditMode/IslandInsectMoodTests.cs new file mode 100644 index 0000000..426433f --- /dev/null +++ b/Assets/Tests/EditMode/IslandInsectMoodTests.cs @@ -0,0 +1,113 @@ +#if UNITY_EDITOR +using InsectGame.Core; +using InsectGame.Data; +using NUnit.Framework; + +namespace InsectGame.Tests +{ + /// + /// 섬 방목 곤충의 시간·날씨 기분() — 야행성은 밤에 활발·낮엔 쉼, 주행성은 반대, 싫은 날씨엔 덜 움직이고 + /// 좋은 날씨엔 활발, 시간·날씨를 안 타는 종은 예전 배회 그대로. + /// + [TestFixture] + public class IslandInsectMoodTests + { + private static readonly DayPhase[] Phases = (DayPhase[])System.Enum.GetValues(typeof(DayPhase)); + private static readonly WeatherType[] Weathers = (WeatherType[])System.Enum.GetValues(typeof(WeatherType)); + + private static WorldState State(DayPhase phase, WeatherType weather) + => new WorldState { DayPhase = phase, Weather = weather, Hour24 = 12 }; + + private static InsectHabit Habit(InsectActivity activity, WeatherSet like = WeatherSet.None, WeatherSet dislike = WeatherSet.None) + => new InsectHabit(activity, like, dislike, InsectTemperament.Docile); + + [Test] + public void Nocturnal_IsLivelyAtNight_AndMostlyRestsByDay() + { + InsectHabit moth = Habit(InsectActivity.Nocturnal); + IslandInsectMood.Mood night = IslandInsectMood.For(moth, State(DayPhase.Night, WeatherType.Clear)); + IslandInsectMood.Mood day = IslandInsectMood.For(moth, State(DayPhase.Day, WeatherType.Clear)); + + Assert.Greater(night.Activity, day.Activity); + Assert.Less(night.RestMax, day.RestMin, "밤에 쉬는 시간이 낮보다 길다"); + Assert.Greater(night.SpeedMultiplier, 1f, "밤엔 조금 빨라야 한다"); + Assert.Less(day.SpeedMultiplier, 1f, "낮엔 느려야 한다"); + Assert.Less(day.HoverMultiplier, 0.5f, "낮엔 나는 종도 땅에 낮게 내려앉는다"); + Assert.Greater(night.HoverMultiplier, day.HoverMultiplier); + } + + [Test] + public void Diurnal_IsTheMirrorImage() + { + InsectHabit bee = Habit(InsectActivity.Diurnal); + IslandInsectMood.Mood day = IslandInsectMood.For(bee, State(DayPhase.Day, WeatherType.Clear)); + IslandInsectMood.Mood morning = IslandInsectMood.For(bee, State(DayPhase.Morning, WeatherType.Clear)); + IslandInsectMood.Mood night = IslandInsectMood.For(bee, State(DayPhase.Night, WeatherType.Clear)); + + Assert.Greater(day.Activity, night.Activity); + Assert.AreEqual(day.Activity, morning.Activity, 1e-6f, "주행성은 아침에도 제 시간이다"); + Assert.Greater(night.RestMin, day.RestMax); + Assert.Less(night.SpeedMultiplier, day.SpeedMultiplier); + } + + [Test] + public void Weather_LikedLivensUp_DislikedSlowsDown() + { + InsectHabit h = Habit(InsectActivity.Any, WeatherSet.Fog, WeatherSet.Rain); + float liked = IslandInsectMood.Activity(h, State(DayPhase.Day, WeatherType.Fog)); + float plain = IslandInsectMood.Activity(h, State(DayPhase.Day, WeatherType.Clear)); + float disliked = IslandInsectMood.Activity(h, State(DayPhase.Day, WeatherType.Rain)); + Assert.Greater(liked, plain); + Assert.Less(disliked, plain); + Assert.Greater(plain - disliked, liked - plain, "싫은 날씨가 좋은 날씨보다 눈에 띄게 바꾼다"); + + IslandInsectMood.Mood rain = IslandInsectMood.For(h, State(DayPhase.Day, WeatherType.Rain)); + IslandInsectMood.Mood fog = IslandInsectMood.For(h, State(DayPhase.Day, WeatherType.Fog)); + Assert.Greater(rain.RestMin, fog.RestMin, "싫은 날씨에 덜 움직인다"); + Assert.Less(rain.SpeedMultiplier, fog.SpeedMultiplier); + } + + [Test] + public void TimelessSpecies_WanderExactlyAsBefore() + { + // 성향을 넣기 전의 배회 — 쉬기 1.2~4.5초 · 속도 ×1 · 나는 높이 ×1. 시간·날씨를 안 타는 종은 그대로여야 한다. + foreach (DayPhase p in Phases) + foreach (WeatherType w in Weathers) + { + IslandInsectMood.Mood m = IslandInsectMood.For(InsectHabit.Neutral, State(p, w)); + Assert.AreEqual(1.2f, m.RestMin, 1e-5f, $"{p}/{w}"); + Assert.AreEqual(4.5f, m.RestMax, 1e-5f, $"{p}/{w}"); + Assert.AreEqual(1f, m.SpeedMultiplier, 1e-5f, $"{p}/{w}"); + Assert.AreEqual(1f, m.HoverMultiplier, 1e-5f, $"{p}/{w}"); + } + IslandInsectMood.Mood neutral = IslandInsectMood.Neutral; + Assert.AreEqual(1.2f, neutral.RestMin, 1e-5f); + Assert.AreEqual(4.5f, neutral.RestMax, 1e-5f); + } + + [Test] + public void Mood_IsMonotonic_AndClamped() + { + IslandInsectMood.Mood prev = IslandInsectMood.FromActivity(0f); + for (int i = 1; i <= 20; i++) + { + IslandInsectMood.Mood m = IslandInsectMood.FromActivity(i / 20f); + Assert.LessOrEqual(m.RestMin, prev.RestMin + 1e-5f, "더 활발한데 더 오래 쉰다"); + Assert.LessOrEqual(m.RestMax, prev.RestMax + 1e-5f); + Assert.GreaterOrEqual(m.SpeedMultiplier, prev.SpeedMultiplier - 1e-5f); + Assert.GreaterOrEqual(m.HoverMultiplier, prev.HoverMultiplier - 1e-5f); + Assert.LessOrEqual(m.RestMin, m.RestMax); + Assert.Greater(m.SpeedMultiplier, 0f, "멈춰 버리면 배회가 아니다"); + prev = m; + } + Assert.AreEqual(1f, IslandInsectMood.FromActivity(3f).Activity, 1e-6f); + Assert.AreEqual(0f, IslandInsectMood.FromActivity(-1f).Activity, 1e-6f); + + // 가장 졸린 경우(잠들 시간 + 싫은 날씨)도 0~1 안이다. + InsectHabit h = Habit(InsectActivity.Nocturnal, WeatherSet.None, WeatherSet.Clear); + float a = IslandInsectMood.Activity(h, State(DayPhase.Day, WeatherType.Clear)); + Assert.That(a, Is.InRange(0f, 1f)); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/IslandInsectMoodTests.cs.meta b/Assets/Tests/EditMode/IslandInsectMoodTests.cs.meta new file mode 100644 index 0000000..38764a1 --- /dev/null +++ b/Assets/Tests/EditMode/IslandInsectMoodTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 468f65f4d197b3846bdef62605320a9b \ No newline at end of file diff --git a/Assets/Tests/EditMode/IslandIntegrationTests.cs b/Assets/Tests/EditMode/IslandIntegrationTests.cs new file mode 100644 index 0000000..0b614db --- /dev/null +++ b/Assets/Tests/EditMode/IslandIntegrationTests.cs @@ -0,0 +1,186 @@ +#if UNITY_EDITOR +using System.Reflection; +using System.Threading.Tasks; +using InsectGame.Core; +using InsectGame.Spawning; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace InsectGame.Tests +{ + /// + /// 실제 PlayScene에서 섬을 드나든다 — 진짜 RegionManager·SubAreaWorldBuilder·InsectSpawner· + /// PlayerMovement·CameraFollower가 붙은 상태다. + /// + /// 섬은 서브에리어 상태를 빌려 타므로(rules/island.md) 결함이 이음매에서 조용히 난다: + /// 서브에리어 빌더가 빈 방을 짓거나, 25m 자동 이탈이 돌아 들어가자마자 쫓겨나거나, 끼임 복구가 + /// (2000,·,2000) 허공으로 보내거나, 나온 뒤 리전 판정이 얼어 있거나. 검수 화면(island-ui)은 + /// 가짜 무대라 이걸 못 본다. + /// + /// 디스크에 쓰지 않는다 — 테스트는 개발자 PC의 실제 저장 폴더에서 돈다. 섬 매니저의 저장을 끄고 + /// 재화를 건드리는 동작(구매·수확)은 부르지 않는다. + /// + [TestFixture] + public class IslandIntegrationTests + { + private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + + [Test] + [Timeout(180000)] + public async Task PlayScene_EnterWalkAndLeaveIsland_KeepsWorldStateConsistent() + { + int previousSceneHandle = SceneManager.GetActiveScene().handle; + SceneManager.LoadScene(GameConstants.Scenes.Play, LoadSceneMode.Single); + await WaitForBootAsync(previousSceneHandle); + + IslandManager island = Object.FindFirstObjectByType(); + IslandWorldBuilder world = Object.FindFirstObjectByType(); + RegionManager regions = Object.FindFirstObjectByType(); + SubAreaWorldBuilder subAreaWorld = Object.FindFirstObjectByType(); + PlayerMovement player = Object.FindFirstObjectByType(); + InsectSpawner spawner = Object.FindFirstObjectByType(); + Assert.IsNotNull(island, "IslandManager가 Bootstrap에 등록되지 않았다"); + Assert.IsNotNull(world, "IslandWorldBuilder가 Bootstrap에 등록되지 않았다"); + Assert.IsNotNull(regions); + Assert.IsNotNull(subAreaWorld); + Assert.IsNotNull(player); + + // 저장을 끄고 메모리 섬을 넣는다 — 물건 둘(하나는 통행 차단), 방목 없음. + island.PersistenceEnabled = false; + var save = new IslandSave { starterGranted = true, guideDone = true }; + save.owned.Add(new IslandOwnedRecord { id = "t_rock", count = 2 }); + save.owned.Add(new IslandOwnedRecord { id = "f_bench", count = 1 }); + save.placed.Add(new IslandPlacedRecord { id = "t_rock", x = 2, z = 0 }); + island.LoadForCapture(save); + + // 본 마을 나루터가 섰는가 — 탐험 메뉴 말고 두 번째 입구. + Assert.IsNotNull(GameObject.Find("IslandDock"), "본 마을 나루터가 지어지지 않았다"); + + Invoke(regions, "Update"); + Assert.IsNotNull(regions.CurrentRegion, "시작 리전이 잡히지 않았다"); + string regionBefore = regions.CurrentRegion.regionId; + Vector3 fieldPos = player.transform.position; + + // ── 진입 ── + Assert.IsTrue(world.CanTravel(out string reason), reason); + Assert.IsTrue(world.EnterOwnIsland()); + Assert.AreEqual(IslandMode.Own, world.Mode); + Assert.IsNotNull(regions.CurrentSubArea); + Assert.IsTrue(regions.CurrentSubArea.detached); + Assert.AreEqual(GameConstants.Island.SubAreaId, regions.CurrentSubArea.subAreaId); + Assert.IsTrue(regions.SubAreaSticky, "sticky가 꺼져 있으면 섬 좌표에서 리전이 null로 바뀌며 RegionChanged가 울린다"); + Assert.IsFalse(subAreaWorld.IsInSubArea, "서브에리어 빌더가 섬을 자기 방으로 취급했다(빈 방·25m 자동 이탈)"); + Assert.IsNull(GameObject.Find("SubArea_" + GameConstants.Island.SubAreaId), "서브에리어 빌더가 섬 id로 방을 지었다"); + Assert.IsNotNull(GameObject.Find("PlayerIsland")); + Assert.IsNotNull(GameObject.Find("IslandTerrain")); + Assert.IsNotNull(GameObject.Find("Placed_t_rock"), "놓인 물건이 세워지지 않았다"); + + float half = IslandGrid.HalfExtent(island.SizeLevel); + AssertOnIsland(player.transform.position, half, "진입 직후"); + + // 리전은 떠나온 곳에 머문다(얼어 있다) — 그래야 나올 때 방문 퀘스트·BGM이 다시 울리지 않는다. + Invoke(regions, "Update"); + Assert.AreEqual(regionBefore, regions.CurrentRegion.regionId); + + // 몇 프레임 굴린다 — 접지(섬 바닥 콜라이더), 서브에리어 빌더의 Update(자동 이탈이 돌면 안 된다), 스포너. + for (int i = 0; i < 30; i++) await Task.Yield(); + Assert.AreEqual(IslandMode.Own, world.Mode, "몇 프레임 뒤 섬에서 쫓겨났다"); + Assert.IsNotNull(regions.CurrentSubArea); + AssertOnIsland(player.transform.position, half, "30프레임 뒤"); + Assert.Greater(player.transform.position.y, IslandWorldBuilder.Origin.y - 0.5f, "섬 바닥을 뚫고 떨어지고 있다"); + Assert.Less(player.transform.position.y, IslandWorldBuilder.Origin.y + 1.0f, "섬 위에 떠 있다"); + if (spawner != null) + Assert.AreEqual(0, spawner.ActiveInsects.Count, "섬에 있는 동안 야생 곤충의 몸이 남아 있다"); + + // 끼임 복구가 섬 도착 칸으로 보내는가((2000,·,2000)이 아니라). + player.transform.position = IslandWorldBuilder.Origin + new Vector3(3f, 0.5f, 0f); + player.RecoverToSafePosition(); + AssertOnIsland(player.transform.position, half, "끼임 복구 뒤"); + + // 배치가 바뀌면 월드가 따라오는가(보관함의 벤치를 놓는다 — 재화를 건드리지 않는다). + Assert.AreEqual(IslandPlaceResult.Ok, island.TryPlace("f_bench", -3, 0, 0)); + Assert.IsNotNull(GameObject.Find("Placed_f_bench"), "놓은 물건이 월드에 생기지 않았다"); + Assert.AreEqual(IslandPlaceResult.Overlap, island.TryPlace("t_rock", -3, 0, 0)); + + // 꾸미기 카메라 — 대상이 초점으로 갔다가 플레이어로 돌아오는가. + Camera mainCamera = Camera.main; + Assert.IsNotNull(mainCamera); + CameraFollower follower = mainCamera.GetComponent(); + Assert.IsNotNull(follower); + FieldInfo targetField = typeof(CameraFollower).GetField("target", Private); + world.BeginEditCamera(); + Assert.AreNotSame(player.transform, targetField.GetValue(follower)); + world.EndEditCamera(); + Assert.AreSame(player.transform, targetField.GetValue(follower), "꾸미기를 닫았는데 카메라가 플레이어로 돌아오지 않았다"); + + // ── 남의 섬 구경 → 내 섬으로 ── + var snapshot = new IslandSnapshot { ownerName = "이웃", sizeLevel = 1 }; + snapshot.placed.Add(new IslandPlacedRecord { id = "f_fountain", x = 0, z = 0 }); + IslandSaveRules.SanitizeSnapshot(snapshot); + Assert.IsTrue(world.EnterVisit(snapshot)); + Assert.AreEqual(IslandMode.Visit, world.Mode); + Assert.AreEqual(1, world.CurrentSizeLevel, "구경 중에는 주인의 섬 크기를 써야 한다"); + Assert.IsNotNull(regions.CurrentSubArea, "구경으로 바꾸다가 서브에리어 상태를 잃었다"); + await Task.Yield(); + Assert.IsNotNull(GameObject.Find("Placed_f_fountain")); + Assert.IsTrue(world.ReturnToOwnIsland()); + Assert.AreEqual(IslandMode.Own, world.Mode); + Assert.AreEqual(0, world.CurrentSizeLevel); + + // ── 퇴장 ── + world.ExitIsland(); + Assert.AreEqual(IslandMode.None, world.Mode); + Assert.IsNull(regions.CurrentSubArea); + Assert.IsFalse(regions.SubAreaSticky, "나왔는데 리전 판정이 얼어 있다 — 다음 리전 이동이 영영 성립하지 않는다"); + Vector3 back = player.transform.position; + Assert.LessOrEqual(Vector2.Distance(new Vector2(back.x, back.z), new Vector2(fieldPos.x, fieldPos.z)), 0.5f, + "들어오기 전 자리로 돌아오지 않았다"); + Assert.AreSame(player.transform, targetField.GetValue(follower)); + + await Task.Yield(); + await Task.Yield(); + Assert.IsNull(GameObject.Find("PlayerIsland"), "섬 월드가 파기되지 않았다"); + Invoke(regions, "Update"); + Assert.IsNotNull(regions.CurrentRegion); + Assert.AreEqual(regionBefore, regions.CurrentRegion.regionId); + + // 다시 들어갈 수 있는가(한 번 쓰고 망가지는 상태가 없는지). + Assert.IsTrue(world.EnterOwnIsland()); + Assert.AreEqual(IslandMode.Own, world.Mode); + world.ExitIsland(); + Assert.AreEqual(IslandMode.None, world.Mode); + } + + private static void AssertOnIsland(Vector3 position, float half, string when) + { + Vector3 local = position - IslandWorldBuilder.Origin; + Assert.LessOrEqual(Mathf.Abs(local.x), half + 0.01f, $"{when}: 섬 밖(x)"); + Assert.LessOrEqual(Mathf.Abs(local.z), half + 0.01f, $"{when}: 섬 밖(z)"); + } + + private static void Invoke(object target, string method) + { + MethodInfo m = target.GetType().GetMethod(method, Private); + Assert.IsNotNull(m, method); + m.Invoke(target, null); + } + + private static async Task WaitForBootAsync(int previousSceneHandle) + { + const int maxFrames = 600; + for (int frame = 0; frame < maxFrames; frame++) + { + Scene scene = SceneManager.GetSceneByName(GameConstants.Scenes.Play); + PlaySceneBootstrap bootstrap = Object.FindFirstObjectByType(); + if (scene.IsValid() && scene.isLoaded && scene.handle != previousSceneHandle + && bootstrap != null && bootstrap.gameObject.scene == scene + && Object.FindFirstObjectByType() != null) + return; + await Task.Yield(); + } + Assert.Fail("PlayScene Bootstrap 초기화가 제한 시간 안에 끝나지 않았습니다."); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/IslandIntegrationTests.cs.meta b/Assets/Tests/EditMode/IslandIntegrationTests.cs.meta new file mode 100644 index 0000000..d6f13d6 --- /dev/null +++ b/Assets/Tests/EditMode/IslandIntegrationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dbe9cb7806c6b424d9aedd1fbae5591f \ No newline at end of file diff --git a/Assets/Tests/EditMode/IslandManagerTests.cs b/Assets/Tests/EditMode/IslandManagerTests.cs new file mode 100644 index 0000000..b27f51d --- /dev/null +++ b/Assets/Tests/EditMode/IslandManagerTests.cs @@ -0,0 +1,329 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using InsectGame.Core; +using InsectGame.Data; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// — 정산·수확·방목·안내 진행이 맞물리는 순서. + /// 순수 공식은 가 보고, 여기는 "변경 전에 정산했는가", "수확물의 소수점이 남는가", + /// "안내의 첫 선물이 한 번만 들어가는가"처럼 상태가 얽힌 부분을 본다. + /// + /// 매니저는 비활성 오브젝트에 붙여 Awake(디스크 로드)를 막고, 저장을 끄고, 시계를 주입한다. + /// 지갑·캔디는 붙이지 않는다 — 그 둘은 지급 즉시 파일에 쓰므로 개발자 PC의 진짜 세이브를 건드린다. + /// + [TestFixture] + public class IslandManagerTests + { + private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + private const long T0 = 1_760_000_000L; + private const long Hour = 3600L; + + private GameObject holder; + private InsectDatabase database; + private IslandManager island; + private long now; + + [SetUp] + public void SetUp() + { + holder = new GameObject("IslandManagerTests"); + holder.SetActive(false); + + database = ScriptableObject.CreateInstance(); + var owned = new List(); + for (int i = 0; i < 5; i++) + { + var species = ScriptableObject.CreateInstance(); + species.insectId = "common_" + i; + species.displayName = "일반 " + i; + species.rarity = InsectRarity.Common; + database.insects.Add(species); + owned.Add(new PlayerInsectData { instanceId = "inst_" + i, insectId = species.insectId, level = 5 }); + } + + var collection = holder.AddComponent(); + Set(collection, "database", database); + Set(collection, "saveData", new PlayerInsectCollectionSave { insects = owned }); + var lookup = (Dictionary)Get(collection, "lookup"); + foreach (PlayerInsectData p in owned) lookup.Add(p.instanceId, p); + + island = holder.AddComponent(); + island.PersistenceEnabled = false; + now = T0; + island.Clock = () => now; + island.AutoWire(collection, null, null, database); + island.LoadForCapture(new IslandSave()); + } + + [TearDown] + public void TearDown() + { + foreach (InsectData species in database.insects) Object.DestroyImmediate(species); + Object.DestroyImmediate(database); + Object.DestroyImmediate(holder); + } + + private void ReleaseThree() + { + for (int i = 0; i < 3; i++) + Assert.AreEqual(IslandReleaseResult.Ok, island.TryRelease("inst_" + i)); + } + + // 안내의 첫 선물이 수확량 계산에 섞이지 않게, 안내가 끝난 섬으로 시작한다. + private void StartWithGuideDone() + { + island.LoadForCapture(new IslandSave { guideDone = true, starterGranted = true }); + island.NotifyEnteredOwnIsland(); // 정산 기준 시각을 잡는다 + } + + [Test] + public void FirstEntry_GrantsStarterKitOnce() + { + island.NotifyEnteredOwnIsland(); + Assert.IsTrue(island.HasEnteredOnce); + Assert.AreEqual(1, island.GetStorageCount("f_bench")); + Assert.AreEqual(2, island.GetStorageCount("f_flowerpot")); + + island.NotifyEnteredOwnIsland(); + Assert.AreEqual(1, island.GetStorageCount("f_bench"), "들어올 때마다 스타터 키트를 다시 준다"); + } + + [Test] + public void Harvest_AfterFiveHours_GivesFlooredAmounts_AndKeepsTheFraction() + { + StartWithGuideDone(); + ReleaseThree(); + now += 5 * Hour; + + // 일반 3마리: 캔디 0.75/h → 3.75, 코인 0.45/h → 2.25 + Assert.IsTrue(island.Harvest(out int candy, out int coin)); + Assert.AreEqual(3, candy); + Assert.AreEqual(2, coin); + + island.Peek(out float pendingCandy, out float pendingCoin, out float accrued, out _); + Assert.AreEqual(0.75f, pendingCandy, 0.01f, "소수점 아래를 버리면 자주 수확할수록 손해를 본다"); + Assert.AreEqual(0.25f, pendingCoin, 0.01f); + Assert.AreEqual(0f, accrued, 0.001f, "수확하면 쌓인 시간이 0으로 돌아가야 한다"); + } + + [Test] + public void Harvest_NothingAccrued_ReturnsFalse() + { + StartWithGuideDone(); + ReleaseThree(); + now += 30 * 60; // 30분 — 캔디 0.375, 코인 0.225 + Assert.IsFalse(island.Harvest(out int candy, out int coin)); + Assert.AreEqual(0, candy); + Assert.AreEqual(0, coin); + Assert.AreEqual(0, island.HarvestCount); + } + + [Test] + public void Harvest_LeftForDays_IsCappedAtCapHours() + { + StartWithGuideDone(); + ReleaseThree(); + now += 100 * Hour; + + Assert.IsTrue(island.IsHarvestFull); + Assert.IsTrue(island.Harvest(out int candy, out _)); + // 상한 8시간 × 0.75/h = 6 + Assert.AreEqual(6, candy); + } + + [Test] + public void PlacingAYieldObject_DoesNotApplyToHoursAlreadyPassed() + { + StartWithGuideDone(); + ReleaseThree(); + island.GrantObject("o_feeder", 1); + + now += 4 * Hour; + island.GetRates(out float before, out _); + Assert.AreEqual(IslandPlaceResult.Ok, island.TryPlace("o_feeder", 2, 2, 0)); + island.GetRates(out float after, out _); + Assert.Greater(after, before, "먹이통을 놓았는데 생산량이 그대로다(캐시가 안 풀렸다)"); + + now += 2 * Hour; + island.Peek(out float pending, out _, out _, out _); + // 앞 4시간은 놓기 전 속도, 뒤 2시간만 놓은 뒤 속도. + Assert.AreEqual(before * 4f + after * 2f, pending, 0.01f, + "변경 전에 정산하지 않으면 방금 놓은 설비가 지난 시간에도 있었던 것처럼 계산된다"); + } + + [Test] + public void Release_BeyondSlots_IsRejected_AndUnreleaseKeepsBond() + { + StartWithGuideDone(); + ReleaseThree(); + Assert.AreEqual(IslandReleaseResult.NoFreeSlot, island.TryRelease("inst_3")); + Assert.AreEqual(IslandReleaseResult.AlreadyReleased, island.TryRelease("inst_0")); + Assert.AreEqual(IslandReleaseResult.NoSuchInsect, island.TryRelease("nope")); + + now += 7 * Hour; + island.Settle(); + Assert.AreEqual(1, island.GetBondLevel("inst_0"), "7시간이면 하트 하나(6시간)"); + + Assert.IsTrue(island.Unrelease("inst_0")); + Assert.IsFalse(island.IsReleased("inst_0")); + Assert.AreEqual(1, island.GetBondLevel("inst_0"), "거두어도 친밀도는 남아야 한다"); + Assert.AreEqual(IslandReleaseResult.Ok, island.TryRelease("inst_3")); + } + + [Test] + public void Release_GhostInstanceIds_AreClearedWhenMakingRoom() + { + // 보유 목록에서 사라진 id가 슬롯을 차지한 채 남아 있으면 새 곤충을 풀 수 없다. + var save = new IslandSave { guideDone = true, starterGranted = true }; + save.released.AddRange(new[] { "gone_a", "gone_b", "inst_0" }); + island.LoadForCapture(save); + Assert.AreEqual(1, island.ReleasedInsects.Count, "사라진 곤충은 방목 목록에서 걸러져야 한다"); + Assert.AreEqual(IslandReleaseResult.Ok, island.TryRelease("inst_1")); + Assert.AreEqual(2, island.ReleasedInsects.Count); + } + + [Test] + public void MoveAndStore_KeepInventoryConsistent() + { + StartWithGuideDone(); + island.GrantObject("f_bench", 2); + Assert.AreEqual(IslandPlaceResult.Ok, island.TryPlace("f_bench", 0, 0, 0)); + Assert.AreEqual(1, island.GetStorageCount("f_bench")); + Assert.AreEqual(IslandPlaceResult.Ok, island.TryPlace("f_bench", 0, 2, 0)); + Assert.AreEqual(IslandPlaceResult.NotOwned, island.TryPlace("f_bench", 0, 3, 0)); + + Assert.AreEqual(IslandPlaceResult.Overlap, island.TryMove(1, 1, 0, 0)); + Assert.AreEqual(IslandPlaceResult.Ok, island.TryMove(1, 2, 2, 1)); + Assert.AreEqual(1, island.Placed[1].rot); + + Assert.IsTrue(island.Store(0)); + Assert.AreEqual(1, island.Placed.Count); + Assert.AreEqual(1, island.GetStorageCount("f_bench"), "넣은 물건은 보관함으로 돌아와야 한다"); + Assert.AreEqual(2, island.GetOwnedCount("f_bench")); + } + + [Test] + public void Buy_WithoutWallet_IsRefused_AndGrantsNothing() + { + StartWithGuideDone(); + Assert.AreEqual(IslandBuyResult.NotEnoughCoins, island.TryBuy("f_bench")); + Assert.AreEqual(IslandBuyResult.NotEnoughGems, island.TryBuy("b_greenhouse")); + Assert.AreEqual(IslandBuyResult.Locked, island.TryBuy("b_lighthouse")); + Assert.AreEqual(IslandBuyResult.Unknown, island.TryBuy("x_typo")); + Assert.AreEqual(0, island.GetOwnedCount("f_bench")); + Assert.AreEqual(IslandBuyResult.NotEnoughCoins, island.TryExpandSize(false)); + Assert.AreEqual(0, island.SizeLevel); + } + + [Test] + public void Guide_WalksThroughByActions_AndGivesTheFirstHarvestGiftOnce() + { + island.NotifyEnteredOwnIsland(); + Assert.IsTrue(island.GuideActive); + Assert.AreEqual(IslandGuideStep.OpenEdit, island.GuideStep); + + island.ReportEditOpened(); + Assert.AreEqual(IslandGuideStep.PlaceFirst, island.GuideStep); + + Assert.AreEqual(IslandPlaceResult.Ok, island.TryPlace("f_bench", 0, 0, 0)); + Assert.AreEqual(IslandGuideStep.ReleaseInsect, island.GuideStep); + + Assert.AreEqual(IslandReleaseResult.Ok, island.TryRelease("inst_0")); + Assert.AreEqual(IslandGuideStep.Harvest, island.GuideStep); + + // 기다리지 않고 바로 수확할 수 있어야 한다 — 그게 선물의 이유다. + Assert.IsTrue(island.Harvest(out int candy, out int coin)); + Assert.AreEqual((int)IslandManager.GuideGiftCandy, candy); + Assert.AreEqual((int)IslandManager.GuideGiftCoin, coin); + Assert.AreEqual(IslandGuideStep.OpenShop, island.GuideStep); + + island.ReportShopOpened(); + Assert.AreEqual(IslandGuideStep.Finish, island.GuideStep); + island.AcknowledgeGuideFinish(); + Assert.IsFalse(island.GuideActive); + + // 다시 보기는 선물을 다시 주지 않고, 이미 한 행동으로 건너뛰지도 않는다. + island.RestartGuide(); + Assert.AreEqual(IslandGuideStep.OpenEdit, island.GuideStep); + island.ReportEditOpened(); + Assert.AreEqual(IslandGuideStep.OpenEdit, island.GuideStep, "다시 보기는 [다음]으로만 넘어간다"); + for (int i = 0; i < (int)IslandGuideStep.Done; i++) island.AdvanceGuideReplay(); + Assert.IsFalse(island.GuideActive); + Assert.IsFalse(island.Harvest(out _, out _), "다시 보기가 수확 선물을 또 넣었다"); + } + + [Test] + public void Guide_EndReplay_ClosesOnlyTheReplay_NeverTheFirstRun() + { + // 첫 안내에서 배너를 닫는 건 화면에서 치우는 것뿐이다 — 단계가 넘어가면 안 된다. + island.NotifyEnteredOwnIsland(); + island.ReportEditOpened(); + Assert.AreEqual(IslandGuideStep.PlaceFirst, island.GuideStep); + island.EndGuideReplay(); + Assert.IsTrue(island.GuideActive, "첫 안내가 닫기로 끝나 버렸다"); + Assert.AreEqual(IslandGuideStep.PlaceFirst, island.GuideStep); + + // 다시 보기는 중간에 그만둘 수 있고, 수확 선물을 다시 넣지 않는다. + StartWithGuideDone(); + island.RestartGuide(); + island.AdvanceGuideReplay(); + Assert.IsTrue(island.GuideIsReplay); + island.EndGuideReplay(); + Assert.IsFalse(island.GuideActive); + Assert.IsFalse(island.GuideIsReplay); + Assert.IsFalse(island.Harvest(out _, out _), "다시 보기를 닫았더니 수확 선물이 들어갔다"); + } + + [Test] + public void GuideBanner_HidesItselfAfterItsTime_ButNotDuringReplay() + { + Assert.Greater(InsectGame.UI.IslandGuideUI.CoachRemaining(0f, false), 0f); + Assert.LessOrEqual(InsectGame.UI.IslandGuideUI.CoachRemaining(InsectGame.UI.IslandGuideUI.CoachSeconds, false), 0f); + // 다시 보기는 [다음]으로 직접 넘긴다 — 읽는 도중에 사라지면 안 된다. + Assert.Greater(InsectGame.UI.IslandGuideUI.CoachRemaining(999f, true), 0f); + + // 마지막 1초 동안만 옅어진다. + Assert.AreEqual(1f, InsectGame.UI.IslandGuideUI.CoachAlpha(InsectGame.UI.IslandGuideUI.CoachSeconds), 0.0001f); + Assert.AreEqual(1f, InsectGame.UI.IslandGuideUI.CoachAlpha(InsectGame.UI.IslandGuideUI.CoachFadeSeconds), 0.0001f); + Assert.AreEqual(0.5f, InsectGame.UI.IslandGuideUI.CoachAlpha(InsectGame.UI.IslandGuideUI.CoachFadeSeconds * 0.5f), 0.0001f); + Assert.AreEqual(0f, InsectGame.UI.IslandGuideUI.CoachAlpha(0f), 0.0001f); + } + + [Test] + public void Snapshot_CarriesLayoutAndSpecies_ButNoInstanceIds() + { + StartWithGuideDone(); + island.GrantObject("f_bench", 1); + island.TryPlace("f_bench", -2, 1, 3); + island.TryRelease("inst_2"); + + IslandSnapshot snapshot = island.BuildSnapshot("탐험가"); + Assert.AreEqual("탐험가", snapshot.ownerName); + Assert.AreEqual(1, snapshot.placed.Count); + Assert.AreEqual(3, snapshot.placed[0].rot); + Assert.AreEqual(1, snapshot.insects.Count); + Assert.AreEqual("common_2", snapshot.insects[0].insectId); + StringAssert.DoesNotContain("inst_2", JsonUtility.ToJson(snapshot), "공개 스냅샷에 instanceId가 실렸다"); + } + + private static object Get(object target, string name) + { + FieldInfo f = target.GetType().GetField(name, Private); + Assert.IsNotNull(f, name); + return f.GetValue(target); + } + + private static void Set(object target, string name, object value) + { + FieldInfo f = target.GetType().GetField(name, Private); + Assert.IsNotNull(f, name); + f.SetValue(target, value); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/IslandManagerTests.cs.meta b/Assets/Tests/EditMode/IslandManagerTests.cs.meta new file mode 100644 index 0000000..5b3d4ad --- /dev/null +++ b/Assets/Tests/EditMode/IslandManagerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9af24c7457c0dd440b5bcc8f8943119a \ No newline at end of file diff --git a/Assets/Tests/EditMode/IslandSaveRulesTests.cs b/Assets/Tests/EditMode/IslandSaveRulesTests.cs new file mode 100644 index 0000000..0bc5ac5 --- /dev/null +++ b/Assets/Tests/EditMode/IslandSaveRulesTests.cs @@ -0,0 +1,247 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Core; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 와 — 섬 세이브의 자가 복구, 방문 스냅샷 정리, 안내 단계. + /// + [TestFixture] + public class IslandSaveRulesTests + { + // ── 세이브 호환 ── + + [Test] + public void EmptyJson_BecomesDefaultIsland() + { + // 구세이브(island.json 없음)·빈 블롭 — 필드 기본값만으로 성립해야 한다. + IslandSave save = JsonUtility.FromJson("{}"); + Assert.IsFalse(IslandSaveRules.Sanitize(save)); + Assert.AreEqual(0, save.sizeLevel); + Assert.AreEqual(0, save.extraSlots); + Assert.AreEqual(0, save.owned.Count); + Assert.AreEqual(0, save.placed.Count); + Assert.AreEqual(0, save.released.Count); + Assert.AreEqual(0L, save.lastSettleUnix); + Assert.IsFalse(save.guideDone); + Assert.IsFalse(save.starterGranted); + Assert.IsTrue(save.isPublic, "공개가 기본이어야 한다 — 필드가 없는 세이브가 비공개로 굳으면 안 된다"); + } + + [Test] + public void RoundTrip_PreservesState() + { + var save = new IslandSave { sizeLevel = 2, extraSlots = 3, lastSettleUnix = 1_760_000_000L, accruedHours = 3.5f }; + save.owned.Add(new IslandOwnedRecord { id = "f_bench", count = 2 }); + save.placed.Add(new IslandPlacedRecord { id = "f_bench", x = -3, z = 4, rot = 1 }); + save.released.Add("abc"); + save.bonds.Add(new IslandBondRecord { instanceId = "abc", hours = 12.5f }); + + IslandSave loaded = JsonUtility.FromJson(JsonUtility.ToJson(save, false)); + Assert.IsFalse(IslandSaveRules.Sanitize(loaded)); + Assert.AreEqual(2, loaded.sizeLevel); + Assert.AreEqual(3, loaded.extraSlots); + Assert.AreEqual(-3, loaded.placed[0].x); + Assert.AreEqual(1, loaded.placed[0].rot); + Assert.AreEqual("abc", loaded.released[0]); + Assert.AreEqual(12.5f, loaded.bonds[0].hours, 1e-4f); + Assert.AreEqual(1_760_000_000L, loaded.lastSettleUnix); + } + + [Test] + public void Sanitize_NullLists_AreRecreated() + { + var save = new IslandSave { owned = null, placed = null, released = null, bonds = null }; + Assert.IsTrue(IslandSaveRules.Sanitize(save)); + Assert.IsNotNull(save.owned); + Assert.IsNotNull(save.placed); + Assert.IsNotNull(save.released); + Assert.IsNotNull(save.bonds); + } + + [Test] + public void Sanitize_ClampsLevelsAndNumbers() + { + var save = new IslandSave + { + sizeLevel = 99, extraSlots = -4, pendingCandy = -5f, pendingCoin = float.NaN, + accruedHours = 500f, harvestCount = -1, guideStep = 77, lastSettleUnix = -10, + }; + Assert.IsTrue(IslandSaveRules.Sanitize(save)); + Assert.AreEqual(GameConstants.Island.MaxSizeLevel, save.sizeLevel); + Assert.AreEqual(0, save.extraSlots); + Assert.AreEqual(0f, save.pendingCandy); + Assert.AreEqual(0f, save.pendingCoin); + Assert.AreEqual(GameConstants.Island.MaxCapHours, save.accruedHours); + Assert.AreEqual(0, save.harvestCount); + Assert.AreEqual((int)IslandGuideStep.Done, save.guideStep); + Assert.AreEqual(0L, save.lastSettleUnix); + } + + [Test] + public void Sanitize_MergesDuplicateOwned_AndDropsEmpty() + { + var save = new IslandSave(); + save.owned.Add(new IslandOwnedRecord { id = "f_bench", count = 1 }); + save.owned.Add(null); + save.owned.Add(new IslandOwnedRecord { id = "", count = 5 }); + save.owned.Add(new IslandOwnedRecord { id = "f_bench", count = 2 }); + Assert.IsTrue(IslandSaveRules.Sanitize(save)); + Assert.AreEqual(1, save.owned.Count); + Assert.AreEqual(3, save.owned[0].count); + } + + [Test] + public void Sanitize_PlacedMoreThanOwned_RaisesOwnedInsteadOfDeleting() + { + // 놓인 물건을 지우는 쪽이 아니라 보유 수를 올린다 — 섬 모양이 유지된다. + var save = new IslandSave(); + save.owned.Add(new IslandOwnedRecord { id = "f_bench", count = 1 }); + save.placed.Add(new IslandPlacedRecord { id = "f_bench", x = 0, z = 0 }); + save.placed.Add(new IslandPlacedRecord { id = "f_bench", x = 3, z = 0 }); + save.placed.Add(new IslandPlacedRecord { id = "t_rock", x = 0, z = 3 }); + Assert.IsTrue(IslandSaveRules.Sanitize(save)); + Assert.AreEqual(3, save.placed.Count); + Assert.AreEqual(2, IslandSaveRules.FindOwned(save, "f_bench").count); + Assert.AreEqual(1, IslandSaveRules.FindOwned(save, "t_rock").count); + } + + [Test] + public void Sanitize_UnknownObjectIds_AreKept() + { + // 더 새 버전에서 산 물건을 구버전이 열었다고 날리면 안 된다. + var save = new IslandSave(); + save.owned.Add(new IslandOwnedRecord { id = "x_from_future", count = 1 }); + save.placed.Add(new IslandPlacedRecord { id = "x_from_future", x = 0, z = 0 }); + IslandSaveRules.Sanitize(save); + Assert.AreEqual(1, save.placed.Count); + Assert.IsNotNull(IslandSaveRules.FindOwned(save, "x_from_future")); + } + + [Test] + public void Sanitize_NegativeRotation_IsNormalized() + { + var save = new IslandSave(); + save.owned.Add(new IslandOwnedRecord { id = "f_bench", count = 1 }); + save.placed.Add(new IslandPlacedRecord { id = "f_bench", rot = -1 }); + Assert.IsTrue(IslandSaveRules.Sanitize(save)); + Assert.AreEqual(3, save.placed[0].rot); + } + + [Test] + public void Sanitize_Released_DropsDuplicatesAndOverflow() + { + var save = new IslandSave(); + save.released.AddRange(new[] { "a", "", "a", "b", "c", "d", "e" }); + Assert.IsTrue(IslandSaveRules.Sanitize(save)); + // 기본 3칸 — 앞에서부터 남긴다. + CollectionAssert.AreEqual(new[] { "a", "b", "c" }, save.released); + } + + // ── 방문 스냅샷(남이 만든 데이터) ── + + [Test] + public void SanitizeSnapshot_DropsUnknownOutOfBoundsAndOverlapping() + { + var snapshot = new IslandSnapshot { sizeLevel = 0, ownerName = null }; + snapshot.placed.Add(new IslandPlacedRecord { id = "b_cabin", x = 0, z = 0 }); + snapshot.placed.Add(new IslandPlacedRecord { id = "t_rock", x = 1, z = 1 }); // 오두막과 겹친다 + snapshot.placed.Add(new IslandPlacedRecord { id = "x_from_future", x = -4, z = 0 }); // 모르는 물건 + snapshot.placed.Add(new IslandPlacedRecord { id = "t_rock", x = 40, z = 0 }); // 섬 밖 + snapshot.placed.Add(new IslandPlacedRecord { id = "t_rock", x = 0, z = -5 }); // 도착 칸 + snapshot.placed.Add(new IslandPlacedRecord { id = "t_rock", x = -4, z = 2, rot = -3 }); + snapshot.placed.Add(null); + + IslandSaveRules.SanitizeSnapshot(snapshot); + Assert.AreEqual(2, snapshot.placed.Count); + Assert.AreEqual("b_cabin", snapshot.placed[0].id); + Assert.AreEqual(1, snapshot.placed[1].rot); + Assert.AreEqual("", snapshot.ownerName); + } + + [Test] + public void SanitizeSnapshot_ClampsInsectsAndSize() + { + var snapshot = new IslandSnapshot { sizeLevel = 50, ownerName = new string('가', 80) }; + for (int i = 0; i < 30; i++) + snapshot.insects.Add(new IslandSnapshotInsect { insectId = "bug" + i, level = 9999 }); + snapshot.insects.Add(new IslandSnapshotInsect { insectId = "" }); + + IslandSaveRules.SanitizeSnapshot(snapshot); + Assert.AreEqual(GameConstants.Island.MaxSizeLevel, snapshot.sizeLevel); + Assert.AreEqual(GameConstants.Island.MaxInsectSlots, snapshot.insects.Count); + Assert.AreEqual(GameConstants.Leveling.FallbackMaxLevel, snapshot.insects[0].level); + Assert.LessOrEqual(snapshot.ownerName.Length, 24); + } + + [Test] + public void Snapshot_JsonRoundTrip_KeepsPlacement() + { + var snapshot = new IslandSnapshot { sizeLevel = 1, comfort = 12, ownerName = "탐험가" }; + snapshot.placed.Add(new IslandPlacedRecord { id = "f_bench", x = -2, z = 3, rot = 2 }); + snapshot.insects.Add(new IslandSnapshotInsect { insectId = "stag", level = 7, shiny = true }); + IslandSnapshot loaded = JsonUtility.FromJson(JsonUtility.ToJson(snapshot)); + Assert.AreEqual(-2, loaded.placed[0].x); + Assert.AreEqual(2, loaded.placed[0].rot); + Assert.IsTrue(loaded.insects[0].shiny); + Assert.AreEqual(12, loaded.comfort); + } + + // ── 안내 단계 ── + + [Test] + public void Guide_FreshIsland_StaysAtFirstStep() + { + var facts = new IslandGuideFacts { ownedInsectCount = 3 }; + Assert.AreEqual(IslandGuideStep.OpenEdit, IslandGuideSteps.Advance(IslandGuideStep.OpenEdit, facts)); + } + + [Test] + public void Guide_AdvancesByAction_OneStepAtATime() + { + var facts = new IslandGuideFacts { ownedInsectCount = 3, editOpened = true }; + Assert.AreEqual(IslandGuideStep.PlaceFirst, IslandGuideSteps.Advance(IslandGuideStep.OpenEdit, facts)); + facts.placedCount = 1; + Assert.AreEqual(IslandGuideStep.ReleaseInsect, IslandGuideSteps.Advance(IslandGuideStep.PlaceFirst, facts)); + facts.releasedCount = 1; + Assert.AreEqual(IslandGuideStep.Harvest, IslandGuideSteps.Advance(IslandGuideStep.ReleaseInsect, facts)); + facts.harvestCount = 1; + Assert.AreEqual(IslandGuideStep.OpenShop, IslandGuideSteps.Advance(IslandGuideStep.Harvest, facts)); + facts.shopOpened = true; + Assert.AreEqual(IslandGuideStep.Finish, IslandGuideSteps.Advance(IslandGuideStep.OpenShop, facts)); + facts.finishAcknowledged = true; + Assert.AreEqual(IslandGuideStep.Done, IslandGuideSteps.Advance(IslandGuideStep.Finish, facts)); + } + + [Test] + public void Guide_AlreadyDecoratedSave_SkipsToTheFirstUndoneStep() + { + // 다른 기기에서 꾸민 섬을 받아 온 세이브가 "벤치를 놓아 보세요"에 멈추면 안 된다. + var facts = new IslandGuideFacts { ownedInsectCount = 3, placedCount = 5, releasedCount = 2, harvestCount = 4 }; + Assert.AreEqual(IslandGuideStep.OpenShop, IslandGuideSteps.Advance(IslandGuideStep.OpenEdit, facts)); + } + + [Test] + public void Guide_NoInsectsOwned_DoesNotBlockOnRelease() + { + var facts = new IslandGuideFacts { ownedInsectCount = 0, placedCount = 1 }; + Assert.AreEqual(IslandGuideStep.Harvest, IslandGuideSteps.Advance(IslandGuideStep.OpenEdit, facts)); + } + + [Test] + public void Guide_EveryVisibleStep_HasText() + { + for (int i = 0; i < (int)IslandGuideStep.Done; i++) + { + var step = (IslandGuideStep)i; + Assert.IsFalse(string.IsNullOrEmpty(IslandGuideSteps.Title(step)), step.ToString()); + Assert.IsFalse(string.IsNullOrEmpty(IslandGuideSteps.Body(step)), step.ToString()); + } + Assert.Greater(IslandGuideSteps.HelpTopics.Length, 0); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/IslandSaveRulesTests.cs.meta b/Assets/Tests/EditMode/IslandSaveRulesTests.cs.meta new file mode 100644 index 0000000..ddf38d6 --- /dev/null +++ b/Assets/Tests/EditMode/IslandSaveRulesTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6ae95a48bf9560b4e81383155e663913 \ No newline at end of file diff --git a/Assets/Tests/EditMode/IslandYieldTests.cs b/Assets/Tests/EditMode/IslandYieldTests.cs new file mode 100644 index 0000000..d8a3abd --- /dev/null +++ b/Assets/Tests/EditMode/IslandYieldTests.cs @@ -0,0 +1,198 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Core; +using InsectGame.Data; +using NUnit.Framework; + +namespace InsectGame.Tests +{ + /// + /// — 섬 생산·누적 상한·친밀도. + /// 섬은 곁들이는 수입이어야 한다: 대역 검사가 "섬만 돌려도 되는 게임"으로 가는 수치 변경을 잡는다. + /// + [TestFixture] + public class IslandYieldTests + { + /// economy_sim의 평소 활동 수입(포획 20 · 전투 10 · 레이드 주 3회) — 하루 약 156캔디. + private const float DailyActiveCandy = 156f; + + private static IslandPlacedRecord Placed(string id, int x = 0, int z = 0) + => new IslandPlacedRecord { id = id, x = x, z = z }; + + [Test] + public void RarityFactor_IsCompressed_ComparedToRewardMultiplier() + { + // 보상 배율(전설 2.8)을 그대로 쓰면 전설 10마리 섬이 활동 수입의 두 배가 된다. + Assert.AreEqual(1f, IslandYield.RarityFactor(InsectRarity.Common), 1e-4f); + Assert.Less(IslandYield.RarityFactor(InsectRarity.Legendary), + InsectRewardCalculator.GetRarityMultiplier(InsectRarity.Legendary)); + Assert.Greater(IslandYield.RarityFactor(InsectRarity.Legendary), IslandYield.RarityFactor(InsectRarity.Epic)); + Assert.Greater(IslandYield.RarityFactor(InsectRarity.Epic), IslandYield.RarityFactor(InsectRarity.Rare)); + Assert.Greater(IslandYield.RarityFactor(InsectRarity.Rare), IslandYield.RarityFactor(InsectRarity.Uncommon)); + Assert.Greater(IslandYield.RarityFactor(InsectRarity.Uncommon), IslandYield.RarityFactor(InsectRarity.Common)); + } + + [Test] + public void BasicIsland_DailyCandy_IsSmallShareOfActiveIncome() + { + // 기본 섬 = 일반 3마리, 물건 없음, 친밀도 0. + float daily = 3f * IslandYield.CandyPerHour(InsectRarity.Common, 0, new IslandEffects()) * 24f; + Assert.AreEqual(18f, daily, 0.01f); + Assert.Less(daily / DailyActiveCandy, 0.2f, "기본 섬이 활동 수입의 20%를 넘는다"); + } + + [Test] + public void FullyBuiltIsland_DailyCandy_DoesNotExceedActiveIncomeByMuch() + { + // 최대치: 전설 10마리 · 쾌적도 상한 · 생산 설비 전부 · 친밀도 5. + var placed = new List(); + foreach (IslandObjectDef def in IslandCatalog.All) + if (def.effect == IslandEffectKind.YieldBonus) placed.Add(Placed(def.id)); + IslandEffects effects = IslandYield.CollectEffects(placed); + effects.comfort = GameConstants.Island.ComfortForMaxBonus * 2; + + float daily = GameConstants.Island.MaxInsectSlots + * IslandYield.CandyPerHour(InsectRarity.Legendary, GameConstants.Island.MaxBondLevel, effects) + * 24f; + Assert.Less(daily, DailyActiveCandy * 1.25f, + $"풀 확장 섬이 하루 {daily:0}캔디 — 활동 수입({DailyActiveCandy})의 1.25배를 넘는다"); + Assert.Greater(daily, DailyActiveCandy * 0.6f, "풀 확장 섬이 너무 짜다 — 확장할 이유가 없다"); + } + + [Test] + public void CollectEffects_SameToolTwice_CountsOnce() + { + var one = IslandYield.CollectEffects(new List { Placed("o_feeder") }); + var two = IslandYield.CollectEffects(new List { Placed("o_feeder"), Placed("o_feeder", 2) }); + Assert.AreEqual(0.05f, one.yieldBonus, 1e-4f); + Assert.AreEqual(one.yieldBonus, two.yieldBonus, 1e-4f, "먹이통을 여러 개 놓으면 효과가 쌓인다"); + } + + [Test] + public void CollectEffects_DifferentTools_Stack() + { + var e = IslandYield.CollectEffects(new List { Placed("o_feeder"), Placed("o_water", 2) }); + Assert.AreEqual(0.10f, e.yieldBonus, 1e-4f); + } + + [Test] + public void CollectEffects_Comfort_FullThenHalfThenNothing() + { + int one = IslandCatalog.Get("f_bench").comfort; + Assert.AreEqual(one, IslandYield.CollectEffects(new List { Placed("f_bench") }).comfort); + Assert.AreEqual(one + one / 2, IslandYield.CollectEffects( + new List { Placed("f_bench"), Placed("f_bench", 3) }).comfort); + Assert.AreEqual(one + one / 2, IslandYield.CollectEffects( + new List { Placed("f_bench"), Placed("f_bench", 3), Placed("f_bench", 0, 3) }).comfort, + "같은 물건 셋째부터는 쾌적도를 주지 않는다"); + } + + [Test] + public void CollectEffects_UnknownIds_AreIgnored() + { + var e = IslandYield.CollectEffects(new List { Placed("x_from_future"), null }); + Assert.AreEqual(0, e.comfort); + Assert.AreEqual(0f, e.yieldBonus); + } + + [Test] + public void ComfortBonus_IsCappedAtMax() + { + Assert.AreEqual(0f, IslandYield.ComfortBonus(0), 1e-4f); + Assert.AreEqual(GameConstants.Island.MaxComfortBonus * 0.5f, + IslandYield.ComfortBonus(GameConstants.Island.ComfortForMaxBonus / 2), 1e-4f); + Assert.AreEqual(GameConstants.Island.MaxComfortBonus, + IslandYield.ComfortBonus(GameConstants.Island.ComfortForMaxBonus * 10), 1e-4f); + } + + [Test] + public void CapHours_BaseAndMax() + { + Assert.AreEqual(GameConstants.Island.BaseCapHours, IslandYield.CapHours(new IslandEffects()), 1e-4f); + Assert.AreEqual(GameConstants.Island.MaxCapHours, + IslandYield.CapHours(new IslandEffects { capHours = 999f }), 1e-4f); + } + + [Test] + public void CapHours_AllCatalogCapObjects_StayWithinMax() + { + var placed = new List(); + foreach (IslandObjectDef def in IslandCatalog.All) + if (def.effect == IslandEffectKind.CapHours) placed.Add(Placed(def.id)); + float cap = IslandYield.CapHours(IslandYield.CollectEffects(placed)); + Assert.Greater(cap, GameConstants.Island.BaseCapHours); + Assert.LessOrEqual(cap, GameConstants.Island.MaxCapHours); + } + + [TestCase(0f, 0)] + [TestCase(5.9f, 0)] + [TestCase(6f, 1)] + [TestCase(18f, 2)] + [TestCase(36f, 3)] + [TestCase(60f, 4)] + [TestCase(89.9f, 4)] + [TestCase(90f, 5)] + [TestCase(9999f, 5)] + public void BondLevel_Thresholds(float hours, int expected) + { + Assert.AreEqual(expected, IslandYield.BondLevel(hours)); + } + + [Test] + public void BondBonus_MaxLevel_IsTwentyPercent() + { + Assert.AreEqual(0.20f, IslandYield.BondBonus(GameConstants.Island.MaxBondLevel), 1e-4f); + Assert.AreEqual(0.20f, IslandYield.BondBonus(99), 1e-4f); + } + + // ── 정산 ── + + private const long T0 = 1_760_000_000L; + + [Test] + public void SettleHours_FirstEver_OnlySetsBaseline() + { + float dt = IslandYield.SettleHours(0, T0, 0f, 8f, out long next); + Assert.AreEqual(0f, dt); + Assert.AreEqual(T0, next); + } + + [Test] + public void SettleHours_NormalElapsed_GivesElapsedHours() + { + float dt = IslandYield.SettleHours(T0, T0 + 3 * 3600, 0f, 8f, out long next); + Assert.AreEqual(3f, dt, 1e-3f); + Assert.AreEqual(T0 + 3 * 3600, next); + } + + [Test] + public void SettleHours_BeyondCap_IsClampedToRemainingRoom() + { + Assert.AreEqual(8f, IslandYield.SettleHours(T0, T0 + 100 * 3600, 0f, 8f, out _), 1e-3f); + Assert.AreEqual(3f, IslandYield.SettleHours(T0, T0 + 100 * 3600, 5f, 8f, out _), 1e-3f); + Assert.AreEqual(0f, IslandYield.SettleHours(T0, T0 + 100 * 3600, 8f, 8f, out _), 1e-3f); + // 설비를 넣어 상한이 줄었어도(쌓인 시간이 상한보다 큼) 음수가 나오지 않는다. + Assert.AreEqual(0f, IslandYield.SettleHours(T0, T0 + 100 * 3600, 12f, 8f, out _), 1e-3f); + } + + [Test] + public void SettleHours_ClockMovedBackSlightly_GivesNothingAndKeepsBaseline() + { + // 시계를 앞뒤로 흔들어 상한분을 반복해 받는 걸 막는다 — 기준 시각을 당기지 않는다. + float dt = IslandYield.SettleHours(T0, T0 - 3600, 0f, 8f, out long next); + Assert.AreEqual(0f, dt); + Assert.AreEqual(T0, next); + } + + [Test] + public void SettleHours_ClockMovedBackFar_ResetsBaseline() + { + // 한 번 먼 미래로 갔던 섬이 그 시각까지 영영 멈추지 않게. + long now = T0 - GameConstants.Island.ClockRollbackResetSeconds - 10; + float dt = IslandYield.SettleHours(T0, now, 0f, 8f, out long next); + Assert.AreEqual(0f, dt); + Assert.AreEqual(now, next); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/IslandYieldTests.cs.meta b/Assets/Tests/EditMode/IslandYieldTests.cs.meta new file mode 100644 index 0000000..34a368e --- /dev/null +++ b/Assets/Tests/EditMode/IslandYieldTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e863b62ba1deabc4da68b7c0fcfe9ce5 \ No newline at end of file diff --git a/Assets/Tests/EditMode/MapMarkerProjectionTests.cs b/Assets/Tests/EditMode/MapMarkerProjectionTests.cs new file mode 100644 index 0000000..5069e05 --- /dev/null +++ b/Assets/Tests/EditMode/MapMarkerProjectionTests.cs @@ -0,0 +1,70 @@ +#if UNITY_EDITOR +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 지도·미니맵 마커 배치의 순수부. 화면(IMGUI)은 테스트로 못 보므로 좌표 계산만 고정한다. + /// + /// KeepClear가 지키는 것: 캠페인 첫 목표(어르신)는 시작점에서 9m 앞이라 월드 지도 축척에서는 + /// 내 위치 점과 한 자리다 — 떼어 놓지 않으면 ! 배지가 내 위치 점 밑에 깔려 안 보인다. + /// + [TestFixture] + public class MapMarkerProjectionTests + { + [Test] + public void KeepClear_FarEnough_LeavesMarkerAlone() + { + Vector2 marker = new Vector2(100f, 40f); + Assert.AreEqual(marker, MapMarkerProjection.KeepClear(marker, new Vector2(10f, 40f), 27f)); + } + + [Test] + public void KeepClear_TooClose_PushesOutAlongSameDirection() + { + Vector2 obstacle = new Vector2(50f, 50f); + Vector2 moved = MapMarkerProjection.KeepClear(new Vector2(53f, 46f), obstacle, 25f); + + Assert.AreEqual(25f, Vector2.Distance(moved, obstacle), 0.001f); + // (3, -4) 방향 그대로 — 오른쪽 위로 가면 된다는 정보가 남아야 한다. + Assert.AreEqual(50f + 15f, moved.x, 0.001f); + Assert.AreEqual(50f - 20f, moved.y, 0.001f); + } + + [Test] + public void KeepClear_ExactlyOnTop_GoesUpInGuiSpace() + { + Vector2 obstacle = new Vector2(50f, 50f); + Vector2 moved = MapMarkerProjection.KeepClear(obstacle, obstacle, 20f); + + Assert.AreEqual(50f, moved.x, 0.001f); + Assert.AreEqual(30f, moved.y, 0.001f); // GUI는 아래가 +y — 위는 y가 줄어든다 + } + + [Test] + public void KeepClear_NonPositiveGap_IsNoOp() + { + Vector2 marker = new Vector2(5f, 5f); + Assert.AreEqual(marker, MapMarkerProjection.KeepClear(marker, marker, 0f)); + } + + [Test] + public void TryRadarOffset_InsideRadius_MapsNorthToUp() + { + bool ok = MapMarkerProjection.TryRadarOffset(Vector3.zero, new Vector3(0f, 0f, 22.5f), 45f, 100f, out Vector2 offset); + + Assert.IsTrue(ok); + Assert.AreEqual(0f, offset.x, 0.001f); + Assert.AreEqual(-50f, offset.y, 0.001f); // 월드 +Z = 미니맵 위쪽 + } + + [Test] + public void TryRadarOffset_OutsideRadius_IsRejected() + { + Assert.IsFalse(MapMarkerProjection.TryRadarOffset(Vector3.zero, new Vector3(46f, 0f, 0f), 45f, 100f, out _)); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/MapMarkerProjectionTests.cs.meta b/Assets/Tests/EditMode/MapMarkerProjectionTests.cs.meta new file mode 100644 index 0000000..8ef58f7 --- /dev/null +++ b/Assets/Tests/EditMode/MapMarkerProjectionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2e49b90cafdbcad43bcb117cbba4605b \ No newline at end of file diff --git a/Assets/Tests/EditMode/MapNavigationTests.cs b/Assets/Tests/EditMode/MapNavigationTests.cs new file mode 100644 index 0000000..9f06112 --- /dev/null +++ b/Assets/Tests/EditMode/MapNavigationTests.cs @@ -0,0 +1,102 @@ +#if UNITY_EDITOR +using System.Reflection; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + public class MapNavigationTests + { + [TestCase(.6666667f, 0f)] + [TestCase(.6666667f, 90f)] + [TestCase(.8333333f, -45f)] + [TestCase(1f, 180f)] + public void MapRotation_KeepsVirtualPivotAndLineEndpointUnderScreenScale(float scale, float angle) + { + Matrix4x4 parent = Matrix4x4.Scale(new Vector3(scale, scale, 1f)); + Vector2 pivot = new Vector2(450f, 320f); + Matrix4x4 matrix = MapMarkerProjection.PivotMatrix(parent, pivot, angle); + Vector3 actualPivot = matrix.MultiplyPoint3x4(Vector3.zero); + Assert.That(Vector3.Distance(actualPivot, new Vector3(pivot.x * scale, pivot.y * scale, 0f)), Is.LessThan(.001f)); + Vector3 expectedEnd = parent.MultiplyPoint3x4(new Vector3(pivot.x, pivot.y, 0f) + + Quaternion.Euler(0f, 0f, angle) * (Vector3.right * 80f)); + Assert.That(Vector3.Distance(matrix.MultiplyPoint3x4(Vector3.right * 80f), expectedEnd), Is.LessThan(.001f)); + } + + [Test] + public void OverworldMarker_UsesEntranceWhileInsideSeparateSubWorld() + { + var sub = new SubAreaData { centerPosition = new Vector3(20f, 0f, 40f) }; + Assert.AreEqual(sub.centerPosition, + MapMarkerProjection.OverworldPlayerPosition(new Vector3(2000f, 0f, 2000f), sub)); + Vector3 field = new Vector3(15f, 0f, 12f); + Assert.AreEqual(field, MapMarkerProjection.OverworldPlayerPosition(field, null)); + } + + [Test] + public void RadarProjection_UsesSameNorthAndScaleRegardlessOfHeight() + { + Assert.IsTrue(MapMarkerProjection.TryRadarOffset(Vector3.zero, new Vector3(10f, 400f, 20f), + 40f, 100f, out Vector2 offset)); + Assert.AreEqual(new Vector2(25f, -50f), offset); + Assert.IsFalse(MapMarkerProjection.TryRadarOffset(Vector3.zero, Vector3.right * 41f, + 40f, 100f, out _)); + Assert.IsFalse(MapMarkerProjection.TryRadarOffset(Vector3.zero, Vector3.zero, + 0f, 100f, out _)); + } + + [TestCase(false)] + [TestCase(true)] + public void MapTravel_ExitsSubWorldBeforeAssigningDestination(bool travelToEntrance) + { + var root = new GameObject("Map navigation fixture"); + var player = new GameObject("Map navigation player"); + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + try + { + var manager = root.AddComponent(); + var map = root.AddComponent(); + var movement = player.AddComponent(); + typeof(PlayerMovement).GetField("movingToClick", flags).SetValue(movement, true); + typeof(PlayerMovement).GetField("clickTarget", flags).SetValue(movement, new Vector3(999f, 0f, 999f)); + typeof(PlayerMovement).GetField("joystickActive", flags).SetValue(movement, true); + typeof(PlayerMovement).GetField("joystickInput", flags).SetValue(movement, Vector2.one); + typeof(RegionManager).GetField("currentSubArea", flags).SetValue(manager, + new SubAreaData { subAreaId = "old" }); + manager.SetSubAreaSticky(true); + typeof(RegionMapUI).GetField("regionManager", flags).SetValue(map, manager); + typeof(RegionMapUI).GetField("playerTransform", flags).SetValue(map, player.transform); + bool restored = false; + manager.SubAreaChanged += value => + { + Assert.IsNull(value); + restored = true; + player.transform.position = new Vector3(7f, 2f, 7f); + }; + Vector3 destination = new Vector3(80f, 0f, 40f); + object target = travelToEntrance + ? (object)new SubAreaData { centerPosition = destination } + : new RegionData { centerPosition = destination }; + typeof(RegionMapUI).GetMethod(travelToEntrance ? "TeleportToSubArea" : "TeleportToRegion", flags) + .Invoke(map, new[] { target }); + Assert.IsFalse((bool)typeof(PlayerMovement).GetField("movingToClick", flags).GetValue(movement)); + Assert.IsFalse((bool)typeof(PlayerMovement).GetField("joystickActive", flags).GetValue(movement)); + Assert.AreEqual(Vector2.zero, typeof(PlayerMovement).GetField("joystickInput", flags).GetValue(movement)); + Assert.AreEqual(player.transform.position, typeof(PlayerMovement).GetField("clickTarget", flags).GetValue(movement)); + Assert.IsTrue(restored); + Assert.IsFalse(manager.SubAreaSticky); + Assert.IsNull(manager.CurrentSubArea); + Assert.AreEqual(new Vector3(80f, 2f, 40f), player.transform.position); + } + finally + { + Object.DestroyImmediate(player); + Object.DestroyImmediate(root); + } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/MapNavigationTests.cs.meta b/Assets/Tests/EditMode/MapNavigationTests.cs.meta new file mode 100644 index 0000000..ae0b076 --- /dev/null +++ b/Assets/Tests/EditMode/MapNavigationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cbf06b1b50fec7845b62bc60d3a926b2 \ No newline at end of file diff --git a/Assets/Tests/EditMode/ModalUIRegistryExceptTests.cs b/Assets/Tests/EditMode/ModalUIRegistryExceptTests.cs new file mode 100644 index 0000000..8c5df78 --- /dev/null +++ b/Assets/Tests/EditMode/ModalUIRegistryExceptTests.cs @@ -0,0 +1,77 @@ +#if UNITY_EDITOR +using InsectGame.UI; +using NUnit.Framework; + +namespace InsectGame.Tests +{ + /// + /// ModalUIRegistry.IsAnyOpenExcept — 모달 안의 버튼이 "다른 연출이 도는 중인가"를 물을 때 + /// 자기 자신은 빼고 본다. 오프닝 다시보기(OpeningReplayCoordinator.CanReplay)가 쓴다: + /// 설정 화면은 당연히 열려 있고, 그 위에 컷신·영상이 있으면 시작하면 안 된다. + /// + [TestFixture] + public class ModalUIRegistryExceptTests + { + private sealed class SettingsLike : IModalUI + { + public bool IsOpen { get; set; } = true; + public void CloseModal() { IsOpen = false; } + } + + private sealed class OverlayLike : IModalUI + { + public bool IsOpen { get; set; } = true; + public void CloseModal() { IsOpen = false; } + } + + private SettingsLike settings; + private OverlayLike overlay; + + [SetUp] + public void SetUp() + { + settings = new SettingsLike(); + overlay = new OverlayLike(); + } + + [TearDown] + public void TearDown() + { + ModalUIRegistry.Unregister(settings); + ModalUIRegistry.Unregister(overlay); + } + + [Test] + public void IsAnyOpenExcept_OnlySelfOpen_IsFalse() + { + ModalUIRegistry.Register(settings); + Assert.IsFalse(ModalUIRegistry.IsAnyOpenExcept(typeof(SettingsLike))); + Assert.IsTrue(ModalUIRegistry.IsAnyOpen(), "자기 자신은 여전히 열려 있어야 한다"); + } + + [Test] + public void IsAnyOpenExcept_OtherModalOnTop_IsTrue() + { + ModalUIRegistry.Register(settings); + ModalUIRegistry.Register(overlay); + Assert.IsTrue(ModalUIRegistry.IsAnyOpenExcept(typeof(SettingsLike))); + } + + [Test] + public void IsAnyOpenExcept_ClosedOther_IsPruned() + { + ModalUIRegistry.Register(settings); + ModalUIRegistry.Register(overlay); + overlay.IsOpen = false; // 닫혔지만 Unregister를 안 부른 잔재 + Assert.IsFalse(ModalUIRegistry.IsAnyOpenExcept(typeof(SettingsLike))); + } + + [Test] + public void IsAnyOpenExcept_NullType_MatchesIsAnyOpen() + { + ModalUIRegistry.Register(settings); + Assert.AreEqual(ModalUIRegistry.IsAnyOpen(), ModalUIRegistry.IsAnyOpenExcept(null)); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/ModalUIRegistryExceptTests.cs.meta b/Assets/Tests/EditMode/ModalUIRegistryExceptTests.cs.meta new file mode 100644 index 0000000..16747db --- /dev/null +++ b/Assets/Tests/EditMode/ModalUIRegistryExceptTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 45bc9bcdcaa105145a169444da880d9d \ No newline at end of file diff --git a/Assets/Tests/EditMode/NpcBossDuelTests.cs b/Assets/Tests/EditMode/NpcBossDuelTests.cs index 0a760dd..d7c4248 100644 --- a/Assets/Tests/EditMode/NpcBossDuelTests.cs +++ b/Assets/Tests/EditMode/NpcBossDuelTests.cs @@ -77,6 +77,66 @@ public void EveryBoss_InsectExistsInExpansion() } } + // ── 간부 팀(StartTeamDuel) ── + // 하수는 한 마리, 간부는 셋, 관장은 다섯 — 마릿수가 곧 계급이다. 팀 곤충 ID가 틀리면 그 곤충만 조용히 + // 빠진 채 대결이 열리고(NpcDuelController.ResolveBossTeam이 경고하고 건너뛴다), 에이스가 표의 insectId와 어긋나면 + // 표를 "대표 곤충"으로 읽는 쪽(장부 프로브·위계 테스트)이 엉뚱한 곤충을 잰다. + + [TestCase("ledger_grip", 3)] + [TestCase("ledger_scale", 3)] + [TestCase("ledger_chief", 5)] + public void Officer_FieldsATeamOfTheRightSize(string storyNpcId, int expected) + { + Assert.IsTrue(NpcBossDuels.TryGet(storyNpcId, out NpcBossDuels.BossDuel d), storyNpcId); + Assert.IsTrue(d.IsTeam, $"{storyNpcId}: 팀 대결이어야 한다"); + Assert.AreEqual(expected, d.RosterSize, $"{storyNpcId}: 내보내는 곤충 수"); + Assert.AreEqual(d.teamInsectIds.Length, d.teamLevels.Length, $"{storyNpcId}: 곤충과 레벨의 길이가 다르다"); + } + + [Test] + public void Thugs_StillFightAlone() + { + foreach (NpcBossDuels.BossDuel d in NpcBossDuels.All()) + { + if (!d.storyNpcId.StartsWith("ledger_thug_")) continue; + Assert.IsFalse(d.IsTeam, $"{d.storyNpcId}: 하수는 한 마리다"); + Assert.AreEqual(1, d.RosterSize); + Assert.AreEqual(d.insectId, d.RosterInsectId(0)); + Assert.AreEqual(d.level, d.RosterLevel(0)); + } + } + + [Test] + public void EveryTeam_LevelsAscend_AndTheLastIsTheAce() + { + foreach (NpcBossDuels.BossDuel d in NpcBossDuels.All()) + { + if (!d.IsTeam) continue; + for (int i = 1; i < d.RosterSize; i++) + Assert.Greater(d.RosterLevel(i), d.RosterLevel(i - 1), + $"{d.storyNpcId}: {i}번째 레벨이 앞보다 높지 않다 — 뒤로 갈수록 세져야 한다"); + int last = d.RosterSize - 1; + Assert.AreEqual(d.insectId, d.RosterInsectId(last), $"{d.storyNpcId}: 에이스가 표의 insectId와 다르다"); + Assert.AreEqual(d.level, d.RosterLevel(last), $"{d.storyNpcId}: 에이스 레벨이 표의 level과 다르다"); + } + } + + [Test] + public void EveryTeamInsect_Exists_AndIsNotRepeated() + { + HashSet ids = AllInsectIds(); + foreach (NpcBossDuels.BossDuel d in NpcBossDuels.All()) + { + HashSet seen = new HashSet(); + for (int i = 0; i < d.RosterSize; i++) + { + string id = d.RosterInsectId(i); + Assert.IsTrue(ids.Contains(id), $"{d.storyNpcId}: {i + 1}번째 곤충 {id}가 존재하지 않는다"); + Assert.IsTrue(seen.Add(id), $"{d.storyNpcId}: {id}를 두 번 내보낸다"); + } + } + } + [Test] public void EveryBoss_LevelWithinCap() { @@ -85,6 +145,11 @@ public void EveryBoss_LevelWithinCap() { Assert.Greater(d.level, 0, $"{d.storyNpcId}: 레벨이 0 이하"); Assert.LessOrEqual(d.level, cap, $"{d.storyNpcId}: 레벨 {d.level} > 상한 {cap}"); + for (int i = 0; i < d.RosterSize; i++) + { + Assert.Greater(d.RosterLevel(i), 0, $"{d.storyNpcId}: {i + 1}번째 레벨이 0 이하"); + Assert.LessOrEqual(d.RosterLevel(i), cap, $"{d.storyNpcId}: {i + 1}번째 레벨 > 상한 {cap}"); + } } } diff --git a/Assets/Tests/EditMode/NpcDialogueContinuityTests.cs b/Assets/Tests/EditMode/NpcDialogueContinuityTests.cs new file mode 100644 index 0000000..ad98c34 --- /dev/null +++ b/Assets/Tests/EditMode/NpcDialogueContinuityTests.cs @@ -0,0 +1,115 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using InsectGame.NPC; +using InsectGame.Story; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + public class NpcDialogueContinuityTests + { + [TestCase("하월", "catcher_rival", "ledger_chief")] + [TestCase("관장 하월", "catcher_rival", "ledger_chief")] + [TestCase("세라", "catcher_rival", "ruins_scholar")] + [TestCase("라온", "ruins_scholar", "catcher_rival")] + [TestCase("village_elder", "catcher_rival", "village_elder")] + [TestCase("", "catcher_rival", "catcher_rival")] + [TestCase("나", "catcher_rival", null)] + [TestCase("내레이션", "ruins_scholar", null)] + public void Portrait_FollowsCurrentSpeaker(string speaker, string fallback, string expected) + { + Assert.AreEqual(expected, NpcDialogueDatabase.StoryPortraitId(speaker, fallback)); + } + + [Test] + public void SpeakerId_UsesWorldName_WhileAuthoredNameIsPreserved() + { + Assert.AreEqual("세라", NpcDialogueDatabase.StorySpeakerName("ruins_scholar")); + Assert.AreEqual("알 수 없는 목소리", NpcDialogueDatabase.StorySpeakerName("알 수 없는 목소리")); + } + + [Test] + public void Ambient_UsesActualDefeatAndCleansing_WithoutChangingOriginalLines() + { + Assert.IsTrue(NpcDialogueDatabase.TryGetStoryNpcLines("ledger_thug_cord", false, false, out var before)); + Assert.IsTrue(NpcDialogueDatabase.TryGetStoryNpcLines("ledger_thug_cord", true, false, out var defeated)); + Assert.IsTrue(NpcDialogueDatabase.TryGetStoryNpcLines("ledger_thug_cord", true, true, out var cleansed)); + CollectionAssert.AreNotEqual(before, defeated); + CollectionAssert.AreNotEqual(defeated, cleansed); + NpcDialogueDatabase.TryGetStoryNpcLines("ledger_thug_cord", false, false, out var untouched); + CollectionAssert.AreEqual(before, untouched); + } + + [Test] + public void AmbientRequest_CannotReplaceActiveStoryOrItsCompletionState() + { + var root = new GameObject("Dialogue continuity test"); + try + { + var ui = root.AddComponent(); + var npc = root.AddComponent(); + var beat = new StoryBeat { beatId = "test", speakerNpcId = "ruins_scholar", + lines = new List { new StoryLine { speaker = "세라", text = "진행 중" } } }; + ui.ShowStoryReplay(beat); + ui.Show(npc); + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + Assert.AreSame(beat, typeof(NpcDialogueUI).GetField("currentBeat", flags).GetValue(ui)); + Assert.IsTrue((bool)typeof(NpcDialogueUI).GetField("storyReplay", flags).GetValue(ui)); + Assert.IsNull(typeof(NpcDialogueUI).GetField("currentNpc", flags).GetValue(ui)); + Assert.IsTrue(ui.IsOpen); + } + finally { Object.DestroyImmediate(root); } + } + + // 대사 앞 영상·등장 연출이 씬 재로드·UI 루트 토글로 끊기면 콜백이 ShowStory를 부른다 — 그때 창이 꺼져 있으면 + // static 모달 레지스트리에 열린 채 남으면 안 된다(새 씬의 조작이 막힌다). 쥐고 있다가 다시 켜지면 연다. + [Test] + public void ShowStory_WhileDisabled_DoesNotRegister_AndOpensWhenEnabledAgain() + { + var root = new GameObject("Dialogue disabled test"); + try + { + var ui = root.AddComponent(); + var beat = new StoryBeat { beatId = "held", speakerNpcId = "ruins_scholar", + lines = new List { new StoryLine { speaker = "세라", text = "기다렸어" } } }; + root.SetActive(false); + + ui.ShowStory(beat); + Assert.IsFalse(ui.IsOpen, "꺼진 창은 열리지 않는다"); + Assert.IsFalse(ReferenceEquals(ModalUIRegistry.TopModal, ui), "꺼진 창이 모달 레지스트리에 올라가면 새 씬 조작이 막힌다"); + + root.SetActive(true); + Assert.IsTrue(ui.IsOpen, "다시 켜지면 쥐고 있던 비트를 연다"); + Assert.IsTrue(ReferenceEquals(ModalUIRegistry.TopModal, ui)); + ui.CloseModal(); + Assert.IsFalse(ui.IsOpen); + } + finally { Object.DestroyImmediate(root); } + } + + [Test] + public void ShowStoryReplay_WhileDisabled_IsDropped_AndDestroyedWindowNeverRegisters() + { + var root = new GameObject("Dialogue replay disabled test"); + var beat = new StoryBeat { beatId = "replay", speakerNpcId = "ruins_scholar", + lines = new List { new StoryLine { speaker = "세라", text = "다시 읽기" } } }; + var ui = root.AddComponent(); + try + { + root.SetActive(false); + ui.ShowStoryReplay(beat); + root.SetActive(true); + Assert.IsFalse(ui.IsOpen, "다시보기는 쥐지 않는다 — 저널에서 다시 누르면 된다"); + } + finally { Object.DestroyImmediate(root); } + + // 파괴된 창(씬 재로드 뒤 늦게 온 콜백) — 관리 껍데기로 불려도 레지스트리에 오르지 않는다. + ui.ShowStory(beat); + Assert.IsFalse(ReferenceEquals(ModalUIRegistry.TopModal, ui)); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/NpcDialogueContinuityTests.cs.meta b/Assets/Tests/EditMode/NpcDialogueContinuityTests.cs.meta new file mode 100644 index 0000000..4139b8f --- /dev/null +++ b/Assets/Tests/EditMode/NpcDialogueContinuityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8c4fa0ecee1f5564c9dc766ea4739434 \ No newline at end of file diff --git a/Assets/Tests/EditMode/NpcRivalDuelTests.cs b/Assets/Tests/EditMode/NpcRivalDuelTests.cs new file mode 100644 index 0000000..5f4b239 --- /dev/null +++ b/Assets/Tests/EditMode/NpcRivalDuelTests.cs @@ -0,0 +1,238 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Text.RegularExpressions; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.NPC; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 라온 라이벌 대결 — 단계 선택()과 표의 무결성. + /// 단계 ID·비트·리전이 틀려도 런타임엔 아무 말이 없다 — [승부하기] 버튼이 그냥 안 뜬다. + /// + [TestFixture] + public class NpcRivalDuelTests + { + private static System.Func Seen(params string[] ids) + { + var set = new HashSet(ids); + return set.Contains; + } + + private static readonly System.Func NoneDefeated = _ => false; + + [Test] + public void TrySelect_BeforeOpeningBeat_Nothing() + { + Assert.IsFalse(NpcRivalDuels.TrySelect("catcher_rival", "pond", Seen(), NoneDefeated, out _)); + } + + [Test] + public void TrySelect_OpenedAndInRegion_PicksThatStage() + { + Assert.IsTrue(NpcRivalDuels.TrySelect("catcher_rival", "pond", Seen("ch2_water"), NoneDefeated, + out NpcRivalDuels.Stage s)); + Assert.AreEqual("rival_pond", s.stageId); + } + + [Test] + public void TrySelect_WrongRegion_Nothing() + { + Assert.IsFalse(NpcRivalDuels.TrySelect("catcher_rival", "meadow", Seen("ch2_water"), NoneDefeated, out _)); + } + + [Test] + public void TrySelect_FrostStage_ClosesWhenRaonIsHurt() + { + Assert.IsTrue(NpcRivalDuels.TrySelect("catcher_rival", "frostline", Seen("ch9_arrive"), NoneDefeated, + out NpcRivalDuels.Stage s)); + Assert.AreEqual("rival_frost", s.stageId); + Assert.IsFalse(NpcRivalDuels.TrySelect("catcher_rival", "frostline", + Seen("ch9_arrive", "ch10_confront"), NoneDefeated, out _), "갱도에서 다친 뒤엔 싸우지 않는다"); + } + + [Test] + public void TrySelect_Defeated_SkipsToNextAvailable() + { + // 표 순서대로 첫 단계를 고른다 — 이긴 단계는 건너뛴다(각 단계는 독립이다). 표지만 세운 합성 상황이다. + var seen = Seen("ch2_water", "post_rival_rematch"); + Assert.IsTrue(NpcRivalDuels.TrySelect("catcher_rival", "pond", seen, NoneDefeated, out NpcRivalDuels.Stage first)); + Assert.AreEqual("rival_pond", first.stageId); + Assert.IsTrue(NpcRivalDuels.TrySelect("catcher_rival", "pond", seen, id => id == "rival_pond", + out NpcRivalDuels.Stage next)); + Assert.AreEqual("rival_final", next.stageId); + } + + [Test] + public void TrySelect_AfterTheEnding_PreInjuryStagesAreClosed_OnlyTheRematch() + { + // 실제 엔딩 세이브는 갱도(ch10_confront)를 지나왔다 — 다치기 전 단계는 전부 닫히고 어디서든 재대결만 뜬다. + var seen = Seen("ch1_rival_intro", "ch2_water", "ch4_reach_swamp", "ch7_arrive", "ch8_arrive", + "ch9_arrive", "ch10_arrive", NpcRivalDuels.InjuryBeatId, "post_rival_rematch"); + foreach (string region in new[] { "meadow", "pond", "swamp", "hollow", "dunes", "frostline", "emberfall", "nameless" }) + { + Assert.IsTrue(NpcRivalDuels.TrySelect("catcher_rival", region, seen, NoneDefeated, out NpcRivalDuels.Stage s), region); + Assert.AreEqual("rival_final", s.stageId, region); + } + } + + [Test] + public void TrySelect_MeadowOpensAtTheFirstMeeting() + { + // 첫 만남 대사가 "승부다!"로 끝나면 곧바로 이 단계가 열린다(StoryBeat.duelAfter → StoryDuelLauncher). + Assert.IsFalse(NpcRivalDuels.TrySelect("catcher_rival", "meadow", Seen("ch1_intro"), NoneDefeated, out _)); + Assert.IsTrue(NpcRivalDuels.TrySelect("catcher_rival", "meadow", Seen("ch1_rival_intro"), NoneDefeated, + out NpcRivalDuels.Stage s)); + Assert.AreEqual("rival_meadow", s.stageId); + Assert.Less(s.level, 6, "첫 파트너(Lv.6)보다 낮다 — 첫 전투에서 막히면 이야기가 시작도 전에 선다"); + } + + [Test] + public void TrySelect_EmberStage_OnlyBetweenArrivalAndTheKiln() + { + Assert.IsTrue(NpcRivalDuels.TrySelect("catcher_rival", "emberfall", Seen("ch10_arrive"), NoneDefeated, + out NpcRivalDuels.Stage s)); + Assert.AreEqual("rival_ember", s.stageId); + Assert.IsFalse(NpcRivalDuels.TrySelect("catcher_rival", "emberfall", + Seen("ch10_arrive", "ch10_confront"), NoneDefeated, out _), "갱도에서 다친 뒤엔 싸우지 않는다"); + } + + [Test] + public void EveryPreInjuryStage_ClosesAtTheKiln() + { + foreach (NpcRivalDuels.Stage s in NpcRivalDuels.All()) + { + if (string.IsNullOrEmpty(s.regionId)) continue; // 엔딩 뒤 재대결 — 다 나은 뒤다 + Assert.AreEqual(NpcRivalDuels.InjuryBeatId, s.closeBeatId, $"{s.stageId}: 다친 라온과 싸우게 된다"); + } + } + + [Test] + public void EveryChapterWhereRaonStands_HasAStage() + { + // 「장마다 한 번」 — 라온 앵커가 있는 리전마다 단계가 있다. 이름 없는 자리는 복귀(ch12_echo) 뒤라 엔딩 재대결이 맡는다. + string village = ReadRepoText("Assets/Scripts/Core/VillageBuilder.cs"); + var staged = new HashSet(); + foreach (NpcRivalDuels.Stage s in NpcRivalDuels.All()) + if (!string.IsNullOrEmpty(s.regionId)) staged.Add(s.regionId); + foreach (Match m in Regex.Matches(village, + @"regionId\s*=\s*""([a-z_]+)"",\s*storyNpcId\s*=\s*""catcher_rival""")) + { + string region = m.Groups[1].Value; + if (region == "nameless") continue; + Assert.IsTrue(staged.Contains(region), $"{region}: 라온이 서 있는데 겨룰 단계가 없다"); + } + } + + [Test] + public void IsRival_OnlyRaon() + { + Assert.IsTrue(NpcRivalDuels.IsRival("catcher_rival")); + Assert.IsFalse(NpcRivalDuels.IsRival("ledger_grip")); + Assert.IsFalse(NpcRivalDuels.IsRival(null)); + } + + [Test] + public void TrySelect_FinalRematch_AnyRegion() + { + Assert.IsTrue(NpcRivalDuels.TrySelect("catcher_rival", "nameless", Seen("post_rival_rematch"), NoneDefeated, + out NpcRivalDuels.Stage s)); + Assert.AreEqual("rival_final", s.stageId); + } + + [Test] + public void TrySelect_OtherNpc_Nothing() + { + Assert.IsFalse(NpcRivalDuels.TrySelect("ruins_scholar", "pond", Seen("ch2_water"), NoneDefeated, out _)); + } + + [Test] + public void Stages_UniquePrefixedIds_NotBossIds() + { + var ids = new HashSet(); + foreach (NpcRivalDuels.Stage s in NpcRivalDuels.All()) + { + Assert.IsTrue(s.stageId.StartsWith(NpcRivalDuels.StagePrefix), s.stageId); + Assert.IsTrue(ids.Add(s.stageId), $"중복 단계 {s.stageId}"); + // 격파 기록을 간부와 같은 집합에 적는다 — 인물 ID와 겹치면 서로의 기록을 먹는다. + Assert.IsFalse(NpcBossDuels.IsBoss(s.stageId), s.stageId); + } + } + + [Test] + public void Stages_HaveBanter_ForTheRightFace() + { + foreach (NpcRivalDuels.Stage s in NpcRivalDuels.All()) + { + Assert.IsTrue(DuelBanter.TryGet(s.stageId, out DuelBanter.Lines l), $"{s.stageId}: 대결 대사 없음"); + Assert.AreEqual(s.storyNpcId, l.npcId, s.stageId); + } + } + + [Test] + public void Stages_GateBeatsExistInStory() + { + string story = ReadRepoText("Assets/Resources/Story.json"); + foreach (NpcRivalDuels.Stage s in NpcRivalDuels.All()) + { + StringAssert.Contains($"\"beatId\": \"{s.openBeatId}\"", story, $"{s.stageId}: 여는 비트 없음"); + if (!string.IsNullOrEmpty(s.closeBeatId)) + StringAssert.Contains($"\"beatId\": \"{s.closeBeatId}\"", story, $"{s.stageId}: 닫는 비트 없음"); + } + } + + [Test] + public void RegionalStages_RaonStandsThere_LevelWithinRegionBand() + { + // 리전 단계는 라온 앵커가 그 리전에 있어야 말을 걸 수 있다. 레벨은 그 리전 구간(요구~수문장) 안. + string village = ReadRepoText("Assets/Scripts/Core/VillageBuilder.cs"); + var anchored = new HashSet(); + foreach (Match m in Regex.Matches(village, + @"regionId\s*=\s*""([a-z_]+)"",\s*storyNpcId\s*=\s*""catcher_rival""")) + anchored.Add(m.Groups[1].Value); + var regions = new Dictionary(); + foreach (RegionData r in RegionDefinitions.CreateAll()) regions[r.regionId] = r; + + foreach (NpcRivalDuels.Stage s in NpcRivalDuels.All()) + { + if (string.IsNullOrEmpty(s.regionId)) continue; + Assert.IsTrue(anchored.Contains(s.regionId), $"{s.stageId}: {s.regionId}에 라온 앵커 없음"); + Assert.IsTrue(regions.TryGetValue(s.regionId, out RegionData r), s.regionId); + Assert.That(s.level, Is.InRange(r.requiredLevel, r.guardianLevel), $"{s.stageId} Lv.{s.level}"); + } + } + + [Test] + public void Stages_InsectsExist_LevelsRise_RewardsSet() + { + var ids = new HashSet(); + foreach (InsectSeed seed in InsectExpansionDefinitions.CreateAll()) ids.Add(seed.id); + foreach (InsectSeed seed in InsectExpansion2Definitions.CreateAll()) ids.Add(seed.id); + foreach (Match m in Regex.Matches(ReadRepoText("Assets/Scripts/Core/PlaySceneBootstrap.cs"), + @"CreateStableInsect\(""([a-z_0-9]+)""")) + ids.Add(m.Groups[1].Value); + + int previous = 0; + foreach (NpcRivalDuels.Stage s in NpcRivalDuels.All()) + { + Assert.IsTrue(ids.Contains(s.insectId), $"{s.stageId}: 곤충 {s.insectId} 없음"); + Assert.Greater(s.level, previous, $"{s.stageId}: 라온은 단계마다 강해진다"); + Assert.LessOrEqual(s.level, 80, "곤충 레벨 상한"); + Assert.IsFalse(string.IsNullOrEmpty(s.rewardItemId), s.stageId); + Assert.Greater(s.rewardCount, 0, s.stageId); + previous = s.level; + } + } + + private static string ReadRepoText(string relativePath) + { + string full = System.IO.Path.Combine(Application.dataPath, "..", relativePath); + Assert.IsTrue(System.IO.File.Exists(full), $"파일 없음: {relativePath}"); + return System.IO.File.ReadAllText(full); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/NpcRivalDuelTests.cs.meta b/Assets/Tests/EditMode/NpcRivalDuelTests.cs.meta new file mode 100644 index 0000000..a645ac8 --- /dev/null +++ b/Assets/Tests/EditMode/NpcRivalDuelTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 41b0af42747fb3d469c0212299505e8a \ No newline at end of file diff --git a/Assets/Tests/EditMode/OpeningSequenceTests.cs b/Assets/Tests/EditMode/OpeningSequenceTests.cs index 04e12e9..6a6eef0 100644 --- a/Assets/Tests/EditMode/OpeningSequenceTests.cs +++ b/Assets/Tests/EditMode/OpeningSequenceTests.cs @@ -281,19 +281,19 @@ public void CalculateSkipButtonRect_AllSafeAreas_IsBottomCenteredPill() [Test] public void Narration_DoesNotOverlapTitle() { - // 타이틀은 6.2s에 뜬다. 앞의 두 줄이 거기까지 남아 있으면 글자가 겹쳐 둘 다 못 읽는다. - Assert.LessOrEqual(OpeningSequenceState.Narration2End, OpeningSequenceState.TitleStart + 0.01f, - "두 번째 내레이션이 타이틀과 겹친다"); - // 세 번째 줄은 타이틀이 자리잡은 뒤(TitleHold 8s) 얹는 것이 의도다. - Assert.GreaterOrEqual(OpeningSequenceState.Narration3Start, OpeningSequenceState.TitleHoldStart, - "세 번째 내레이션이 타이틀이 자리잡기 전에 뜬다"); + // 타이틀은 곡의 강세(TitleStart)에 뜬다. 앞의 세 줄이 거기까지 남아 있으면 글자가 겹쳐 둘 다 못 읽는다. + Assert.LessOrEqual(OpeningSequenceState.Narration3End, OpeningSequenceState.TitleStart + 0.01f, + "세 번째 내레이션이 타이틀과 겹친다"); + // 마지막 줄은 타이틀이 자리잡은 뒤(TitleHold) 얹는 것이 의도다. + Assert.GreaterOrEqual(OpeningSequenceState.Narration4Start, OpeningSequenceState.TitleHoldStart, + "마지막 내레이션이 타이틀이 자리잡기 전에 뜬다"); } [Test] public void Narration_EndsBeforeSequenceEnds() { - // 마지막 줄이 페이드아웃(9.2s~10s) 뒤까지 남으면 검은 화면에 글자만 뜬다. - Assert.LessOrEqual(OpeningSequenceState.Narration3End, OpeningSequenceState.Duration, + // 마지막 줄이 페이드아웃 뒤까지 남으면 검은 화면에 글자만 뜬다. + Assert.LessOrEqual(OpeningSequenceState.Narration4End, OpeningSequenceState.Duration, "내레이션이 오프닝보다 늦게 끝난다"); } @@ -304,6 +304,26 @@ public void Narration_LinesDoNotOverlapEachOther() "1번과 2번 내레이션이 겹친다"); Assert.Less(OpeningSequenceState.Narration2End, OpeningSequenceState.Narration3Start, "2번과 3번 내레이션이 겹친다"); + Assert.Less(OpeningSequenceState.Narration3End, OpeningSequenceState.Narration4Start, + "3번과 4번 내레이션이 겹친다"); + } + + [Test] + public void Narration_AvoidsVideoBlackout() + { + // 영상은 8.3s부터 암전했다가 파트너가 떠오른다. 그 사이 글자만 떠 있으면 영상이 멈춘 것처럼 보인다. + Assert.LessOrEqual(OpeningSequenceState.Narration2End, OpeningSequenceState.BlackoutStart, + "두 번째 내레이션이 암전 속까지 남는다"); + Assert.GreaterOrEqual(OpeningSequenceState.Narration3Start, OpeningSequenceState.PartnerRevealEnd, + "세 번째 내레이션이 파트너가 다 떠오르기 전에 뜬다"); + } + + [Test] + public void Narration_LineCountMatchesTimeline() + { + // 문장은 컨트롤러에, 시각은 상태 클래스에 있다 — 한쪽만 늘리면 줄이 안 뜨거나 범위 밖을 읽는다. + Assert.AreEqual(OpeningSequenceState.NarrationCount, OpeningSceneController.NarrationLineCount, + "내레이션 문장 수와 시각 구간 수가 다르다"); } [Test] @@ -312,10 +332,12 @@ public void GetNarration_ReturnsCorrectLineAtEachWindow() AssertNarrationAt((OpeningSequenceState.Narration1Start + OpeningSequenceState.Narration1End) * 0.5f, 0); AssertNarrationAt((OpeningSequenceState.Narration2Start + OpeningSequenceState.Narration2End) * 0.5f, 1); AssertNarrationAt((OpeningSequenceState.Narration3Start + OpeningSequenceState.Narration3End) * 0.5f, 2); + AssertNarrationAt((OpeningSequenceState.Narration4Start + OpeningSequenceState.Narration4End) * 0.5f, 3); // 창 사이 공백과 시작 전에는 아무것도 안 뜬다. AssertNarrationAt(0.2f, -1); AssertNarrationAt((OpeningSequenceState.Narration1End + OpeningSequenceState.Narration2Start) * 0.5f, -1); + AssertNarrationAt((OpeningSequenceState.BlackoutStart + OpeningSequenceState.PartnerRevealEnd) * 0.5f, -1); } [Test] diff --git a/Assets/Tests/EditMode/OutfitPartSurfaceTests.cs b/Assets/Tests/EditMode/OutfitPartSurfaceTests.cs new file mode 100644 index 0000000..3cc71d0 --- /dev/null +++ b/Assets/Tests/EditMode/OutfitPartSurfaceTests.cs @@ -0,0 +1,137 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Core; +using NUnit.Framework; + +namespace InsectGame.Tests +{ + /// + /// 의상 spawn 파츠의 재질(광택) 판정 — . + /// + /// 예외 표는 itemId 문자열과 색 역할로 적혀 있어서 틀려도 조용하다: 키에 오타가 나거나 레시피가 바뀌어 + /// 예외 역할이 사라지면 그 아이템은 예외도 경고도 없이 천(무광)으로 그려진다. 그 두 갈래를 여기서 막는다. + /// + [TestFixture] + public class OutfitPartSurfaceTests + { + private static Dictionary CatalogSlots() + { + Dictionary map = new Dictionary(); + OutfitItem[] catalog = CharacterOutfitManager.BuildCatalog(); + for (int i = 0; i < catalog.Length; i++) map[catalog[i].itemId] = catalog[i].slot; + return map; + } + + private static OutfitRecipe RecipeOf(string itemId) + { + Dictionary slots = CatalogSlots(); + Assert.IsTrue(slots.TryGetValue(itemId, out OutfitSlot slot), itemId + "가 카탈로그에 없다"); + Assert.IsTrue(OutfitShapeLibrary.TryGet(slot, itemId, out OutfitRecipe recipe), itemId + " 레시피가 없다"); + return recipe; + } + + // ── 예외 표 정합 ── + + [Test] + public void SurfaceOverrides_EveryKey_IsExactRecipe() + { + HashSet ids = new HashSet(OutfitShapeLibrary.ExactRecipeIds()); + foreach (KeyValuePair e in OutfitShapeLibrary.SurfaceOverrideEntries()) + Assert.IsTrue(ids.Contains(e.Key), $"재질 예외 '{e.Key}'에 해당하는 레시피가 없다 — 오타면 조용히 천으로 그려진다"); + } + + /// + /// 예외 역할이 레시피의 spawn 파츠에 실제로 있어야 하고, 기본 재질이 적용될 파츠도 남아 있어야 한다. + /// 둘 중 하나가 비면 표가 말하는 것과 화면이 다르다(예: 왕관 보석을 기본색으로 바꾸면 보석이 금속이 된다). + /// + [Test] + public void SurfaceOverrides_ExceptRole_SplitsRealSpawnParts() + { + foreach (KeyValuePair e in OutfitShapeLibrary.SurfaceOverrideEntries()) + { + if (!e.Value.HasExcept) continue; + + OutfitRecipe recipe = RecipeOf(e.Key); + bool exceptSeen = false, baseSeen = false; + for (int i = 0; i < recipe.parts.Length; i++) + { + if (recipe.parts[i].IsBind) continue; + if (recipe.parts[i].role == e.Value.ExceptRole) exceptSeen = true; + else baseSeen = true; + } + + Assert.IsTrue(exceptSeen, $"{e.Key}: 예외 역할 {e.Value.ExceptRole}을 쓰는 spawn 파츠가 없다"); + Assert.IsTrue(baseSeen, $"{e.Key}: 기본 재질 {e.Value.Kind}이 적용될 파츠가 없다 — 예외만 남았다"); + Assert.AreNotEqual(e.Value.Kind, e.Value.ExceptKind, $"{e.Key}: 예외가 기본과 같은 재질이다"); + } + } + + /// + /// 역참조가 레시피마다 자기 id를 돌려줘야 한다. 두 아이템이 레시피 인스턴스를 나누면 + /// 한쪽의 재질 예외가 다른 쪽에 조용히 번진다. + /// + [Test] + public void ItemIdOf_EveryExactRecipe_RoundTrips() + { + Dictionary slots = CatalogSlots(); + foreach (string id in OutfitShapeLibrary.ExactRecipeIds()) + { + OutfitSlot slot = slots.TryGetValue(id, out OutfitSlot s) ? s : OutfitSlot.Hat; + Assert.IsTrue(OutfitShapeLibrary.TryGet(slot, id, out OutfitRecipe recipe), id); + Assert.AreEqual(id, OutfitShapeLibrary.ItemIdOf(recipe), $"{id}의 레시피가 다른 id로 역참조된다"); + } + Assert.IsNull(OutfitShapeLibrary.ItemIdOf(null)); + } + + // ── 판정 ── + + /// 표에 없는 아이템은 슬롯 기본 — bind 노드(Cap=천, Backpack=가죽)와 같은 재질이다. + [Test] + public void SurfaceOf_UnlistedItem_FollowsSlotDefault() + { + Assert.AreEqual(SurfaceKind.Cloth, OutfitShapeLibrary.SurfaceOf(OutfitSlot.Hat, "hat_straw", PartColorRole.Primary)); + Assert.AreEqual(SurfaceKind.Cloth, OutfitShapeLibrary.SurfaceOf(OutfitSlot.Outerwear, "outer_legendary", PartColorRole.Secondary)); + Assert.AreEqual(SurfaceKind.Cloth, OutfitShapeLibrary.SurfaceOf(OutfitSlot.Accessory, "acc_scarf", PartColorRole.Primary)); + Assert.AreEqual(SurfaceKind.Leather, OutfitShapeLibrary.SurfaceOf(OutfitSlot.Backpack, "bag_dragon", PartColorRole.Secondary)); + Assert.AreEqual(SurfaceKind.Cloth, OutfitShapeLibrary.SurfaceOf(OutfitSlot.Hat, null, PartColorRole.Primary)); + } + + /// 꽃 왕관의 금색 꽃술은 고정색(Gold)이지만 꽃이다 — 금색이라고 금속으로 가르지 않는다. + [Test] + public void SurfaceOf_FlowerCrownGoldStamen_StaysCloth() + { + Assert.AreEqual(SurfaceKind.Cloth, OutfitShapeLibrary.SurfaceOf(OutfitSlot.Hat, "hat_flower", PartColorRole.Fixed)); + } + + [Test] + public void SurfaceOf_Crown_GoldIsMetal_GemIsGlossy() + { + Assert.AreEqual(SurfaceKind.Metal, OutfitShapeLibrary.SurfaceOf(OutfitSlot.Hat, "hat_crown", PartColorRole.Primary)); + Assert.AreEqual(SurfaceKind.Metal, OutfitShapeLibrary.SurfaceOf(OutfitSlot.Hat, "hat_crown", PartColorRole.Fixed)); + Assert.AreEqual(SurfaceKind.Wet, OutfitShapeLibrary.SurfaceOf(OutfitSlot.Hat, "hat_crown", PartColorRole.Secondary)); + } + + [Test] + public void SurfaceOf_ScienceCase_ClaspsAreMetal_HandleIsLeather() + { + Assert.AreEqual(SurfaceKind.Metal, OutfitShapeLibrary.SurfaceOf(OutfitSlot.Backpack, "bag_science", PartColorRole.Secondary)); + Assert.AreEqual(SurfaceKind.Leather, OutfitShapeLibrary.SurfaceOf(OutfitSlot.Backpack, "bag_science", PartColorRole.PrimaryDark)); + } + + /// + /// 슬롯 기본은 무광 계열이어야 한다 — 광택 통일의 요지다. 금속·유리 광택은 예외 표에서만 나온다. + /// + [Test] + public void DefaultSurface_EverySlot_IsNonMetallicAndNotGlossierThanLeather() + { + CharacterPalette.SurfaceValues(SurfaceKind.Leather, out float leatherGloss, out _); + foreach (OutfitSlot slot in System.Enum.GetValues(typeof(OutfitSlot))) + { + CharacterPalette.SurfaceValues(OutfitShapeLibrary.DefaultSurface(slot), out float gloss, out float metallic); + Assert.AreEqual(0f, metallic, slot + ": 슬롯 기본이 금속이다"); + Assert.LessOrEqual(gloss, leatherGloss, slot + ": 슬롯 기본이 가죽보다 반짝인다"); + } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/OutfitPartSurfaceTests.cs.meta b/Assets/Tests/EditMode/OutfitPartSurfaceTests.cs.meta new file mode 100644 index 0000000..2d23478 --- /dev/null +++ b/Assets/Tests/EditMode/OutfitPartSurfaceTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 00eb23ec33b915b4cbba2419b26c2657 \ No newline at end of file diff --git a/Assets/Tests/EditMode/OutfitPatternLibraryTests.cs b/Assets/Tests/EditMode/OutfitPatternLibraryTests.cs new file mode 100644 index 0000000..8f6b9d8 --- /dev/null +++ b/Assets/Tests/EditMode/OutfitPatternLibraryTests.cs @@ -0,0 +1,155 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Core; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 의상 표면 무늬() — 절차 생성 텍스처. + /// + /// 무늬 결함은 조용하다: itemId 오타면 그 옷은 예외 없이 **민무늬**로 돌아가고, 표면을 잘못 고르면 + /// 조끼 소매에 조끼 무늬가 찍힌다. 그래서 표의 키·슬롯·표면 정합과 "무늬끼리 실제로 다른가"를 고정한다. + /// 그림 자체는 OutfitRenderProbe -outfitAll로 눈으로 본다. + /// + [TestFixture] + public class OutfitPatternLibraryTests + { + private static Dictionary Catalog() + { + Dictionary map = new Dictionary(); + foreach (OutfitItem item in CharacterOutfitManager.BuildCatalog()) map[item.itemId] = item; + return map; + } + + /// 이 슬롯의 옷이 무늬를 입는 표면(ApplyToCharacter가 부르는 것과 같다). + private static PatternSurface[] SurfacesFor(OutfitSlot slot) + { + switch (slot) + { + case OutfitSlot.Top: return new[] { PatternSurface.Torso, PatternSurface.Panel, PatternSurface.Sleeve }; + case OutfitSlot.Outerwear: return new[] { PatternSurface.Torso, PatternSurface.Sleeve }; + case OutfitSlot.Bottom: return new[] { PatternSurface.Leg }; + case OutfitSlot.Shoes: return new[] { PatternSurface.Boot }; + default: return new PatternSurface[0]; + } + } + + [Test] + public void EveryPatternKey_IsARealWearableItem() + { + Dictionary catalog = Catalog(); + foreach (KeyValuePair e in OutfitPatternLibrary.Entries()) + { + Assert.IsTrue(catalog.TryGetValue(e.Key, out OutfitItem item), $"'{e.Key}'는 카탈로그에 없다 — 오타면 조용히 민무늬가 된다"); + Assert.Greater(SurfacesFor(item.slot).Length, 0, $"{e.Key}: 무늬를 입힐 표면이 없는 슬롯({item.slot})이다"); + } + } + + /// 무늬가 있는 옷은 자기 슬롯의 표면 중 **하나 이상**에 실제로 그려진다(표면 판정이 전부 거짓이면 표가 장식이 된다). + [Test] + public void EveryPatternedItem_DrawsOnAtLeastOneOfItsSurfaces() + { + Dictionary catalog = Catalog(); + foreach (KeyValuePair e in OutfitPatternLibrary.Entries()) + { + OutfitItem item = catalog[e.Key]; + bool any = false; + foreach (PatternSurface s in SurfacesFor(item.slot)) + any |= OutfitPatternLibrary.Get(e.Key, s, item.primaryColor, item.secondaryColor) != null; + Assert.IsTrue(any, $"{e.Key}({e.Value}): 어느 표면에도 그려지지 않는다"); + } + } + + [Test] + public void UnknownOrPlainItems_HaveNoTexture() + { + Assert.IsNull(OutfitPatternLibrary.Get(null, PatternSurface.Torso, Color.red, Color.blue)); + Assert.IsNull(OutfitPatternLibrary.Get("top_does_not_exist", PatternSurface.Torso, Color.red, Color.blue)); + Assert.IsNull(OutfitPatternLibrary.Get("bot_pants", PatternSurface.Leg, Color.grey, Color.white), "기본 바지는 민무늬"); + // 조끼 소매는 속셔츠 색이지 조끼 무늬가 아니다 + Assert.IsNull(OutfitPatternLibrary.Get("top_vest", PatternSurface.Sleeve, Color.yellow, Color.white)); + } + + [Test] + public void Textures_HaveTheSurfaceSize_AndAreCached() + { + Texture2D torso = OutfitPatternLibrary.Get("top_stripe", PatternSurface.Torso, new Color(0.2f, 0.4f, 0.8f), Color.white); + Texture2D again = OutfitPatternLibrary.Get("top_stripe", PatternSurface.Torso, new Color(0.2f, 0.4f, 0.8f), Color.white); + Assert.IsNotNull(torso); + Assert.AreSame(torso, again, "같은 무늬·표면·색은 한 번만 굽는다"); + Assert.AreEqual(256, torso.width); + Assert.AreEqual(128, torso.height); + + Texture2D other = OutfitPatternLibrary.Get("top_stripe", PatternSurface.Torso, Color.red, Color.white); + Assert.AreNotSame(torso, other, "색이 다르면 다른 텍스처"); + + Texture2D leg = OutfitPatternLibrary.Get("bot_jeans", PatternSurface.Leg, new Color(0.15f, 0.25f, 0.55f), Color.white); + Assert.AreEqual(64, leg.width); + } + + [Test] + public void Render_IsDeterministic() + { + Color[] a = OutfitPatternLibrary.Render(PatternKind.Galaxy, PatternSurface.Torso, Color.blue, Color.magenta, 64, 32); + Color[] b = OutfitPatternLibrary.Render(PatternKind.Galaxy, PatternSurface.Torso, Color.blue, Color.magenta, 64, 32); + CollectionAssert.AreEqual(a, b, "같은 입력이면 같은 무늬 — 난수 시드가 흔들리면 옷을 갈아입을 때마다 별이 옮겨 다닌다"); + } + + /// + /// 상의 14벌의 무늬가 **서로 달라야** 한다 — 이 개편 전엔 상의 전부가 가슴의 같은 색 조각이었다. + /// 같은 두 색으로 구워도 다른 그림이 나오는지 본다(색 차이에 기대지 않는다). + /// + [Test] + public void TopPatterns_AreVisuallyDistinct() + { + Dictionary catalog = Catalog(); + Dictionary seen = new Dictionary(); + foreach (KeyValuePair e in OutfitPatternLibrary.Entries()) + { + if (catalog[e.Key].slot != OutfitSlot.Top) continue; + Color[] px = OutfitPatternLibrary.Render(e.Value, PatternSurface.Torso, new Color(0.5f, 0.3f, 0.2f), new Color(0.9f, 0.9f, 0.6f), 64, 32); + string sig = Signature(px); + Assert.IsFalse(seen.ContainsKey(sig), $"{e.Key}의 무늬가 {(seen.ContainsKey(sig) ? seen[sig] : "")}와 같다"); + seen[sig] = e.Key; + } + Assert.GreaterOrEqual(seen.Count, 14, "상의 무늬가 14벌을 덮지 못한다"); + } + + /// 아틀라스 표면은 앞 칸과 뒤 칸이 다르게 그려질 수 있어야 한다(단추·주머니는 앞에만). + [Test] + public void AtlasSurfaces_KeepFrontOnlyMotifsOffTheBack() + { + Color[] px = OutfitPatternLibrary.Render(PatternKind.PirateCoat, PatternSurface.Torso, new Color(0.5f, 0.1f, 0.1f), new Color(0.85f, 0.7f, 0.15f), 128, 64); + int w = 128, h = 64; + long front = 0, back = 0; + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + Color c = px[y * w + x]; + bool gold = c.r > 0.7f && c.g > 0.55f; + if (gold && x < w / 2) front++; + if (gold && x >= w / 2) back++; + } + } + Assert.Greater(front, back * 3 / 2, "금단추·금테가 등에 앞면만큼 있다 — 아틀라스 칸이 뒤바뀌었거나 앞면 전용 무늬가 새었다"); + } + + private static string Signature(Color[] px) + { + unchecked + { + long h = 17; + for (int i = 0; i < px.Length; i += 3) + { + Color32 c = px[i]; + h = h * 31 + (c.r >> 3) * 1024 + (c.g >> 3) * 32 + (c.b >> 3); + } + return h.ToString(); + } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/OutfitPatternLibraryTests.cs.meta b/Assets/Tests/EditMode/OutfitPatternLibraryTests.cs.meta new file mode 100644 index 0000000..0ed51aa --- /dev/null +++ b/Assets/Tests/EditMode/OutfitPatternLibraryTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2b603c1abcc26d14c96f27533600151a \ No newline at end of file diff --git a/Assets/Tests/EditMode/OutfitShapeLibraryTests.cs b/Assets/Tests/EditMode/OutfitShapeLibraryTests.cs index 21f7a70..b9be38e 100644 --- a/Assets/Tests/EditMode/OutfitShapeLibraryTests.cs +++ b/Assets/Tests/EditMode/OutfitShapeLibraryTests.cs @@ -197,6 +197,139 @@ public void HatRecipes_HideBaseCapNodes() } } + /// + /// 정수리가 **열린** 모자(머리띠·마스크·바이저·꽃관). 여기 없는 모자 레시피는 전부 올림머리(HairCrown)를 + /// 숨겨야 한다 — 안 숨기면 스파이크·번이 헬멧·챙을 뚫고 나온다. 새 모자를 만들면 둘 중 하나를 **고르게** 한다. + /// + private static readonly HashSet OpenTopHats = new HashSet + { + "hat_flower", "hat_butterfly_wing", "hat_hero_mask", "hat_cyber_visor", + }; + + [Test] + public void HatRecipes_EitherHideTheHairCrownOrAreListedOpenTop() + { + Dictionary slots = CatalogSlots(); + int hats = 0; + foreach (string id in OutfitShapeLibrary.ExactRecipeIds()) + { + if (!slots.TryGetValue(id, out OutfitSlot slot) || slot != OutfitSlot.Hat) continue; + hats++; + Assert.IsTrue(OutfitShapeLibrary.TryGet(OutfitSlot.Hat, id, out OutfitRecipe r)); + bool hides = r.hideNodes != null && System.Array.IndexOf(r.hideNodes, OutfitShapeLibrary.HairCrownNode) >= 0; + if (OpenTopHats.Contains(id)) + Assert.IsFalse(hides, id + ": 정수리가 열린 모자인데 올림머리를 숨긴다"); + else + Assert.IsTrue(hides, id + ": 정수리를 덮는 모자인데 올림머리(HairCrown)를 안 숨긴다 — 머리가 모자를 뚫는다"); + } + Assert.Greater(hats, OpenTopHats.Count, "모자 레시피가 사라졌다"); + } + + // ── 몸 전체 형태(스타일) 표 ── + + /// + /// 스타일 표의 키는 실재하는 아이템이고, **그 표가 다루는 슬롯**이어야 한다. 오타면 반바지가 긴바지로, + /// 망토가 자켓으로 조용히 돌아간다(예외 없음). + /// + [Test] + public void StyleTables_NameRealItemsOfTheRightSlot() + { + Dictionary slots = CatalogSlots(); + foreach (string id in OutfitShapeLibrary.StyledItemIds()) + Assert.IsTrue(slots.ContainsKey(id), $"스타일 표의 '{id}'가 카탈로그에 없다"); + + foreach (KeyValuePair e in slots) + { + string id = e.Key; + if (OutfitShapeLibrary.SleeveOf(id) != SleeveLength.Short || OutfitShapeLibrary.SleeveIsSecondary(id)) + Assert.AreEqual(OutfitSlot.Top, e.Value, id + ": 소매 표는 상의 전용"); + if (OutfitShapeLibrary.OuterFormOf(id) != OuterForm.OpenJacket) + Assert.AreEqual(OutfitSlot.Outerwear, e.Value, id + ": 겉옷 형태 표는 겉옷 전용"); + if (OutfitShapeLibrary.LegFormOf(id) != LegForm.Long) + Assert.AreEqual(OutfitSlot.Bottom, e.Value, id + ": 하의 길이 표는 하의 전용"); + if (OutfitShapeLibrary.FootFormOf(id) != FootForm.Shoe) + Assert.AreEqual(OutfitSlot.Shoes, e.Value, id + ": 신발 형태 표는 신발 전용"); + } + } + + /// + /// 반바지·샌들은 다리·발을 피부로 칠하므로 **덧붙일 천·밑창 레시피가 반드시 있어야** 한다 — + /// 없으면 맨다리·맨발로 걷는다. + /// + [Test] + public void SkinRevealingForms_HaveTheirClothRecipes() + { + foreach (KeyValuePair e in CatalogSlots()) + { + if (OutfitShapeLibrary.LegFormOf(e.Key) == LegForm.Shorts) + Assert.IsTrue(OutfitShapeLibrary.TryGet(OutfitSlot.Bottom, e.Key, out _), e.Key + ": 반바지인데 천 레시피가 없다"); + if (OutfitShapeLibrary.FootFormOf(e.Key) == FootForm.Sandal) + Assert.IsTrue(OutfitShapeLibrary.TryGet(OutfitSlot.Shoes, e.Key, out _), e.Key + ": 샌들인데 밑창 레시피가 없다"); + } + } + + // ── 파츠별 앵커 ── + + [Test] + public void BelongsTo_SplitsDefaultAndExplicitAnchors() + { + OutfitPart plain = new OutfitPart { prim = PrimitiveType.Cube }; + OutfitPart sameAsRecipe = new OutfitPart { prim = PrimitiveType.Cube, hasAnchor = true, anchor = OutfitAnchor.Body }; + OutfitPart onLeg = new OutfitPart { prim = PrimitiveType.Cube, hasAnchor = true, anchor = OutfitAnchor.LegL }; + OutfitPart bind = new OutfitPart { bindName = "NetRing" }; + + Assert.IsTrue(OutfitShapeLibrary.BelongsTo(plain, OutfitAnchor.Body, OutfitAnchor.Body, true)); + Assert.IsTrue(OutfitShapeLibrary.BelongsTo(sameAsRecipe, OutfitAnchor.Body, OutfitAnchor.Body, true), "레시피 앵커와 같은 지정은 기본 컨테이너로"); + Assert.IsFalse(OutfitShapeLibrary.BelongsTo(sameAsRecipe, OutfitAnchor.Body, OutfitAnchor.Body, false), "같은 파츠가 두 번 만들어지면 안 된다"); + Assert.IsTrue(OutfitShapeLibrary.BelongsTo(onLeg, OutfitAnchor.Body, OutfitAnchor.LegL, false)); + Assert.IsFalse(OutfitShapeLibrary.BelongsTo(onLeg, OutfitAnchor.Body, OutfitAnchor.LegR, false)); + Assert.IsFalse(OutfitShapeLibrary.BelongsTo(onLeg, OutfitAnchor.Body, OutfitAnchor.Body, true)); + Assert.IsFalse(OutfitShapeLibrary.BelongsTo(bind, OutfitAnchor.Root, OutfitAnchor.Root, true), "bind는 컨테이너에 들어가지 않는다"); + } + + [Test] + public void Mirror_FlipsXAndSideRotations() + { + OutfitPart p = new OutfitPart { pos = new Vector3(0.1f, -0.2f, 0.05f), euler = new Vector3(10f, 20f, -30f) }; + OutfitPart m = OutfitShapeLibrary.Mirror(p); + Assert.AreEqual(new Vector3(-0.1f, -0.2f, 0.05f), m.pos); + Assert.AreEqual(new Vector3(10f, -20f, 30f), m.euler); + } + + /// + /// 다리에 다는 파츠는 **다리 관절 아래**에 가야 걸을 때 함께 흔들린다. 반바지를 가짜 계층에 입혀 + /// 양다리 컨테이너가 각 관절 아래 생기고, 왼다리 파츠가 거울 좌표인지 본다. + /// + [Test] + public void LegParts_SpawnUnderEachLegPivot_Mirrored() + { + GameObject root = new GameObject("AnchorProbe"); + try + { + GameObject body = new GameObject("Body"); body.transform.SetParent(root.transform, false); + GameObject legL = new GameObject("LegLPivot"); legL.transform.SetParent(root.transform, false); + GameObject legR = new GameObject("LegRPivot"); legR.transform.SetParent(root.transform, false); + + Assert.IsTrue(OutfitShapeLibrary.TryGet(OutfitSlot.Bottom, "bot_cargo", out OutfitRecipe cargo)); + OutfitShapeLibrary.Apply(root.transform, OutfitSlot.Bottom, cargo, Color.yellow, Color.white); + + Transform cL = legL.transform.Find(OutfitShapeLibrary.SpawnContainerName(OutfitSlot.Bottom, OutfitAnchor.LegL)); + Transform cR = legR.transform.Find(OutfitShapeLibrary.SpawnContainerName(OutfitSlot.Bottom, OutfitAnchor.LegR)); + Assert.IsNotNull(cL, "왼다리 관절 아래 컨테이너가 없다"); + Assert.IsNotNull(cR, "오른다리 관절 아래 컨테이너가 없다"); + Assert.AreEqual(cR.childCount, cL.childCount); + Assert.Greater(cR.childCount, 0); + Assert.AreEqual(-cR.GetChild(0).localPosition.x, cL.GetChild(0).localPosition.x, 1e-5f, "왼다리가 거울 좌표가 아니다"); + Assert.Greater(cR.GetChild(0).localPosition.x, 0f, "오른다리 기준 좌표는 바깥이 +X다"); + Assert.IsNull(root.transform.Find(OutfitShapeLibrary.SpawnContainerName(OutfitSlot.Bottom)), + "다리 파츠만 있는 레시피가 루트에 빈 컨테이너를 만들었다"); + } + finally + { + Object.DestroyImmediate(root); + } + } + [Test] public void HatRecipes_AnchorToHatRoot() { diff --git a/Assets/Tests/EditMode/OutfitShapeParityTests.cs b/Assets/Tests/EditMode/OutfitShapeParityTests.cs index 7ab6c60..54f96b7 100644 --- a/Assets/Tests/EditMode/OutfitShapeParityTests.cs +++ b/Assets/Tests/EditMode/OutfitShapeParityTests.cs @@ -1,4 +1,4 @@ -#if UNITY_EDITOR +#if UNITY_EDITOR using InsectGame.Core; using NUnit.Framework; using UnityEngine; @@ -25,7 +25,7 @@ public class OutfitShapeParityTests [TestCase("tool_wand", "NetRing", PrimitiveType.Sphere, 0.37f, 1.10f, 0.05f, 0.10f, 0.10f, 0.10f, 0f, 0f, 0f)] // 올가미 — 고리의 X축 -20°는 edge-on collapse 방지값이라 특히 중요하다 [TestCase("tool_lasso", "NetHandle", PrimitiveType.Cylinder, 0.29f, 0.65f, 0f, 0.04f, 0.25f, 0.04f, 20f, 0f, -12f)] - [TestCase("tool_lasso", "NetRing", PrimitiveType.Cylinder, 0.35f, 0.94f, 0.06f, 0.28f, 0.02f, 0.28f, -20f, 0f, 0f)] + [TestCase("tool_lasso", "NetRing", PrimitiveType.Cylinder, 0.35f, 0.94f, 0.06f, 0.28f, 0.28f, 0.28f, -20f, 0f, 0f)] // 2026-09-30 토러스 고리(스케일 균일) // 수리검 [TestCase("tool_shuriken", "NetHandle", PrimitiveType.Cube, 0.29f, 0.52f, 0.10f, 0.18f, 0.02f, 0.05f, 0f, 45f, 0f)] [TestCase("tool_shuriken", "NetRing", PrimitiveType.Cube, 0.29f, 0.52f, 0.10f, 0.05f, 0.02f, 0.18f, 0f, 45f, 0f)] @@ -42,8 +42,8 @@ public class OutfitShapeParityTests [TestCase("tool_camera", "NetHandle", PrimitiveType.Cube, 0.29f, 0.57f, 0.18f, 0.16f, 0.10f, 0.10f, 0f, 0f, 0f)] [TestCase("tool_camera", "NetRing", PrimitiveType.Cylinder, 0.29f, 0.57f, 0.26f, 0.07f, 0.07f, 0.06f, 90f, 0f, 0f)] // 기본 잠자리채(else) — PlayerVisualBuilder의 NetHandle/NetRing 초기 좌표와도 일치해야 한다 - [TestCase("tool_net", "NetHandle", PrimitiveType.Cylinder, 0.29f, 0.74f, 0.02f, 0.04f, 0.40f, 0.04f, 20f, 0f, -15f)] - [TestCase("tool_net", "NetRing", PrimitiveType.Cylinder, 0.34f, 1.14f, 0.06f, 0.20f, 0.02f, 0.20f, -20f, 0f, 0f)] + [TestCase("tool_net", "NetHandle", PrimitiveType.Cylinder, 0.29f, 0.74f, 0f, 0.04f, 0.40f, 0.04f, 0f, 0f, 0f)] + [TestCase("tool_net", "NetRing", PrimitiveType.Cylinder, 0.29f, 1.14f, 0f, 0.22f, 0.22f, 0.22f, -25f, 0f, 0f)] // 2026-09-30 세운 테 + 그물(NetHead) public void ToolRecipe_Branch_MatchesLegacyTransform( string itemId, string bindName, PrimitiveType prim, float px, float py, float pz, float sx, float sy, float sz, float ex, float ey, float ez) diff --git a/Assets/Tests/EditMode/OutfitUnlockConditionTextTests.cs b/Assets/Tests/EditMode/OutfitUnlockConditionTextTests.cs index 2052ce3..1cb4d59 100644 --- a/Assets/Tests/EditMode/OutfitUnlockConditionTextTests.cs +++ b/Assets/Tests/EditMode/OutfitUnlockConditionTextTests.cs @@ -15,8 +15,7 @@ namespace InsectGame.Tests /// 토큰인데, 한때 그 값이 카드에 **그대로** 그려져 한국어 게임에 영문 식별자가 노출됐다. /// 이 문장으로 바꾼다. /// - /// 해금 판정은 여기 범위가 아니다 — 저장소에 조건을 평가해 소유를 부여하는 코드가 - /// 아직 없다(별건). 이 테스트는 표시만 본다. + /// 해금 판정은 여기 범위가 아니다 — OutfitUnlockRulesTests가 본다. 이 테스트는 표시만 본다. /// [TestFixture] public class OutfitUnlockConditionTextTests diff --git a/Assets/Tests/EditMode/OutfitUnlockRulesTests.cs b/Assets/Tests/EditMode/OutfitUnlockRulesTests.cs new file mode 100644 index 0000000..9f22e06 --- /dev/null +++ b/Assets/Tests/EditMode/OutfitUnlockRulesTests.cs @@ -0,0 +1,141 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.IO; +using NUnit.Framework; +using InsectGame.Core; +using InsectGame.Data; + +namespace InsectGame.Tests +{ + /// + /// 조건부 의상 해금 판정(). + /// + /// 판정기가 생기기 전에는 카탈로그의 조건 4개가 문구로만 존재했다 — 꽃 왕관·연구원 가운·장화· + /// 곤충박사 배지를 얻을 방법이 없었고, 연구원 세트는 완성이 불가능했다. 여기서는 판정식과 + /// **카탈로그의 조건이 실제로 도달 가능한가**(리전·퀘스트가 실재하는가)를 함께 고정한다. + /// 오타 하나면 예외도 경고도 없이 그 의상이 다시 영영 잠긴다. + /// + [TestFixture] + public class OutfitUnlockRulesTests + { + private static bool NoQuests(string _) => false; + + [Test] + public void Region_MetOnlyWhenStandingThere() + { + Assert.IsTrue(OutfitUnlockRules.IsMet("region_garden", "garden", 1, NoQuests)); + Assert.IsFalse(OutfitUnlockRules.IsMet("region_garden", "meadow", 99, NoQuests)); + Assert.IsFalse(OutfitUnlockRules.IsMet("region_garden", null, 99, NoQuests)); + } + + [Test] + public void Level_MetAtOrAboveThreshold() + { + Assert.IsFalse(OutfitUnlockRules.IsMet("level_15", null, 14, NoQuests)); + Assert.IsTrue(OutfitUnlockRules.IsMet("level_15", null, 15, NoQuests)); + Assert.IsTrue(OutfitUnlockRules.IsMet("level_15", null, 40, NoQuests)); + } + + [Test] + public void Quest_AsksTheQuestIdWithoutPrefix() + { + string asked = null; + bool met = OutfitUnlockRules.IsMet("quest_q_complete", null, 1, id => { asked = id; return true; }); + + Assert.IsTrue(met); + Assert.AreEqual("q_complete", asked, "접두사를 떼고 물어야 한다"); + Assert.IsFalse(OutfitUnlockRules.IsMet("quest_q_complete", null, 1, null), "퀘스트 출처가 없으면 거짓"); + } + + /// 잘못 적은 토큰이 의상을 공짜로 풀어 주면 안 된다. + [Test] + public void UnknownOrMalformed_IsNeverMet() + { + Assert.IsFalse(OutfitUnlockRules.IsMet("", "garden", 99, _ => true)); + Assert.IsFalse(OutfitUnlockRules.IsMet(null, "garden", 99, _ => true)); + Assert.IsFalse(OutfitUnlockRules.IsMet("wat_garden", "garden", 99, _ => true)); + Assert.IsFalse(OutfitUnlockRules.IsMet("level_abc", null, 99, _ => true)); + Assert.IsFalse(OutfitUnlockRules.IsMet("level_0", null, 99, _ => true)); + Assert.IsFalse(OutfitUnlockRules.IsMet("region_", "", 99, _ => true)); + Assert.IsFalse(OutfitUnlockRules.IsMet("quest_", null, 99, _ => true)); + } + + // ── 카탈로그 정합 ── + + private static List ConditionalItems() + { + List list = new List(); + foreach (OutfitItem item in CharacterOutfitManager.BuildCatalog()) + if (!string.IsNullOrEmpty(item.unlockCondition)) list.Add(item); + return list; + } + + [Test] + public void Catalog_EveryConditionIsAKnownFormat() + { + List items = ConditionalItems(); + Assert.Greater(items.Count, 0, "조건부 의상이 사라졌다 — 카탈로그가 바뀌었으면 이 테스트도 확인할 것"); + foreach (OutfitItem item in items) + Assert.IsTrue(OutfitUnlockRules.IsKnownFormat(item.unlockCondition), + $"{item.itemId}: 판정기가 모르는 조건 '{item.unlockCondition}' — 영영 안 풀린다"); + } + + /// + /// 조건부 의상은 가격이 없어야 한다 — 가격이 있으면 카드가 구매 버튼을 그리고 잠김 문구는 안 뜬다 + /// (CharacterOutfitUI의 분기 순서가 보석 → 코인 → 조건이다). 둘 다면 조건이 장식이 된다. + /// + [Test] + public void Catalog_ConditionalItemsAreNotForSale() + { + foreach (OutfitItem item in ConditionalItems()) + { + Assert.AreEqual(0, item.price, $"{item.itemId}: 조건부인데 코인 가격이 있다"); + Assert.AreEqual(0, item.gemPrice, $"{item.itemId}: 조건부인데 보석 가격이 있다"); + Assert.IsFalse(item.unlockedByDefault, $"{item.itemId}: 조건부인데 기본 보유다"); + } + } + + [Test] + public void Catalog_RegionConditionsNameRealRegions() + { + HashSet regions = new HashSet(); + foreach (RegionData r in RegionDefinitions.CreateAll()) + if (r != null && !string.IsNullOrEmpty(r.regionId)) regions.Add(r.regionId); + + foreach (OutfitItem item in ConditionalItems()) + { + if (!item.unlockCondition.StartsWith(OutfitUnlockRules.RegionPrefix)) continue; + string id = item.unlockCondition.Substring(OutfitUnlockRules.RegionPrefix.Length); + Assert.IsTrue(regions.Contains(id), $"{item.itemId}: 리전 '{id}'이 없다 — 도달 불가"); + } + } + + [Test] + public void Catalog_LevelConditionsAreReachable() + { + foreach (OutfitItem item in ConditionalItems()) + { + if (!OutfitUnlockRules.TryParseLevel(item.unlockCondition, out int lv)) continue; + // PlayerProgressController.maxLevel(80)을 넘으면 영영 안 풀린다. + Assert.LessOrEqual(lv, 80, $"{item.itemId}: 만렙보다 높은 레벨 조건"); + } + } + + /// + /// 퀘스트는 TutorialQuestManager.Initialize의 코드 배열에만 있다(씬 없이는 못 만든다) — + /// quest_lint와 같은 방식으로 소스에서 questId를 읽는다. + /// + [Test] + public void Catalog_QuestConditionsNameRealQuests() + { + string src = File.ReadAllText("Assets/Scripts/Core/TutorialQuestManager.cs"); + foreach (OutfitItem item in ConditionalItems()) + { + if (!item.unlockCondition.StartsWith(OutfitUnlockRules.QuestPrefix)) continue; + string id = item.unlockCondition.Substring(OutfitUnlockRules.QuestPrefix.Length); + StringAssert.Contains($"questId = \"{id}\"", src, $"{item.itemId}: 퀘스트 '{id}'가 없다 — 도달 불가"); + } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/OutfitUnlockRulesTests.cs.meta b/Assets/Tests/EditMode/OutfitUnlockRulesTests.cs.meta new file mode 100644 index 0000000..189576b --- /dev/null +++ b/Assets/Tests/EditMode/OutfitUnlockRulesTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 50a322d331df0fe4aa8fe02bf4856bcd \ No newline at end of file diff --git a/Assets/Tests/EditMode/OutfitWindowTests.cs b/Assets/Tests/EditMode/OutfitWindowTests.cs new file mode 100644 index 0000000..3345638 --- /dev/null +++ b/Assets/Tests/EditMode/OutfitWindowTests.cs @@ -0,0 +1,203 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 의상 창·2D 초상의 순수 계산부(2026-09-30 4단계). 그리기 자체(IMGUI)는 테스트 제외라 + /// 검수 빌드 -battleScenario outfit 캡처로 본다. + /// + [TestFixture] + public class OutfitWindowTests + { + /// + /// 상세 패널은 보너스를 **전부** 보여 준다. 예전 카드는 대표 1개만, 상세 툴팁은 이미 가진 옷에만 떴다 — + /// 사기 전에 무엇이 좋은지 알 수 없었다. + /// + [Test] + public void FullBonusText_ListsEveryNonZeroBonus() + { + OutfitStatBonus b = new OutfitStatBonus { captureChanceBonus = 0.02f, expMultiplier = 0.03f, defBonus = 0.01f }; + string text = CharacterOutfitUI.FullBonusText(b); + StringAssert.Contains("포획 +2%", text); + StringAssert.Contains("DEF +1%", text); + StringAssert.Contains("경험치 +3%", text); + StringAssert.DoesNotContain("ATK", text, "0인 보너스를 적었다"); + Assert.AreEqual("보너스 없음", CharacterOutfitUI.FullBonusText(default)); + } + + [Test] + public void Filter_SplitsOwnedAndNotOwned() + { + Assert.IsTrue(CharacterOutfitUI.PassesFilter(CharacterOutfitUI.ItemFilter.All, true)); + Assert.IsTrue(CharacterOutfitUI.PassesFilter(CharacterOutfitUI.ItemFilter.All, false)); + Assert.IsTrue(CharacterOutfitUI.PassesFilter(CharacterOutfitUI.ItemFilter.Owned, true)); + Assert.IsFalse(CharacterOutfitUI.PassesFilter(CharacterOutfitUI.ItemFilter.Owned, false)); + Assert.IsTrue(CharacterOutfitUI.PassesFilter(CharacterOutfitUI.ItemFilter.NotOwned, false)); + Assert.IsFalse(CharacterOutfitUI.PassesFilter(CharacterOutfitUI.ItemFilter.NotOwned, true)); + } + + /// + /// 모든 세트 구성품이 카드에서 세트를 찾아야 한다 — 예전 세트 점은 이미 한 벌 이상 입은 세트에만 찍혀, + /// 시작하지 않은 세트는 무엇이 한 벌인지 알 수 없었다. 세트 구성품 id는 실재해야 한다(오타면 표시가 조용히 빠진다). + /// + [Test] + public void EverySetMember_IsFoundByTheCard_AndExists() + { + HashSet catalog = new HashSet(); + foreach (OutfitItem item in CharacterOutfitManager.BuildCatalog()) catalog.Add(item.itemId); + + foreach (OutfitSetDefinition set in OutfitSetCatalog.GetAllSets()) + { + foreach (string id in set.requiredItemIds) + { + Assert.IsTrue(catalog.Contains(id), $"{set.setId}: 구성품 '{id}'가 카탈로그에 없다"); + Assert.IsNotNull(CharacterOutfitUI.SetOf(id), $"{id}: 세트 표시가 안 뜬다"); + } + } + Assert.IsNull(CharacterOutfitUI.SetOf("top_shirt"), "세트가 아닌 옷에 세트 표시가 뜬다"); + Assert.IsNull(CharacterOutfitUI.SetOf(null)); + } + + /// + /// 2D 초상(상점 폴백·NPC 대사)의 입은 3D와 **같은 표정 모양**이어야 한다. 예전 2D는 폭이 표정 번호에 비례해 + /// 무표정이 가장 컸고(3D와 반대), 모양 차이는 없었다. + /// + [Test] + public void MouthShapes_2DMatches3D() + { + for (int f = 0; f < 4; f++) + { + PlayerVisualBuilder.MouthShape(f, out PlayerVisualBuilder.MouthKind k3, out Vector2 size3, out _); + CharacterPortraitRenderer.MouthShape2D(f, out CharacterPortraitRenderer.Mouth2D k2, out float w2, out _); + Assert.AreEqual(k3.ToString(), k2.ToString(), $"표정 {f}: 모양이 다르다"); + } + + // 폭 순서: 활짝 ≥ 미소 > 차분 > 무표정 — 3D와 2D가 같은 순서 + float[] w3 = new float[4], w2d = new float[4]; + for (int f = 0; f < 4; f++) + { + PlayerVisualBuilder.MouthShape(f, out _, out Vector2 s, out _); + CharacterPortraitRenderer.MouthShape2D(f, out _, out float w, out _); + w3[f] = s.x; w2d[f] = w; + } + for (int a = 0; a < 4; a++) + for (int b = 0; b < 4; b++) + if (w3[a] > w3[b] + 1e-4f) Assert.Greater(w2d[a], w2d[b], $"표정 {a}·{b}: 2D 입 크기 순서가 3D와 반대다"); + } + + /// 데스크톱 미리보기 칸(560×730)은 예전 비율 그대로다 — 그림 514 · 상세 200. + [Test] + public void PreviewStage_Desktop_KeepsOldSplit() + { + float stage = CharacterOutfitUI.PreviewStageHeight(560f, 730f, false); + Assert.AreEqual(514f, stage, 0.01f); + Assert.AreEqual(200f, 730f - 16f - stage, 0.01f, "상세 높이가 바뀌었다"); + } + + /// + /// 세로 화면의 긴 칸(460×1258)에서 그림은 텍스처 비율까지만 커지고 나머지는 상세가 받는다. + /// 옛 레이아웃은 창이 820 고정이라 세로 화면(1920)의 가운데 43%만 썼다(2026-09-30 검수 캡처). + /// + [Test] + public void PreviewStage_TallColumn_CapsPictureAtTextureAspect() + { + float w = 460f, h = 1258f; + float stage = CharacterOutfitUI.PreviewStageHeight(w, h, true); + Assert.AreEqual((w - 16f) * CharacterModelPreviewRenderer.PreviewHeightPerWidth, stage, 0.01f, + "그림이 텍스처 비율보다 길면 위아래가 빈 여백이다"); + Assert.Greater(h - 16f - stage, 150f + 110f, "상세가 설명(110)까지 받을 높이를 못 얻었다"); + } + + [Test] + public void PreviewStage_ShortColumn_KeepsDetailMinimum() + { + float stage = CharacterOutfitUI.PreviewStageHeight(460f, 400f, true); + Assert.AreEqual(400f - 16f - 150f, stage, 0.01f); + Assert.AreEqual(1f, CharacterOutfitUI.PreviewStageHeight(460f, 50f, true), "음수 높이"); + } + + /// + /// 데스크톱 캐시샵의 오른쪽 콘텐츠(패널 − 캐릭터 칸 − 세로 스크롤바)에 상자 세 장이 들어가야 한다. + /// 패널 1200이던 때 812뿐이라 골드 상자가 잘리고 가로 스크롤이 생겼다(2026-09-30 검수 캡처). + /// + [Test] + public void CashShopDesktop_ThreeCardsFitWithoutSideScroll() + { + const float VerticalScrollbar = 20f; + float content = CashShopUI.DesktopPanelWidth - CashShopUI.DesktopCharColumnWidth - VerticalScrollbar; + Assert.GreaterOrEqual(content, CashShopUI.DesktopBoxTabMinWidth, "상자 세 장이 안 들어간다"); + Assert.GreaterOrEqual(content, CashShopUI.DesktopCardTabMinWidth, "아이템 카드 세 장이 안 들어간다"); + Assert.LessOrEqual(CashShopUI.DesktopPanelWidth, UIScale.ReferenceWidth - 48f, "패널이 데스크톱 화면보다 넓다"); + } + + /// 하단 "장비 보너스" 줄은 모든 보너스를 적고, 장착이 같으면 문자열을 다시 만들지 않는다. + [Test] + public void EquippedSummary_ListsBonuses_AndIsCachedUntilChanged() + { + GameObject go = new GameObject("OutfitUITest"); + try + { + CharacterOutfitUI ui = go.AddComponent(); + OutfitStatBonus a = new OutfitStatBonus { captureChanceBonus = 0.02f, rareSpawnBonus = 0.05f }; + string first = ui.EquippedSummaryText(a); + StringAssert.Contains("포획+2%", first); + StringAssert.Contains("레어+5%", first); + Assert.AreSame(first, ui.EquippedSummaryText(a), "같은 보너스인데 문자열을 다시 만들었다"); + + OutfitStatBonus b = a; + b.defBonus = 0.01f; + string second = ui.EquippedSummaryText(b); + StringAssert.Contains("DEF+1%", second, "보너스가 바뀌었는데 옛 문자열이 남았다"); + } + finally + { + Object.DestroyImmediate(go); + } + } + + /// + /// 넘치는 요약 줄은 항목 경계(공백)에서 나눈다 — IMGUI 줄바꿈은 한글 사이 아무 데서나 끊어 + /// "캔디 / +2%"처럼 항목을 갈랐다(검수 캡처). + /// + [Test] + public void SplitAtMiddleSpace_BreaksBetweenEntries() + { + string text = "장비 보너스: 포획+2% ATK+2% DEF+2% 이속+5% 캔디+2%"; + string split = CharacterOutfitUI.SplitAtMiddleSpace(text); + string[] lines = split.Split('\n'); + Assert.AreEqual(2, lines.Length); + foreach (string entry in new[] { "포획+2%", "ATK+2%", "DEF+2%", "이속+5%", "캔디+2%" }) + Assert.IsTrue(lines[0].Contains(entry) || lines[1].Contains(entry), $"'{entry}'가 두 줄에 갈렸다"); + Assert.AreEqual(text, split.Replace('\n', ' '), "공백 하나만 줄바꿈으로 바뀌어야 한다"); + Assert.AreEqual("공백없음", CharacterOutfitUI.SplitAtMiddleSpace("공백없음")); + } + + /// + /// 재화·가격 라벨에 보조 평면 이모지(💎·🪙 등)를 쓰지 않는다 — 스탠드얼론·기기 기본 폰트에 없어 □로 깨졌다 + /// (2026-09-30 검수 빌드 캡처). 주석의 이모지는 괜찮다. + /// + [TestCase("Assets/Scripts/UI/CashShopUI.cs")] + [TestCase("Assets/Scripts/UI/CharacterOutfitUI.cs")] + public void ShopLabels_HaveNoSupplementaryEmoji(string relativePath) + { + string full = System.IO.Path.Combine(Application.dataPath, "..", relativePath); + Assert.IsTrue(System.IO.File.Exists(full), $"소스를 못 찾음: {relativePath}"); + string[] lines = System.IO.File.ReadAllLines(full); + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i]; + int comment = line.IndexOf("//", System.StringComparison.Ordinal); + string code = comment >= 0 ? line.Substring(0, comment) : line; + foreach (char c in code) + Assert.IsFalse(char.IsSurrogate(c), $"{relativePath}:{i + 1} 코드에 이모지가 있다 — □로 깨진다: {line.Trim()}"); + } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/OutfitWindowTests.cs.meta b/Assets/Tests/EditMode/OutfitWindowTests.cs.meta new file mode 100644 index 0000000..af94c9d --- /dev/null +++ b/Assets/Tests/EditMode/OutfitWindowTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d0708b03d6574a84ba6994e871b9cd7c \ No newline at end of file diff --git a/Assets/Tests/EditMode/OutpostFacilityTests.cs b/Assets/Tests/EditMode/OutpostFacilityTests.cs index 2ffd86b..28ff823 100644 --- a/Assets/Tests/EditMode/OutpostFacilityTests.cs +++ b/Assets/Tests/EditMode/OutpostFacilityTests.cs @@ -34,6 +34,80 @@ public void Cleanup() if (host != null) Object.DestroyImmediate(host); } + [Test] + public void Houses_HavePitchedClosedRoofs() + { + Transform main = GameObject.Find("Village/MainVillage").transform; + for (int i = 1; i <= 5; i++) + { + Transform house = main.Find("House_" + i); + Transform right = house.Find("RoofR"); + Transform left = house.Find("RoofL"); + Assert.Greater(right.TransformPoint(Vector3.left * 0.5f).y, + right.TransformPoint(Vector3.right * 0.5f).y); + Assert.Greater(left.TransformPoint(Vector3.right * 0.5f).y, + left.TransformPoint(Vector3.left * 0.5f).y); + Assert.Less(Mathf.Abs(house.Find("RoofRidge").position.y - + right.TransformPoint(Vector3.left * 0.5f).y), 0.15f); + Assert.IsNotNull(house.Find("Gable").GetComponent().sharedMesh); + } + } + + [Test] + public void VillageDoors_HaveNonBlockingPathsToPlaza() + { + Transform main = GameObject.Find("Village/MainVillage").transform; + Transform paths = main.Find("Walkways"); + Assert.IsNotNull(paths); + foreach (Transform building in main) + { + Transform door = building.Find("Door"); + if (door == null) continue; + Transform path = paths.Find("Access_" + building.name); + Assert.IsNotNull(path, building.name); + Vector3 end = main.InverseTransformPoint(path.TransformPoint(Vector3.forward * 5f)); + end.y = 0f; + Assert.Less(end.magnitude, 8f, building.name); + } + foreach (Collider collider in paths.GetComponentsInChildren()) + Assert.IsFalse(collider.enabled, "장식 길이 첫 프레임에 이동을 막으면 안 된다"); + } + + [Test] + public void EntranceWalkways_ClearBuildingWalls() + { + Transform main = GameObject.Find("Village/MainVillage").transform; + for (int i = 0; i < 3; i++) + { + Transform path = main.Find("Walkways/Entrance_" + i); + for (int sample = 0; sample <= 40; sample++) + { + Vector3 point = path.TransformPoint(new Vector3(0f, 0f, -5f + sample * 0.25f)); + foreach (Transform building in main) + { + Transform wall = building.Find("Wall"); + if (wall == null) continue; + Vector3 local = wall.InverseTransformPoint(point); + float dx = Mathf.Max(0f, Mathf.Abs(local.x) - 0.5f) * wall.lossyScale.x; + float dz = Mathf.Max(0f, Mathf.Abs(local.z) - 0.5f) * wall.lossyScale.z; + bool overlaps = dx * dx + dz * dz < 0.7f * 0.7f; + Assert.IsFalse(overlaps, path.name + " intersects " + building.name); + } + } + } + } + + [Test] + public void CabinLogs_LeaveDoorwayVisible() + { + foreach (Transform part in GameObject.Find("Village").GetComponentsInChildren()) + { + if (!part.name.StartsWith("FrontLog")) continue; + Assert.GreaterOrEqual(Mathf.Abs(part.localPosition.x) - part.localScale.y, 0.5f, + "문 정면을 가로 통나무가 덮으면 안 된다"); + } + } + private InteractionPointDef Find(string id) { foreach (InteractionPointDef p in result.interactions) diff --git a/Assets/Tests/EditMode/PlayerVisualRebuildTests.cs b/Assets/Tests/EditMode/PlayerVisualRebuildTests.cs index bcbed8a..57a909a 100644 --- a/Assets/Tests/EditMode/PlayerVisualRebuildTests.cs +++ b/Assets/Tests/EditMode/PlayerVisualRebuildTests.cs @@ -1,4 +1,4 @@ -#if UNITY_EDITOR +#if UNITY_EDITOR using InsectGame.Core; using NUnit.Framework; using UnityEngine; @@ -30,6 +30,33 @@ public void TearDown() if (player != null) Object.DestroyImmediate(player); } + [TestCase(-72f)] + [TestCase(0f)] + [TestCase(72f)] + public void RotateAttachment_HandAndNet_PreserveGripDistance(float angle) + { + Vector3 pivot = new Vector3(0.29f, 0.78f, 0f); + Vector3 hand = new Vector3(0.29f, 0.52f, 0f); + Vector3 handle = new Vector3(0.29f, 0.74f, 0f); + Vector3 movedHand = PlayerVisualBuilder.RotateAttachment(hand, pivot, angle); + Vector3 movedHandle = PlayerVisualBuilder.RotateAttachment(handle, pivot, angle); + Assert.AreEqual(Vector3.Distance(hand, handle), Vector3.Distance(movedHand, movedHandle), 0.0001f); + Vector3 restored = PlayerVisualBuilder.RotateAttachment(movedHand, pivot, -angle); + Assert.Less(Vector3.Distance(hand, restored), 0.0001f); + } + + [Test] + public void DefaultNet_HandlePassesThroughHand_AndRingMeetsTip() + { + Transform hand = player.transform.Find("HandR"); + Transform handle = player.transform.Find("NetHandle"); + Transform ring = player.transform.Find("NetRing"); + Vector3 tip = handle.localPosition + handle.localRotation * Vector3.up * handle.localScale.y; + Assert.Less(Vector3.Distance(tip, ring.localPosition), 0.001f); + Assert.AreEqual(hand.localPosition.x, handle.localPosition.x, 0.001f); + Assert.AreEqual(hand.localPosition.z, handle.localPosition.z, 0.001f); + } + [Test] public void RebuildFromPrefs_KeepsForeignChildren_ReplacesBuiltNodes() { diff --git a/Assets/Tests/EditMode/ProcMeshLibraryTests.cs b/Assets/Tests/EditMode/ProcMeshLibraryTests.cs index db21247..f7b1666 100644 --- a/Assets/Tests/EditMode/ProcMeshLibraryTests.cs +++ b/Assets/Tests/EditMode/ProcMeshLibraryTests.cs @@ -395,6 +395,196 @@ public void Cache_DifferentShapesWithSameNumbers_DoNotCollide() Assert.AreNotSame(disc, sphere); } + + // ── 2026-09-30 캐릭터 본체 개편에서 늘어난 생성기 ── + + private static Mesh Profiled() => ProcMeshLibrary.ProfiledRoundedBox(new Vector3(0.471f, 0.451f, 0.371f), 0.08f, 4, 0.9f, 0.85f, 1f); + private static Mesh SmileArc() => ProcMeshLibrary.Arc(0.51f, 0.23f, 205f, 335f, 10); + private static Mesh LidArcReversed() => ProcMeshLibrary.Arc(0.51f, 0.19f, 172f, 35f, 10); // 끝각이 더 작은 방향 + private static Mesh OpenMouth() => ProcMeshLibrary.Sector(0.51f, 1.01f, 180f, 360f, 12); + private static Mesh Fringe(bool rounded) => + ProcMeshLibrary.FringeShell(0.371f, 0.361f, 55f, 61f, 18f, 12f, 6f, 5, rounded ? 0.5f : 0.8f, rounded); + + /// 새 생성기도 와인딩·유한성·단위 노멀 세 규약을 그대로 지킨다(평면 생성기는 면 노멀 ↔ 정점 노멀 기준). + [Test] + public void NewGenerators_KeepWindingFiniteAndUnitNormals() + { + Mesh[] meshes = { Profiled(), SmileArc(), LidArcReversed(), OpenMouth(), Fringe(false), Fringe(true) }; + string[] labels = { "ProfiledRoundedBox", "Arc", "Arc(역방향)", "Sector", "FringeShell(톱니)", "FringeShell(물결)" }; + for (int i = 0; i < meshes.Length; i++) + { + AssertWindingMatchesNormals(meshes[i], labels[i]); + AssertFinite(meshes[i], labels[i]); + AssertUnitNormals(meshes[i], labels[i]); + } + AssertOutwardWinding(Profiled(), "ProfiledRoundedBox"); + } + + /// + /// 프로필 상자는 X만 누른다 — Z(두께)가 변하면 몸통 앞면에 붙인 셔츠 패널·배지가 아래쪽에서 떠 보인다. + /// 위·아래 폭은 요청한 배율을 따른다. + /// + [Test] + public void ProfiledRoundedBox_ScalesWidthOnly() + { + Mesh m = Profiled(); + Bounds b = m.bounds; + Assert.AreEqual(0.371f, b.size.z, 0.002f, "두께가 변했다"); + Assert.AreEqual(0.451f, b.size.y, 0.002f, "높이가 변했다"); + + float topHalf = 0f, bottomHalf = 0f; + foreach (Vector3 v in m.vertices) + { + if (v.y > b.max.y - 0.09f) topHalf = Mathf.Max(topHalf, Mathf.Abs(v.x)); + if (v.y < b.min.y + 0.09f) bottomHalf = Mathf.Max(bottomHalf, Mathf.Abs(v.x)); + } + Assert.Greater(topHalf, bottomHalf, "위(배율 1)가 아래(0.9)보다 넓어야 한다"); + } + + [Test] + public void ProfileScale_PassesThroughItsThreeControlPoints() + { + Assert.AreEqual(0.9f, ProcMeshLibrary.ProfileScale(0f, 0.9f, 0.8f, 1f), 1e-5f); + Assert.AreEqual(0.8f, ProcMeshLibrary.ProfileScale(0.5f, 0.9f, 0.8f, 1f), 1e-5f); + Assert.AreEqual(1f, ProcMeshLibrary.ProfileScale(1f, 0.9f, 0.8f, 1f), 1e-5f); + } + + /// + /// 오프셋 캡슐은 모양은 같고 위치만 민다 — 팔 원점을 어깨로 옮겨도 쉬는 자세의 팔 모양이 같아야 한다. + /// 오프셋 0은 기존 캐시 키 그대로다(기존 메시를 쓰는 곳이 바뀌지 않는다). + /// + [Test] + public void TaperedCapsule_Offset_TranslatesWithoutReshaping() + { + Mesh plain = ProcMeshLibrary.TaperedCapsule(0.052f, 0.037f, 0.113f, 8, 10); + Mesh shifted = ProcMeshLibrary.TaperedCapsule(0.052f, 0.037f, 0.113f, 8, 10, -0.0565f); + + Assert.AreSame(plain, ProcMeshLibrary.TaperedCapsule(0.052f, 0.037f, 0.113f, 8, 10, 0f)); + Assert.AreEqual(plain.bounds.size.x, shifted.bounds.size.x, 1e-5f); + Assert.AreEqual(plain.bounds.size.y, shifted.bounds.size.y, 1e-5f); + Assert.AreEqual(plain.bounds.center.y - 0.0565f, shifted.bounds.center.y, 1e-5f); + } + + /// + /// 앞머리 끝모양: 톱니 끝은 가장 낮고(tipDeg), 뿌리는 그보다 toothDeg 위다. 양 끝(가장자리)은 뿌리다 — + /// 끝이 가장자리에 걸리면 관자놀이에서 가닥이 잘린 것처럼 보인다. 옆 처짐은 가운데엔 없다. + /// + [Test] + public void FringeEdge_TipsRootsAndSideDrop() + { + const int teeth = 4; + float tip = 17f, tooth = 19f; + + // 대칭 톱니(skew 0.5): 첫 톱니 끝은 u = 0.5/teeth + Assert.AreEqual(tip, ProcMeshLibrary.FringeEdgeDeg(0.5f / teeth, tip, tooth, 0f, teeth, 0.5f, false), 1e-3f); + Assert.AreEqual(tip + tooth, ProcMeshLibrary.FringeEdgeDeg(0f, tip, tooth, 0f, teeth, 0.5f, false), 1e-3f); + Assert.AreEqual(tip + tooth, ProcMeshLibrary.FringeEdgeDeg(1f, tip, tooth, 0f, teeth, 0.5f, false), 1e-3f, + "오른쪽 끝도 뿌리여야 한다"); + Assert.AreEqual(tip + tooth, ProcMeshLibrary.FringeEdgeDeg(1f, tip, tooth, 0f, teeth, 0.5f, true), 1e-3f, + "물결도 가장자리는 뿌리"); + + // 옆 처짐: 가운데는 그대로, 가장자리는 처짐만큼 내려간다. + float mid = ProcMeshLibrary.FringeEdgeDeg(0.5f, tip, tooth, 10f, teeth, 0.5f, false); + float midNoDrop = ProcMeshLibrary.FringeEdgeDeg(0.5f, tip, tooth, 0f, teeth, 0.5f, false); + Assert.AreEqual(midNoDrop, mid, 1e-3f); + Assert.AreEqual(tip + tooth - 10f, ProcMeshLibrary.FringeEdgeDeg(0f, tip, tooth, 10f, teeth, 0.5f, false), 1e-3f); + } + + // ── 3단계(의상 형태)에서 늘어난 생성기 ── + + [Test] + public void ClothAndRingGenerators_KeepWindingFiniteAndUnitNormals() + { + Mesh[] meshes = + { + ProcMeshLibrary.DrapeShell(201f, 1.31f, 4, 0.021f), + ProcMeshLibrary.DrapeShell(359f, 1.37f, 6, 0.011f, 3), + ProcMeshLibrary.Torus(0.061f, 24, 8), + ProcMeshLibrary.NetHead(0.051f, 0.83f), + }; + string[] labels = { "DrapeShell(망토)", "DrapeShell(치마)", "Torus", "NetHead" }; + for (int i = 0; i < meshes.Length; i++) + { + AssertWindingMatchesNormals(meshes[i], labels[i]); + AssertFinite(meshes[i], labels[i]); + AssertUnitNormals(meshes[i], labels[i]); + } + } + + /// 늘어진 천은 원점이 윗변이고 아래로 늘어진다 — 레시피가 "어깨에 건다"고 적는 좌표의 전제. + [Test] + public void DrapeShell_HangsDownFromItsTopEdge_AndFlares() + { + Mesh m = ProcMeshLibrary.DrapeShell(202f, 1.4f, 0, 0f); + Assert.AreEqual(0f, m.bounds.max.y, 1e-4f, "윗변이 원점 높이가 아니다"); + Assert.AreEqual(-1f, m.bounds.min.y, 1e-4f, "길이 1(아래로)이 아니다"); + + float topWidth = 0f, bottomWidth = 0f; + foreach (Vector3 v in m.vertices) + { + if (v.y > -0.01f) topWidth = Mathf.Max(topWidth, Mathf.Abs(v.x)); + if (v.y < -0.99f) bottomWidth = Mathf.Max(bottomWidth, Mathf.Abs(v.x)); + } + Assert.Greater(bottomWidth, topWidth, "밑단이 퍼지지 않는다"); + Assert.Less(m.bounds.center.z, 0f, "뒤(−Z)를 감싸야 한다 — 망토가 가슴 앞에 걸린다"); + } + + /// + /// 잠자리채 머리의 원점은 테 **아래 끝**이다 — 노드를 자루 끝에 두면 테가 자루 위에 선다 + /// (PlayerVisualRebuildTests가 "자루 끝 = 테 노드 위치"를 고정한다). 그물은 뒤(−Z)로 늘어진다. + /// + [Test] + public void NetHead_StandsOnItsOrigin_AndTheBagTrailsBack() + { + Mesh m = ProcMeshLibrary.NetHead(0.052f, 0.84f); + Assert.AreEqual(0f, m.bounds.min.y, 0.06f, "테 아래 끝이 원점이 아니다"); + Assert.Greater(m.bounds.max.y, 0.95f, "테가 자루 위로 서지 않는다"); + Assert.Less(m.bounds.min.z, -0.7f, "그물이 뒤로 늘어지지 않는다"); + } + + /// + /// 둥근 상자 UV 아틀라스 — 앞면은 u 0~0.5, 뒷면·옆면은 0.5~1. 앞면 전용 무늬(단추·거미줄 중심)가 등에 찍히지 않는 전제다. + /// + [Test] + public void RoundedBox_Uv_SplitsFrontFromBack() + { + Mesh m = ProcMeshLibrary.RoundedBox(new Vector3(0.481f, 0.461f, 0.381f), 0.08f, 3); + Vector3[] v = m.vertices; + Vector2[] uv = m.uv; + Assert.AreEqual(v.Length, uv.Length, "UV가 없다 — 무늬 텍스처가 한 점 색으로 번진다"); + + Vector3[] n = m.normals; + for (int i = 0; i < v.Length; i++) + { + if (n[i].z > 0.95f) Assert.LessOrEqual(uv[i].x, 0.5f, "앞면 정점이 뒤 칸 UV를 쓴다"); + if (n[i].z < -0.95f) Assert.GreaterOrEqual(uv[i].x, 0.5f, "뒷면 정점이 앞 칸 UV를 쓴다"); + } + } + + /// 캡슐(팔·다리) UV — 원통 투영이고 이음매(u 0/1)는 **뒤**에 있다. 앞(+Z)이 u 0.5다. + [Test] + public void TaperedCapsule_Uv_SeamSitsAtTheBack() + { + Mesh m = ProcMeshLibrary.TaperedCapsule(0.053f, 0.041f, 0.117f, 8, 10); + Vector3[] v = m.vertices; + Vector2[] uv = m.uv; + Assert.AreEqual(v.Length, uv.Length); + for (int i = 0; i < v.Length; i++) + { + Vector2 xz = new Vector2(v[i].x, v[i].z); + if (xz.magnitude < 0.02f) continue; // 극점 근처는 방향이 없다 + if (v[i].z > 0.03f && Mathf.Abs(v[i].x) < 0.01f) Assert.AreEqual(0.5f, uv[i].x, 0.08f, "앞이 u 0.5가 아니다"); + } + } + + /// 앞머리 껍질은 윗변이 끝모양보다 항상 위다 — 뒤집히면 면이 안쪽을 향해 컬링된다. + [Test] + public void FringeShell_TopStaysAboveEdge() + { + Mesh m = Fringe(false); + Assert.Greater(m.bounds.max.y, 0.361f * Mathf.Sin(55f * Mathf.Deg2Rad), "윗변이 정수리 덮개 밑으로 들어갈 만큼 높아야 한다"); + Assert.Greater(m.bounds.max.z, 0.3f, "정면(+Z)을 덮어야 한다"); + } } } #endif diff --git a/Assets/Tests/EditMode/QuestListLayoutTests.cs b/Assets/Tests/EditMode/QuestListLayoutTests.cs index a77920d..cbdb112 100644 --- a/Assets/Tests/EditMode/QuestListLayoutTests.cs +++ b/Assets/Tests/EditMode/QuestListLayoutTests.cs @@ -55,6 +55,28 @@ public void GetContentHeight_OneExpanded_AddsExactlyOneExtra() Delta); } + [Test] + public void GetRowHeight_TrackableExpandedRow_AddsFollowButtonSpace_OnlyWhenExpanded() + { + Assert.AreEqual( + QuestListLayout.RowHeight + QuestListLayout.ExpandedExtra + QuestListLayout.TrackButtonExtra, + QuestListLayout.GetRowHeight(true, true), Delta); + // 접힌 행엔 버튼이 없다 — 따라갈 수 있어도 높이는 그대로다. + Assert.AreEqual(QuestListLayout.RowHeight, QuestListLayout.GetRowHeight(false, true), Delta); + } + + [Test] + public void GetContentHeight_TrackableExpanded_MatchesRowHeight_AndIsClampedToExpanded() + { + // 행 높이와 콘텐츠 높이가 같은 답을 내야 스크롤 끝의 [따라가기]가 잘리지 않는다. + float collapsed = QuestListLayout.GetContentHeight(5, 3, 0); + Assert.AreEqual( + collapsed + QuestListLayout.GetRowHeight(true, true) - QuestListLayout.RowHeight, + QuestListLayout.GetContentHeight(5, 3, 1, 1), Delta); + // 펼친 행이 없는데 버튼 수만 넘어오면 무시한다. + Assert.AreEqual(collapsed, QuestListLayout.GetContentHeight(5, 3, 0, 1), Delta); + } + [Test] public void GetContentHeight_ExpandedCountAboveRowCount_IsClamped() { @@ -81,6 +103,15 @@ public void GetContentHeight_EmptyList_StillHasStoryHeader() QuestListLayout.GetContentHeight(0, 0, 0), Delta); } + + [Test] + public void TrackButton_FitsInsideReservedSpace_AndMeetsTouchMinimum() + { + // 버튼 기하(TutorialQuestUI)와 행 높이 예약(TrackButtonExtra)이 따로 놀면 버튼이 다음 행을 덮는다. + Assert.LessOrEqual(QuestListLayout.TrackButtonTopGap + QuestListLayout.TrackButtonHeight, + QuestListLayout.TrackButtonExtra); + Assert.GreaterOrEqual(QuestListLayout.TrackButtonHeight, UIScale.MinTouchHeight); + } } } #endif diff --git a/Assets/Tests/EditMode/QuestRewardFormatterTests.cs b/Assets/Tests/EditMode/QuestRewardFormatterTests.cs index 757b139..d089542 100644 --- a/Assets/Tests/EditMode/QuestRewardFormatterTests.cs +++ b/Assets/Tests/EditMode/QuestRewardFormatterTests.cs @@ -202,6 +202,40 @@ public void Collect_AllFourRewards_YieldsFourEntriesInOrder() Assert.AreEqual(3, entries[2].Amount); } + [Test] + public void Format_CoinsAndIslandObject_AreShown() + { + // 섬 퀘스트 보상 — 코인과 섬 물건(카탈로그의 표시명으로). + TutorialQuest quest = Quest(exp: 20); + quest.rewardCoins = 100; + quest.rewardIslandObjectId = "f_lantern"; + quest.rewardIslandObjectCount = 1; + Assert.AreEqual("경험치 20 + 코인 100 + 섬 물건 등불", QuestRewardFormatter.Format(quest, Resolver)); + + quest.rewardIslandObjectCount = 2; + StringAssert.Contains("섬 물건 등불 ×2", QuestRewardFormatter.Format(quest, Resolver)); + } + + [Test] + public void IslandObjectReward_UnknownIdOrZeroCount_IsNeitherShownNorCounted() + { + // 지급(GrantRewards)이 같은 술어를 쓴다 — 모르는 id는 "보이지도 않고 주지도 않는다". + TutorialQuest unknown = Quest(); + unknown.rewardIslandObjectId = "x_typo"; + unknown.rewardIslandObjectCount = 1; + Assert.IsFalse(QuestRewardFormatter.HasAny(unknown)); + Assert.AreEqual(string.Empty, QuestRewardFormatter.Format(unknown, Resolver)); + + TutorialQuest zero = Quest(); + zero.rewardIslandObjectId = "f_lantern"; + zero.rewardIslandObjectCount = 0; + Assert.IsFalse(QuestRewardFormatter.HasAny(zero)); + + TutorialQuest coinsOnly = Quest(); + coinsOnly.rewardCoins = 5; + Assert.IsTrue(QuestRewardFormatter.HasAny(coinsOnly)); + } + [Test] public void Collect_ReusedBuffer_IsClearedBetweenCalls() { diff --git a/Assets/Tests/EditMode/QuestTraitTests.cs b/Assets/Tests/EditMode/QuestTraitTests.cs new file mode 100644 index 0000000..b9f7e37 --- /dev/null +++ b/Assets/Tests/EditMode/QuestTraitTests.cs @@ -0,0 +1,756 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Spawning; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 조건부 퀘스트(·) — + /// 순수 판정, 실제 퀘스트 표의 불변식, 매니저를 실제로 굴린 진행, 실제 곤충 DB로 잰 달성 확률. + /// + /// 실패가 전부 조용한 계열이다: 조건이 안 먹으면 퀘스트가 그냥 안 차거나(너무 어렵다) 너무 쉽게 찬다. + /// 예외도 경고도 없다. + /// + [TestFixture] + public class QuestTraitTests + { + private const BindingFlags Inst = BindingFlags.Instance | BindingFlags.NonPublic; + + private readonly List objects = new List(); + private readonly Dictionary savedPrefs = new Dictionary(); + + private static readonly string[] PrefBaseKeys = + { + GameConstants.PrefsKeys.QuestProgress, GameConstants.PrefsKeys.QuestCompleted, + GameConstants.PrefsKeys.ActiveQuest, GameConstants.PrefsKeys.QuestUnseen, + GameConstants.PrefsKeys.QuestSideProgress, GameConstants.PrefsKeys.QuestSideRepeat, + }; + + [SetUp] + public void SetUp() + { + // 매니저가 진행을 PlayerPrefs에 쓴다 — 이 PC의 진짜 퀘스트 진행을 덮지 않게 백업해 둔다. + savedPrefs.Clear(); + foreach (string baseKey in PrefBaseKeys) + { + string key = AuthManager.ScopedKey(baseKey); + savedPrefs[key] = PlayerPrefs.HasKey(key) ? PlayerPrefs.GetString(key) : null; + } + DreamPrologueState.End(); + } + + [TearDown] + public void TearDown() + { + DreamPrologueState.End(); + foreach (var kv in savedPrefs) + { + if (kv.Value == null) PlayerPrefs.DeleteKey(kv.Key); + else PlayerPrefs.SetString(kv.Key, kv.Value); + } + PlayerPrefs.Save(); + foreach (Object o in objects) if (o != null) Object.DestroyImmediate(o); + objects.Clear(); + } + + // ── 입력 만들기 ── + + private static CaptureFacts Cap(InsectRarity rarity = InsectRarity.Common, float mm = 30f, float ratio = 1f, + InsectElement primary = InsectElement.Bug, InsectElement secondary = InsectElement.None, + bool shiny = false, bool sizeKnown = true) + { + return new CaptureFacts + { + rarity = rarity, sizeMm = mm, sizeRatio = ratio, primary = primary, secondary = secondary, + shiny = shiny, sizeKnown = sizeKnown, + }; + } + + private static BattleFacts Win(InsectRarity rarity = InsectRarity.Common, int enemyLevel = 5, int playerLevel = 5, + int actions = 4, float hp = 0.5f, + InsectElement primary = InsectElement.Bug, InsectElement secondary = InsectElement.None) + { + return new BattleFacts + { + enemyRarity = rarity, enemyLevel = enemyLevel, playerLevel = playerLevel, + playerActions = actions, playerHpRatio = hp, enemyPrimary = primary, enemySecondary = secondary, + }; + } + + private static TutorialQuest CaptureQuest(float minMm = 0f, float maxMm = 0f, float minRatio = 0f, float maxRatio = 0f, + InsectElement element = InsectElement.None, bool shiny = false, InsectRarity minRarity = InsectRarity.Common) + { + return new TutorialQuest + { + questId = "t_cap", type = QuestType.CaptureTrait, category = QuestCategory.Side, + minSizeMm = minMm, maxSizeMm = maxMm, minSizeRatio = minRatio, maxSizeRatio = maxRatio, + requiredElement = element, requireShiny = shiny, minRarity = minRarity, + }; + } + + private static TutorialQuest BattleQuest(int levelEdge = 0, int maxTurns = 0, int hpPercent = 0, + InsectElement element = InsectElement.None, InsectRarity minRarity = InsectRarity.Common) + { + return new TutorialQuest + { + questId = "t_bat", type = QuestType.BattleFeat, category = QuestCategory.Side, + minLevelEdge = levelEdge, maxTurns = maxTurns, minHpPercent = hpPercent, + requiredElement = element, minRarity = minRarity, + }; + } + + // ── 포획 판정: 몸길이 ── + + [Test] + public void CaptureSize_MinAndMax_AreInclusive() + { + TutorialQuest big = CaptureQuest(minMm: 40f); + Assert.IsTrue(QuestTraitRules.Matches(big, Cap(mm: 40f))); + Assert.IsTrue(QuestTraitRules.Matches(big, Cap(mm: 75f))); + Assert.IsFalse(QuestTraitRules.Matches(big, Cap(mm: 39.9f))); + + TutorialQuest small = CaptureQuest(maxMm: 20f); + Assert.IsTrue(QuestTraitRules.Matches(small, Cap(mm: 20f))); + Assert.IsFalse(QuestTraitRules.Matches(small, Cap(mm: 20.1f))); + } + + [Test] + public void CaptureSize_IsJudgedByTheRoundedValueThePlayerSees() + { + // 화면은 소수 첫째 자리다(SizeLabel). 39.96은 "40.0mm"로 보이는데 40 이상 퀘스트에 안 세어지면 버그로 느낀다. + Assert.AreEqual("40.0mm", InsectSizeCalculator.SizeLabel(39.96f)); + Assert.IsTrue(QuestTraitRules.Matches(CaptureQuest(minMm: 40f), Cap(mm: 39.96f))); + Assert.IsTrue(QuestTraitRules.Matches(CaptureQuest(maxMm: 20f), Cap(mm: 20.04f)), + "20.04는 20.0mm로 보인다"); + Assert.IsFalse(QuestTraitRules.Matches(CaptureQuest(minMm: 40f), Cap(mm: 39.94f)), "39.9mm로 보인다"); + } + + [Test] + public void CaptureSize_Band_NeedsBothBounds() + { + TutorialQuest band = CaptureQuest(minMm: 25f, maxMm: 30f); + Assert.IsTrue(QuestTraitRules.Matches(band, Cap(mm: 25f))); + Assert.IsTrue(QuestTraitRules.Matches(band, Cap(mm: 27.5f))); + Assert.IsTrue(QuestTraitRules.Matches(band, Cap(mm: 30f))); + Assert.IsFalse(QuestTraitRules.Matches(band, Cap(mm: 24.9f))); + Assert.IsFalse(QuestTraitRules.Matches(band, Cap(mm: 30.1f))); + } + + [Test] + public void CaptureRatio_BoundaryRolls_AreNotLostToFloatNoise() + { + // 배율은 정수 롤(0~100)의 Lerp라 롤 90이 정확히 1.2가 아닐 수 있다 — 경계 롤이 떨어지면 안 된다. + TutorialQuest big = CaptureQuest(minRatio: 1.2f); + Assert.IsTrue(QuestTraitRules.Matches(big, Cap(ratio: InsectSizeCalculator.ScaleFor(90))), "롤 90 = 1.20배"); + Assert.IsFalse(QuestTraitRules.Matches(big, Cap(ratio: InsectSizeCalculator.ScaleFor(89)))); + + TutorialQuest small = CaptureQuest(maxRatio: 0.8f); + Assert.IsTrue(QuestTraitRules.Matches(small, Cap(ratio: InsectSizeCalculator.ScaleFor(10))), "롤 10 = 0.80배"); + Assert.IsFalse(QuestTraitRules.Matches(small, Cap(ratio: InsectSizeCalculator.ScaleFor(11)))); + } + + [Test] + public void CaptureSize_UnknownSize_NeverFillsASizeCondition() + { + // 개체 저장에 실패한 포획은 크기를 모른다 — 중간값으로 크기 조건을 채우면 안 된다. + CaptureFacts unknown = Cap(mm: 27f, ratio: 1f, sizeKnown: false); + Assert.IsFalse(QuestTraitRules.Matches(CaptureQuest(minMm: 25f, maxMm: 30f), unknown)); + Assert.IsFalse(QuestTraitRules.Matches(CaptureQuest(maxRatio: 1.0f), unknown)); + // 크기를 안 보는 조건은 그대로 센다. + Assert.IsTrue(QuestTraitRules.Matches(CaptureQuest(minRarity: InsectRarity.Common), unknown)); + } + + // ── 포획 판정: 속성·이로치·등급·결합 ── + + [Test] + public void CaptureElement_MatchesPrimaryOrSecondary() + { + TutorialQuest water = CaptureQuest(element: InsectElement.Water); + Assert.IsTrue(QuestTraitRules.Matches(water, Cap(primary: InsectElement.Water))); + Assert.IsTrue(QuestTraitRules.Matches(water, Cap(primary: InsectElement.Wind, secondary: InsectElement.Water)), + "부속성도 그 속성이다"); + Assert.IsFalse(QuestTraitRules.Matches(water, Cap(primary: InsectElement.Wind, secondary: InsectElement.Leaf))); + } + + [Test] + public void CaptureShiny_OnlyShinyCounts() + { + TutorialQuest shiny = CaptureQuest(shiny: true); + Assert.IsTrue(QuestTraitRules.Matches(shiny, Cap(shiny: true))); + Assert.IsFalse(QuestTraitRules.Matches(shiny, Cap(shiny: false))); + } + + [Test] + public void Capture_MinRarity_IsAtLeast_NotExactly() + { + TutorialQuest rare = CaptureQuest(minRarity: InsectRarity.Rare, minMm: 1f); + Assert.IsFalse(QuestTraitRules.Matches(rare, Cap(InsectRarity.Uncommon))); + Assert.IsTrue(QuestTraitRules.Matches(rare, Cap(InsectRarity.Rare))); + Assert.IsTrue(QuestTraitRules.Matches(rare, Cap(InsectRarity.Legendary))); + } + + [Test] + public void Capture_Conditions_AreAllRequired() + { + TutorialQuest q = CaptureQuest(minMm: 40f, element: InsectElement.Water, shiny: true); + Assert.IsTrue(QuestTraitRules.Matches(q, Cap(mm: 50f, primary: InsectElement.Water, shiny: true))); + Assert.IsFalse(QuestTraitRules.Matches(q, Cap(mm: 30f, primary: InsectElement.Water, shiny: true)), "크기 부족"); + Assert.IsFalse(QuestTraitRules.Matches(q, Cap(mm: 50f, primary: InsectElement.Wind, shiny: true)), "속성 다름"); + Assert.IsFalse(QuestTraitRules.Matches(q, Cap(mm: 50f, primary: InsectElement.Water, shiny: false)), "이로치 아님"); + } + + // ── 전투 판정 ── + + [Test] + public void BattleLevelEdge_IsEnemyMinusMine() + { + TutorialQuest upset = BattleQuest(levelEdge: 3); + Assert.IsTrue(QuestTraitRules.Matches(upset, Win(enemyLevel: 13, playerLevel: 10))); + Assert.IsFalse(QuestTraitRules.Matches(upset, Win(enemyLevel: 12, playerLevel: 10))); + Assert.IsFalse(QuestTraitRules.Matches(upset, Win(enemyLevel: 5, playerLevel: 20)), "내가 더 높으면 하극상이 아니다"); + } + + [Test] + public void BattleMaxTurns_CountsMyActions_Inclusive() + { + TutorialQuest swift = BattleQuest(maxTurns: 3); + Assert.IsTrue(QuestTraitRules.Matches(swift, Win(actions: 1))); + Assert.IsTrue(QuestTraitRules.Matches(swift, Win(actions: 3))); + Assert.IsFalse(QuestTraitRules.Matches(swift, Win(actions: 4))); + } + + [Test] + public void BattleHpPercent_IsComparedAsWholePercent() + { + TutorialQuest clean = BattleQuest(hpPercent: 70); + Assert.IsTrue(QuestTraitRules.Matches(clean, Win(hp: 0.70f))); + Assert.IsTrue(QuestTraitRules.Matches(clean, Win(hp: 0.699f)), "69.9%는 70%로 보인다"); + Assert.IsFalse(QuestTraitRules.Matches(clean, Win(hp: 0.69f))); + Assert.IsTrue(QuestTraitRules.Matches(clean, Win(hp: 1f))); + } + + [Test] + public void BattleEnemyRarityAndElement_AreChecked() + { + TutorialQuest elite = BattleQuest(minRarity: InsectRarity.Rare); + Assert.IsFalse(QuestTraitRules.Matches(elite, Win(InsectRarity.Uncommon))); + Assert.IsTrue(QuestTraitRules.Matches(elite, Win(InsectRarity.Epic))); + + TutorialQuest wind = BattleQuest(element: InsectElement.Wind); + Assert.IsTrue(QuestTraitRules.Matches(wind, Win(primary: InsectElement.Wind))); + Assert.IsTrue(QuestTraitRules.Matches(wind, Win(primary: InsectElement.Bug, secondary: InsectElement.Wind))); + Assert.IsFalse(QuestTraitRules.Matches(wind, Win(primary: InsectElement.Water))); + } + + [Test] + public void BattleCombined_AceNeedsRarityAndSpeed() + { + TutorialQuest ace = BattleQuest(maxTurns: 3, minRarity: InsectRarity.Rare); + Assert.IsTrue(QuestTraitRules.Matches(ace, Win(InsectRarity.Rare, actions: 2))); + Assert.IsFalse(QuestTraitRules.Matches(ace, Win(InsectRarity.Rare, actions: 5)), "느리다"); + Assert.IsFalse(QuestTraitRules.Matches(ace, Win(InsectRarity.Common, actions: 2)), "약한 상대"); + } + + [Test] + public void Matches_WrongQuestType_IsAlwaysFalse() + { + Assert.IsFalse(QuestTraitRules.Matches(BattleQuest(maxTurns: 3), Cap()), "전투 퀘스트에 포획 입력"); + Assert.IsFalse(QuestTraitRules.Matches(CaptureQuest(minMm: 1f), Win()), "포획 퀘스트에 전투 입력"); + Assert.IsFalse(QuestTraitRules.Matches(null, Cap())); + var plain = new TutorialQuest { questId = "plain", type = QuestType.Capture }; + Assert.IsFalse(QuestTraitRules.Matches(plain, Cap()), "일반 포획은 이 규칙의 대상이 아니다"); + } + + [Test] + public void HasCondition_FalseOnlyForAnUnconditionedQuest() + { + Assert.IsFalse(QuestTraitRules.HasCondition(new TutorialQuest { type = QuestType.CaptureTrait })); + Assert.IsFalse(QuestTraitRules.HasCondition(null)); + Assert.IsTrue(QuestTraitRules.HasCondition(CaptureQuest(minMm: 1f))); + Assert.IsTrue(QuestTraitRules.HasCondition(CaptureQuest(shiny: true))); + Assert.IsTrue(QuestTraitRules.HasCondition(BattleQuest(maxTurns: 1))); + Assert.IsTrue(QuestTraitRules.HasCondition(new TutorialQuest { type = QuestType.BattleFeat, resetOnLoss = true })); + Assert.IsTrue(QuestTraitRules.HasCondition(BattleQuest(minRarity: InsectRarity.Rare))); + } + + // ── 입력 만들기(From) ── + + private static InsectData Species(float baseMm, InsectRarity rarity = InsectRarity.Uncommon, + InsectElement primary = InsectElement.Leaf, InsectElement secondary = InsectElement.Bug) + { + var data = ScriptableObject.CreateInstance(); + data.insectId = "trait_probe"; + data.baseSizeMm = baseMm; + data.rarity = rarity; + data.primaryType = primary; + data.secondaryType = secondary; + return data; + } + + [Test] + public void CaptureFacts_From_ReadsTheSavedIndividual() + { + InsectData data = Species(40f); + objects.Add(data); + var captured = new PlayerInsectData { instanceId = "abc", insectId = "trait_probe", sizeRoll = 100, isShiny = true }; + + CaptureFacts f = CaptureFacts.From(data, captured); + + Assert.IsTrue(f.sizeKnown); + Assert.AreEqual(50f, f.sizeMm, 0.01f, "40mm × 1.25"); + Assert.AreEqual(1.25f, f.sizeRatio, 1e-4f); + Assert.IsTrue(f.shiny, "이로치는 저장된 개체의 값이다"); + Assert.AreEqual(InsectRarity.Uncommon, f.rarity); + Assert.AreEqual(InsectElement.Leaf, f.primary); + Assert.AreEqual(InsectElement.Bug, f.secondary); + } + + [Test] + public void CaptureFacts_From_NullIndividual_DoesNotKnowTheSize() + { + InsectData data = Species(40f); + objects.Add(data); + + CaptureFacts f = CaptureFacts.From(data, null, shinyFallback: true); + + Assert.IsFalse(f.sizeKnown); + Assert.IsTrue(f.shiny, "저장 실패 때는 필드에서 본 이로치 표지를 쓴다"); + Assert.IsFalse(CaptureFacts.From(null, null).sizeKnown); + } + + [Test] + public void BattleFacts_From_ClampsHpAndSurvivesZeroMax() + { + InsectData enemy = Species(30f, InsectRarity.Epic, InsectElement.Wind, InsectElement.None); + objects.Add(enemy); + + BattleFacts f = BattleFacts.From(enemy, 12, 9, 3, 45, 90); + Assert.AreEqual(0.5f, f.playerHpRatio, 1e-4f); + Assert.AreEqual(12, f.enemyLevel); + Assert.AreEqual(9, f.playerLevel); + Assert.AreEqual(3, f.playerActions); + Assert.AreEqual(InsectRarity.Epic, f.enemyRarity); + Assert.AreEqual(InsectElement.Wind, f.enemyPrimary); + + Assert.AreEqual(1f, BattleFacts.From(enemy, 1, 1, 1, 200, 90).playerHpRatio, "넘치는 HP는 1로"); + Assert.AreEqual(0f, BattleFacts.From(enemy, 1, 1, 1, 10, 0).playerHpRatio, "최대 HP 0이어도 0으로 나눠 터지지 않는다"); + } + + // ── 실제 퀘스트 표 ── + + private static TutorialQuest[] RealQuests() + { + var host = new GameObject("QuestTraitProbe"); + host.SetActive(false); // Awake(싱글턴)·Start(세이브 로드)를 돌리지 않는다 + try + { + var mgr = host.AddComponent(); + typeof(TutorialQuestManager).GetMethod("Initialize", Inst).Invoke(mgr, null); + return mgr.GetAllQuests(); + } + finally { Object.DestroyImmediate(host); } + } + + private static bool IsTrait(TutorialQuest q) => + q.type == QuestType.CaptureTrait || q.type == QuestType.BattleFeat; + + [Test] + public void RealTable_TraitQuests_AreSideOnly_AndCarryACondition() + { + int count = 0; + foreach (TutorialQuest q in RealQuests()) + { + if (!IsTrait(q)) continue; + count++; + Assert.AreEqual(QuestCategory.Side, q.category, + $"{q.questId}: 조건부 타입을 Story에 달면 선형 체인이 세지 않아 영구 정지한다"); + Assert.IsTrue(QuestTraitRules.HasCondition(q), + $"{q.questId}: 조건이 없으면 무엇이든 센다 — 기본 Capture/Battle로 쓸 것"); + } + Assert.GreaterOrEqual(count, 25, "조건부 퀘스트가 표에서 빠졌다"); + } + + [Test] + public void RealTable_NoQuestRequiresARepeatableOne() + { + // 반복 서브는 completedQuests에 들어가지 않고 목표만 올려 재시작한다(CompleteSideQuest). + // 그걸 선행으로 물면 그 퀘스트는 영영 안 열린다. + TutorialQuest[] all = RealQuests(); + var byId = new Dictionary(); + foreach (TutorialQuest q in all) byId[q.questId] = q; + + foreach (TutorialQuest q in all) + { + if (string.IsNullOrEmpty(q.prerequisiteQuestId)) continue; + Assert.IsTrue(byId.TryGetValue(q.prerequisiteQuestId, out TutorialQuest pre), $"{q.questId}의 선행이 없다"); + Assert.IsFalse(pre.repeatable, $"{q.questId}의 선행 {pre.questId}가 반복형이라 영영 완료로 안 잡힌다"); + } + } + + [Test] + public void RealTable_TaleChains_AreOneShot_AndEachEpisodeRequiresThePreviousOne() + { + var tales = new List(); + foreach (TutorialQuest q in RealQuests()) + if (q.questId.StartsWith("s_tale_")) tales.Add(q); + Assert.AreEqual(11, tales.Count, "외전: 일기 4 + 라온 4 + 세라 3"); + + foreach (TutorialQuest q in tales) + { + Assert.IsFalse(q.repeatable, $"{q.questId}: 외전은 한 번 읽는 이야기다"); + Assert.IsTrue(q.title.StartsWith("[외전]"), $"{q.questId}: 외전 표지가 목록에서 구분해 준다"); + } + + // 연작은 번호 순으로 사슬이다(1화만 본편 퀘스트를 선행으로 문다). + foreach (string series in new[] { "s_tale_diary", "s_tale_raon", "s_tale_sera" }) + { + int episode = 1; + var chain = tales.FindAll(t => t.questId.StartsWith(series)); + Assert.GreaterOrEqual(chain.Count, 3, series); + foreach (TutorialQuest ep in chain) + { + if (episode == 1) + Assert.IsFalse(ep.prerequisiteQuestId.StartsWith("s_tale_"), $"{ep.questId}: 1화는 본편 퀘스트를 선행으로 문다"); + else + Assert.AreEqual(series + (episode - 1), ep.prerequisiteQuestId, + $"{ep.questId}: 앞 화를 선행으로 물어야 차례로 열린다"); + episode++; + } + } + } + + // ── 매니저를 실제로 굴린다 ── + + private TutorialQuestManager NewManager(params string[] completed) + { + var host = new GameObject("QuestTraitManager"); + host.SetActive(false); // Awake(싱글턴 등록)·Start(세이브 로드)를 건너뛴다 — 상태는 아래에서 직접 채운다 + objects.Add(host); + var mgr = host.AddComponent(); + typeof(TutorialQuestManager).GetMethod("Initialize", Inst).Invoke(mgr, null); + typeof(TutorialQuestManager).GetField("tutorialSessionStarted", Inst).SetValue(mgr, true); + var done = (HashSet)typeof(TutorialQuestManager).GetField("completedQuests", Inst).GetValue(mgr); + done.Clear(); + foreach (string id in completed) done.Add(id); + return mgr; + } + + private static void BattleEnded(TutorialQuestManager mgr, bool playerWon) => + typeof(TutorialQuestManager).GetMethod("OnBattleEnded", Inst).Invoke(mgr, new object[] { playerWon }); + + [Test] + public void OneCapture_AdvancesEveryMatchingQuest_InParallel() + { + // 영웅 등급 82mm 물 곤충 한 마리 = 큰 곤충 셋 + 물가 + 영웅 채집단이 함께 오른다. + TutorialQuestManager mgr = NewManager("q_capture3", "q_capture10"); + + mgr.NotifyCapture(Cap(InsectRarity.Epic, mm: 82f, ratio: 1.1f, primary: InsectElement.Water)); + + Assert.AreEqual(1, mgr.GetSideProgress("s_size_big40")); + Assert.AreEqual(1, mgr.GetSideRepeatCount("s_size_big60"), "목표 1이라 바로 한 단계 끝났다"); + Assert.IsTrue(mgr.IsQuestCompleted("s_size_big80"), "80mm 이상 1회형은 완료"); + Assert.AreEqual(1, mgr.GetSideProgress("s_trait_water")); + Assert.AreEqual(1, mgr.GetSideProgress("s_pack_epic"), "등급 기반 기존 서브도 같은 포획으로 오른다"); + // 안 맞는 조건은 건드리지 않는다. + Assert.AreEqual(0, mgr.GetSideProgress("s_size_small20")); + Assert.AreEqual(0, mgr.GetSideProgress("s_trait_light")); + Assert.AreEqual(0, mgr.GetSideProgress("s_size_ratio_big"), "1.1배는 20% 미만"); + } + + [Test] + public void LockedQuests_DoNotCount() + { + TutorialQuestManager mgr = NewManager(); // 선행을 하나도 안 깼다 + + mgr.NotifyCapture(Cap(InsectRarity.Epic, mm: 82f, primary: InsectElement.Water)); + mgr.NotifyBattleFeat(Win(InsectRarity.Epic, actions: 1, hp: 1f)); + + Assert.AreEqual(0, mgr.GetSideProgress("s_size_big40")); + Assert.AreEqual(0, mgr.GetSideProgress("s_trait_water")); + Assert.AreEqual(0, mgr.GetSideProgress("s_feat_swift")); + Assert.IsFalse(mgr.IsQuestCompleted("s_size_big80")); + } + + [Test] + public void OneWin_CannotAdvanceTwoChaptersOfTheSameTale() + { + // 빠르고 깔끔한 승리는 라온 1화(3번 이내)와 2화(HP 70%)를 둘 다 만족한다. 1화가 끝나면 2화가 열리지만 + // 같은 승리가 2화까지 채우면 연작이 한 걸음씩이 아니게 된다. + TutorialQuestManager mgr = NewManager("q_battle"); + + mgr.NotifyBattleFeat(Win(actions: 2, hp: 1f)); + + Assert.IsTrue(mgr.IsQuestCompleted("s_tale_raon1")); + Assert.AreEqual(0, mgr.GetSideProgress("s_tale_raon2"), "방금 열린 2화에 같은 승리가 들어갔다"); + + mgr.NotifyBattleFeat(Win(actions: 2, hp: 1f)); + Assert.AreEqual(1, mgr.GetSideProgress("s_tale_raon2"), "다음 승리부터 센다"); + } + + [Test] + public void ParallelBattleFeats_ProgressTogether() + { + TutorialQuestManager mgr = NewManager("q_battle", "q_battle10", "q_battle3"); + + // 희귀 · 바람 · 2번 행동 · 풀 HP · 내 곤충보다 4레벨 위 + mgr.NotifyBattleFeat(Win(InsectRarity.Rare, enemyLevel: 14, playerLevel: 10, actions: 2, hp: 1f, + primary: InsectElement.Wind)); + + Assert.AreEqual(1, mgr.GetSideProgress("s_feat_elite")); + Assert.AreEqual(1, mgr.GetSideProgress("s_feat_upset")); + Assert.AreEqual(1, mgr.GetSideProgress("s_feat_swift")); + Assert.AreEqual(1, mgr.GetSideProgress("s_feat_clean")); + Assert.AreEqual(1, mgr.GetSideProgress("s_feat_wind")); + Assert.AreEqual(1, mgr.GetSideProgress("s_feat_streak")); + Assert.AreEqual(0, mgr.GetSideProgress("s_feat_epic"), "희귀는 영웅이 아니다"); + Assert.AreEqual(1, mgr.GetSideRepeatCount("s_feat_ace"), "희귀 이상을 2번 행동 — 목표 1이라 한 단계 끝"); + } + + [Test] + public void Streak_BreaksOnLoss_AndANonMatchingWinLeavesItAlone() + { + TutorialQuestManager mgr = NewManager("q_battle", "q_battle3"); + + mgr.NotifyBattleFeat(Win()); + mgr.NotifyBattleFeat(Win()); + Assert.AreEqual(2, mgr.GetSideProgress("s_feat_streak")); + + BattleEnded(mgr, true); // 이긴 신호는 연승을 끊지 않는다 + Assert.AreEqual(2, mgr.GetSideProgress("s_feat_streak")); + + BattleEnded(mgr, false); // 지거나 도망쳤다 + Assert.AreEqual(0, mgr.GetSideProgress("s_feat_streak")); + Assert.AreEqual(0, mgr.GetSideRepeatCount("s_feat_streak"), "반복 횟수(목표 상승)는 건드리지 않는다"); + + mgr.NotifyBattleFeat(Win()); + Assert.AreEqual(1, mgr.GetSideProgress("s_feat_streak"), "끊긴 뒤 처음부터 다시 센다"); + } + + [Test] + public void Streak_LossDoesNotTouchOrdinaryFeats() + { + TutorialQuestManager mgr = NewManager("q_battle", "q_battle3"); + mgr.NotifyBattleFeat(Win(actions: 2, hp: 1f)); + Assert.AreEqual(1, mgr.GetSideProgress("s_feat_swift")); + + BattleEnded(mgr, false); + + Assert.AreEqual(1, mgr.GetSideProgress("s_feat_swift"), "연승이 아닌 퀘스트의 진행은 지는 것으로 줄지 않는다"); + Assert.AreEqual(1, mgr.GetSideProgress("s_feat_clean")); + } + + [Test] + public void OneShotStreak_OfTheTale_CompletesAndOnlyResetsWhileOpen() + { + TutorialQuestManager mgr = NewManager("q_battle", "s_tale_raon1", "s_tale_raon2"); + + mgr.NotifyBattleFeat(Win()); + mgr.NotifyBattleFeat(Win()); + BattleEnded(mgr, false); + Assert.AreEqual(0, mgr.GetSideProgress("s_tale_raon3")); + + mgr.NotifyBattleFeat(Win()); + mgr.NotifyBattleFeat(Win()); + mgr.NotifyBattleFeat(Win()); + Assert.IsTrue(mgr.IsQuestCompleted("s_tale_raon3"), "세 번 연달아 이기면 도전장 3이 끝난다"); + + BattleEnded(mgr, false); // 끝난 뒤의 패배는 아무것도 되돌리지 않는다 + Assert.IsTrue(mgr.IsQuestCompleted("s_tale_raon3")); + } + + [Test] + public void RepeatableTrait_RaisesItsTarget_AfterEachRound() + { + TutorialQuestManager mgr = NewManager("q_capture3"); + TutorialQuest q = mgr.FindQuest("s_size_small20"); + Assert.AreEqual(4, mgr.EffectiveTarget(q)); + + for (int i = 0; i < 4; i++) mgr.NotifyCapture(Cap(mm: 18f)); + + Assert.AreEqual(1, mgr.GetSideRepeatCount("s_size_small20")); + Assert.AreEqual(0, mgr.GetSideProgress("s_size_small20"), "다음 단계는 0에서 시작"); + Assert.AreEqual(7, mgr.EffectiveTarget(q), "4 + 3"); + } + + [Test] + public void DreamPrologue_FreezesTraitQuests() + { + TutorialQuestManager mgr = NewManager("q_capture3", "q_battle", "q_battle3"); + + DreamPrologueState.Begin(); + mgr.NotifyCapture(Cap(mm: 18f)); + mgr.NotifyBattleFeat(Win(actions: 1, hp: 1f)); + BattleEnded(mgr, false); + DreamPrologueState.End(); + + Assert.AreEqual(0, mgr.GetSideProgress("s_size_small20"), "꿈속의 곤충이 꿈 밖 퀘스트를 채웠다"); + Assert.AreEqual(0, mgr.GetSideProgress("s_feat_swift"), "꿈속의 승리가 꿈 밖 퀘스트를 채웠다"); + + mgr.NotifyCapture(Cap(mm: 18f)); + Assert.AreEqual(1, mgr.GetSideProgress("s_size_small20"), "꿈이 끝나면 다시 센다"); + } + + [Test] + public void SessionNotStarted_CountsNothing() + { + TutorialQuestManager mgr = NewManager("q_capture3"); + typeof(TutorialQuestManager).GetField("tutorialSessionStarted", Inst).SetValue(mgr, false); + + mgr.NotifyCapture(Cap(mm: 18f)); + + Assert.AreEqual(0, mgr.GetSideProgress("s_size_small20")); + } + + // ── 실제 곤충 DB로 달성 가능성을 잰다 ── + // + // 기준값(40·60·80mm, 16·20mm, 속성)을 손으로 정하면 "분포 밖의 값"이 조용히 들어온다 — 어떤 곤충도 80mm가 + // 안 되거나, 어떤 속성이 필드에 한 종뿐이거나. 부트스트랩이 만드는 진짜 DB와 진짜 판정(QuestTraitRules)으로 + // 포획 한 번당 확률을 재서 "채우려면 평균 몇 번 만나야 하는가"를 본다. + + private InsectDatabase RealDatabase() + { + var host = new GameObject("QuestTraitTests_RealDb"); + host.SetActive(false); // Awake(월드 생성)를 막고 private 생성 함수만 부른다 + objects.Add(host); + var bootstrap = host.AddComponent(); + var db = (InsectDatabase)typeof(PlaySceneBootstrap).GetMethod("EnsureExpandedDatabase", Inst).Invoke(bootstrap, null); + Assert.IsNotNull(db); + objects.Add(db); + return db; + } + + private static float Share(InsectRarity r) + { + switch (r) + { + case InsectRarity.Common: return FieldSpawnRules.CommonShare; + case InsectRarity.Uncommon: return FieldSpawnRules.UncommonShare; + case InsectRarity.Rare: return FieldSpawnRules.RareShare; + case InsectRarity.Epic: return FieldSpawnRules.EpicShare; + default: return FieldSpawnRules.LegendaryShare; + } + } + + /// + /// 필드에서 한 번 잡았을 때 이 조건부 포획이 세어질 확률 — 등급은 전역 등급표로, 종은 등급 안에서 균등, + /// 개체 크기는 롤 0~100 균등으로 가정한다(실제 스폰 가중치·리전 풀은 무시한 근사). 판정은 진짜 규칙을 쓴다. + /// + private static double CaptureOdds(InsectDatabase db, TutorialQuest quest) + { + double total = 0; + foreach (InsectRarity rarity in System.Enum.GetValues(typeof(InsectRarity))) + { + var species = db.insects.FindAll(d => d != null && d.rarity == rarity); + if (species.Count == 0) continue; + + double within = 0; + foreach (InsectData d in species) + { + int hits = 0; + for (int roll = InsectSizeCalculator.MinRoll; roll <= InsectSizeCalculator.MaxRoll; roll++) + { + float scale = InsectSizeCalculator.ScaleFor(roll); + var facts = new CaptureFacts + { + rarity = d.rarity, primary = d.primaryType, secondary = d.secondaryType, + sizeMm = d.baseSizeMm * scale, sizeRatio = scale, sizeKnown = true, shiny = quest.requireShiny, + }; + if (QuestTraitRules.Matches(quest, facts)) hits++; + } + within += hits / (double)(InsectSizeCalculator.MaxRoll - InsectSizeCalculator.MinRoll + 1); + } + total += Share(rarity) * within / species.Count; + } + return total; + } + + /// 전투는 등급·속성만 DB로 잴 수 있다(행동 수·HP·레벨 차는 전투가 정한다) — 상한 근사다. + private static double EnemyOdds(InsectDatabase db, TutorialQuest quest) + { + double total = 0; + foreach (InsectRarity rarity in System.Enum.GetValues(typeof(InsectRarity))) + { + var species = db.insects.FindAll(d => d != null && d.rarity == rarity); + if (species.Count == 0) continue; + int hits = 0; + foreach (InsectData d in species) + { + var facts = new BattleFacts + { + enemyRarity = d.rarity, enemyPrimary = d.primaryType, enemySecondary = d.secondaryType, + enemyLevel = 99, playerLevel = 1, playerActions = 1, playerHpRatio = 1f, + }; + if (QuestTraitRules.Matches(quest, facts)) hits++; + } + total += Share(rarity) * hits / species.Count; + } + return total; + } + + [Test] + public void RealDb_SizeProfileIsApplied() + { + InsectDatabase db = RealDatabase(); + var distinct = new HashSet(); + foreach (InsectData d in db.insects) if (d != null) distinct.Add(Mathf.RoundToInt(d.baseSizeMm)); + Assert.Greater(distinct.Count, 20, "종 몸길이가 한 값으로 몰려 있으면 크기 퀘스트의 확률 계산이 무의미하다"); + } + + [Test] + public void RealDb_EveryTraitQuest_IsReachableAtSensibleOdds() + { + InsectDatabase db = RealDatabase(); + var report = new System.Text.StringBuilder(); + int checkedCount = 0; + + foreach (TutorialQuest q in RealQuests()) + { + if (!IsTrait(q)) continue; + + double odds; + if (q.type == QuestType.CaptureTrait) + { + if (q.requireShiny) continue; // 이로치는 종 데이터가 아니라 굴림(필드 1%)이다 + odds = CaptureOdds(db, q); + } + else + { + // 등급·속성 조건이 없으면 DB로 낼 수 있는 숫자가 없다(행동 수·HP·레벨 차는 전투가 정한다). + if (q.minRarity == InsectRarity.Common && q.requiredElement == InsectElement.None) continue; + odds = EnemyOdds(db, q); + } + + checkedCount++; + double encounters = odds > 0 ? q.targetCount / odds : double.PositiveInfinity; + report.AppendLine($"{q.questId,-20} 한 번당 {odds * 100:0.0}% → 목표 {q.targetCount}에 평균 {encounters:0}번"); + + Assert.Greater(odds, 0.0, $"{q.questId}: 어떤 곤충도 이 조건을 못 채운다 — 영원히 안 찬다"); + Assert.LessOrEqual(encounters, 450.0, + $"{q.questId}: 평균 {encounters:0}번을 만나야 찬다 — 한 번당 {odds * 100:0.00}%"); + } + + Debug.Log("[QuestTrait] 달성 확률(포획·조우 한 번당)\n" + report); + Assert.GreaterOrEqual(checkedCount, 20, "검사 대상이 너무 적다 — 판정 분기가 바뀌었는지 확인"); + } + + [Test] + public void RealDb_EveryElementUsedByAQuest_HasSeveralSpecies() + { + InsectDatabase db = RealDatabase(); + foreach (TutorialQuest q in RealQuests()) + { + if (!IsTrait(q) || q.requiredElement == InsectElement.None) continue; + int species = db.insects.FindAll(d => d != null + && (d.primaryType == q.requiredElement || d.secondaryType == q.requiredElement)).Count; + Assert.GreaterOrEqual(species, 5, + $"{q.questId}: {q.requiredElement} 속성 곤충이 {species}종뿐이라 목표를 채우기 전에 같은 종만 마주친다"); + } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/QuestTraitTests.cs.meta b/Assets/Tests/EditMode/QuestTraitTests.cs.meta new file mode 100644 index 0000000..03fce6a --- /dev/null +++ b/Assets/Tests/EditMode/QuestTraitTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 11b49bba56fd0494ca959644d0e87977 \ No newline at end of file diff --git a/Assets/Tests/EditMode/RaidBossFormTests.cs b/Assets/Tests/EditMode/RaidBossFormTests.cs new file mode 100644 index 0000000..0eedc2d --- /dev/null +++ b/Assets/Tests/EditMode/RaidBossFormTests.cs @@ -0,0 +1,461 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using InsectGame.Battle; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.Spawning; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 체력에 따라 모습을 바꾸는 레이드 보스( + ). + /// + /// 실패가 조용한 자리들: 래치가 없으면 회복할 때마다 모습이 되돌아가고, 정체(Data)를 갈아끼우면 이겨도 나비가 + /// 잡히고 최종장 비트가 사마귀를 못 알아보고, 상성을 정체로 재면 모습이 바뀌어도 약점이 그대로다(바뀐 건 그림뿐). + /// 쓰러지는 일격에 변신이 끼면 시체가 나비로 일어난다. + /// + [TestFixture] + public class RaidBossFormTests + { + private const string Boss = "mantis_unnamed"; + private const string Butterfly = "butterfly_swallowtail"; + private const string Firefly = "firefly_blue"; + + private readonly List created = new List(); + + [TearDown] + public void TearDown() + { + for (int i = created.Count - 1; i >= 0; i--) + if (created[i] != null) Object.DestroyImmediate(created[i]); + created.Clear(); + } + + // ── 표(순수) ── + + [Test] + public void Table_UnnamedMantis_BorrowsButterflyThenFirefly() + { + Assert.IsTrue(RaidBossForms.TryGetStages(Boss, out RaidBossForms.Stage[] stages)); + Assert.AreEqual(2, stages.Length); + Assert.AreEqual(66, stages[0].HpPercent); + Assert.AreEqual(Butterfly, stages[0].FormInsectId); + Assert.AreEqual(33, stages[1].HpPercent); + Assert.AreEqual(Firefly, stages[1].FormInsectId); + Assert.AreEqual(Boss, RaidBossForms.FormInsectId(Boss, 0), "0번 모습은 자기 자신"); + Assert.IsFalse(RaidBossForms.HasForms("boss"), "표에 없는 보스는 변신하지 않는다"); + } + + [Test] + public void Table_EveryStage_DescendsAndStaysInRange() + { + foreach (string bossId in RaidBossForms.BossIds) + { + RaidBossForms.TryGetStages(bossId, out RaidBossForms.Stage[] stages); + int previous = 100; + foreach (RaidBossForms.Stage s in stages) + { + Assert.Less(s.HpPercent, previous, $"{bossId}: 임계가 내림차순이 아니다 — 뒤 단계가 먼저 열린다"); + Assert.Greater(s.HpPercent, 0, $"{bossId}: 0% 임계는 쓰러진 뒤라 영영 안 열린다"); + Assert.IsFalse(string.IsNullOrEmpty(s.LineFormat), $"{bossId}: 표시 한 줄이 비었다"); + Assert.AreNotEqual(bossId, s.FormInsectId, $"{bossId}: 자기 모습으로 변신한다"); + previous = s.HpPercent; + } + } + } + + [TestCase(1000, 0)] + [TestCase(661, 0)] + [TestCase(660, 1)] + [TestCase(331, 1)] + [TestCase(330, 2)] + [TestCase(1, 2)] + public void FormIndexFor_UsesInclusiveThresholds(int hp, int expected) + { + Assert.AreEqual(expected, RaidBossForms.FormIndexFor(Boss, hp, 1000)); + } + + [Test] + public void NextFormIndex_LatchesAndIgnoresAKillingBlow() + { + Assert.AreEqual(1, RaidBossForms.NextFormIndex(Boss, 1, 1000, 1000), "회복해도 돌아가지 않는다"); + Assert.AreEqual(2, RaidBossForms.NextFormIndex(Boss, 0, 300, 1000), "한 번에 둘을 넘으면 마지막 모습"); + Assert.AreEqual(0, RaidBossForms.NextFormIndex(Boss, 0, 0, 1000), "쓰러졌으면 변신하지 않는다"); + Assert.AreEqual(0, RaidBossForms.NextFormIndex("boss", 0, 1, 1000)); + } + + [Test] + public void BuildLine_NamesTheBorrowedForm() + { + Assert.AreEqual("그림자가 호랑나비의 모습을 빌렸다!", RaidBossForms.BuildLine(Boss, 1, "호랑나비")); + Assert.AreEqual("그림자가 파란반딧불이의 모습을 빌렸다!", RaidBossForms.BuildLine(Boss, 2, "파란반딧불이")); + Assert.AreEqual(string.Empty, RaidBossForms.BuildLine(Boss, 0, "x")); + } + + [Test] + public void RealDatabase_HasEveryBossAndForm() + { + InsectDatabase db = RealDatabase(); + foreach (string bossId in RaidBossForms.BossIds) + { + Assert.IsNotNull(db.GetById(bossId), $"{bossId}: 보스가 곤충 DB에 없다"); + RaidBossForms.TryGetStages(bossId, out RaidBossForms.Stage[] stages); + foreach (RaidBossForms.Stage s in stages) + { + InsectData form = db.GetById(s.FormInsectId); + Assert.IsNotNull(form, $"{bossId}: 모습 {s.FormInsectId}가 곤충 DB에 없다 — 그 단계에서 변신이 조용히 빠진다"); + Assert.IsFalse(string.IsNullOrEmpty(form.displayName), $"{s.FormInsectId}: 표시명이 없다"); + } + } + } + + // ── 컨트롤러 ── + + [Test] + public void CrossingEachThreshold_ChangesFormOnce_AndLatches() + { + RaidBattleController raid = Raid(out List changes); + Assert.AreEqual(3, raid.BossFormCount); + Assert.AreEqual(0, raid.BossFormIndex); + + DamageTo(raid, 65); + ActOnce(raid); + Assert.AreEqual(1, changes.Count, "66% 아래 — 한 번"); + Assert.AreEqual(1, raid.BossFormIndex); + Assert.AreEqual(Butterfly, raid.BossFormData.insectId); + + ActOnce(raid); + ActOnce(raid); + Assert.AreEqual(1, changes.Count, "같은 구간에서 또 바뀌었다"); + + DamageTo(raid, 32); + ActOnce(raid); + Assert.AreEqual(2, changes.Count, "33% 아래 — 한 번 더"); + Assert.AreEqual(2, raid.BossFormIndex); + Assert.AreEqual(Firefly, raid.BossFormData.insectId); + Assert.AreEqual(1, changes[1].FromIndex); + Assert.AreEqual(2, changes[1].ToIndex); + Assert.IsFalse(changes[1].SkippedStages); + + raid.BossStats.Heal(raid.BossStats.MaxHp); + ActOnce(raid); + Assert.AreEqual(2, raid.BossFormIndex, "회복했다고 모습이 돌아갔다"); + Assert.AreEqual(2, changes.Count); + } + + [Test] + public void FormChange_SwapsElementsAndSkills_KeepsIdentityHpLevelAndEnrage() + { + RaidBattleController raid = Raid(out List changes); + Assert.AreEqual("sig_mantis", raid.NextBossIntent.Skill.skillId, "전제: 원래 모습은 사마귀 전용기를 예고한다"); + + DamageTo(raid, 40); + RunRound(raid); // 이 라운드의 첫 행동이 변신을 부르고, 다음 라운드 시작이 격노를 켠다 + Assert.AreEqual(1, raid.BossFormIndex); + Assert.IsTrue(raid.BossEnraged, "전제: 40%면 격노"); + int maxHp = raid.BossStats.MaxHp; + int level = raid.BossStats.Level; + + Assert.AreEqual(Boss, raid.BossStats.Data.insectId, "정체가 바뀌었다 — 이기면 나비가 잡히고 최종장 비트가 사마귀를 못 알아본다"); + Assert.AreEqual(Butterfly, raid.BossStats.CombatData.insectId); + Assert.AreEqual(InsectElement.Wind, raid.BossStats.CombatData.primaryType, "속성이 모습을 따라가지 않았다"); + Assert.AreEqual("sig_butterfly", raid.NextBossIntent.Skill.skillId, "기술이 모습을 따라가지 않았다"); + Assert.AreEqual(InsectElement.Wind, raid.NextBossIntent.Element); + + DamageTo(raid, 30); + ActOnce(raid); + Assert.AreEqual(2, raid.BossFormIndex); + Assert.AreEqual(maxHp, raid.BossStats.MaxHp, "최대 HP가 바뀌었다"); + Assert.AreEqual(level, raid.BossStats.Level, "레벨이 바뀌었다"); + Assert.IsTrue(raid.BossEnraged, "변신이 격노를 풀었다"); + // 반딧불이 모습엔 전용기가 없다 — 그 모습이 아는 가장 센 피해기를 쓴다. + Assert.AreEqual("glow_strong", raid.NextBossIntent.Skill.skillId, "전용기 없는 모습은 가장 센 피해기를 써야 한다"); + Assert.AreSame(raid.NextBossIntent, raid.CurrentRoundResult.BossIntent, "예고와 실행이 같은 객체여야 한다"); + + // 이번 라운드 보스의 응답이 바뀐 기술로 나간다. + while (raid.CanSubmitTeamCommand) raid.ResolveTeamCommand(0); + RaidRoundResult round = raid.ResolveBossResponse(); + Assert.IsNotNull(round); + Assert.AreEqual("glow_strong", round.BossAction.Skill.skillId); + } + + [Test] + public void FormChange_MovesTheTeamsTypeMatchup() + { + // 상성은 정체가 아니라 지금 모습으로 잰다 — 같은 공격이 모습에 따라 다른 피해를 낸다. + InsectData mantis = Data(Boss, 40000, 20, 60, InsectElement.Leaf); + InsectData butterfly = Data(Butterfly, 100, 10, 10, InsectElement.Wind); + InsectElement attackElement = InsectElement.None; + foreach (InsectElement e in System.Enum.GetValues(typeof(InsectElement))) + { + if (InsectTypeChart.GetEffectiveness(e, InsectElement.Leaf, InsectElement.None) + != InsectTypeChart.GetEffectiveness(e, InsectElement.Wind, InsectElement.None)) { attackElement = e; break; } + } + Assert.AreNotEqual(InsectElement.None, attackElement, "전제: 풀과 바람에 상성이 다른 속성이 있어야 한다"); + + var asMantis = new RaidBossStats(mantis, 10, 100000, 100, 100); + var asButterfly = new RaidBossStats(mantis, 10, 100000, 100, 100); + asButterfly.ChangeForm(butterfly); + InsectBattleStats attacker = new InsectBattleStats(Data("ally", 4000, 50, 50, InsectElement.None), 10); + InsectSkill hit = Skill("hit", SkillEffectType.Damage, 60, attackElement); + + int vsMantis = RaidRoundResolver.ResolveLeaderSkill(0, 0, attacker, asMantis, new[] { attacker }, hit, null).Damage; + int vsButterfly = RaidRoundResolver.ResolveLeaderSkill(0, 0, attacker, asButterfly, new[] { attacker }, hit, null).Damage; + float effMantis = InsectTypeChart.GetEffectiveness(attackElement, InsectElement.Leaf, InsectElement.None); + float effButterfly = InsectTypeChart.GetEffectiveness(attackElement, InsectElement.Wind, InsectElement.None); + Assert.AreEqual(effButterfly > effMantis, vsButterfly > vsMantis, + $"{attackElement} 공격 — 사마귀 {vsMantis}, 나비 모습 {vsButterfly}: 모습이 바뀌어도 약점이 그대로다"); + Assert.AreNotEqual(vsMantis, vsButterfly); + } + + [Test] + public void OneHitAcrossBothThresholds_FiresOneEventToTheLastForm() + { + RaidBattleController raid = Raid(out List changes); + DamageTo(raid, 20); + ActOnce(raid); + + Assert.AreEqual(1, changes.Count, "임계 둘을 한 번에 넘으면 이벤트는 한 번"); + Assert.AreEqual(0, changes[0].FromIndex); + Assert.AreEqual(2, changes[0].ToIndex); + Assert.IsTrue(changes[0].SkippedStages); + Assert.AreEqual(Firefly, changes[0].ToData.insectId); + Assert.AreEqual(Boss, changes[0].FromData.insectId); + Assert.AreEqual("그림자가 Blue Firefly의 모습을 빌렸다!", changes[0].Line); + Assert.AreEqual(changes[0].Line, raid.BossFormLine); + } + + [Test] + public void FormChange_FiresBeforeTheMemberActionEvent() + { + // 행동을 받은 화면이 이미 바뀐 모습을 읽고, 그 공격 연출이 끝난 뒤 변신 단계를 끼울 수 있게 — 순서를 고정한다. + RaidBattleController raid = Raid(out _); + var order = new List(); + raid.BossFormChanged += c => order.Add("form"); + raid.RaidMemberActionResolved += a => order.Add("action"); + DamageTo(raid, 50); + ActOnce(raid); + CollectionAssert.AreEqual(new[] { "form", "action" }, order); + } + + [Test] + public void KillingBlow_DoesNotTransform() + { + RaidBattleController raid = Raid(out List changes, allyPower: 5000000); + ActOnce(raid); // 한 방에 쓰러진다 — 임계를 다 지나쳤어도 시체는 일어나지 않는다 + + Assert.AreEqual(0, raid.BossStats.CurrentHp, "전제: 보스가 쓰러져야 한다"); + Assert.AreEqual(0, changes.Count); + Assert.AreEqual(0, raid.BossFormIndex); + Assert.AreEqual(Boss, raid.BossFormData.insectId); + } + + [Test] + public void BossWithoutForms_IsUntouched() + { + RaidBattleController raid = Raid(out List changes, bossId: "boss"); + Assert.AreEqual(1, raid.BossFormCount); + DamageTo(raid, 10); + RunRound(raid); + RunRound(raid); + + Assert.AreEqual(0, changes.Count); + Assert.AreEqual(0, raid.BossFormIndex); + Assert.AreSame(raid.BossStats.Data, raid.BossStats.CombatData); + Assert.AreEqual("sig_mantis", raid.NextBossIntent.Skill.skillId, "변신 없는 보스의 기술 고르기가 바뀌었다"); + Assert.AreEqual(string.Empty, raid.BossFormLine); + } + + [Test] + public void MissingFormData_StaysInTheOriginalForm() + { + // DB에서 모습 곤충을 못 찾으면 바꾸지 않는다(경고 한 번) — 반쯤 바뀐 상태(번호만 오르고 속성은 그대로)가 되지 않게. + RaidBattleController raid = Raid(out List changes); + raid.SetInsectLookup(id => null); + DamageTo(raid, 50); + + // LogAssert(UnityEngine.TestTools)는 이 어셈블리에서 참조가 안 된다(asmdef 없음) — 로그 이벤트로 직접 센다. + int warnings = 0; + Application.LogCallback count = (message, stack, type) => + { + if (type == LogType.Warning && message.StartsWith("[Raid]")) warnings++; + }; + Application.logMessageReceived += count; + try + { + ActOnce(raid); + ActOnce(raid); + } + finally + { + Application.logMessageReceived -= count; + } + + Assert.AreEqual(1, warnings, "모습을 못 찾았다는 경고는 한 번만 — 행동마다 찍으면 로그가 도배된다"); + Assert.AreEqual(0, changes.Count); + Assert.AreEqual(0, raid.BossFormIndex); + Assert.AreSame(raid.BossStats.Data, raid.BossStats.CombatData); + } + + // ── 도우미 ── + + private InsectData mantisData; + + private RaidBattleController Raid(out List changes, string bossId = Boss, int allyPower = 1) + { + mantisData = Data(bossId, 40000, 20, 60, InsectElement.Leaf); + mantisData.learnset = new[] { Learn(Skill("sig_mantis", SkillEffectType.Damage, 4, InsectElement.Leaf, signature: true), 1) }; + + InsectData butterfly = Data(Butterfly, 100, 10, 10, InsectElement.Wind); + butterfly.displayName = "Swallowtail"; + butterfly.learnset = new[] + { + Learn(Skill("flutter", SkillEffectType.Damage, 30, InsectElement.Wind), 1), + Learn(Skill("sig_butterfly", SkillEffectType.Damage, 5, InsectElement.Wind, signature: true), 1), + }; + InsectData firefly = Data(Firefly, 100, 10, 10, InsectElement.Light); + firefly.displayName = "Blue Firefly"; + firefly.learnset = new[] + { + Learn(Skill("glow_weak", SkillEffectType.Damage, 20, InsectElement.Light), 1), + Learn(Skill("glow_strong", SkillEffectType.Damage, 50, InsectElement.Light), 1), + Learn(Skill("glow_heal", SkillEffectType.Heal, 90, InsectElement.Light), 1), + Learn(Skill("glow_late", SkillEffectType.Damage, 99, InsectElement.Light), 50), // 아직 못 배웠다(레벨 10) + }; + var forms = new Dictionary { [Butterfly] = butterfly, [Firefly] = firefly }; + + GameObject bossObject = Track(new GameObject("FormTestBoss")); + InsectEntity bossEntity = bossObject.AddComponent(); + SetField(bossEntity, "data", mantisData); + SetField(bossEntity, "level", 10); + + GameObject controllerObject = Track(new GameObject("FormTestController")); + RaidBattleController controller = controllerObject.AddComponent(); + controller.SetRandomSource(new LowestSlotRandomSource()); + controller.SetCritSource(null); + controller.SetInsectLookup(id => forms.TryGetValue(id, out InsectData d) ? d : null); + var list = new List(); + controller.BossFormChanged += c => list.Add(c); + changes = list; + + InsectData[] team = new InsectData[5]; + int[] levels = new int[5]; + InsectSkill[][] skills = new InsectSkill[5][]; + InsectSkill poke = Skill("poke", SkillEffectType.Damage, allyPower, InsectElement.Bug); + for (int i = 0; i < 5; i++) + { + team[i] = Data($"ally_{i}", 4000, 20, 200, InsectElement.Bug); + levels[i] = 10; + skills[i] = new[] { poke }; + } + + Assert.IsTrue(controller.StartRaid(bossEntity, team, levels, null, skills)); + return controller; + } + + // HP를 최대의 percent%로 맞춘다(비율 보정 없는 ApplyDamage) — 변신은 다음 팀 행동이 부른다. + private static void DamageTo(RaidBattleController raid, int percent) + { + int target = raid.BossStats.MaxHp * percent / 100; + int cut = raid.BossStats.CurrentHp - target; + if (cut > 0) raid.BossStats.ApplyDamage(cut); + } + + // 팀원 한 마리가 행동한다. 팀 턴이 닫혀 있으면 보스 응답·라운드 마무리를 먼저 돌린다. + private static void ActOnce(RaidBattleController raid) + { + if (!raid.CanSubmitTeamCommand) + { + raid.ResolveBossResponse(); + raid.CompleteRoundPresentation(); + } + Assert.IsTrue(raid.CanSubmitTeamCommand, "팀이 행동할 차례가 와야 한다"); + Assert.IsNotNull(raid.ResolveTeamCommand(0)); + } + + private static void RunRound(RaidBattleController raid) + { + if (!raid.CanSubmitTeamCommand) + { + raid.ResolveBossResponse(); + raid.CompleteRoundPresentation(); + } + int guard = raid.TeamStats.Length + 1; + while (guard-- > 0 && raid.CanSubmitTeamCommand) + Assert.IsNotNull(raid.ResolveTeamCommand(0)); + raid.ResolveBossResponse(); + Assert.IsTrue(raid.CompleteRoundPresentation()); + } + + private InsectData Data(string id, int hp, int attack, int defense, InsectElement element) + { + InsectData data = Track(ScriptableObject.CreateInstance()); + data.insectId = id; + data.displayName = id; + data.primaryType = element; + data.secondaryType = InsectElement.None; + data.baseHp = hp; + data.baseAtk = attack; + data.baseDef = defense; + data.candyReward = 1; + data.expReward = 1; + return data; + } + + private InsectSkill Skill(string id, SkillEffectType effectType, int power, InsectElement element, + bool signature = false) + { + InsectSkill skill = Track(ScriptableObject.CreateInstance()); + skill.skillId = id; + skill.displayName = id; + skill.element = element; + skill.effectType = effectType; + skill.power = power; + skill.cooldownTurns = 0; + skill.accuracy = 1f; + skill.effectValue = effectType == SkillEffectType.Heal ? 0.1f : 0f; + skill.isSignatureSkill = signature; + return skill; + } + + private static InsectLearnableSkill Learn(InsectSkill skill, int level) + { + return new InsectLearnableSkill { skillId = skill.skillId, learnLevel = level, skill = skill }; + } + + private InsectDatabase RealDatabase() + { + var host = Track(new GameObject("RaidBossFormTests_RealDb")); + host.SetActive(false); // Awake(월드 생성)를 막고 private 생성 함수만 부른다 + var bootstrap = host.AddComponent(); + var db = (InsectDatabase)typeof(PlaySceneBootstrap) + .GetMethod("EnsureExpandedDatabase", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(bootstrap, null); + Assert.IsNotNull(db); + Track(db); + return db; + } + + private T Track(T obj) where T : Object + { + created.Add(obj); + return obj; + } + + private static void SetField(object target, string fieldName, object value) + { + FieldInfo field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(field, fieldName); + field.SetValue(target, value); + } + + private sealed class LowestSlotRandomSource : IRaidRandomSource + { + public float Next01() => 0f; + public int NextInt(int minInclusive, int maxExclusive) => minInclusive; + } + } +} +#endif diff --git a/Assets/Tests/EditMode/RaidBossFormTests.cs.meta b/Assets/Tests/EditMode/RaidBossFormTests.cs.meta new file mode 100644 index 0000000..a0e598d --- /dev/null +++ b/Assets/Tests/EditMode/RaidBossFormTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f4122b97545014e4091f66501fa43eae \ No newline at end of file diff --git a/Assets/Tests/EditMode/RaidBossPhaseTests.cs b/Assets/Tests/EditMode/RaidBossPhaseTests.cs index 9e6ea0b..069148d 100644 --- a/Assets/Tests/EditMode/RaidBossPhaseTests.cs +++ b/Assets/Tests/EditMode/RaidBossPhaseTests.cs @@ -181,6 +181,7 @@ private RaidBattleController Raid(InsectLearnableSkill[] bossLearnset) RaidBattleController controller = controllerObject.AddComponent(); controller.SetRandomSource(new LowestSlotRandomSource()); + controller.SetCritSource(null); // 치명타는 별개 줄기 — 피해·라운드를 단언하므로 끈다 InsectData[] team = new InsectData[5]; int[] levels = new int[5]; diff --git a/Assets/Tests/EditMode/RaidCameraFramingTests.cs b/Assets/Tests/EditMode/RaidCameraFramingTests.cs index 5fe2589..0c6127c 100644 --- a/Assets/Tests/EditMode/RaidCameraFramingTests.cs +++ b/Assets/Tests/EditMode/RaidCameraFramingTests.cs @@ -15,6 +15,30 @@ namespace InsectGame.Tests [TestFixture] public class RaidCameraFramingTests { + [Test] + public void BossMarker_IsGroundRing_NotAnEnclosingOpaqueSurface() + { + var root = new GameObject("BossMarkerTest"); + try + { + var arena = root.AddComponent(); + var boss = new GameObject("Boss"); + boss.transform.SetParent(root.transform); + boss.transform.position = new Vector3(0f, 2f, 0f); + boss.transform.localScale = Vector3.one * 2f; + typeof(BattleArenaController).GetMethod("CreateBossAura", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + .Invoke(arena, new object[] { boss.transform }); + var marker = boss.transform.Find("BossAura"); + Assert.IsNotNull(marker); + Assert.IsNull(marker.GetComponent(), "A closed surface must not cover the boss."); + LineRenderer ring = marker.GetComponent(); + Assert.IsTrue(ring.loop); + for (int i = 0; i < ring.positionCount; i++) + Assert.AreEqual(0.055f, marker.TransformPoint(ring.GetPosition(i)).y, 0.001f); + } + finally { Object.DestroyImmediate(root); } + } // 실제 아레나 배치(BattleArenaController.SetupRaidBattle)와 같은 상대 좌표. private static readonly Vector3 TeamPos = new Vector3(0f, 0.5f, -2f); private static readonly Vector3 BossPos = new Vector3(0f, 2.2f, 3f); diff --git a/Assets/Tests/EditMode/RaidStartGuardTests.cs b/Assets/Tests/EditMode/RaidStartGuardTests.cs index 9ea25c6..800dcb2 100644 --- a/Assets/Tests/EditMode/RaidStartGuardTests.cs +++ b/Assets/Tests/EditMode/RaidStartGuardTests.cs @@ -122,6 +122,7 @@ private RaidBattleController CreateController() GameObject controllerObject = Track(new GameObject("RaidGuardTestController")); RaidBattleController controller = controllerObject.AddComponent(); controller.SetRandomSource(new FixedRaidRandomSource()); + controller.SetCritSource(null); // 치명타는 별개 줄기 — 결정적으로 둔다 return controller; } diff --git a/Assets/Tests/EditMode/RaidSynchronizedRoundTests.cs b/Assets/Tests/EditMode/RaidSynchronizedRoundTests.cs index 1409ea1..0a7559c 100644 --- a/Assets/Tests/EditMode/RaidSynchronizedRoundTests.cs +++ b/Assets/Tests/EditMode/RaidSynchronizedRoundTests.cs @@ -741,6 +741,7 @@ private RaidBattleController CreateRaid(InsectData bossData, RaidBattleController controller = controllerObject.AddComponent(); controller.SetRandomSource(random ?? new FixedRaidRandomSource()); + controller.SetCritSource(null); // 치명타는 별개 줄기 — 피해를 정확히 단언하므로 끈다 int[] levels = Enumerable.Repeat(10, teamData.Length).ToArray(); InsectSkill[][] skills = new InsectSkill[teamData.Length][]; diff --git a/Assets/Tests/EditMode/RegionActionRegionTests.cs b/Assets/Tests/EditMode/RegionActionRegionTests.cs new file mode 100644 index 0000000..c3cd9d8 --- /dev/null +++ b/Assets/Tests/EditMode/RegionActionRegionTests.cs @@ -0,0 +1,51 @@ +#if UNITY_EDITOR +using InsectGame.Core; +using InsectGame.Data; +using NUnit.Framework; + +namespace InsectGame.Tests +{ + /// + /// 행동이 일어난 리전() — 지역 의뢰()와 스토리 리전 게이트가 읽는다. + /// + /// 나의 섬은 분리 서브에리어라 들어가 있는 동안 CurrentRegion이 떠나기 전 리전으로 남는다(sticky). 그 값을 그대로 읽으면 + /// 섬에 찾아온 손님 곤충을 잡을 때 그 리전의 지역 의뢰·스토리 포획으로 셌다(2026-10-03). + /// + [TestFixture] + public class RegionActionRegionTests + { + private static readonly RegionData Pond = new RegionData { regionId = "pond" }; + + [Test] + public void ActionRegion_OnTheField_IsTheCurrentRegion() + { + Assert.AreEqual("pond", RegionManager.ActionRegionIdOf(Pond, null)); + Assert.IsNull(RegionManager.ActionRegionIdOf(null, null), "길 위"); + } + + [Test] + public void ActionRegion_InACave_StaysInsideItsRegion() + { + // 지역 의뢰는 서브에리어 안의 행동도 센다(rules/quest-system.md) — 동굴은 그 리전 안이다. + var cave = new SubAreaData { subAreaId = "pond_deep", detached = false }; + Assert.AreEqual("pond", RegionManager.ActionRegionIdOf(Pond, cave)); + } + + [Test] + public void ActionRegion_OnTheIsland_IsNoRegion() + { + var island = new SubAreaData { subAreaId = GameConstants.Island.SubAreaId, detached = true }; + Assert.IsNull(RegionManager.ActionRegionIdOf(Pond, island), "섬에서 잡은 손님이 떠나기 전 리전으로 세어진다"); + } + + [Test] + public void RegionQuest_DoesNotCountACaptureOnTheIsland() + { + var island = new SubAreaData { subAreaId = GameConstants.Island.SubAreaId, detached = true }; + Assert.IsFalse(QuestRegionGate.Counts("pond", RegionManager.ActionRegionIdOf(Pond, island))); + Assert.IsTrue(QuestRegionGate.Counts("pond", RegionManager.ActionRegionIdOf(Pond, null))); + Assert.IsTrue(QuestRegionGate.Counts(null, RegionManager.ActionRegionIdOf(Pond, island)), "리전 제한 없는 의뢰는 섬에서도 센다"); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/RegionActionRegionTests.cs.meta b/Assets/Tests/EditMode/RegionActionRegionTests.cs.meta new file mode 100644 index 0000000..87d5508 --- /dev/null +++ b/Assets/Tests/EditMode/RegionActionRegionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 93713e2c3d8c38448ad431ffe09721a5 \ No newline at end of file diff --git a/Assets/Tests/EditMode/RegionManagerStartPolicyTests.cs b/Assets/Tests/EditMode/RegionManagerStartPolicyTests.cs index e06aa8f..9eb2b52 100644 --- a/Assets/Tests/EditMode/RegionManagerStartPolicyTests.cs +++ b/Assets/Tests/EditMode/RegionManagerStartPolicyTests.cs @@ -49,6 +49,55 @@ public void TearDown() PlayerPrefs.Save(); } + [Test] + public void ForceExitSubArea_ClearsStaleEntranceBeforeAnotherInput() + { + var nearby = new SubAreaData { subAreaId = "test_exit" }; + typeof(RegionManager).GetField("nearbySubArea", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(manager, nearby); + manager.RequestEnterSubArea(); + manager.ForceExitSubArea(); + manager.RequestEnterSubArea(); + Assert.IsNull(manager.CurrentSubArea); + Assert.IsNull(manager.NearbySubArea); + Assert.IsFalse(manager.SubAreaSticky); + } + + [Test] + public void SafeSubAreaSpawn_BlockedEntrance_DoesNotChooseBeyondSmallestWalls() + { + Vector3 origin = new Vector3(4000f, 0f, 4000f); + var blocker = GameObject.CreatePrimitive(PrimitiveType.Cube); + try + { + blocker.transform.position = origin + new Vector3(0f, 1.5f, -5f); + blocker.transform.localScale = new Vector3(9f, 4f, 7f); + Physics.SyncTransforms(); + var method = typeof(SubAreaWorldBuilder).GetMethod("FindSafeSpawnPosition", + BindingFlags.Static | BindingFlags.NonPublic); + Vector3 spawn = (Vector3)method.Invoke(null, new object[] { origin }); + Assert.That(spawn.x - origin.x, Is.InRange(-8f, 8f)); + Assert.That(spawn.z - origin.z, Is.InRange(-8f, 8f)); + Assert.IsFalse(blocker.GetComponent().bounds.Contains(spawn + Vector3.up)); + } + finally { Object.DestroyImmediate(blocker); } + } + + [Test] + public void SubAreaDecoration_ColliderDisabledBeforeDeferredDestruction() + { + var builder = managerObject.AddComponent(); + var decoration = GameObject.CreatePrimitive(PrimitiveType.Cube); + try + { + Collider collider = decoration.GetComponent(); + typeof(SubAreaWorldBuilder).GetMethod("NoCollider", BindingFlags.Instance | BindingFlags.NonPublic) + .Invoke(builder, new object[] { decoration }); + Assert.IsFalse(collider.enabled); + } + finally { Object.DestroyImmediate(decoration); } + } + [Test] public void Initialize_LegacyLastSubAreaIdExists_DeletesLegacyKey() { diff --git a/Assets/Tests/EditMode/RegionStoryLockTests.cs b/Assets/Tests/EditMode/RegionStoryLockTests.cs new file mode 100644 index 0000000..87ed43a --- /dev/null +++ b/Assets/Tests/EditMode/RegionStoryLockTests.cs @@ -0,0 +1,205 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.NPC; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 이야기 잠금 — 서릿길은 집게를, 잿불 골짜기는 저울을 이겨야 열린다(앞 리전 수문장에 더해서, 2026-10-04). + /// + /// 판정의 단일 출처는 다(울타리 통행·지도·HUD·지역 의뢰·섬 손님이 전부 읽는다). + /// 깨지면 조용하다: 간부를 이겼는데 영영 안 열리거나, 수문장만 이기면 열려 간부전이 필수가 아니게 되거나, + /// 이미 열렸던 지역이 닫혀 그 안을 돌던 플레이어가 갇힌다. 안내 문구가 이미 이긴 수문장을 다시 가리키는 것도 여기서 본다. + /// + [TestFixture] + public class RegionStoryLockTests + { + private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + + // ── 순수 판정 ── + + [Test] + public void SavedUnlock_NeverCloses() + { + // 이야기 잠금이 생기기 전에 열린 서릿길 — 간부를 안 이겼어도 그대로 열려 있다. + Assert.IsTrue(RegionManager.IsOpenByProgress("frostline", true, false, _ => false)); + } + + [Test] + public void GuardianOnly_StoryLockedRegionStaysClosed() + { + Assert.IsFalse(RegionManager.IsOpenByProgress("frostline", false, true, _ => false)); + Assert.IsFalse(RegionManager.IsOpenByProgress("emberfall", false, true, id => id == "ledger_grip"), + "잿불 골짜기의 열쇠는 저울이다 — 집게 승리로는 안 열린다"); + } + + [Test] + public void GuardianAndBoss_Opens() + { + Assert.IsTrue(RegionManager.IsOpenByProgress("frostline", false, true, id => id == "ledger_grip")); + Assert.IsTrue(RegionManager.IsOpenByProgress("emberfall", false, true, id => id == "ledger_scale")); + } + + [Test] + public void BossOnly_WithoutGuardian_StaysClosed() + { + Assert.IsFalse(RegionManager.IsOpenByProgress("frostline", false, false, _ => true), + "간부만 이기고 앞 리전 수문장을 안 넘었으면 닫혀 있다 — 잠금은 더하기다"); + } + + [Test] + public void OtherRegions_GuardianIsEnough_AndNoGateMeansOldBehavior() + { + Assert.IsTrue(RegionManager.IsOpenByProgress("dunes", false, true, _ => false)); + Assert.IsFalse(RegionManager.IsOpenByProgress("dunes", false, false, _ => true)); + // 판정 함수가 없으면(미배선) 옛 동작 — 수문장만으로 열린다. 배선이 빠져 영영 못 여는 쪽보다 낫다. + Assert.IsTrue(RegionManager.IsOpenByProgress("frostline", false, true, null)); + } + + [Test] + public void DescribeLockText_NamesTheRealKey() + { + Assert.AreEqual("서릿길 — 모래언덕의 장수말벌에게 이겨야 열립니다", + RegionManager.DescribeLockText("서릿길", RegionManager.LockKind.Guardian, "모래언덕의 장수말벌", "집게")); + Assert.AreEqual("서릿길 — 집게에게 이겨야 열립니다", + RegionManager.DescribeLockText("서릿길", RegionManager.LockKind.StoryDuel, "모래언덕의 장수말벌", "집게")); + Assert.AreEqual("서릿길 — 아직 갈 수 없습니다", + RegionManager.DescribeLockText("서릿길", RegionManager.LockKind.Unknown, null, null), "원인을 모르면 지어내지 않는다"); + Assert.AreEqual(string.Empty, RegionManager.DescribeLockText("서릿길", RegionManager.LockKind.Open, null, null)); + } + + // ── 표 ── + + [Test] + public void StoryLocks_AreGripAndScale_WithTheirDialogueNames() + { + var expected = new Dictionary { ["frostline"] = "ledger_grip", ["emberfall"] = "ledger_scale" }; + var actual = new Dictionary(); + var regions = new Dictionary(); + foreach (RegionData r in RegionDefinitions.CreateAll()) regions[r.regionId] = r; + + foreach (RegionManager.StoryLock l in RegionManager.AllStoryLocks()) + { + actual[l.regionId] = l.storyNpcId; + Assert.IsTrue(regions.ContainsKey(l.regionId), $"{l.regionId}: 없는 리전"); + Assert.IsTrue(NpcBossDuels.TryGet(l.storyNpcId, out NpcBossDuels.BossDuel duel), $"{l.storyNpcId}: 간부 대결 표에 없다"); + // 안내 문구의 이름이 대결 컷인·대사창 이름표와 같아야 한다. + Assert.AreEqual(duel.displayName, l.displayName, l.regionId); + Assert.AreEqual(NpcDialogueDatabase.StorySpeakerName(l.storyNpcId), l.displayName, l.regionId); + } + CollectionAssert.AreEquivalent(expected, actual); + } + + // ── 매니저 — 실제 판정 경로 (저장은 부르지 않는다) ── + + // 마스터 계정 우회(AuthManager.MasterPrivilegesActive)는 모든 리전을 연다. 앞선 통합 테스트가 PlayScene을 띄우면 + // AuthManager가 남아 이 PC의 마스터 계정 표지를 들고 있을 수 있다 — 그러면 잠금을 잴 수 없어 예전엔 Inconclusive로 + // 건너뛰었다(2026-10-04 전체 실행에서 3건). 잠금 판정 테스트 동안만 Instance를 비우고 끝나면 되돌린다. + private AuthManager authBeforeTest; + private static readonly PropertyInfo AuthInstance = typeof(AuthManager).GetProperty("Instance"); + + [SetUp] + public void HideAuthManager() + { + authBeforeTest = AuthManager.Instance; + AuthInstance.GetSetMethod(true).Invoke(null, new object[] { null }); + } + + [TearDown] + public void RestoreAuthManager() + { + AuthInstance.GetSetMethod(true).Invoke(null, new object[] { authBeforeTest }); + authBeforeTest = null; + } + + private static RegionManager NewManager(GameObject host, IEnumerable unlocked, IEnumerable guardians, + System.Func duelWon) + { + var manager = host.AddComponent(); + typeof(RegionManager).GetField("regions", Private).SetValue(manager, RegionDefinitions.CreateAll()); + typeof(RegionManager).GetField("unlockedRegions", Private).SetValue(manager, new HashSet(unlocked)); + typeof(RegionManager).GetField("defeatedGuardians", Private).SetValue(manager, new HashSet(guardians)); + manager.AutoWireDuelGate(duelWon); + return manager; + } + + [Test] + public void Manager_GuardianBeaten_GripNot_FrostlineLocked_PointsAtGrip() + { + GameObject host = new GameObject("RegionStoryLockTests"); + host.SetActive(false); + try + { + var beaten = new HashSet(); + RegionManager rm = NewManager(host, new[] { "meadow", "pond", "dunes" }, new[] { "hollow", "dunes" }, beaten.Contains); + RegionData frost = rm.GetRegionById("frostline"); + + Assert.IsFalse(rm.IsRegionAccessible(frost), "수문장만 이겼다 — 집게가 남았다"); + Assert.AreEqual(RegionManager.LockKind.StoryDuel, rm.GetLockKind(frost, out RegionData gate, out RegionManager.StoryLock lk)); + Assert.AreEqual("dunes", gate.regionId); + Assert.AreEqual("ledger_grip", lk.storyNpcId); + Assert.AreEqual("서릿길 — 집게에게 이겨야 열립니다", rm.DescribeLock(frost)); + + beaten.Add("ledger_grip"); + Assert.IsTrue(rm.IsRegionAccessible(frost), "집게를 이기는 순간 열린다(해금 기록 없이 두 격파 기록으로)"); + Assert.AreEqual(string.Empty, rm.DescribeLock(frost)); + } + finally { Object.DestroyImmediate(host); } + } + + [Test] + public void Manager_GuardianNotBeaten_PointsAtGuardianFirst() + { + GameObject host = new GameObject("RegionStoryLockTestsGuardian"); + host.SetActive(false); + try + { + RegionManager rm = NewManager(host, new[] { "meadow" }, new string[0], _ => false); + RegionData frost = rm.GetRegionById("frostline"); + Assert.AreEqual(RegionManager.LockKind.Guardian, rm.GetLockKind(frost, out RegionData gate, out _)); + Assert.AreEqual("dunes", gate.regionId); + StringAssert.Contains(gate.guardianDisplayName, rm.DescribeLock(frost)); + } + finally { Object.DestroyImmediate(host); } + } + + [Test] + public void Manager_SavedUnlock_StaysOpen_EvenWithoutGrip() + { + GameObject host = new GameObject("RegionStoryLockTestsSaved"); + host.SetActive(false); + try + { + RegionManager rm = NewManager(host, new[] { "meadow", "frostline" }, new[] { "dunes" }, _ => false); + Assert.IsTrue(rm.IsRegionAccessible(rm.GetRegionById("frostline")), + "이야기 잠금 전에 열린 지역은 닫지 않는다 — 안을 돌던 플레이어를 가두지 않는다"); + } + finally { Object.DestroyImmediate(host); } + } + + [Test] + public void Manager_DefeatGuardian_HoldsTheUnlockRecordWhileTheBossRemains() + { + // DefeatGuardian 자체는 저장을 부르므로 여기선 그 분기 판정만 본다 — 해금 기록을 적으면 영영 열린다. + GameObject host = new GameObject("RegionStoryLockTestsHold"); + host.SetActive(false); + try + { + var beaten = new HashSet(); + RegionManager rm = NewManager(host, new[] { "meadow" }, new string[0], beaten.Contains); + MethodInfo held = typeof(RegionManager).GetMethod("IsHeldByStoryLock", Private); + Assert.IsTrue((bool)held.Invoke(rm, new object[] { "frostline" })); + Assert.IsFalse((bool)held.Invoke(rm, new object[] { "hollow" }), "이야기 잠금이 없는 리전은 바로 적는다"); + beaten.Add("ledger_grip"); + Assert.IsFalse((bool)held.Invoke(rm, new object[] { "frostline" }), "집게를 먼저 이겼으면 수문장 격파가 곧바로 연다"); + } + finally { Object.DestroyImmediate(host); } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/RegionStoryLockTests.cs.meta b/Assets/Tests/EditMode/RegionStoryLockTests.cs.meta new file mode 100644 index 0000000..b754de3 --- /dev/null +++ b/Assets/Tests/EditMode/RegionStoryLockTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 971e36ace86b0e64eab88f2ecc85c35c \ No newline at end of file diff --git a/Assets/Tests/EditMode/SaveScopeTests.cs b/Assets/Tests/EditMode/SaveScopeTests.cs index ce3547d..db81f27 100644 --- a/Assets/Tests/EditMode/SaveScopeTests.cs +++ b/Assets/Tests/EditMode/SaveScopeTests.cs @@ -21,6 +21,20 @@ public void ScopedFiles_ContainsStoryProgress_ForMigrationAndCleanup() CollectionAssert.Contains(scopedFiles, GameConstants.SaveFiles.StoryProgress); } + [Test] + public void ScopedFiles_ContainsIsland_ForAccountCleanup() + { + // 빠지면 계정을 지우고 같은 uid로 다시 로그인했을 때 옛 섬이 되살아난다. + FieldInfo field = typeof(SaveScope).GetField( + "ScopedFiles", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.IsNotNull(field); + string[] scopedFiles = field.GetValue(null) as string[]; + Assert.IsNotNull(scopedFiles); + CollectionAssert.Contains(scopedFiles, GameConstants.SaveFiles.Island); + } + [Test] public void MigrationVersion_StoryProgressAddition_IsAtLeastFour() { diff --git a/Assets/Tests/EditMode/SceneryBatcherTests.cs b/Assets/Tests/EditMode/SceneryBatcherTests.cs new file mode 100644 index 0000000..1e1a9c0 --- /dev/null +++ b/Assets/Tests/EditMode/SceneryBatcherTests.cs @@ -0,0 +1,240 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Core; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.Rendering; + +namespace InsectGame.Tests +{ + /// + /// 장식 배처()와 색표()의 순수 규칙. + /// + /// 셋 다 틀려도 조용하다. UV가 칸 경계에 걸리면 이웃 색이 번지고, 팔레트가 넘치면 색별 머티리얼로 + /// 떨어져 드로우콜만 늘고, 인덱스 폭이 모자라면 삼각형이 엉뚱한 정점을 이어 조각이 튄다 — 예외는 없다. + /// 메시·오브젝트를 만드는 합치기 자체는 FieldDesignTour 캡처로 본다. + /// + [TestFixture] + public class SceneryBatcherTests + { + private static Color Distinct(int i) => new Color(i / 1024f, (i % 7) / 7f, 0.5f, 1f); + + [Test] + public void PaletteUV_EveryIndex_SamplesCenterOfItsOwnTexel() + { + // 점 필터라도 UV가 칸 경계에 있으면 반올림 방향에 따라 이웃 칸을 읽는다 — 칸 중앙이어야 한다 + for (int i = 0; i < SceneryPalette.Size; i++) + { + Vector2 uv = SceneryPalette.UV(i); + float texel = uv.x * SceneryPalette.Size; + Assert.AreEqual(i, Mathf.FloorToInt(texel), $"칸 {i}"); + Assert.AreEqual(0.5f, texel - i, 1e-3f, $"칸 {i}: 중앙이 아니다"); + Assert.AreEqual(0.5f, uv.y, 1e-6f); + } + } + + [Test] + public void PaletteTryIndex_SameColorTwice_ReturnsSameSlot() + { + var palette = new SceneryPalette(); + Assert.IsTrue(palette.TryIndex(Distinct(0), out int a)); + Assert.IsTrue(palette.TryIndex(Distinct(1), out int b)); + Assert.IsTrue(palette.TryIndex(Distinct(0), out int again)); + Assert.AreEqual(0, a); + Assert.AreEqual(1, b); + Assert.AreEqual(a, again, "같은 색이 칸을 두 개 먹으면 256칸이 금방 찬다"); + } + + [Test] + public void PaletteTryIndex_OverCapacity_FallsBackAndWarnsOnce() + { + var palette = new SceneryPalette(); + for (int i = 0; i < SceneryPalette.Size; i++) + { + Assert.IsTrue(palette.TryIndex(Distinct(i), out int slot), $"칸 {i}"); + Assert.AreEqual(i, slot); + } + + // LogAssert(UnityEngine.TestTools)는 이 어셈블리에서 참조가 안 된다(asmdef 없음) — 로그 이벤트로 직접 센다 + int warnings = 0; + void Count(string message, string stack, LogType type) + { + if (type == LogType.Warning && message.Contains("[SceneryPalette]")) warnings++; + } + Application.logMessageReceived += Count; + try + { + Assert.IsFalse(palette.TryIndex(Distinct(SceneryPalette.Size), out int overflow), "257번째 색은 색별 머티리얼로 떨어져야 한다"); + Assert.AreEqual(-1, overflow); + Assert.IsFalse(palette.TryIndex(Distinct(SceneryPalette.Size + 1), out _)); + Assert.IsFalse(palette.TryIndex(Distinct(SceneryPalette.Size), out _)); + + // 이미 든 색은 넘친 뒤에도 제 칸을 계속 받는다 — 넘친 색만 폴백한다 + Assert.IsTrue(palette.TryIndex(Distinct(3), out int kept)); + Assert.AreEqual(3, kept); + } + finally + { + Application.logMessageReceived -= Count; + } + + // 넘친 걸 알려야 하지만 한 번만 — 소품마다 부르므로 매번 찍으면 로그가 수천 줄이 된다 + Assert.AreEqual(1, warnings); + } + + [Test] + public void IndexFormatFor_Over65000Vertices_Uses32Bit() + { + Assert.AreEqual(IndexFormat.UInt16, SceneryBatcher.IndexFormatFor(0)); + Assert.AreEqual(IndexFormat.UInt16, SceneryBatcher.IndexFormatFor(65000)); + Assert.AreEqual(IndexFormat.UInt32, SceneryBatcher.IndexFormatFor(65001)); + Assert.AreEqual(IndexFormat.UInt32, SceneryBatcher.IndexFormatFor(200000)); + } + + // ── 색별 머티리얼 캐시 키 ── + // 옛 캐시는 색만 키로 써서, 같은 색이 한 배처에선 발광·다른 배처에선 광택으로 지정되면 먼저 만든 쪽으로 + // 합쳐졌다(한쪽이 엉뚱하게 빛나거나 빛을 잃는다 — 예외 없음). 아래 머티리얼 테스트는 씬 없이 Material만 만든다. + + private static readonly Color Shared = new Color(0.40f, 0.60f, 0.80f, 1f); + private static readonly Color Ember = new Color(1.2f, 0.5f, 0.1f, 1f); + + [Test] + public void KeyFor_SameColorDifferentSurface_Differs() + { + var plain = new SceneryBatcher(8f, null); + var glowA = new SceneryBatcher(8f, null); + glowA.MarkGlow(Shared, Ember); + var glowB = new SceneryBatcher(8f, null); + glowB.MarkGlow(Shared, Ember); + var glowDim = new SceneryBatcher(8f, null); + glowDim.MarkGlow(Shared, Ember * 0.5f); + var wet = new SceneryBatcher(8f, null); + wet.MarkWet(Shared); + + Assert.AreNotEqual(plain.KeyFor(Shared), glowA.KeyFor(Shared), "무광과 발광이 한 머티리얼로 합쳐진다"); + Assert.AreNotEqual(glowA.KeyFor(Shared), wet.KeyFor(Shared), "발광과 젖은 광택이 한 머티리얼로 합쳐진다"); + Assert.AreNotEqual(plain.KeyFor(Shared), wet.KeyFor(Shared), "무광과 젖은 광택이 한 머티리얼로 합쳐진다"); + Assert.AreNotEqual(glowA.KeyFor(Shared), glowDim.KeyFor(Shared), "발광 세기가 다른데 먼저 만든 쪽으로 합쳐진다"); + // 같은 표면은 여전히 하나로 모여야 한다 — 갈라지면 머티리얼·드로우콜이 배처 수만큼 는다 + Assert.AreEqual(glowA.KeyFor(Shared), glowB.KeyFor(Shared)); + Assert.AreEqual(glowA.KeyFor(Shared).GetHashCode(), glowB.KeyFor(Shared).GetHashCode()); + Assert.AreEqual(SceneryMaterialKey.Matte(Shared), plain.KeyFor(Shared)); + } + + [Test] + public void ColorMaterial_SharedCache_SameColorDifferentSurface_GetsDifferentMaterial() + { + var cache = new Dictionary(); + var owned = new List(); + var glow = new SceneryBatcher(8f, null); + glow.MarkGlow(Shared, Ember); + var glowToo = new SceneryBatcher(8f, null); + glowToo.MarkGlow(Shared, Ember); + var wet = new SceneryBatcher(8f, null); + wet.MarkWet(Shared); + try + { + Material glowing = glow.ColorMaterial(Shared, owned, cache); + Material wetOne = wet.ColorMaterial(Shared, owned, cache); + Assert.AreNotSame(glowing, wetOne, "같은 색이라도 표면이 다르면 머티리얼이 달라야 한다"); + Assert.AreSame(glowing, glowToo.ColorMaterial(Shared, owned, cache), "같은 색·같은 표면은 머티리얼 하나를 함께 쓴다"); + Assert.AreEqual(2, owned.Count, "회수 목록에는 만든 것만 한 번씩 오른다"); + if (glowing.HasProperty("_EmissionColor")) + { + Assert.IsTrue(glowing.IsKeywordEnabled("_EMISSION")); + Assert.IsFalse(wetOne.IsKeywordEnabled("_EMISSION"), "젖은 쪽이 발광 머티리얼을 받았다"); + } + } + finally + { + foreach (Material m in owned) if (m != null) Object.DestroyImmediate(m); + } + } + + [Test] + public void ColorMaterial_SharingScopeIsTheDictionaryInstance() + { + // 공유 범위는 넘긴 사전 인스턴스다 — 리전 장식(RegionDressingBuilder)과 울타리 장식(RegionTerrainBuilder)은 + // 각자 사전을 들고 각자 회수한다. 다른 사전끼리 머티리얼이 섞이면 한쪽 OnDestroy가 다른 쪽이 쓰는 걸 파괴한다. + var cacheA = new Dictionary(); + var cacheB = new Dictionary(); + var ownedA = new List(); + var ownedB = new List(); + var first = new SceneryBatcher(8f, null); + var second = new SceneryBatcher(8f, null); + try + { + Material a = first.ColorMaterial(Shared, ownedA, cacheA); + Assert.AreSame(a, second.ColorMaterial(Shared, ownedA, cacheA), "같은 사전을 넘긴 배처끼리는 같은 색·표면을 하나로 모아야 한다"); + Material b = second.ColorMaterial(Shared, ownedB, cacheB); + Assert.AreNotSame(a, b, "다른 사전을 넘긴 배처가 남의 머티리얼을 받았다"); + Assert.AreEqual(1, ownedA.Count); + Assert.AreEqual(1, ownedB.Count, "만든 머티리얼은 그 사전 소유자의 회수 목록에만 오른다"); + Assert.AreSame(a, cacheA[SceneryMaterialKey.Matte(Shared)], "캐시 키가 표면 키(무광)가 아니다"); + + // 소유자가 파괴한 뒤 다시 빌드하면(씬 재로드 전 재빌드 등) 파괴된 항목을 새로 만든다 + Object.DestroyImmediate(a); + Material again = first.ColorMaterial(Shared, ownedA, cacheA); + Assert.IsTrue(again != null, "파괴된 캐시 항목을 그대로 돌려줬다"); + } + finally + { + foreach (Material m in ownedA) if (m != null) Object.DestroyImmediate(m); + foreach (Material m in ownedB) if (m != null) Object.DestroyImmediate(m); + } + } + + // ── 머티리얼 생성·반투명 (빌더 다섯 곳이 공유) ── + + [Test] + public void Create_OpaqueIsMatte_TranslucentKeepsShaderGloss() + { + Material opaque = SceneryMaterials.Create(new Color(0.5f, 0.4f, 0.3f, 1f)); + Material glass = SceneryMaterials.Create(new Color(0.6f, 0.8f, 1f, 0.3f)); + try + { + Assert.AreSame(SceneryMaterials.LitShader, opaque.shader, "셰이더 캐시와 다른 셰이더로 만들었다"); + Assert.AreSame(SceneryMaterials.LitShader, SceneryMaterials.LitShader, "셰이더를 매번 다시 찾는다"); + if (opaque.HasProperty("_Glossiness")) + { + Assert.AreEqual(SceneryMaterials.MatteGloss, opaque.GetFloat("_Glossiness"), 1e-5f, "불투명 소품이 무광이 아니다"); + Assert.AreNotEqual(SceneryMaterials.MatteGloss, glass.GetFloat("_Glossiness"), "반투명은 셰이더 기본 광택을 유지해야 한다"); + } + Assert.IsFalse(SceneryMaterials.IsTranslucent(new Color(0.5f, 0.4f, 0.3f, 1f))); + Assert.IsTrue(SceneryMaterials.IsTranslucent(new Color(0.6f, 0.8f, 1f, 0.3f))); + } + finally + { + Object.DestroyImmediate(opaque); + Object.DestroyImmediate(glass); + } + } + + [Test] + public void MakeFade_SetsAlphaBlendTransparentQueueAndNoDepthWrite() + { + // Standard는 기본 Opaque라 알파를 넣어도 무시된다 — 이 넷이 함께 서야 실제로 비친다 + Material mat = SceneryMaterials.Create(new Color(0.2f, 0.4f, 0.7f, 0.5f)); + try + { + SceneryMaterials.MakeFade(mat); + Assert.AreEqual((int)RenderQueue.Transparent, mat.renderQueue); + Assert.IsTrue(mat.IsKeywordEnabled("_ALPHABLEND_ON")); + Assert.IsFalse(mat.IsKeywordEnabled("_ALPHAPREMULTIPLY_ON")); + Assert.IsFalse(mat.IsKeywordEnabled("_ALPHATEST_ON")); + if (mat.HasProperty("_SrcBlend")) + { + // Standard는 이 셋을 Float 프로퍼티로 선언한다 — 정수로 넣어도 실수로 읽힌다 + Assert.AreEqual((float)BlendMode.SrcAlpha, mat.GetFloat("_SrcBlend")); + Assert.AreEqual((float)BlendMode.OneMinusSrcAlpha, mat.GetFloat("_DstBlend")); + Assert.AreEqual(0f, mat.GetFloat("_ZWrite")); + } + } + finally + { + Object.DestroyImmediate(mat); + } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/SceneryBatcherTests.cs.meta b/Assets/Tests/EditMode/SceneryBatcherTests.cs.meta new file mode 100644 index 0000000..dce488b --- /dev/null +++ b/Assets/Tests/EditMode/SceneryBatcherTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: afeec7211b963c34499193e77de9feaa \ No newline at end of file diff --git a/Assets/Tests/EditMode/ShaderVariantKeeperTests.cs b/Assets/Tests/EditMode/ShaderVariantKeeperTests.cs new file mode 100644 index 0000000..ace51bb --- /dev/null +++ b/Assets/Tests/EditMode/ShaderVariantKeeperTests.cs @@ -0,0 +1,78 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Linq; +using InsectGame.Core; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 런타임에 켜는 Standard 키워드 조합이 전부 빌드에 남는가(). + /// 빠지면 빌드는 성공하는데 기기에서만 물·얼음이 불투명, 등불이 무발광으로 그려진다 — 에디터로는 절대 안 보인다. + /// + [TestFixture] + public class ShaderVariantKeeperTests + { + // 변형 선택에 쓰이는 Standard shader_feature 중 이 저장소가 런타임에 만지는 것. + private static readonly string[] Tracked = { "_ALPHABLEND_ON", "_ALPHATEST_ON", "_ALPHAPREMULTIPLY_ON", "_EMISSION" }; + + private readonly List made = new List(); + + [TearDown] + public void TearDown() + { + foreach (Material m in made) Object.DestroyImmediate(m); + made.Clear(); + } + + [Test] + public void BuildKeepers_CoverEveryKeywordComboTheSceneryPathsEnable() + { + Color opaque = new Color(0.4f, 0.5f, 0.3f); + Color glass = new Color(0.6f, 0.8f, 0.9f, 0.4f); + Color glow = new Color(1f, 0.8f, 0.3f); + + Material fade = Track(SceneryMaterials.Create(glass)); + SceneryMaterials.MakeFade(fade); + Material emission = Track(SceneryMaterials.Create(opaque)); + SceneryMaterials.SetEmission(emission, glow); + Material both = Track(SceneryMaterials.Create(glass)); // 연기 덩이 발광(SubAreaGateBuilder)의 순서 + SceneryMaterials.SetEmission(both, glow); + SceneryMaterials.MakeFade(both); + Material plain = Track(SceneryMaterials.Create(opaque)); + + if (plain.shader.name != "Standard") Assert.Ignore("Standard 없음 — 폴백 셰이더에선 키워드가 의미 없다"); + + var keeperSets = SceneryMaterials.BuildKeepers.Select(k => Key(k.keywords)).ToList(); + foreach (Material m in new[] { fade, emission, both }) + { + string combo = Key(Tracked.Where(k => m.IsKeywordEnabled(k))); + Assert.IsTrue(keeperSets.Contains(combo), $"런타임 조합 '{combo}'가 BuildKeepers에 없다 — 빌드에서 빠진다"); + } + Assert.AreEqual(string.Empty, Key(Tracked.Where(k => plain.IsKeywordEnabled(k))), "불투명 기본은 키워드가 없어야 한다"); + } + + [Test] + public void BuildKeepers_ExistInResources_WithExactlyTheirKeywords() + { + foreach ((string name, string[] keywords) in SceneryMaterials.BuildKeepers) + { + Material m = Resources.Load(SceneryMaterials.BuildKeeperFolder + "/" + name); + Assert.IsNotNull(m, $"Resources/{SceneryMaterials.BuildKeeperFolder}/{name}.mat 없음 — " + + "InsectGame.EditorTools.ShaderVariantKeepers.Ensure로 만든다"); + Assert.AreEqual("Standard", m.shader.name, name); + Assert.AreEqual(Key(keywords), Key(Tracked.Where(k => m.IsKeywordEnabled(k))), $"{name}: 키워드"); + } + } + + private Material Track(Material m) + { + made.Add(m); + return m; + } + + private static string Key(IEnumerable keywords) => string.Join("+", keywords.OrderBy(k => k)); + } +} +#endif diff --git a/Assets/Tests/EditMode/ShaderVariantKeeperTests.cs.meta b/Assets/Tests/EditMode/ShaderVariantKeeperTests.cs.meta new file mode 100644 index 0000000..447380b --- /dev/null +++ b/Assets/Tests/EditMode/ShaderVariantKeeperTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 85b0e211289e31440abbd1262280cff3 \ No newline at end of file diff --git a/Assets/Tests/EditMode/StoryBattleHudTests.cs b/Assets/Tests/EditMode/StoryBattleHudTests.cs new file mode 100644 index 0000000..5dc54db --- /dev/null +++ b/Assets/Tests/EditMode/StoryBattleHudTests.cs @@ -0,0 +1,437 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Battle; +using InsectGame.Data; +using InsectGame.Story; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 「이야기 전투」의 화면(2026-10-04) — 대화창 「승부!」, 1대1 팀 대결의 남은 곤충 공·교체 문구, 레이드 변신 문구·수문장 등장 배너, + /// 그리고 그 문구들의 조사(이/가·을/를). + /// + /// 자리는 전부 화면 파일의 순수 함수(··)를 부른다 — + /// 식을 여기 다시 세우지 않는다. IMGUI 그리기 자체는 QA 빌드(-battleScenario story·team-duel·raid-forms·guardian-intro)로 본다. + /// + [TestFixture] + public class StoryBattleHudTests + { + // ── 조사 ── + + [TestCase("집게", "가")] + [TestCase("저울", "이")] + [TestCase("관장 하월", "이")] + [TestCase("라온", "이")] + [TestCase("상대", "가")] + [TestCase("사마귀(이로치)", "가")] // 닫는 괄호는 건너뛰고 그 앞 글자(치) + [TestCase("나방!", "이")] + [TestCase("2", "가")] // 이 + [TestCase("3", "이")] // 삼 + [TestCase("10", "이")] // 십(끝 0 — 받침) + public void IGa_FollowsTheFinalConsonant(string word, string expected) + { + Assert.AreEqual(expected, KoreanJosa.IGa(word)); + } + + [TestCase("지네", "를")] + [TestCase("사슴벌레", "를")] + [TestCase("이름 잃은 나방", "을")] + [TestCase("아틀라스나방", "을")] + [TestCase("장수풍뎅이", "를")] + [TestCase("왕잠자리", "를")] + [TestCase("곰", "을")] + public void EulReul_FollowsTheFinalConsonant(string word, string expected) + { + Assert.AreEqual(expected, KoreanJosa.EulReul(word)); + } + + [Test] + public void OtherParticles_FollowTheFinalConsonant() + { + Assert.AreEqual("는", KoreanJosa.EunNeun("집게")); + Assert.AreEqual("은", KoreanJosa.EunNeun("저울")); + Assert.AreEqual("와", KoreanJosa.WaGwa("집게")); + Assert.AreEqual("과", KoreanJosa.WaGwa("저울")); + Assert.AreEqual("집게가", KoreanJosa.With("집게", KoreanJosa.IGa)); + } + + [TestCase("Atlas")] + [TestCase("")] + [TestCase(null)] + [TestCase("!!")] + public void UnknownEnding_WritesBothForms_InsteadOfGuessing(string word) + { + Assert.IsNull(KoreanJosa.HasFinalConsonant(word)); + Assert.AreEqual("이(가)", KoreanJosa.IGa(word)); + Assert.AreEqual("을(를)", KoreanJosa.EulReul(word)); + } + + [Test] + public void SendOutText_PicksParticlesForBothNames() + { + Assert.AreEqual("집게가 지네를 내보냈다!", DuelTeamHud.SendOutText("집게", "지네")); + Assert.AreEqual("관장 하월이 이름 잃은 나방을 내보냈다!", DuelTeamHud.SendOutText("관장 하월", "이름 잃은 나방")); + Assert.AreEqual("상대가 곤충을 내보냈다!", DuelTeamHud.SendOutText(null, "")); + } + + // ── 남은 곤충 공 ── + + [TestCase(2)] + [TestCase(3)] + [TestCase(5)] + public void TeamBalls_SitOnTheTopRow_BetweenLabelAndLevel(int count) + { + Rect card = new Rect(1436f, 100f, 460f, DuelHudLayout.HpCardHeight); + Rect row = DuelTeamHud.BallRow(card, count); + Assert.LessOrEqual(row.xMax, card.xMax - DuelTeamHud.LevelReserve + 0.01f, "레벨 글자를 밟는다"); + Assert.GreaterOrEqual(row.x, card.x + DuelTeamHud.CardTextInset + DuelTeamHud.MinLabelWidth - 0.01f, "「상대 곤충」 자리를 밟는다"); + Assert.AreEqual(DuelTeamHud.BallSize, row.height, 0.01f, "넓은 카드에선 공을 줄이지 않는다"); + // 윗줄(y+10~34) 안 — HP 이름 줄(y+36~)을 건드리지 않는다. + Assert.GreaterOrEqual(row.y - DuelTeamHud.CurrentRing, card.y + 8f); + Assert.LessOrEqual(row.yMax + DuelTeamHud.CurrentRing, card.y + 36f); + Rect previous = default; + for (int i = 0; i < count; i++) + { + Rect ball = DuelTeamHud.Ball(row, i, count); + Assert.GreaterOrEqual(ball.x, row.x - 0.01f); + Assert.LessOrEqual(ball.xMax, row.xMax + 0.01f); + if (i > 0) Assert.Greater(ball.x - previous.xMax, DuelTeamHud.CurrentRing * 2f - 0.01f, "고리 두른 공이 이웃 공에 닿는다"); + previous = ball; + } + } + + [Test] + public void TeamBalls_NarrowCard_ShrinkButKeepTheLabelRoom() + { + Rect card = new Rect(0f, 0f, 300f, DuelHudLayout.HpCardHeight); + Rect row = DuelTeamHud.BallRow(card, 5); + Assert.GreaterOrEqual(row.x, card.x + DuelTeamHud.CardTextInset + DuelTeamHud.MinLabelWidth - 0.01f); + Assert.LessOrEqual(row.xMax, card.xMax - DuelTeamHud.LevelReserve + 0.01f); + Assert.Less(row.height, DuelTeamHud.BallSize); + Assert.Greater(row.height, 0f); + } + + [Test] + public void ShownIndex_LagsOneBehindWhileTheFaintedInsectIsStillOnScreen() + { + Assert.AreEqual(0, DuelTeamHud.ShownIndex(1, true), "컨트롤러는 이미 둘째 — 화면은 아직 첫째가 쓰러지는 중"); + Assert.AreEqual(1, DuelTeamHud.ShownIndex(1, false)); + Assert.AreEqual(0, DuelTeamHud.ShownIndex(0, true)); + Assert.AreEqual(4, DuelTeamHud.ShownIndex(4, false)); + } + + [Test] + public void BallState_BeforeIsFainted_ShownFollowsItsBar_AfterIsWaiting() + { + Assert.AreEqual(DuelTeamHud.TeamBall.Fainted, DuelTeamHud.BallState(0, 1, false)); + Assert.AreEqual(DuelTeamHud.TeamBall.Current, DuelTeamHud.BallState(1, 1, false)); + Assert.AreEqual(DuelTeamHud.TeamBall.Fainted, DuelTeamHud.BallState(1, 1, true)); + Assert.AreEqual(DuelTeamHud.TeamBall.Waiting, DuelTeamHud.BallState(2, 1, false)); + } + + // ── 교체 문구 — 화면 여러 장에서 전투 HUD의 다른 자리와 겹치지 않는다 ── + + [TestCase(1280f, 720f, 0f, 0f, false)] + [TestCase(720f, 1280f, 0f, 0f, true)] + [TestCase(1920f, 1080f, 0f, 0f, false)] + [TestCase(1080f, 1920f, 0f, 0f, true)] + [TestCase(1080f, 2400f, 110f, 60f, true)] + [TestCase(2400f, 1080f, 0f, 0f, true)] + [TestCase(1440f, 1080f, 0f, 0f, false)] + public void SendOut_ClearsEveryBattleHudPiece(float width, float height, float notchTop, float gestureBottom, bool mobile) + { + HudFrame f = HudFrame.ForScreen(width, height, 0f, 0f, notchTop, gestureBottom, mobile); + Rect safe = DuelHudLayout.Safe(f); + Rect player = DuelHudLayout.HpCard(safe, true); + Rect enemy = DuelHudLayout.HpCard(safe, false); + // 상대 쪽 쌓기가 가장 긴 경우 — 장부 게이지 + 보정 칩 + 말풍선. + var pieces = new List> + { + Piece("내 HP 카드", player), + Piece("상대 HP 카드", enemy), + Piece("내 보정 칩", BattleHudStack.EnvironmentChip(player, true, false, player.width)), + Piece("연속 배지", BattleHudStack.ComboBadge(player, true)), + Piece("장부 게이지", BattleHudStack.LedgerGauge(enemy)), + Piece("상대 보정 칩", BattleHudStack.EnvironmentChip(enemy, false, true, enemy.width)), + Piece("대결 말풍선", BattleHudStack.Bubble(enemy, true, true)), + Piece("머리 띠", DuelHudLayout.Heading(f)), + Piece("배속 버튼", DuelHudLayout.SpeedControl(f)), + Piece("턴 배너", DuelHudLayout.TurnBanner(f)), + Piece("공격 배너", DuelHudLayout.PhaseBanner(f)), + Piece("행동 문구", DuelHudLayout.ActionBar(f)), + Piece("전투 문구 1줄", BattleEffectTextOverlay.RowRect(f, 0)), + Piece("전투 문구 2줄", BattleEffectTextOverlay.RowRect(f, 1)), + Piece("전투 문구 3줄", BattleEffectTextOverlay.RowRect(f, 2)), + }; + + foreach (bool withLine in new[] { false, true }) + { + DuelTeamHud.SendOutPlan plan = DuelTeamHud.SendOut(f, withLine); + Assert.AreEqual(withLine, plan.HasLine); + var mine = new List> { Piece("교체 제목", plan.Title) }; + if (withLine) mine.Add(Piece("교체 한마디", plan.Line)); + // 떠오르는 동안(아래로 SendOutRise만큼 내려간 자리)도 잰다. + int count = mine.Count; + for (int i = 0; i < count; i++) + mine.Add(Piece(mine[i].Key + "(떠오르는 중)", Shift(mine[i].Value, DuelTeamHud.SendOutRise))); + + foreach (KeyValuePair m in mine) + { + AssertContained(safe, m.Value, $"{width}x{height} {m.Key}"); + foreach (KeyValuePair p in pieces) + Assert.IsFalse(m.Value.Overlaps(p.Value), $"{width}x{height} 한마디{withLine}: {m.Key} {m.Value} ↔ {p.Key} {p.Value}"); + } + if (withLine) Assert.IsFalse(plan.Title.Overlaps(plan.Line), "제목과 한마디가 겹친다"); + Assert.GreaterOrEqual(plan.Title.height - 20f, StoryRecapLayout.LineHeight(DuelTeamHud.SendOutFont), "큰 글자 한 줄이 안 들어간다"); + Assert.GreaterOrEqual(plan.Title.width, Mathf.Min(DuelTeamHud.SendOutTitleMaxWidth, f.ContentWidth) - 0.01f); + } + } + + // ── 레이드 — 변신 문구·수문장 배너는 아래쪽, 보스(위쪽)와 팀 줄을 비킨다 ── + + [TestCase(1280f, 720f, 0f, 0f, false)] + [TestCase(720f, 1280f, 0f, 0f, true)] + [TestCase(1920f, 1080f, 0f, 0f, false)] + [TestCase(1080f, 1920f, 0f, 0f, true)] + [TestCase(1080f, 2400f, 110f, 60f, true)] + [TestCase(2400f, 1080f, 0f, 0f, true)] + [TestCase(1440f, 1080f, 0f, 0f, false)] + public void RaidTransformBanner_SitsOnTheLowerStage(float width, float height, float notchTop, float gestureBottom, bool mobile) + { + HudFrame f = HudFrame.ForScreen(width, height, 0f, 0f, notchTop, gestureBottom, mobile); + Rect safe = DuelHudLayout.Safe(f); + Rect banner = RaidStageLayout.TransformBanner(f); + AssertContained(safe, banner, $"{width}x{height} 변신 문구"); + AssertContained(safe, Shift(banner, RaidStageLayout.BannerRise), $"{width}x{height} 변신 문구(떠오르는 중)"); + Assert.AreEqual(RaidStageLayout.TransformBannerHeight, banner.height, 0.01f, "아래 무대가 판보다 좁다"); + Assert.GreaterOrEqual(banner.height - 20f, RaidStageLayout.LineHeight(RaidStageLayout.TransformFont)); + foreach (KeyValuePair p in RaidPieces(f)) + { + Assert.IsFalse(banner.Overlaps(p.Value), $"{width}x{height}: 변신 문구 {banner} ↔ {p.Key} {p.Value}"); + Assert.IsFalse(Shift(banner, RaidStageLayout.BannerRise).Overlaps(p.Value), $"{width}x{height}: 떠오르는 변신 문구 ↔ {p.Key}"); + } + Assert.Greater(banner.y, f.Height * 0.5f, "변신 문구가 위쪽(보스 자리)에 섰다"); + } + + [TestCase(1280f, 720f, 0f, 0f, false)] + [TestCase(720f, 1280f, 0f, 0f, true)] + [TestCase(1920f, 1080f, 0f, 0f, false)] + [TestCase(1080f, 1920f, 0f, 0f, true)] + [TestCase(1080f, 2400f, 110f, 60f, true)] + [TestCase(2400f, 1080f, 0f, 0f, true)] + [TestCase(1440f, 1080f, 0f, 0f, false)] + public void GuardianBanner_LeavesTheUpperTwoThirdsToTheBoss(float width, float height, float notchTop, float gestureBottom, bool mobile) + { + HudFrame f = HudFrame.ForScreen(width, height, 0f, 0f, notchTop, gestureBottom, mobile); + Rect safe = DuelHudLayout.Safe(f); + foreach (bool hasIntro in new[] { true, false }) + { + RaidStageLayout.GuardianPlan plan = RaidStageLayout.GuardianBanner(f, hasIntro); + string tag = $"{width}x{height} 별칭{hasIntro}"; + AssertContained(safe, plan.Card, tag + " 배너"); + Assert.GreaterOrEqual(plan.Card.y, f.Height * 2f / 3f - 0.01f, tag + ": 보스 자리(위 2/3)를 덮는다"); + // 아레나의 수문장 샷은 안전 영역 아래에서 GuardianWindowBottom 위로 수문장을 담는다(visual-dev) — 배너는 그 밑에 선다. + float safeBottom = f.Height - f.SafeBottom; + float windowBottom = safeBottom - (safeBottom - f.SafeTop) * BattleStaging.GuardianWindowBottom; + Assert.GreaterOrEqual(plan.Card.y, windowBottom - 0.01f, tag + ": 아레나의 수문장 창을 덮는다"); + // 인트로 동안 서 있는 것 — 보스 카드와 쉬는 자리의 팀 줄. + Assert.IsFalse(plan.Card.Overlaps(RaidStageLayout.BossCard(f)), tag + ": 보스 HP 카드"); + Assert.IsFalse(plan.Card.Overlaps(RaidStageLayout.TeamStripRest(f)), tag + ": 팀 줄"); + + var rows = new List> { Piece("칭호", plan.Title), Piece("이름 줄", plan.NameRow) }; + if (hasIntro) + { + rows.Add(Piece("별칭", plan.Epithet)); + rows.Add(Piece("등장 줄", plan.Line)); + } + for (int i = 0; i < rows.Count; i++) + { + AssertContained(plan.Card, rows[i].Value, tag + " " + rows[i].Key); + for (int j = 0; j < i; j++) + Assert.IsFalse(rows[i].Value.Overlaps(rows[j].Value), $"{tag}: {rows[i].Key} ↔ {rows[j].Key}"); + } + Assert.GreaterOrEqual(plan.Title.height, RaidStageLayout.LineHeight(RaidStageLayout.GuardianTitleFont)); + if (hasIntro) + { + Assert.GreaterOrEqual(plan.Epithet.height, RaidStageLayout.LineHeight(RaidStageLayout.GuardianEpithetFont)); + Assert.GreaterOrEqual(plan.Line.height, RaidStageLayout.LineHeight(RaidStageLayout.GuardianLineFont)); + } + // 떠오르는 동안(아래로 밀린 자리)도 화면 밖으로 나가지 않는다. + Assert.LessOrEqual(plan.Card.yMax + RaidStageLayout.GuardianSlide, f.Height + 0.01f, tag + ": 슬라이드가 화면 밖"); + } + Assert.Greater(RaidStageLayout.GuardianHeight(true), RaidStageLayout.GuardianHeight(false)); + } + + [Test] + public void EpithetPunch_StartsBigAndSettlesToOne() + { + Assert.AreEqual(RaidStageLayout.EpithetPunchPeak, RaidStageLayout.EpithetPunch(0f), 0.0001f); + Assert.AreEqual(1f, RaidStageLayout.EpithetPunch(RaidStageLayout.EpithetPunchSeconds), 0.0001f); + Assert.AreEqual(1f, RaidStageLayout.EpithetPunch(5f), 0.0001f); + float last = RaidStageLayout.EpithetPunchPeak; + for (float s = 0.02f; s <= RaidStageLayout.EpithetPunchSeconds; s += 0.02f) + { + float v = RaidStageLayout.EpithetPunch(s); + Assert.LessOrEqual(v, last + 0.0001f, "도로 커진다"); + last = v; + } + // 찍히는 순간이 인트로 안이다 — 별칭이 보일 틈이 있어야 한다. + Assert.Less(BattleStaging.GuardianRoarAt + RaidStageLayout.EpithetPunchSeconds, BattleStaging.GuardianIntroSeconds - 0.5f); + } + + [Test] + public void GuardianNameRow_CentersNameAndChips_AndShrinksOnlyTheName() + { + Rect row = new Rect(100f, 500f, 900f, 52f); + RaidStageLayout.NameRow wide = RaidStageLayout.LayoutNameRow(row, 300f, 76f, 76f); + Assert.AreEqual(300f, wide.Name.width, 0.01f); + Assert.IsFalse(wide.Name.Overlaps(wide.Chip1)); + Assert.IsFalse(wide.Chip1.Overlaps(wide.Chip2)); + float left = wide.Name.x - row.x, right = row.xMax - wide.Chip2.xMax; + Assert.AreEqual(left, right, 0.5f, "묶음이 가운데가 아니다"); + AssertContained(row, wide.Chip2, "둘째 칩"); + + RaidStageLayout.NameRow narrow = RaidStageLayout.LayoutNameRow(row, 2000f, 76f, 0f); + Assert.AreEqual(76f, narrow.Chip1.width, 0.01f, "칩은 줄이지 않는다"); + Assert.AreEqual(0f, narrow.Chip2.width, 0.01f); + AssertContained(row, narrow.Name, "이름"); + AssertContained(row, narrow.Chip1, "칩"); + Assert.IsFalse(narrow.Name.Overlaps(narrow.Chip1)); + Assert.AreEqual(RaidStageLayout.GuardianChipHeight, narrow.Chip1.height, 0.01f); + Assert.AreEqual(RaidStageLayout.ChipWidth("벌레"), 2 * RaidStageLayout.GuardianChipCharWidth + RaidStageLayout.GuardianChipPadding, 0.01f); + } + + // ── 보스 이름 아래 「○○의 모습」 ── + + [Test] + public void BossFormCaption_RowsStayInsideTheCardAboveTheHpBar() + { + HudFrame f = HudFrame.ForScreen(1920f, 1080f, 0f, 0f, 0f, 0f, false); + Rect card = RaidStageLayout.BossCard(f); + Rect caption = RaidStageLayout.BossFormCaptionRow(card); + Rect stats = RaidStageLayout.BossStatRow(card); + AssertContained(card, caption, "모습"); + AssertContained(card, stats, "ATK·DEF"); + Assert.IsFalse(caption.Overlaps(stats)); + Assert.LessOrEqual(caption.yMax, card.y + 62f, "HP 막대(y+62)를 밟는다"); + Assert.GreaterOrEqual(caption.y, card.y + 32f, "이름 줄(y+8~40)을 밟는다"); + Assert.AreEqual("호랑나비의 모습", RaidStageLayout.FormCaptionText("호랑나비")); + Assert.IsNull(RaidStageLayout.FormCaptionText("")); + } + + [Test] + public void ShownForm_UntilTheArenaSwapsTheModel_KeepsTheOldForm() + { + InsectData mantis = ScriptableObject.CreateInstance(); + InsectData butterfly = ScriptableObject.CreateInstance(); + InsectData firefly = ScriptableObject.CreateInstance(); + try + { + var first = new RaidBossFormChange { FromIndex = 0, ToIndex = 1, FromData = mantis, ToData = butterfly }; + var second = new RaidBossFormChange { FromIndex = 1, ToIndex = 2, FromData = butterfly, ToData = firefly }; + + // 원래 모습 — 글자 없음. + Assert.IsNull(RaidStageLayout.ShownForm(0, mantis, RaidStageLayout.UnrevealedChange(false, null, null, 0f))); + // 사마귀 → 나비가 걸렸지만 연출 전 — 아직 원래 모습(글자 없음). + Assert.IsNull(RaidStageLayout.ShownForm(1, butterfly, RaidStageLayout.UnrevealedChange(true, first, null, 0f))); + // 변신 단계 — 아레나가 모델을 바꾸기 전(TransformSwap 전)엔 그대로, 바꾼 뒤엔 새 모습. + float before = BattleStaging.TransformSwap - 0.01f, after = BattleStaging.TransformSwap; + Assert.IsNull(RaidStageLayout.ShownForm(1, butterfly, RaidStageLayout.UnrevealedChange(false, null, first, before))); + Assert.AreSame(butterfly, RaidStageLayout.ShownForm(1, butterfly, RaidStageLayout.UnrevealedChange(false, null, first, after))); + // 단계가 끝나면(활성 변신 없음) 새 모습. + Assert.AreSame(butterfly, RaidStageLayout.ShownForm(1, butterfly, RaidStageLayout.UnrevealedChange(false, null, null, 0f))); + // 나비 → 반딧불이가 걸렸지만 연출 전 — 나비. + Assert.AreSame(butterfly, RaidStageLayout.ShownForm(2, firefly, RaidStageLayout.UnrevealedChange(true, second, null, 0f))); + Assert.AreSame(butterfly, RaidStageLayout.ShownForm(2, firefly, RaidStageLayout.UnrevealedChange(false, null, second, before))); + Assert.AreSame(firefly, RaidStageLayout.ShownForm(2, firefly, RaidStageLayout.UnrevealedChange(false, null, second, after))); + } + finally + { + Object.DestroyImmediate(mantis); + Object.DestroyImmediate(butterfly); + Object.DestroyImmediate(firefly); + } + } + + [Test] + public void RoarPunch_OnlyAroundTheRoar() + { + Assert.AreEqual(1f, RaidStageLayout.RoarPunch(-0.1f), 0.0001f, "울부짖기 전엔 그대로"); + Assert.AreEqual(RaidStageLayout.RoarPunchPeak, RaidStageLayout.RoarPunch(0f), 0.0001f); + Assert.AreEqual(1f, RaidStageLayout.RoarPunch(RaidStageLayout.EpithetPunchSeconds), 0.0001f); + Assert.Less(RaidStageLayout.RoarPunchPeak, RaidStageLayout.EpithetPunchPeak + 0.0001f); + // 울부짖는 박자가 변신 단계 안이다. + Assert.Less(BattleStaging.TransformRoar * RaidBattleUI.BossTransformDuration + RaidStageLayout.EpithetPunchSeconds, + RaidBattleUI.BossTransformDuration); + } + + // ── 대화창 「승부!」 ── + + [Test] + public void DialogueDuelPrompt_OwnDuelOrPendingDuel_TurnsTheLastButtonIntoDuel() + { + Assert.IsTrue(DialogueDuelPrompt.LeadsToDuel("ledger_grip", false, false, false), "간부"); + Assert.IsTrue(DialogueDuelPrompt.LeadsToDuel(" catcher_rival ", false, false, false), "라온(앞뒤 공백)"); + Assert.IsTrue(DialogueDuelPrompt.LeadsToDuel(null, true, false, false), "선택 결과 대사 — 런처가 기다린다"); + Assert.IsFalse(DialogueDuelPrompt.LeadsToDuel(null, false, false, false)); + Assert.IsFalse(DialogueDuelPrompt.LeadsToDuel("no_such_npc", false, false, false), "대결 표에 없는 상대 — 런처도 버린다"); + Assert.IsFalse(DialogueDuelPrompt.LeadsToDuel("ledger_grip", true, true, false), "저널 다시보기는 대결을 열지 않는다"); + Assert.IsFalse(DialogueDuelPrompt.LeadsToDuel("ledger_grip", true, false, true), "꿈은 대결을 열지 않는다"); + } + + [Test] + public void DialogueDuelPrompt_Labels() + { + Assert.AreEqual(DialogueDuelPrompt.NextLabel, DialogueDuelPrompt.AdvanceLabel(false, true, true), "덜 나왔으면 줄을 마저 보인다"); + Assert.AreEqual(DialogueDuelPrompt.DuelLabel, DialogueDuelPrompt.AdvanceLabel(true, true, true)); + Assert.AreEqual(DialogueDuelPrompt.CloseLabel, DialogueDuelPrompt.AdvanceLabel(true, true, false)); + Assert.AreEqual(DialogueDuelPrompt.NextLabel, DialogueDuelPrompt.AdvanceLabel(true, false, true)); + Assert.IsTrue(DialogueDuelPrompt.IsDuelButton(true, true, true)); + Assert.IsFalse(DialogueDuelPrompt.IsDuelButton(false, true, true)); + Assert.IsFalse(DialogueDuelPrompt.IsDuelButton(true, false, true)); + Assert.AreEqual("건너뛰고 승부", DialogueDuelPrompt.SkipButtonLabel(true)); + Assert.AreEqual("건너뛰기", DialogueDuelPrompt.SkipButtonLabel(false)); + } + + [Test] + public void RealStory_EveryDuelAfterBeat_ShowsTheDuelButton() + { + int found = 0; + foreach (StoryBeat beat in StoryService.AllBeats()) + { + if (beat == null || string.IsNullOrWhiteSpace(beat.duelAfter)) continue; + found++; + Assert.IsTrue(DialogueDuelPrompt.LeadsToDuel(beat.duelAfter, false, false, false), + $"{beat.beatId}: duelAfter '{beat.duelAfter}'인데 「승부!」가 안 뜬다"); + } + Assert.Greater(found, 0, "Story.json에 대사 직후 대결이 하나도 없다"); + } + + // ── 도우미 ── + + private static IEnumerable> RaidPieces(HudFrame f) + { + yield return Piece("보스 HP 카드", RaidStageLayout.BossCard(f)); + yield return Piece("보스 예고", RaidStageLayout.BossIntent(f)); + yield return Piece("팀 줄(쉬는 자리)", RaidStageLayout.TeamStripRest(f)); + yield return Piece("합체 게이지", RaidStageLayout.UniteGauge(f)); + } + + private static KeyValuePair Piece(string name, Rect rect) => new KeyValuePair(name, rect); + + private static Rect Shift(Rect r, float dy) => new Rect(r.x, r.y + dy, r.width, r.height); + + private static void AssertContained(Rect outer, Rect inner, string what) + { + const float eps = 0.01f; + Assert.GreaterOrEqual(inner.xMin, outer.xMin - eps, $"{what}: 왼쪽 밖 {inner} / {outer}"); + Assert.LessOrEqual(inner.xMax, outer.xMax + eps, $"{what}: 오른쪽 밖 {inner} / {outer}"); + Assert.GreaterOrEqual(inner.yMin, outer.yMin - eps, $"{what}: 위 밖 {inner} / {outer}"); + Assert.LessOrEqual(inner.yMax, outer.yMax + eps, $"{what}: 아래 밖 {inner} / {outer}"); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/StoryBattleHudTests.cs.meta b/Assets/Tests/EditMode/StoryBattleHudTests.cs.meta new file mode 100644 index 0000000..44787ce --- /dev/null +++ b/Assets/Tests/EditMode/StoryBattleHudTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 95b86291e88a19d47b4ad7e0fe5cf139 \ No newline at end of file diff --git a/Assets/Tests/EditMode/StoryBattleWaitTests.cs b/Assets/Tests/EditMode/StoryBattleWaitTests.cs new file mode 100644 index 0000000..b1567b9 --- /dev/null +++ b/Assets/Tests/EditMode/StoryBattleWaitTests.cs @@ -0,0 +1,416 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using InsectGame.Core; +using InsectGame.Story; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 전투 화면 뒤로 미룬 이야기의 대기 시계()와 그것을 쓰는 세 지휘자. + /// + /// 결과 화면은 2026-10-04부터 눌러야 닫힌다(BattleResultRules — 예전엔 4초 뒤 저절로). 세 곳의 "굳은 전투 화면" 상한 + /// (컷신·영상 12초 포기, 카메라 없는 대사 큐 12초 폴백)이 결과 화면 시간을 세면, 보상을 천천히 보는 아이의 미뤄 둔 컷신·영상이 + /// 조용히 사라지거나 대사가 결과 화면 위로 뜬다. 여기서 고정하는 것 둘: + /// + /// 결과 화면에 30초 머문 뒤 닫아도 미뤄 둔 컷신·영상·대사가 나온다. + /// 결과 화면이 아닌데 전투 카메라가 안 풀리면(진짜로 멈춘 화면) 여전히 상한에 걸린다. + /// + /// + /// 지휘자는 비활성 오브젝트에 붙인다 — Update·Awake가 돌지 않아 시계는 테스트가 넣는 시간으로만 흐르고, 영상 지휘자는 + /// VideoPlayer를 만들지 않아 디코더를 열지 않는다(배치·PlayMode 러너엔 디코더가 없다 — rules/testing.md). + /// LogAssert는 이 어셈블리에서 참조가 안 된다(asmdef 없음) — 경고는 로그 이벤트로 직접 센다. + /// + [TestFixture] + public class StoryBattleWaitTests + { + private const float Tick = 0.1f; + private const int ThirtySeconds = 300; // Tick × 300 + + private sealed class FakeModal : IModalUI + { + public bool IsOpen { get; set; } = true; + public void CloseModal() { IsOpen = false; } + } + + private readonly List spawned = new List(); + private CameraFollower follower; + + [SetUp] + public void SetUp() + { + GameObject cameraObject = new GameObject("StoryBattleWaitTestsCamera"); + spawned.Add(cameraObject); + follower = cameraObject.AddComponent(); + } + + [TearDown] + public void TearDown() + { + if (follower != null) follower.ExitBattleMode(); + foreach (GameObject go in spawned) + if (go != null) Object.DestroyImmediate(go); + spawned.Clear(); + } + + // ── 순수 규칙 ─────────────────────────────────────────────── + + [Test] + public void Advance_ResultScreenShowing_ClockStandsStill() + { + Assert.AreEqual(5f, StoryBattleWait.Advance(5f, 1f, resultScreenShowing: true)); + } + + [Test] + public void Advance_OutsideResultScreen_CountsRealSeconds() + { + Assert.AreEqual(6f, StoryBattleWait.Advance(5f, 1f, resultScreenShowing: false), 1e-5f); + Assert.AreEqual(5f, StoryBattleWait.Advance(5f, -1f, resultScreenShowing: false), "되감기는 프레임은 시계를 줄이지 않는다"); + } + + [Test] + public void TickScene_ThirtySecondsOnResultScreen_ThenClosed_Plays() + { + float waited = 0f; + for (int i = 0; i < ThirtySeconds; i++) + Assert.AreEqual(StoryBattleWait.SceneStep.Wait, + StoryBattleWait.TickScene(ref waited, Tick, battleOnScreen: true, resultScreenShowing: true, modalOpen: false), + $"결과 화면 {i * Tick:0.0}초 — 보상을 보는 시간에 컷신을 버리면 안 된다"); + Assert.AreEqual(0f, waited, "결과 화면 시간은 대기로 세지 않는다"); + + Assert.AreEqual(StoryBattleWait.SceneStep.Play, + StoryBattleWait.TickScene(ref waited, Tick, battleOnScreen: false, resultScreenShowing: false, modalOpen: false)); + } + + [Test] + public void TickScene_StuckBattleCameraOutsideResult_GivesUpAtLimit() + { + float waited = 0f; + int ticks = 0; + StoryBattleWait.SceneStep step; + do + { + step = StoryBattleWait.TickScene(ref waited, Tick, battleOnScreen: true, resultScreenShowing: false, modalOpen: false); + ticks++; + } while (step == StoryBattleWait.SceneStep.Wait && ticks < 1000); + + Assert.AreEqual(StoryBattleWait.SceneStep.GiveUp, step, "결과 화면이 아닌데 안 풀리는 화면은 영원히 기다리지 않는다"); + Assert.AreEqual(StoryBattleWait.SceneGiveUpSeconds, ticks * Tick, Tick + 1e-3f, "상한은 그대로다"); + } + + [Test] + public void TickScene_ResultTimeThenStuck_CountsOnlyTheStuckPart() + { + float waited = 0f; + for (int i = 0; i < ThirtySeconds; i++) + StoryBattleWait.TickScene(ref waited, Tick, true, true, false); + + // 결과 화면이 끝났는데(단계가 바뀌었는데) 카메라가 안 풀린다 — 여기서부터 센다. + float stuck = StoryBattleWait.SceneGiveUpSeconds - 1f; + Assert.AreEqual(StoryBattleWait.SceneStep.Wait, StoryBattleWait.TickScene(ref waited, stuck, true, false, false), + "앞의 결과 화면 30초가 상한에 보태지면 굳은 지 11초 만에 버린다"); + Assert.AreEqual(StoryBattleWait.SceneStep.GiveUp, StoryBattleWait.TickScene(ref waited, 1.1f, true, false, false)); + } + + [Test] + public void TickScene_ModalOnlyAfterBattle_NeverGivesUp() + { + // 전투가 닫힌 뒤 이어진 대사(모달)를 오래 읽는다 — 영상은 그 뒤에 나와야 한다. + float waited = 0f; + for (int i = 0; i < ThirtySeconds * 2; i++) + Assert.AreEqual(StoryBattleWait.SceneStep.Wait, + StoryBattleWait.TickScene(ref waited, Tick, battleOnScreen: false, resultScreenShowing: false, modalOpen: true)); + Assert.AreEqual(StoryBattleWait.SceneStep.Play, + StoryBattleWait.TickScene(ref waited, Tick, false, false, false)); + } + + [Test] + public void ReadProbe_MissingOrThrowing_IsFalse() + { + Assert.IsFalse(StoryBattleWait.ReadProbe(null), "미배선이면 예전처럼 결과 화면도 센다"); + Assert.IsFalse(StoryBattleWait.ReadProbe(() => throw new System.InvalidOperationException("qa")), + "전투 화면 쪽 예외로 대기 규칙이 죽지 않는다"); + Assert.IsTrue(StoryBattleWait.ReadProbe(() => true)); + } + + // ── 컷신 ───────────────────────────────────────────────── + + [Test] + public void Cutscene_ResultScreenThirtySeconds_ThenClosed_PlaysDeferredCutscene() + { + bool resultShowing = true; + CutsceneDirector cutscene = NewCutscene(() => resultShowing); + try + { + follower.EnterBattleMode(Vector3.zero, Vector3.forward * 5f); + Invoke(cutscene, "OnBeatCompleted", new StoryBeat { beatId = "qa_cleanse", cutsceneId = CutsceneLibrary.BlightCleanse }); + Assert.IsFalse(cutscene.IsPlaying, "전투 화면이 떠 있으면 미룬다"); + + for (int i = 0; i < ThirtySeconds; i++) + Assert.AreEqual(StoryBattleWait.SceneStep.Wait, Step(cutscene, Tick), + $"결과 화면 {i * Tick:0.0}초 — 거점 정화 컷신을 버리면 안 된다"); + + // 플레이어가 결과 화면을 닫았다 — EndBattle이 카메라를 푼다. + resultShowing = false; + follower.ExitBattleMode(); + Assert.AreEqual(StoryBattleWait.SceneStep.Play, Step(cutscene, Tick)); + Assert.IsTrue(cutscene.IsPlaying, "결과 화면을 30초 본 뒤에도 컷신이 나온다"); + } + finally { cutscene.Stop(); } + } + + [Test] + public void Cutscene_StuckBattleCameraOutsideResult_StillDroppedAtLimit() + { + CutsceneDirector cutscene = NewCutscene(() => false); + int warned = CountWarnings("[Cutscene]", () => + { + follower.EnterBattleMode(Vector3.zero, Vector3.forward * 5f); + Invoke(cutscene, "OnBeatCompleted", new StoryBeat { beatId = "qa_cleanse", cutsceneId = CutsceneLibrary.BlightCleanse }); + + Assert.AreEqual(StoryBattleWait.SceneStep.Wait, Step(cutscene, StoryBattleWait.SceneGiveUpSeconds - 0.5f)); + Assert.AreEqual(StoryBattleWait.SceneStep.GiveUp, Step(cutscene, 1f), + "결과 화면이 아닌데 카메라가 안 풀리면 상한에서 버린다 — 억지로 틀면 전투 구도에 갇힌다"); + + follower.ExitBattleMode(); + Assert.AreEqual(StoryBattleWait.SceneStep.None, Step(cutscene, Tick), "버린 컷신이 뒤늦게 튀어나오지 않는다"); + Assert.IsFalse(cutscene.IsPlaying); + }); + cutscene.Stop(); + Assert.AreEqual(1, warned, "버릴 때 한 번 말한다 — 컷신이 안 나오는 건 화면상 티가 안 난다"); + } + + // ── 영상 ───────────────────────────────────────────────── + + [Test] + public void Video_ResultScreenThirtySeconds_ThenClosed_PlaysDeferredVideo() + { + Assume.That(!ModalUIRegistry.IsAnyOpen(), "다른 테스트가 모달을 남겼다"); + bool resultShowing = true; + StoryVideoDirector video = NewVideo(() => resultShowing); + + follower.EnterBattleMode(Vector3.zero, Vector3.forward * 5f); + Invoke(video, "OnBeatCompleted", new StoryBeat { beatId = "qa_chief", videoId = StoryVideoLibrary.Ch12Ledger }); + Assert.IsNotNull(Pending(video), "전투 화면이 떠 있으면 미룬다"); + + for (int i = 0; i < ThirtySeconds; i++) + Assert.AreEqual(StoryBattleWait.SceneStep.Wait, Step(video, Tick), + $"결과 화면 {i * Tick:0.0}초 — 간부전 승리 영상을 버리면 안 된다"); + + resultShowing = false; + follower.ExitBattleMode(); + Assert.AreEqual(StoryBattleWait.SceneStep.Play, Step(video, Tick), "결과 화면을 30초 본 뒤에도 영상이 나온다"); + Assert.IsNull(Pending(video)); + } + + [Test] + public void Video_LongDialogueAfterBattle_DoesNotDropDeferredVideo() + { + Assume.That(!ModalUIRegistry.IsAnyOpen(), "다른 테스트가 모달을 남겼다"); + StoryVideoDirector video = NewVideo(() => false); + follower.EnterBattleMode(Vector3.zero, Vector3.forward * 5f); + Invoke(video, "OnBeatCompleted", new StoryBeat { beatId = "qa_chief", videoId = StoryVideoLibrary.Ch12Ledger }); + follower.ExitBattleMode(); + + // 전투가 닫히자 미뤄 둔 다른 대사가 먼저 열렸다 — 아이가 30초 읽는다. + FakeModal dialogue = new FakeModal(); + ModalUIRegistry.Register(dialogue); + try + { + for (int i = 0; i < ThirtySeconds; i++) + Assert.AreEqual(StoryBattleWait.SceneStep.Wait, Step(video, Tick), + "대사를 읽는 시간은 굳은 화면이 아니다 — 영상을 버리면 안 된다"); + } + finally + { + dialogue.CloseModal(); + ModalUIRegistry.Unregister(dialogue); + } + Assert.AreEqual(StoryBattleWait.SceneStep.Play, Step(video, Tick)); + } + + [Test] + public void Video_StuckBattleCameraOutsideResult_StillDroppedAtLimit() + { + Assume.That(!ModalUIRegistry.IsAnyOpen(), "다른 테스트가 모달을 남겼다"); + StoryVideoDirector video = NewVideo(() => false); + int warned = CountWarnings("[StoryVideo]", () => + { + follower.EnterBattleMode(Vector3.zero, Vector3.forward * 5f); + Invoke(video, "OnBeatCompleted", new StoryBeat { beatId = "qa_chief", videoId = StoryVideoLibrary.Ch12Ledger }); + Assert.AreEqual(StoryBattleWait.SceneStep.Wait, Step(video, StoryBattleWait.SceneGiveUpSeconds - 0.5f)); + Assert.AreEqual(StoryBattleWait.SceneStep.GiveUp, Step(video, 1f)); + Assert.IsNull(Pending(video)); + }); + Assert.AreEqual(1, warned); + } + + // ── 대사 큐(StoryDirector) ─────────────────────────────────── + + [Test] + public void Story_NoCamera_ResultScreenThirtySeconds_DoesNotFireOverIt_ThenFiresOnClose() + { + Assume.That(!ModalUIRegistry.IsAnyOpen(), "다른 테스트가 모달을 남겼다"); + bool resultShowing = true; + StoryDirector director = NewStory(() => resultShowing); + WithBattleWinBeat(director, () => + { + string fired = null; + director.StoryBeatTriggered += beat => fired = beat.beatId; + Invoke(director, "DeferTrigger", "BattleWin", "qa_bug"); + + Assert.IsTrue((bool)Invoke(director, "ShouldDeferNow"), "결과 화면은 카메라 없이도 전투 화면으로 친다"); + for (int i = 0; i < ThirtySeconds; i++) Invoke(director, "TickPendingTriggers", Tick); + Assert.IsNull(fired, "카메라가 없을 때의 12초 폴백이 결과 화면 위로 대사를 띄우면 보상 패널을 덮는다"); + + resultShowing = false; + director.NotifyBattlePresentationClosed(); + Assert.AreEqual("qa_win", fired, "결과 화면을 30초 본 뒤 닫아도 미뤄 둔 대사가 나온다"); + }); + } + + [Test] + public void Story_NoCamera_StuckNonResultScreen_StillFiresAtFallback() + { + Assume.That(!ModalUIRegistry.IsAnyOpen(), "다른 테스트가 모달을 남겼다"); + StoryDirector director = NewStory(() => false); + WithBattleWinBeat(director, () => + { + string fired = null; + director.StoryBeatTriggered += beat => fired = beat.beatId; + int warned = CountWarnings("[Story]", () => + { + Invoke(director, "DeferTrigger", "BattleWin", "qa_bug"); + Invoke(director, "TickPendingTriggers", StoryBattleWait.DialogueFallbackSeconds - 0.5f); + Assert.IsNull(fired, "통지를 기다리는 동안은 쏘지 않는다"); + Invoke(director, "TickPendingTriggers", 1f); + }); + Assert.AreEqual("qa_win", fired, "종료 통지가 끝내 안 오면 상한에서 그냥 쏜다 — 진행을 잃는 것보다 낫다"); + Assert.AreEqual(1, warned); + }); + } + + [Test] + public void Story_CameraWired_LongResultScreen_FiresAfterCloseWithoutStuckWarning() + { + Assume.That(!ModalUIRegistry.IsAnyOpen(), "다른 테스트가 모달을 남겼다"); + bool resultShowing = true; + StoryDirector director = NewStory(() => resultShowing); + director.AutoWire(follower); + WithBattleWinBeat(director, () => + { + string fired = null; + director.StoryBeatTriggered += beat => fired = beat.beatId; + int warned = CountWarnings("[Story]", () => + { + follower.EnterBattleMode(Vector3.zero, Vector3.forward * 5f); + Invoke(director, "DeferTrigger", "BattleWin", "qa_bug"); + for (int i = 0; i < ThirtySeconds * 3; i++) Invoke(director, "TickPendingTriggers", Tick); // 90초 + Assert.IsNull(fired, "전투 화면 위로 대사를 쏘지 않는다"); + + // 닫았다 — 이번엔 통지 대신 다음 프레임이 흘린다(통지 경로는 위 테스트가 본다). + resultShowing = false; + follower.ExitBattleMode(); + Invoke(director, "TickPendingTriggers", Tick); + }); + Assert.AreEqual("qa_win", fired); + Assert.AreEqual(0, warned, "결과 화면을 오래 본 것은 '화면이 안 닫혔다'가 아니다 — 거짓 경고를 남기지 않는다"); + }); + } + + // ── 배선 ───────────────────────────────────────────────── + + [Test] + public void Bootstrap_WiresTheResultProbeIntoAllThreeDirectors() + { + // 빠지면 예외도 경고도 없이 예전 동작(결과 화면도 센다)으로 돌아간다 — 소스에서 확인한다. + string source = File.ReadAllText(Path.Combine(Application.dataPath, "Scripts/Core/PlaySceneBootstrap.cs")); + StringAssert.Contains("storyDirector.AutoWire(battleResultShowing)", source); + StringAssert.Contains("cutscene.AutoWire(battleResultShowing)", source); + StringAssert.Contains("storyVideo.AutoWire(battleResultShowing)", source); + StringAssert.Contains("battleScreen.ResultShownSeconds", source, "1대1 결과 화면"); + StringAssert.Contains("raidBattleUi.ResultShownSeconds", source, "레이드 결과 화면 — fin_seal(전설)은 레이드로만 이긴다"); + } + + // ── 도움 ───────────────────────────────────────────────── + + private GameObject Inactive(string name) + { + GameObject go = new GameObject(name); + go.SetActive(false); // Awake·Update가 돌지 않는다 — 시계는 테스트가 넣는 시간으로만 흐른다 + spawned.Add(go); + return go; + } + + private CutsceneDirector NewCutscene(System.Func resultShowing) + { + GameObject player = new GameObject("StoryBattleWaitTestsPlayer"); + spawned.Add(player); + CutsceneDirector cutscene = Inactive("StoryBattleWaitTestsCutscene").AddComponent(); + cutscene.AutoWire(null, follower, null, player.transform); + cutscene.AutoWire(resultShowing); + return cutscene; + } + + private StoryVideoDirector NewVideo(System.Func resultShowing) + { + StoryVideoDirector video = Inactive("StoryBattleWaitTestsVideo").AddComponent(); + video.AutoWire(null, follower, null); + video.AutoWire(resultShowing); + return video; + } + + private StoryDirector NewStory(System.Func resultShowing) + { + StoryDirector director = Inactive("StoryBattleWaitTestsStory").AddComponent(); + director.AutoWire(resultShowing); + return director; + } + + private static StoryBattleWait.SceneStep Step(Object director, float deltaSeconds) + => (StoryBattleWait.SceneStep)Invoke(director, "TickPending", deltaSeconds); + + private static object Pending(StoryVideoDirector video) => typeof(StoryVideoDirector) + .GetField("pending", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(video); + + private static object Invoke(object target, string method, params object[] args) + { + MethodInfo info = target.GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(info, $"{target.GetType().Name}.{method}가 사라졌다"); + return info.Invoke(target, args); + } + + private static int CountWarnings(string prefix, System.Action action) + { + int warned = 0; + Application.LogCallback count = (message, _, type) => + { + if (type == LogType.Warning && message.StartsWith(prefix)) warned++; + }; + Application.logMessageReceived += count; + try { action(); } + finally { Application.logMessageReceived -= count; } + return warned; + } + + /// 아무 승리에나 뜨는 BattleWin 비트 하나만 둔 이야기로 돌린다 — 실제 계정 진행·저장과 무관하다(CompleteBeat는 부르지 않는다). + private static void WithBattleWinBeat(StoryDirector director, System.Action action) + { + FieldInfo cache = typeof(StoryService).GetField("cache", BindingFlags.Static | BindingFlags.NonPublic); + object previous = cache.GetValue(null); + cache.SetValue(null, new Dictionary + { + ["qa_win"] = new StoryBeat { beatId = "qa_win", trigger = new StoryTrigger { type = "BattleWin", param = "" } } + }); + typeof(StoryDirector).GetField("progress", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(director, new StoryProgressData()); + try { action(); } + finally { cache.SetValue(null, previous); } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/StoryBattleWaitTests.cs.meta b/Assets/Tests/EditMode/StoryBattleWaitTests.cs.meta new file mode 100644 index 0000000..be2cfa4 --- /dev/null +++ b/Assets/Tests/EditMode/StoryBattleWaitTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d02e435171916ce468cdd59aae9c28ef \ No newline at end of file diff --git a/Assets/Tests/EditMode/StoryChapterTests.cs b/Assets/Tests/EditMode/StoryChapterTests.cs new file mode 100644 index 0000000..bf00dbf --- /dev/null +++ b/Assets/Tests/EditMode/StoryChapterTests.cs @@ -0,0 +1,198 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Story; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 장 「지난 이야기」() — 장을 여는 비트의 대사 앞에 카드로 뜨고 저널 장 탭 머리에 보인다. + /// 실패는 전부 조용하다: 여는 비트 ID가 오타면 카드가 안 뜨고, 다른 장의 비트면 엉뚱한 장면 앞에 뜬다. + /// story_lint 검사 34가 같은 기준을 정적으로 본다 — 기준값(4줄·40자)은 그쪽과 같아야 한다. + /// + [TestFixture] + public class StoryChapterTests + { + private static readonly string[] CampaignChapters = + { + "ch1", "ch2", "ch3", "ch4", "ch5", "ch6", "ch7", "ch8", "ch9", "ch10", "ch11", "ch12", "fin", + }; + + private const int MaxRecapLines = 4; + private const int MaxCardLineChars = 40; + // 지난 이야기가 없는 장 — 1장만 recap이 비어도 된다. + private const string FirstChapterId = "ch1"; + + private static Dictionary BeatsById() + { + var byId = new Dictionary(); + foreach (StoryBeat b in StoryService.AllBeats()) + if (b != null && !string.IsNullOrEmpty(b.beatId)) byId[b.beatId] = b; + return byId; + } + + // ── 실제 Story.json ── + + [Test] + public void AllChapters_RealStory_AreThirteenCampaignChaptersInOrder() + { + var ids = new List(); + foreach (StoryChapter c in StoryService.AllChapters()) + ids.Add(c != null ? c.chapterId : null); + + CollectionAssert.AreEqual(CampaignChapters, ids, + "장은 ch1~ch12·fin 13개가 이 순서여야 한다(배열 순서가 곧 장 순서) — 실제: " + string.Join(",", ids)); + } + + [Test] + public void AllChapters_RealStory_OpeningBeatExistsInSameChapter() + { + Dictionary byId = BeatsById(); + Assume.That(byId.Count, Is.GreaterThan(0), "Story.json 로드 실패"); + + var openings = new HashSet(); + foreach (StoryChapter c in StoryService.AllChapters()) + { + Assert.IsNotNull(c); + Assert.IsFalse(string.IsNullOrEmpty(c.openingBeatId), $"{c.chapterId}: openingBeatId가 비어 있다"); + Assert.IsTrue(byId.TryGetValue(c.openingBeatId, out StoryBeat opening), + $"{c.chapterId}: 여는 비트 {c.openingBeatId}가 없다 — 카드가 영영 안 뜬다"); + Assert.AreEqual(c.chapterId, opening.chapterId, + $"{c.chapterId}: 여는 비트 {c.openingBeatId}가 다른 장({opening.chapterId})의 비트다 — 엉뚱한 장면 앞에 카드가 뜬다"); + Assert.IsTrue(openings.Add(c.openingBeatId), + $"{c.openingBeatId}를 두 장이 연다 — 뒤 장의 카드가 영영 안 뜬다"); + } + } + + [Test] + public void AllChapters_RealStory_RecapAndGoalFitTheCard() + { + int count = 0; + foreach (StoryChapter c in StoryService.AllChapters()) + { + count++; + Assert.IsFalse(string.IsNullOrWhiteSpace(c.title), $"{c.chapterId}: title이 비어 있다"); + Assert.IsFalse(string.IsNullOrWhiteSpace(c.goal), $"{c.chapterId}: goal이 비어 있다"); + Assert.LessOrEqual(c.goal.Length, MaxCardLineChars, $"{c.chapterId}: goal {c.goal.Length}자"); + + Assert.IsNotNull(c.recap, $"{c.chapterId}: recap이 null"); + Assert.LessOrEqual(c.recap.Count, MaxRecapLines, $"{c.chapterId}: recap {c.recap.Count}줄"); + if (c.chapterId != FirstChapterId) + Assert.GreaterOrEqual(c.recap.Count, 1, $"{c.chapterId}: 지난 이야기가 없다(1장 외에는 1줄 이상)"); + for (int i = 0; i < c.recap.Count; i++) + { + string line = c.recap[i]; + Assert.IsFalse(string.IsNullOrWhiteSpace(line), $"{c.chapterId}.recap[{i}]: 빈 줄"); + Assert.LessOrEqual(line.Length, MaxCardLineChars, $"{c.chapterId}.recap[{i}]: {line.Length}자"); + } + } + Assert.AreEqual(CampaignChapters.Length, count, "장 수가 맞지 않는다"); + } + + [Test] + public void TryGetChapterOpenedBy_RealStory_TrueOnlyForOpeningBeats() + { + Dictionary byId = BeatsById(); + Assume.That(byId.Count, Is.GreaterThan(0), "Story.json 로드 실패"); + + var openingToChapter = new Dictionary(); + foreach (StoryChapter c in StoryService.AllChapters()) + if (c != null && !string.IsNullOrEmpty(c.openingBeatId)) openingToChapter[c.openingBeatId] = c.chapterId; + Assume.That(openingToChapter.Count, Is.GreaterThan(0), "chapters가 비어 있다"); + + foreach (string beatId in byId.Keys) + { + bool opens = StoryService.TryGetChapterOpenedBy(beatId, out StoryChapter chapter); + bool expected = openingToChapter.TryGetValue(beatId, out string chapterId); + Assert.AreEqual(expected, opens, $"{beatId}: 장을 여는 비트 판정이 어긋난다"); + if (expected) + { + Assert.IsNotNull(chapter); + Assert.AreEqual(chapterId, chapter.chapterId); + Assert.AreEqual(beatId, chapter.openingBeatId); + } + else + { + Assert.IsNull(chapter, $"{beatId}: 여는 비트가 아닌데 장을 돌려준다"); + } + } + } + + [Test] + public void TryGetChapterOpenedBy_NullOrEmptyBeatId_ReturnsFalse() + { + Assert.IsFalse(StoryService.TryGetChapterOpenedBy(null, out StoryChapter a)); + Assert.IsNull(a); + Assert.IsFalse(StoryService.TryGetChapterOpenedBy(string.Empty, out StoryChapter b)); + Assert.IsNull(b); + } + + [Test] + public void TryGetChapter_RealStory_FindsEveryCampaignChapterAndRejectsUnknown() + { + foreach (string id in CampaignChapters) + { + Assert.IsTrue(StoryService.TryGetChapter(id, out StoryChapter c), $"{id} 장이 없다"); + Assert.AreEqual(id, c.chapterId); + } + Assert.IsFalse(StoryService.TryGetChapter("town", out StoryChapter town), "마을 이야기는 장 카드가 없다"); + Assert.IsNull(town); + Assert.IsFalse(StoryService.TryGetChapter(null, out _)); + } + + // ── 순수 파싱 — 옛 JSON 호환 ── + + [Test] + public void StoryListJson_WithoutChaptersKey_ParsesAsEmptyChapterList() + { + // 장 데이터가 들어오기 전의 Story.json 형태. JsonUtility는 없는 키를 건드리지 않으므로 필드 초기값(빈 목록)이 남아야 한다 — + // null이 되면 AllChapters·카드 경로가 NRE로 죽는다. + const string oldJson = "{\"beats\":[{\"beatId\":\"ch1_intro\",\"chapterId\":\"ch1\",\"order\":1}]}"; + + StoryList list = JsonUtility.FromJson(oldJson); + + Assert.IsNotNull(list); + Assert.AreEqual(1, list.beats.Count); + Assert.IsNotNull(list.chapters, "chapters 키가 없는 옛 JSON이 null 목록으로 읽혔다"); + Assert.AreEqual(0, list.chapters.Count); + Assert.IsTrue(string.IsNullOrEmpty(list.beats[0].why), "why가 없는 옛 비트는 이유가 비어 있다"); + Assert.AreEqual(string.Empty, StoryObjectiveResolver.WhyFor(list.beats[0], false), + "이유 없는 옛 비트도 HUD에는 빈 문자열이 간다(null을 흘리지 않는다)"); + } + + [Test] + public void StoryListJson_WithChapters_ParsesTitleRecapGoalInOrder() + { + const string json = "{\"beats\":[],\"chapters\":[" + + "{\"chapterId\":\"ch1\",\"title\":\"1장 · 초원\",\"openingBeatId\":\"ch1_intro\",\"recap\":[],\"goal\":\"어르신 만나기\"}," + + "{\"chapterId\":\"ch2\",\"title\":\"2장 · 연못\",\"openingBeatId\":\"ch2_arrive\"," + + "\"recap\":[\"첫 줄\",\"둘째 줄\",\"셋째 줄\"],\"goal\":\"연못으로\"}]}"; + + StoryList list = JsonUtility.FromJson(json); + + Assert.AreEqual(2, list.chapters.Count); + Assert.AreEqual("ch1", list.chapters[0].chapterId); + Assert.AreEqual(0, list.chapters[0].recap.Count, "빈 recap은 빈 목록이다(카드 없음)"); + StoryChapter ch2 = list.chapters[1]; + Assert.AreEqual("2장 · 연못", ch2.title); + Assert.AreEqual("ch2_arrive", ch2.openingBeatId); + CollectionAssert.AreEqual(new[] { "첫 줄", "둘째 줄", "셋째 줄" }, ch2.recap); + Assert.AreEqual("연못으로", ch2.goal); + } + + [Test] + public void StoryListJson_ChapterWithoutRecapKey_KeepsEmptyRecapList() + { + const string json = "{\"chapters\":[{\"chapterId\":\"ch1\",\"title\":\"1장\",\"openingBeatId\":\"ch1_intro\",\"goal\":\"g\"}]}"; + + StoryList list = JsonUtility.FromJson(json); + + Assert.AreEqual(1, list.chapters.Count); + Assert.IsNotNull(list.chapters[0].recap, "recap 키가 없으면 빈 목록이어야 한다 — 카드 경로가 Count를 읽는다"); + Assert.AreEqual(0, list.chapters[0].recap.Count); + Assert.IsNotNull(list.beats, "beats 키가 없어도 빈 목록"); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/StoryChapterTests.cs.meta b/Assets/Tests/EditMode/StoryChapterTests.cs.meta new file mode 100644 index 0000000..7372537 --- /dev/null +++ b/Assets/Tests/EditMode/StoryChapterTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3e7ec200f7625ca47924d3772ab6b5d5 \ No newline at end of file diff --git a/Assets/Tests/EditMode/StoryDeferGateTests.cs b/Assets/Tests/EditMode/StoryDeferGateTests.cs index 76a5942..e25bac4 100644 --- a/Assets/Tests/EditMode/StoryDeferGateTests.cs +++ b/Assets/Tests/EditMode/StoryDeferGateTests.cs @@ -1,5 +1,7 @@ #if UNITY_EDITOR using System.Reflection; +using System.Collections; +using System.Collections.Generic; using InsectGame.Core; using InsectGame.Story; using InsectGame.UI; @@ -138,6 +140,210 @@ public void NoCameraWired_FallsBackToModalCheckOnly() try { Assert.IsTrue(Defers()); } finally { ModalUIRegistry.Unregister(modal); } } + + [Test] + public void PendingStory_EventIsQueuedEvenBeforeModalRegisters() + { + FieldInfo pending = typeof(StoryDirector).GetField("pendingBeatId", BindingFlags.Instance | BindingFlags.NonPublic); + pending.SetValue(director, "reading"); + MethodInfo route = typeof(StoryDirector).GetMethod("RouteTrigger", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsFalse((bool)route.Invoke(director, new object[] { "CaptureInsect", "qa_insect" })); + IList queue = (IList)typeof(StoryDirector).GetField("pendingTriggers", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(director); + Assert.AreEqual(1, queue.Count, "모달 등록 전/보상 지급 중 이벤트도 읽던 대사 뒤에 남겨야 한다"); + } + + [Test] + public void PendingStory_NpcTalkDoesNotClaimItOpenedAnotherStory() + { + WithStoryCache(new StoryBeat { beatId = "qa_npc", trigger = new StoryTrigger { type = "NpcTalk", param = "qa_npc" } }, () => + { + typeof(StoryDirector).GetField("pendingBeatId", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(director, "reading"); + Assert.IsFalse(director.OnNpcTalked("qa_npc")); + }); + } + + [Test] + public void LevelReachedBeforePrerequisite_ResweepContinuesWithoutAnotherLevelUp() + { + GameObject progressHost = new GameObject("InactiveStoryProgressFixture"); + progressHost.SetActive(false); // Awake/저장 로드를 실행하지 않는다. + try + { + PlayerProgressController player = progressHost.AddComponent(); + typeof(PlayerProgressController).GetField("data", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(player, new PlayerProgressData { level = 5 }); + director.AutoWire(null, null, player, null, null); + WithStoryCache(new StoryBeat + { + beatId = "qa_growth", prerequisiteBeatId = "qa_intro", + trigger = new StoryTrigger { type = "LevelReach", param = "3" } + }, () => + { + string fired = null; + director.StoryBeatTriggered += beat => fired = beat.beatId; + MethodInfo sweep = typeof(StoryDirector).GetMethod("ResweepPersistentConditions", BindingFlags.Instance | BindingFlags.NonPublic); + MethodInfo drain = typeof(StoryDirector).GetMethod("DrainPendingTriggers", BindingFlags.Instance | BindingFlags.NonPublic); + sweep.Invoke(director, null); + drain.Invoke(director, null); + Assert.IsNull(fired, "선행 대화 전에는 누적 조건만으로 발화하면 안 된다"); + typeof(StoryDirector).GetMethod("MarkSeen", BindingFlags.Instance | BindingFlags.NonPublic) + .Invoke(director, new object[] { "qa_intro" }); + sweep.Invoke(director, null); + drain.Invoke(director, null); + Assert.AreEqual("qa_growth", fired); + Assert.AreEqual(5, player.Level); + }); + } + finally { Object.DestroyImmediate(progressHost); } + } + + private void WithStoryCache(StoryBeat beat, System.Action action) + { + WithStoryCache(new[] { beat }, action); + } + + [TestCase("DexProgress", "2")] + [TestCase("RegionCleansed", "forest")] + [TestCase("DuelWin", "ledger_grip")] + public void PersistedCondition_RespectsPrerequisiteAndRegion_AndDoesNotReplay(string type, string param) + { + GameObject stateHost = new GameObject("InactivePersistentStoryFixture"); + stateHost.SetActive(false); + try + { + var region = stateHost.AddComponent(); + var dex = stateHost.AddComponent(); + var blight = stateHost.AddComponent(); + var duel = stateHost.AddComponent(); + SetField(region, "currentRegion", new InsectGame.Data.RegionData { regionId = "pond" }); + var savedDex = new InsectGame.Dex.DexSaveData(); + savedDex.records.Add(new InsectGame.Dex.DexRecord("qa_a") { capturedCount = 1 }); + savedDex.records.Add(new InsectGame.Dex.DexRecord("qa_b") { capturedCount = 1 }); + SetField(dex, "saveData", savedDex); + SetField(blight, "loaded", true); + ((HashSet)GetField(blight, "cleansedRegions")).Add("forest"); + SetField(duel, "bossStateLoaded", true); + ((HashSet)GetField(duel, "defeatedBosses")).Add("ledger_grip"); + director.AutoWire(region, null, null, null, null); + director.AutoWire(dex); + director.AutoWire(blight); + director.AutoWire(duel); + WithStoryCache(new StoryBeat + { + beatId = "qa_persisted", prerequisiteBeatId = "qa_intro", requiredRegionId = "forest", + trigger = new StoryTrigger { type = type, param = param } + }, () => + { + int fired = 0; + director.StoryBeatTriggered += _ => fired++; + SweepAndDrain(); + Assert.AreEqual(0, fired); + InvokeDirector("MarkSeen", "qa_intro"); + SweepAndDrain(); + Assert.AreEqual(0, fired, "조건과 선행 대화를 완료해도 다른 리전에서는 발화하지 않는다"); + var forest = new InsectGame.Data.RegionData { regionId = "forest" }; + SetField(region, "currentRegion", forest); + InvokeDirector("OnRegionChanged", forest); + InvokeDirector("DrainPendingTriggers"); + Assert.AreEqual(1, fired, "재포획/재격파 없이 해당 리전 진입으로 이어진다"); + SweepAndDrain(); + Assert.AreEqual(1, fired, "열린 대사를 중복 발화하지 않는다"); + // 저장/보상 부작용 없이 완료 상태를 재현한다. + InvokeDirector("MarkSeen", "qa_persisted"); + SetField(director, "pendingBeatId", null); + SweepAndDrain(); + Assert.AreEqual(1, fired, "이미 읽은 비트는 보상 가능한 대사로 다시 열리지 않는다"); + Assert.AreEqual(2, dex.CapturedSpeciesCount); + }); + } + finally { Object.DestroyImmediate(stateHost); } + } + + [Test] + public void CompletedQuestResweep_DoesNotOvertakeQueuedChoiceResult() + { + GameObject questHost = new GameObject("InactiveStoryQuestFixture"); + questHost.SetActive(false); + try + { + var quest = questHost.AddComponent(); + ((HashSet)GetField(quest, "completedQuests")).Add("qa_quest"); + director.AutoWire(null, null, null, null, quest); + WithStoryCache(new[] + { + new StoryBeat { beatId = "qa_choice", trigger = new StoryTrigger { type = "Immediate" } }, + new StoryBeat { beatId = "qa_quest_story", trigger = new StoryTrigger { type = "QuestComplete", param = "qa_quest" } } + }, () => + { + var fired = new List(); + director.StoryBeatTriggered += beat => fired.Add(beat.beatId); + director.QueueChoice("qa_choice"); + InvokeDirector("ResweepCompletedQuests"); + InvokeDirector("DrainPendingTriggers"); + CollectionAssert.AreEqual(new[] { "qa_choice" }, fired); + InvokeDirector("MarkSeen", "qa_choice"); + SetField(director, "pendingBeatId", null); + InvokeDirector("DrainPendingTriggers"); + CollectionAssert.AreEqual(new[] { "qa_choice", "qa_quest_story" }, fired); + }); + } + finally { Object.DestroyImmediate(questHost); } + } + + private static object GetField(object target, string name) => target.GetType() + .GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).GetValue(target); + + [Test] + public void FinalVictory_AfterSeal_ChiefConversationOpensAftermathWithoutAnotherBattle() + { + Assert.IsTrue(StoryService.TryGetBeat("ch12_clash", out StoryBeat aftermath)); + GameObject regionHost = new GameObject("InactiveFinalStoryRegionFixture"); + regionHost.SetActive(false); + try + { + var region = regionHost.AddComponent(); + SetField(region, "currentRegion", new InsectGame.Data.RegionData { regionId = "nameless" }); + director.AutoWire(region, null, null, null, null); + WithStoryCache(aftermath, () => + { + string fired = null; + director.StoryBeatTriggered += beat => fired = beat.beatId; + Assert.IsFalse(director.OnNpcTalked("ledger_chief"), "최종 승리 전에 여운을 미리 말하지 않는다"); + InvokeDirector("MarkSeen", "fin_seal"); + Assert.IsTrue(director.OnNpcTalked("ledger_chief")); + Assert.AreEqual("ch12_clash", fired); + Assert.AreEqual(55, aftermath.onComplete.rewardCandy); + Assert.AreEqual(130, aftermath.onComplete.rewardExp); + }); + } + finally { Object.DestroyImmediate(regionHost); } + } + + private static void SetField(object target, string name, object value) => target.GetType() + .GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).SetValue(target, value); + + private void InvokeDirector(string name, params object[] args) => typeof(StoryDirector) + .GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic).Invoke(director, args); + + private void SweepAndDrain() + { + InvokeDirector("ResweepPersistentConditions"); + InvokeDirector("DrainPendingTriggers"); + } + + private void WithStoryCache(StoryBeat[] beats, System.Action action) + { + FieldInfo cache = typeof(StoryService).GetField("cache", BindingFlags.Static | BindingFlags.NonPublic); + object previous = cache.GetValue(null); + var fixture = new Dictionary(); + foreach (StoryBeat beat in beats) fixture.Add(beat.beatId, beat); + cache.SetValue(null, fixture); + // 실제 계정 진행과 무관한 테스트. CompleteBeat/Save는 호출하지 않는다. + typeof(StoryDirector).GetField("progress", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(director, new StoryProgressData()); + try { action(); } + finally { cache.SetValue(null, previous); } + } } } #endif diff --git a/Assets/Tests/EditMode/StoryDialogueStagingTests.cs b/Assets/Tests/EditMode/StoryDialogueStagingTests.cs new file mode 100644 index 0000000..68a4179 --- /dev/null +++ b/Assets/Tests/EditMode/StoryDialogueStagingTests.cs @@ -0,0 +1,142 @@ +#if UNITY_EDITOR +using InsectGame.UI; +using NUnit.Framework; +using Fx = InsectGame.UI.StoryDialogueStaging.LineFx; +using Side = InsectGame.UI.StoryDialogueStaging.Side; + +namespace InsectGame.Tests +{ + [TestFixture] + public class StoryDialogueStagingTests + { + [Test] + public void ParseFx_CommaSeparated_IgnoresCaseAndSpaces() + { + Assert.AreEqual(Fx.Shake | Fx.Flash, StoryDialogueStaging.ParseFx("shake, Flash")); + Assert.AreEqual(Fx.None, StoryDialogueStaging.ParseFx(null)); + Assert.AreEqual(Fx.None, StoryDialogueStaging.ParseFx("")); + } + + [Test] + public void ParseFx_UnknownToken_IsDropped() + { + // 모르는 토큰으로 연출 전체가 날아가면 안 된다 — 아는 것만 남긴다(오타는 story_lint가 잡는다). + Assert.AreEqual(Fx.Pause, StoryDialogueStaging.ParseFx("pause,zoom")); + } + + [Test] + public void KnownFxTokens_EachMapsToItsOwnFlag() + { + // 목록(린트가 읽는 단일 출처)과 해석 switch가 어긋나면 저작한 연출이 조용히 사라진다. + Fx seen = Fx.None; + foreach (string token in StoryDialogueStaging.KnownFxTokens) + { + Fx fx = StoryDialogueStaging.ParseFx(token); + Assert.AreNotEqual(Fx.None, fx, $"'{token}'이 아무 연출로도 풀리지 않는다"); + Assert.AreEqual(Fx.None, seen & fx, $"'{token}'이 다른 토큰과 같은 플래그다"); + seen |= fx; + } + } + + [Test] + public void VisibleChars_GrowsOverTimeAndStopsAtLength() + { + const int len = 40; + Assert.AreEqual(0, StoryDialogueStaging.VisibleChars(len, 0f, Fx.None)); + int early = StoryDialogueStaging.VisibleChars(len, 0.2f, Fx.None); + int later = StoryDialogueStaging.VisibleChars(len, 0.6f, Fx.None); + Assert.Greater(early, 0); + Assert.Greater(later, early); + float full = StoryDialogueStaging.RevealDuration(len, Fx.None); + Assert.AreEqual(len, StoryDialogueStaging.VisibleChars(len, full + 0.01f, Fx.None)); + Assert.AreEqual(len, StoryDialogueStaging.VisibleChars(len, 999f, Fx.None)); + } + + [Test] + public void VisibleChars_PauseDelaysTheFirstLetter() + { + Assert.AreEqual(0, StoryDialogueStaging.VisibleChars(20, StoryDialogueStaging.PauseSeconds - 0.01f, Fx.Pause)); + Assert.Greater(StoryDialogueStaging.VisibleChars(20, StoryDialogueStaging.PauseSeconds + 0.2f, Fx.Pause), 0); + } + + [Test] + public void TypingSpeed_ShoutFasterSlowSlower() + { + float normal = StoryDialogueStaging.TypingSpeed(Fx.None); + Assert.Greater(StoryDialogueStaging.TypingSpeed(Fx.Shout), normal); + Assert.Less(StoryDialogueStaging.TypingSpeed(Fx.Slow), normal); + Assert.Less(StoryDialogueStaging.TypingSpeed(Fx.Whisper), normal); + } + + [Test] + public void RevealDuration_OrdinaryLineUnderTwoSeconds() + { + // 한 줄이 2초를 넘겨 나오면 읽는 사람이 기다리게 된다 — 50자 기준. + Assert.Less(StoryDialogueStaging.RevealDuration(50, Fx.None), 2f); + } + + [Test] + public void ShakeAndFlash_OnlyWhenTaggedAndOnlyBriefly() + { + Assert.AreEqual(0f, StoryDialogueStaging.ShakeOffset(Fx.None, 0.05f)); + Assert.AreNotEqual(0f, StoryDialogueStaging.ShakeOffset(Fx.Shake, 0.05f)); + Assert.AreEqual(0f, StoryDialogueStaging.ShakeOffset(Fx.Shake, StoryDialogueStaging.ShakeSeconds)); + + Assert.AreEqual(0f, StoryDialogueStaging.FlashOverlay(Fx.None, 0f)); + float start = StoryDialogueStaging.FlashOverlay(Fx.Flash, 0f); + float mid = StoryDialogueStaging.FlashOverlay(Fx.Flash, StoryDialogueStaging.FlashSeconds * 0.5f); + Assert.Greater(start, mid); + Assert.AreEqual(0f, StoryDialogueStaging.FlashOverlay(Fx.Flash, StoryDialogueStaging.FlashSeconds)); + } + + [Test] + public void HopOffset_RisesAndLandsWithinWindow() + { + Assert.AreEqual(0f, StoryDialogueStaging.HopOffset(0f), 0.0001f); + Assert.Greater(StoryDialogueStaging.HopOffset(StoryDialogueStaging.HopSeconds * 0.5f), 0f); + Assert.AreEqual(0f, StoryDialogueStaging.HopOffset(StoryDialogueStaging.HopSeconds)); + } + + [Test] + public void AssignSides_TwoSpeakers_FaceEachOther() + { + var stages = StoryDialogueStaging.AssignSides(new[] { "a", "b", "a", "b" }); + Assert.AreEqual("a", stages[3].LeftId); + Assert.AreEqual("b", stages[3].RightId); + Assert.AreEqual(new[] { Side.Left, Side.Right, Side.Left, Side.Right }, + new[] { stages[0].Active, stages[1].Active, stages[2].Active, stages[3].Active }); + } + + [Test] + public void AssignSides_FirstLineHasOnlyTheSpeakerOnStage() + { + var stages = StoryDialogueStaging.AssignSides(new[] { "a", "b" }); + Assert.AreEqual("a", stages[0].LeftId); + Assert.IsNull(stages[0].RightId, "아직 말하지 않은 사람이 먼저 무대에 서 있다"); + } + + [Test] + public void AssignSides_NarrationKeepsStageAndSpeaksForNoOne() + { + var stages = StoryDialogueStaging.AssignSides(new[] { "a", null, "b" }); + Assert.AreEqual(Side.None, stages[1].Active); + Assert.AreEqual("a", stages[1].LeftId, "지문 줄에서 서 있던 사람이 사라졌다"); + Assert.AreEqual(Side.Right, stages[2].Active); + } + + [Test] + public void AssignSides_ThirdSpeaker_ReplacesWhoeverSpokeLongestAgo() + { + // a·b 다음 c — 더 오래 말하지 않은 a의 자리에 선다. + var abc = StoryDialogueStaging.AssignSides(new[] { "a", "b", "c" }); + Assert.AreEqual("c", abc[2].LeftId); + Assert.AreEqual("b", abc[2].RightId); + + // a·b·a 다음 c — 이번엔 b가 더 오래 말하지 않았다. 방금 말한 a는 남아 대꾸를 받는다. + var abac = StoryDialogueStaging.AssignSides(new[] { "a", "b", "a", "c" }); + Assert.AreEqual("a", abac[3].LeftId); + Assert.AreEqual("c", abac[3].RightId); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/StoryDialogueStagingTests.cs.meta b/Assets/Tests/EditMode/StoryDialogueStagingTests.cs.meta new file mode 100644 index 0000000..398b758 --- /dev/null +++ b/Assets/Tests/EditMode/StoryDialogueStagingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b5d52fc9005fdce408e1e6e79163dd38 \ No newline at end of file diff --git a/Assets/Tests/EditMode/StoryDuelFlowTests.cs b/Assets/Tests/EditMode/StoryDuelFlowTests.cs new file mode 100644 index 0000000..ce597dd --- /dev/null +++ b/Assets/Tests/EditMode/StoryDuelFlowTests.cs @@ -0,0 +1,357 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using System.Text.RegularExpressions; +using InsectGame.Core; +using InsectGame.NPC; +using InsectGame.Story; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 「이야기 전투」(2026-10-04) — 대사 직후 대결( → ), + /// 간부 필수(승리 비트가 다음 장의 선행), 그리고 이미 이긴 세이브의 자동 통과. + /// + /// 실패가 전부 무증상이라 여기서 고정한다: 대결이 그냥 안 열리거나(오타·퇴장 연출·꿈), 이겨도 다음 장이 안 열리거나 + /// (승리를 놓친 세이브), 이미 지나온 장으로 HUD가 되돌려 보낸다(나중에 끼워 넣은 스파인). 정적 저작은 story_lint 검사 35·8이 함께 본다. + /// + [TestFixture] + public class StoryDuelFlowTests + { + private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + + private static System.Func Seen(params string[] ids) + { + var set = new HashSet(ids); + return set.Contains; + } + + private static StoryBeat RealBeat(string id) + { + Assert.IsTrue(StoryService.TryGetBeat(id, out StoryBeat beat), $"Story.json에 {id}가 없다"); + return beat; + } + + // ── 순수 판정 ── + + [Test] + public void KindOf_BossesRivalAndOthers() + { + Assert.AreEqual(StoryDuelLauncher.DuelKind.Boss, StoryDuelLauncher.KindOf("ledger_grip")); + Assert.AreEqual(StoryDuelLauncher.DuelKind.Boss, StoryDuelLauncher.KindOf("ledger_scale")); + Assert.AreEqual(StoryDuelLauncher.DuelKind.Boss, StoryDuelLauncher.KindOf("ledger_chief")); + Assert.AreEqual(StoryDuelLauncher.DuelKind.Rival, StoryDuelLauncher.KindOf("catcher_rival")); + Assert.AreEqual(StoryDuelLauncher.DuelKind.None, StoryDuelLauncher.KindOf("ruins_scholar")); + Assert.AreEqual(StoryDuelLauncher.DuelKind.None, StoryDuelLauncher.KindOf("")); + Assert.AreEqual(StoryDuelLauncher.DuelKind.None, StoryDuelLauncher.KindOf(null)); + } + + [TestCase(false, false, false, false, false)] + [TestCase(true, false, false, false, true)] // 대사·영상·컷신·연출이 떠 있다 + [TestCase(false, true, false, false, true)] // 다른 전투(결과 화면 포함) + [TestCase(false, false, true, false, true)] // 선택지 결과 대사가 큐에 남았다 + [TestCase(false, false, false, true, true)] // 조작이 묶여 있다 + public void ShouldWait_AnyBlockerWaits(bool modal, bool battle, bool story, bool frozen, bool expected) + { + Assert.AreEqual(expected, StoryDuelLauncher.ShouldWait(modal, battle, story, frozen)); + } + + // ── 대기열 ── + + private static void Complete(StoryDuelLauncher launcher, StoryBeat beat) + { + typeof(StoryDuelLauncher).GetMethod("OnBeatCompleted", Private).Invoke(launcher, new object[] { beat }); + } + + [Test] + public void BeatWithDuelAfter_IsQueued_OthersAreNot() + { + GameObject host = new GameObject("StoryDuelFlowTests"); + host.SetActive(false); // Update가 돌아 진짜 전투를 열지 않게 + try + { + var launcher = host.AddComponent(); + Complete(launcher, new StoryBeat { beatId = "qa_talk" }); + Assert.IsFalse(launcher.HasPendingDuel, "duelAfter가 없는 비트는 대결을 걸지 않는다"); + + Complete(launcher, new StoryBeat { beatId = "qa_confront", duelAfter = " ledger_grip " }); + Assert.IsTrue(launcher.HasPendingDuel); + Assert.AreEqual("ledger_grip", launcher.PendingNpcId, "앞뒤 공백은 저작 실수로 보고 걷어 낸다"); + Assert.AreEqual("ledger_grip", launcher.LastQueuedNpcId); + } + finally { Object.DestroyImmediate(host); } + } + + [Test] + public void InDream_NothingIsQueued() + { + GameObject host = new GameObject("StoryDuelFlowTestsDream"); + host.SetActive(false); + DreamPrologueState.Begin(); + try + { + var launcher = host.AddComponent(); + Complete(launcher, new StoryBeat { beatId = "qa_confront", duelAfter = "ledger_grip" }); + Assert.IsFalse(launcher.HasPendingDuel, "꿈은 이야기의 대결을 열지 않는다(rules/dream-prologue.md)"); + } + finally + { + DreamPrologueState.End(); + Object.DestroyImmediate(host); + } + } + + [Test] + public void UnknownOpponent_IsNotQueued_AndSaysSo() + { + GameObject host = new GameObject("StoryDuelFlowTestsTypo"); + host.SetActive(false); + // LogAssert는 쓰지 않는다 — asmdef가 없어 테스트가 Assembly-CSharp로 컴파일되는 이 프로젝트에선 참조가 안 된다. + int warned = 0; + Application.LogCallback count = (message, _, type) => + { + if (type == LogType.Warning && Regex.IsMatch(message, "대결 표에 없는 상대")) warned++; + }; + Application.logMessageReceived += count; + try + { + var launcher = host.AddComponent(); + Complete(launcher, new StoryBeat { beatId = "qa_confront", duelAfter = "ledger_gripp" }); + Assert.IsFalse(launcher.HasPendingDuel); + Assert.AreEqual(1, warned, "오타는 조용히 넘기지 않는다 — 대결이 안 열리는 건 화면상 티가 안 난다"); + } + finally + { + Application.logMessageReceived -= count; + Object.DestroyImmediate(host); + } + } + + // ── 실제 Story.json ── + + [Test] + public void RealStory_DuelAfterBeats_AreTheFourStoryBattles() + { + var actual = new Dictionary(); + foreach (StoryBeat b in StoryService.AllBeats()) + if (b != null && !string.IsNullOrEmpty(b.duelAfter)) actual[b.beatId] = b.duelAfter; + + var expected = new Dictionary + { + ["ch1_rival_intro"] = "catcher_rival", + ["ch8_confront"] = "ledger_grip", + ["ch9_confront"] = "ledger_scale", + ["ch12_confront"] = "ledger_chief", + }; + CollectionAssert.AreEquivalent(expected, actual); + foreach (string id in expected.Keys) + { + StoryBeat b = RealBeat(id); + Assert.IsTrue(string.IsNullOrEmpty(b.stageExitId), $"{id}: 퇴장 연출이 대결 상대를 걸어 나가게 한다"); + Assert.AreNotEqual(StoryDuelLauncher.DuelKind.None, StoryDuelLauncher.KindOf(b.duelAfter), id); + } + } + + [Test] + public void RealStory_ScenesEndWithTheChallenge() + { + // 대사가 끝나면 곧바로 싸운다 — 마지막 줄이 도전이어야 "왜 갑자기 전투?"가 안 된다. + StringAssert.EndsWith("승부다!", LastLine("ch1_rival_intro").text); + Assert.AreEqual("집게", LastLine("ch8_confront").speaker); + Assert.AreEqual("하월", LastLine("ch12_confront").speaker); + // 저울은 선택지 장면이다 — 대결은 고른 결과 대사 뒤에 열리므로 결과 둘이 도전으로 끝난다. + Assert.AreEqual("저울", LastLine("ch9_confront_answer").speaker); + Assert.AreEqual("저울", LastLine("ch9_confront_silent").speaker); + } + + private static StoryLine LastLine(string beatId) + { + StoryBeat b = RealBeat(beatId); + Assert.Greater(b.lines.Count, 0, beatId); + return b.lines[b.lines.Count - 1]; + } + + [Test] + public void RealStory_RaonsFirstMeeting_OpensTheMeadowStageRightAway() + { + // 대사를 닫는 순간(CompleteBeat → MarkSeen 뒤 StoryBeatCompleted) 그 자리에서 단계가 골라져야 한다. + Assert.AreEqual("meadow", RealBeat("ch1_rival_intro").requiredRegionId); + Assert.IsTrue(NpcRivalDuels.TrySelect("catcher_rival", "meadow", + Seen("ch1_intro", "ch1_first_capture", "ch1_rival_intro"), _ => false, out NpcRivalDuels.Stage s)); + Assert.AreEqual("rival_meadow", s.stageId); + } + + [TestCase("duel_grip_win", "ch8_confront", "ch9_arrive")] + [TestCase("duel_scale_win", "ch9_confront", "ch10_arrive")] + [TestCase("duel_chief_win", "ch12_confront", "fin_unnamed")] + public void RealStory_BossWin_GatesTheNextStep(string winId, string confrontId, string nextId) + { + StoryBeat win = RealBeat(winId); + Assert.AreEqual(StoryDirector.TriggerDuelWin, win.trigger.type); + Assert.AreEqual(confrontId, win.requiredBeatId, $"{winId}: 대치를 본 뒤에만 뜬다"); + // 리전 게이트를 두지 않는다 — 자동 통과(재확인)가 어디서든 이어지게. DuelWin 소스가 그 간부 대결뿐이라 엉뚱한 리전 발화도 없다. + Assert.IsTrue(string.IsNullOrEmpty(win.requiredRegionId), $"{winId}: 리전 게이트가 자동 통과를 그 리전으로 묶는다"); + Assert.IsFalse(string.IsNullOrWhiteSpace(win.why), $"{winId}: 스파인 — HUD 이유가 필요하다"); + Assert.AreEqual(winId, RealBeat(nextId).prerequisiteBeatId, $"{nextId}: 간부를 이겨야 열린다"); + } + + [Test] + public void RealStory_RetiredTauntBeats_AreGone() + { + // "한 번 더 말을 걸면 붙는다"(talk_*)는 대사 직후 대결로 바뀌었다. 남아 있으면 진 뒤 재도전하러 + // 말을 걸 때 그 대사가 대결을 가로막는다(WorldInteractionController는 NpcTalk 비트가 뜨면 대결을 안 연다). + foreach (string id in new[] { "talk_grip", "talk_scale", "talk_chief" }) + Assert.IsFalse(StoryService.TryGetBeat(id, out _), id); + foreach (StoryBeat b in StoryService.AllBeats()) + { + if (b == null || b.trigger == null || b.trigger.type != StoryDirector.TriggerNpcTalk) continue; + if (b.trigger.param != "ledger_grip" && b.trigger.param != "ledger_scale") continue; + Assert.Fail($"{b.beatId}: 집게·저울에게 거는 말 걸기 비트가 재도전을 가로막는다"); + } + } + + // ── 자동 통과 — 이미 이긴 세이브 ── + + [Test] + public void AlreadyBeatenBoss_WinBeatFiresOnResweep_AnywhereAndOnce() + { + StoryBeat win = RealBeat("duel_grip_win"); + GameObject directorHost = new GameObject("StoryDuelFlowTestsDirector"); + GameObject stateHost = new GameObject("StoryDuelFlowTestsState"); + stateHost.SetActive(false); // Awake/저장 로드를 돌리지 않는다 + try + { + var director = directorHost.AddComponent(); + var region = stateHost.AddComponent(); + var duel = stateHost.AddComponent(); + // 서릿길에 서 있다 — 이야기 잠금 전에 이미 열렸던 세이브. 승리 비트를 놓친 채다. + SetField(region, "currentRegion", new InsectGame.Data.RegionData { regionId = "frostline" }); + SetField(duel, "bossStateLoaded", true); + ((HashSet)GetField(duel, "defeatedBosses")).Add("ledger_grip"); + director.AutoWire(region, null, null, null, null); + director.AutoWire(duel); + + WithStoryCache(director, new[] { win }, () => + { + var fired = new List(); + director.StoryBeatTriggered += b => fired.Add(b.beatId); + + Invoke(director, "ResweepPersistentConditions"); + Invoke(director, "DrainPendingTriggers"); + Assert.IsEmpty(fired, "대치를 보기 전에는 뜨지 않는다"); + + Invoke(director, "MarkSeen", "ch8_confront"); + Invoke(director, "ResweepPersistentConditions"); + Invoke(director, "DrainPendingTriggers"); + CollectionAssert.AreEqual(new[] { "duel_grip_win" }, fired, + "다시 싸우지 않아도 재확인만으로 이어진다(다른 리전에서도)"); + + Invoke(director, "MarkSeen", "duel_grip_win"); + SetField(director, "pendingBeatId", null); + Invoke(director, "ResweepPersistentConditions"); + Invoke(director, "DrainPendingTriggers"); + Assert.AreEqual(1, fired.Count, "본 승리 비트를 다시 띄우지 않는다"); + Assert.IsFalse(director.IsBusy, "큐가 비었다 — 대사 직후 대결이 기다리지 않는다"); + }); + } + finally + { + Object.DestroyImmediate(directorHost); + Object.DestroyImmediate(stateHost); + } + } + + [Test] + public void IsBusy_TracksTheStoryQueue() + { + GameObject host = new GameObject("StoryDuelFlowTestsBusy"); + try + { + var director = host.AddComponent(); + Assert.IsFalse(director.IsBusy); + SetField(director, "pendingBeatId", "reading"); + Assert.IsTrue(director.IsBusy, "대사가 떠 있다"); + SetField(director, "pendingBeatId", null); + director.QueueChoice("ch9_confront_answer"); // 선택지 결과가 큐에 들어간다 + Assert.IsTrue(director.IsBusy, "선택지 결과 대사가 아직 남았다 — 대결이 먼저 열리면 안 된다"); + } + finally { Object.DestroyImmediate(host); } + } + + // ── 나중에 끼워 넣은 스파인 ── + + [Test] + public void LiveSpine_OnlyBeatsWithUnseenFollowers() + { + var beats = new[] + { + new StoryBeat { beatId = "win", chapterId = "ch8" }, + new StoryBeat { beatId = "arrive", chapterId = "ch9", prerequisiteBeatId = "win" }, + new StoryBeat { beatId = "next", chapterId = "ch9", prerequisiteBeatId = "arrive" }, + }; + HashSet fresh = StoryObjectiveResolver.CollectLiveSpineBeatIds(beats, Seen()); + CollectionAssert.AreEquivalent(new[] { "win", "arrive" }, fresh, "정상 진행에서는 정적 스파인과 같다"); + + HashSet legacy = StoryObjectiveResolver.CollectLiveSpineBeatIds(beats, Seen("arrive")); + Assert.IsFalse(legacy.Contains("win"), "뒤를 이미 본 선행은 더 잇는 것이 없다"); + Assert.IsTrue(legacy.Contains("arrive")); + } + + [Test] + public void RealStory_SaveThatPassedFrostlineWithoutGrip_IsNotSentBack() + { + // 이야기 잠금 전에 서릿길을 지나온 세이브 — ch9까지 다 봤고 집게는 안 이겼다. 다음 목표는 잿불 쪽이어야 한다. + var beats = new List(StoryService.AllBeats()); + Assume.That(beats.Count, Is.GreaterThan(0), "Story.json 로드 실패"); + var seen = new HashSet(); + foreach (StoryBeat b in beats) + { + int rank = StoryObjectiveResolver.ChapterRank(b.chapterId); + if (rank <= 9 && b.beatId != "duel_grip_win") seen.Add(b.beatId); + } + seen.Add("duel_scale_win"); + Assume.That(seen.Contains("ch9_arrive")); + + StoryBeat chosen = StoryObjectiveResolver.SelectObjectiveBeat(beats, seen.Contains, + StoryObjectiveResolver.CollectLiveSpineBeatIds(beats, seen.Contains), null, + StoryObjectiveResolver.CollectChoiceTargetIds(beats)); + Assert.IsNotNull(chosen); + Assert.AreNotEqual("duel_grip_win", chosen.beatId, "지나온 장의 간부전으로 HUD가 되돌려 보낸다"); + Assert.AreEqual(10, StoryObjectiveResolver.ChapterRank(chosen.chapterId), $"다음 목표 {chosen.beatId}({chosen.chapterId})"); + } + + [Test] + public void DescribeDuelLockObjective_NamesTheOpponent() + { + Assert.AreEqual("서릿길(으)로 가려면 집게에게 이기기", + StoryObjectiveResolver.DescribeDuelLockObjective("서릿길", "집게")); + Assert.AreEqual("서릿길(으)로", StoryObjectiveResolver.DescribeDuelLockObjective("서릿길", null)); + } + + // ── 리플렉션 도우미 (StoryDeferGateTests와 같은 방식 — 저장·보상 부작용 없이 상태만 세운다) ── + + private static void SetField(object target, string name, object value) + => target.GetType().GetField(name, Private).SetValue(target, value); + + private static object GetField(object target, string name) + => target.GetType().GetField(name, Private).GetValue(target); + + private static void Invoke(StoryDirector director, string name, params object[] args) + => typeof(StoryDirector).GetMethod(name, Private).Invoke(director, args); + + private static void WithStoryCache(StoryDirector director, StoryBeat[] beats, System.Action action) + { + FieldInfo cache = typeof(StoryService).GetField("cache", BindingFlags.Static | BindingFlags.NonPublic); + object previous = cache.GetValue(null); + var fixture = new Dictionary(); + foreach (StoryBeat beat in beats) fixture.Add(beat.beatId, beat); + cache.SetValue(null, fixture); + typeof(StoryDirector).GetField("progress", Private).SetValue(director, new StoryProgressData()); + try { action(); } + finally { cache.SetValue(null, previous); } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/StoryDuelFlowTests.cs.meta b/Assets/Tests/EditMode/StoryDuelFlowTests.cs.meta new file mode 100644 index 0000000..04f87b6 --- /dev/null +++ b/Assets/Tests/EditMode/StoryDuelFlowTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9407a2bf5d3071a4d94b7e1351d6197a \ No newline at end of file diff --git a/Assets/Tests/EditMode/StoryJournalVideoTests.cs b/Assets/Tests/EditMode/StoryJournalVideoTests.cs new file mode 100644 index 0000000..96a8c6b --- /dev/null +++ b/Assets/Tests/EditMode/StoryJournalVideoTests.cs @@ -0,0 +1,111 @@ +#if UNITY_EDITOR +using InsectGame.Story; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 저널 영상 다시보기 — 행 버튼 자리()와 다시 볼 영상 고르기(). + /// 화면은 QA 빌드 -battleScenario story-video의 00-journal-* 컷(ESC가 영상만 닫는지도 거기서 잰다). + /// + [TestFixture] + public class StoryJournalVideoTests + { + // 목록 영역 폭 — 데스크톱(패널 960 − 탭 260 − 여백), 모바일(탭 220), 좁게 줄어든 패널. + [TestCase(644f, 92f)] + [TestCase(684f, 104f)] + [TestCase(480f, 92f)] + public void RowButtons_InsideRow_NoOverlap_TouchHeight(float width, float height) + { + var row = new Rect(0f, 300f, width, height); + Rect replay = StoryJournalRowLayout.ReplayButton(row); + Rect video = StoryJournalRowLayout.VideoButton(row); + + foreach (Rect b in new[] { replay, video }) + { + Assert.GreaterOrEqual(b.height, UIScale.MinTouchHeight, "터치 높이 하한"); + Assert.GreaterOrEqual(b.xMin, row.xMin); + Assert.LessOrEqual(b.xMax, row.xMax); + Assert.GreaterOrEqual(b.yMin, row.yMin, "버튼이 행 위로 넘친다"); + Assert.LessOrEqual(b.yMax, row.yMax, "버튼이 행 아래로 넘친다"); + } + Assert.IsFalse(replay.Overlaps(video), "「▶ 영상」과 「다시 읽기」가 겹친다"); + Assert.Less(video.xMax, replay.xMin, "「▶ 영상」은 「다시 읽기」 왼쪽"); + Assert.AreEqual(replay.y, video.y, 0.01f, "두 버튼이 같은 줄"); + } + + [TestCase(644f, 92f)] + [TestCase(684f, 104f)] + [TestCase(480f, 92f)] + public void RowText_StopsBeforeTheLeftmostButton(float width, float height) + { + var row = new Rect(20f, 0f, width, height); + float textX = row.x + StoryJournalRowLayout.TextLeft; + + float withVideo = StoryJournalRowLayout.TextWidth(row, true); + Assert.LessOrEqual(textX + withVideo, StoryJournalRowLayout.VideoButton(row).xMin, "글자가 「▶ 영상」 밑으로 들어간다"); + + float withoutVideo = StoryJournalRowLayout.TextWidth(row, false); + Assert.LessOrEqual(textX + withoutVideo, StoryJournalRowLayout.ReplayButton(row).xMin, "글자가 「다시 읽기」 밑으로 들어간다"); + Assert.Greater(withoutVideo, withVideo, "영상이 없는 행은 글자 폭이 더 넓다"); + } + + [Test] + public void ReplayButton_StaysInTheSameColumn_WithOrWithoutVideo() + { + // 「다시 읽기」 자리는 영상 유무를 묻지 않는다 — 행마다 버튼 열이 흔들리지 않는다. + var a = new Rect(0f, 0f, 644f, 92f); + var b = new Rect(0f, 100f, 644f, 92f); + Assert.AreEqual(StoryJournalRowLayout.ReplayButton(a).x, StoryJournalRowLayout.ReplayButton(b).x, 0.01f); + Assert.AreEqual(StoryJournalRowLayout.ReplayButton(a).width, StoryJournalRowLayout.ReplayWidth, 0.01f); + } + + // ── 다시 볼 영상 ── + + private static bool Known(string id) => id == "vid_a" || id == "vid_b"; + + [Test] + public void Videos_IntroThenOutro_WhenTheBeatHasBoth() + { + var beat = new StoryBeat { beatId = "x", introVideoId = "vid_a", videoId = "vid_b" }; + StoryJournalVideo.Pair p = StoryJournalVideo.For(beat, Known); + Assert.IsTrue(p.HasAny); + Assert.AreEqual("vid_a", p.First, "대사 앞 영상이 먼저 — 이야기 순서"); + Assert.AreEqual("vid_b", p.Then); + } + + [Test] + public void Videos_OneVideo_PlaysAlone() + { + StoryJournalVideo.Pair intro = StoryJournalVideo.For(new StoryBeat { beatId = "i", introVideoId = "vid_a" }, Known); + Assert.AreEqual("vid_a", intro.First); + Assert.IsNull(intro.Then); + + StoryJournalVideo.Pair outro = StoryJournalVideo.For(new StoryBeat { beatId = "o", videoId = "vid_b" }, Known); + Assert.AreEqual("vid_b", outro.First); + Assert.IsNull(outro.Then); + + StoryJournalVideo.Pair same = StoryJournalVideo.For(new StoryBeat { beatId = "s", introVideoId = "vid_a", videoId = "vid_a" }, Known); + Assert.AreEqual("vid_a", same.First); + Assert.IsNull(same.Then, "같은 영상을 두 번 틀지 않는다"); + } + + [Test] + public void Videos_NoneOrUnknown_MeansNoButton() + { + Assert.IsFalse(StoryJournalVideo.For(null, Known).HasAny); + Assert.IsFalse(StoryJournalVideo.For(new StoryBeat { beatId = "n" }, Known).HasAny); + Assert.IsFalse(StoryJournalVideo.For(new StoryBeat { beatId = "w", videoId = " " }, Known).HasAny); + Assert.IsFalse(StoryJournalVideo.For(new StoryBeat { beatId = "u", videoId = "vid_typo" }, Known).HasAny, + "라이브러리가 모르는 ID에는 눌러도 안 나오는 버튼을 세우지 않는다"); + + // 모르는 앞 영상은 빼고 아는 뒤 영상만. + StoryJournalVideo.Pair p = StoryJournalVideo.For(new StoryBeat { beatId = "m", introVideoId = "vid_typo", videoId = "vid_b" }, Known); + Assert.AreEqual("vid_b", p.First); + Assert.IsNull(p.Then); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/StoryJournalVideoTests.cs.meta b/Assets/Tests/EditMode/StoryJournalVideoTests.cs.meta new file mode 100644 index 0000000..da654ea --- /dev/null +++ b/Assets/Tests/EditMode/StoryJournalVideoTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d1404647b31ca1b499cb022ef8c51a0f \ No newline at end of file diff --git a/Assets/Tests/EditMode/StoryObjectiveResolverTests.cs b/Assets/Tests/EditMode/StoryObjectiveResolverTests.cs index 46b283c..e50de8a 100644 --- a/Assets/Tests/EditMode/StoryObjectiveResolverTests.cs +++ b/Assets/Tests/EditMode/StoryObjectiveResolverTests.cs @@ -343,6 +343,52 @@ public void CompareBeatPriority_MainStoryBeatsSideChapters() StoryObjectiveResolver.CompareBeatPriority(Beat("a", "ch12", 0), Beat("b", "side", 0), spine), 0); } + [Test] + public void CompareObjectivePriority_FinaleLeafBeatsSideSpine_ButChapterLeafDoesNot() + { + var spine = new HashSet { "town_meet" }; + StoryBeat epilogue = Beat("fin_epilogue", "fin", 53); + StoryBeat townMeet = Beat("town_meet", "town", 1); + // 에필로그는 leaf인데도 마을 주민 만남(스파인)보다 앞이다 — 엔딩이 곁이야기에 가려지면 안 된다. + Assert.Less(StoryObjectiveResolver.CompareObjectivePriority(epilogue, townMeet, spine), 0); + // 본편 leaf 전체를 앞세우지는 않는다 — 놓친 1장 동굴이 곁이야기 사슬을 전부 가로챈다. + Assert.Greater(StoryObjectiveResolver.CompareObjectivePriority(Beat("ch1_cave", "ch1", 1), townMeet, spine), 0); + } + + [Test] + public void CompareBeatPriority_FinaleLeafIsNotPromoted_ChiefTauntBeforeReconciliation() + { + // 발화 순서엔 종장 우대가 없다 — 있으면 엔딩 뒤 하월에게 말을 걸 때 화해(ch12_clash)가 + // 도발(talk_chief)보다 먼저 떠 서사가 거꾸로 흐른다. 둘 다 leaf라 챕터 순(12장 → 종장)이다. + var spine = new HashSet(); + StoryBeat taunt = Beat("talk_chief", "ch12", 82, param: "ledger_chief"); + StoryBeat clash = Beat("ch12_clash", "fin", 54, param: "ledger_chief"); + Assert.Less(StoryObjectiveResolver.CompareBeatPriority(taunt, clash, spine), 0); + } + + [Test] + public void SelectObjectiveBeat_RealStoryData_AfterSeal_PointsToEpilogueNotTownTales() + { + // 본편·종장 사슬을 fin_seal까지 다 봤고 곁이야기(마을 주민·도감·꽃밭)는 하나도 안 본 세이브. + var beats = new List(StoryService.AllBeats()); + Assume.That(beats.Count, Is.GreaterThan(0), "Story.json 로드 실패"); + var finaleLeaves = new HashSet { "fin_epilogue", "ch12_clash", "post_rival_rematch" }; + var seen = new HashSet(); + foreach (StoryBeat b in beats) + if (StoryObjectiveResolver.ChapterRank(b.chapterId) <= StoryObjectiveResolver.ChapterRank("fin") + && !finaleLeaves.Contains(b.beatId)) + seen.Add(b.beatId); + Assume.That(seen.Contains("fin_seal"), "종장 봉인 비트가 없다 — 저작이 바뀌었는가?"); + + StoryBeat chosen = StoryObjectiveResolver.SelectObjectiveBeat(beats, seen.Contains, + StoryObjectiveResolver.CollectSpineBeatIds(beats), null, + StoryObjectiveResolver.CollectChoiceTargetIds(beats)); + + Assert.IsNotNull(chosen); + Assert.AreEqual("fin_epilogue", chosen.beatId, + $"봉인 뒤 HUD가 에필로그가 아니라 {chosen.beatId}({chosen.chapterId})를 가리킨다"); + } + [Test] public void CompareBeatPriority_SameRank_FallsBackToBeatId_SoOrderIsDeterministic() { diff --git a/Assets/Tests/EditMode/StoryObjectiveWhyTests.cs b/Assets/Tests/EditMode/StoryObjectiveWhyTests.cs new file mode 100644 index 0000000..36fc3cf --- /dev/null +++ b/Assets/Tests/EditMode/StoryObjectiveWhyTests.cs @@ -0,0 +1,139 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.Story; +using NUnit.Framework; + +namespace InsectGame.Tests +{ + /// + /// HUD 목표 행 두 번째 줄의 이유(). 트래커(StoryObjectiveTracker.Why)는 + /// MonoBehaviour라 고르는 부분만 순수 함수()로 떼어 여기서 고정한다. + /// 실제 데이터 검사는 story_lint 검사 33과 같은 기준이다 — 대상 집합은 C#의 목표 우선순위가 정한다. + /// + [TestFixture] + public class StoryObjectiveWhyTests + { + // story_lint 검사 33과 같은 값. 목표 행 둘째 줄(22pt, 한 줄)에 줄이지 않고 드는 길이에 여유를 둔 값이다. + private const int MaxWhyChars = 20; + + private static StoryBeat Beat(string id, string chapter, string why = null, + string prereq = null, string triggerType = "NpcTalk") + { + return new StoryBeat + { + beatId = id, + chapterId = chapter, + prerequisiteBeatId = prereq, + why = why, + trigger = new StoryTrigger { type = triggerType, param = "npc" }, + }; + } + + // ── WhyFor ── + + [Test] + public void WhyFor_BeatWithWhy_ReturnsIt() + { + Assert.AreEqual("상자에 갇힌 곤충이 있대", + StoryObjectiveResolver.WhyFor(Beat("ch8_arrive", "ch8", "상자에 갇힌 곤충이 있대"), false)); + } + + [Test] + public void WhyFor_RedirectedToGatekeeper_ReturnsEmpty() + { + // 잠긴 리전이라 "모래언덕으로 가려면 들판 수문장 격파"로 바꿔 쳤다 — 이유는 모래언덕 비트의 것이라 붙이면 엉뚱하다. + Assert.AreEqual(string.Empty, + StoryObjectiveResolver.WhyFor(Beat("ch8_arrive", "ch8", "상자에 갇힌 곤충이 있대"), true)); + } + + [Test] + public void WhyFor_NullBeatOrMissingWhy_ReturnsEmptyNotNull() + { + Assert.AreEqual(string.Empty, StoryObjectiveResolver.WhyFor(null, false)); + Assert.AreEqual(string.Empty, StoryObjectiveResolver.WhyFor(Beat("a", "ch1"), false)); + Assert.AreEqual(string.Empty, StoryObjectiveResolver.WhyFor(Beat("a", "ch1", string.Empty), false)); + } + + [Test] + public void WhyFor_WhitespaceOrPaddedWhy_IsTrimmed() + { + Assert.AreEqual(string.Empty, StoryObjectiveResolver.WhyFor(Beat("a", "ch1", " "), false)); + Assert.AreEqual("숲이 조용해졌대", StoryObjectiveResolver.WhyFor(Beat("a", "ch1", " 숲이 조용해졌대 "), false)); + } + + // ── 이유가 꼭 있어야 하는 급 — 목표 우선순위의 0급 ── + + [Test] + public void IsObjectiveThread_SpineOrFinale_IsTrueAndLeafIsFalse() + { + var spine = new HashSet { "ch3_spine" }; + + Assert.IsTrue(StoryObjectiveResolver.IsObjectiveThread(Beat("ch3_spine", "ch3"), spine)); + Assert.IsTrue(StoryObjectiveResolver.IsObjectiveThread(Beat("fin_epilogue", StoryObjectiveResolver.FinaleChapterId), spine), + "종장 leaf는 목표에서 스파인과 같은 급이다(CompareObjectivePriority)"); + Assert.IsFalse(StoryObjectiveResolver.IsObjectiveThread(Beat("gd_forest", "ch3"), spine)); + Assert.IsFalse(StoryObjectiveResolver.IsObjectiveThread(null, spine)); + } + + [Test] + public void IsObjectiveThread_AgreesWithObjectivePriority() + { + // 0급 판정이 우선순위와 갈리면 lint·테스트가 엉뚱한 비트에 이유를 요구한다. + var spine = new HashSet { "a" }; + StoryBeat thread = Beat("a", "ch5"); + StoryBeat leaf = Beat("b", "ch1"); // 앞 장이어도 leaf는 0급 뒤다 + + Assert.IsTrue(StoryObjectiveResolver.IsObjectiveThread(thread, spine)); + Assert.IsFalse(StoryObjectiveResolver.IsObjectiveThread(leaf, spine)); + Assert.Less(StoryObjectiveResolver.CompareObjectivePriority(thread, leaf, spine), 0); + } + + // ── 실제 Story.json ── + + [Test] + public void RealStory_EveryMainObjectiveThreadBeat_HasShortWhy() + { + var beats = new List(StoryService.AllBeats()); + Assume.That(beats.Count, Is.GreaterThan(0), "Story.json 로드 실패"); + HashSet spine = StoryObjectiveResolver.CollectSpineBeatIds(beats); + HashSet choiceTargets = StoryObjectiveResolver.CollectChoiceTargetIds(beats); + + var missing = new List(); + int required = 0; + foreach (StoryBeat b in beats) + { + if (b == null || !IsMainChapter(b.chapterId)) continue; + if (!StoryObjectiveResolver.IsObjectiveThread(b, spine)) continue; + if (choiceTargets.Contains(b.beatId)) continue; // 고르는 순간 뜬다 — 목표가 아니다 + if (b.trigger != null && b.trigger.type == StoryDirector.TriggerImmediate) continue; // 목표 행에 머무르지 않는다 + required++; + if (string.IsNullOrWhiteSpace(b.why)) missing.Add(b.beatId); + } + + Assert.Greater(required, 0, "이유가 필요한 비트를 하나도 못 찾았다 — 기준이 무너졌다"); + Assert.IsEmpty(missing, $"HUD 목표로 먼저 뽑히는 본편 비트 {missing.Count}/{required}개에 이유(why)가 없다: " + + string.Join(", ", missing)); + } + + [Test] + public void RealStory_EveryWrittenWhy_FitsOneHudLine() + { + var tooLong = new List(); + foreach (StoryBeat b in StoryService.AllBeats()) + { + if (b == null || string.IsNullOrEmpty(b.why)) continue; + if (b.why.Length > MaxWhyChars) tooLong.Add($"{b.beatId}={b.why.Length}자"); + } + Assert.IsEmpty(tooLong, $"이유는 {MaxWhyChars}자 이하(HUD 한 줄): " + string.Join(", ", tooLong)); + } + + // 본편 — ch1..ch12와 종장. ChapterRank가 본편에만 1~12·1000을 준다. + private static bool IsMainChapter(string chapterId) + { + if (chapterId == StoryObjectiveResolver.FinaleChapterId) return true; + return !string.IsNullOrEmpty(chapterId) && chapterId.StartsWith("ch") + && int.TryParse(chapterId.Substring(2), out _); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/StoryObjectiveWhyTests.cs.meta b/Assets/Tests/EditMode/StoryObjectiveWhyTests.cs.meta new file mode 100644 index 0000000..cd3184e --- /dev/null +++ b/Assets/Tests/EditMode/StoryObjectiveWhyTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 563263bb9cfdf7144b98b3242a1491f0 \ No newline at end of file diff --git a/Assets/Tests/EditMode/StoryPreludeTests.cs b/Assets/Tests/EditMode/StoryPreludeTests.cs new file mode 100644 index 0000000..fa4c83e --- /dev/null +++ b/Assets/Tests/EditMode/StoryPreludeTests.cs @@ -0,0 +1,557 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using InsectGame.Story; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 대사 앞 영상(StoryBeat.introVideoId)과 연출 고리(), 저널 다시보기(PlayReplay). + /// + /// 급소 하나: 대사 앞 연출이 콜백을 못 부르면 그 비트가 pendingBeatId에 갇혀 캠페인이 멈추고, 두 번 부르면 대사가 + /// 두 번 열린다. 그래서 종료 경로마다 "정확히 한 번"을 센다 — 끝·건너뛰기·ESC·디코더 오류·준비 시간 초과·재생 초과· + /// 비활성·재진입, 그리고 시작도 못 한 경우(파일 없음·모르는 ID·꺼짐)는 "한 번도 안 부르고 false"(호출부가 대사를 띄운다). + /// + /// 영상 지휘자는 디코더를 열지 않는다(DecoderEnabled = false — 배치·PlayMode 러너엔 디코더가 없다, rules/testing.md). + /// 재생 상태(모달·콜백)만 세우고, 디코더 이벤트(끝·오류)와 시간(준비·초과)은 private 진입점을 직접 두드린다. + /// LogAssert는 이 어셈블리에서 참조가 안 된다(asmdef 없음) — 일부러 던지는 테스트는 로거를 잠깐 끈다. + /// + [TestFixture] + public class StoryPreludeTests + { + private readonly List spawned = new List(); + + [TearDown] + public void TearDown() + { + foreach (GameObject go in spawned) + if (go != null) Object.DestroyImmediate(go); + spawned.Clear(); + } + + // ── 영상 지휘자: 대사 앞 영상의 종료 경로 ───────────────────────────── + + [Test] + public void Prelude_SkipButton_CallsDoneExactlyOnce() + { + StoryVideoDirector video = NewVideo(); + int done = 0; + Assert.IsTrue(video.TryPlayPrelude(IntroBeat(StoryVideoLibrary.Ch6Wall), () => done++), "영상이 있으면 게이트를 건다"); + Assert.IsTrue(video.IsPlayingPrelude); + Assert.AreEqual(0, done, "영상이 끝나기 전에는 대사를 열지 않는다"); + + video.Stop(); // 「건너뛰기」 버튼이 부르는 그것 + Assert.AreEqual(1, done); + video.Stop(); + video.CloseModal(); + Assert.AreEqual(1, done, "두 번 닫아도 대사는 한 번만 열린다"); + Assert.IsFalse(video.IsPlaying); + } + + [Test] + public void Prelude_Escape_ClosesVideoAndOpensDialogueOnce() + { + Assume.That(!ModalUIRegistry.IsAnyOpen(), "다른 테스트가 모달을 남겼다"); + StoryVideoDirector video = NewVideo(); + int done = 0; + Assert.IsTrue(video.TryPlayPrelude(IntroBeat(StoryVideoLibrary.Ch7Fence), () => done++)); + Assert.AreSame(video, ModalUIRegistry.TopModal, "재생 중엔 영상이 모달 맨 위다 — ESC·뒤로가기가 영상을 닫는다"); + + Assert.IsTrue(ModalUIRegistry.HandleEscape()); + Assert.AreEqual(1, done); + Assert.IsFalse(ModalUIRegistry.IsAnyOpen(), "영상이 모달을 남기지 않는다"); + } + + [Test] + public void Prelude_NormalEnd_DecoderError_PrepareTimeout_Overrun_EachCallDoneOnce() + { + StoryVideoDirector video = NewVideo(); + + int ended = 0; + Assert.IsTrue(video.PlayIntro(Def("qa_end.mp4"), () => ended++)); + Invoke(video, "OnReachedEnd", new object[] { null }); // loopPointReached + Assert.AreEqual(1, ended, "끝까지 보면 대사가 열린다"); + + int errored = 0; + Assert.IsTrue(video.PlayIntro(Def("qa_error.mp4"), () => errored++)); + Invoke(video, "OnVideoError", null, "qa decoder"); // errorReceived — Android의 파일 없음도 이 길로 온다 + Assert.AreEqual(1, errored, "디코더가 실패해도 대사는 연다"); + + int prepare = 0; + Assert.IsTrue(video.PlayIntro(Def("qa_prepare.mp4"), () => prepare++)); + Invoke(video, "TickPlayback", 4.9f); + Assert.AreEqual(0, prepare, "준비 시간 안에는 기다린다"); + Invoke(video, "TickPlayback", 0.2f); + Assert.AreEqual(1, prepare, "디코더가 끝내 준비되지 않으면 시간 초과로 대사를 연다"); + + int overrun = 0; + StoryVideoDefinition longDef = Def("qa_overrun.mp4"); + Assert.IsTrue(video.PlayIntro(longDef, () => overrun++)); + SetField(video, "prepared", true); // 첫 프레임이 떴다고 친다 + Invoke(video, "TickPlayback", longDef.expectedDuration + 2.9f); + Assert.AreEqual(0, overrun); + Invoke(video, "TickPlayback", 0.2f); + Assert.AreEqual(1, overrun, "종료 이벤트가 안 오는 기기에서도 길이 + 여유 뒤에 대사를 연다"); + + Assert.IsFalse(video.IsPlaying); + // 앞 경로의 콜백이 뒤 경로(같은 지휘자의 다음 영상)에서 다시 불리지 않는다. + Assert.AreEqual(1, ended); + Assert.AreEqual(1, errored); + Assert.AreEqual(1, prepare); + } + + [Test] + public void Prelude_DirectorDisabled_OpensDialogueOnce() + { + StoryVideoDirector video = NewVideo(); + int done = 0; + Assert.IsTrue(video.TryPlayPrelude(IntroBeat(StoryVideoLibrary.FinShadow), () => done++)); + + video.gameObject.SetActive(false); // OnDisable — 꺼진 채 기다리면 시간 초과가 영영 안 온다 + Assert.AreEqual(1, done); + video.gameObject.SetActive(true); + Assert.AreEqual(1, done, "다시 켜도 또 부르지 않는다"); + Assert.IsFalse(video.IsPlaying); + } + + [Test] + public void Prelude_ReentrantPlay_FinishesTheFirstOnceThenTheSecondOnce() + { + StoryVideoDirector video = NewVideo(); + int first = 0, second = 0; + Assert.IsTrue(video.PlayIntro(Def("qa_first.mp4"), () => first++)); + Assert.IsTrue(video.PlayIntro(Def("qa_second.mp4"), () => second++)); + Assert.AreEqual(1, first, "재진입은 앞 영상의 종료 경로다 — 앞 비트의 대사를 연다"); + Assert.AreEqual(0, second, "새 콜백을 재진입 Stop이 불러 버리면 안 된다(재생이 시작된 뒤에 건다)"); + + video.Stop(); + Assert.AreEqual(1, first); + Assert.AreEqual(1, second); + } + + [Test] + public void Prelude_PlainPlayOverAPrelude_StillOpensItsDialogue() + { + // 대사 뒤 영상(Play)이 어떤 이유로든 대사 앞 영상을 끊어도 그 비트의 대사는 열린다. + StoryVideoDirector video = NewVideo(); + int done = 0; + Assert.IsTrue(video.PlayIntro(Def("qa_intro.mp4"), () => done++)); + video.Play(Def("qa_outro.mp4")); + Assert.AreEqual(1, done); + Assert.IsTrue(video.IsPlaying); + Assert.IsFalse(video.IsPlayingPrelude, "새 영상에는 콜백이 없다"); + video.Stop(); + Assert.AreEqual(1, done); + } + + [Test] + public void Prelude_MissingFile_ReturnsFalseWithoutCallback() + { + Assume.That(!ModalUIRegistry.IsAnyOpen(), "다른 테스트가 모달을 남겼다"); + StoryVideoDirector video = NewVideo(); + video.DecoderEnabled = true; // 파일 확인까지는 진짜로 간다 — 파일이 없어 디코더는 열리지 않는다 + int done = 0; + int warned = CountWarnings("[StoryVideo]", () => + Assert.IsFalse(video.PlayIntro(Def("qa_missing_intro_video.mp4"), () => done++), + "시작도 못 했다 — false면 호출부가 곧바로 대사를 띄운다")); + Assert.AreEqual(0, done, "false를 돌려주며 콜백까지 부르면 대사가 두 번 열린다"); + Assert.AreEqual(1, warned, "파일이 없다고 한 번 말한다"); + Assert.IsFalse(video.IsPlaying); + Assert.IsFalse(ModalUIRegistry.IsAnyOpen(), "조작·모달을 남기지 않는다"); + } + + [Test] + public void Prelude_NothingToPlay_ReturnsFalse() + { + StoryVideoDirector video = NewVideo(); + int done = 0; + Assert.IsFalse(video.TryPlayPrelude(new StoryBeat { beatId = "qa_plain", lines = Lines(3) }, () => done++), "영상 없는 비트"); + Assert.IsFalse(video.TryPlayPrelude(null, () => done++)); + Assert.IsFalse(video.TryPlayPrelude(IntroBeat(StoryVideoLibrary.Ch6Wall), null)); + int warned = CountWarnings("[StoryVideo]", () => + Assert.IsFalse(video.TryPlayPrelude(IntroBeat("vid_qa_typo"), () => done++), "모르는 ID")); + Assert.AreEqual(1, warned, "오타는 조용히 넘기지 않는다"); + Assert.AreEqual(0, done); + Assert.IsFalse(video.IsPlaying); + } + + [Test] + public void Prelude_InactiveDirector_DoesNotGate() + { + // 꺼진 채 true를 돌려주면 Update가 안 돌아 시간 초과가 영영 안 온다 → 그 비트가 갇힌다. + GameObject go = new GameObject("StoryPreludeTestsInactiveVideo"); + go.SetActive(false); + spawned.Add(go); + StoryVideoDirector video = go.AddComponent(); + int done = 0; + Assert.IsFalse(video.TryPlayPrelude(IntroBeat(StoryVideoLibrary.Ch6Wall), () => done++)); + Assert.AreEqual(0, done); + } + + [Test] + public void Prelude_CallbackRunsAfterTheDirectorIsClean() + { + // 콜백(대사 열기)이 다시 조작을 잠그고 모달을 건다 — 그때 영상은 이미 내려가 있어야 한다. + Assume.That(!ModalUIRegistry.IsAnyOpen(), "다른 테스트가 모달을 남겼다"); + StoryVideoDirector video = NewVideo(); + bool sawPlaying = true, sawModal = true; + Assert.IsTrue(video.PlayIntro(Def("qa_clean.mp4"), () => + { + sawPlaying = video.IsPlaying; + sawModal = ModalUIRegistry.IsAnyOpen(); + })); + video.Stop(); + Assert.IsFalse(sawPlaying, "콜백이 불릴 때 영상은 이미 멈춰 있다"); + Assert.IsFalse(sawModal, "콜백이 불릴 때 영상은 모달에서 이미 빠져 있다"); + } + + [Test] + public void Prelude_CallbackThrows_DirectorStaysUsable() + { + Assume.That(!ModalUIRegistry.IsAnyOpen(), "다른 테스트가 모달을 남겼다"); + StoryVideoDirector video = NewVideo(); + Assert.IsTrue(video.PlayIntro(Def("qa_throw.mp4"), () => throw new System.InvalidOperationException("qa"))); + Quietly(() => Assert.DoesNotThrow(video.Stop, "콜백의 예외가 건너뛰기(OnGUI)·재진입 Play를 끊지 않는다")); + Assert.IsFalse(video.IsPlaying); + Assert.IsFalse(ModalUIRegistry.IsAnyOpen()); + + int next = 0; + Assert.IsTrue(video.PlayIntro(Def("qa_after.mp4"), () => next++), "다음 영상은 평소대로 돈다"); + video.Stop(); + Assert.AreEqual(1, next); + } + + // ── 다시보기 ───────────────────────────────────────────── + + [Test] + public void PlayReplay_UnknownOrEmptyId_IsFalse() + { + StoryVideoDirector video = NewVideo(); + Assert.IsFalse(video.PlayReplay("vid_nope")); + Assert.IsFalse(video.PlayReplay(null)); + Assert.IsFalse(video.PlayReplay("")); + Assert.IsFalse(video.IsPlaying); + } + + [Test] + public void PlayReplay_KnownId_PlaysWithoutStorySideEffects() + { + StoryVideoDirector video = NewVideo(); + Assert.IsTrue(video.PlayReplay(StoryVideoLibrary.Ch12Ledger)); + Assert.IsTrue(video.IsPlaying); + Assert.AreEqual(StoryVideoLibrary.Ch12Ledger, video.CurrentVideoId); + Assert.IsFalse(video.IsPlayingPrelude, "다시보기는 대사를 열지 않는다"); + Assert.IsFalse(video.PlayReplay(StoryVideoLibrary.Ch1Prologue), "재생 중이면 끊지 않는다"); + Assert.AreEqual(StoryVideoLibrary.Ch12Ledger, video.CurrentVideoId); + video.Stop(); + Assert.IsFalse(video.IsPlaying); + } + + [Test] + public void PlayReplay_DuringPrelude_DoesNotStealTheBeat() + { + StoryVideoDirector video = NewVideo(); + int done = 0; + Assert.IsTrue(video.TryPlayPrelude(IntroBeat(StoryVideoLibrary.FinReturn), () => done++)); + Assert.IsFalse(video.PlayReplay(StoryVideoLibrary.Ch1Prologue), "대사 앞 영상을 끊으면 그 대사가 다시보기 밑에서 열린다"); + Assert.AreEqual(0, done); + Assert.IsTrue(video.IsPlayingPrelude); + video.Stop(); + Assert.AreEqual(1, done); + } + + [Test] + public void PlayReplay_InactiveDirector_IsFalse() + { + GameObject go = new GameObject("StoryPreludeTestsInactiveReplay"); + go.SetActive(false); + spawned.Add(go); + StoryVideoDirector video = go.AddComponent(); + Assert.IsFalse(video.PlayReplay(StoryVideoLibrary.Ch1Prologue)); + } + + // ── 고리: 영상 → NPC 등장 연출 ───────────────────────────────── + + private sealed class FakePrelude : IStoryStagePrelude + { + public readonly string name; + public readonly List log; + public bool hasContent = true; + public bool completeSynchronously; + public bool throwBeforeStart; + public bool fireTwice; + public bool lieAndFireAfterFalse; + public System.Action pending; + public int calls; + + public FakePrelude(string name, List log) + { + this.name = name; + this.log = log; + } + + public bool TryPlayPrelude(StoryBeat beat, System.Action onDone) + { + calls++; + if (throwBeforeStart) throw new System.InvalidOperationException("qa " + name); + if (lieAndFireAfterFalse) { pending = onDone; return false; } + if (!hasContent) return false; + log.Add(name + ":start"); + if (completeSynchronously) + { + log.Add(name + ":end"); + onDone(); + if (fireTwice) onDone(); + return true; + } + pending = onDone; + return true; + } + + public void Finish() + { + log.Add(name + ":end"); + System.Action done = pending; + pending = null; + done?.Invoke(); + if (fireTwice) done?.Invoke(); + } + } + + [Test] + public void Chain_RunsVideoThenStageThenDialogue() + { + var log = new List(); + var video = new FakePrelude("video", log); + var stage = new FakePrelude("stage", log); + var chain = new StoryPreludeChain(video, stage); + int done = 0; + + Assert.IsTrue(chain.TryPlayPrelude(new StoryBeat { beatId = "qa" }, () => { done++; log.Add("dialogue"); })); + CollectionAssert.AreEqual(new[] { "video:start" }, log, "영상이 먼저 — 그동안 인물은 들어오지 않는다"); + + video.Finish(); + CollectionAssert.AreEqual(new[] { "video:start", "video:end", "stage:start" }, log); + Assert.AreEqual(0, done); + + stage.Finish(); + CollectionAssert.AreEqual(new[] { "video:start", "video:end", "stage:start", "stage:end", "dialogue" }, log); + Assert.AreEqual(1, done); + } + + [Test] + public void Chain_NoLinkHasContent_ReturnsFalseAndNeverCalls() + { + var log = new List(); + var video = new FakePrelude("video", log) { hasContent = false }; + var stage = new FakePrelude("stage", log) { hasContent = false }; + int done = 0; + Assert.IsFalse(new StoryPreludeChain(video, stage).TryPlayPrelude(new StoryBeat { beatId = "qa" }, () => done++), + "아무 연출도 없으면 false — 대화창이 곧바로 대사를 띄운다"); + Assert.AreEqual(0, done, "false인데 부르면 대사가 두 번 열린다"); + Assert.AreEqual(1, video.calls); + Assert.AreEqual(1, stage.calls); + } + + [Test] + public void Chain_OnlyOneLinkHasContent_RunsJustThatOne() + { + var log = new List(); + var video = new FakePrelude("video", log) { hasContent = false }; + var stage = new FakePrelude("stage", log); + int done = 0; + Assert.IsTrue(new StoryPreludeChain(video, stage).TryPlayPrelude(new StoryBeat { beatId = "qa" }, () => done++)); + stage.Finish(); + Assert.AreEqual(1, done, "영상 없는 비트는 예전처럼 등장 연출 → 대사"); + + log.Clear(); + var video2 = new FakePrelude("video", log); + var stage2 = new FakePrelude("stage", log) { hasContent = false }; + int done2 = 0; + Assert.IsTrue(new StoryPreludeChain(video2, stage2).TryPlayPrelude(new StoryBeat { beatId = "qa" }, () => done2++)); + video2.Finish(); + Assert.AreEqual(1, done2, "등장 연출이 없으면 영상 → 대사"); + Assert.AreEqual(1, stage2.calls, "영상이 끝난 뒤에야 등장 연출에 묻는다"); + } + + [Test] + public void Chain_SynchronousAndDoubleCallbacks_OpenDialogueOnce() + { + var log = new List(); + var video = new FakePrelude("video", log) { completeSynchronously = true, fireTwice = true }; + var stage = new FakePrelude("stage", log) { hasContent = false }; + int done = 0; + Assert.IsTrue(new StoryPreludeChain(video, stage).TryPlayPrelude(new StoryBeat { beatId = "qa" }, () => done++), + "이미 끝났어도 true — 대사는 콜백이 열었다"); + Assert.AreEqual(1, done, "같은 콜백이 두 번 와도 대사는 한 번"); + } + + [Test] + public void Chain_LinkThatLiesThenFiresLate_IsIgnored() + { + var log = new List(); + var liar = new FakePrelude("video", log) { lieAndFireAfterFalse = true }; + var stage = new FakePrelude("stage", log) { hasContent = false }; + int done = 0; + Assert.IsFalse(new StoryPreludeChain(liar, stage).TryPlayPrelude(new StoryBeat { beatId = "qa" }, () => done++)); + liar.pending?.Invoke(); // false를 돌려 놓고 나중에 부른다 — 이미 호출부가 대사를 띄웠다 + Assert.AreEqual(0, done, "버린 고리의 늦은 콜백은 대사를 다시 열지 않는다"); + } + + [Test] + public void Chain_FirstLinkThrows_SkipsToTheNext() + { + var log = new List(); + var video = new FakePrelude("video", log) { throwBeforeStart = true }; + var stage = new FakePrelude("stage", log); + int done = 0; + bool started = false; + Quietly(() => started = new StoryPreludeChain(video, stage).TryPlayPrelude(new StoryBeat { beatId = "qa" }, () => done++)); + Assert.IsTrue(started, "영상이 던져도 등장 연출은 돈다"); + stage.Finish(); + Assert.AreEqual(1, done); + } + + [Test] + public void Chain_StageThrowsAfterVideo_StillOpensDialogueOnce() + { + // 영상이 끝난 뒤 콜백 안에서 등장 연출이 던진다 — 대화창의 try/catch는 첫 호출만 감싸서 이건 고리만 받을 수 있다. + var log = new List(); + var video = new FakePrelude("video", log); + var stage = new FakePrelude("stage", log) { throwBeforeStart = true }; + int done = 0; + Assert.IsTrue(new StoryPreludeChain(video, stage).TryPlayPrelude(new StoryBeat { beatId = "qa" }, () => done++)); + Quietly(video.Finish); + Assert.AreEqual(1, done, "연출을 잃어도 진행(대사)은 잃지 않는다"); + } + + [Test] + public void Chain_DestroyedUnityLink_IsSkipped() + { + GameObject go = new GameObject("StoryPreludeTestsDestroyedVideo"); + StoryVideoDirector destroyed = go.AddComponent(); + Object.DestroyImmediate(go); + + var log = new List(); + var stage = new FakePrelude("stage", log); + int done = 0; + Assert.IsTrue(new StoryPreludeChain(destroyed, stage).TryPlayPrelude(IntroBeat(StoryVideoLibrary.Ch6Wall), () => done++), + "인터페이스 참조의 null 비교는 파괴를 못 본다 — 고리가 Unity의 ==로 다시 본다"); + stage.Finish(); + Assert.AreEqual(1, done); + } + + [Test] + public void Chain_WithRealVideoDirector_VideoThenStageThenDialogue() + { + StoryVideoDirector video = NewVideo(); + var log = new List(); + var stage = new FakePrelude("stage", log); + int done = 0; + var chain = new StoryPreludeChain(video, stage); + + Assert.IsTrue(chain.TryPlayPrelude(IntroBeat(StoryVideoLibrary.Ch6Wall), () => done++)); + Assert.IsTrue(video.IsPlayingPrelude); + Assert.AreEqual(0, stage.calls, "영상이 도는 동안 인물은 들어오지 않는다"); + + video.CloseModal(); // 아이가 「건너뛰기」를 눌렀다 + Assert.IsFalse(video.IsPlaying); + Assert.AreEqual(1, stage.calls); + Assert.AreEqual(0, done); + stage.Finish(); + Assert.AreEqual(1, done); + + // 영상이 없는 비트는 영상 지휘자를 건너 등장 연출로 곧장 간다. + int plain = 0; + Assert.IsTrue(chain.TryPlayPrelude(new StoryBeat { beatId = "qa_plain", lines = Lines(2) }, () => plain++)); + Assert.IsFalse(video.IsPlaying); + stage.Finish(); + Assert.AreEqual(1, plain); + } + + // ── 배선 ───────────────────────────────────────────────── + + [Test] + public void Bootstrap_WiresTheChainAndTheJournalReplay() + { + // 빠지면 예외도 경고도 없이 대사 앞 영상이 안 나오거나(체인 대신 연출만 넘김) 저널 「▶ 영상」이 안 선다. + string source = File.ReadAllText(Path.Combine(Application.dataPath, "Scripts/Core/PlaySceneBootstrap.cs")); + StringAssert.Contains("npcDialogue.AutoWire(new InsectGame.Story.StoryPreludeChain(storyVideo, stageDirector))", source, + "순서는 영상 → 등장 연출이다"); + StringAssert.DoesNotContain("npcDialogue.AutoWire(stageDirector)", source, "연출만 넘기면 대사 앞 영상이 안 나온다"); + + int video = source.IndexOf("EnsureComponent", System.StringComparison.Ordinal); + int journal = source.IndexOf("storyJournal.AutoWire(storyVideo)", System.StringComparison.Ordinal); + Assert.Greater(video, 0); + Assert.Greater(journal, video, "저널 배선은 영상 지휘자를 만든 뒤에"); + } + + // ── 도움 ───────────────────────────────────────────────── + + private StoryVideoDirector NewVideo() + { + GameObject go = new GameObject("StoryPreludeTestsVideo"); + spawned.Add(go); + StoryVideoDirector video = go.AddComponent(); // 활성 — Awake가 VideoPlayer를 붙인다(열지는 않는다) + video.DecoderEnabled = false; + return video; + } + + private static StoryBeat IntroBeat(string introVideoId) + => new StoryBeat { beatId = "qa_intro", introVideoId = introVideoId, lines = Lines(5) }; + + private static List Lines(int count) + { + var lines = new List(); + for (int i = 0; i < count; i++) lines.Add(new StoryLine { speaker = "세라", text = "qa " + i }); + return lines; + } + + private static StoryVideoDefinition Def(string fileName) + => new StoryVideoDefinition("vid_qa", fileName, 12f, new[] { new StoryVideoCue(1f, 2f, "qa") }); + + private static object Invoke(object target, string method, params object[] args) + { + MethodInfo info = target.GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(info, $"{target.GetType().Name}.{method}가 사라졌다"); + return info.Invoke(target, args); + } + + private static void SetField(object target, string field, object value) + { + FieldInfo info = target.GetType().GetField(field, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(info, $"{target.GetType().Name}.{field}가 사라졌다"); + info.SetValue(target, value); + } + + private static int CountWarnings(string prefix, System.Action action) + { + int warned = 0; + Application.LogCallback count = (message, _, type) => + { + if (type == LogType.Warning && message.StartsWith(prefix)) warned++; + }; + Application.logMessageReceived += count; + try { action(); } + finally { Application.logMessageReceived -= count; } + return warned; + } + + /// + /// 일부러 던지는 테스트 — 지휘자·고리가 남기는 LogError가 러너의 "예상 못 한 오류 로그" 실패로 번지지 않게 + /// 로거를 잠깐 끈다(LogAssert는 asmdef가 없어 참조가 안 된다). + /// + private static void Quietly(System.Action action) + { + bool was = Debug.unityLogger.logEnabled; + Debug.unityLogger.logEnabled = false; + try { action(); } + finally { Debug.unityLogger.logEnabled = was; } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/StoryPreludeTests.cs.meta b/Assets/Tests/EditMode/StoryPreludeTests.cs.meta new file mode 100644 index 0000000..00dc943 --- /dev/null +++ b/Assets/Tests/EditMode/StoryPreludeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bd3737bcb1ac8c14797a4b10ec9c3dd0 \ No newline at end of file diff --git a/Assets/Tests/EditMode/StoryRecapLayoutTests.cs b/Assets/Tests/EditMode/StoryRecapLayoutTests.cs new file mode 100644 index 0000000..31c8789 --- /dev/null +++ b/Assets/Tests/EditMode/StoryRecapLayoutTests.cs @@ -0,0 +1,287 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Linq; +using InsectGame.Story; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 이야기를 따라가기 쉽게 하는 화면 장치 셋의 배치 — 「지난 이야기」 카드(), + /// 저널 장 머리(), HUD 목표 행의 「왜」 둘째 줄(). + /// 전부 순수 계산이라 화면을 띄우지 않고 가로·세로·배율별로 잰다. 그림은 QA 빌드 -battleScenario story의 recap-* 컷. + /// + [TestFixture] + public class StoryRecapLayoutTests + { + private static IEnumerable Screens() + { + yield return new TestCaseData("desktop 1280x720", 1280f, 720f, 0f, 0f, 0f, 0f, false); + yield return new TestCaseData("desktop 1920x1080", 1920f, 1080f, 0f, 0f, 0f, 0f, false); + yield return new TestCaseData("desktop 2560x1080", 2560f, 1080f, 0f, 0f, 0f, 0f, false); + foreach (float s in new[] { 2f / 3f, 1f, 4f / 3f }) + { + string k = s.ToString("0.###"); + yield return new TestCaseData($"portrait 720x1280 s{k}", 720f * s, 1280f * s, 0f, 0f, 0f, 0f, true); + yield return new TestCaseData($"portrait 20x9 notch s{k}", 1080f * s, 2400f * s, 0f, 0f, 100f * s, 40f * s, true); + yield return new TestCaseData($"landscape 20x9 notch s{k}", 2400f * s, 1080f * s, 100f * s, 100f * s, 0f, 30f * s, true); + } + } + + private static HudFrame Frame(float pw, float ph, float l, float r, float t, float b, bool mobile) + => HudFrame.ForScreen(pw, ph, l, r, t, b, mobile); + + // ── 카드를 띄우는가 ── + + [Test] + public void ShouldShow_OnlyForFirstShowing_WithARecapLine() + { + var ch = new StoryChapter { chapterId = "ch8", openingBeatId = "ch8_arrive", recap = new List { "지난 줄" }, goal = "목표" }; + Assert.IsTrue(StoryRecapLayout.ShouldShow(ch, false)); + Assert.IsFalse(StoryRecapLayout.ShouldShow(ch, true), "저널 다시보기에는 카드가 없다"); + Assert.IsFalse(StoryRecapLayout.ShouldShow(null, false)); + Assert.IsFalse(StoryRecapLayout.ShouldShow(new StoryChapter { recap = new List() }, false), "1장처럼 지난 이야기가 없으면 카드 없음"); + Assert.IsFalse(StoryRecapLayout.ShouldShow(new StoryChapter { recap = new List { "", " " } }, false), "빈 줄만이면 카드 없음"); + Assert.IsFalse(StoryRecapLayout.ShouldShow(new StoryChapter { recap = null }, false)); + } + + [Test] + public void BodyFont_IsAtLeastTheDialogueBody() + { + // 대사 본문은 가로 34·세로 36(NpcDialogueUI.DrawStoryStage) — 아이가 읽는 카드는 그보다 작지 않다. + Assert.GreaterOrEqual(StoryRecapLayout.BodyFont, 36); + Assert.Greater(StoryRecapLayout.TitleFont, StoryRecapLayout.BodyFont); + } + + // ── 대사 상자와 카드 판 ── + + [Test] + public void DialogueBox_KeepsTheStageSize() + { + // 무대(DrawStoryStage)가 쓰던 값 그대로 — 가로 min(1400, 안전 폭)×350, 세로 안전 폭×500, 안전 영역 바닥. + HudFrame land = Frame(1920f, 1080f, 0f, 0f, 0f, 0f, false); + Rect box = StoryRecapLayout.DialogueBox(land); + Assert.AreEqual(1400f, box.width, 0.01f); + Assert.AreEqual(350f, box.height, 0.01f); + Assert.AreEqual(land.ContentBottom, box.yMax, 0.01f); + Assert.AreEqual(land.Width * 0.5f, box.center.x, 0.01f); + + HudFrame port = Frame(1080f, 1920f, 0f, 0f, 0f, 0f, true); + Rect pbox = StoryRecapLayout.DialogueBox(port); + Assert.AreEqual(port.ContentWidth, pbox.width, 0.01f); + Assert.AreEqual(500f, pbox.height, 0.01f); + Assert.AreEqual(port.ContentBottom, pbox.yMax, 0.01f); + } + + [TestCaseSource(nameof(Screens))] + public void Area_StandsAboveTheDialogueBox_InsideTheSafeArea(string name, float pw, float ph, float l, float r, float t, float b, bool mobile) + { + HudFrame f = Frame(pw, ph, l, r, t, b, mobile); + Rect area = StoryRecapLayout.Area(f); + Rect box = StoryRecapLayout.DialogueBox(f); + Assert.LessOrEqual(area.yMax, box.y - UITheme.Space.L + 0.01f, name + ": 판이 대사 상자 위에서 끝난다"); + Assert.GreaterOrEqual(area.y, f.ContentTop - 0.01f, name); + Assert.GreaterOrEqual(area.x, f.ContentLeft - 0.01f, name); + Assert.LessOrEqual(area.xMax, f.ContentRight + 0.01f, name); + // 지난 이야기 3줄(세로에선 두 줄씩 접힌다) + 두 줄 목표가 줄이지 않고 들어갈 만큼 높다. + float lines = f.Portrait ? 2f : 1f; + float need = StoryRecapLayout.FixedHeight(3, true) + + (3f + 1f) * lines * StoryRecapLayout.LineHeight(StoryRecapLayout.BodyFont); + Assert.GreaterOrEqual(area.height, need, $"{name}: 판 {area} 높이가 보통 카드({need})보다 낮다"); + } + + // ── 카드 배치 ── + + [TestCaseSource(nameof(Screens))] + public void Layout_TypicalChapter_FitsUnsqueezed_InOrder(string name, float pw, float ph, float l, float r, float t, float b, bool mobile) + { + HudFrame f = Frame(pw, ph, l, r, t, b, mobile); + Rect area = StoryRecapLayout.Area(f); + float body = StoryRecapLayout.LineHeight(StoryRecapLayout.BodyFont); + float lines = f.Portrait ? 2f : 1f; // 세로 화면은 30자 한 줄이 두 줄로 접힌다 + var recap = new[] { body * lines, body * lines, body * lines }; + StoryRecapLayout.Plan p = StoryRecapLayout.Layout(area, StoryRecapLayout.CardWidth(f), recap, body * lines); + + Assert.IsFalse(p.Squeezed, name + ": 보통 장은 줄이지 않는다"); + AssertInside(area, p.Card, name + " 카드"); + Assert.AreEqual(Mathf.Min(StoryRecapLayout.CardWidth(f), area.width), p.Card.width, 0.01f); + Assert.AreEqual(area.center.x, p.Card.center.x, 0.01f, name + ": 가로 가운데"); + AssertStacked(name, p); + for (int i = 0; i < recap.Length; i++) Assert.AreEqual(recap[i], p.Recap[i].height, 0.01f, "잰 높이 그대로"); + } + + [TestCaseSource(nameof(Screens))] + public void Layout_ManyLongLines_SqueezesBodyAndStaysInsideTheArea(string name, float pw, float ph, float l, float r, float t, float b, bool mobile) + { + HudFrame f = Frame(pw, ph, l, r, t, b, mobile); + Rect area = StoryRecapLayout.Area(f); + float body = StoryRecapLayout.LineHeight(StoryRecapLayout.BodyFont); + float[] recap = Enumerable.Repeat(body * 3f, 10).ToArray(); // 10줄 × 세 줄씩 접힘 + StoryRecapLayout.Plan p = StoryRecapLayout.Layout(area, StoryRecapLayout.CardWidth(f), recap, body * 3f); + + Assert.IsTrue(p.Squeezed, name); + AssertInside(area, p.Card, name + " 카드"); + AssertStacked(name, p); + Assert.LessOrEqual(p.Goal.yMax, p.Card.yMax - StoryRecapLayout.PadY + 0.5f, name + ": 목표가 카드 안에서 끝난다"); + } + + [Test] + public void Layout_NoGoal_HasNoDividerOrGoalSlot() + { + var area = new Rect(0f, 0f, 1200f, 640f); + StoryRecapLayout.Plan p = StoryRecapLayout.Layout(area, 1200f, new[] { 49f }, 0f); + Assert.IsFalse(p.HasGoal); + Assert.AreEqual(0f, p.Goal.height); + Assert.AreEqual(StoryRecapLayout.FixedHeight(1, false) + 49f, p.Card.height, 0.01f); + } + + // ── 저널 장 머리 ── + + [Test] + public void JournalHead_StaysInTheTopHalf_AndTheListStartsBelowIt() + { + var area = new Rect(300f, 200f, 640f, 800f); + float body = StoryJournalHeadLayout.LineHeight(StoryJournalHeadLayout.BodyFont); + StoryJournalHeadLayout.Plan p = StoryJournalHeadLayout.Layout(area, new[] { body, body * 2f, body }, body); + Assert.IsFalse(p.Squeezed); + Assert.AreEqual(area.y, p.Block.y, 0.01f); + Assert.LessOrEqual(p.Block.height, area.height * StoryJournalHeadLayout.MaxShare + 0.01f); + Assert.GreaterOrEqual(p.List.y, p.Block.yMax, "목록은 블록 아래에서 시작한다"); + Assert.AreEqual(area.yMax, p.List.yMax, 0.01f); + AssertInside(p.Block, p.Recap[0], "첫 줄"); + AssertInside(p.Block, p.Goal, "목표"); + Assert.LessOrEqual(p.Recap[2].yMax, p.Divider.y); + Assert.LessOrEqual(p.Divider.yMax, p.Goal.y); + + // 줄이 아주 많아도 블록은 영역 절반을 넘지 않는다(목록이 늘 보인다). + StoryJournalHeadLayout.Plan big = StoryJournalHeadLayout.Layout(area, Enumerable.Repeat(body * 3f, 12).ToArray(), body * 3f); + Assert.IsTrue(big.Squeezed); + Assert.LessOrEqual(big.Block.height, area.height * StoryJournalHeadLayout.MaxShare + 0.5f); + AssertInside(big.Block, big.Goal, "넘친 목표"); + + // 내용이 없으면 블록도 없고 목록이 영역을 다 쓴다. + StoryJournalHeadLayout.Plan none = StoryJournalHeadLayout.Layout(area, new float[0], 0f); + Assert.AreEqual(area, none.List); + Assert.AreEqual(0f, none.Block.height); + } + + [Test] + public void JournalHead_HasContent_RecapOrGoal() + { + Assert.IsFalse(StoryJournalHeadLayout.HasContent(null)); + Assert.IsFalse(StoryJournalHeadLayout.HasContent(new StoryChapter())); + Assert.IsTrue(StoryJournalHeadLayout.HasContent(new StoryChapter { goal = "목표만" }), "1장은 지난 이야기 없이 목표만 있다"); + Assert.IsTrue(StoryJournalHeadLayout.HasContent(new StoryChapter { recap = new List { "줄" } })); + Assert.IsFalse(StoryJournalHeadLayout.HasContent(new StoryChapter { recap = new List { " " }, goal = "" })); + } + + // ── HUD 목표 행의 「왜」 ── + + [Test] + public void ObjectiveRow_WhyAddsASecondLine_OnlyWhenPresent() + { + Assert.AreEqual(QuestChipLayout.RowHeight, QuestChipLayout.RowHeightFor(false), "이유가 없으면 높이 그대로"); + Assert.AreEqual(QuestChipLayout.RowHeightWithWhy, QuestChipLayout.RowHeightFor(true)); + Assert.Less(QuestChipLayout.WhyFontSize, QuestChipLayout.ObjectiveFontSize, "이유는 할 일보다 작다"); + + var row = new Rect(40f, 500f, 500f, QuestChipLayout.RowHeightWithWhy); + Rect main = QuestChipLayout.RowMainLine(row, true); + Rect why = QuestChipLayout.RowWhyLine(row); + AssertInside(row, main, "할 일 줄"); + AssertInside(row, why, "이유 줄"); + Assert.LessOrEqual(main.yMax, why.y + 0.01f, "이유는 할 일 아래"); + Assert.GreaterOrEqual(main.height, Mathf.Ceil(QuestChipLayout.ObjectiveFontSize * 1.35f)); + Assert.GreaterOrEqual(why.height, Mathf.Ceil(QuestChipLayout.WhyFontSize * 1.35f)); + Assert.GreaterOrEqual(why.x, main.x, "이유는 글리프 폭만큼 들여 쓴다"); + + // 이유가 없으면 할 일이 행 전체(예전과 같다). + var plain = new Rect(40f, 500f, 500f, QuestChipLayout.RowHeight); + Rect only = QuestChipLayout.RowMainLine(plain, false); + Assert.AreEqual(plain.y, only.y); + Assert.AreEqual(plain.height, only.height); + } + + [TestCaseSource(nameof(Screens))] + public void ObjectiveRowWithWhy_StaysInsideTheSafeArea_AndTheStageClearsIt(string name, float pw, float ph, float l, float r, float t, float b, bool mobile) + { + HudFrame f = Frame(pw, ph, l, r, t, b, mobile); + float h = QuestChipLayout.RowHeightWithWhy; + Rect chip = QuestChipLayout.ChipRect(f, QuestChipLayout.ExpandedHeight, h); + Rect row = QuestChipLayout.Row(chip, QuestChipLayout.StackWidth(f), h); + Assert.GreaterOrEqual(row.y, f.ContentTop - 0.5f, name); + Assert.LessOrEqual(row.yMax, f.ContentBottom + 0.5f, $"{name}: 두 줄 목표 행 {row}이(가) 안전 영역 아래로 나간다"); + Assert.LessOrEqual(chip.yMax, row.y, name); + // 가운데 무대(잠깐 뜨는 카드가 서는 자리)는 두 줄 행과 겹치지 않는다 — 필드와 섬 둘 다. + foreach (bool island in new[] { false, true }) + Assert.IsFalse(HudStage.Area(f, island).Overlaps(row), $"{name}{(island ? " 섬" : "")}: 무대 {HudStage.Area(f, island)} ↔ 목표 행 {row}"); + } + + // ── 잡담 띠 ── + + [Test] + public void NextStoryLine_AppendsWhy_OnlyWhenPresent() + { + Assert.AreEqual("다음 이야기 · 모래언덕으로", NpcDialogueUI.ComposeNextStory("모래언덕으로", null)); + Assert.AreEqual("다음 이야기 · 모래언덕으로", NpcDialogueUI.ComposeNextStory("모래언덕으로", "")); + Assert.AreEqual("다음 이야기 · 모래언덕으로 — 상자에 갇힌 곤충이 있대", + NpcDialogueUI.ComposeNextStory("모래언덕으로", "상자에 갇힌 곤충이 있대")); + } + + // ── 실제 대사창 — 카드는 처음 뜰 때만 ── + + [Test] + public void RealOpeningBeat_ShowsTheCardWhenFired_ButNotOnJournalReplay() + { + StoryChapter chapter = StoryService.AllChapters().FirstOrDefault(c => + StoryRecapLayout.ShouldShow(c, false) + && StoryService.TryGetBeat(c.openingBeatId, out StoryBeat b) && b.lines != null && b.lines.Count > 0); + if (chapter == null) Assert.Ignore("Story.json에 지난 이야기가 있는 장이 아직 없다"); + StoryService.TryGetBeat(chapter.openingBeatId, out StoryBeat beat); + + var root = new GameObject("Recap card test"); + try + { + // StoryDirector를 주입하지 않는다 — 닫아도 완료 처리(보상·열람 기록)가 없다. + var ui = root.AddComponent(); + ui.ShowStoryReplay(beat); + Assert.IsTrue(ui.IsOpen); + Assert.IsFalse(ui.IsShowingRecap, "다시보기에는 카드가 없다"); + ui.CloseModal(); + + ui.ShowStory(beat); + Assert.IsTrue(ui.IsShowingRecap, $"{chapter.chapterId}를 여는 {beat.beatId}가 처음 뜨면 카드가 먼저다"); + } + finally { Object.DestroyImmediate(root); } + } + + // ── 도우미 ── + + private static void AssertStacked(string name, StoryRecapLayout.Plan p) + { + AssertInside(p.Card, p.Title, name + " 제목"); + float y = p.Title.yMax; + Assert.LessOrEqual(y, p.RecapHeader.y + 0.01f, name + ": 소제목은 제목 아래"); + y = p.RecapHeader.yMax; + foreach (Rect line in p.Recap) + { + Assert.LessOrEqual(y, line.y + 0.01f, name + ": 줄이 겹치지 않고 아래로"); + y = line.yMax; + } + if (!p.HasGoal) return; + Assert.LessOrEqual(y, p.Divider.y + 0.01f, name + ": 구분선은 마지막 줄 아래"); + Assert.LessOrEqual(p.Divider.yMax, p.GoalHeader.y + 0.01f); + Assert.LessOrEqual(p.GoalHeader.yMax, p.Goal.y + 0.01f); + AssertInside(p.Card, p.Goal, name + " 목표"); + } + + private static void AssertInside(Rect outer, Rect inner, string what) + { + Assert.IsTrue(inner.xMin >= outer.xMin - 0.5f && inner.yMin >= outer.yMin - 0.5f + && inner.xMax <= outer.xMax + 0.5f && inner.yMax <= outer.yMax + 0.5f, + $"{what} {inner}이(가) {outer} 밖으로 나간다"); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/StoryRecapLayoutTests.cs.meta b/Assets/Tests/EditMode/StoryRecapLayoutTests.cs.meta new file mode 100644 index 0000000..550957b --- /dev/null +++ b/Assets/Tests/EditMode/StoryRecapLayoutTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4445db829567ac84590d53de655bebbc \ No newline at end of file diff --git a/Assets/Tests/EditMode/StoryStarterGrantTests.cs b/Assets/Tests/EditMode/StoryStarterGrantTests.cs new file mode 100644 index 0000000..2ecd5e5 --- /dev/null +++ b/Assets/Tests/EditMode/StoryStarterGrantTests.cs @@ -0,0 +1,78 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using InsectGame.Core; +using InsectGame.Story; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 첫 파트너 지급은 "포획"이 아니다. + /// + /// 어르신이 파트너를 건네는 순간(ch1_intro 보상) 컬렉션이 InsectCaptured를 울리고, + /// 그게 그대로 CaptureInsect 트리거가 되면 다음 비트 ch1_first_capture + /// ("훌륭해! 풀밭의 아이를 하나 만나고 왔구나")가 아무것도 잡기 전에 뜬다. 정작 처음 잡았을 때는 + /// 그 대사가 이미 지나가 있다. 예외도 경고도 없고 대사는 멀쩡히 뜨므로 눈으로 순서를 따져야만 보인다. + /// + [TestFixture] + public class StoryStarterGrantTests + { + private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + + private GameObject host; + private StoryDirector director; + private MethodInfo onInsectCaptured; + private FieldInfo grantingStarter; + private List frameCaptures; + + [SetUp] + public void SetUp() + { + host = new GameObject("StoryStarterGrantTests"); + director = host.AddComponent(); + onInsectCaptured = typeof(StoryDirector).GetMethod("OnInsectCaptured", Private); + grantingStarter = typeof(StoryDirector).GetField("grantingStarter", Private); + Assert.IsNotNull(onInsectCaptured); + Assert.IsNotNull(grantingStarter, "첫 파트너 지급을 가리는 표지가 사라졌다"); + frameCaptures = (List)typeof(StoryDirector).GetField("frameCaptures", Private).GetValue(director); + } + + [TearDown] + public void TearDown() + { + if (frameCaptures != null) frameCaptures.Clear(); + if (host != null) Object.DestroyImmediate(host); + } + + private void Capture(string insectId) + { + onInsectCaptured.Invoke(director, new object[] { new PlayerInsectData { insectId = insectId } }); + } + + [Test] + public void OrdinaryCapture_IsQueuedAsACaptureTrigger() + { + Capture("beetle_basic"); + CollectionAssert.AreEqual(new[] { "beetle_basic" }, frameCaptures); + } + + [Test] + public void StarterGrant_IsNotACapture() + { + grantingStarter.SetValue(director, true); + Capture("longhorn_beetle"); + Assert.AreEqual(0, frameCaptures.Count, "첫 파트너를 받는 순간 '첫 포획 축하' 대사가 뜬다"); + } + + [Test] + public void StarterBeat_IsTheOneThatHandsOverAnInsect() + { + // 가리는 조건이 beatId == StarterBeatId다. 그 비트가 곤충을 안 주게 바뀌면 이 장치가 헛돈다. + Assert.IsTrue(StoryService.TryGetBeat(InsectGame.Data.StarterInsectCatalog.StarterBeatId, out StoryBeat beat)); + Assert.IsFalse(string.IsNullOrEmpty(beat.onComplete.rewardInsectId)); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/StoryStarterGrantTests.cs.meta b/Assets/Tests/EditMode/StoryStarterGrantTests.cs.meta new file mode 100644 index 0000000..115ce77 --- /dev/null +++ b/Assets/Tests/EditMode/StoryStarterGrantTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e96adb06d76b86b43b77a6ed75a83f6e \ No newline at end of file diff --git a/Assets/Tests/EditMode/StoryVideoLibraryTests.cs b/Assets/Tests/EditMode/StoryVideoLibraryTests.cs new file mode 100644 index 0000000..dfb2ce2 --- /dev/null +++ b/Assets/Tests/EditMode/StoryVideoLibraryTests.cs @@ -0,0 +1,262 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using InsectGame.Core; +using InsectGame.Story; +using NUnit.Framework; + +namespace InsectGame.Tests +{ + /// + /// 스토리 영상 저작의 순수 검사. 재생·복귀는 VideoPlayer와 MonoBehaviour 수명에 달려 있어 + /// 기기 확인 대상이고(배치모드엔 디코더가 없다), 여기서는 저작이 프리즈 상한 안에 있고 + /// 자막 큐가 서로 겹치거나 영상 밖으로 나가지 않는지를 고정한다. + /// + [TestFixture] + public class StoryVideoLibraryTests + { + /// 저작된 영상 전부를 리플렉션으로 센다 — 손 목록은 새 항목을 조용히 빠뜨린다(CutsceneTimelineTests와 같은 이유). + private static readonly string[] AllVideos = CollectVideoIds(); + + private static string[] CollectVideoIds() + { + var ids = new List(); + foreach (FieldInfo f in typeof(StoryVideoLibrary).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (!f.IsLiteral || f.IsInitOnly) continue; + if (f.FieldType != typeof(string)) continue; + ids.Add((string)f.GetRawConstantValue()); + } + ids.Sort(System.StringComparer.Ordinal); + return ids.ToArray(); + } + + // ── 순수 계산 ── + + private static StoryVideoCue[] ThreeCues() + { + return new[] + { + new StoryVideoCue(1f, 2f, "첫 줄"), + new StoryVideoCue(4f, 1f, "둘째 줄"), + new StoryVideoCue(6f, 2f, "셋째 줄"), + }; + } + + [TestCase(0f, -1)] + [TestCase(1f, 0)] + [TestCase(2.99f, 0)] + [TestCase(3f, -1)] + [TestCase(4.5f, 1)] + [TestCase(7.99f, 2)] + [TestCase(8f, -1)] + public void TryGetCue_MapsElapsedToCue(float elapsed, int expected) + { + bool found = StoryVideoTimeline.TryGetCue(ThreeCues(), elapsed, out int index); + Assert.AreEqual(expected >= 0, found); + Assert.AreEqual(expected, index); + } + + [Test] + public void TryGetCue_NullOrEmpty_IsFalse() + { + Assert.IsFalse(StoryVideoTimeline.TryGetCue(null, 1f, out _)); + Assert.IsFalse(StoryVideoTimeline.TryGetCue(new StoryVideoCue[0], 1f, out _)); + } + + [Test] + public void CueProgress_IsClampedZeroToOne() + { + var cue = new StoryVideoCue(2f, 4f, "x"); + Assert.AreEqual(0f, StoryVideoTimeline.CueProgress(cue, 0f), 0.001f); + Assert.AreEqual(0.5f, StoryVideoTimeline.CueProgress(cue, 4f), 0.001f); + Assert.AreEqual(1f, StoryVideoTimeline.CueProgress(cue, 9f), 0.001f); + } + + // ── 저작된 영상 ── + + [Test] + public void Library_IdCollection_IsNotEmpty() + { + // 리플렉션이 빈 배열을 내면 아래 검사가 전부 "통과"한다 — 0건 통과는 검사기 고장이다. + Assert.GreaterOrEqual(AllVideos.Length, 5, "StoryVideoLibrary의 const 문자열을 못 읽었다"); + CollectionAssert.AllItemsAreUnique(AllVideos); + foreach (string id in AllVideos) + Assert.IsTrue(StoryVideoLibrary.TryGet(id, out _), $"{id}가 switch에 없다"); + } + + [Test] + public void Library_UnknownId_IsFalse() + { + Assert.IsFalse(StoryVideoLibrary.TryGet("vid_nope", out StoryVideoDefinition def)); + Assert.IsNull(def); + } + + [TestCaseSource(nameof(AllVideos))] + public void Library_Definitions_AreWellFormed(string videoId) + { + Assert.IsTrue(StoryVideoLibrary.TryGet(videoId, out StoryVideoDefinition def), videoId); + Assert.AreEqual(videoId, def.videoId, "정의의 videoId가 상수와 다르다"); + StringAssert.StartsWith("vid_", videoId); + StringAssert.EndsWith(".mp4", def.fileName); + Assert.IsFalse(def.fileName.Contains("/") || def.fileName.Contains("\\"), + "파일명에 경로를 넣지 않는다 — 폴더는 StoryVideoDirector가 붙인다"); + // 파일명 = videoId에서 vid_ 제거. 둘이 어긋나면 lint 보고와 폴더가 따로 논다. + Assert.AreEqual(videoId.Substring(4) + ".mp4", def.fileName); + Assert.IsNotNull(def.cues); + } + + [TestCaseSource(nameof(AllVideos))] + public void Library_Definitions_FinishBeforeAutoUnfreeze(string videoId) + { + // **가장 중요한 검사.** PlayerMovement는 frozen이 걸린 뒤 AutoUnfreezeTime이 지나면 + // 스스로 푼다. 프리즈 타이머는 실제 재생 시작(OnPrepared)에 다시 감기므로, 최악 프리즈는 + // 영상 길이 + 종료 이벤트가 안 올 때의 오버런 여유(StoryVideoDirector.OverrunGraceSeconds=3)다. + // 그 합이 상한보다 작아야 검은 화면 뒤에서 캐릭터가 걷는 일이 없다. + const float overrunGrace = 3f; + const float safetyMargin = 1f; + Assert.IsTrue(StoryVideoLibrary.TryGet(videoId, out StoryVideoDefinition def)); + Assert.Greater(def.expectedDuration, 3f, $"{videoId}가 너무 짧아 연출로 읽히지 않는다"); + Assert.LessOrEqual(def.expectedDuration + overrunGrace, + GameConstants.Player.AutoUnfreezeTime - safetyMargin, + $"{videoId}가 {def.expectedDuration:F1}s로 자동 프리즈 해제({GameConstants.Player.AutoUnfreezeTime}s)에 너무 가깝다"); + } + + /// + /// 맞닿은 큐(앞 큐의 끝 = 뒤 큐의 시작)는 저작 의도다(예: 9.8 + 2.4 = 12.2). float로 더하면 12.2000008이 되어 + /// 12.2f보다 커지므로 그만큼은 겹침으로 치지 않는다 — 진짜 겹침(0.2초 등)은 그대로 잡는다. + /// + private const float TouchEpsilon = 1e-3f; + + [TestCaseSource(nameof(AllVideos))] + public void Library_Cues_AreOrderedAndInsideVideo(string videoId) + { + Assert.IsTrue(StoryVideoLibrary.TryGet(videoId, out StoryVideoDefinition def)); + Assert.Greater(def.cues.Length, 0, $"{videoId}에 자막이 하나도 없다"); + float lastEnd = 0f; + for (int i = 0; i < def.cues.Length; i++) + { + StoryVideoCue cue = def.cues[i]; + Assert.IsFalse(string.IsNullOrWhiteSpace(cue.text), $"{videoId} 큐 {i}의 문구가 비었다"); + Assert.Greater(cue.duration, 1f, $"{videoId} 큐 {i}가 읽기엔 너무 짧다"); + Assert.GreaterOrEqual(cue.at, 0f, $"{videoId} 큐 {i}가 영상 앞에서 시작한다"); + Assert.GreaterOrEqual(cue.at, lastEnd - TouchEpsilon, $"{videoId} 큐 {i}가 앞 큐와 겹친다({cue.at:F2} < {lastEnd:F2})"); + Assert.LessOrEqual(cue.End, def.expectedDuration + TouchEpsilon, $"{videoId} 큐 {i}가 영상 밖으로 나간다"); + lastEnd = cue.End; + } + } + + // ── 그림책 15편(2026-10-05) — 초등 고학년: 영상이 설명하고 대사는 짧게 ── + + /// + /// 자막 한 줄 상한(공백·문장부호 포함 글자 수). 아이가 2~3초 안에 한 번에 읽는 길이 — 사양의 최장이 + /// 「이름을 받으면, 울타리 안에 자리를 얻는다.」(24자)다. 이보다 길면 문장을 둘로 나눌 것. + /// + private const int MaxCueChars = 24; + + /// 대사 앞(introVideoId)에 붙는 네 편 — 새로 그렸다. 빠지면 그 비트가 영상 없이 짧은 대사만 남는다. + private static readonly string[] IntroVideos = + { + StoryVideoLibrary.Ch6Wall, StoryVideoLibrary.Ch7Fence, StoryVideoLibrary.FinShadow, StoryVideoLibrary.FinReturn, + }; + + [Test] + public void Library_HasFifteenStoryVideos_IncludingTheFourIntros() + { + Assert.AreEqual(15, AllVideos.Length, "그림책 영상은 15편이다(대사 뒤 11 · 대사 앞 4) — 늘리거나 줄였다면 이 수와 사양을 함께 고칠 것"); + foreach (string id in IntroVideos) + CollectionAssert.Contains(AllVideos, id, $"대사 앞 영상 {id}가 상수로 없다"); + + Assert.IsTrue(StoryVideoLibrary.TryGet(StoryVideoLibrary.Ch6Wall, out StoryVideoDefinition wall)); + Assert.AreEqual("ch6_wall.mp4", wall.fileName); + Assert.AreEqual(15f, wall.expectedDuration, 1e-4f); + Assert.IsTrue(StoryVideoLibrary.TryGet(StoryVideoLibrary.Ch7Fence, out StoryVideoDefinition fence)); + Assert.AreEqual("ch7_fence.mp4", fence.fileName); + Assert.AreEqual(14f, fence.expectedDuration, 1e-4f); + Assert.IsTrue(StoryVideoLibrary.TryGet(StoryVideoLibrary.FinShadow, out StoryVideoDefinition shadow)); + Assert.AreEqual("fin_shadow.mp4", shadow.fileName); + Assert.AreEqual(14.4f, shadow.expectedDuration, 1e-4f); + Assert.IsTrue(StoryVideoLibrary.TryGet(StoryVideoLibrary.FinReturn, out StoryVideoDefinition back)); + Assert.AreEqual("fin_return.mp4", back.fileName); + Assert.AreEqual(14.6f, back.expectedDuration, 1e-4f); + } + + [TestCaseSource(nameof(AllVideos))] + public void Library_Definitions_AreAtMostFifteenSeconds(string videoId) + { + Assert.IsTrue(StoryVideoLibrary.TryGet(videoId, out StoryVideoDefinition def)); + Assert.LessOrEqual(def.expectedDuration, 15f + 1e-4f, $"{videoId}가 15초를 넘는다 — 아이가 기다리기엔 길다"); + } + + [TestCaseSource(nameof(AllVideos))] + public void Library_Cues_AreShortEnoughToReadAtOnce(string videoId) + { + Assert.IsTrue(StoryVideoLibrary.TryGet(videoId, out StoryVideoDefinition def)); + foreach (StoryVideoCue cue in def.cues) + { + Assert.LessOrEqual(cue.text.Length, MaxCueChars, $"{videoId} 자막이 {cue.text.Length}자다: 「{cue.text}」"); + StringAssert.DoesNotContain("\n", cue.text, $"{videoId} 자막은 한 줄이다 — 줄바꿈은 자막 띠 두 줄을 먹는다"); + } + } + + [TestCaseSource(nameof(AllVideos))] + public void Library_Cues_NeverNameTheNameless(string videoId) + { + // 이름을 부르지 않는 것이 이 이야기의 규칙이자 승리 조건이다(StoryBible 2장). + // 대사가 지키는 금칙을 자막이 깨면 결말이 무너진다. + Assert.IsTrue(StoryVideoLibrary.TryGet(videoId, out StoryVideoDefinition def)); + foreach (StoryVideoCue cue in def.cues) + StringAssert.DoesNotContain("무명", cue.text, $"{videoId} 자막이 그것의 이름을 부른다"); + } + + /// + /// 쉬운 말로 바꾼 옛 말(story_lint 검사 32의 RETIRED_WORDS와 같은 목록) — 대사는 그 검사가 보지만 자막은 + /// C#에 있어 거기 안 걸린다. 한 화면에서 대사는 「이름 벽」, 자막은 「봉인」이면 같은 것을 두 이름으로 부르게 된다. + /// + [TestCaseSource(nameof(AllVideos))] + public void Library_Cues_UseTheEasyWords(string videoId) + { + string[] retired = { "봉인", "무명", "지워진 개체", "예비 울타리" }; + Assert.IsTrue(StoryVideoLibrary.TryGet(videoId, out StoryVideoDefinition def)); + foreach (StoryVideoCue cue in def.cues) + foreach (string word in retired) + StringAssert.DoesNotContain(word, cue.text, $"{videoId} 자막에 쓰지 않는 말 「{word}」"); + } + + /// + /// 실제 Story.json(JsonUtility)이 introVideoId를 읽는가 + 그 비트의 저작 규칙. story_lint 검사 25·13·36과 같은 규칙을 + /// 런타임 파서 쪽에서 한 번 더 본다 — 필드 이름이 어긋나면 JsonUtility는 조용히 버리고 영상이 그냥 안 나온다. + /// + [Test] + public void RealStory_IntroVideoBeats_PointToLibraryAndStayShort() + { + var expected = new Dictionary + { + ["ch6_secret"] = StoryVideoLibrary.Ch6Wall, + ["ch7_opening"] = StoryVideoLibrary.Ch7Fence, + ["fin_unnamed"] = StoryVideoLibrary.FinShadow, + ["fin_seal"] = StoryVideoLibrary.FinReturn, + }; + + int intros = 0; + foreach (StoryBeat beat in StoryService.AllBeats()) + { + if (beat == null || string.IsNullOrEmpty(beat.introVideoId)) continue; + intros++; + Assert.IsTrue(StoryVideoLibrary.TryGet(beat.introVideoId, out _), $"{beat.beatId}: 모르는 introVideoId {beat.introVideoId}"); + Assert.IsTrue(string.IsNullOrEmpty(beat.videoId), $"{beat.beatId}: 대사 앞·뒤 영상을 한 비트에 두지 않는다"); + Assert.IsNotNull(beat.lines, beat.beatId); + Assert.That(beat.lines.Count, Is.InRange(1, 6), $"{beat.beatId}: 영상이 설명하므로 대사는 1~6줄"); + } + + foreach (KeyValuePair pair in expected) + { + Assert.IsTrue(StoryService.TryGetBeat(pair.Key, out StoryBeat beat), $"{pair.Key}가 Story.json에 없다"); + Assert.AreEqual(pair.Value, beat.introVideoId, $"{pair.Key}의 대사 앞 영상"); + Assert.IsTrue(string.IsNullOrEmpty(beat.cutsceneId), $"{pair.Key}: 영상으로 옮긴 비트에 옛 컷신이 남았다"); + } + Assert.GreaterOrEqual(intros, expected.Count); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/StoryVideoLibraryTests.cs.meta b/Assets/Tests/EditMode/StoryVideoLibraryTests.cs.meta new file mode 100644 index 0000000..62b505a --- /dev/null +++ b/Assets/Tests/EditMode/StoryVideoLibraryTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d12552689ce41f8419d54af1d8e40be7 \ No newline at end of file diff --git a/Assets/Tests/EditMode/StoryVideoScreenLayoutTests.cs b/Assets/Tests/EditMode/StoryVideoScreenLayoutTests.cs new file mode 100644 index 0000000..0ae952d --- /dev/null +++ b/Assets/Tests/EditMode/StoryVideoScreenLayoutTests.cs @@ -0,0 +1,87 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 스토리 영상 화면의 자막 띠·「건너뛰기」 자리() — 가로·세로·노치·배율별. + /// 그림은 QA 빌드 -battleScenario story-video(가로 1280×720·세로 720×1280). + /// + [TestFixture] + public class StoryVideoScreenLayoutTests + { + private static IEnumerable Screens() + { + yield return new TestCaseData("desktop 1280x720", 1280f, 720f, 0f, 0f, 0f, 0f, false); + yield return new TestCaseData("desktop 1920x1080", 1920f, 1080f, 0f, 0f, 0f, 0f, false); + yield return new TestCaseData("desktop 2560x1080", 2560f, 1080f, 0f, 0f, 0f, 0f, false); + yield return new TestCaseData("tablet 4x3", 1600f, 1200f, 0f, 0f, 0f, 0f, true); + foreach (float s in new[] { 2f / 3f, 1f, 4f / 3f }) + { + string k = s.ToString("0.###"); + yield return new TestCaseData($"portrait 720x1280 s{k}", 720f * s, 1280f * s, 0f, 0f, 0f, 0f, true); + yield return new TestCaseData($"portrait 20x9 notch s{k}", 1080f * s, 2400f * s, 0f, 0f, 100f * s, 40f * s, true); + yield return new TestCaseData($"landscape 20x9 notch s{k}", 2400f * s, 1080f * s, 100f * s, 100f * s, 0f, 30f * s, true); + // 노치 인셋이 커서 가로 띠가 「건너뛰기」 열까지 닿는 화면 — 띠가 버튼 위로 올라가야 한다. + yield return new TestCaseData($"landscape 16x9 wide inset s{k}", 1920f * s, 1080f * s, 160f * s, 160f * s, 0f, 30f * s, true); + } + } + + [TestCaseSource(nameof(Screens))] + public void SubtitleBand_And_Skip_StayInside_AndNeverOverlap(string name, float pw, float ph, + float l, float r, float t, float b, bool mobile) + { + HudFrame f = HudFrame.ForScreen(pw, ph, l, r, t, b, mobile); + Rect band = StoryVideoScreenLayout.SubtitleBand(f); + Rect skip = StoryVideoScreenLayout.Skip(f); + + foreach (Rect rect in new[] { band, skip }) + { + Assert.GreaterOrEqual(rect.xMin, f.SafeLeft - 0.01f, $"{name}: 왼쪽 세이프 에어리어 밖"); + Assert.LessOrEqual(rect.xMax, f.Width - f.SafeRight + 0.01f, $"{name}: 오른쪽 세이프 에어리어 밖"); + Assert.GreaterOrEqual(rect.yMin, f.ContentTop - 0.01f, $"{name}: 위 콘텐츠 영역 밖"); + Assert.LessOrEqual(rect.yMax, f.ContentBottom + 0.01f, $"{name}: 아래 콘텐츠 영역 밖"); + } + Assert.IsFalse(band.Overlaps(skip), $"{name}: 자막 띠 {band}와 「건너뛰기」 {skip}가 겹친다"); + Assert.GreaterOrEqual(skip.height, UIScale.MinTouchHeight, $"{name}: 터치 높이 하한"); + } + + [TestCaseSource(nameof(Screens))] + public void Skip_IsTopRightInPortrait_BottomRightInLandscape(string name, float pw, float ph, + float l, float r, float t, float b, bool mobile) + { + HudFrame f = HudFrame.ForScreen(pw, ph, l, r, t, b, mobile); + Rect skip = StoryVideoScreenLayout.Skip(f); + Assert.Greater(skip.center.x, f.Width * 0.5f, $"{name}: 오른쪽"); + if (f.Portrait) Assert.Less(skip.center.y, f.Height * 0.5f, $"{name}: 세로는 위 — 아래는 자막 띠 자리"); + else Assert.Greater(skip.center.y, f.Height * 0.5f, $"{name}: 가로는 아래"); + // 자막 띠는 늘 아래 절반. + Assert.Greater(StoryVideoScreenLayout.SubtitleBand(f).center.y, f.Height * 0.5f, $"{name}: 자막은 아래"); + } + + [TestCaseSource(nameof(Screens))] + public void SubtitleText_FitsTwoLines_OfTheLongestCue(string name, float pw, float ph, + float l, float r, float t, float b, bool mobile) + { + HudFrame f = HudFrame.ForScreen(pw, ph, l, r, t, b, mobile); + Rect text = StoryVideoScreenLayout.SubtitleText(StoryVideoScreenLayout.SubtitleBand(f)); + float line = StoryVideoScreenLayout.LineHeight(StoryVideoScreenLayout.SubtitleFontSize); + Assert.GreaterOrEqual(text.height, line * StoryVideoScreenLayout.SubtitleLines, $"{name}: 두 줄이 안 들어간다"); + // 가장 긴 자막(24자)이 두 줄 안에 줄이지 않고 든다 — 한 줄에 12자(한글 한 자 ≈ 글자 크기). + Assert.GreaterOrEqual(text.width, 12f * StoryVideoScreenLayout.SubtitleFontSize, $"{name}: 한 줄이 너무 좁다"); + } + + [Test] + public void Readability_ForKids() + { + Assert.That(StoryVideoScreenLayout.SubtitleFontSize, Is.InRange(34, 36), "아이가 읽는 자막 크기"); + Assert.That(StoryVideoScreenLayout.SubtitleBandAlpha, Is.InRange(0.65f, 0.7f), "밝은 그림책 장면 위의 띠 농도"); + Assert.LessOrEqual(StoryVideoScreenLayout.LineHeight(StoryVideoScreenLayout.SkipFontSize), + StoryVideoScreenLayout.SkipHeight, "「건너뛰기」 글자가 버튼 높이 안"); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/StoryVideoScreenLayoutTests.cs.meta b/Assets/Tests/EditMode/StoryVideoScreenLayoutTests.cs.meta new file mode 100644 index 0000000..b3433bf --- /dev/null +++ b/Assets/Tests/EditMode/StoryVideoScreenLayoutTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fd2f969e3910b0d4f883c387398b93af \ No newline at end of file diff --git a/Assets/Tests/EditMode/TeamDuelTests.cs b/Assets/Tests/EditMode/TeamDuelTests.cs new file mode 100644 index 0000000..f2a451a --- /dev/null +++ b/Assets/Tests/EditMode/TeamDuelTests.cs @@ -0,0 +1,513 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using InsectGame.Battle; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.NPC; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 간부 팀 대결() — 상대가 곤충을 차례로 내보내고 마지막이 쓰러져야 이긴다. + /// + /// 실패는 전부 조용하다: 첫 곤충이 쓰러질 때 전투가 끝나 버리면 간부전이 한 마리 대결로 줄고, 교체가 상대의 독·디버프를 + /// 안 지우면 새 곤충이 이전 곤충의 상처를 안고 나오고, 장부가 교체로 비워지면 압박이 곤충마다 처음부터 다시 찬다. + /// 승리·보상이 두 번 나가면 간부 격파 기록과 보상이 겹친다. 그래서 이벤트 횟수와 상태를 고정한다. + /// + [TestFixture] + public class TeamDuelTests + { + private readonly List objects = new List(); + private string currencyPath; + + [SetUp] + public void SetUp() + { + // 승리 코인이 실제 세이브에 쓰인다 — 백업하고 비운 상태에서 잰다(DexCoinRewardTests와 같은 방식). + currencyPath = SaveScope.FilePath(GameConstants.SaveFiles.PlayerCurrency); + string backup = currencyPath + ".teamduelbak"; + // 앞선 실행이 도중에 죽어 백업이 남아 있으면 그게 진짜 세이브다 — 지우지 말고 먼저 되돌린다. + if (File.Exists(backup)) + { + if (File.Exists(currencyPath)) File.Delete(currencyPath); + File.Move(backup, currencyPath); + } + if (File.Exists(currencyPath)) File.Move(currencyPath, backup); + } + + [TearDown] + public void TearDown() + { + for (int i = objects.Count - 1; i >= 0; i--) + if (objects[i] != null) Object.DestroyImmediate(objects[i]); + objects.Clear(); + if (File.Exists(currencyPath)) File.Delete(currencyPath); + if (File.Exists(currencyPath + ".teamduelbak")) File.Move(currencyPath + ".teamduelbak", currencyPath); + } + + // ── 교체 ── + + [Test] + public void FirstOpponentFaints_BattleContinues_AndSwitchesOnce() + { + Fixture f = NewFixture(); + InsectData[] team = { Enemy("e0"), Enemy("e1"), Enemy("e2") }; + Assert.IsTrue(f.battle.StartTeamDuel(f.player, 10, team, new[] { 5, 6, 7 }, equippedSkills: f.skills)); + Assert.IsTrue(f.battle.IsEnemyTeamBattle); + Assert.AreEqual(3, f.battle.EnemyTeamSize); + Assert.AreEqual(0, f.battle.EnemyTeamIndex); + Assert.AreEqual(3, f.battle.EnemiesRemaining); + + f.battle.UseSkill(Nuke); + + Assert.AreEqual(0, f.counts.battleEnded, "첫 곤충이 쓰러졌다고 전투가 끝났다"); + Assert.AreEqual(0, f.counts.duelEnded); + Assert.AreEqual(1, f.counts.switched, "교체 이벤트는 한 번"); + Assert.AreEqual("e0", f.counts.lastOutgoing.Data.insectId); + Assert.AreEqual("e1", f.counts.lastIncoming.Data.insectId); + Assert.AreEqual(6, f.counts.lastIncoming.Level); + Assert.AreEqual(f.counts.lastIncoming.MaxHp, f.counts.lastIncoming.CurrentHp, "들어온 곤충은 풀피로 나온다"); + Assert.AreEqual(1, f.battle.EnemyTeamIndex); + Assert.AreEqual(2, f.battle.EnemiesRemaining); + Assert.IsTrue(f.battle.IsEnemyTeamMemberFainted(0)); + Assert.IsFalse(f.battle.IsEnemyTeamMemberFainted(1)); + Assert.AreEqual("e1", f.battle.EnemyInsectId, "지금 상대가 들어온 곤충이어야 한다"); + Assert.AreEqual("e2", f.battle.GetEnemyTeamInsect(2).insectId); + Assert.AreEqual(7, f.battle.GetEnemyTeamLevel(2)); + } + + [Test] + public void IncomingOpponent_DoesNotActInTheRoundItEnters() + { + Fixture f = NewFixture(); + InsectData[] team = { Enemy("e0"), Enemy("e1") }; + Assert.IsTrue(f.battle.StartTeamDuel(f.player, 10, team, new[] { 5, 5 }, equippedSkills: f.skills)); + f.battle.UseSkill(Nuke); + Assert.IsFalse(f.battle.EnemyActedThisRound, "쓰러진 곤충 대신 들어온 곤충이 같은 라운드에 반격했다"); + } + + [Test] + public void Switch_ResetsOpponentState_AndKeepsMine() + { + Fixture f = NewFixture(); + InsectData venomous = Enemy("e0"); + venomous.skills = new[] { Skill("enemy_venom", SkillEffectType.PoisonDot, 2, 0f, 5) }; + InsectData[] team = { venomous, Enemy("e1") }; + Assert.IsTrue(f.battle.StartTeamDuel(f.player, 10, team, new[] { 5, 5 }, equippedSkills: f.skills)); + + f.battle.UseSkill(Boost); // 내 공격 버프 — 상대는 내게 독 + f.battle.UseSkill(Weaken); // 상대 공격 하락 + f.battle.UseSkill(Venom); // 상대에게 독 + Assert.Greater(Count(f.battle, onPlayer: false), 0, "전제: 교체 전 상대에게 걸린 효과가 있어야 한다"); + int mineBefore = Count(f.battle, onPlayer: true); + Assert.Greater(mineBefore, 0, "전제: 내게 걸린 효과(버프·독)가 있어야 한다"); + + f.battle.UseSkill(Nuke); // e0 쓰러짐 → e1 + + Assert.AreEqual(1, f.counts.switched); + Assert.AreEqual(0, Count(f.battle, onPlayer: false), "상대 쪽 버프·디버프·독은 새 곤충 기준으로 비워져야 한다"); + Assert.AreEqual(mineBefore, Count(f.battle, onPlayer: true), "내 곤충의 버프·독은 그대로여야 한다"); + Assert.AreEqual(0f, f.counts.lastIncoming.AttackBonus, 0.0001f, "들어온 곤충이 이전 곤충의 공격 하락을 물려받았다"); + Assert.AreEqual(0, f.battle.EnemyStunTurns); + Assert.IsTrue(HasPlayerEffect(f.battle, InsectBattleController.EffectKind.AtkBuff), "내 공격 버프가 사라졌다"); + Assert.IsTrue(HasPlayerEffect(f.battle, InsectBattleController.EffectKind.Dot), "내게 걸린 독이 사라졌다(내 상태는 그대로다)"); + } + + [Test] + public void Ledger_CarriesAcrossTheSwitch() + { + // 장부는 곤충이 아니라 인물의 것이다 — 교체로 비워지면 간부전마다 압박이 곤충 수만큼 쪼개진다. + Fixture f = NewFixture(); + InsectData[] team = { Enemy("e0"), Enemy("e1") }; + Assert.IsTrue(f.battle.StartTeamDuel(f.player, 10, team, new[] { 5, 5 }, equippedSkills: f.skills)); + f.battle.ArmLedger(9); + + f.battle.UseBasicAttack(); + f.battle.UseBasicAttack(); + f.battle.UseBasicAttack(); + int before = f.battle.LedgerTally; + Assert.Greater(before, 0, "전제: 같은 행동을 되풀이해 장부가 차 있어야 한다"); + Assert.AreEqual(0, f.counts.switched, "전제: 기본 공격 셋으로는 첫 곤충이 안 쓰러진다"); + + f.battle.UseSkill(Nuke); + + Assert.AreEqual(1, f.counts.switched); + Assert.AreEqual(9, f.battle.LedgerThreshold, "교체가 장부 임계를 지웠다"); + Assert.AreEqual(LedgerPressure.NextTally(before, 9, false), f.battle.LedgerTally, + "교체가 장부를 비웠다 — 행동을 바꾼 만큼(-1)만 줄어야 한다"); + } + + // ── 끝 ── + + [Test] + public void LastOpponentFaints_WinsExactlyOnce() + { + Fixture f = NewFixture(); + InsectData[] team = { Enemy("e0"), Enemy("e1"), Enemy("e2") }; + Assert.IsTrue(f.battle.StartTeamDuel(f.player, 10, team, new[] { 5, 6, 7 }, equippedSkills: f.skills)); + + f.battle.UseSkill(Nuke); + f.battle.UseSkill(Nuke); + Assert.AreEqual(0, f.counts.battleEnded, "두 번째 곤충에서 끝났다"); + f.battle.UseSkill(Nuke); + + Assert.AreEqual(2, f.counts.switched, "세 마리 팀은 교체가 두 번"); + Assert.AreEqual(1, f.counts.battleEnded, "승리는 한 번"); + Assert.AreEqual(1, f.counts.duelEnded); + Assert.IsTrue(f.counts.won); + Assert.IsTrue(f.battle.GetLastPlayerWon()); + Assert.AreEqual(0, f.battle.EnemiesRemaining); + Assert.AreEqual("e2", f.battle.EnemyInsectId, "BattleEnded 시점의 상대는 에이스다"); + + f.battle.UseSkill(Nuke); // 끝난 뒤 입력은 무시된다 + Assert.AreEqual(1, f.counts.battleEnded); + Assert.AreEqual(2, f.counts.switched); + } + + [Test] + public void Rewards_AreTheSumOfOneDuelPerOpponent() + { + InsectData[] team = { Enemy("e0", InsectRarity.Common), Enemy("e1", InsectRarity.Rare), Enemy("e2", InsectRarity.Epic) }; + int[] levels = { 5, 6, 7 }; + + int candySum = 0, expSum = 0; + for (int i = 0; i < team.Length; i++) + { + Fixture single = NewFixture(); + Assert.IsTrue(single.battle.StartDuel(single.player, 10, team[i], levels[i], equippedSkills: single.skills)); + single.battle.UseSkill(Nuke); + Assert.AreEqual(1, single.counts.battleEnded); + candySum += single.battle.GetLastCandyReward(); + expSum += single.battle.GetLastExpReward(); + } + Assert.Greater(candySum, 0, "전제: 보상이 0이면 합산을 잴 수 없다"); + + Fixture f = NewFixture(); + int coinsBefore = f.wallet.Coins; + Assert.IsTrue(f.battle.StartTeamDuel(f.player, 10, team, levels, equippedSkills: f.skills)); + for (int i = 0; i < team.Length; i++) f.battle.UseSkill(Nuke); + + Assert.AreEqual(1, f.counts.battleEnded); + Assert.AreEqual(candySum, f.battle.GetLastCandyReward(), "캔디는 상대마다 한 마리 대결과 같은 식의 합"); + Assert.AreEqual(expSum, f.battle.GetLastExpReward(), "EXP는 상대마다 한 마리 대결과 같은 식의 합"); + Assert.AreEqual(coinsBefore + 3 * team.Length, f.wallet.Coins, "코인은 상대마다 승리 코인(3)"); + Assert.IsFalse(f.battle.GetLastCaptureAttempted(), "대결은 포획 판정이 없다"); + } + + [Test] + public void Defeat_EndsOnceWithNoReward_AndTheNextChallengeStartsFromTheFirst() + { + Fixture f = NewFixture(); + InsectData brute = Enemy("brute"); + brute.baseHp = 50000; + brute.baseAtk = 5000; + brute.skills = new[] { Skill("crush", SkillEffectType.Damage, 5000) }; + InsectData[] team = { Enemy("e0"), brute }; + int coinsBefore = f.wallet.Coins; + Assert.IsTrue(f.battle.StartTeamDuel(f.player, 10, team, new[] { 5, 9 }, equippedSkills: f.skills)); + + f.battle.UseSkill(Nuke); // e0 쓰러짐 → brute + Assert.AreEqual(1, f.counts.switched); + f.battle.UseSkill(Nuke); // brute는 버티고 한 방에 눕힌다 — 교체 화면(PlayerFainted 구독자)이 없으니 패배 + + Assert.AreEqual(1, f.counts.battleEnded, "패배는 한 번"); + Assert.AreEqual(1, f.counts.duelEnded); + Assert.IsFalse(f.counts.won); + Assert.AreEqual(0, f.battle.GetLastCandyReward(), "지면 쓰러뜨린 곤충의 보상도 없다"); + Assert.AreEqual(coinsBefore, f.wallet.Coins); + + Assert.IsTrue(f.battle.StartTeamDuel(f.player, 10, team, new[] { 5, 9 }, equippedSkills: f.skills)); + Assert.AreEqual(0, f.battle.EnemyTeamIndex, "재도전은 처음부터"); + Assert.AreEqual(2, f.battle.EnemiesRemaining); + Assert.AreEqual("e0", f.battle.EnemyInsectId); + } + + [Test] + public void BattleFeat_UsesTheAce_AndTheWholeBattlesActionCount() + { + // 조건부 퀘스트 입력은 정적 매니저로 가므로 여기서는 그 재료를 본다 — 끝났을 때 상대는 에이스, 행동 수는 전체. + Fixture f = NewFixture(); + InsectData[] team = { Enemy("e0"), Enemy("e1") }; + Assert.IsTrue(f.battle.StartTeamDuel(f.player, 10, team, new[] { 5, 8 }, equippedSkills: f.skills)); + f.battle.UseBasicAttack(); + f.battle.UseSkill(Nuke); + f.battle.UseSkill(Nuke); + Assert.AreEqual(1, f.counts.battleEnded); + Assert.AreEqual("e1", f.battle.EnemyInsectId); + Assert.AreEqual(3, (int)typeof(InsectBattleController) + .GetField("playerActionCount", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(f.battle), + "내 행동 수는 교체로 다시 세지 않는다"); + } + + [Test] + public void StartTeamDuel_RejectsBadRosters() + { + Fixture f = NewFixture(); + Assert.IsFalse(f.battle.StartTeamDuel(f.player, 10, null, new[] { 5 })); + Assert.IsFalse(f.battle.StartTeamDuel(f.player, 10, new InsectData[0], new int[0])); + Assert.IsFalse(f.battle.StartTeamDuel(f.player, 10, new[] { Enemy("a"), null }, new[] { 5, 5 }), "빈 칸"); + Assert.IsFalse(f.battle.StartTeamDuel(f.player, 10, new[] { Enemy("a"), Enemy("b") }, new[] { 5 }), "레벨 누락"); + Assert.IsFalse(f.battle.IsBattleInProgress(), "거절은 상태를 건드리지 않는다"); + } + + // ── 한 마리 대결 회귀 ── + + [Test] + public void SingleDuel_IsUnchanged_AndDoesNotInheritATeam() + { + Fixture f = NewFixture(); + Assert.IsTrue(f.battle.StartTeamDuel(f.player, 10, new[] { Enemy("t0"), Enemy("t1") }, new[] { 5, 5 }, + equippedSkills: f.skills)); + Assert.IsTrue(f.battle.IsEnemyTeamBattle); + + InsectData lone = Enemy("lone", InsectRarity.Rare); + Assert.IsTrue(f.battle.StartDuel(f.player, 10, lone, 6, equippedSkills: f.skills)); + Assert.IsFalse(f.battle.IsEnemyTeamBattle, "앞 전투의 팀이 남았다"); + Assert.AreEqual(1, f.battle.EnemyTeamSize); + Assert.AreEqual(0, f.battle.EnemyTeamIndex); + Assert.AreEqual(1, f.battle.EnemiesRemaining); + Assert.AreEqual("lone", f.battle.GetEnemyTeamInsect(0).insectId); + + int endedBefore = f.counts.battleEnded; + f.battle.UseSkill(Nuke); + Assert.AreEqual(endedBefore + 1, f.counts.battleEnded, "한 마리 대결은 그 곤충이 쓰러지면 끝난다"); + Assert.AreEqual(0, f.counts.switched, "한 마리 대결에 교체 이벤트가 울렸다"); + Assert.AreEqual(0, f.battle.EnemiesRemaining); + Assert.AreEqual(Mathf.RoundToInt(InsectRewardCalculator.GetCandyReward(lone)), f.battle.GetLastCandyReward(), + "한 마리 대결 보상이 예전 식과 다르다"); + Assert.AreEqual(Mathf.RoundToInt(InsectRewardCalculator.GetExpReward(lone, 6, 10)), f.battle.GetLastExpReward()); + } + + // ── 실제 DB ── + + [Test] + public void RealDatabase_HasEveryRosterInsect() + { + InsectDatabase db = RealDatabase(); + foreach (NpcBossDuels.BossDuel d in NpcBossDuels.All()) + for (int i = 0; i < d.RosterSize; i++) + Assert.IsNotNull(db.GetById(d.RosterInsectId(i)), + $"{d.storyNpcId}의 {i + 1}번째 곤충 '{d.RosterInsectId(i)}'가 곤충 DB에 없다 — 그 곤충만 조용히 빠진다"); + } + + /// + /// 전투 길이 감각 — 실제 곤충으로 간부 팀과 같은 종·같은 레벨의 거울 팀을 붙여 몇 번 행동하는지 로그([TeamDuel])에 남긴다. + /// 한 마리 대결(에이스 대 에이스)과 나란히 적어 "몇 배 길어졌나"를 본다. 숫자를 단언하지 않는다 — 끝나기만 하면 된다 + /// (가장 센 피해기만 누르는 단순한 수라 실제 플레이보다 짧게 나온다). + /// + [Test] + public void RealTeams_MirrorMatch_LogsHowLongTheyLast() + { + InsectDatabase db = RealDatabase(); + foreach (NpcBossDuels.BossDuel d in NpcBossDuels.All()) + { + if (!d.IsTeam) continue; + var team = new InsectData[d.RosterSize]; + var levels = new int[d.RosterSize]; + for (int i = 0; i < d.RosterSize; i++) + { + team[i] = db.GetById(d.RosterInsectId(i)); + levels[i] = d.RosterLevel(i); + } + + MirrorResult teamRun = RunMirror(team, levels, team, levels, d.ledgerThreshold); + MirrorResult aloneRun = RunMirror(new[] { team[team.Length - 1] }, new[] { levels[levels.Length - 1] }, + new[] { team[team.Length - 1] }, new[] { levels[levels.Length - 1] }, d.ledgerThreshold); + Debug.Log($"[TeamDuel] {d.storyNpcId} {d.RosterSize}마리 거울전 — 내 행동 {teamRun.actions}번 · 쓰러진 내 곤충 {teamRun.myFainted} · " + + $"{(teamRun.won ? "승" : "패")} | 에이스 한 마리 대결 {aloneRun.actions}번 → {(aloneRun.actions > 0 ? (float)teamRun.actions / aloneRun.actions : 0f):0.0}배"); + Assert.IsTrue(teamRun.ended, $"{d.storyNpcId}: 거울전이 {MirrorActionCap}번 안에 끝나지 않았다"); + } + } + + private const int MirrorActionCap = 400; + + private struct MirrorResult + { + public bool ended; + public bool won; + public int actions; + public int myFainted; + } + + private MirrorResult RunMirror(InsectData[] mine, int[] myLevels, InsectData[] theirs, int[] theirLevels, int ledger) + { + Fixture f = NewFixture(); + f.battle.SetRandomSeed(7); + f.battle.SetCritSource(null); + var result = new MirrorResult(); + int myIndex = 0; + bool fainted = false; + f.battle.PlayerFainted += () => fainted = true; + + Assert.IsTrue(f.battle.StartTeamDuel(mine[0], myLevels[0], theirs, theirLevels, + equippedSkills: StrongestSkills(mine[0], myLevels[0]))); + f.battle.ArmLedger(ledger); + while (f.counts.battleEnded == 0 && result.actions < MirrorActionCap) + { + if (fainted) + { + fainted = false; + result.myFainted++; + myIndex++; + if (myIndex >= mine.Length) { f.battle.ConcludeDefeatWithoutSwap(); break; } + f.battle.SwapPlayerInsect(mine[myIndex], myLevels[myIndex], StrongestSkills(mine[myIndex], myLevels[myIndex])); + } + + int pick = -1; + InsectSkill[] skills = f.battle.GetPlayerSkills(); + for (int i = 0; skills != null && i < skills.Length; i++) + if (f.battle.CanUseSkill(i) && (pick < 0 || skills[i].power > skills[pick].power)) pick = i; + if (pick >= 0) f.battle.UseSkill(pick); else f.battle.UseBasicAttack(); + result.actions++; + } + result.ended = f.counts.battleEnded > 0; + result.won = f.counts.won; + return result; + } + + // 해금된 피해기 중 위력 순으로 넷 — 거울전의 "같은 수준의 플레이어". + private static InsectSkill[] StrongestSkills(InsectData data, int level) + { + var list = new List(); + if (data != null && data.learnset != null) + foreach (InsectLearnableSkill l in data.learnset) + if (l != null && l.skill != null && l.learnLevel <= level && l.skill.effectType == SkillEffectType.Damage + && !list.Contains(l.skill)) + list.Add(l.skill); + list.Sort((a, b) => b.power.CompareTo(a.power)); + if (list.Count > 4) list.RemoveRange(4, list.Count - 4); + return list.Count > 0 ? list.ToArray() : null; + } + + // ── 도우미 ── + + private const int Nuke = 0; + private const int Boost = 1; + private const int Weaken = 2; + private const int Venom = 3; + + private sealed class Counts + { + public int battleEnded; + public int duelEnded; + public int switched; + public bool won; + public InsectBattleStats lastOutgoing; + public InsectBattleStats lastIncoming; + } + + private sealed class Fixture + { + public InsectBattleController battle; + public PlayerCurrencyWallet wallet; + public InsectData player; + public InsectSkill[] skills; + public Counts counts; + } + + private Fixture NewFixture() + { + // 캔디·경험치 지급처는 붙이지 않는다(디스크에 쓴다) — 값은 GetLast*Reward로 읽는다. + // 코인은 지갑을 붙여 실제 지급을 본다(SetUp이 세이브를 비워 둔다). + var go = new GameObject("TeamDuelFixture"); + go.SetActive(false); + objects.Add(go); + var wallet = go.AddComponent(); + typeof(PlayerCurrencyWallet).GetField("data", BindingFlags.Instance | BindingFlags.NonPublic) + .SetValue(wallet, new PlayerCurrencyData { coins = 11, gems = 0 }); + var battle = go.AddComponent(); + battle.AutoWire(wallet); + battle.SetRandomSeed(1); + battle.SetCritSource(null); // 피해·보상을 정확히 보므로 치명타를 끈다 + + var c = new Counts(); + battle.BattleEnded += won => { c.battleEnded++; c.won = won; }; + battle.DuelEnded += won => c.duelEnded++; + battle.EnemySwitched += (o, i) => { c.switched++; c.lastOutgoing = o; c.lastIncoming = i; }; + + InsectData player = Species("player", 5000, 20, 30, InsectRarity.Common); + InsectSkill[] skills = + { + Skill("nuke", SkillEffectType.Damage, 2000), + Skill("boost", SkillEffectType.BuffAttack, 1, 0.5f, 5), + Skill("weaken", SkillEffectType.DebuffAttack, 1, 0.3f, 5), + Skill("venom", SkillEffectType.PoisonDot, 3, 0f, 5), + }; + player.skills = skills; + return new Fixture { battle = battle, wallet = wallet, player = player, skills = skills, counts = c }; + } + + private InsectData Enemy(string id, InsectRarity rarity = InsectRarity.Common) + { + InsectData d = Species(id, 500, 10, 20, rarity); + d.skills = new[] { Skill(id + "_poke", SkillEffectType.Damage, 1) }; + return d; + } + + private InsectData Species(string id, int hp, int atk, int def, InsectRarity rarity) + { + var d = ScriptableObject.CreateInstance(); + objects.Add(d); + d.insectId = id; + d.displayName = id; + d.rarity = rarity; + d.baseHp = hp; + d.baseAtk = atk; + d.baseDef = def; + d.primaryType = InsectElement.None; + d.secondaryType = InsectElement.None; + d.candyReward = 3; + d.expReward = 5; + return d; + } + + private InsectSkill Skill(string id, SkillEffectType type, int power, float effectValue = 0f, int duration = 0) + { + var s = ScriptableObject.CreateInstance(); + objects.Add(s); + s.skillId = id; + s.displayName = id; + s.element = InsectElement.None; + s.effectType = type; + s.power = power; + s.effectValue = effectValue; + s.effectDurationTurns = duration; + s.cooldownTurns = 0; + s.accuracy = 1f; + return s; + } + + private static int Count(InsectBattleController battle, bool onPlayer) + { + int n = 0; + foreach (InsectBattleController.EffectSnapshot e in battle.GetActiveEffects()) + if (e.targetIsPlayer == onPlayer) n++; + return n; + } + + private static bool HasPlayerEffect(InsectBattleController battle, InsectBattleController.EffectKind kind) + { + foreach (InsectBattleController.EffectSnapshot e in battle.GetActiveEffects()) + if (e.targetIsPlayer && e.kind == kind) return true; + return false; + } + + private InsectDatabase RealDatabase() + { + var host = new GameObject("TeamDuelTests_RealDb"); + host.SetActive(false); // Awake(월드 생성)를 막고 private 생성 함수만 부른다 + objects.Add(host); + var bootstrap = host.AddComponent(); + var db = (InsectDatabase)typeof(PlaySceneBootstrap) + .GetMethod("EnsureExpandedDatabase", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(bootstrap, null); + Assert.IsNotNull(db); + objects.Add(db); + return db; + } + } +} +#endif diff --git a/Assets/Tests/EditMode/TeamDuelTests.cs.meta b/Assets/Tests/EditMode/TeamDuelTests.cs.meta new file mode 100644 index 0000000..9f6256e --- /dev/null +++ b/Assets/Tests/EditMode/TeamDuelTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ab951d1cf6568eb4385b2b226ae7dbb5 \ No newline at end of file diff --git a/Assets/Tests/EditMode/TownTaleTests.cs b/Assets/Tests/EditMode/TownTaleTests.cs new file mode 100644 index 0000000..cfaf539 --- /dev/null +++ b/Assets/Tests/EditMode/TownTaleTests.cs @@ -0,0 +1,383 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Reflection; +using InsectGame.Core; +using InsectGame.NPC; +using InsectGame.Story; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 마을 이야기(Story.json town 챕터) + 지역 의뢰(s_town_*) + 따라가기 판정. + /// + /// 셋 다 실패가 조용하다 — 의뢰 리전이 틀리면 진행이 안 세어지고, 매듭 비트의 퀘스트 게이트가 + /// 반복 퀘스트를 물면 영영 안 열리고, 따라가기 판정이 발화 게이트와 어긋나면 !를 따라가 + /// 말을 걸어도 아무 일도 없다. 순수부는 합성 비트로, 저작은 실제 데이터로 고정한다. + /// + [TestFixture] + public class TownTaleTests + { + private const string Npc = "town_qa"; + + private static StoryBeat Talk(string id, int order, string prereq = null, + string gate = null, string quest = null, string npc = Npc, string chapter = "town") + { + return new StoryBeat + { + beatId = id, + chapterId = chapter, + order = order, + prerequisiteBeatId = prereq, + requiredBeatId = gate, + requiredQuestId = quest, + trigger = new StoryTrigger { type = StoryDirector.TriggerNpcTalk, param = npc } + }; + } + + // 만남(도착 게이트) → 징후(포획, 따라가지 않음) → 매듭(의뢰 게이트) → 후일담(본편 게이트) + private static List Tale() + { + return new List + { + Talk("qa_meet", 1, gate: "qa_arrive"), + new StoryBeat + { + beatId = "qa_sign", chapterId = "town", order = 2, prerequisiteBeatId = "qa_meet", + requiredRegionId = "pond", + trigger = new StoryTrigger { type = StoryDirector.TriggerCaptureInsect, param = "water_strider_pond" } + }, + Talk("qa_close", 3, prereq: "qa_meet", quest: "s_qa"), + Talk("qa_after", 4, prereq: "qa_close", gate: "qa_later"), + }; + } + + private static System.Func Set(params string[] ids) + { + var set = new HashSet(ids); + return id => set.Contains(id); + } + + private static TaleStepKind Step(System.Func seen, System.Func questDone, + out StoryBeat beat, out string questId) + { + return StoryTaleResolver.ResolveStep(Tale(), Npc, seen, questDone, out beat, out questId); + } + + // ── QuestRegionGate ── + + [Test] + public void QuestRegionGate_NoRegion_CountsEverywhere() + { + Assert.IsTrue(QuestRegionGate.Counts(null, "pond")); + Assert.IsTrue(QuestRegionGate.Counts(string.Empty, null)); + } + + [Test] + public void QuestRegionGate_Region_CountsOnlyInsideThatRegion() + { + Assert.IsTrue(QuestRegionGate.Counts("pond", "pond")); + Assert.IsFalse(QuestRegionGate.Counts("pond", "meadow")); + // 리전 사이 빈 땅(CurrentRegion null)에서 한 행동은 세지 않는다. + Assert.IsFalse(QuestRegionGate.Counts("pond", null)); + } + + [Test] + public void QuestRegionGate_IsOpen_FollowsAccessProbe_AndClosedWithoutOne() + { + Assert.IsTrue(QuestRegionGate.IsOpen(null, null), "리전 한정이 없으면 늘 열려 있다"); + Assert.IsFalse(QuestRegionGate.IsOpen("pond", null), "판정기가 없으면 닫힌 쪽으로 둔다"); + Assert.IsTrue(QuestRegionGate.IsOpen("pond", id => id == "pond")); + Assert.IsFalse(QuestRegionGate.IsOpen("ruins", id => id == "pond")); + } + + // ── 따라가기 단계 판정 ── + + [Test] + public void ResolveStep_BeforeRegionArrival_IsWaiting() + { + Assert.AreEqual(TaleStepKind.Waiting, Step(Set(), Set(), out _, out _)); + } + + [Test] + public void ResolveStep_AfterArrival_IsTalkToMeet() + { + Assert.AreEqual(TaleStepKind.Talk, Step(Set("qa_arrive"), Set(), out StoryBeat beat, out _)); + Assert.AreEqual("qa_meet", beat.beatId); + } + + [Test] + public void ResolveStep_MetButErrandUnfinished_IsErrandWithQuest_IgnoringCaptureFlavor() + { + // 포획 징후 비트(qa_sign)가 미열람이어도 할 일은 의뢰다 — 징후는 곁들임이라 따라가지 않는다. + TaleStepKind step = Step(Set("qa_arrive", "qa_meet"), Set(), out StoryBeat beat, out string quest); + Assert.AreEqual(TaleStepKind.Errand, step); + Assert.AreEqual("qa_close", beat.beatId); + Assert.AreEqual("s_qa", quest); + } + + [Test] + public void ResolveStep_ErrandDone_IsReport() + { + TaleStepKind step = Step(Set("qa_arrive", "qa_meet"), Set("s_qa"), out StoryBeat beat, out _); + Assert.AreEqual(TaleStepKind.Report, step); + Assert.AreEqual("qa_close", beat.beatId); + } + + [Test] + public void ResolveStep_ClosedButMainStoryBehind_IsWaiting_ThenTalkWhenItCatchesUp() + { + Assert.AreEqual(TaleStepKind.Waiting, + Step(Set("qa_arrive", "qa_meet", "qa_close"), Set("s_qa"), out _, out _)); + Assert.AreEqual(TaleStepKind.Talk, + Step(Set("qa_arrive", "qa_meet", "qa_close", "qa_later"), Set("s_qa"), out StoryBeat beat, out _)); + Assert.AreEqual("qa_after", beat.beatId); + } + + [Test] + public void ResolveStep_AllTalksSeen_IsDone_AndUnknownNpcIsNone() + { + var all = Set("qa_arrive", "qa_meet", "qa_close", "qa_later", "qa_after"); + Assert.AreEqual(TaleStepKind.Done, Step(all, Set("s_qa"), out _, out _)); + Assert.AreEqual(TaleStepKind.None, + StoryTaleResolver.ResolveStep(Tale(), "nobody", all, Set(), out _, out _)); + } + + [Test] + public void CollectTaleNpcIds_OnlyTownChapter_InAuthoringOrder() + { + var beats = new List + { + Talk("b_meet", 20, npc: "town_b"), + Talk("a_meet", 10, npc: "town_a"), + Talk("a_close", 11, npc: "town_a"), + Talk("echo", 1, npc: "ruins_scholar", chapter: "ch3"), // 동행자 여운 — 표식 대상 아님 + }; + CollectionAssert.AreEqual(new[] { "town_a", "town_b" }, StoryTaleResolver.CollectTaleNpcIds(beats)); + } + + [Test] + public void FindNpcForQuest_MapsErrandToItsResident() + { + Assert.AreEqual(Npc, StoryTaleResolver.FindNpcForQuest(Tale(), "s_qa")); + Assert.IsNull(StoryTaleResolver.FindNpcForQuest(Tale(), "q_move")); + } + + [Test] + public void FindNpcForQuest_MainStoryQuestGate_IsNotAnErrand() + { + // 본편도 퀘스트 게이트를 쓴다(ch1_intro ← q_move). 거르지 않으면 튜토리얼 행에 [따라가기]가 뜬다. + var beats = Tale(); + beats.Add(Talk("ch1_intro", 1, quest: "q_move", npc: "village_elder", chapter: "ch1")); + Assert.IsNull(StoryTaleResolver.FindNpcForQuest(beats, "q_move")); + Assert.IsNull(StoryTaleResolver.FindNpcForQuest(StoryService.AllBeats(), "q_move"), + "실제 저작에서 튜토리얼 퀘스트가 마을 의뢰로 잡힌다"); + } + + [Test] + public void DescribeErrand_NamesRegionOnlyFromOutside_AndClampsProgress() + { + Assert.AreEqual("연못에서 곤충 포획 3/5", + StoryTaleResolver.DescribeErrand(QuestType.Capture, "연못", false, 3, 5)); + Assert.AreEqual("전투 승리 1/3", + StoryTaleResolver.DescribeErrand(QuestType.Battle, "숲", true, 1, 3)); + Assert.AreEqual("희귀 곤충 포획 3/3", + StoryTaleResolver.DescribeErrand(QuestType.CaptureRare, "고대 유적", true, 9, 3)); + } + + // ── 저작 데이터 ── + + private static TutorialQuest[] LoadQuests() + { + GameObject host = new GameObject("TownTaleQuestFixture"); + host.SetActive(false); // Awake(싱글턴 등록)·Start(세이브 로드)를 돌리지 않는다 + try + { + var mgr = host.AddComponent(); + typeof(TutorialQuestManager).GetMethod("Initialize", BindingFlags.Instance | BindingFlags.NonPublic) + .Invoke(mgr, null); + return mgr.GetAllQuests(); + } + finally { Object.DestroyImmediate(host); } + } + + [Test] + public void EveryTownTale_ReportWaitsOnOneShotRegionErrand_InTheSameRegionAsItsSign() + { + var beats = new List(StoryService.AllBeats()); + List residents = StoryTaleResolver.CollectTaleNpcIds(beats); + Assert.GreaterOrEqual(residents.Count, 12, "1막 일곱 + 2막 다섯 마을"); + + var quests = new Dictionary(); + foreach (TutorialQuest q in LoadQuests()) quests[q.questId] = q; + + foreach (string npc in residents) + { + StoryBeat meet = null, report = null; + foreach (StoryBeat b in beats) + { + if (b.chapterId != StoryTaleResolver.TownChapterId || b.trigger == null) continue; + if (b.trigger.type != StoryDirector.TriggerNpcTalk || b.trigger.param != npc) continue; + // 표식 판정(ResolveStep)은 리전 게이트를 보지 않는다 — 대화 비트에 리전을 달면 + // 리전 밖에서 !가 떠 있는데 말을 걸어도 아무 일이 없다. + Assert.IsTrue(string.IsNullOrEmpty(b.requiredRegionId), + $"{b.beatId}: 마을 이야기 대화 비트에 requiredRegionId — 표식과 발화가 갈린다"); + if (!string.IsNullOrEmpty(b.requiredQuestId)) { Assert.IsNull(report, $"{npc}: 의뢰 보고가 둘"); report = b; } + else if (string.IsNullOrEmpty(b.prerequisiteBeatId)) meet = b; + } + Assert.IsNotNull(meet, $"{npc}: 만남 비트가 없다"); + Assert.IsFalse(string.IsNullOrEmpty(meet.requiredBeatId), + $"{npc}: 만남이 도착 게이트 없이 열린다 — 캠페인 시작부터 !가 뜬다"); + Assert.IsNotNull(report, $"{npc}: 의뢰를 기다리는 매듭 비트가 없다"); + + Assert.IsTrue(quests.TryGetValue(report.requiredQuestId, out TutorialQuest q), + $"{npc}: 매듭이 무는 {report.requiredQuestId}가 퀘스트 배열에 없다"); + Assert.AreEqual(QuestCategory.Side, q.category, $"{q.questId}: 서브 퀘스트여야 한다"); + Assert.IsFalse(q.repeatable, $"{q.questId}: 반복 퀘스트는 완료 기록이 안 남아 매듭이 영영 안 열린다"); + Assert.IsFalse(string.IsNullOrEmpty(q.requiredRegionId), $"{q.questId}: 지역 의뢰여야 한다"); + + // 징후(포획) 비트의 리전 = 의뢰 리전 — 주민이 선 마을과 일을 시키는 곳이 같아야 한다. + foreach (StoryBeat b in beats) + { + if (b.prerequisiteBeatId != meet.beatId || b.trigger == null + || b.trigger.type != StoryDirector.TriggerCaptureInsect) continue; + Assert.AreEqual(q.requiredRegionId, b.requiredRegionId, $"{b.beatId}: 의뢰와 다른 리전"); + } + } + } + + [Test] + public void EveryTownResident_HasOwnNamePortraitLinesAndAppearance() + { + NpcAppearance elder = NpcVisualBuilder.StoryNpcAppearance("village_elder"); + foreach (string npc in StoryTaleResolver.CollectTaleNpcIds(StoryService.AllBeats())) + { + Assert.AreNotEqual(npc, NpcDialogueDatabase.StorySpeakerName(npc), $"{npc}: 표시명 미등록(ID가 그대로 뜬다)"); + // 초상은 검사기가 안 본다 — 빠지면 대사창에 얼굴 없이 이름표만 뜬다(2막 간부가 그랬다). + Assert.IsTrue(NpcDialogueUI.HasStoryPortrait(npc), $"{npc}: 대사창 초상 미등록"); + Assert.IsTrue(NpcDialogueDatabase.TryGetStoryNpcLines(npc, out string[] lines) && lines.Length > 0, + $"{npc}: 전용 잡담 없음"); + NpcAppearance a = NpcVisualBuilder.StoryNpcAppearance(npc); + Assert.IsFalse(a.top == elder.top && a.hat == elder.hat && a.hairStyle == elder.hairStyle, + $"{npc}: 외형 switch 미등록(마을 어르신 얼굴로 선다)"); + } + } + + [Test] + public void TownSignBeats_NeverSpokenByRaon() + { + // 징후는 포획 트리거라 만남 뒤 **언제든** 뜬다 — 라온 이탈 구간(ch10 대치 ~ ch12 복귀)에도. + // 그 사이 라온의 말을 두지 않는다(StoryBible 4장·13장). 1막 초원·연못은 세라를 만나기 전일 수 + // 있어 지문이, 그 뒤 마을은 세라가 맡는다. + foreach (StoryBeat b in StoryService.AllBeats()) + { + if (b.chapterId != StoryTaleResolver.TownChapterId || b.trigger == null + || b.trigger.type != StoryDirector.TriggerCaptureInsect) continue; + Assert.AreNotEqual("catcher_rival", b.speakerNpcId, $"{b.beatId}: 라온이 화자"); + if (b.lines == null) continue; + foreach (StoryLine line in b.lines) + Assert.AreNotEqual("라온", line.speaker, $"{b.beatId}: 라온의 대사 — 이탈 구간에 뜰 수 있다"); + } + } + + [Test] + public void EveryStoryPortrait_WearsSameTorsoAndHatAsWorld() + { + // 초상 표와 월드 외형 표가 따로 있다 — 물결 할머니 밀짚모자가 초상에선 주황, 잎새의 두건은 초상에 아예 + // 없었고, 명부회 일곱은 전원이 달랐다(집게의 붉은 모자가 대사창에선 맨머리). 마을 주민만 보던 이 검사를 + // 초상이 있는 스토리 인물 전원으로 넓힌다. 몸통은 월드에서 **실제로 보이는** 색 — 셔츠 또는 겉옷. + var ids = new HashSet(StoryTaleResolver.CollectTaleNpcIds(StoryService.AllBeats())); + foreach (StoryBeat b in StoryService.AllBeats()) + { + if (!string.IsNullOrEmpty(b.speakerNpcId)) ids.Add(b.speakerNpcId); + if (b.trigger != null && b.trigger.type == StoryDirector.TriggerNpcTalk && !string.IsNullOrEmpty(b.trigger.param)) + ids.Add(b.trigger.param); + } + int checkedCount = 0; + foreach (string npc in ids) + { + if (!NpcDialogueUI.TryGetStoryPortraitColors(npc, out Color top, out Color hat)) continue; + checkedCount++; + NpcAppearance world = NpcVisualBuilder.StoryNpcAppearance(npc); + Color torso = NpcDialogueUI.VisibleTorsoColor(world); + Assert.IsTrue(SameRgb(top, world.top) || SameRgb(top, torso), + $"{npc}: 몸통 — 초상 {top} / 월드 셔츠 {world.top}·겉옷 {torso}"); + Assert.AreEqual(world.hasHat, hat.a > 0f, $"{npc}: 모자 유무가 다르다"); + if (world.hasHat) + Assert.IsTrue(SameRgb(hat, world.hat), $"{npc}: 모자색 — 초상 {hat} / 월드 {world.hat}"); + } + Assert.GreaterOrEqual(checkedCount, 12 + 7 + 3, "마을 주민 12 · 명부회 7 · 동행자 3"); + } + + [Test] + public void EveryStoryPortrait_HasWorldSkinHairAndHairStyle_IncludingGreyTheCharacterPaletteLacks() + { + // 두 표가 같은 번호를 서로 다른 팔레트로 읽어 "자"의 회색 머리가 대사창에서 빨강, 세라의 검은 머리가 보라, + // 어르신·물결 할머니·너울의 백발이 금발이 됐다. 초상이 있는 인물 전원이 월드 외형에서 받는지 본다. + int checkedCount = 0; + foreach (string npc in PortraitCandidates()) + { + if (!NpcDialogueUI.TryGetStoryPortraitFace(npc, out Color skin, out Color hair, out int hairStyle)) continue; + checkedCount++; + NpcAppearance world = NpcVisualBuilder.StoryNpcAppearance(npc); + Assert.IsTrue(SameRgb(skin, world.skin), $"{npc}: 피부 — 초상 {skin} / 월드 {world.skin}"); + Assert.IsTrue(SameRgb(hair, world.hair), $"{npc}: 머리색 — 초상 {hair} / 월드 {world.hair}"); + // 월드 0 짧은·1 중간·2 올림 ↔ 초상 0 짧은·1 중간·2 긴·3 올림 + Assert.AreEqual(world.hairStyle == 2 ? 3 : world.hairStyle, hairStyle, $"{npc}: 머리 모양"); + } + Assert.GreaterOrEqual(checkedCount, 12 + 7 + 3, "마을 주민 12 · 명부회 7 · 동행자 3"); + } + + [Test] + public void CompanionPortraits_MatchWorldHair_SeraBlackElderGrey() + { + // 사용자가 짚은 두 사람 — 세라(필드 검은 머리가 대사창 보라)와 어르신(필드 백발이 대사창 금발). + foreach (string npc in new[] { "ruins_scholar", "village_elder", "catcher_rival" }) + { + Assert.IsTrue(NpcDialogueUI.TryGetStoryPortraitFace(npc, out _, out Color hair, out _), $"{npc}: 초상 없음"); + Assert.IsTrue(SameRgb(hair, NpcVisualBuilder.StoryNpcAppearance(npc).hair), $"{npc}: 머리색 — 초상 {hair}"); + } + } + + // ── 표식 판정 ── + // 의뢰 표식은 눌러서 따라가는 손잡이다 — 본편 대상이 됐다고 민트 !로 덮이면 그 주민의 의뢰가 + // 지도에서 사라진다. 할 의뢰가 없는 주민일 때만 본편 표식이 붙는다. + + [TestCase(TaleStepKind.Talk, false, QuestMark.New)] + [TestCase(TaleStepKind.Talk, true, QuestMark.New)] + [TestCase(TaleStepKind.Report, false, QuestMark.Report)] + [TestCase(TaleStepKind.Report, true, QuestMark.Report)] + [TestCase(TaleStepKind.Errand, true, QuestMark.Main)] + [TestCase(TaleStepKind.Waiting, true, QuestMark.Main)] + [TestCase(TaleStepKind.Done, true, QuestMark.Main)] + [TestCase(TaleStepKind.None, true, QuestMark.Main)] + [TestCase(TaleStepKind.Errand, false, QuestMark.None)] + [TestCase(TaleStepKind.Waiting, false, QuestMark.None)] + [TestCase(TaleStepKind.Done, false, QuestMark.None)] + public void MarkFor_TaleMarkWinsOverMain_MainOnlyWhenNoTaleToTell( + TaleStepKind step, bool isMainTarget, QuestMark expected) + { + Assert.AreEqual(expected, StoryTaleResolver.MarkFor(step, isMainTarget)); + } + + private static HashSet PortraitCandidates() + { + var ids = new HashSet(StoryTaleResolver.CollectTaleNpcIds(StoryService.AllBeats())); + foreach (StoryBeat b in StoryService.AllBeats()) + { + if (!string.IsNullOrEmpty(b.speakerNpcId)) ids.Add(b.speakerNpcId); + if (b.trigger != null && b.trigger.type == StoryDirector.TriggerNpcTalk && !string.IsNullOrEmpty(b.trigger.param)) + ids.Add(b.trigger.param); + } + return ids; + } + + private static bool SameRgb(Color a, Color b) + { + return Mathf.Abs(a.r - b.r) < 0.01f && Mathf.Abs(a.g - b.g) < 0.01f && Mathf.Abs(a.b - b.b) < 0.01f; + } + } +} +#endif diff --git a/Assets/Tests/EditMode/TownTaleTests.cs.meta b/Assets/Tests/EditMode/TownTaleTests.cs.meta new file mode 100644 index 0000000..9686739 --- /dev/null +++ b/Assets/Tests/EditMode/TownTaleTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4a6dab9d874b3fb4e83c437c90167ea6 \ No newline at end of file diff --git a/Assets/Tests/EditMode/TrainerLevelGapTests.cs b/Assets/Tests/EditMode/TrainerLevelGapTests.cs new file mode 100644 index 0000000..b274628 --- /dev/null +++ b/Assets/Tests/EditMode/TrainerLevelGapTests.cs @@ -0,0 +1,149 @@ +#if UNITY_EDITOR +using InsectGame.Core; +using InsectGame.Data; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 캐릭터 레벨 ↔ 곤충 레벨 차이 규칙()과 그걸 쓰는 EXP 지급식. + /// 포획 공식 쪽 적용은 CaptureChanceCalculatorTests·BattleCaptureChanceCalculatorTests가 본다. + /// + [TestFixture] + public class TrainerLevelGapTests + { + private InsectData species; + + [SetUp] + public void SetUp() + { + species = ScriptableObject.CreateInstance(); + species.insectId = "test_beetle"; + species.rarity = InsectRarity.Common; + species.expReward = 5; + } + + [TearDown] + public void TearDown() + { + if (species != null) Object.DestroyImmediate(species); + } + + // ── 포획 배율 ── + + [TestCase(-20)] + [TestCase(0)] + [TestCase(5)] + public void CaptureMultiplier_WithinGrace_IsOne(int gap) + { + Assert.AreEqual(1f, TrainerLevelGap.CaptureMultiplier(30, 30 + gap), 0.0001f); + Assert.IsFalse(TrainerLevelGap.IsCaptureRestricted(30, 30 + gap)); + } + + [TestCase(6, 0.9f)] + [TestCase(10, 0.5f)] + [TestCase(14, 0.1f)] + public void CaptureMultiplier_BeyondGrace_DropsTenPercentPerLevel(int gap, float expected) + { + Assert.AreEqual(expected, TrainerLevelGap.CaptureMultiplier(20, 20 + gap), 0.0001f); + Assert.IsTrue(TrainerLevelGap.IsCaptureRestricted(20, 20 + gap)); + } + + [TestCase(15)] + [TestCase(60)] + public void CaptureMultiplier_FarAbove_ClampsToMinimum(int gap) + { + Assert.AreEqual(GameConstants.TrainerLevel.MinCaptureMultiplier, + TrainerLevelGap.CaptureMultiplier(10, 10 + gap), 0.0001f); + } + + [Test] + public void Gap_NonPositiveLevels_TreatedAsLevelOne() + { + Assert.AreEqual(0, TrainerLevelGap.Gap(0, 1)); + Assert.AreEqual(0, TrainerLevelGap.Gap(-3, 0)); + Assert.AreEqual(9, TrainerLevelGap.Gap(0, 10)); + } + + // ── EXP 배율 ── + + [Test] + public void ExpLevelFactor_GrowsLinearlyWithInsectLevel() + { + Assert.AreEqual(1f, TrainerLevelGap.ExpLevelFactor(1), 0.0001f); + Assert.AreEqual(2f, TrainerLevelGap.ExpLevelFactor(21), 0.0001f); + Assert.AreEqual(4f, TrainerLevelGap.ExpLevelFactor(61), 0.0001f); + } + + [TestCase(0, 1.0f)] + [TestCase(5, 1.5f)] + [TestCase(10, 2.0f)] + [TestCase(30, 2.0f)] + [TestCase(-5, 0.5f)] + [TestCase(-8, 0.2f)] + [TestCase(-30, 0.2f)] + public void ExpGapFactor_HigherBonusCapsAtDouble_LowerPenaltyFloorsAtFifth(int gap, float expected) + { + Assert.AreEqual(expected, TrainerLevelGap.ExpGapFactor(40, 40 + gap), 0.0001f); + } + + [Test] + public void ExpMultiplier_HigherInsect_NeverGivesLess() + { + // 요구사항 그대로 — 더 높은 곤충을 이기거나 잡으면 EXP가 더 오른다(같은 캐릭터 레벨 기준). + float previous = 0f; + for (int insect = 1; insect <= 80; insect++) + { + float current = TrainerLevelGap.ExpMultiplier(30, insect); + Assert.GreaterOrEqual(current, previous, $"곤충 Lv{insect}"); + previous = current; + } + } + + // ── 실제 지급식 (InsectRewardCalculator) ── + + [Test] + public void GetExpReward_LevelOneEqualLevel_MatchesBaseReward() + { + Assert.AreEqual(InsectRewardCalculator.GetExpReward(species), + InsectRewardCalculator.GetExpReward(species, insectLevel: 1, trainerLevel: 1)); + } + + [Test] + public void GetExpReward_HigherLevelInsect_GivesMoreExp() + { + int lower = InsectRewardCalculator.GetExpReward(species, insectLevel: 20, trainerLevel: 30); + int equal = InsectRewardCalculator.GetExpReward(species, insectLevel: 30, trainerLevel: 30); + int higher = InsectRewardCalculator.GetExpReward(species, insectLevel: 40, trainerLevel: 30); + + Assert.Less(lower, equal); + Assert.Less(equal, higher); + // 같은 레벨: 5 × (1 + 29×0.05) = 12.25 → 12 + Assert.AreEqual(12, equal); + } + + [Test] + public void GetExpReward_RarityAndLevelCompound() + { + species.rarity = InsectRarity.Legendary; + species.expReward = 24; + // 24 × 2.8 × (1 + 60×0.05) × (1 + 10×0.10) = 67.2 × 4 × 2 = 537.6 + Assert.AreEqual(538, InsectRewardCalculator.GetExpReward(species, insectLevel: 61, trainerLevel: 51)); + } + + [Test] + public void GetExpReward_FarBelowTrainer_StillGivesAtLeastOne() + { + // 5 × 1 × 0.2 = 1 — 바닥 배율에서도 0으로 떨어지지 않는다. + Assert.AreEqual(1, InsectRewardCalculator.GetExpReward(species, insectLevel: 1, trainerLevel: 80)); + } + + [Test] + public void GetExpReward_NullData_IsZero() + { + Assert.AreEqual(0, InsectRewardCalculator.GetExpReward(null, 10, 10)); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/TrainerLevelGapTests.cs.meta b/Assets/Tests/EditMode/TrainerLevelGapTests.cs.meta new file mode 100644 index 0000000..cb90638 --- /dev/null +++ b/Assets/Tests/EditMode/TrainerLevelGapTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3d1208d15f3dcf84084f25c06f49de75 \ No newline at end of file diff --git a/Assets/Tests/EditMode/TrainingPricingTests.cs b/Assets/Tests/EditMode/TrainingPricingTests.cs new file mode 100644 index 0000000..75c7620 --- /dev/null +++ b/Assets/Tests/EditMode/TrainingPricingTests.cs @@ -0,0 +1,186 @@ +#if UNITY_EDITOR +using System; +using InsectGame.Core; +using InsectGame.Data; +using NUnit.Framework; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace InsectGame.Tests +{ + /// + /// — 기술 가격(가치 기준)과 능력치(개체값) 훈련 비용. + /// + /// 예전엔 기술 가격이 power에서만 나와 상태기(위력 칸 1)가 "가장 약한 기술"로 팔렸다 + /// (광폭화 공격 +60% = 26캔디 1회, 파멸의 독침 = 216캔디). 피해기 가격은 그대로 두고 상태기만 + /// 효과로 값을 매기는 것이 설계라, 두 방향을 함께 고정한다. + /// + [TestFixture] + public class TrainingPricingTests + { + private static InsectSkill Skill(SkillEffectType type, int power, float effect = 0.2f, int turns = 2, float accuracy = 1f) + { + InsectSkill s = ScriptableObject.CreateInstance(); + s.skillId = "t_" + type + power; + s.effectType = type; + s.power = power; + s.effectValue = effect; + s.effectDurationTurns = turns; + s.accuracy = accuracy; + return s; + } + + [Test] + public void DamageSkills_KeepOldPowerPricing() + { + // 옛 범용기 공식: 회당 max(5, power/2 + 요구Lv), 횟수 clamp(1 + power/12, 1, 5). + foreach (int power in new[] { 10, 13, 20, 28, 36, 40, 60, 78 }) + foreach (int level in new[] { 1, 7, 16, 34 }) + { + InsectSkill s = Skill(SkillEffectType.Damage, power); + try + { + Assert.AreEqual(Mathf.Max(5, power / 2 + level), TrainingPricing.SessionCost(s, level), $"위력 {power} Lv{level}"); + Assert.AreEqual(Mathf.Clamp(1 + power / 12, 1, 5), TrainingPricing.RequiredSessions(s), $"위력 {power}"); + } + finally { Object.DestroyImmediate(s); } + } + } + + [Test] + public void StatusSkill_IsPricedByEffect_NotByPowerField() + { + // 광폭화(공격 +60% · 2턴, Lv26)와 돌격(위력 20, Lv7) — 옛 가격은 26 대 34로 광폭화가 더 쌌다. + InsectSkill berserk = Skill(SkillEffectType.BuffAttack, 1, 0.6f, 2); + InsectSkill charge = Skill(SkillEffectType.Damage, 20); + try + { + Assert.Greater(TrainingPricing.RequiredSessions(berserk), 1, "상태기도 한 번에 배우면 안 된다"); + Assert.Greater(TrainingPricing.TotalSkillCost(berserk, 26), TrainingPricing.TotalSkillCost(charge, 7) * 3, + "공격 +60% 버프가 위력 20 돌격의 몇 배는 되어야 한다"); + Assert.Greater(TrainingPricing.SkillValue(berserk), 30f); + } + finally + { + Object.DestroyImmediate(berserk); + Object.DestroyImmediate(charge); + } + } + + [Test] + public void EveryEffectType_HasRealValue() + { + // 위력 칸이 1인 효과가 하나라도 1로 읽히면 그 종류만 다시 헐값이 된다. + foreach (SkillEffectType type in Enum.GetValues(typeof(SkillEffectType))) + { + int power = type == SkillEffectType.Damage || type == SkillEffectType.PoisonDot ? 12 : 1; + InsectSkill s = Skill(type, power, 0.3f, 3); + try + { + Assert.GreaterOrEqual(TrainingPricing.SkillValue(s), 12f, $"{type}의 가치가 위력 칸(1)으로 떨어졌다"); + } + finally { Object.DestroyImmediate(s); } + } + } + + [Test] + public void LowerAccuracy_LowersValue() + { + InsectSkill sure = Skill(SkillEffectType.Damage, 42); + InsectSkill risky = Skill(SkillEffectType.Damage, 42, accuracy: 0.9f); + try + { + Assert.Less(TrainingPricing.SkillValue(risky), TrainingPricing.SkillValue(sure)); + } + finally + { + Object.DestroyImmediate(sure); + Object.DestroyImmediate(risky); + } + } + + [Test] + public void LaterUnlock_CostsMore() + { + InsectSkill s = Skill(SkillEffectType.Damage, 26); + try + { + Assert.Greater(TrainingPricing.SessionCost(s, 20), TrainingPricing.SessionCost(s, 5)); + } + finally { Object.DestroyImmediate(s); } + } + + // ── 능력치(개체값) ── + + [Test] + public void StatCost_EscalatesPerIv_AndZeroAtMax() + { + Assert.AreEqual(20, TrainingPricing.StatCost(0, InsectRarity.Common)); + Assert.AreEqual(257, TrainingPricing.StatCost(14, InsectRarity.Common)); + Assert.AreEqual(0, TrainingPricing.StatCost(PlayerInsectData.MaxIV, InsectRarity.Common), "최대면 더 올릴 수 없다"); + for (int iv = 1; iv < PlayerInsectData.MaxIV; iv++) + Assert.Greater(TrainingPricing.StatCost(iv, InsectRarity.Common), TrainingPricing.StatCost(iv - 1, InsectRarity.Common)); + } + + [Test] + public void StatCost_RarerCostsMore_UsingRewardMultiplier() + { + int common = TrainingPricing.StatCost(8, InsectRarity.Common); + int legendary = TrainingPricing.StatCost(8, InsectRarity.Legendary); + Assert.AreEqual(Mathf.RoundToInt(common * InsectRewardCalculator.GetRarityMultiplier(InsectRarity.Legendary)), legendary, 1); + Assert.Greater(TrainingPricing.StatCost(8, InsectRarity.Rare), common); + } + + [Test] + public void AverageCommon_ToGradeS_CostsMoreThanLevelingToFifty() + { + // 설계: 평균 일반 개체(5/5/5)를 S급(14/14/13)으로 ≈ 2,900캔디 — Lv1→50 레벨업 전부(2,548)보다 크다. + // "댓가가 커야 한다"가 요구였다. 계수를 낮추면 이 테스트가 먼저 알린다. + int toS = TrainingPricing.StatCostRange(5, 14, InsectRarity.Common) * 2 + + TrainingPricing.StatCostRange(5, 13, InsectRarity.Common); + int levelTo50 = 0; + for (int lv = 1; lv < 50; lv++) + levelTo50 += GameConstants.Leveling.FallbackBaseCandyCost + (lv - 1) * GameConstants.Leveling.FallbackCandyCostGrowth; + + Assert.Greater(toS, levelTo50); + Assert.That(toS, Is.InRange(2500, 3300), "표준안(20×1.2^IV) 대역을 벗어났다 — balance.md 「훈련 기준점」도 함께 고칠 것"); + } + + [Test] + public void GradeForSum_BoundariesMatchPercentThresholds() + { + Assert.AreEqual(41, PlayerInsectData.MinIvSumFor(IVGrade.S)); + Assert.AreEqual(32, PlayerInsectData.MinIvSumFor(IVGrade.A)); + Assert.AreEqual(23, PlayerInsectData.MinIvSumFor(IVGrade.B)); + Assert.AreEqual(14, PlayerInsectData.MinIvSumFor(IVGrade.C)); + Assert.AreEqual(0, PlayerInsectData.MinIvSumFor(IVGrade.D)); + + var pid = new PlayerInsectData { ivHp = 14, ivAtk = 14, ivDef = 12 }; + Assert.AreEqual(IVGrade.A, pid.Grade); + pid.ivDef = 13; + Assert.AreEqual(IVGrade.S, pid.Grade, "합 41이 S"); + } + + [Test] + public void RaiseIv_CapsAtMax_AndKeepsMissingHp() + { + var pid = new PlayerInsectData { ivHp = 3, ivAtk = PlayerInsectData.MaxIV, currentHp = 50 }; + + Assert.IsTrue(pid.RaiseIv(GrowthStat.Hp)); + Assert.AreEqual(4, pid.ivHp); + Assert.AreEqual(50 + PlayerInsectData.HpPerIv, pid.currentHp, "최대 HP가 는 만큼 현재 HP도 올라야 부상으로 안 뜬다"); + + Assert.IsFalse(pid.RaiseIv(GrowthStat.Attack), "15에서는 더 못 올린다"); + Assert.AreEqual(PlayerInsectData.MaxIV, pid.ivAtk); + + var fainted = new PlayerInsectData { ivHp = 3, currentHp = 0 }; + fainted.RaiseIv(GrowthStat.Hp); + Assert.AreEqual(0, fainted.currentHp, "기절은 훈련으로 풀리지 않는다"); + + var legacy = new PlayerInsectData { ivHp = 3, currentHp = -1 }; + legacy.RaiseIv(GrowthStat.Hp); + Assert.AreEqual(-1, legacy.currentHp, "미초기화 센티넬은 그대로 둔다(로드가 풀피로 채운다)"); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/TrainingPricingTests.cs.meta b/Assets/Tests/EditMode/TrainingPricingTests.cs.meta new file mode 100644 index 0000000..382c173 --- /dev/null +++ b/Assets/Tests/EditMode/TrainingPricingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3019c7569b3047944a95337673a3d277 \ No newline at end of file diff --git a/Assets/Tests/EditMode/TrainingRoundTripTests.cs b/Assets/Tests/EditMode/TrainingRoundTripTests.cs index 7901267..af7d6f3 100644 --- a/Assets/Tests/EditMode/TrainingRoundTripTests.cs +++ b/Assets/Tests/EditMode/TrainingRoundTripTests.cs @@ -188,6 +188,64 @@ public void TrainSkill_DiscMethod_ZeroCost_StillLearns() Assert.AreEqual(0, items.GetCount(disc.itemId)); } + // ── 성장 훈련: 능력치(개체값) ── + + [Test] + public void TrainStat_SpendsEscalatingCandy_AndRaisesGrade() + { + // 13/13/13 = 39 → A. 두 번 올려 41이 되면 S. 컬렉션이 없으면 등급 배율은 일반(1.0). + candy.SetCandies(1000); + var insect = new PlayerInsectData { instanceId = "g", insectId = "test_beetle", level = 10, ivHp = 13, ivAtk = 13, ivDef = 13 }; + int cost13 = TrainingPricing.StatCost(13, InsectRarity.Common); + + Assert.AreEqual(cost13, training.GetStatTrainingCost(insect, GrowthStat.Hp)); + Assert.IsTrue(training.TrainStat(insect, GrowthStat.Hp)); + Assert.AreEqual(14, insect.ivHp); + Assert.AreEqual(1000 - cost13, candy.Candies); + Assert.AreEqual(IVGrade.A, insect.Grade); + + Assert.IsTrue(training.TrainStat(insect, GrowthStat.Attack)); + Assert.AreEqual(IVGrade.A, training.LastStatGradeBefore); + Assert.AreEqual(IVGrade.S, insect.Grade, "능력치를 올리면 등급이 따라 오른다"); + Assert.AreEqual(1000 - cost13 * 2, candy.Candies); + + Assert.Greater(training.GetStatTrainingCost(insect, GrowthStat.Hp), cost13, "올릴수록 비싸진다"); + } + + [Test] + public void TrainStat_AtMax_IsRefusedAndChargesNothing() + { + candy.SetCandies(1000); + var insect = new PlayerInsectData { instanceId = "g", insectId = "test_beetle", level = 10, ivDef = PlayerInsectData.MaxIV }; + + Assert.IsFalse(training.CanTrainStat(insect, GrowthStat.Defense)); + Assert.IsFalse(training.TrainStat(insect, GrowthStat.Defense)); + Assert.AreEqual(1000, candy.Candies); + Assert.AreEqual(PlayerInsectData.MaxIV, insect.ivDef); + } + + [Test] + public void TrainStat_NotEnoughCandy_IsRefused() + { + candy.SetCandies(10); + var insect = new PlayerInsectData { instanceId = "g", insectId = "test_beetle", level = 10, ivAtk = 5 }; + + Assert.IsFalse(training.TrainStat(insect, GrowthStat.Attack)); + Assert.AreEqual(5, insect.ivAtk); + Assert.AreEqual(10, candy.Candies); + } + + [Test] + public void TrainLevel_WithoutCollection_IsRefused() + { + // 레벨 가격의 정본은 컬렉션이다 — 배선이 없으면 값을 지어내지 않고 거부한다. + var insect = InsectAtLevel(5); + Assert.IsFalse(training.CanTrainLevel(insect)); + Assert.IsFalse(training.TrainLevel(insect)); + Assert.AreEqual(5, insect.level); + Assert.AreEqual(100, candy.Candies); + } + [Test] public void TrainSkill_DiscMethod_WithoutDisc_IsRefused() { diff --git a/Assets/Tests/EditMode/TutorialQuestOrderTests.cs b/Assets/Tests/EditMode/TutorialQuestOrderTests.cs index 60219fb..5c09430 100644 --- a/Assets/Tests/EditMode/TutorialQuestOrderTests.cs +++ b/Assets/Tests/EditMode/TutorialQuestOrderTests.cs @@ -97,6 +97,179 @@ public void CollectBackfillTargets_NullInput_IsSafe() Assert.AreEqual(0, TutorialQuestOrder.CollectBackfillTargets(Chain, null).Count); } + // ── 미리 세기 ── + // + // 예전엔 활성 퀘스트만 진행을 셌다. 도감을 여는 퀘스트 중에 잡은 세 마리는 "3마리 포획"에 + // 들어가지 않아 차례가 오면 다시 잡아야 했다. 지금은 아직 차례가 안 온 스토리 퀘스트에도 센다. + + private static TutorialQuest Typed(string id, QuestType type, int target = 1, + QuestCategory category = QuestCategory.Story) + { + return new TutorialQuest { questId = id, type = type, targetCount = target, category = category }; + } + + private static readonly TutorialQuest[] BankChain = + { + Typed("q_approach", QuestType.Capture), + Typed("q_dex", QuestType.OpenDex, 1, QuestCategory.Side), + Typed("q_capture3", QuestType.Capture, 3), + Typed("q_levelup", QuestType.LevelUp), + Typed("q_battle", QuestType.Battle), + Typed("q_battle3", QuestType.Battle, 3), + Typed("q_capture_rare", QuestType.CaptureRare), + Typed("q_blight_first", QuestType.CleanseBlight), + Typed("q_blight_both", QuestType.CleanseBlight), + Typed("s_capture_wild", QuestType.Capture, 5, QuestCategory.Side), + }; + + private static List BankIds(string active, QuestType action, + InsectGame.Data.InsectRarity rarity, params string[] done) + { + var into = new List(); + TutorialQuestOrder.CollectBankTargets(BankChain, Done(done), active, action, rarity, into); + return into.ConvertAll(q => q.questId); + } + + [Test] + public void CollectBankTargets_Capture_CountsTowardEveryUpcomingCaptureQuest_ButNotTheActiveOne() + { + // 활성 퀘스트는 원래 경로가 올린다 — 여기서도 세면 한 번 잡고 두 번 올라간다. + List ids = BankIds("q_approach", QuestType.Capture, InsectGame.Data.InsectRarity.Common); + CollectionAssert.AreEqual(new[] { "q_capture3" }, ids); + } + + [Test] + public void CollectBankTargets_UncommonCapture_AlsoCountsTowardRareCaptureQuest() + { + List ids = BankIds("q_approach", QuestType.Capture, InsectGame.Data.InsectRarity.Uncommon); + CollectionAssert.AreEqual(new[] { "q_capture3", "q_capture_rare" }, ids); + } + + [Test] + public void CollectBankTargets_CompletedAndSideQuests_AreSkipped() + { + // 서브(s_capture_wild)는 자기 경로로 세므로 대상이 아니고, 끝난 퀘스트는 더 올릴 것이 없다. + List ids = BankIds("q_levelup", QuestType.Capture, InsectGame.Data.InsectRarity.Common, + "q_approach", "q_capture3"); + Assert.AreEqual(0, ids.Count); + } + + [Test] + public void CollectBankTargets_BattleWhileDoingSomethingElse_IsKeptForLater() + { + List ids = BankIds("q_capture3", QuestType.Battle, InsectGame.Data.InsectRarity.Common, "q_approach"); + CollectionAssert.AreEqual(new[] { "q_battle", "q_battle3" }, ids); + } + + [Test] + public void CollectBankTargets_BlightCleanse_IsNeverBanked() + { + // 「하나 무너뜨리기」와 「하나 더」가 이어져 있다 — 미리 세면 첫 정화 하나가 둘을 한꺼번에 깬다. + Assert.IsFalse(TutorialQuestOrder.IsBankable(QuestType.CleanseBlight)); + Assert.AreEqual(0, BankIds(null, QuestType.CleanseBlight, InsectGame.Data.InsectRarity.Common).Count); + } + + [TestCase(QuestType.Movement)] + [TestCase(QuestType.VisitRegion)] + [TestCase(QuestType.VisitSubArea)] + [TestCase(QuestType.TalkToElder)] + [TestCase(QuestType.DefeatGuardian)] + [TestCase(QuestType.SetTeam)] + public void IsBankable_MomentBoundGoals_AreNot(QuestType type) + { + // 그 퀘스트가 가리키는 순간의 행동이어야 하는 것들 — 초원에 들어간 것이 "연못에 가 보세요"를 깨면 안 된다. + Assert.IsFalse(TutorialQuestOrder.IsBankable(type)); + } + + [Test] + public void CountsToward_CaptureRarity_MatchesOnlyThatRarity() + { + var pack = new TutorialQuest + { + questId = "pack", type = QuestType.CaptureRarity, + requiredRarity = InsectGame.Data.InsectRarity.Rare + }; + Assert.IsTrue(TutorialQuestOrder.CountsToward(pack, QuestType.Capture, InsectGame.Data.InsectRarity.Rare)); + Assert.IsFalse(TutorialQuestOrder.CountsToward(pack, QuestType.Capture, InsectGame.Data.InsectRarity.Epic)); + } + + [Test] + public void CollectBankTargets_NullInput_IsSafe() + { + var into = new List { Typed("stale", QuestType.Capture) }; + TutorialQuestOrder.CollectBankTargets(null, Done(), null, QuestType.Capture, + InsectGame.Data.InsectRarity.Common, into); + Assert.AreEqual(0, into.Count, "이전 호출의 대상이 남으면 엉뚱한 퀘스트가 올라간다"); + } + + // ── 실제 스토리 체인 ── + // + // 수문장 전까지는 "하는 일"만 남긴다. 창을 한 번 열면 끝나는 과제가 체인에 다시 끼면 첫 전투가 + // 뒤로 밀린다(예전엔 9번째였다). 순서 자체도 고정한다 — 남은 퀘스트의 **상대 순서**가 바뀌면 + // 소급 완료가 기존 세이브에서 아직 할 차례인 퀘스트를 보상 없이 삼킨다. + + private static TutorialQuest[] RealQuests() + { + var host = new UnityEngine.GameObject("QuestOrderProbe"); + try + { + var mgr = host.AddComponent(); + typeof(TutorialQuestManager).GetMethod("Initialize", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).Invoke(mgr, null); + return mgr.GetAllQuests(); + } + finally { UnityEngine.Object.DestroyImmediate(host); } + } + + [Test] + public void RealChain_UpToFirstGuardian_IsActionsOnly_InTheOldRelativeOrder() + { + var story = new List(); + foreach (TutorialQuest q in RealQuests()) + { + if (q.category != QuestCategory.Story) continue; + story.Add(q.questId); + if (q.questId == "q_guardian1") break; + } + + CollectionAssert.AreEqual(new[] + { + "q_move", "q_talk_elder", "q_approach", "q_capture3", "q_levelup", + "q_battle", "q_battle3", "q_capture_rare", "q_guardian1", + }, story); + } + + [TestCase("q_collection")] + [TestCase("q_dex")] + [TestCase("q_equip")] + [TestCase("q_item")] + [TestCase("q_training")] + [TestCase("q_team")] + public void RealChain_LookAroundChores_AreOptionalOneShotSideQuests(string questId) + { + TutorialQuest quest = System.Array.Find(RealQuests(), q => q.questId == questId); + + Assert.IsNotNull(quest, "ID를 지우면 그 퀘스트를 깬 세이브의 완료 기록이 떠돈다"); + Assert.AreEqual(QuestCategory.Side, quest.category); + Assert.IsFalse(quest.repeatable); + Assert.IsFalse(string.IsNullOrEmpty(quest.prerequisiteQuestId), + "선행이 없으면 첫 포획도 하기 전에 서브 목록이 과제로 찬다"); + } + + [Test] + public void RealChain_EveryStoryPrerequisite_IsAStoryQuest() + { + // 스토리가 서브(둘러보기) 과제를 선행으로 물면 그 과제를 안 한 사람의 본편이 멈춘다. + TutorialQuest[] quests = RealQuests(); + foreach (TutorialQuest q in quests) + { + if (q.category != QuestCategory.Story || string.IsNullOrEmpty(q.prerequisiteQuestId)) continue; + TutorialQuest prereq = System.Array.Find(quests, p => p.questId == q.prerequisiteQuestId); + Assert.IsNotNull(prereq, $"{q.questId}: 선행 {q.prerequisiteQuestId} 없음"); + Assert.AreEqual(QuestCategory.Story, prereq.category, $"{q.questId}의 선행 {prereq.questId}가 서브다"); + } + } + // ── 이동 퀘스트 진행 규칙 ── // // 옛 판정은 `한 프레임 이동량 > 1m` 하나였다. 플레이어 속도는 8m/s(의상 보정 최대 ×2)라 diff --git a/Assets/Tests/EditMode/UIParityTests.cs b/Assets/Tests/EditMode/UIParityTests.cs new file mode 100644 index 0000000..3cffe14 --- /dev/null +++ b/Assets/Tests/EditMode/UIParityTests.cs @@ -0,0 +1,55 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using InsectGame.Data; +using InsectGame.UI; +using NUnit.Framework; + +namespace InsectGame.Tests +{ + /// + /// 화면 간 표기·색 기준이 한 출처에서 나오는지 고정한다(2026-09-30 도감·필드·배틀팀 품질 맞춤). + /// 배틀팀·보유 곤충·포획 창이 enum 이름("Rare")을 그대로 찍어 도감의 "희귀"와 섞여 나왔다. + /// + [TestFixture] + public class UIParityTests + { + [Test] + public void InsectRarityKorean_EveryRarity_HasDistinctHangulLabel() + { + var seen = new HashSet(); + foreach (InsectRarity rarity in Enum.GetValues(typeof(InsectRarity))) + { + string label = rarity.Korean(); + Assert.IsFalse(string.IsNullOrEmpty(label), rarity.ToString()); + foreach (char c in label) + Assert.IsTrue(c >= '가' && c <= '힣', $"{rarity} → \"{label}\"에 한글 아닌 글자"); + Assert.IsTrue(seen.Add(label), $"{rarity} 표기 \"{label}\"가 다른 등급과 겹친다"); + } + } + + [Test] + public void InsectRarityKorean_MatchesDexVocabulary() + { + // 도감이 원래 쓰던 표 — 이 단어들이 게임 전체의 표기다. + Assert.AreEqual("일반", InsectRarity.Common.Korean()); + Assert.AreEqual("고급", InsectRarity.Uncommon.Korean()); + Assert.AreEqual("희귀", InsectRarity.Rare.Korean()); + Assert.AreEqual("영웅", InsectRarity.Epic.Korean()); + Assert.AreEqual("전설", InsectRarity.Legendary.Korean()); + } + + [Test] + public void GetHpColor_Thresholds_MintAmberCoral() + { + UITheme t = UITheme.Instance; + Assert.AreEqual(t.accentMint, t.GetHpColor(1f)); + Assert.AreEqual(t.accentMint, t.GetHpColor(0.51f)); + Assert.AreEqual(t.accentAmber, t.GetHpColor(0.5f)); + Assert.AreEqual(t.accentAmber, t.GetHpColor(0.21f)); + Assert.AreEqual(t.accentCoral, t.GetHpColor(0.2f)); + Assert.AreEqual(t.accentCoral, t.GetHpColor(0f)); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/UIParityTests.cs.meta b/Assets/Tests/EditMode/UIParityTests.cs.meta new file mode 100644 index 0000000..6b83b94 --- /dev/null +++ b/Assets/Tests/EditMode/UIParityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d1292b3c149e8994fa579b8c9956b284 \ No newline at end of file diff --git a/Assets/Tests/EditMode/WeatherForecastTests.cs b/Assets/Tests/EditMode/WeatherForecastTests.cs new file mode 100644 index 0000000..30cd465 --- /dev/null +++ b/Assets/Tests/EditMode/WeatherForecastTests.cs @@ -0,0 +1,260 @@ +#if UNITY_EDITOR +using System; +using InsectGame.Core; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 날씨 예보()와 시계 시간대() — 순수 규칙. + /// 난수는 0~1을 고르게 훑어 넣는다(결정적). + /// + [TestFixture] + public class WeatherForecastTests + { + private const int Sweep = 20000; + + private static WeatherType[] AllWeather() + => (WeatherType[])Enum.GetValues(typeof(WeatherType)); + + [Test] + public void WeatherType_Snow_IsAppendedAfterTheOriginalFour() + { + // InsectSpawnCondition.allowedWeather가 정수로 직렬화된다 — 앞 값이 밀리면 저장된 조건이 다른 날씨를 가리킨다. + Assert.AreEqual(0, (int)WeatherType.Clear); + Assert.AreEqual(1, (int)WeatherType.Rain); + Assert.AreEqual(2, (int)WeatherType.Fog); + Assert.AreEqual(3, (int)WeatherType.Wind); + Assert.AreEqual(4, (int)WeatherType.Snow); + Assert.AreEqual(WeatherForecast.WeatherCount, AllWeather().Length, "예보 표가 모든 날씨를 센다"); + } + + [Test] + public void Next_NeverRepeatsTheCurrentWeather() + { + foreach (WeatherType current in AllWeather()) + for (int i = 0; i < Sweep; i++) + Assert.AreNotEqual(current, WeatherForecast.Next(current, (i + 0.5f) / Sweep), + $"{current} 다음에 같은 날씨"); + } + + [Test] + public void Next_ReachesEveryOtherWeather() + { + foreach (WeatherType current in AllWeather()) + { + var seen = new bool[WeatherForecast.WeatherCount]; + for (int i = 0; i < Sweep; i++) seen[(int)WeatherForecast.Next(current, (i + 0.5f) / Sweep)] = true; + foreach (WeatherType w in AllWeather()) + if (w != current) Assert.IsTrue(seen[(int)w], $"{current} 다음에 {w}가 영영 안 나온다"); + } + } + + [Test] + public void Next_AfterBadWeather_FavorsClear() + { + // 궂은 날씨 뒤의 맑음 몫이 키워져 있어야 줄줄이 이어지지 않는다 — 같은 표로 맑음을 키우지 않은 값보다 크다. + int clear = 0; + for (int i = 0; i < Sweep; i++) + if (WeatherForecast.Next(WeatherType.Rain, (i + 0.5f) / Sweep) == WeatherType.Clear) clear++; + + float others = WeatherForecast.FogShare + WeatherForecast.WindShare + WeatherForecast.SnowShare; + float plain = WeatherForecast.ClearShare / (WeatherForecast.ClearShare + others); + Assert.Greater(clear / (float)Sweep, plain + 0.02f); + } + + [Test] + public void Next_RollExtremes_StayInsideTheTable() + { + foreach (WeatherType current in AllWeather()) + { + Assert.AreNotEqual(current, WeatherForecast.Next(current, 0f)); + Assert.AreNotEqual(current, WeatherForecast.Next(current, 1f)); + Assert.AreNotEqual(current, WeatherForecast.Next(current, -3f), "범위 밖 난수는 clamp"); + Assert.AreNotEqual(current, WeatherForecast.Next(current, 9f)); + } + } + + [Test] + public void RollDuration_ClearIsLongerThanBadWeather_AndStaysInBand() + { + Assert.AreEqual(WeatherForecast.ClearDurationMin, WeatherForecast.RollDuration(WeatherType.Clear, 0f), 0.001f); + Assert.AreEqual(WeatherForecast.ClearDurationMax, WeatherForecast.RollDuration(WeatherType.Clear, 1f), 0.001f); + foreach (WeatherType w in AllWeather()) + { + if (w == WeatherType.Clear) continue; + Assert.AreEqual(WeatherForecast.BadDurationMin, WeatherForecast.RollDuration(w, 0f), 0.001f); + Assert.AreEqual(WeatherForecast.BadDurationMax, WeatherForecast.RollDuration(w, 1f), 0.001f); + } + Assert.Less(WeatherForecast.BadDurationMax, WeatherForecast.ClearDurationMax, "궂은 날씨가 맑음보다 짧다"); + } + + [Test] + public void Transition_FitsInsideTheShortestWeather() + { + // 전환이 끝나기 전에 다음 날씨로 넘어가면 하늘이 한 번도 제 모습이 못 된다. + Assert.Less(WeatherForecast.TransitionSeconds * 3f, WeatherForecast.BadDurationMin); + } + + [TestCase("frostline")] + [TestCase("mountain")] + public void EffectiveIn_ColdRegion_TurnsRainIntoSnow(string region) + { + Assert.AreEqual(WeatherType.Snow, WeatherForecast.EffectiveIn(WeatherType.Rain, region)); + Assert.AreEqual(WeatherType.Snow, WeatherForecast.EffectiveIn(WeatherType.Snow, region)); + Assert.AreEqual(WeatherType.Clear, WeatherForecast.EffectiveIn(WeatherType.Clear, region)); + Assert.AreEqual(WeatherType.Fog, WeatherForecast.EffectiveIn(WeatherType.Fog, region)); + Assert.AreEqual(WeatherType.Wind, WeatherForecast.EffectiveIn(WeatherType.Wind, region)); + } + + [TestCase("dunes")] + [TestCase("emberfall")] + public void EffectiveIn_WarmRegion_NeverSnows(string region) + { + Assert.AreEqual(WeatherType.Wind, WeatherForecast.EffectiveIn(WeatherType.Snow, region)); + Assert.AreEqual(WeatherType.Rain, WeatherForecast.EffectiveIn(WeatherType.Rain, region), "비는 그대로"); + } + + [TestCase("meadow")] + [TestCase("pond")] + [TestCase("forest")] + [TestCase("swamp")] + [TestCase("garden")] + [TestCase("ruins")] + [TestCase("hollow")] + [TestCase("canopy")] + [TestCase("nameless")] + [TestCase(null)] + [TestCase("")] + public void EffectiveIn_TemperateOrUnknown_KeepsTheWorldWeather(string region) + { + foreach (WeatherType w in AllWeather()) + Assert.AreEqual(w, WeatherForecast.EffectiveIn(w, region), $"{region ?? "null"}에서 {w}"); + } + + [Test] + public void EffectiveIn_OnlyColdAndWarmRegions_AreClimateRegions() + { + // 기후 표의 지역 ID가 실제 리전 정의에 있어야 한다 — 오타면 그 지역은 조용히 온대가 된다. + string defs = System.IO.File.ReadAllText( + System.IO.Path.Combine(Application.dataPath, "Scripts/Core/RegionDefinitions.cs")); + foreach (string id in new[] { "frostline", "mountain", "dunes", "emberfall" }) + StringAssert.Contains($"regionId = \"{id}\"", defs, $"기후 표의 {id}가 리전 정의에 없다"); + } + + [Test] + public void DisplayName_CoversEveryWeatherAndPhase() + { + foreach (WeatherType w in AllWeather()) + { + string name = WeatherForecast.DisplayName(w); + Assert.IsFalse(string.IsNullOrEmpty(name), $"{w} 이름"); + } + Assert.AreEqual("눈", WeatherForecast.DisplayName(WeatherType.Snow)); + foreach (DayPhase p in Enum.GetValues(typeof(DayPhase))) + Assert.IsFalse(string.IsNullOrEmpty(WeatherForecast.DisplayName(p)), $"{p} 이름"); + } + + // ── 시계 ── + + [TestCase(0f, DayPhase.Night)] + [TestCase(5.99f, DayPhase.Night)] + [TestCase(6f, DayPhase.Morning)] + [TestCase(10.99f, DayPhase.Morning)] + [TestCase(11f, DayPhase.Day)] + [TestCase(16.99f, DayPhase.Day)] + [TestCase(17f, DayPhase.Evening)] + [TestCase(20.99f, DayPhase.Evening)] + [TestCase(21f, DayPhase.Night)] + [TestCase(23.99f, DayPhase.Night)] + public void PhaseOfHour_Boundaries(float hour, DayPhase expected) + { + Assert.AreEqual(expected, GameClock.PhaseOfHour(hour)); + } + + [Test] + public void Night_IsAboutAThirdOfTheDay() + { + // 밤이 너무 길면 낮 곤충을 못 만나는 시간이 길어진다 — 9시간(37.5%)이 기준이다. + int night = 0; + for (int i = 0; i < Sweep; i++) + if (GameClock.PhaseOfHour(i / (float)Sweep * 24f) == DayPhase.Night) night++; + Assert.AreEqual(0.375f, night / (float)Sweep, 0.01f); + } + + [Test] + public void GameClock_SetTime01_ChangesPhaseAndNotifies() + { + var go = new GameObject("ClockTest"); + try + { + var clock = go.AddComponent(); + var seen = new System.Collections.Generic.List(); + clock.DayPhaseChanged += seen.Add; + + clock.SetTime01(0.5f, hold: true); // 정오 — 처음(아침)과 다르다 + Assert.AreEqual(DayPhase.Day, clock.GetDayPhase()); + clock.SetTime01(0.5f, hold: true); // 같은 시간대 — 알리지 않는다 + clock.SetTime01(23f / 24f, hold: true); + clock.SetTime01(1.25f, hold: true); // 범위 밖은 감아서 06시 + Assert.AreEqual(DayPhase.Morning, clock.GetDayPhase()); + + CollectionAssert.AreEqual(new[] { DayPhase.Day, DayPhase.Night, DayPhase.Morning }, seen); + } + finally { UnityEngine.Object.DestroyImmediate(go); } + } + + [Test] + public void WorldStateProvider_RegionalState_UsesTheRegionClimate() + { + var go = new GameObject("WorldStateTest"); + try + { + var weather = go.AddComponent(); + var clock = go.AddComponent(); + var provider = go.AddComponent(); + provider.AutoWire(clock, weather); + + weather.SetWeather(WeatherType.Rain, hold: true, instant: true); + Assert.AreEqual(WeatherType.Rain, provider.GetWorldState().Weather, "지역을 모르면 세계 날씨"); + Assert.AreEqual(WeatherType.Rain, provider.GetWorldState("meadow").Weather); + Assert.AreEqual(WeatherType.Snow, provider.GetWorldState("frostline").Weather, "설산에서는 비가 눈"); + + weather.SetWeather(WeatherType.Snow, hold: true, instant: true); + Assert.AreEqual(WeatherType.Wind, provider.GetWorldState("dunes").Weather, "사막에는 눈이 안 온다"); + } + finally { UnityEngine.Object.DestroyImmediate(go); } + } + + [Test] + public void WeatherSystem_SetWeather_HoldsAndReportsTransitions() + { + var go = new GameObject("WeatherTest"); + try + { + var weather = go.AddComponent(); + var changes = new System.Collections.Generic.List<(WeatherType, WeatherType)>(); + weather.WeatherChanged += (a, b) => changes.Add((a, b)); + + Assert.AreEqual(WeatherType.Clear, weather.CurrentWeather, "처음은 맑음"); + Assert.AreEqual(1f, weather.Blend01, 0.0001f); + + weather.SetWeather(WeatherType.Fog, hold: true); + Assert.AreEqual(WeatherType.Fog, weather.CurrentWeather, "스폰·전투는 전환을 기다리지 않는다"); + Assert.AreEqual(WeatherType.Clear, weather.PreviousWeather); + Assert.AreEqual(0f, weather.Blend01, 0.0001f, "하늘은 이제 옮겨 간다"); + + weather.SetWeather(WeatherType.Fog, hold: true, instant: true); // 같은 날씨 — 알리지 않고 전환만 끝낸다 + Assert.AreEqual(1f, weather.Blend01, 0.0001f); + + weather.SetWeather(WeatherType.Snow, hold: true, instant: true); + Assert.AreEqual(1f, weather.Blend01, 0.0001f); + + CollectionAssert.AreEqual(new[] { (WeatherType.Clear, WeatherType.Fog), (WeatherType.Fog, WeatherType.Snow) }, changes); + } + finally { UnityEngine.Object.DestroyImmediate(go); } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/WeatherForecastTests.cs.meta b/Assets/Tests/EditMode/WeatherForecastTests.cs.meta new file mode 100644 index 0000000..c3282dd --- /dev/null +++ b/Assets/Tests/EditMode/WeatherForecastTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e77ead16577dbf44b81ce2e2e45c347a \ No newline at end of file diff --git a/Assets/Tests/EditMode/WorldClockHudTests.cs b/Assets/Tests/EditMode/WorldClockHudTests.cs new file mode 100644 index 0000000..4fe12a9 --- /dev/null +++ b/Assets/Tests/EditMode/WorldClockHudTests.cs @@ -0,0 +1,718 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using InsectGame.Battle; +using InsectGame.Core; +using InsectGame.Data; +using InsectGame.UI; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 시각·날씨 칩()의 순수 규칙 — 시각 표기, 알림 문구 고르기, 칩·알림의 자리(필드·나의 섬), + /// 그리고 같은 하늘을 전투 화면에 옮긴 낮·밤·날씨 보정 칩()의 자리·문구. + /// 그리기(IMGUI)는 검증할 수 없으니 판정만 고정한다. 화면은 QA 빌드로 눈으로 본다. + /// + [TestFixture] + public class WorldClockHudTests + { + private static readonly string[] Regions = { null, "meadow", "pond", "frostline", "mountain", "dunes", "emberfall" }; + + private static WeatherType[] AllWeathers => (WeatherType[])Enum.GetValues(typeof(WeatherType)); + private static DayPhase[] AllPhases => (DayPhase[])Enum.GetValues(typeof(DayPhase)); + + private static int ToMinutes(string hhmm) + { + Assert.IsTrue(Regex.IsMatch(hhmm, @"^([01]\d|2[0-3]):[0-5]0$"), "형식이 어긋났다: " + hhmm); + return int.Parse(hhmm.Substring(0, 2)) * 60 + int.Parse(hhmm.Substring(3, 2)); + } + + // ── 시각 표기 ── + + [TestCase(0f, "00:00")] + [TestCase(6f, "06:00")] + [TestCase(11f, "11:00")] + [TestCase(12f, "12:00")] + [TestCase(17f, "17:00")] + [TestCase(21f, "21:00")] + [TestCase(22.25f, "22:10")] // 22:15 → 10분 칸으로 내림 + [TestCase(22.17f, "22:10")] // 22:10.2 + [TestCase(22.16f, "22:00")] // 22:09.6 — 반올림이면 22:10이다. 내림이라 22:00 + [TestCase(9.99f, "09:50")] + [TestCase(23.99f, "23:50")] + [TestCase(23.9999f, "23:50")] + [TestCase(0.16f, "00:00")] + [TestCase(0.17f, "00:10")] + [TestCase(24f, "00:00")] // 하루가 감긴다 — 24:00은 없다 + [TestCase(48.5f, "00:30")] + [TestCase(-1f, "23:00")] + public void FormatClock_KnownHours(float hour, string expected) + { + Assert.AreEqual(expected, WorldClockRules.FormatClock(hour)); + } + + [Test] + public void FormatClock_WholeDay_IsWellFormedAndNeverGoesBackwards() + { + int previous = -1; + for (int i = 0; i < 2400; i++) + { + int minutes = ToMinutes(WorldClockRules.FormatClock(i * 0.01f)); + Assert.GreaterOrEqual(minutes, previous, $"{i * 0.01f}시에 시각이 뒤로 갔다"); + previous = minutes; + } + Assert.AreEqual(23 * 60 + 50, previous, "하루의 마지막 칸은 23:50이다"); + } + + [Test] + public void ClockStepIndex_CoversTheDayInTenMinuteSteps() + { + Assert.AreEqual(144, WorldClockRules.ClockStepsPerDay); + Assert.AreEqual(0, WorldClockRules.ClockStepIndex(0f)); + Assert.AreEqual(143, WorldClockRules.ClockStepIndex(23.99f)); + Assert.AreEqual(0, WorldClockRules.ClockStepIndex(24f), "정각 24시는 다음 날 0시다"); + Assert.AreEqual(WorldClockRules.ClockStepIndex(5.01f), WorldClockRules.ClockStepIndex(29.01f), "하루 뒤도 같은 칸"); + for (int i = 0; i < 2400; i++) + { + int step = WorldClockRules.ClockStepIndex(i * 0.01f); + Assert.That(step, Is.InRange(0, 143)); + } + } + + // ── 시간대 이름 ── + + [TestCase(6f, "아침 06:00")] + [TestCase(10.99f, "아침 10:50")] + [TestCase(11f, "낮 11:00")] + [TestCase(16.99f, "낮 16:50")] + [TestCase(17f, "저녁 17:00")] + [TestCase(20.99f, "저녁 20:50")] + [TestCase(21f, "밤 21:00")] + [TestCase(22.17f, "밤 22:10")] + [TestCase(0f, "밤 00:00")] + [TestCase(5.99f, "밤 05:50")] + public void TimeLabel_NamesThePhaseThenTheTime(float hour, string expected) + { + Assert.AreEqual(expected, WorldClockRules.TimeLabel(hour)); + } + + [Test] + public void TimeLabel_ShownTimeNeverContradictsThePhaseName() + { + // 반올림했다면 20:55가 "저녁 21:00"으로 보였을 것이다. 내림이라 이름과 시각은 정시 경계에서 함께 바뀐다. + for (int i = 0; i < 24 * 60; i++) + { + float hour = i / 60f + 0.005f; // 그 분의 18초 지점 + string[] parts = WorldClockRules.TimeLabel(hour).Split(' '); + Assert.AreEqual(2, parts.Length); + + int shown = ToMinutes(parts[1]); + int real = Mathf.FloorToInt(hour * 60f); + Assert.LessOrEqual(shown, real, parts[1]); + Assert.Less(real - shown, WorldClockRules.ClockStepMinutes, parts[1]); + + DayPhase realPhase = GameClock.PhaseOfHour(hour); + Assert.AreEqual(WeatherForecast.DisplayName(realPhase), parts[0]); + Assert.AreEqual(realPhase, GameClock.PhaseOfHour(shown / 60f), "표시된 시각이 다른 시간대에 속한다: " + parts[1]); + } + } + + // ── 알림: 시간대 ── + + [TestCase(DayPhase.Day, DayPhase.Evening, "해가 저물어 갑니다")] + [TestCase(DayPhase.Evening, DayPhase.Night, "밤이 되었습니다 — 야행성 곤충이 깨어납니다")] + [TestCase(DayPhase.Night, DayPhase.Morning, "아침이 밝았습니다")] + public void ChooseNotice_PhaseChange_SaysTheNewPhase(DayPhase from, DayPhase to, string text) + { + ClockNotice notice = WorldClockRules.ChooseNotice( + new SkyState(from, WeatherType.Clear), new SkyState(to, WeatherType.Clear), "meadow", false); + Assert.IsTrue(notice.HasValue); + Assert.AreEqual(text, notice.Text); + Assert.IsTrue(notice.AboutPhase); + Assert.AreEqual(to, notice.Phase); + } + + [Test] + public void ChooseNotice_MorningToDay_SaysDaytime() + { + ClockNotice notice = WorldClockRules.ChooseNotice( + new SkyState(DayPhase.Morning, WeatherType.Clear), new SkyState(DayPhase.Day, WeatherType.Clear), null, false); + StringAssert.Contains("낮", notice.Text); + } + + [Test] + public void ChooseNotice_PhaseChange_IsTheSameInEveryRegion() + { + // 시간대는 어느 지역에서나 같다 — 기후는 날씨에만 걸린다. + foreach (string region in Regions) + { + ClockNotice notice = WorldClockRules.ChooseNotice( + new SkyState(DayPhase.Day, WeatherType.Rain), new SkyState(DayPhase.Evening, WeatherType.Rain), region, false); + Assert.AreEqual(WorldClockRules.PhaseNotice(DayPhase.Evening), notice.Text, region ?? "null"); + } + } + + [Test] + public void ChooseNotice_PhaseWinsWhenPhaseAndWeatherChangeTogether() + { + ClockNotice notice = WorldClockRules.ChooseNotice( + new SkyState(DayPhase.Day, WeatherType.Clear), new SkyState(DayPhase.Evening, WeatherType.Rain), "meadow", false); + Assert.IsTrue(notice.AboutPhase); + Assert.AreEqual(WorldClockRules.PhaseNotice(DayPhase.Evening), notice.Text); + Assert.AreEqual(WeatherType.Rain, notice.Weather, "색을 고르는 쪽이 새 날씨도 볼 수 있다"); + } + + // ── 알림: 날씨 ── + + [TestCase(WeatherType.Clear, WeatherType.Rain, "meadow", "비가 내리기 시작합니다")] + [TestCase(WeatherType.Clear, WeatherType.Rain, null, "비가 내리기 시작합니다")] + [TestCase(WeatherType.Clear, WeatherType.Snow, "meadow", "눈이 내리기 시작합니다")] + [TestCase(WeatherType.Rain, WeatherType.Fog, "meadow", "안개가 끼기 시작합니다")] + [TestCase(WeatherType.Fog, WeatherType.Wind, "pond", "센바람이 불기 시작합니다")] + [TestCase(WeatherType.Rain, WeatherType.Clear, "meadow", "비가 그쳤습니다")] + [TestCase(WeatherType.Snow, WeatherType.Clear, "meadow", "눈이 그쳤습니다")] + [TestCase(WeatherType.Fog, WeatherType.Clear, "meadow", "안개가 걷혔습니다")] + [TestCase(WeatherType.Wind, WeatherType.Clear, "meadow", "바람이 잦아들었습니다")] + public void ChooseNotice_WeatherChange_SaysWhatHappened(WeatherType from, WeatherType to, string region, string text) + { + ClockNotice notice = WorldClockRules.ChooseNotice( + new SkyState(DayPhase.Day, from), new SkyState(DayPhase.Day, to), region, false); + Assert.IsTrue(notice.HasValue); + Assert.AreEqual(text, notice.Text); + Assert.IsFalse(notice.AboutPhase); + Assert.AreEqual(to, notice.Weather); + } + + [TestCase("frostline")] + [TestCase("mountain")] + public void ChooseNotice_ColdRegion_RainIsSnow(string region) + { + ClockNotice start = WorldClockRules.ChooseNotice( + new SkyState(DayPhase.Day, WeatherType.Clear), new SkyState(DayPhase.Day, WeatherType.Rain), region, false); + Assert.AreEqual("눈이 내리기 시작합니다", start.Text, "세계는 비가 와도 이 지역에선 눈이다"); + Assert.AreEqual(WeatherType.Snow, start.Weather); + + ClockNotice stop = WorldClockRules.ChooseNotice( + new SkyState(DayPhase.Day, WeatherType.Rain), new SkyState(DayPhase.Day, WeatherType.Clear), region, false); + Assert.AreEqual("눈이 그쳤습니다", stop.Text); + } + + [TestCase("frostline")] + [TestCase("mountain")] + public void ChooseNotice_ColdRegion_RainToSnow_IsNotAChange(string region) + { + // 둘 다 "눈"으로 보인다 — 알릴 것이 없다. + Assert.IsFalse(WorldClockRules.ChooseNotice( + new SkyState(DayPhase.Day, WeatherType.Rain), new SkyState(DayPhase.Day, WeatherType.Snow), region, false).HasValue); + Assert.IsFalse(WorldClockRules.ChooseNotice( + new SkyState(DayPhase.Day, WeatherType.Snow), new SkyState(DayPhase.Day, WeatherType.Rain), region, false).HasValue); + } + + [TestCase("dunes")] + [TestCase("emberfall")] + public void ChooseNotice_WarmRegion_SnowIsWind(string region) + { + ClockNotice start = WorldClockRules.ChooseNotice( + new SkyState(DayPhase.Day, WeatherType.Clear), new SkyState(DayPhase.Day, WeatherType.Snow), region, false); + Assert.AreEqual("센바람이 불기 시작합니다", start.Text, "눈이 오지 않는 지역이다"); + Assert.AreEqual(WeatherType.Wind, start.Weather); + + // 사막에서 센바람 ↔ 눈은 둘 다 센바람이다. + Assert.IsFalse(WorldClockRules.ChooseNotice( + new SkyState(DayPhase.Day, WeatherType.Wind), new SkyState(DayPhase.Day, WeatherType.Snow), region, false).HasValue); + Assert.IsFalse(WorldClockRules.ChooseNotice( + new SkyState(DayPhase.Day, WeatherType.Snow), new SkyState(DayPhase.Day, WeatherType.Wind), region, false).HasValue); + } + + [Test] + public void ChooseNotice_NoticeAppears_ExactlyWhenTheVisibleWeatherChanges() + { + // 모든 지역 × 모든 (이전, 새) 날씨 — 알림이 뜨는 조건은 "지역 기준으로 보이는 날씨가 달라졌다" 하나다. + foreach (string region in Regions) + foreach (WeatherType before in AllWeathers) + foreach (WeatherType after in AllWeathers) + { + ClockNotice notice = WorldClockRules.ChooseNotice( + new SkyState(DayPhase.Day, before), new SkyState(DayPhase.Day, after), region, false); + bool visibleChange = WeatherForecast.EffectiveIn(before, region) != WeatherForecast.EffectiveIn(after, region); + Assert.AreEqual(visibleChange, notice.HasValue, $"{region ?? "null"}: {before} → {after}"); + if (notice.HasValue) + Assert.AreEqual(WeatherForecast.EffectiveIn(after, region), notice.Weather); + } + } + + [Test] + public void ChooseNotice_NothingChanged_ReturnsNone() + { + foreach (string region in Regions) + foreach (DayPhase phase in AllPhases) + foreach (WeatherType weather in AllWeathers) + Assert.IsFalse(WorldClockRules.ChooseNotice( + new SkyState(phase, weather), new SkyState(phase, weather), region, false).HasValue); + } + + [Test] + public void ChooseNotice_Indoors_NeverNotifies() + { + // 서브에리어(동굴·섬)는 하늘이 안 보인다. + foreach (string region in Regions) + foreach (DayPhase before in AllPhases) + foreach (DayPhase after in AllPhases) + foreach (WeatherType weather in AllWeathers) + Assert.IsFalse(WorldClockRules.ChooseNotice( + new SkyState(before, WeatherType.Clear), new SkyState(after, weather), region, true).HasValue); + } + + [Test] + public void ClockNotice_Default_HasNoValue() + { + Assert.IsFalse(default(ClockNotice).HasValue); + Assert.IsNull(default(ClockNotice).Text); + } + + [Test] + public void Notices_AreShortHangulLines_ThatTheImguiFontCanDraw() + { + // IMGUI 폰트가 이모지를 못 그린다 — 한글 음절·공백·줄표만 쓴다. 한 줄이다. + var texts = new System.Collections.Generic.List(); + foreach (DayPhase phase in AllPhases) texts.Add(WorldClockRules.PhaseNotice(phase)); + foreach (WeatherType before in AllWeathers) + foreach (WeatherType after in AllWeathers) + if (before != after) texts.Add(WorldClockRules.WeatherNotice(before, after)); + + foreach (string text in texts) + { + Assert.IsFalse(string.IsNullOrWhiteSpace(text)); + Assert.LessOrEqual(text.Length, 30, text); + foreach (char c in text) + { + bool hangul = c >= '가' && c <= '힣'; + Assert.IsTrue(hangul || c == ' ' || c == '—', $"'{c}' (U+{(int)c:X4}) in \"{text}\""); + } + } + } + + // ── 배치 ── + + [Test] + public void ChipBelow_StacksUnderTheAnchor_InTheSameColumn() + { + var capturePanel = new Rect(1460f, 32f, 440f, 226f); + Rect desktop = WorldClockRules.ChipBelow(capturePanel, false); + Assert.AreEqual(capturePanel.x, desktop.x); + Assert.AreEqual(capturePanel.width, desktop.width); + Assert.AreEqual(WorldClockRules.DesktopChipHeight, desktop.height); + Assert.AreEqual(capturePanel.yMax + UITheme.Space.S, desktop.y, 0.001f); + Assert.IsFalse(desktop.Overlaps(capturePanel)); + + var shortcutBar = new Rect(732f, 58f, 324f, 312f); + Rect mobile = WorldClockRules.ChipBelow(shortcutBar, true); + Assert.AreEqual(shortcutBar.xMax, mobile.xMax, 0.001f); + Assert.AreEqual(WorldClockRules.MobileChipHeight, mobile.height); + Assert.IsFalse(mobile.Overlaps(shortcutBar)); + } + + [Test] + public void ChipBelow_RealAnchors_NeverOverlapThePanelAbove() + { + // 화면 크기와 무관하게 성립해야 한다 — 실제 앵커(포획 아이템 패널·단축 바)로 잰다. + Rect capturePanel = KeyGuideHUD.CaptureItemsRect; + Assert.IsFalse(WorldClockRules.ChipBelow(capturePanel, false).Overlaps(capturePanel)); + + Rect bar = QuickAccessBarUI.ShortcutBarRect; + Assert.IsFalse(WorldClockRules.ChipBelow(bar, true).Overlaps(bar)); + } + + [Test] + public void NoticeBelow_SitsUnderTheChip_RightAligned_AndStaysInsideTheAvailableWidth() + { + var chip = new Rect(1460f, 266f, 440f, 64f); + Rect desktop = WorldClockRules.NoticeBelow(chip, false, chip.xMax - 24f); + Assert.AreEqual(chip.xMax, desktop.xMax, 0.001f); + Assert.GreaterOrEqual(desktop.y, chip.yMax); + Assert.AreEqual(WorldClockRules.DesktopNoticeWidth, desktop.width); + Assert.IsFalse(desktop.Overlaps(chip)); + + // 좁은 화면 — 왼쪽 안전 가장자리를 넘지 않는다. + Rect narrow = WorldClockRules.NoticeBelow(chip, false, 300f); + Assert.AreEqual(300f, narrow.width); + Assert.AreEqual(chip.xMax, narrow.xMax, 0.001f); + + Rect degenerate = WorldClockRules.NoticeBelow(chip, true, -50f); + Assert.GreaterOrEqual(degenerate.width, 1f, "폭이 0이하로 내려가지 않는다"); + } + + // ── 어느 하늘인가 — 섬은 세계 날씨 ── + + [Test] + public void SkyRegionId_OnIslandOrInSubArea_IsTheWorldSky() + { + Assert.IsNull(WorldClockRules.SkyRegionId("frostline", true), "섬에서는 떠나온 설산이 아니라 세계 날씨다"); + Assert.AreEqual("frostline", WorldClockRules.SkyRegionId("frostline", false)); + Assert.IsNull(WorldClockRules.SkyRegionId(null, false)); + } + + [TestCase("meadow")] + [TestCase("frostline")] + [TestCase("dunes")] + public void SkyRegionId_AgreesWithTheBattleEnvironment(string regionId) + { + // 필드 칩과 전투 칩이 같은 하늘을 말해야 한다 — 갈리면 칩은 "비"인데 전투는 "눈" 성향으로 돈다. + var region = new RegionData { regionId = regionId }; + var island = new SubAreaData { subAreaId = "player_island", detached = true }; + Assert.AreEqual(BattleEnvironment.WeatherRegionId(region, null), WorldClockRules.SkyRegionId(regionId, false)); + Assert.AreEqual(BattleEnvironment.WeatherRegionId(region, island), WorldClockRules.SkyRegionId(regionId, true)); + } + + [Test] + public void ChooseNotice_OnIsland_SpeaksOfTheWorldWeather() + { + // 설산에서 섬으로 건너왔어도 섬은 세계 날씨다 — 세계가 비면 섬도 "비"다(설산이었다면 "눈"). + string island = WorldClockRules.SkyRegionId("frostline", true); + ClockNotice notice = WorldClockRules.ChooseNotice( + new SkyState(DayPhase.Day, WeatherType.Clear), new SkyState(DayPhase.Day, WeatherType.Rain), island, false); + Assert.AreEqual("비가 내리기 시작합니다", notice.Text); + Assert.AreEqual(WeatherType.Rain, notice.Weather); + + ClockNotice night = WorldClockRules.ChooseNotice( + new SkyState(DayPhase.Evening, WeatherType.Clear), new SkyState(DayPhase.Night, WeatherType.Clear), island, false); + Assert.AreEqual(WorldClockRules.PhaseNotice(DayPhase.Night), night.Text, "섬에서도 밤이 오면 알린다"); + } + + // ── 섬 HUD 판(IslandHudLayout) — 모양 셋 ── + + [TestCase(IslandHudForm.Strip)] + [TestCase(IslandHudForm.Column)] + [TestCase(IslandHudForm.Grid)] + public void IslandHudLayout_Buttons_StayInsideTheirPanel_WithoutOverlap(IslandHudForm form) + { + Rect quick = form == IslandHudForm.Strip ? DesktopQuickBar(1920f, 1080f) + : form == IslandHudForm.Column ? MobileQuickBar(1080f, 57.6f, 0f) + : MobileQuickBar(1920f, 32.4f, 0f); + foreach (bool visiting in new[] { false, true }) + { + Rect panel = IslandHudLayout.Panel(form, quick, visiting, 1015.2f); + Assert.IsFalse(panel.Overlaps(quick), $"{form} visiting={visiting}: 단축 바와 떨어져 선다"); + int count = visiting ? IslandHudLayout.VisitButtonCount : IslandHudLayout.OwnButtonCount; + for (int i = 0; i < count; i++) + { + Rect b = IslandButton(panel, form, visiting, i); + AssertInside(panel, b); + Assert.GreaterOrEqual(b.height, UIScale.MinTouchHeight, $"{form} 버튼 {i}"); + for (int j = 0; j < i; j++) + Assert.IsFalse(b.Overlaps(IslandButton(panel, form, visiting, j)), $"{form} visiting={visiting}: {i}·{j}"); + } + } + + Rect own = IslandHudLayout.OwnPanel(form, quick, 1015.2f); + Rect harvest = IslandHudLayout.OwnButton(own, form, IslandHudLayout.HarvestIndex); + for (int i = 0; i < IslandHudLayout.OwnButtonCount; i++) + Assert.GreaterOrEqual(harvest.width, IslandHudLayout.OwnButton(own, form, i).width - 0.001f, "수확 칸이 가장 넓다(수확물 숫자)"); + switch (form) + { + case IslandHudForm.Strip: + Assert.AreEqual(IslandHudLayout.HarvestWidth, harvest.width); + Assert.IsFalse(IslandHudLayout.DesktopInfo(own).Overlaps(own), "정보 줄은 버튼 줄 위"); + break; + case IslandHudForm.Column: + Assert.AreEqual(IslandHudLayout.MobileOwnColumnHeight, own.height, "7줄이 다 들어가는 높이"); + Assert.AreEqual(quick.x, own.x, 0.001f, "단축 바 아래 한 열"); + break; + case IslandHudForm.Grid: + // 단축 바 왼쪽에 윗변을 맞추고 높이가 단축 바와 같다 — 둘이 위쪽 한 띠에 나란히 선다. + Assert.AreEqual(quick.y, own.y, 0.001f); + Assert.AreEqual(quick.height, own.height, 0.001f); + Assert.LessOrEqual(own.xMax, quick.x - IslandHudLayout.AnchorGap + 0.001f); + Assert.AreEqual(own.width - 16f, harvest.width, 0.001f, "수확은 한 줄을 다 쓴다"); + break; + } + } + + [Test] + public void IslandMobileChip_PortraitUnderTheColumn_LandscapeAtItsFieldPlace() + { + Rect quickP = MobileQuickBar(1080f, 57.6f, 0f); + Rect column = IslandHudLayout.OwnPanel(IslandHudForm.Column, quickP, 1804.8f); + Rect chipP = WorldClockRules.IslandMobileChip(quickP, column, true); + Assert.AreEqual(column.x, chipP.x, 0.001f, "섬 HUD 열과 한 열로 선다"); + Assert.AreEqual(column.width, chipP.width, 0.001f); + Assert.AreEqual(column.yMax + UITheme.Space.S, chipP.y, 0.001f, "열 바로 아래"); + + // 가로 섬은 HUD가 단축 바 왼쪽이라 단축 바 아래가 빈다 — 칩은 필드와 같은 자리다. + Rect quickL = MobileQuickBar(1920f, 32.4f, 0f); + Rect grid = IslandHudLayout.OwnPanel(IslandHudForm.Grid, quickL, 1015.2f); + Assert.AreEqual(WorldClockRules.ChipBelow(quickL, true), WorldClockRules.IslandMobileChip(quickL, grid, false)); + } + + // 나의 섬 화면 전체(섬 HUD·결과 토스트·시각 칩·알림과 남의 자리)의 겹침은 HudOverlapSweepTests가 화면 18장에서 잰다 + // (예전 IslandScreen_* — 결과 토스트는 이제 가운데 무대(HudStage)에 선다). + + // ── 퀘스트 칩·목표 행(QuestChipLayout) — 단축 바·시각 칩·포획 아이템 패널·미니맵·리전 배너·잡기 버튼·조이스틱 자리와 ── + // 2026-10-03 전엔 데스크톱 16:9(가상 폭 1920)에서 좌하단 칩(x 16~416)이 단축 바(x 360~1560)와 56px 겹쳤고, + // 목표 행은 칩 아래(안전 영역 밖, 1080 화면에서 y 1054~1111)에 그려져 거의 안 보였다. 가로 모바일에서는 미니맵 아래 목표 행 + // (y 526~583)이 가상 조이스틱의 시작 영역(좌하단 사분면, y 540부터)으로 43px 내려가 있었다. + + // 칩·목표 행·복원 버튼이 다른 필드 HUD와 겹치지 않는지는 HudOverlapSweepTests가 잰다(예전 QuestChip_ExpandedDoneOrCollapsed_ClearOfFieldHud). + + [Test] + public void QuestChip_Desktop_StopsAtTheBar_AndKeepsFourHundredWhereThereIsRoom() + { + // 16:9 — 단축 바 왼쪽 끝까지만(334). 그 전엔 400이라 단축 바와 56px 겹쳤다. + Rect bar1920 = DesktopBar(1920f, 1080f); + float w1920 = QuestChipLayout.Width(QuestStackPlace.BottomLeft, 1920f, 0f, 0f, 16f, bar1920); + Assert.AreEqual(bar1920.x - UITheme.Space.S - 16f, w1920, 0.001f); + Assert.Less(w1920, QuestChipLayout.DesktopMaxWidth); + Assert.GreaterOrEqual(w1920, QuestChipLayout.DesktopMinBesideWidth); + + // 21:9 — 자리가 넉넉하면 예전 그대로 400. + Assert.AreEqual(QuestChipLayout.DesktopMaxWidth, QuestChipLayout.Width(QuestStackPlace.BottomLeft, 2560f, 0f, 0f, 16f, DesktopBar(2560f, 1080f)), 0.001f); + + // 목표 행이 없으면 칩은 예전처럼 바닥에 붙는다 — 있을 때만 그 높이만큼 올라간다. + float bottom = QuestChipLayout.DesktopBottom(16f, bar1920, 1047.6f); + Assert.AreEqual(1047.6f, bottom, 0.001f); + Assert.AreEqual(1047.6f - QuestChipHeight, + QuestChipLayout.Chip(QuestStackPlace.BottomLeft, 16f, w1920, QuestChipHeight, 0f, 0f, bottom).y, 0.001f); + Rect lifted = QuestChipLayout.Chip(QuestStackPlace.BottomLeft, 16f, w1920, QuestChipHeight, QuestRowHeight, 0f, bottom); + Assert.AreEqual(1047.6f, QuestChipLayout.Row(lifted, w1920, QuestRowHeight).yMax, 0.001f, "목표 행이 바닥에 닿는다"); + + // 세로 모바일은 바뀌지 않았다 — 미니맵 아래(StackBelowY), 폭 500. + Rect mobileChip = QuestChipLayout.Chip(QuestStackPlace.UnderMinimap, 16f, + QuestChipLayout.Width(QuestStackPlace.UnderMinimap, 1080f, 0f, 0f, 16f, Rect.zero), + QuestChipHeight, QuestRowHeight, QuestChipLayout.MobileTop(QuestStackPlace.UnderMinimap, 57.6f), 0f); + Assert.AreEqual(16f, mobileChip.x, 0.001f); + Assert.AreEqual(437.6f, mobileChip.y, 0.001f); + Assert.AreEqual(QuestChipLayout.MobileMaxWidth, mobileChip.width, 0.001f); + } + + [Test] + public void QuestChip_DesktopTooNarrowBesideTheBar_StacksAboveIt() + { + // 데스크톱 레이아웃인데 창이 세로에 가깝다(가상 폭 1080) — 단축 바가 안전 폭을 다 쓴다. 칩은 그 위로 올라간다. + UISafeLayout.SafeBox vertical = UISafeLayout.Compute(1140f, 0f, 0f); + UISafeLayout.SafeBox horizontal = UISafeLayout.ComputeWithMargin(1080f, 0f, 0f, UISafeLayout.MarginX); + Rect bar = QuickAccessBarUI.ShortcutBarRectIn(false, horizontal, vertical); + float width = QuestChipLayout.Width(QuestStackPlace.BottomLeft, 1080f, 0f, 0f, 16f, bar); + float bottom = QuestChipLayout.DesktopBottom(16f, bar, vertical.End); + Rect chip = QuestChipLayout.Chip(QuestStackPlace.BottomLeft, 16f, width, QuestChipHeight, QuestRowHeight, 0f, bottom); + Rect row = QuestChipLayout.Row(chip, width, QuestRowHeight); + Assert.AreEqual(QuestChipLayout.DesktopMaxWidth, width, 0.001f); + Assert.IsFalse(chip.Overlaps(bar)); + Assert.IsFalse(row.Overlaps(bar)); + Assert.LessOrEqual(row.yMax, bar.y); + Assert.GreaterOrEqual(chip.y, vertical.Start); + } + + [Test] + public void ShortcutBarRectIn_MatchesThePanelHarness() + { + // TopPanel(324, 312, Right) / BottomPanel(1200, 76)와 같은 값 — 순수 계산으로 뗐을 뿐 자리는 그대로다. + Rect mobile = QuickAccessBarUI.ShortcutBarRectIn(true, + UISafeLayout.ComputeWithMargin(1080f, 0f, 0f, UISafeLayout.MarginX), UISafeLayout.Compute(1920f, 0f, 0f)); + Assert.AreEqual(732f, mobile.x, 0.001f); + Assert.AreEqual(57.6f, mobile.y, 0.001f); + Assert.AreEqual(324f, mobile.width, 0.001f); + Assert.AreEqual(312f, mobile.height, 0.001f); + Rect desktop = QuickAccessBarUI.ShortcutBarRectIn(false, + UISafeLayout.ComputeWithMargin(1920f, 0f, 0f, UISafeLayout.MarginX), UISafeLayout.Compute(1080f, 0f, 0f)); + Assert.AreEqual(360f, desktop.x, 0.001f); + Assert.AreEqual(1080f - 32.4f - 76f, desktop.y, 0.001f); + Assert.AreEqual(1200f, desktop.width, 0.001f); + Assert.AreEqual(76f, desktop.height, 0.001f); + // 실제 화면에서도 프로퍼티와 같다. + Assert.AreEqual(QuickAccessBarUI.ShortcutBarRect, + QuickAccessBarUI.ShortcutBarRectIn(UIScale.IsMobileLayout, UISafeLayout.HorizontalBox, UISafeLayout.VerticalBox)); + } + + [Test] + public void QuestChip_MobileLandscape_BesideTheMinimap_AboveTheJoystick() + { + // 1280×720 → 가상 1920×1080. 미니맵(16~236 × 182~402) 오른쪽, ContentTop+260부터. + UISafeLayout.SafeBox vertical = UISafeLayout.Compute(1080f, 0f, 0f); + Rect bar = QuickAccessBarUI.ShortcutBarRectIn(true, UISafeLayout.ComputeWithMargin(1920f, 0f, 0f, UISafeLayout.MarginX), vertical); + QuestStackPlace place = QuestChipLayout.PlaceFor(true, false); + Assert.AreEqual(QuestStackPlace.BesideMinimap, place); + float x = QuestChipLayout.Left(place, 16f); + float width = QuestChipLayout.Width(place, 1920f, 0f, 0f, 16f, bar); + Rect chip = QuestChipLayout.Chip(place, x, width, QuestChipHeight, QuestRowHeight, QuestChipLayout.MobileTop(place, vertical.Start), 0f); + Rect row = QuestChipLayout.Row(chip, width, QuestRowHeight); + Rect minimap = new Rect(16f, vertical.Start + MinimapUI.TopOffset, MinimapUI.PanelSize, MinimapUI.PanelSize); + + Assert.AreEqual(minimap.xMax + UITheme.Space.S, chip.x, 0.001f, "미니맵 오른쪽"); + Assert.AreEqual(vertical.Start + QuestChipLayout.BesideMinimapTop, chip.y, 0.001f, "위쪽 가운데 줄(리전 배너·내기 점수판) 아래"); + Assert.AreEqual(QuestChipLayout.MobileMaxWidth, width, 0.001f); + Assert.LessOrEqual(Mathf.Abs(chip.yMax - minimap.yMax), 4f, "칩 아랫변이 미니맵 아랫변과 거의 맞는다"); + Assert.Less(row.yMax, 1080f * 0.5f, "목표 행까지 화면 가운데 줄(조이스틱 시작 영역) 위에서 끝난다"); + Assert.Less(row.xMax, 1920f * 0.5f - 120f, "화면 가운데(캐릭터)와 떨어져 있다"); + + // 세로 모바일은 그대로 미니맵 아래다. + Assert.AreEqual(QuestStackPlace.UnderMinimap, QuestChipLayout.PlaceFor(true, true)); + Assert.AreEqual(QuestStackPlace.BottomLeft, QuestChipLayout.PlaceFor(false, false)); + Assert.AreEqual(QuestChipLayout.MobileTop(QuestStackPlace.UnderMinimap, 57.6f), + 57.6f + MinimapUI.TopOffset + MinimapUI.PanelSize + UITheme.Space.S, 0.001f, "= MinimapUI.StackBelowY"); + } + + // ── 가상 조이스틱 — 필드 HUD 위에서는 시작하지 않는다 ── + + [Test] + public void JoystickCanBeginAt_OnlyInTheLowerLeftQuadrant_AndNeverOnHud() + { + // 화면 좌표는 Y-up 픽셀(Touch.position). 1280×720, 왼쪽 인셋 40·아래 인셋 20. + const float w = 1280f, h = 720f, left = 40f, bottom = 20f; + Assert.IsTrue(VirtualJoystickUI.CanBeginAt(new Vector2(500f, 300f), w, h, left, bottom, false), "좌하단, HUD 아님"); + Assert.IsFalse(VirtualJoystickUI.CanBeginAt(new Vector2(500f, 300f), w, h, left, bottom, true), + "좌하단이라도 HUD(대화·동굴 입구 버튼 등) 위 — 그 버튼과 함께 캐릭터가 움직이면 안 된다"); + // 유휴 힌트 원("여기를 누르면 움직인다") 안은 HUD가 덮어도 시작한다 — 꿈속 섬 안내 카드가 세로 화면에서 이 자리를 덮는다. + Vector2 hint = VirtualJoystickUI.HintCenter(w, h, left, bottom); + float r = VirtualJoystickUI.HintRadius(w, h); + Assert.AreEqual(720f * 0.14f, r, 0.001f, "짧은 변의 14%"); + Assert.AreEqual(left + r * 1.25f, hint.x, 0.001f); + Assert.AreEqual(bottom + r * 1.25f, hint.y, 0.001f); + Assert.IsTrue(VirtualJoystickUI.CanBeginAt(hint, w, h, left, bottom, true), "힌트 원 가운데"); + Assert.IsTrue(VirtualJoystickUI.CanBeginAt(hint + new Vector2(r * 0.9f, 0f), w, h, left, bottom, true), "힌트 원 가장자리 안"); + Assert.IsFalse(VirtualJoystickUI.CanBeginAt(hint + new Vector2(r * 1.1f, 0f), w, h, left, bottom, true), "힌트 원 밖"); + Assert.IsFalse(VirtualJoystickUI.CanBeginAt(new Vector2(900f, 150f), w, h, left, bottom, false), "오른쪽 절반"); + Assert.IsFalse(VirtualJoystickUI.CanBeginAt(new Vector2(200f, 500f), w, h, left, bottom, false), "위쪽 절반"); + Assert.IsFalse(VirtualJoystickUI.CanBeginAt(new Vector2(30f, 150f), w, h, left, bottom, false), "왼쪽 노치 안"); + Assert.IsFalse(VirtualJoystickUI.CanBeginAt(new Vector2(200f, 10f), w, h, left, bottom, false), "제스처 바 안"); + // 사분면 자체는 줄이지 않았다 — 가운데 줄 바로 아래·바로 왼쪽까지 시작된다. + Assert.IsTrue(VirtualJoystickUI.CanBeginAt(new Vector2(639f, 359f), w, h, left, bottom, false)); + } + + // 조이스틱 자리를 늘 덮는 HUD가 없는지·잠깐 뜨는 것을 다 띄워도 절반 넘게 비는지는 HudOverlapSweepTests가 잰다. + + // ── 전투 화면의 낮·밤·날씨 보정 칩 ── + + [TestCase(1920f, 1080f, false)] + [TestCase(1440f, 1080f, false)] + [TestCase(2560f, 1080f, false)] + [TestCase(1080f, 1920f, true)] + [TestCase(1080f, 2400f, true)] + public void BattleEnvironmentChip_UnderItsHpCard_ClearOfCardsGaugeDeckAndEachOther(float width, float height, bool portrait) + { + var safe = new Rect(48f, 48f, width - 96f, height - 96f); + Rect playerCard = DuelHudLayout.HpCard(safe, true); + Rect enemyCard = DuelHudLayout.HpCard(safe, false); + float deckHeight = portrait ? 610f : 300f; // BattleScreenUI.DrawSkillPanel의 하단 기술 판 + var deck = new Rect(safe.x, safe.yMax - deckHeight, safe.width, deckHeight); + var gauge = new Rect(enemyCard.x, enemyCard.yMax + UITheme.Space.S, enemyCard.width, BattleHudStack.LedgerGaugeHeight); + float grown = 1f + BattleHudStack.PulseAmount; + + foreach (bool ledger in new[] { false, true }) + foreach (float textWidth in new[] { 0f, 160f, 420f, 2000f }) + { + string at = $"{width}x{height} ledger={ledger} text={textWidth}"; + Rect mine = BattleHudStack.EnvironmentChip(playerCard, true, false, textWidth); + Rect theirs = BattleHudStack.EnvironmentChip(enemyCard, false, ledger, textWidth); + + // 자기 카드의 바깥 모서리에 붙어 카드 폭 안에 선다. + Assert.AreEqual(playerCard.x, mine.x, 0.001f, at); + Assert.AreEqual(enemyCard.xMax, theirs.xMax, 0.001f, at); + Assert.LessOrEqual(mine.xMax, playerCard.xMax + 0.001f, at); + Assert.GreaterOrEqual(theirs.x, enemyCard.x - 0.001f, at); + Assert.GreaterOrEqual(mine.width, BattleHudStack.EnvironmentChipMinWidth - 0.001f, at); + + // 커졌다 돌아오는 순간(최대 배율)까지 포함해 무엇도 밟지 않는다. + Rect mineGrown = new Rect(mine.x, mine.y, mine.width, mine.height * grown); + Rect theirsGrown = new Rect(theirs.x, theirs.y, theirs.width, theirs.height * grown); + foreach (Rect r in new[] { mine, theirs, mineGrown, theirsGrown }) + { + Assert.IsFalse(r.Overlaps(playerCard), "내 HP 카드 — " + at); + Assert.IsFalse(r.Overlaps(enemyCard), "상대 HP 카드 — " + at); + Assert.IsFalse(r.Overlaps(deck), "기술 판 — " + at); + if (ledger) Assert.IsFalse(r.Overlaps(gauge), "장부 게이지 — " + at); + } + Assert.IsFalse(mineGrown.Overlaps(theirsGrown), "두 칩끼리 — " + at); + + // 대결 말풍선은 칩이 있을 때만 그 아래로 내려간다. + Assert.GreaterOrEqual(BattleHudStack.BubbleTop(enemyCard, ledger, true), theirsGrown.yMax, at); + } + } + + [Test] + public void BattleEnvironmentChip_NoEffect_LeavesTheStackWhereItWas() + { + // 보정이 없으면 자리도 비우지 않는다 — 말풍선은 칩이 생기기 전과 같은 자리다(카드 아래, 장부가 있으면 그 아래). + var card = new Rect(1436f, 116f, 460f, DuelHudLayout.HpCardHeight); + Assert.AreEqual(card.yMax + UITheme.Space.S, BattleHudStack.BubbleTop(card, false, false), 0.001f); + Assert.AreEqual(card.yMax + UITheme.Space.S + 26f + UITheme.Space.S, BattleHudStack.BubbleTop(card, true, false), 0.001f); + Assert.AreEqual(BattleHudStack.EnvironmentChipTop(card, true), BattleHudStack.BubbleTop(card, true, false), 0.001f); + } + + [Test] + public void BattleEnvironmentChip_Pulse_IsOneGentleBumpThenRests() + { + float end = BattleHudStack.PulseDelay + BattleHudStack.PulseSeconds; + Assert.LessOrEqual(end, 2f, "전투 시작 1~2초 안에 끝난다"); + Assert.AreEqual(1f, BattleHudStack.EnvironmentPulse(0f, false)); + Assert.AreEqual(1f, BattleHudStack.EnvironmentPulse(end + 0.01f, false)); + Assert.AreEqual(1f, BattleHudStack.EnvironmentPulse(60f, false)); + + float peak = 1f; + for (int i = 0; i <= 300; i++) + { + float scale = BattleHudStack.EnvironmentPulse(i * 0.01f, false); + Assert.That(scale, Is.InRange(1f, 1f + BattleHudStack.PulseAmount + 1e-4f)); + peak = Mathf.Max(peak, scale); + Assert.AreEqual(1f, BattleHudStack.EnvironmentPulse(i * 0.01f, true), "움직임 줄이기면 커지지 않는다"); + } + Assert.Greater(peak, 1.05f, "눈에 띌 만큼은 커진다"); + } + + [TestCase("밤 · 야행성", 10, true, "밤 · 야행성 +10%")] + [TestCase("비 · 비를 싫어함", -10, false, "비 · 비를 싫어함 −10%")] + [TestCase("아침 · 주행성 · 센바람을 좋아함", 10, true, "아침 · 주행성 · 센바람을 좋아함 +10%")] + [TestCase("안개 · 안개를 싫어함", 0, false, "안개 · 안개를 싫어함 −0%")] // 반올림으로 0이어도 방향은 남는다 + [TestCase("", 10, true, "+10%")] + [TestCase(null, -10, false, "−10%")] + public void BattleEnvironmentLabel_ReasonThenSignedPercent_WithoutEmoji(string reason, int percent, bool favorable, string expected) + { + string label = BattleHudStack.EnvironmentLabel(reason, percent, favorable); + Assert.AreEqual(expected, label); + // IMGUI 폰트가 이모지를 못 그린다 — 한글·공백·가운뎃점·숫자·부호·퍼센트만. + foreach (char c in label) + { + bool ok = (c >= '가' && c <= '힣') || char.IsDigit(c) || c == ' ' || c == '·' || c == '+' || c == '−' || c == '%'; + Assert.IsTrue(ok, $"'{c}' (U+{(int)c:X4}) in \"{label}\""); + } + } + + // ── 도우미 ── + + // 모바일 단축 바 — QuickAccessBarUI.ShortcutBarRect와 같은 모양(우상단, 폭 324, 2열 4줄 = 높이 312). + private static Rect MobileQuickBar(float screenW, float contentTop, float rightInset) + => new Rect(screenW - rightInset - 24f - 324f, contentTop, 324f, 312f); + + // 데스크톱 단축 바 — 하단 가운데 폭 1200, 한 줄(16 + 버튼 60). + private static Rect DesktopQuickBar(float screenW, float screenH) + { + float margin = Mathf.Clamp(screenH * 0.03f, 24f, 64f); + float h = 16f + QuickAccessBarUI.BarButtonHeight; + return new Rect((screenW - 1200f) * 0.5f, screenH - margin - h, 1200f, h); + } + + // 퀘스트 칩 높이 — TutorialQuestUI가 스타일에서 파생하는 값(여백 10 + 제목 ceil(34×1.35) + 6 + 진행 ceil(26×1.35) + 10). + private const float QuestChipHeight = 10f + 46f + 6f + 36f + 10f; + // 목표 행 — TutorialQuestUI.ObjectiveRowHeight(ceil(27×1.35) + 10×2). + private const float QuestRowHeight = 37f + 20f; + + // 데스크톱 단축 바 — 진짜 순수 계산(인셋 0). + private static Rect DesktopBar(float w, float h) + => QuickAccessBarUI.ShortcutBarRectIn(false, UISafeLayout.ComputeWithMargin(w, 0f, 0f, UISafeLayout.MarginX), + UISafeLayout.Compute(h, 0f, 0f)); + + private static Rect IslandButton(Rect panel, IslandHudForm form, bool visiting, int index) + => visiting ? IslandHudLayout.VisitButton(panel, form, index) : IslandHudLayout.OwnButton(panel, form, index); + + private static void AssertInside(Rect outer, Rect inner) + { + Assert.GreaterOrEqual(inner.xMin, outer.xMin - 0.01f); + Assert.GreaterOrEqual(inner.yMin, outer.yMin - 0.01f); + Assert.LessOrEqual(inner.xMax, outer.xMax + 0.01f); + Assert.LessOrEqual(inner.yMax, outer.yMax + 0.01f); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/WorldClockHudTests.cs.meta b/Assets/Tests/EditMode/WorldClockHudTests.cs.meta new file mode 100644 index 0000000..9b26cfb --- /dev/null +++ b/Assets/Tests/EditMode/WorldClockHudTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 81e2a83e1973dce47ad1a36e423b82c7 \ No newline at end of file diff --git a/Assets/Tests/EditMode/WorldRouteLayoutTests.cs b/Assets/Tests/EditMode/WorldRouteLayoutTests.cs new file mode 100644 index 0000000..6a2b712 --- /dev/null +++ b/Assets/Tests/EditMode/WorldRouteLayoutTests.cs @@ -0,0 +1,146 @@ +#if UNITY_EDITOR +using InsectGame.Core; +using InsectGame.Data; +using NUnit.Framework; +using UnityEngine; + +namespace InsectGame.Tests +{ + public class WorldRouteLayoutTests + { + [Test] + public void GatewaySigns_ReuseOneMarkerPerRegionAndKeepLaneClear() + { + var before = new System.Collections.Generic.HashSet(); + foreach (GameObject existing in Object.FindObjectsByType(FindObjectsSortMode.None)) + before.Add(existing.GetInstanceID()); + GameObject host = new GameObject("GatewaySignsTest"); + try + { + RegionData[] regions = RegionDefinitions.CreateAll(); + host.AddComponent().BuildBoundaries(regions); + int signs = 0; + foreach (RegionData region in regions) + { + GameObject marker = GameObject.Find("GatewayMarker_" + region.regionId + "_0"); + Assert.IsNotNull(marker, region.regionId); + signs++; + Assert.AreEqual(4, marker.transform.childCount, "기존 표지마다 네 장식 자식만 추가한다"); + foreach (Collider collider in marker.GetComponentsInChildren()) + Assert.IsFalse(collider.enabled, "표지판이 이동 통로를 막으면 안 된다"); + Transform board = marker.transform.Find("WayfindingBoard"); + Assert.Less(marker.GetComponent().bounds.max.y, + board.GetComponent().bounds.min.y, "기둥이 표지판의 글자를 가리면 안 된다"); + Vector3 gateway = WorldRouteLayout.GetGateway(region, regions); + Vector3 outward = (gateway - region.centerPosition).normalized; + Vector3 right = Vector3.Cross(Vector3.up, outward).normalized; + float centerOffset = Mathf.Abs(Vector3.Dot(board.position - gateway, right)); + Assert.Greater(centerOffset - board.lossyScale.x * 0.5f, + WorldRouteLayout.RoadWidth * 0.5f + 0.4f, region.regionId); + TextMesh inside = marker.transform.Find("DestinationsInside").GetComponent(); + Assert.That(inside.text, Does.StartWith(region.displayName)); + Assert.IsNotNull(inside.font); + Assert.AreEqual(inside.font.material, inside.GetComponent().sharedMaterial); + Assert.AreEqual("InsectGame/GatewayText", inside.font.material.shader.name); + Vector3 textBounds = inside.GetComponent().localBounds.size; + Assert.LessOrEqual(textBounds.x, 2.86f); + Assert.LessOrEqual(textBounds.y, 1.43f); + Assert.Greater(textBounds.y, 0.3f, "글자가 판에 비해 지나치게 작아서는 안 된다"); + + foreach (WorldRouteEdge edge in WorldRouteLayout.FieldConnections) + { + string target = edge.FromRegionId == region.regionId ? edge.ToRegionId : + edge.ToRegionId == region.regionId ? edge.FromRegionId : null; + if (target != null) Assert.That(inside.text, + Does.Contain(WorldRouteLayout.Find(regions, target).displayName)); + } + } + Assert.AreEqual(13, signs); + } + finally + { + foreach (GameObject generated in Object.FindObjectsByType(FindObjectsSortMode.None)) + if (generated != null && !before.Contains(generated.GetInstanceID())) Object.DestroyImmediate(generated); + } + } + + [Test] + public void PondBridge_AlignsWithGatewayAndLeavesWaterPassageOpen() + { + var before = new System.Collections.Generic.HashSet(); + foreach (GameObject existing in Object.FindObjectsByType(FindObjectsSortMode.None)) + before.Add(existing.GetInstanceID()); + GameObject host = new GameObject("RouteRiverTest"); + try + { + RegionData[] regions = RegionDefinitions.CreateAll(); + WorldTerrainBuilder builder = host.AddComponent(); + var flags = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; + typeof(WorldTerrainBuilder).GetMethod("BuildRiver", flags).Invoke(builder, new object[] { regions }); + typeof(WorldTerrainBuilder).GetMethod("BuildBridges", flags).Invoke(builder, new object[] { regions }); + Physics.SyncTransforms(); + Transform bridge = GameObject.Find("Bridge_PondRiver_Floor").transform; + RegionData pond = WorldRouteLayout.Find(regions, "pond"); + Vector3 direction = (WorldRouteLayout.GetGateway(pond, regions) - pond.centerPosition).normalized; + Assert.Greater(Vector3.Dot(bridge.forward, direction), 0.999f); + Assert.IsNotNull(GameObject.Find("River_Water_0")); + for (int i = -10; i <= 10; i++) + { + Vector3 point = bridge.position + direction * i * 0.5f + Vector3.up * 1.4f; + for (int side = -1; side <= 1; side += 2) + { + Collider waterBlocker = GameObject.Find("River_Blocker_" + side).GetComponent(); + Assert.Greater(Vector3.Distance(point, waterBlocker.ClosestPoint(point)), 0.4f); + } + } + } + finally + { + foreach (GameObject generated in Object.FindObjectsByType(FindObjectsSortMode.None)) + if (!before.Contains(generated.GetInstanceID())) Object.DestroyImmediate(generated); + } + } + + [Test] + public void EveryRegionGateway_HasClearApproachOutsideOtherRegions() + { + RegionData[] regions = RegionDefinitions.CreateAll(); + foreach (RegionData region in regions) + { + Vector3 gateway = WorldRouteLayout.GetGateway(region, regions); + Assert.AreEqual(region.radius - 1f, Vector3.Distance(region.centerPosition, gateway), 0.01f); + foreach (RegionData other in regions) + if (other != region) + Assert.GreaterOrEqual(WorldRouteLayout.DistanceToSegment(other.centerPosition, + region.centerPosition, gateway), other.radius + WorldRouteLayout.CorridorClearance - 0.01f, + region.regionId + " gateway enters locked " + other.regionId); + } + } + + [Test] + public void EveryFieldRoute_ExistsUsesGatewayAndAvoidsThirdRegions() + { + RegionData[] regions = RegionDefinitions.CreateAll(); + foreach (WorldRouteEdge edge in WorldRouteLayout.FieldConnections) + { + Vector3[] route = WorldRouteLayout.BuildRoute(regions, edge.FromRegionId, edge.ToRegionId); + string label = edge.FromRegionId + " -> " + edge.ToRegionId; + Assert.GreaterOrEqual(route.Length, 4, label); + RegionData from = WorldRouteLayout.Find(regions, edge.FromRegionId); + RegionData to = WorldRouteLayout.Find(regions, edge.ToRegionId); + Assert.AreEqual(WorldRouteLayout.GetGateway(from, regions), route[1], label); + Assert.AreEqual(WorldRouteLayout.GetGateway(to, regions), route[route.Length - 2], label); + for (int i = 1; i < route.Length; i++) + foreach (RegionData other in regions) + { + if (other == from && i <= 2 || other == to && i >= route.Length - 2) continue; + Assert.GreaterOrEqual(WorldRouteLayout.DistanceToSegment(other.centerPosition, + route[i - 1], route[i]), other.radius + WorldRouteLayout.CorridorClearance - 0.01f, + label + " segment " + i + " enters " + other.regionId); + } + Assert.AreSame(route, WorldRouteLayout.BuildRoute(regions, edge.FromRegionId, edge.ToRegionId)); + } + } + } +} +#endif diff --git a/Assets/Tests/EditMode/WorldRouteLayoutTests.cs.meta b/Assets/Tests/EditMode/WorldRouteLayoutTests.cs.meta new file mode 100644 index 0000000..69a3e68 --- /dev/null +++ b/Assets/Tests/EditMode/WorldRouteLayoutTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 62b5db5dac913174f9e315961deeb02b \ No newline at end of file diff --git a/Assets/Tests/EditMode/WorldSkyRulesTests.cs b/Assets/Tests/EditMode/WorldSkyRulesTests.cs new file mode 100644 index 0000000..a9b3ce9 --- /dev/null +++ b/Assets/Tests/EditMode/WorldSkyRulesTests.cs @@ -0,0 +1,669 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.IO; +using NUnit.Framework; +using InsectGame.Core; +using UnityEngine; + +namespace InsectGame.Tests +{ + /// + /// 낮·밤·날씨 하늘의 순수 규칙() 불변식. + /// + /// 전부 증상이 조용한 것들이다. 밤이 너무 어두워도 예외는 없고 게임이 그냥 안 보이며, 안개 날씨가 짙어도 + /// 캐릭터가 뿌옇게 묻힐 뿐이고, 시간대 경계에서 조명이 뚝 끊겨도 에러가 없다. 눈으로 보는 건 + /// LiveSceneCapture -captureHour -captureWeather가 한다. + /// + /// 기본 상태(보정 전)는 SubAreaEnvironment가 실제로 만드는 값과 같게 적었다 — 필드는 리전 대기 표 + /// ()와 PlaySceneBootstrap.EnsureLight의 라이트·Trilight 환경광, 섬은 섬 프로필(0.40/0.44/0.50 환경광 등). + /// + [TestFixture] + public class WorldSkyRulesTests + { + private static readonly WeatherType[] AllWeathers = (WeatherType[])Enum.GetValues(typeof(WeatherType)); + + // ── 기본 상태 ── + + private static WorldSkyRules.LightingState FieldBase(RegionAtmosphere.Profile p) + { + return new WorldSkyRules.LightingState + { + lightColor = p.light, + lightIntensity = 1.2f * p.intensity, + lightRotation = Quaternion.Euler(50f, 30f, 0f), + shadowStrength = 0.5f, + ambientSky = p.ambientSky, + ambientEquator = new Color(0.42f, 0.43f, 0.41f), + ambientGround = new Color(0.26f, 0.24f, 0.21f), + fogEnabled = true, + fogColor = p.fog, + fogDensity = Mathf.Min(p.fogDensity, RegionAtmosphere.MaxFogDensity), + background = new Color(0.5f, 0.8f, 1f), + }; + } + + private static WorldSkyRules.LightingState IslandBase() + { + var amb = new Color(0.40f, 0.44f, 0.50f); + return new WorldSkyRules.LightingState + { + lightColor = new Color(1f, 0.97f, 0.88f), + lightIntensity = 1.1f, + lightRotation = Quaternion.Euler(52f, 35f, 0f), + shadowStrength = 0.5f, + ambientSky = amb, + ambientEquator = amb, + ambientGround = amb, + fogEnabled = true, + fogColor = new Color(0.66f, 0.84f, 0.95f), + fogDensity = 0.006f, + background = new Color(0.56f, 0.80f, 0.96f), + }; + } + + private static List> AllFieldBases() + { + var list = new List>(); + foreach (InsectGame.Data.RegionData r in RegionDefinitions.CreateAll()) + { + Assert.IsTrue(RegionAtmosphere.TryGet(r.regionId, out RegionAtmosphere.Profile p), r.regionId); + list.Add(new KeyValuePair(r.regionId, FieldBase(p))); + } + Assert.GreaterOrEqual(list.Count, 10, "리전 표를 못 읽었다 — 이 검사가 무의미해졌다"); + return list; + } + + private static WorldSkyRules.LightingState Meadow() + { + Assert.IsTrue(RegionAtmosphere.TryGet("meadow", out RegionAtmosphere.Profile p)); + return FieldBase(p); + } + + private static float SunElevation01(WorldSkyRules.LightingState s) => Mathf.Max(0f, -(s.lightRotation * Vector3.forward).y); + + private static void AssertColor(Color expected, Color actual, float tol, string message) + { + Assert.AreEqual(expected.r, actual.r, tol, message + " (r)"); + Assert.AreEqual(expected.g, actual.g, tol, message + " (g)"); + Assert.AreEqual(expected.b, actual.b, tol, message + " (b)"); + } + + // ── 정오 맑음은 기준이다 ── + + [Test] + public void Apply_NoonClear_ReturnsTheBaseStateUnchanged() + { + // 기존 한낮 모습이 곧 기준이다 — 이 규칙이 생겼다고 한낮의 리전 색·세기가 달라지면 안 된다 + foreach (KeyValuePair kv in AllFieldBases()) + { + WorldSkyRules.LightingState b = kv.Value; + WorldSkyRules.LightingState o = WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(12f, WeatherType.Clear), true); + AssertColor(b.lightColor, o.lightColor, 0.002f, kv.Key + " 햇빛색"); + Assert.AreEqual(b.lightIntensity, o.lightIntensity, 0.002f, kv.Key + " 햇빛 세기"); + Assert.Less(Quaternion.Angle(b.lightRotation, o.lightRotation), 0.5f, kv.Key + " 햇빛 방향"); + AssertColor(b.ambientSky, o.ambientSky, 0.002f, kv.Key + " 위 환경광"); + AssertColor(b.ambientEquator, o.ambientEquator, 0.002f, kv.Key + " 옆 환경광"); + AssertColor(b.ambientGround, o.ambientGround, 0.002f, kv.Key + " 아래 환경광"); + AssertColor(b.fogColor, o.fogColor, 0.002f, kv.Key + " 안개색"); + AssertColor(b.background, o.background, 0.002f, kv.Key + " 배경"); + Assert.AreEqual(b.fogDensity, o.fogDensity, 1e-6f, kv.Key + " 안개 밀도"); + Assert.AreEqual(b.shadowStrength, o.shadowStrength, 0.002f, kv.Key + " 그림자"); + } + + WorldSkyRules.LightingState island = IslandBase(); + WorldSkyRules.LightingState oi = WorldSkyRules.Apply(island, WorldSkyRules.Evaluate(12f, WeatherType.Clear), false); + AssertColor(island.ambientSky, oi.ambientSky, 0.002f, "섬 환경광"); + AssertColor(island.background, oi.background, 0.002f, "섬 하늘"); + Assert.AreEqual(island.fogDensity, oi.fogDensity, 1e-6f, "섬 안개 밀도"); + } + + [Test] + public void Apply_ClearWeather_NeverTouchesTheFogDensity() + { + // 날씨 없이 시각만으로는 안개 밀도가 달라지지 않는다 — 밤에 안개가 끼는 건 날씨의 몫이다 + foreach (KeyValuePair kv in AllFieldBases()) + for (float h = 0f; h < 24f; h += 0.5f) + Assert.AreEqual(kv.Value.fogDensity, + WorldSkyRules.Apply(kv.Value, WorldSkyRules.Evaluate(h, WeatherType.Clear), true).fogDensity, 1e-6f, + $"{kv.Key} {h}시"); + } + + // ── 낮 밝기 곡선 ── + + [Test] + public void Daylight_PeaksAtNoon() + { + foreach (KeyValuePair kv in AllFieldBases()) + { + float noon = WorldSkyRules.UpLuminance(WorldSkyRules.Apply(kv.Value, WorldSkyRules.Evaluate(12f, WeatherType.Clear), true)); + for (float h = 0f; h < 24f; h += 0.05f) + { + float lum = WorldSkyRules.UpLuminance(WorldSkyRules.Apply(kv.Value, WorldSkyRules.Evaluate(h, WeatherType.Clear), true)); + Assert.LessOrEqual(lum, noon + 1e-4f, $"{kv.Key}: {h:F2}시가 정오보다 밝다"); + } + float before = WorldSkyRules.UpLuminance(WorldSkyRules.Apply(kv.Value, WorldSkyRules.Evaluate(11f, WeatherType.Clear), true)); + float after = WorldSkyRules.UpLuminance(WorldSkyRules.Apply(kv.Value, WorldSkyRules.Evaluate(13f, WeatherType.Clear), true)); + Assert.Greater(noon, before, kv.Key + ": 정오가 11시와 같다 — 최대가 평평하다"); + Assert.Greater(noon, after, kv.Key + ": 정오가 13시와 같다 — 최대가 평평하다"); + } + } + + [Test] + public void SunHeight_RisesAtSixPeaksAtNoonAndSetsAtNineteen() + { + Assert.AreEqual(0f, WorldSkyRules.SunHeight01(5f), 1e-6f); + Assert.AreEqual(0f, WorldSkyRules.SunHeight01(WorldSkyRules.SunriseHour), 1e-5f); + Assert.AreEqual(1f, WorldSkyRules.SunHeight01(WorldSkyRules.NoonHour), 1e-5f); + Assert.AreEqual(0f, WorldSkyRules.SunHeight01(WorldSkyRules.SunsetHour), 1e-5f); + Assert.AreEqual(0f, WorldSkyRules.SunHeight01(23f), 1e-6f); + for (float h = 6f; h < 12f; h += 0.25f) + Assert.Less(WorldSkyRules.SunHeight01(h), WorldSkyRules.SunHeight01(h + 0.25f), $"{h}시 오르막"); + for (float h = 12f; h < 19f; h += 0.25f) + Assert.Greater(WorldSkyRules.SunHeight01(h), WorldSkyRules.SunHeight01(h + 0.25f), $"{h}시 내리막"); + Assert.AreEqual(-1f, WorldSkyRules.SunAzimuth(3f), 1e-6f); + Assert.AreEqual(0f, WorldSkyRules.SunAzimuth(12f), 1e-6f); + Assert.AreEqual(1f, WorldSkyRules.SunAzimuth(22f), 1e-6f); + } + + [Test] + public void Sun_MovesAcrossTheSkyThroughTheDay() + { + WorldSkyRules.LightingState b = Meadow(); + WorldSkyRules.LightingState morning = WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(8f, WeatherType.Clear), true); + WorldSkyRules.LightingState noon = WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(12f, WeatherType.Clear), true); + WorldSkyRules.LightingState evening = WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(16f, WeatherType.Clear), true); + Assert.Greater(SunElevation01(noon), SunElevation01(morning), "정오 해가 아침 해보다 높아야 한다"); + Assert.Greater(SunElevation01(noon), SunElevation01(evening), "정오 해가 오후 해보다 높아야 한다"); + Assert.Greater(Quaternion.Angle(morning.lightRotation, evening.lightRotation), 30f, "그림자 방향이 하루 동안 돌아야 한다"); + } + + [Test] + public void SunriseAndSunset_AreWarmerThanNoon() + { + WorldSkyRules.LightingState b = Meadow(); + float noon = RedMinusBlue(WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(12f, WeatherType.Clear), true).lightColor); + Assert.Greater(RedMinusBlue(WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(6f, WeatherType.Clear), true).lightColor), noon + 0.1f, "일출 빛이 주황빛이 아니다"); + Assert.Greater(RedMinusBlue(WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(18.5f, WeatherType.Clear), true).lightColor), noon + 0.1f, "노을 빛이 주황빛이 아니다"); + } + + private static float RedMinusBlue(Color c) => c.r - c.b; + + // ── 연속성 ── + + [Test] + public void Apply_AcrossTheDay_ChangesSmoothly() + { + // 시간대 경계(5·6·7·17·19·20·21·22시)에서 뚝 끊기면 한 틱(0.02시 = 0.6초)에 조도가 튄다 + var bases = new List { Meadow(), IslandBase() }; + foreach (KeyValuePair kv in AllFieldBases()) + if (kv.Key == "emberfall") bases.Add(kv.Value); + + foreach (WorldSkyRules.LightingState b in bases) + { + foreach (WeatherType w in AllWeathers) + { + for (int i = 0; i < 1200; i++) + { + float h = i * 0.02f; + WorldSkyRules.LightingState a = WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(h, w), true); + WorldSkyRules.LightingState c = WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(h + 0.02f, w), true); // 마지막 틱은 24시 = 0시로 이어진다 + Assert.LessOrEqual(Mathf.Abs(WorldSkyRules.UpLuminance(a) - WorldSkyRules.UpLuminance(c)), 0.03f, $"{w} {h:F2}시 위쪽 조도가 튄다"); + Assert.LessOrEqual(Mathf.Abs(WorldSkyRules.SideLuminance(a) - WorldSkyRules.SideLuminance(c)), 0.03f, $"{w} {h:F2}시 옆쪽 조도가 튄다"); + Assert.LessOrEqual(Mathf.Abs(a.lightIntensity - c.lightIntensity), 0.05f, $"{w} {h:F2}시 햇빛 세기가 튄다"); + Assert.LessOrEqual(ChannelDelta(a.fogColor, c.fogColor), 0.06f, $"{w} {h:F2}시 안개색이 튄다"); + Assert.LessOrEqual(ChannelDelta(a.background, c.background), 0.06f, $"{w} {h:F2}시 하늘색이 튄다"); + Assert.LessOrEqual(Quaternion.Angle(a.lightRotation, c.lightRotation), 6f, $"{w} {h:F2}시 햇빛 방향이 튄다"); + } + } + } + } + + private static float ChannelDelta(Color a, Color b) + { + return Mathf.Max(Mathf.Abs(a.r - b.r), Mathf.Max(Mathf.Abs(a.g - b.g), Mathf.Abs(a.b - b.b))); + } + + [Test] + public void Evaluate_HoursWrapAroundTheDay() + { + Assert.AreEqual(WorldSkyRules.Evaluate(23f, WeatherType.Rain).sunFactor, WorldSkyRules.Evaluate(-1f, WeatherType.Rain).sunFactor, 1e-5f); + Assert.AreEqual(WorldSkyRules.Evaluate(1f, WeatherType.Clear).moonFactor, WorldSkyRules.Evaluate(25f, WeatherType.Clear).moonFactor, 1e-5f); + Assert.AreEqual(WorldSkyRules.Evaluate(0f, WeatherType.Clear).starAlpha, WorldSkyRules.Evaluate(24f, WeatherType.Clear).starAlpha, 1e-5f); + } + + // ── 밤은 어둡되 보인다 ── + + [Test] + public void Night_StaysReadable_ForEveryRegionWeatherAndHour() + { + // 환경광 바닥이 구조적 보장이고(어떤 조합이어도 그 밑으로 못 내려간다), 조도 하한은 실측 여유를 둔 값이다 — + // 기본 필드 정오 맑음의 위쪽 조도가 약 1.43이고 가장 어두운 조합(어두운 리전·한낮 아닌 해 질 녘의 비)이 약 0.44다. + const float MinUp = 0.40f; + const float MinSide = 0.33f; + var bases = AllFieldBases(); + bases.Add(new KeyValuePair("island", IslandBase())); + + foreach (KeyValuePair kv in bases) + { + bool field = kv.Key != "island"; + foreach (WeatherType w in AllWeathers) + { + for (float h = 0f; h < 24f; h += 0.25f) + { + WorldSkyRules.LightingState s = WorldSkyRules.Apply(kv.Value, WorldSkyRules.Evaluate(h, w), field); + string tag = $"{kv.Key} {w} {h:F2}시"; + Assert.GreaterOrEqual(WorldSkyRules.Luminance(s.ambientSky), WorldSkyRules.AmbientFloorSky - 1e-4f, tag + " 위 환경광이 바닥 아래"); + Assert.GreaterOrEqual(WorldSkyRules.Luminance(s.ambientEquator), WorldSkyRules.AmbientFloorEquator - 1e-4f, tag + " 옆 환경광이 바닥 아래"); + Assert.GreaterOrEqual(WorldSkyRules.Luminance(s.ambientGround), WorldSkyRules.AmbientFloorGround - 1e-4f, tag + " 아래 환경광이 바닥 아래"); + Assert.GreaterOrEqual(WorldSkyRules.UpLuminance(s), MinUp, tag + " 위를 향한 면이 너무 어둡다"); + Assert.GreaterOrEqual(WorldSkyRules.SideLuminance(s), MinSide, tag + " 캐릭터 옆면이 너무 어둡다"); + } + } + } + } + + [Test] + public void Night_UsesColdDimMoonlightFromHighUp() + { + WorldSkyRules.LightingState b = Meadow(); + foreach (float h in new[] { 0f, 2f, 23f }) + { + WorldSkyRules.LightingState s = WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(h, WeatherType.Clear), true); + Assert.AreEqual(WorldSkyRules.MoonIntensity, s.lightIntensity, 0.01f, $"{h}시 달빛 세기"); + Assert.Less(s.lightIntensity, b.lightIntensity * 0.5f, $"{h}시 달빛이 낮 햇빛의 절반 아래여야 한다"); + Assert.Greater(s.lightColor.b, s.lightColor.r + 0.2f, $"{h}시 달빛이 차갑고 푸르지 않다"); + Assert.Greater(SunElevation01(s), 0.6f, $"{h}시 달이 낮게 떠 있다 — 그림자가 길어진다"); + } + } + + [Test] + public void Moonlight_TakesOverBeforeDuskIsDarkerThanNight() + { + // 달빛이 20시에야 켜지면 해가 약해진 19시가 한밤보다 어두운 홈이 생긴다(실측 74%) — 18~22시가 한밤 아래로 꺼지지 않는다 + WorldSkyRules.LightingState b = Meadow(); + float night = WorldSkyRules.UpLuminance(WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(0f, WeatherType.Clear), true)); + for (float h = 17f; h <= 22f; h += 0.1f) + { + float lum = WorldSkyRules.UpLuminance(WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(h, WeatherType.Clear), true)); + Assert.GreaterOrEqual(lum, night * 0.95f, $"{h:F1}시가 한밤보다 눈에 띄게 어둡다 ({lum:F3} < {night:F3})"); + } + float dawnNight = WorldSkyRules.UpLuminance(WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(4f, WeatherType.Clear), true)); + for (float h = 4.5f; h <= 7f; h += 0.1f) + { + float lum = WorldSkyRules.UpLuminance(WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(h, WeatherType.Clear), true)); + Assert.GreaterOrEqual(lum, dawnNight * 0.95f, $"{h:F1}시 새벽이 한밤보다 눈에 띄게 어둡다"); + } + } + + [Test] + public void Night_IsDarkerThanDayButNotBlack() + { + WorldSkyRules.LightingState b = Meadow(); + float noon = WorldSkyRules.UpLuminance(WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(12f, WeatherType.Clear), true)); + float night = WorldSkyRules.UpLuminance(WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(0f, WeatherType.Clear), true)); + Assert.Less(night, noon * 0.6f, "밤이 낮만큼 밝으면 밤이 아니다"); + Assert.Greater(night, noon * 0.3f, "밤이 낮의 30%도 안 되면 곤충·캐릭터가 안 보인다"); + } + + [Test] + public void Stars_ShowAtNightAndHideInDaylightAndUnderClouds() + { + Assert.AreEqual(1f, WorldSkyRules.Evaluate(0f, WeatherType.Clear).starAlpha, 1e-4f); + Assert.AreEqual(0f, WorldSkyRules.Evaluate(12f, WeatherType.Clear).starAlpha, 1e-4f); + Assert.Less(WorldSkyRules.Evaluate(0f, WeatherType.Rain).starAlpha, 0.3f, "비 오는 밤에도 별이 환하다"); + Assert.Less(WorldSkyRules.Evaluate(0f, WeatherType.Rain).starAlpha, WorldSkyRules.Evaluate(0f, WeatherType.Wind).starAlpha); + } + + // ── 날씨 ── + + [Test] + public void WeatherType_EveryNonClearWeatherDimsTheSun() + { + // 새 날씨를 enum에 늘리고 GradeOf에 칸을 안 만들면 조용히 맑음으로 떨어진다 — 정오에서 맑음과 달라야 한다 + Assert.AreEqual(5, AllWeathers.Length, "WeatherType이 늘었다 — WorldSkyRules.GradeOf·WeatherMix·WeatherEffects에 칸을 만들 것"); + float clear = WorldSkyRules.Evaluate(12f, WeatherType.Clear).sunFactor; + foreach (WeatherType w in AllWeathers) + { + if (w == WeatherType.Clear) continue; + Assert.Less(WorldSkyRules.Evaluate(12f, w).sunFactor, clear, w + "이 맑음과 같은 빛이다"); + Assert.Greater(WorldSkyRules.Evaluate(12f, w).overcast, 0f, w + "이 흐림 0이다"); + } + } + + [Test] + public void Weather_RainIsDarkerThanClearAndFogIsThickerThanRain() + { + WorldSkyRules.LightingState b = Meadow(); + WorldSkyRules.LightingState clear = WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(12f, WeatherType.Clear), true); + WorldSkyRules.LightingState rain = WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(12f, WeatherType.Rain), true); + WorldSkyRules.LightingState fog = WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(12f, WeatherType.Fog), true); + Assert.Less(WorldSkyRules.UpLuminance(rain), WorldSkyRules.UpLuminance(clear) * 0.7f, "비 오는 낮이 맑은 낮만큼 밝다"); + Assert.Greater(fog.fogDensity, rain.fogDensity, "안개 날씨가 비보다 옅다"); + Assert.Greater(rain.fogDensity, clear.fogDensity, "비 오는 날 연무가 맑은 날과 같다"); + Assert.Less(rain.shadowStrength, clear.shadowStrength * 0.5f, "흐린 날 그림자가 맑은 날만큼 진하다"); + } + + [Test] + public void FogWeather_KeepsThePlayerReadable_InTheField() + { + // 안개 낀 날이라도 카메라(11m)에서 캐릭터가 읽혀야 한다 — 같은 식(FogTransmittance)·같은 모드 상수로 모든 리전·시각·전환을 훑는다 + float dist = RegionAtmosphere.PlayerCameraDistance; + float worst = 1f; + foreach (KeyValuePair kv in AllFieldBases()) + { + foreach (WeatherType from in AllWeathers) + { + foreach (WeatherType to in AllWeathers) + { + for (float blend = 0f; blend <= 1f; blend += 0.25f) + { + WorldSkyRules.WeatherMix mix = WorldSkyRules.WeatherMix.Blend(from, to, blend); + for (float h = 0f; h < 24f; h += 3f) + { + WorldSkyRules.LightingState s = WorldSkyRules.Apply(kv.Value, WorldSkyRules.Evaluate(h, mix), true); + Assert.LessOrEqual(s.fogDensity, RegionAtmosphere.MaxWeatherFogDensity + 1e-6f, $"{kv.Key} {from}→{to} {blend}"); + float t = RegionAtmosphere.FogTransmittance(RegionAtmosphere.FieldFogMode, s.fogDensity, dist); + worst = Mathf.Min(worst, t); + Assert.GreaterOrEqual(t, RegionAtmosphere.MinWeatherTransmittance, + $"{kv.Key} {from}→{to} {blend:F2}: {RegionAtmosphere.FieldFogMode} {dist}m 투과율 {t:F3}"); + } + } + } + } + } + // 상한 자체도 같은 식으로 통과해야 한다 — 상수를 올리다 하한과 어긋나면 위 검사가 못 잡는 조합이 생긴다 + Assert.GreaterOrEqual(RegionAtmosphere.FogTransmittance(RegionAtmosphere.FieldFogMode, RegionAtmosphere.MaxWeatherFogDensity, dist), + RegionAtmosphere.MinWeatherTransmittance, "안개 날씨 밀도 상한의 11m 투과율이 하한 아래다"); + // 안개 날씨가 실제로 안개로 보여야 한다 — 하한만 지키려고 가산을 0으로 두면 이 날씨가 조용히 사라진다 + Assert.Less(worst, 0.95f, "안개 날씨의 최소 11m 투과율이 95%를 넘는다 — 안개가 안 보인다"); + } + + [Test] + public void FogWeather_ThickensFarFogMoreThanTheRegionHaze() + { + WorldSkyRules.LightingState b = Meadow(); + float baseFar = RegionAtmosphere.FogTransmittance(RegionAtmosphere.FieldFogMode, b.fogDensity, 40f); + WorldSkyRules.LightingState fog = WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(12f, WeatherType.Fog), true); + float fogFar = RegionAtmosphere.FogTransmittance(RegionAtmosphere.FieldFogMode, fog.fogDensity, 40f); + Assert.Less(fogFar, 0.35f, "안개 날씨에 40m 밖 풍경이 안 묻힌다"); + Assert.Greater(baseFar, 0.8f, "리전 연무가 40m에서 이미 짙다 — 이 비교가 의미를 잃었다"); + } + + [Test] + public void FogWeather_KeepsThePlayerReadable_OnTheIsland() + { + // 섬은 분리 서브에리어라 안개가 지수(Exp)다 — 환산 계수와 상한이 11m 투과율을 지킨다 + float dist = RegionAtmosphere.PlayerCameraDistance; + WorldSkyRules.LightingState island = IslandBase(); + foreach (WeatherType from in AllWeathers) + { + foreach (WeatherType to in AllWeathers) + { + for (float blend = 0f; blend <= 1f; blend += 0.25f) + { + WorldSkyRules.LightingState s = WorldSkyRules.Apply(island, + WorldSkyRules.Evaluate(12f, WorldSkyRules.WeatherMix.Blend(from, to, blend)), false); + Assert.LessOrEqual(s.fogDensity, RegionAtmosphere.MaxWeatherFogDensitySubArea + 1e-6f, $"{from}→{to} {blend}"); + float t = RegionAtmosphere.FogTransmittance(RegionAtmosphere.SubAreaFogMode, s.fogDensity, dist); + Assert.GreaterOrEqual(t, RegionAtmosphere.MinWeatherTransmittanceSubArea, $"섬 {from}→{to} {blend:F2}: 투과율 {t:F3}"); + } + } + } + Assert.GreaterOrEqual( + RegionAtmosphere.FogTransmittance(RegionAtmosphere.SubAreaFogMode, RegionAtmosphere.MaxWeatherFogDensitySubArea, dist), + RegionAtmosphere.MinWeatherTransmittanceSubArea, "섬 안개 밀도 상한의 11m 투과율이 하한 아래다"); + WorldSkyRules.LightingState fog = WorldSkyRules.Apply(island, WorldSkyRules.Evaluate(12f, WeatherType.Fog), false); + Assert.Greater(fog.fogDensity, island.fogDensity * 1.5f, "섬에서 안개 날씨가 안 보인다"); + } + + [Test] + public void FogWeather_TurnsOnFogWhereTheBaseHasNone() + { + // 길 위(리전 밖)는 기본 안개가 꺼져 있다 — 안개 날씨가 오면 켜져야 하고, 날씨가 없으면 꺼진 채여야 한다 + WorldSkyRules.LightingState b = Meadow(); + b.fogEnabled = false; + b.fogDensity = 0f; + Assert.IsFalse(WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(12f, WeatherType.Clear), true).fogEnabled); + WorldSkyRules.LightingState fog = WorldSkyRules.Apply(b, WorldSkyRules.Evaluate(12f, WeatherType.Fog), true); + Assert.IsTrue(fog.fogEnabled); + Assert.Greater(fog.fogDensity, 0.01f); + } + + [Test] + public void WeatherMix_BlendEndsAreExactAndMiddleIsBetween() + { + WorldSkyRules.WeatherMix start = WorldSkyRules.WeatherMix.Blend(WeatherType.Rain, WeatherType.Clear, 0f); + Assert.AreEqual(1f, start.rain, 1e-6f); + Assert.AreEqual(0f, start.clear, 1e-6f); + WorldSkyRules.WeatherMix end = WorldSkyRules.WeatherMix.Blend(WeatherType.Rain, WeatherType.Clear, 1f); + Assert.AreEqual(0f, end.rain, 1e-6f); + Assert.AreEqual(1f, end.clear, 1e-6f); + WorldSkyRules.WeatherMix mid = WorldSkyRules.WeatherMix.Blend(WeatherType.Rain, WeatherType.Clear, 0.5f); + Assert.AreEqual(1f, mid.Total, 1e-5f, "가중치 합이 1이 아니다"); + Assert.AreEqual(0.5f, mid.rain, 1e-5f); + + // 전환 양끝의 하늘이 각 날씨 하나만 평가한 것과 같다 + foreach (WeatherType from in AllWeathers) + { + foreach (WeatherType to in AllWeathers) + { + WorldSkyRules.SkyFrame a0 = WorldSkyRules.Evaluate(15f, WorldSkyRules.WeatherMix.Blend(from, to, 0f)); + WorldSkyRules.SkyFrame a1 = WorldSkyRules.Evaluate(15f, from); + Assert.AreEqual(a1.sunFactor, a0.sunFactor, 1e-5f, $"{from}→{to} 시작"); + Assert.AreEqual(a1.fogAdd, a0.fogAdd, 1e-6f, $"{from}→{to} 시작 안개"); + WorldSkyRules.SkyFrame b0 = WorldSkyRules.Evaluate(15f, WorldSkyRules.WeatherMix.Blend(from, to, 1f)); + WorldSkyRules.SkyFrame b1 = WorldSkyRules.Evaluate(15f, to); + Assert.AreEqual(b1.sunFactor, b0.sunFactor, 1e-5f, $"{from}→{to} 끝"); + Assert.AreEqual(b1.overcast, b0.overcast, 1e-6f, $"{from}→{to} 끝 흐림"); + } + } + + WorldSkyRules.SkyFrame clear = WorldSkyRules.Evaluate(12f, WeatherType.Clear); + WorldSkyRules.SkyFrame rain = WorldSkyRules.Evaluate(12f, WeatherType.Rain); + WorldSkyRules.SkyFrame between = WorldSkyRules.Evaluate(12f, WorldSkyRules.WeatherMix.Blend(WeatherType.Clear, WeatherType.Rain, 0.5f)); + Assert.Less(between.sunFactor, clear.sunFactor); + Assert.Greater(between.sunFactor, rain.sunFactor); + } + + [Test] + public void WeatherMix_SameWeatherBlendIsThatWeatherAtAnyProgress() + { + foreach (WeatherType w in AllWeathers) + for (float blend = 0f; blend <= 1f; blend += 0.25f) + Assert.AreEqual(1f, WorldSkyRules.WeatherMix.Blend(w, w, blend).Get(w), 1e-5f, $"{w} {blend}"); + } + + [Test] + public void WeatherMix_FromZeroWeights_FallsBackToClear() + { + // 가중치가 전부 0인 입력(방어)이 NaN을 만들지 않고 맑음으로 평가된다 + WorldSkyRules.SkyFrame f = WorldSkyRules.Evaluate(12f, default(WorldSkyRules.WeatherMix)); + Assert.AreEqual(WorldSkyRules.Evaluate(12f, WeatherType.Clear).sunFactor, f.sunFactor, 1e-6f); + Assert.IsFalse(float.IsNaN(f.overcast)); + } + + [Test] + public void WeatherMix_FromRegionClimate_FollowsForecastEffectiveWeather() + { + // 입자·조명은 지역 기준 날씨를 읽는다 — 설산의 비는 눈, 사막의 눈은 센바람 + var go = new GameObject("WorldSkyRulesTestWeather"); + try + { + WeatherSystem ws = go.AddComponent(); + ws.SetWeather(WeatherType.Rain, hold: true, instant: true); + Assert.AreEqual(1f, WorldSkyRules.WeatherMix.From(ws, "meadow").rain, 1e-5f, "초원의 비는 비"); + Assert.AreEqual(1f, WorldSkyRules.WeatherMix.From(ws, "frostline").snow, 1e-5f, "설산의 비는 눈"); + Assert.AreEqual(0f, WorldSkyRules.WeatherMix.From(ws, "frostline").rain, 1e-5f); + Assert.AreEqual(1f, WorldSkyRules.WeatherMix.From(ws, null).rain, 1e-5f, "섬·길 위는 세계 날씨 그대로"); + ws.SetWeather(WeatherType.Snow, hold: true, instant: true); + Assert.AreEqual(1f, WorldSkyRules.WeatherMix.From(ws, "dunes").wind, 1e-5f, "사막의 눈은 센바람"); + Assert.AreEqual(1f, WorldSkyRules.WeatherMix.From(ws, "meadow").snow, 1e-5f); + Assert.AreEqual(1f, WorldSkyRules.WeatherMix.From(null, "meadow").clear, 1e-5f, "시스템이 없으면 맑음"); + } + finally + { + UnityEngine.Object.DestroyImmediate(go); + } + } + + // ── 판정 ── + + [Test] + public void Applies_OnlyOnTheFieldAndTheIsland_NeverInTheDream() + { + Assert.IsTrue(WorldSkyRules.Applies(false, null, false), "메인 필드"); + Assert.IsTrue(WorldSkyRules.Applies(true, GameConstants.Island.SubAreaId, false), "나의 섬"); + Assert.IsFalse(WorldSkyRules.Applies(true, "mountain_cave", false), "동굴은 실내"); + Assert.IsFalse(WorldSkyRules.Applies(true, "greenhouse", false), "방은 실내"); + Assert.IsFalse(WorldSkyRules.Applies(false, null, true), "꿈속 필드는 늘 맑은 한낮"); + Assert.IsFalse(WorldSkyRules.Applies(true, GameConstants.Island.SubAreaId, true), "꿈속 섬은 늘 맑은 한낮"); + } + + // ── 보간 ── + + [Test] + public void LightingState_LerpEndsAreExactAndFogOffSideCountsAsZeroDensity() + { + WorldSkyRules.LightingState a = Meadow(); + WorldSkyRules.LightingState b = WorldSkyRules.Apply(a, WorldSkyRules.Evaluate(0f, WeatherType.Rain), true); + WorldSkyRules.LightingState at0 = WorldSkyRules.LightingState.Lerp(a, b, 0f); + WorldSkyRules.LightingState at1 = WorldSkyRules.LightingState.Lerp(a, b, 1f); + Assert.AreEqual(a.lightIntensity, at0.lightIntensity, 1e-6f); + Assert.AreEqual(b.lightIntensity, at1.lightIntensity, 1e-6f); + AssertColor(a.ambientSky, at0.ambientSky, 1e-6f, "t=0 환경광"); + AssertColor(b.ambientSky, at1.ambientSky, 1e-6f, "t=1 환경광"); + Assert.Less(Quaternion.Angle(a.lightRotation, at0.lightRotation), 0.01f); + Assert.Less(Quaternion.Angle(b.lightRotation, at1.lightRotation), 0.01f); + + // 꺼진 쪽의 밀도 찌꺼기가 번지지 않는다 + a.fogEnabled = false; + a.fogDensity = 0.5f; + b.fogEnabled = true; + b.fogDensity = 0.01f; + Assert.AreEqual(0.005f, WorldSkyRules.LightingState.Lerp(a, b, 0.5f).fogDensity, 1e-6f); + Assert.IsTrue(WorldSkyRules.LightingState.Lerp(a, b, 0.5f).fogEnabled); + } + + [Test] + public void SkyFrame_LightLevelStaysBetweenNightAndNoon() + { + foreach (WeatherType w in AllWeathers) + { + for (float h = 0f; h < 24f; h += 0.5f) + { + float level = WorldSkyRules.Evaluate(h, w).lightLevel; + Assert.GreaterOrEqual(level, 0.4f - 1e-5f, $"{w} {h}시"); + Assert.LessOrEqual(level, 1f + 1e-5f, $"{w} {h}시"); + } + } + Assert.AreEqual(1f, WorldSkyRules.Evaluate(12f, WeatherType.Clear).lightLevel, 1e-5f); + Assert.AreEqual(0.4f, WorldSkyRules.Evaluate(0f, WeatherType.Clear).lightLevel, 1e-5f); + } + + // ── 흐린 하늘의 연무 막 ── + + private static readonly WeatherType[] OvercastWeathers = { WeatherType.Rain, WeatherType.Fog, WeatherType.Snow }; + + private static float Saturation(Color c) => Mathf.Max(c.r, Mathf.Max(c.g, c.b)) - Mathf.Min(c.r, Mathf.Min(c.g, c.b)); + + [Test] + public void SkyHaze_IsZeroOnClearDays_SoTheVeilCostsNothing() + { + // 맑은 하늘은 지금 그대로여야 한다 — 짙기가 0이면 연무 돔이 GameObject째 꺼진다 + for (float h = 0f; h < 24f; h += 0.25f) + Assert.AreEqual(0f, WorldSkyRules.Evaluate(h, WeatherType.Clear).skyHaze, 1e-6f, $"{h}시"); + } + + [Test] + public void SkyHaze_FullyVeilsTheHorizon_OnOvercastDays() + { + // 비·눈·안개는 지평선이 안개색으로 완전히 덮여야 산이 하늘로 녹는다. 센바람은 먼지 낀 맑은 날이라 옅다. + for (float h = 0f; h < 24f; h += 0.5f) + { + foreach (WeatherType w in OvercastWeathers) + Assert.AreEqual(1f, WorldSkyRules.Evaluate(h, w).skyHaze, 1e-5f, $"{w} {h}시"); + float wind = WorldSkyRules.Evaluate(h, WeatherType.Wind).skyHaze; + Assert.Greater(wind, 0f, $"센바람 {h}시 — 먼지 낀 지평선이 사라졌다"); + Assert.Less(wind, 0.3f, $"센바람 {h}시 — 맑은 날인데 하늘이 덮였다"); + } + } + + [Test] + public void SkyHaze_RisesSmoothlyThroughAWeatherTransition() + { + // 10초 날씨 전환 동안 연무가 뚝 켜지면 하늘이 한 프레임에 회색으로 바뀐다 + foreach (WeatherType w in OvercastWeathers) + { + float prev = WorldSkyRules.Evaluate(12f, WorldSkyRules.WeatherMix.Blend(WeatherType.Clear, w, 0f)).skyHaze; + Assert.AreEqual(0f, prev, 1e-6f, $"맑음→{w} 시작"); + for (int i = 1; i <= 100; i++) + { + float haze = WorldSkyRules.Evaluate(12f, WorldSkyRules.WeatherMix.Blend(WeatherType.Clear, w, i / 100f)).skyHaze; + Assert.GreaterOrEqual(haze, prev - 1e-6f, $"맑음→{w} {i}% 연무가 줄었다"); + Assert.LessOrEqual(haze - prev, 0.05f, $"맑음→{w} {i}% 연무가 튄다"); + prev = haze; + } + Assert.AreEqual(1f, prev, 1e-5f, $"맑음→{w} 끝"); + } + } + + [Test] + public void VeilProfile_IsDensestAtTheHorizonAndThinsUpward() + { + Assert.AreEqual(1f, WorldSkyRules.VeilProfile(-90f), 1e-6f, "땅 쪽"); + Assert.AreEqual(1f, WorldSkyRules.VeilProfile(0f), 1e-6f, "지평선"); + Assert.AreEqual(1f, WorldSkyRules.VeilProfile(WorldSkyRules.VeilBandDegrees), 1e-6f, "지평선 띠"); + Assert.AreEqual(WorldSkyRules.VeilZenithFactor, WorldSkyRules.VeilProfile(90f), 1e-6f, "천정"); + Assert.GreaterOrEqual(WorldSkyRules.VeilZenithFactor, 0.7f, "천정이 너무 맑다 — 흐린 날 위쪽이 파랗게 남는다"); + Assert.Less(WorldSkyRules.VeilZenithFactor, 1f, "세로 분포가 없다 — 지평선과 천정이 같은 짙기다"); + float prev = 1f; + for (float e = -90f; e <= 90f; e += 0.5f) + { + float v = WorldSkyRules.VeilProfile(e); + Assert.LessOrEqual(v, prev + 1e-6f, $"{e}°에서 위로 갈수록 짙어졌다"); + Assert.LessOrEqual(prev - v, 0.01f, $"{e}°에서 짙기가 튄다"); + prev = v; + } + } + + [Test] + public void OvercastSky_IsGreyerThanClear_AndNeverWarmer() + { + // 흐린 날 하늘(연무 막 = 안개색, 섬은 배경색도)이 맑은 날보다 채도가 낮아야 회색으로 읽힌다. 그리고 맑은 날보다 + // 따뜻(붉음)해지면 안 된다 — 2026-10-03 캡처에서 흐린 정오의 지평선이 해 질 녘처럼 주황으로 찍혔다. + var bases = AllFieldBases(); + bases.Add(new KeyValuePair("island", IslandBase())); + foreach (KeyValuePair kv in bases) + { + bool field = kv.Key != "island"; + foreach (float h in new[] { 0f, 6.5f, 12f, 15f, 19f }) + { + WorldSkyRules.LightingState clear = WorldSkyRules.Apply(kv.Value, WorldSkyRules.Evaluate(h, WeatherType.Clear), field); + foreach (WeatherType w in OvercastWeathers) + { + WorldSkyRules.LightingState o = WorldSkyRules.Apply(kv.Value, WorldSkyRules.Evaluate(h, w), field); + string tag = $"{kv.Key} {w} {h}시"; + Assert.LessOrEqual(Saturation(o.fogColor), Saturation(clear.fogColor) * 0.5f + 0.01f, tag + " 하늘(안개색)이 회색이 아니다"); + Assert.LessOrEqual(Saturation(o.background), Saturation(clear.background) * 0.5f + 0.01f, tag + " 배경 하늘이 회색이 아니다"); + Assert.LessOrEqual(o.fogColor.r - o.fogColor.b, Mathf.Max(0f, clear.fogColor.r - clear.fogColor.b) + 1e-4f, + tag + " 흐린 하늘이 맑은 하늘보다 붉다"); + Assert.LessOrEqual(WorldSkyRules.Luminance(o.fogColor), WorldSkyRules.Luminance(clear.fogColor) + 1e-4f, + tag + " 흐린 하늘이 맑은 하늘보다 밝다"); + } + } + } + } + + [Test] + public void SkyVisuals_NeverThickensTheProceduralAtmosphere() + { + // 절차적 스카이박스의 대기 두께를 올리면 뿌예지는 게 아니라 지평선이 주황이 된다(레일리 계수가 두께의 2.5제곱). + // 흐림은 연무 돔이 맡는다 — 두께 속성을 다시 만지면 이 검사가 잡는다. + string src = File.ReadAllText(Path.Combine(Application.dataPath, "..", "Assets/Scripts/Core/WorldSkyVisuals.cs")); + StringAssert.DoesNotContain("\"_AtmosphereThickness\"", src, "스카이박스 대기 두께를 바꾸고 있다 — 흐린 날 지평선이 노을처럼 붉어진다"); + StringAssert.Contains("WorldSkyRules.VeilProfile", src, "연무 돔이 규칙의 세로 분포를 쓰지 않는다"); + } + } +} +#endif diff --git a/Assets/Tests/EditMode/WorldSkyRulesTests.cs.meta b/Assets/Tests/EditMode/WorldSkyRulesTests.cs.meta new file mode 100644 index 0000000..7294314 --- /dev/null +++ b/Assets/Tests/EditMode/WorldSkyRulesTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 036afad8dd9ca2942b4a1745812c2b7d \ No newline at end of file diff --git a/Assets/link.xml b/Assets/link.xml index cab1a66..d25a42d 100644 --- a/Assets/link.xml +++ b/Assets/link.xml @@ -8,4 +8,10 @@ PhysicsModule 전체를 보존해 스트리핑을 재활성화하더라도 안전하게 유지한다. --> + + diff --git a/CLAUDE.md b/CLAUDE.md index 57a65aa..1dd4cf0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,22 +58,27 @@ RaidBattleController → RaidBattleUI |---|---|---| | 배틀 1v1 | `InsectBattleController` | 턴제, 스킬 쿨다운, 도주 판정 | | 레이드 5v1 | `RaidBattleController` | 보스 스탯 배율, 유나이트 게이지, 주기적 AOE | -| 포획 | `CaptureController` → `CaptureMinigameController` | 3단계 난이도 미니게임 | -| 스폰 | `InsectSpawner` | WorldState(시간+날씨) 후보 필터링, 거리 컬링 | +| 포획 | `CaptureController` → `CaptureMinigameController` | 미니게임 3종(살금살금·가두기·던지기)이 포획마다 무작위. 규칙은 `CaptureMinigame`(순수), 결과는 0~3점 | +| 스폰 | `InsectSpawner` → `FieldSpawnRules`·`FieldPopulation` | 리전별 슬롯 기록(리전 이동으로 리롤 안 함) + 시간 기반 재생·수명 순환, 45m 실체화/55m 회수. 희귀도는 전역 등급표, 레벨만 리전 대역 | | 세이브 | `PlayerProgressSaveService`, `CloudSaveManager` | 로컬 7개 JSON + Firestore. 규칙은 `rules/save-system.md` | | 스탯/IV | `PlayerInsectData` | IV 0~15(HP/ATK/DEF), 등급 S~D | +| 첫 시작 연출 | `DreamPrologueDirector` | 「챔피언의 꿈」 — 신규 계정 1회(설정에서 다시보기). 경기장 입장 영상 → 챔피언전(샌드박스 전투) → 꾸며진 섬 걷기 → 깨어남. **꿈 밖 기록을 건드리지 않는 약속**을 `DreamPrologueState.Active`로 지킨다. 규칙은 `rules/dream-prologue.md` | +| 나의 섬 | `IslandManager` → `IslandWorldBuilder` | 개인 섬 하우징. 칸 배치·방목 시간 보상·스냅샷 방문. **분리 서브에리어**로 탄다. 규칙은 `rules/island.md` | +| 낮·밤·날씨 | `GameClock` · `WeatherSystem` → `SubAreaEnvironment`(하늘) · `InsectSpawner`(성향·보너스 슬롯) | 하루 12분, 날씨 5종(맑음·비·안개·센바람·눈). 날씨는 세계에 하나지만 **보이는 건 지역 기후로 다르다**(`GetWorldState(regionId)`). 곤충 194종이 `InsectHabitTable`의 성향(주행성·야행성·좋아하는 날씨)을 든다. 규칙은 `rules/world-environment.md` | ### UI 흐름 ``` MainMenu → PlayScene ├→ 필드 탐험 (PlayerMovement + HUD) ├→ CaptureChoiceUI (곤충 접근 시) - │ ├→ [E] 미니게임 포획 + │ ├→ [E] 미니게임 포획 (지난번 채집망으로 바로 — 버튼 아래 줄에서 바꾼다) │ ├→ [B] 1v1 배틀 │ └→ [R] 레이드 (Epic/Legendary만) ├→ DexScreenUI (도감) ├→ CollectionUI (보유 곤충) ├→ ShopUI / GachaUI + ├→ IslandVisitUI (탐험 메뉴 [내 섬] · 본 마을 나루터) + │ └→ 섬: IslandHudUI → 꾸미기 / 상점 / 곤충 / 수확 / 방문 / 도움말 └→ SettingsPanel ``` @@ -87,7 +92,10 @@ AutoWire·이벤트·오브젝트 풀 패턴, 금지 사항이 전부 거기 있 `rules/ui-layout.md`(배치는 `UISafeLayout`, 표면·색은 `UISurface`+`UITheme` 토큰 경유 강제), `rules/save-system.md`(세이브 필드 추가), `rules/scriptable-objects.md`(SO 생성), -`rules/quest-system.md`(퀘스트 추가 시 다지점 등록 — 빠뜨리면 영구 정지), +`rules/quest-system.md`(퀘스트 추가 시 다지점 등록 — 빠뜨리면 영구 정지. 크기·속성·전투 조건은 새 타입 없이 `CaptureTrait`·`BattleFeat` + 조건 필드), +`rules/dream-prologue.md`(첫 시작 연출 — 표지를 읽어야 하는 자리, 샌드박스 전투), +`rules/island.md`(나의 섬 — `detached` 서브에리어를 걸러야 하는 자리, 물건 추가 3곳, 다이아 환급 금지), +`rules/world-environment.md`(낮·밤·날씨 — 지역 기준 날씨를 읽어야 하는 자리, 하늘 보정의 이중 적용 금지, 새 곤충은 성향표 한 줄), `rules/testing.md`(테스트 필수 기준), `rules/agent-coordination.md`(공유 파일 수정 경계). @@ -98,11 +106,11 @@ AutoWire·이벤트·오브젝트 풀 패턴, 금지 사항이 전부 거기 있 | 검사기 | 강제하는 규칙 | 단일 출처 | |---|---|---| -| `quest_lint.py` | questId 중복·prerequisite 무결성·QuestType↔진행 배선 등 11검사 | `rules/quest-system.md` | +| `quest_lint.py` | questId 중복·prerequisite 무결성·QuestType↔진행 배선·지역 의뢰 정합·조건부 퀘스트 정합 등 13검사 | `rules/quest-system.md` | | `ui_layout_lint.py` | 패널 y·height 직접 계산 금지 (`UISafeLayout` 경유) | `rules/ui-layout.md` | | `subscription_lint.py` | `OnDisable`에서 해지한 구독을 `OnEnable`에서 되살릴 것 | `rules/ui-layout.md` | | `data_lint.py` | 곤충·아이템·리전 데이터 정합(ID 유일성, 참조 무결, 풀 배정) | 코드(`InsectDatabase` 등)와 스크립트 자신 | -| `story_lint.py` | 스토리 비트 트리거·보상·리전키 정합 + 스토리 NPC 4중 등록(앰비언트·소개비트·표시명·외형) 24검사 | 코드(`StoryBeat`)와 스크립트 자신 | +| `story_lint.py` | 스토리 비트 트리거·보상·리전키 정합 + 스토리 NPC 4중 등록(앰비언트·소개비트·표시명·외형) + 컷신·영상 ID 실재성 + 대사 화자·줄 연출 토큰 + 퀘스트 게이트의 1회 완료형 + 명부회 어휘(주인공 쪽이 「거둬들이다」를 안 쓴다) + 쉬운 이야기(대사 한 줄·장면 길이·쓰지 않는 말·HUD 목표 이유 why·장 「지난 이야기」) + 대사 직후 대결(`duelAfter` 상대·자리·라온 단계·런처 배선, 간부 승리 스파인의 재확인) + 대사 앞 영상(`introVideoId` 실재·`videoId`와 배타·대사 1~6줄) 36검사 | 코드(`StoryBeat`)와 스크립트 자신 | | `dex_grant_lint.py` | 곤충을 지급하면 도감에도 올릴 것(`AddCapturedInsect`↔`RegisterCapture`) | 코드(`DexController`)와 스크립트 자신 | | `blight_lint.py` | 명부회 아크 — 거점(보스·귀환종·비트·재도전 예외·스폰 하한·퀘스트 달성 가능성)과 「장부」 소모 지점 20검사 | 코드(`RegionData` 거점 필드)와 스크립트 자신 | | `singleton_lint.py` | 싱글턴이 `OnDestroy`에서 `Instance`를 비울 것 | 코드(`*Manager.cs`)와 스크립트 자신 | diff --git a/Docs/BattlePacingValidation.md b/Docs/BattlePacingValidation.md new file mode 100644 index 0000000..ad93ad3 --- /dev/null +++ b/Docs/BattlePacingValidation.md @@ -0,0 +1,42 @@ +# Battle pacing validation — 2026-09-18 + +## Implemented behavior + +- Normal attack presentation: 1.8 seconds; skill: 2.5 seconds at 1x. Arena callback reveals HP and damage numbers at impact. 2x affects presentation delta only. +- Resolved rounds retain the player-action HP snapshot separately from the final enemy reply. Enemy healing and player healing therefore reveal on their own action, not prematurely. +- An enemy reply is presented before pending defeat/swap, including lethal replies. End-of-action faint presentation gets 0.7 presentation seconds before the result/swap screen. +- Damage labels follow the actual target model screen position. Controller hit/faint/text effects are deferred while the battle screen owns presentation; messages drain at impact. +- Ordinary wild direct-hit damage uses multiplier 0.7 before the existing defense ratio. Duel/guardian/raid, damage over time, save stats and HP growth remain unchanged. + +## Executed checks + +`python Docs/battle_pacing_measure.py` passed. This is a portable formula proxy, not Unity execution or a live-species balance certification. + +60 neutral, equal-stat synthetic fixtures: five rarity stat bands × four levels (5/15/30/50) × three skill powers (18/30/45). Skill cooldown 2, player basic fallback, enemy existing null-skill fallback, no IV/outfit/item bonuses or STAB. Deterministic; no random draws. + +| Version | Median rounds | Min–max | Round-count distribution | +|---|---:|---:|---| +| Baseline | 3 | 1–4 | 1:1, 2:19, 3:38, 4:2 | +| Wild pacing 0.7 | 4 | 2–5 | 2:1, 3:24, 4:33, 5:2 | + +The median meets the 4–6 target in this deliberately neutral fixture matrix. This does not imply every matchup lasts 4–6 rounds. Real species with STAB, status effects, IVs and equipped skills still need runtime distribution measurement. + +`python .claude/scripts/ui_layout_lint.py`: PASS, 231 files, 0 violations at execution time. + +## Added Unity regression coverage + +`BattlePacingTests`: 60-fixture controller matrix; unchanged max HP/duel and guardian damage; player-action snapshots before reply; lethal player hit without invented reply; stunned enemy without invented attack; 1x/2x damage and cooldown equivalence. + +Unity test execution and rendered validation are **not claimed**. Root verification found Unity editor license failure (exit 198). Run the project's PlayMode test runner, not EditMode (which discovers zero tests), once the license is available. Check fresh XML and nonzero test count. + +## Outstanding runtime acceptance + +Fatal enemy hit → faint → swap/result; poison KO; heal/buff/miss text and HP timing; stun/escape; 16:9/4:3/mobile target labels; repeated battles; 1x/2x switching; reduced motion/flashes; Android frame-time comparison. UI and video validation require a real Game view or standalone capture including IMGUI. + +## Adversarial review follow-up + +Separated the after-enemy-action snapshot from final round state. Poison KO now appears as end-of-round HP loss rather than being counted as the enemy's direct hit. Healing/buffs no longer play the UI hit sound when they deal no damage. Player stun and failed escape do not launch a player attack model animation. Invalid skills retain the current round and snapshots. + +Disabled UI ignores delayed impact callbacks, clears stale input, and restores deferred-presentation ownership on enable. Interrupted action completion uses its retained snapshot; an arena cleaned up while hidden is reconstructed from encounter origins. An interrupted faint restarts before the result transition. + +Added controller/UI phase regression cases for poison KO snapshots, player stun, invalid skills, deterministic failed escape, healing snapshots and disable/enable resume. Added recovery (30% heal, cooldown 3) and defense (35%, duration 3, cooldown 3) rotations against an equal-stat damaging opponent, with damage fallback and a 20-round stall bound. These NUnit cases are source additions, not claimed executed Unity results. diff --git a/Docs/DesignIntegrationValidation.md b/Docs/DesignIntegrationValidation.md new file mode 100644 index 0000000..61f5caa --- /dev/null +++ b/Docs/DesignIntegrationValidation.md @@ -0,0 +1,80 @@ +# 모델·UI·전투 통합 개선 — 구현 및 검증 기록 + +2026-09-18. 구현은 저장소 작업 트리에 적용했다. Unity 라이선스 오류 때문에 실제 실행 화면과 Android 성능의 최종 합격 판정은 보류한다. + +## 적용된 변경 + +| 영역 | 이전 경로/문제 | 적용한 변경 | +|---|---|---| +| 1대1 구도 | 아레나 구도를 CameraFollower가 다른 측면 구도로 덮어씀 | 아군 왼쪽·적 오른쪽 3/4 구도, 프레이밍을 카메라에 그대로 전달 | +| 화면 대응 | 고정 모델 간격·카메라 거리 | 모델 렌더 경계, 화면 비율, 시야각, 비대칭 안전 영역으로 모델을 하단 조작부 위에 배치; 교체·화면 변화 시 재계산 | +| 레이드 구도 | 고정 팀·보스 프레이밍 | 기존 정면 방향을 유지하면서 팀 전체·보스 경계를 안전 영역 안에 맞춤 | +| 카메라 전환 | 먼 전투 아레나로 필드를 가로질러 보간 가능 | 명시적 아레나 프레임은 즉시 적용; 확대된 프레이밍에 맞춰 경계벽 이동 | +| 타격 | 계산 직후 효과·쓰러짐, 개별 고정 타이머 | 기본 1.8초·스킬 2.5초, 40% 명중 시점에 HP·숫자·효과 반영 | +| 전투 종료 | 치명적 적 반격이 결과/교체에 가려질 수 있음 | 적 반격 → 쓰러짐 → 결과/교체; 종료 처리·보상 계산은 기존 경로 유지 | +| 상태 효과 | 독 피해와 적의 직접 피해가 같은 숫자에 포함 | 플레이어 행동 후·적 행동 후·턴 종료 HP 스냅샷 분리 | +| 교전 길이 | 중립 수식 대리 실험 중앙값 3라운드 | 일반 야생전 직접 피해 계수 0.7, 대리 실험 중앙값 4라운드; 저장 스탯·듀얼·수문장·레이드 계수 유지 | +| 접근성 | 전투별 시간·효과 설정이 분산 | 공통 1×/2× 표시 시간, 전투 중 배속 버튼, 흔들림·섬광 감소; 전역 timeScale 조작 없음 | +| 곤충 | 날개와 무늬 분리 운동, 일반 딱정벌레 초과 다리, 잠자리 다리 누락 | 날개 힌지와 장식 연결, 다리·관절·뿔·턱·더듬이 연결, 재질 구분 | +| 캐릭터 | NPC 목/모자 비례 불일치, 손과 도구의 별도 회전 | 공유 비례 기준, NPC 목·모자 보정, 손·도구의 공통 팔 회전, 기본 채집 도구 연결 | +| 탐험 UI | 11개 바로가기 상시 노출 | 도감·보유 곤충·지도·메뉴 4개, 전체 메뉴 12개 목적지, 기존 11개 단축키·퀘스트 배지 보존 | +| 모달 입력 | 클릭은 막지만 키보드·조이스틱·기존 자동이동이 계속됨 | 모달 중 모든 이동 입력과 남은 이동 목표 차단 | +| 화면 디자인 | 기존 버튼색과 공통 테마 혼재 | 공통 의미 색상 연결, 보유 곤충·훈련·상점/가챠·가방·퀘스트·대화·로그인 색상 통일, 도감·의상 미리보기 확대 | + +## 변경 계약 + +- `BattleFraming.Compute`: 경계·시야각·비율·안전 영역으로 카메라 위치와 시선 계산. 기존 레이드 방향은 보존. +- `BattleArenaController.PlaySkillEffect`: 기존 오버로드 유지, 마지막 선택 매개변수 `duration` 추가. 기존 명중 콜백과 `IsPlayingSkill`을 완료 판정에 사용. +- `BattlePresentation`: 표시 전용 `Speed`, `DeltaTime`, `ReducedMotion`, `ReducedFlashes`. 설정은 로컬 PlayerPrefs에 저장하며 개체/클라우드 세이브 스키마는 변경하지 않음. +- `InsectBattleController`: 행동별 HP 스냅샷·행동 유무·표시 지연 소유권 추가. 실제 피해·턴 계산은 동기 실행을 유지. +- 빠른 메뉴는 목적지 enum으로 연결. 의상 슬롯·모델 노드 계약·곤충 ID는 유지. + +## 검증 결과와 한계 + +### 2026-09-18 라이선스 복구 후 실제 검증 + +후속 실행에서 실제 IMGUI 전투 영상 3개와 모델 전후 비교를 추가했고 최종 PlayMode 1,054건이 통과했다. 세부 증거와 남은 범위는 `Docs/PolishFollowupPlan.md`의 실행 결과를 참조한다. 아래 1,047건/전투 미검증 기록은 후속 실행 이전 상태다. + +- Unity 6000.3.10f1 라이선스 인증, 패키지 로딩, D3D11 초기화 및 실제 스크립트 컴파일 정상. `polish-upm-envfix.log` 종료 코드 0. +- Codex 자식 프로세스에 `ALLUSERSPROFILE`이 누락되어 UPM이 경로 오류를 냈다. 자식 환경에 `CommonApplicationData` 경로를 복원해 해결했으며 시스템 환경설정은 변경하지 않았다. 재현 도구: `Tools/Start-UnityValidation.ps1 -Mode Compile|Test|Models`. +- 초기 PlayMode 1,044/1,044 통과. 숲 공터·종별 동작 연결 및 듀얼 카메라 영역 조정 후 최종 PlayMode **1,047/1,047 통과**, 실패/건너뜀 0. 새 결과: `.claude/cache/design-validation/polish-integrated-playmode.xml`. +- 저장소 CI 325개 C# 검사 통과, 담당 커버리지 241/241, `git diff --check` 통과. +- `Artifacts/model-design-20260918`에 모델 전용 실제 렌더 27장 생성. 장수풍뎅이·사마귀 측면 및 플레이어 정면을 직접 검수했다. 곤충 연결부, 플레이어 모자·의상의 단순한 형상은 추가 개선 대상으로 남는다. 이 이미지는 전후 비교나 IMGUI 화면이 아니다. +- 전체 전투 화면/영상, Android 프레임 시간, 모든 모델·의상 조합의 시각적 합격은 아직 미검증이다. 아래 라이선스 실패 기록은 복구 전 이력이다. + +### 복구 전 기록 + +- Unity 설치 경로의 실제 Roslyn 및 기존 Bee 참조로 런타임 코드(에디터 테스트 포함)와 에디터 도구 컴파일을 수행했다. 이는 Unity import·실행·플레이어 빌드 검증을 대체하지 않는다. +- 저장소 CI 검사와 에이전트 담당 커버리지를 실행했다. 최종 명령 출력은 `.claude/cache/design-validation`에 기록한다. +- `Docs/battle_pacing_measure.py`: 합성 중립 조건 60개, 중앙값 3→4라운드, 범위 1–4→2–5. 실종 데이터 전수/실플레이 결과가 아니다. 상세는 `BattlePacingValidation.md`. +- 추가 회귀 테스트: 카메라 모서리/안전 영역, 전투 순서·독·회복·기절·도주·재활성화, 배속과 수치 독립성, 메뉴 목적지·모달·입력, 날개 연결·도구 회전. +- Unity batch 실행은 **exit 198**, `No valid Unity Editor license found. Please activate your license.`로 종료했다. 이번 테스트 런의 XML은 생성되지 않았으며 기존 `TestResults.xml`을 이번 결과로 사용하지 않았다. +- 새 모델의 실제 렌더, 전체 의상 조합, 16:9·4:3·모바일 IMGUI, 전후 영상, Android 프레임 시간은 **미검증**이다. 이번 작업에서 생성된 비교 PNG/영상은 없다. +- 전체 종은 공용 형상/재질 개선을 적용했고 대표 종별 형태를 보강했다. 전체 종·모든 의상 조합의 시각적 완성도 인증은 아직 하지 않았다. + +## 재현 명령 + +저장소 루트에서: + +```powershell +python -X utf8 Tools/Verify-DesignCompile.py +python -X utf8 .claude/scripts/ci_check.py +python -X utf8 .claude/scripts/verify_coverage.py +python -X utf8 Docs/battle_pacing_measure.py +``` + +라이선스 활성화 후, 기존 에디터가 프로젝트를 점유하지 않는 상태에서 새 결과 파일명으로 실행한다: + +```powershell +& 'D:/Unity/Unity Hub/6000.3.10f1/Editor/Unity.exe' -batchmode -projectPath 'C:/Project/곤충게임' -runTests -testPlatform PlayMode -testFilter InsectGame.Tests -testResults 'C:/Project/곤충게임/.claude/cache/design-validation/playmode-results.xml' -logFile 'C:/Project/곤충게임/.claude/cache/design-validation/playmode.log' +``` + +러너 종료 후 XML 생성 시각과 실행 건수가 0보다 큰지 확인한다. `-quit` 또는 EditMode 러너를 사용하지 않는다. + +대표 곤충 6종 + 플레이어·성인 NPC·아동 NPC의 정면/측면/후면 **모델 전용** 27장 캡처: + +```powershell +& 'D:/Unity/Unity Hub/6000.3.10f1/Editor/Unity.exe' -batchmode -projectPath 'C:/Project/곤충게임' -executeMethod InsectGame.EditorTools.ModelDesignCapture.Run -modelCaptureOut 'C:/Project/곤충게임/.claude/cache/design-validation/models' -logFile 'C:/Project/곤충게임/.claude/cache/design-validation/models.log' +``` + +이 캡처는 IMGUI를 포함하지 않는다. HUD·메뉴·전투의 화면 합격은 실제 Game view 또는 standalone 전체 화면 캡처로 별도 검증해야 한다. Android는 같은 기기·설정·장면에서 전후 프레임 시간·드로우콜·메모리를 측정하며, 10% 초과 악화 여부는 아직 판정할 수 없다. diff --git a/Docs/HubLaunchAndWayfinding.md b/Docs/HubLaunchAndWayfinding.md new file mode 100644 index 0000000..4cb7ce5 --- /dev/null +++ b/Docs/HubLaunchAndWayfinding.md @@ -0,0 +1,34 @@ +# Hub 실행과 필드 길 안내 — 2026-09-19 + +## 실행 + +Unity Hub의 Projects에서 경로가 **`C:\Project\곤충게임`**인 항목을 연다. 설치 버전은 **6000.3.10f1**이다. 같은 이름으로 등록된 `D:\김도현\곤충게임`은 이번 작업본이 아니다. + +에디터 컴파일이 끝난 뒤 상단 **Play**를 누르면 정식 `OpeningScene → PlayScene` 경로로 시작한다. 이전 자동 검수 뒤 빈 `Untitled` 씬이 남아 있어도 Play의 시작 씬은 오프닝을 사용한다. 기존 빌드 씬 순서도 오프닝·플레이 순서로 확인했다. + +- `Insect Game > Play > Game from Opening`: 정식 시작점을 선택하고 실행한다. +- `Insect Game > Play > Current Scene (development)`: 현재 작업 씬을 바로 실행한다. 이후 일반 Play도 이 선택을 유지하며, 정식 실행 메뉴로 되돌릴 수 있다. + +시작점 선택은 프로젝트별 에디터 설정이며 게임 저장 구조나 사용자 진행 데이터의 변환을 추가하지 않는다. 배치 테스트·검수 실행에는 자동 시작점 설정을 적용하지 않는다. 수정 중인 씬은 자동으로 교체하지 않는다. + +## 실제 Hub 확인 + +Computer Use로 Hub의 C: 프로젝트 행을 클릭해 에디터를 열고 상단 Play를 눌렀다. 실제 `PlayScene`의 필드와 HUD 표시, `PlaySceneBootstrap`의 월드 생성·BuildSystems 완료를 확인했다. 해당 실행 로그에서 C# 오류와 Exception 항목은 없었다. 실행 화면은 `Artifacts/hub-launch/editor-play.png`에 보관했다. 이는 실제 데스크톱 실행 검증이며 Android 검증이나 전체 캠페인 완주 결과는 아니다. + +## 길 안내 확장 + +기존 13개 지역 입구 표지를 재사용한다. 통로 중앙의 봉을 길 옆으로 옮기고, 잉크색 판·지역색 띠·양면 지역명과 실제 연결 목적지를 표시한다. 기존 경로 정보를 읽어 목적지 방향을 계산하며 충돌체는 비활성화한다. 추가 객체는 지역당 4개, 총 52개이며 별도 조명은 추가하지 않는다. + +한글 폰트와 렌더 머티리얼을 명시하고 글자 경계가 판 안에 맞도록 크기를 계산한다. 현재 폰트는 OS의 한글 폰트 후보를 사용하므로 Android 기기별 표시 확인과 번들 폰트 도입 여부는 별도 검수 대상이다. + +## 검증 기록 + +Unity PlayMode 1차 1,104/1,104 통과 (`PlayMode-20260919-071453-594.xml`). 13개 연결 경로의 5,838개 고정 시드 중심선 샘플에서 장애물 0건. + +첫 근접 촬영(`Artifacts/map-20260919-071641-049`)에서는 기본 글꼴 재질이 표지판 뒤쪽 글자를 투과해 그리는 문제를 발견했다. 깊이 검사와 뒷면 제거를 반영한 최종 촬영 결과를 별도로 기록한다. + + +최종 근접 촬영: `Artifacts/map-20260919-072103-514`의 초원·연못·유적 양면 6장. 글자 투과와 기둥 가림을 수정한 화면을 확인했다. 모음 이미지는 `Artifacts/hub-launch/wayfinding-review.png`. 같은 시드의 경로 충돌 샘플도 0건을 유지했다. + +최종 Unity PlayMode: **1,104/1,104 통과**, `.claude/cache/design-validation/PlayMode-20260919-072228-440.xml`. 표지 개수·통로 여유·폰트/재질·글자 경계·기둥 가림 회귀 검사를 포함한다. +저장소 CI 전체 통과 (339개 C# 파일 검사), git diff --check 통과. 검수 후 동일 C: 프로젝트를 Hub에서 다시 열었다. diff --git a/Docs/InsectDetailAndQuickbarReview.md b/Docs/InsectDetailAndQuickbarReview.md new file mode 100644 index 0000000..f6f0c0c --- /dev/null +++ b/Docs/InsectDetailAndQuickbarReview.md @@ -0,0 +1,23 @@ +# 탐험 퀵버튼·곤충 정보·모델 검수 + +## 적용 내용 + +- 탐험 퀵바를 `도감 / 보유 곤충 / 배틀팀 / 가방 / 퀘스트 / 지도 / 메뉴` 7개로 확장했다. 기존 목적지 ID, 단축키, 알림은 유지한다. 모바일에서는 2열 4행으로 배치하고 마지막 `메뉴` 버튼은 한 줄 전체를 쓴다. +- 도감 상세를 덮던 투명 버튼을 제거해 닫기·이전·다음·회전·색다른 모습 버튼이 입력을 받을 수 있게 했다. 도감 배경 그리드는 상세가 열린 동안 입력을 받지 않는다. +- 도감 `보유 곤충` 카드에 `개체 정보` 연결을 추가했다. 같은 종이 여러 마리여도 `instanceId`로 정확한 개체를 보유 곤충 상세에 연다. 보유 곤충 목록의 카드 본문과 `상세` 버튼도 상세로 이어진다. +- 나비·나방의 떠 있던 더듬이, 누락된 6개 다리와 날개 윤곽, 잠자리의 구형 뒷날개·분리된 날개맥, 개미의 블록형 턱, 사슴벌레 얼굴 아래 떠 있던 블록을 고쳤다. 필드·도감·보유 화면이 공통 모델 생성 경로를 사용한다. +- 도감 상세의 색다른 개체 안내를 모델 프리뷰 아래로 내려 다리와 겹치지 않도록 했다. +- 세로 화면의 도감 상세는 모델과 정보 열을 위아래로 배치한다. 수치에 전체 폭을 주고, 마지막 통계 행까지 상자 안에 들어가도록 높이를 조정했다. + +## 검증 증거 + +- Unity PlayMode: `.claude/cache/design-validation/PlayMode-20260925-003810-370.xml` — 1114/1114 통과, 실패·건너뜀 0. 개체 ID 탐색, 더듬이/날개 연결, 개미 턱/사슴벌레 발톱 구조를 포함한다. +- 저장소 CI: `python -X utf8 .claude/scripts/ci_check.py` 통과(341개 C# 파일; 데이터·퀘스트·스토리·UI 배치·긴 글자 검사 포함). +- 모델 3면 갤러리: `Artifacts/models-20260925-003454-256/` — 대표 곤충 6종과 플레이어·성인·아동 27장. 이전 `Artifacts/models-20260918-234811-446/`와 같은 조명·경계 기반 구도다. +- 16:9 실제 IMGUI: `Artifacts/insect-ui-169-20260925-0056/` — 퀵바·도감 그리드/상세/보유 카드·보유 곤충 목록/상세 7장. 메모리 내 검수 데이터 3개체를 사용하며 저장·계정 서비스를 실행하지 않는다. +- 4:3 최종 화면: `Artifacts/insect-ui-43-20260925-0116/` — 7장. 세로 화면 최종 화면: `Artifacts/insect-ui-portrait-20260925-0121/` — 7장. 퀵버튼, 보유 카드, 통계 상자의 마지막 행을 이미지로 확인했다. +- Windows 게임 창에서 하단 도감 버튼, 도감 두 번째 장수풍뎅이 `개체 정보`, 상세의 이전/다음·회전·색다른 모습·닫기, 보유 곤충 퀵버튼, 목록 `상세`와 카드 본문, 뒤로 버튼을 직접 클릭했다. 같은 종의 Lv.22·S 개체와 Lv.16·A 개체가 각각 정확히 열렸다. + +## 검수 범위 + +위 캡처와 실클릭은 검수용 Windows fixture 결과다. Android 기기 입력·성능과 실제 계정의 저장 데이터는 이 검증에 포함되지 않는다. 모델 갤러리는 정지 이미지이므로 비행·전투 애니메이션의 체감 품질은 별도 플레이 검수가 필요하다. Intel Arc/D3D12의 세로 검수 창은 촬영을 마친 뒤 종료 중 두 번 네이티브 크래시가 났다. 같은 최종 촬영을 D3D11에서 완료하고 정상 종료했으며, 기기 런타임의 안정성을 이 결과로 단정하지 않는다. diff --git a/Docs/IslandDesign.md b/Docs/IslandDesign.md new file mode 100644 index 0000000..a13540c --- /dev/null +++ b/Docs/IslandDesign.md @@ -0,0 +1,218 @@ +# 나의 섬 (개인 섬 하우징) 기획서 + +상태: **구현됨** (2026-10-02 승인·구현). 이 문서는 **왜 이렇게 지었는가**를 남긴다. +수치의 단일 출처는 코드(`GameConstants.Island`·`IslandCatalog`·`IslandYield`)이고 유지 규칙은 `.claude/rules/island.md`다 — +여기에 숫자 사본을 두지 않는다(대역만 적는다). + +## 1. 개요 + +캐릭터마다 섬 하나를 준다. 보유 곤충 몇 마리를 풀어놓으면 섬을 돌아다니며 시간에 따라 캔디·코인을 +만들고, 플레이어는 그걸 수확해 건물·가구·지형지물·도구를 사서 칸 단위로 배치한다. 섬은 친구에게 +공개할 수 있고 친구는 저장된 모습을 걸어다니며 구경한다. + +결정된 방향(2026-10-02 사용자 선택): + +| 항목 | 결정 | +|---|---| +| 곤충 역할 | 방목 + 시간 보상(접속 안 한 시간도 상한까지 누적) | +| 배치 | 칸 단위 자유배치(회전·이동·회수) | +| 공유 | 스냅샷 방문(읽기 전용, 비동기) | +| 화폐 | 기본은 코인, 프리미엄은 다이아(젬). 섬·곤충 자리 확장은 코인으로도 다이아로도 | +| 진행 | 계획 승인 후 끝까지 | +| 서버 배포 | 함수 코드와 테스트만 작성 — 배포는 사용자가 직접 | +| 해금 | 「곤충 수집가」(`q_capture3`) 완료 후 | + +목적: ①전투·포획 밖의 **매일 들를 이유** ②코인의 쓸 곳(현재 코인 소비처는 의상·병원뿐) ③다이아 소비처 +④친구 기능에 전투 말고 보여줄 것. + +## 2. 상세 설계 + +### 2.1 들어가고 나오기 + +- **퀵바 메뉴 「내 섬」** + **본 마을 나루터**(상호작용 지점) 두 경로. +- 섬은 메인 월드와 분리된 좌표 `(3000, 0, 3000)`에 짓는다(서브에리어 2000·배틀 아레나 1000과 안 겹침). +- 나갈 때는 들어오기 전 자리로 돌아간다. 섬 안 HUD의 「나가기」 또는 나루터. +- 섬에서는 야생 스폰·포획·전투가 없다. + +### 2.2 섬 안에서 할 수 있는 것 (하단 HUD) + +| 버튼 | 내용 | +|---|---| +| 꾸미기 | 편집 모드 진입 — 보관함에서 골라 칸에 놓기, 놓인 것 탭해서 이동·회전·회수 | +| 상점 | 건물 / 가구 / 지형지물 / 도구 / 확장 5탭 | +| 곤충 | 방목할 곤충 고르기·교체, 친밀도 보기 | +| 수확 | 쌓인 캔디·코인 받기 (쌓인 양과 가득 찰 때까지 남은 시간 표시) | +| 방문 | 친구 섬·섬 코드로 방문, 내 섬 공개 설정, 좋아요 수 | +| 도움말 | 가이드 다시 보기 | +| 나가기 | 메인 월드로 | + +### 2.3 배치 규칙 + +- 섬은 격자(1칸 = 1.5m). 물건마다 차지 칸(가로×세로)이 있고 90° 단위로 회전한다. +- 겹침 금지, 섬 경계 밖 금지, 나루터 앞 도착 칸은 비워 둔다(막으면 들어오자마자 갇힌다). +- 회수하면 보관함으로 돌아간다. **되팔기는 없다** — 다이아 환급은 서버 규칙상 불가능하다(4.4). +- 편집 모드에서는 캐릭터 이동을 멈춘다. 지면 탭이 「배치」와 「클릭-이동」 둘 다로 읽히는 걸 막기 위해서다. + +### 2.4 방목과 수확 + +- 보유 곤충에 「섬 방목」 표시를 붙인다(배틀팀과 같은 구조 — 곤충은 그대로 보유 목록에 있고 전투에도 쓸 수 있다). +- 방목 중인 곤충은 섬 위를 천천히 돌아다닌다. 탭하면 이름·친밀도가 뜬다. +- 시간이 지나면 캔디·코인이 쌓인다. 상한(기본 8시간분)에 닿으면 멈춘다 → 수확하러 들를 이유. +- **친밀도**: 방목 시간에 따라 오른다(하트 0~5). 효과는 **섬 생산량 보너스뿐** — 전투 스탯에 손대지 않는다. +- **쾌적도**: 놓인 물건의 쾌적도 합. 생산량 보너스. 같은 물건을 도배해도 오르지 않게 종류 수로 체감시킨다. +- **도구**(먹이통·급수대·수확 바구니 등)는 놓아두면 효과가 나는 설비다: 생산 +%, 상한 시간 +, 친밀도 속도 +. + +### 2.5 공유(스냅샷 방문) + +- 편집 모드를 닫을 때 섬의 현재 모습을 서버에 올린다(공개 설정이 켜져 있을 때만). +- 방문: 친구 목록에서 고르거나 **섬 코드**(기존 친구 코드 8자리 재사용)를 입력한다. +- 방문 중에는 걸어다니며 구경만 한다. 편집·수확 불가. 「좋아요」 하루 1회. +- 상대가 접속 중이 아니어도 된다. 실시간 동석은 이번 범위 밖(구조만 열어둔다). + +### 2.6 퀘스트 (서브 퀘스트, 전부 Side) + +| ID | 제목 | 목표 | 보상의 성격 | +|---|---|---|---| +| `s_island_arrive` | 나의 섬 | 섬에 들어가기 | 코인(첫 구매 밑천) + 등불 | +| `s_island_place` | 꾸미기 시작 | 물건 3개 배치 | 코인 + 꽃밭 | +| `s_island_release` | 첫 손님 | 곤충 2마리 방목 | 캔디 + 먹이통 | +| `s_island_harvest` | 첫 수확 | 수확 1회 | 캔디 + EXP | +| `s_island_shop` | 섬 상점 | 상점에서 1개 구매 | 코인 | +| `s_island_visit` | 이웃 섬 구경 | 다른 사람 섬 방문 1회 | 캔디 + 팻말 | +| `s_island_keeper` | 섬지기 (반복) | 수확 N회, 달성마다 목표 상승 | 캔디·코인 소량 | + +값은 `TutorialQuestManager`의 배열이 단일 출처다. 보상에 다이아는 넣지 않는다(4.4). +앞 다섯 개가 곧 **가이드의 본체**다 — 하나씩 따라 하면 기본 조작을 전부 한 번씩 해 보게 된다. +섬 첫 진입 때는 퀘스트와 별개로 **스타터 키트**(벤치·화분·어린 나무)가 보관함에 들어온다 — 안내가 "보관함의 벤치를 놓아 보세요"로 바로 쓴다. + +### 2.7 가이드 + +- 섬 **첫 진입 때 단계형 안내**(코치 배너): 섬 소개 → 꾸미기 버튼 → 보관함에서 벤치 놓기 → 곤충 방목 → 수확·상점·나가기. + 단계는 실제 행동으로 넘어간다(「다음」 버튼이 아니라 벤치를 놓으면 다음 단계). +- 수확 단계에 들어설 때 **첫 선물**(캔디·코인 약간)을 넣어 둔다 — 곤충 한 마리로는 캔디 1개에 네 시간이 걸려 + "수확을 눌러 보세요"가 성립하지 않는다. +- 이미 한 행동의 단계는 건너뛴다(다른 기기에서 꾸민 섬을 클라우드로 받아 온 세이브가 첫 단계에 멈추지 않게). +- 언제든 「도움말」로 다시 본다(항목별 설명 + "처음 안내 다시 보기"). 진행 위치는 섬 세이브에 저장. +- 배너는 **✕로 닫거나 12초가 지나면 사라진다**(남은 시간은 배너 아래 얇은 줄). 닫는 것은 화면에서 치우는 것뿐이라 + 단계는 그대로고, 다음 단계에 가거나 섬에 다시 들어오면 그 단계 안내가 다시 뜬다. 마지막 인사는 닫는 것이 곧 확인이다. +- 모바일 필드에서는 **화면 가운데 줄, 캐릭터 발 아래**에 놓는다 — 위쪽은 왼편에 미니맵·퀘스트 칩, 오른편에 퀵바·섬 버튼 줄이 + 있어 빈 자리가 없다(처음엔 좌상단에 놓았다가 미니맵·퀘스트 칩 밑에 깔렸다). 꾸미기 화면 위에서는 상단 바 아래. +- 기존 `GuidedTutorialController`는 Story 퀘스트 활성 이벤트에 묶여 있어 Side 퀘스트로는 안 뜬다 → 섬 전용 가이드를 따로 둔다. + +## 3. 수치 설계 — 대역과 근거 + +기준: `economy_sim`의 평소 활동 수입은 하루 캔디 약 156(포획 20·전투 10·레이드 주 3회), 코인은 전투 승리당 3이라 하루 약 30. +다이아는 유료 전용이다(무료 지급 경로 0건). + +| 지키려는 것 | 대역 | 어디서 검사하나 | +|---|---|---| +| 기본 섬(일반 3마리)의 캔디 | 활동 수입의 20% 이하 (현재 12%) | `economy_sim` 신호 6 · `IslandYieldTests` | +| 풀 확장 섬(전설 최대 마리·보너스 전부)의 캔디 | 활동 수입의 125% 이하 (현재 113%) | `economy_sim` 신호 7 · `IslandYieldTests` | +| 도배로 생산이 오르지 않을 것 | 쾌적도는 종류로 체감, 설비 효과는 id당 한 번 | `IslandYieldTests` | +| 방치하면 멈출 것 | 누적 상한(기본 8시간, 설비로 최대 16) | `IslandYieldTests` | + +- **섬 전용 등급 배율을 따로 둔 이유**: 보상 배율(전설 2.8)을 그대로 쓰면 전설 10마리 섬이 하루 300캔디를 넘겨 활동 수입의 두 배가 된다. + 섬은 곁들이는 수입이라 폭을 눌렀다(전설 1.6). +- **섬이 코인을 만드는 이유**: 코인 수입이 전투 승리 3코인뿐이라, 섬이 코인을 만들지 않으면 가구를 사는 순환이 돌지 않는다. + 코인 생산은 등급을 보지 않는다 — 코인은 꾸미기 재화라 수집 깊이와 묶지 않는다. +- **확장을 코인으로도 살 수 있게 한 이유**: 다이아가 유료 전용이라, 섬 크기·곤충 자리를 다이아로만 팔면 무과금은 기본 섬에 갇힌다. +- **친밀도가 전투 스탯에 손대지 않는 이유**: 섬에 오래 둔 곤충이 전투에서 세지면 섬이 필수 콘텐츠가 된다. 효과는 섬 생산량뿐이다. +- 카탈로그 28종: 건물 5 · 가구 10 · 지형지물 8 · 도구 5. + +## 4. 시스템 연결 + +### 4.1 섬은 「합성 서브에리어」로 탄다 + +"분리된 공간에 있다"는 판정이 전부 `RegionManager.CurrentSubArea != null`에 걸려 있다 +(PlayerMovement 접지·끼임 복구, InsectSpawner 필드 틱, MinimapUI, RegionMapUI, SubAreaEnvironment, BlightVfx). +섬을 별개 상태로 만들면 그 전부를 따로 고쳐야 하고, 빠뜨리면 왕복할 때마다 `RegionChanged`가 재발화해 +방문 퀘스트·스토리 트리거·BGM이 오작동한다. + +그래서 `RegionManager.EnterDetachedSubArea`(위치와 무관한 진입)를 두고 섬을 `SubAreaData.detached = true`인 +합성 구역(id `player_island`)으로 태운다. + +- `SubAreaWorldBuilder`는 `detached` 구역을 건너뛴다(방 생성·25m 자동 이탈·퇴장 버튼). 섬은 `IslandWorldBuilder`가 짓는다. +- 전용종이 비어 있으므로 `InsectSpawner`는 몸을 거두고 아무것도 스폰하지 않는다(기존 동작 그대로). +- `VisitSubArea` 퀘스트는 `detached` 구역을 세지 않는다. 스토리 `SubAreaEnter`는 섬 id에 걸린 비트가 없어 지나간다. +- `SubAreaEnvironment`에 섬 조명 프로필, `PlayerMovement` 끼임 복구는 섬 도착 칸으로 분기. +- 방목 곤충은 `InsectEntity.BuildForBattle` 경로로 세운다 — 야생 스폰 경로로 세우면 섬 위에서 플레이어를 보고 달아나고 잡기 버튼이 뜬다. + +### 4.2 세이브 + +- 로컬: `island.json` 신규(계정 스코프). 보관함·배치·방목 목록·친밀도·마지막 정산 시각·확장 단계·가이드 진행·공개 설정. +- 클라우드: `GameSaveData.islandData` 블롭 1개(수집·직렬화·파싱·복원 + `SaveScope.ScopedFiles` + `RegisterReloadable`). +- 구세이브 호환: 파일이 없으면 기본 섬(0단계, 스타터 키트 미지급 상태). 필드 기본값만으로 성립하게 선언. +- 방목 목록은 `instanceId`로 든다. 로드·클라우드 복원 때 보유 목록과 대조해 없는 id는 버린다. +- 오프라인 누적은 기기 시계(UTC)로 잰다. 시계를 되돌려 음수가 나오면 0으로 본다. 상한이 있어 시계 조작의 이득은 한 번의 상한분이다. + +### 4.3 화폐 + +- 코인 `wallet.SpendCoins`, 다이아 `wallet.SpendGems`(잔액 검사·차감·즉시 클라우드 저장이 한 번에 됨). +- 구매 순서: 차감 → 보관함 기록·저장 → 클라우드 저장 한 번 더(차감 직후 스냅샷엔 소유권이 아직 없다). + +### 4.4 서버 제약 (코드로 확인) + +- `firestore.rules`: 클라이언트는 다이아를 **줄이기만** 할 수 있다. 늘리면 `users/{uid}` 업로드 전체가 거부된다 + → 되팔기 환급·퀘스트 다이아 보상 불가. +- 타인 문서는 클라이언트에서 직접 못 읽는다. 친구 기능은 전부 Cloud Function `socialPvpApi` 경유 + → 섬 공유도 거기에 액션 5개(`publishIsland` / `getMyIsland` / `getIsland` / `likeIsland` / `deleteIsland`)를 얹고 + `islands/{uid}`에 저장한다. 규격·오류 코드·배포 명령은 `Docs/SocialPvp.md` 「섬 공유」. **함수 배포가 필요하다**(사용자가 직접). +- 서버가 배포되기 전에도 내 섬 기능은 전부 동작하고, 방문만 "준비 중"으로 뜬다. +- **2026-10-02 현재 미배포이고, 배포하지 않기로 정했다.** 운영 프로젝트에는 `socialPvpApi` 자체가 올라간 적이 없어서 + (함수 0건, 주소 404) 섬 공유 배포 = 친구·PvP·월드 채널까지 포함한 온라인 기능 전체의 첫 운영 배포가 된다. + 그 동안 서브 퀘스트 `s_island_visit`(이웃 섬 구경)은 완료할 수 없다. + +### 4.5 Bootstrap 등록 + +`World/IslandManager`(로직·세이브) → `World/IslandWorld`(지형·실체화) → `UI/Island*`(HUD·편집·상점·곤충·방문·가이드). +`IslandManager`는 `insectCollection`·`wallet`·`candyInventory` 뒤, `cloudSave.RegisterReloadable`은 곤충 컬렉션 뒤. +새 싱글턴은 만들지 않는다(AutoWire). + +## 5. UI/UX + +- 섬 HUD·편집 트레이는 **필드 위 비모달**이라 매 프레임 `FieldHudInput.RegisterBlockingRect` 등록. +- 상점·곤충·방문 창은 `IModalUI` 전체 패널(`UISafeLayout.CenteredPanel` + `UISurface`). +- 색은 `UITheme` 토큰, 데이터 길이 문구는 `UIHelper.LabelFit`. +- 편집 모드: 칸 하이라이트(놓을 수 있음 민트 / 못 놓음 코랄), 하단 트레이(보관함 가로 스크롤), 선택 물건 위 「회전·확정·회수」. + +## 6. 데이터 구조 + +- `IslandObjectDef` — id, 이름, 설명, 분류(건물/가구/지형/도구), 차지 칸, 코인가·다이아가, 쾌적도, 효과, 해금 조건. +- `IslandCatalog` — 코드 정의 정적 카탈로그(의상 카탈로그와 같은 방식). +- `IslandSave` — `[Serializable]` 일반 클래스, `JsonUtility`. +- `IslandSnapshot` — 공개용 축약본(배치 + 방목 곤충의 종·레벨·샤이니 + 주인 이름·좋아요 수). 개인 정보·instanceId는 싣지 않는다. + +## 7. 구현 결과 + +### 새 파일 + +| 층 | 파일 | 몫 | +|---|---|---| +| 규칙(순수) | `IslandData` · `IslandCatalog` · `IslandGrid` · `IslandYield` · `IslandGuideSteps` | 데이터 모델, 카탈로그, 격자, 생산 공식, 안내 단계 | +| 상태 | `IslandManager`(+`IslandSaveRules`) | 세이브·구매·배치·방목·수확·가이드 진행 | +| 월드 | `IslandWorldBuilder` · `IslandInsectWalker` · `IslandTerrainBuilder` · `IslandObjectBuilder` · `IslandMaterialCache` | 드나들기·콜라이더·곤충 / 지형·물건 모양 / 머티리얼 | +| 공유 | `IslandShareClient` · `functions/island.js` · `functions/social-api.js` | 공개·방문·좋아요 | +| 화면 | `IslandHudUI` · `IslandEditUI` · `IslandShopUI` · `IslandInsectUI` · `IslandVisitUI` · `IslandGuideUI` · `IslandUiKit` | | +| 검수 | `IslandVisualCapture`(`-battleScenario island-ui`) · `Assets/Editor/IslandModelCapture` | 화면 / 모양 | +| 클라우드 | `FirestoreDocParser` | 복원 파서(공백에 관대) | + +### 고친 기존 파일 + +`GameConstants`(Island 상수·세이브 파일) · `SaveScope` · `CloudSaveManager`(섬 블롭 + 파서 교체) · `AuthManager`(계정 삭제 시 섬 내리기) · +`RegionManager`(분리 진입) · `SubAreaData`(`detached`) · `SubAreaWorldBuilder` · `SubAreaEnvironment` · `PlayerMovement` · `CameraFollower`(오프셋 덮어쓰기) · +`TutorialQuestData`/`TutorialQuestManager`(QuestType 6종·보상 2종·퀘스트 7개) · `QuestRewardFormatter` · `QuickAccessBarUI`([내 섬] · H) · +`WorldInteractionTypes`/`WorldInteractionController`(나루터) · `PlaySceneBootstrap`(등록) · `BattleVisualCapture`(시나리오). + +## 8. 테스트 항목 + +- 격자: 겹침 거부, 경계 밖 거부, 회전 시 차지 칸, 도착 칸 보호, 섬 축소가 없으므로 확장 뒤 기존 배치 유효. +- 생산: 등급 배율, 상한 clamp, 음수 경과 = 0, 쾌적도·친밀도 상한, **풀 확장 섬 일일 캔디가 대역 안**(수치 변경 감지). +- 카탈로그: id 유일, 가격 둘 중 하나는 양수, 차지 칸 ≥ 1, 퀘스트 보상 id 실재. +- 세이브: 빈 JSON → 기본 섬, 없는 instanceId 정리, 모르는 물건 id 무시(구버전·신버전 혼용). +- 수동: 섬 왕복 뒤 방문 퀘스트·스토리 트리거·BGM이 재발화하지 않는지, 오프닝 다시보기 뒤 섬 UI 구독 유지. + +## 9. 알려진 한계 (이번 범위) + +- IMGUI는 배치 캡처에 안 잡힌다 → 섬 UI는 QA 빌드 시나리오(`island-ui`)로만 눈 확인이 된다. +- 실시간 동석 없음. 방문자에게 주인 캐릭터는 보이지 않는다(곤충과 섬만). +- 계정 삭제 시 공개 섬 문서 삭제는 서버 액션으로 함께 넣는다(현재 `socialProfiles`도 안 지워지는 기존 문제와 같은 계열). diff --git a/Docs/PolishFollowupPlan.md b/Docs/PolishFollowupPlan.md new file mode 100644 index 0000000..6d51c4a --- /dev/null +++ b/Docs/PolishFollowupPlan.md @@ -0,0 +1,61 @@ +# 실제 화면 기반 후속 완성도 개선 + +## 기준과 범위 + +기준 이미지는 `Artifacts/model-design-20260918`의 Unity 실제 렌더다. 스타일은 잉크색 UI와 따뜻한 탐험가, 자연색 곤충의 스타일리시 탐험 RPG로 유지한다. 세이브·의상 슬롯·모델 노드 계약과 IMGUI를 보존한다. + +1. **형태 정리**: 장수풍뎅이 갑각과 흉부 틈, 사마귀 앞다리 단면을 수정한다. 플레이어와 NPC 모자를 둥근 크라운/챙으로 통일하고 셔츠와 외투 표면 간섭을 제거한다. +2. **실제 전투 검수**: 저장·인증 시스템을 생성하지 않는 독립 QA 씬을 Windows 플레이어로 실행한다. IMGUI가 포함된 대기·명중·복귀·결과 화면과 연속 프레임을 기록한다. 게임의 실제 UI와 전투 코드를 사용한다. +3. **화면 적합성**: 16:9, 4:3, 넓은 모바일 비율에서 아군 왼쪽/적 오른쪽, 상단 HP 카드와 하단 스킬 패널의 가림을 확인한다. 캡처 메타데이터에 실제 해상도를 기록한다. +4. **회귀와 보고**: Unity PlayMode, 저장소 CI, 담당 커버리지 검사 후 같은 각도 모델 전후 이미지와 실제 전투 캡처를 보고한다. Android 기기 성능과 실제 기기 노치 검증은 별도 미완료 항목으로 남긴다. + +## 합격 조건 + +- 주요 연결부에 보이는 틈·튀어나온 단면·표면 깜박임이 없다. +- 캐릭터 생성/필드/미리보기는 공유 빌더를 사용하며 NPC 고정 색과 외형 선택은 유지된다. +- 전투 화면은 모델과 행동을 읽을 수 있고 마지막 공격 후 결과 전환을 확인할 수 있다. +- 테스트 0건이나 카메라 전용 렌더를 실제 UI 검증으로 보고하지 않는다. +- 화면과 실행 결과로 확인한 항목만 완료로 표시한다. + +## 이번 실행 결과 — 2026-09-18 + +다음 후속 구현·실행에서 교체·도주·레이드·배속과 추가 모델 보정을 진행했다. 최신 결과는 `Docs/PolishRegressionRound2.md`를 참조한다. 아래 기록은 1차 검수 시점의 결과다. + +### 적용 + +- 딱정벌레 갑각의 큰 틈과 뿔 접합부, 사마귀 앞다리의 굵은 곡선과 평면 끝을 정리했다. 메시당 172정점을 유지한다. +- 플레이어/NPC의 모자 크라운과 챙을 공유 비례의 둥근 형태로 변경했다. 셔츠를 외투 표면 앞으로 얇게 배치하여 불규칙한 교차면을 제거했다. +- 전투 양측 모델의 실제 경계로 간격을 계산한다. 작은 모델은 가까이 배치하고 큰 날개/뿔은 여유를 확보한다. 곤충 교체 시에도 재계산한다. +- 실제 캡처에서 발견한 도망가기 보조 설명 잘림을 수정했다. + +### 실제 화면과 실행 증거 + +| 화면 | 실제 해상도 | 프레임 | 결과 도달 | 검은 프레임 | 명중 전 표시 HP 변화 | +|---|---|---:|---|---:|---:| +| 16:9 | 1280×720 | 258 | 확인 | 0 | 0 | +| 4:3 | 1024×768 | 266 | 확인 | 0 | 0 | +| 넓은 화면 | 1400×600 | 265 | 확인 | 0 | 0 | + +숲 공터의 장수풍뎅이 대 사마귀 Lv15 고정 난수 검수 조합이다. 세이브·인증·보상 저장 서비스를 만들지 않는다. 실제 BattleScreenUI 행동 메서드로 진행하며 화면 HP와 최종 계산 HP를 따로 기록한다. 물리적 버튼 클릭/키 입력 자동화는 포함하지 않는다. + +기록 프레임 기준 일반 공격 약 1.78~1.84초, 주요 스킬 약 2.51~2.55초. 마지막 행동은 피격/쓰러짐 대기를 포함하여 약 2.67초 뒤 결과로 전환했다. 약 0.1초 간격의 화면 표본이므로 미세 프레임 단위 보증은 아니다. 영상은 실제 기록 시각에 맞춘 가변 프레임 간격의 무음 MP4이며 속도를 임의로 줄이지 않았다. + +- 모델 전후: `Artifacts/polish-model-comparison.png` (상단 이전, 하단 이후) +- 새 모델 27장: `Artifacts/models-20260918-231529-083` +- 최종 전투: `Artifacts/battle-final-169`, `Artifacts/battle-final-43`, `Artifacts/battle-final-wide` +- 각 폴더의 `battle-review.mp4`, `timeline.csv`, `verification.json`, `manifest.txt`가 영상과 계측 결과다. +- 최종 Unity PlayMode **1,054/1,054 통과**, 실패/건너뜀 0: `.claude/cache/design-validation/PlayMode-20260918-232405-790.xml` +- 저장소 CI 327개 C# 검사 통과, 담당 커버리지 243/243, `git diff --check` 통과. + +숨김 창 첫 캡처는 검은 화면이어서 시각 검증에서 제외했다. 사용자 승인 후 표시된 창으로 재촬영했다. 모델 전용 갤러리와 실제 IMGUI 전투 촬영은 별도 증거다. + +### 남은 작업 + +- 딱정벌레 흉부–머리 경계와 뿔 뿌리, 사마귀의 각진 머리·얇은 날개·낫 끝, 캐릭터 의상 세부 형상은 추가 아트 패스가 필요하다. 현재 결과를 최종 아트 완성으로 판정하지 않는다. +- 모든 종/의상 조합, 실제 버튼 입력, 레이드/교체/도주/상태이상/2배속의 영상 회귀는 아직 미검증이다. +- Android SDK의 `adb devices`에 연결 기기가 없었다. Android 성능 10% 기준, 실제 노치·터치 및 음향 검증은 수행하지 않았다. +- 다음 순서는 위 잔여 형상 보정 → 레이드·교체·도주 UI 회귀 → 실제 Android 기기 비교다. + +### 재현 + +`Tools/Start-UnityValidation.ps1 -Mode BattleBuild`로 독립 Windows 검수 빌드를 만든다. 게임은 `-battleCaptureOut <새 빈 폴더> -screen-fullscreen 0 -screen-width 1280 -screen-height 720`로 실행한다. 배치/그래픽 없는 모드는 거부하며 120초 감시로 멈춘 실행을 종료한다. 이후 `python Tools/Export-BattleCapture.py <캡처 폴더>`로 검은 화면 검사와 MP4 출력을 수행한다. ffmpeg는 PATH 또는 `.claude/cache/ffmpeg-runtime`의 검수용 설치본을 사용한다. diff --git a/Docs/PolishRegressionRound2.md b/Docs/PolishRegressionRound2.md new file mode 100644 index 0000000..af0a924 --- /dev/null +++ b/Docs/PolishRegressionRound2.md @@ -0,0 +1,48 @@ +# 모델·전투 후속 검수 — 2026-09-18~19 + +## 적용한 변경 + +- 딱정벌레 흉부를 곡면으로 연결하고 뿔을 가로지르던 더듬이를 재배치했다. 사마귀의 삼각형 머리를 둥글게 만들고 날개 두께·안착 위치·낫 끝을 보정했다. +- 실제 교체 화면에서 어두운 글자와 색 네모 아이콘을 확인해 공통 카드, 곤충 썸네일, 교체 가능/쓰러짐 상태 표시로 교체했다. +- 레이드의 큰 반투명 구체가 Windows 실행 파일에서 불투명하게 렌더되어 보스를 가렸다. 몸을 둘러싼 구체를 제거하고 바닥 표식으로 변경했다. 높은 보스 배치를 받치는 지형을 추가했다. +- 레이드 HP·레벨 줄, 다음 행동 안내, 기술·자동 버튼, 합체 게이지를 공통 표면과 읽기 쉬운 배치로 정리했다. +- 도주 성공이 일반 패배와 같은 결과/음악 경로로 처리되던 문제를 `DidEscape` 상태로 구분했다. 성공 화면은 **무사히 이탈**이며 패배 음악 호출을 생략한다. 실제 음향 청취 검증은 포함하지 않는다. +- 전투 판정이 연출의 `UnityEngine.Random` 소비에 영향을 받던 결함을 수정했다. 두 컨트롤러는 독립 `BattleRandomSource`를 사용하고 테스트/검수에는 명시적인 seed를 주입한다. 개체 스탯·세이브 데이터는 변경하지 않았다. + +## 검증 + +Unity PlayMode **1,064/1,064 통과**, 실패/건너뜀 0. 최종 기능 변경 검증 파일은 `.claude/cache/design-validation/PlayMode-20260918-235921-599.xml`이다. 이후 지형 색상 대비와 QA 로그만 조정했다. + +- 전투 난수 테스트: 시각용 난수를 추가 소비해도 1대1 저명중·도주와 레이드 명중·보스 대상이 동일함을 검증한다. +- 도주 상태 테스트: 성공 이벤트 시점, 중복 종료 방지, 새 전투에서 초기화, 일반 패배 구분. +- 보스 표식 테스트: 보스를 덮는 MeshRenderer 대신 지면 링을 사용하고 위치를 확인한다. +- 저장소 CI 330개 C# 검사 통과, 담당 커버리지 245/245, `git diff --check` 통과. + +## 실제 촬영 + +모든 촬영은 저장/인증 시스템 없는 독립 Windows Development 플레이어에서 실제 IMGUI를 포함한다. UI의 행동 메서드를 호출하므로 물리적인 버튼 클릭/터치 검증은 아니다. 프레임 시각을 반영한 무음 MP4를 생성한다. + +| 시나리오 | 확인 결과 | 폴더 | +|---|---|---| +| 도주 | 고정 실패 → 적 반격 → 성공 → 무사히 이탈 | `Artifacts/followup-escape` | +| 기절 후 교체 | 교체 선택창 → 사슴벌레 교체 → 전투 결과 | `Artifacts/followup-swap` | +| 공격 중 2배속 | 1배속에서 2배속 전환 후 결과, 동일 행동 HP 흐름 유지 | `Artifacts/followup-speed` | +| 레이드 | 5명 순차 행동 → 보스 예고·반격 → 결과 | `Artifacts/followup-raid-final` | + +도주/교체/배속은 각각 100/263/170프레임이며 검은 프레임, 기록된 명중 전 HP 변화, 대기·교체 상태의 좌우 역전이 없었다. 약 0.1초 간격 표본이며 모든 프레임의 수학적 보증은 아니다. 레이드 CSV는 별도 표시 HP를 기록하지만 명중 플래그 기반 자동 동기화 판정은 하지 않는다. + +레이드 AOE는 현재 게임 상수 `RaidBossUsesAreaAttack=false`이므로 이번 실제 영상에서 발생하지 않았다. 이 설정을 바꾸거나 AOE 영상 검증을 통과했다고 보고하지 않는다. + +최종 레이드는 486프레임으로 결과까지 도달했고 보스 반격을 확인했다. 실제 화면에서 보스 전체가 보이며 팀 HP와 합체 게이지가 겹치지 않는다. 받침 지형은 생성 및 렌더링을 확인했지만 단순한 원통 형태여서 지형과 자연스럽게 연결하는 추가 아트 보정이 필요하다. + +모델 비교: `Artifacts/followup-model-comparison.png` (상단 이전/하단 이후). 원본 새 모델 27장: `Artifacts/models-20260918-234811-446`. + +## 남은 범위 + +- 모든 종·의상 조합과 측후면의 최종 아트 검수, 나머지 화면의 실물 UI 통일. +- 상태이상 처치·중단 후 복귀·실제 버튼 입력의 별도 영상 회귀. +- Android 기기 성능/노치/터치 및 음향 청취. 이번 실행에서 `adb devices`는 연결 기기 0대를 반환했다. + +## 재현 + +`Tools/Start-UnityValidation.ps1 -Mode BattleBuild`로 빌드한 뒤 `-battleScenario escape|swap|raid|speed`와 `-battleCaptureOut <새 빈 폴더>`를 전달한다. 렌더링을 위해 배치모드 없이 창을 표시해야 한다. `Tools/Export-BattleCapture.py`는 검은 화면 검사, 기록 시각 기반 영상 내보내기, 명중 전 표시 HP/대기 좌우 검사를 수행한다. diff --git a/Docs/SocialPvp.md b/Docs/SocialPvp.md index 60bc6d9..474c589 100644 --- a/Docs/SocialPvp.md +++ b/Docs/SocialPvp.md @@ -21,6 +21,15 @@ ## 배포 +**2026-10-02 기준 운영 프로젝트(`insect-exploration-8f0ca`)에는 배포된 함수가 하나도 없다** — +`firebase functions:list`가 0건이고 `socialPvpApi` 주소는 404다(`verifyGooglePlayPurchase`도 미배포). +그래서 아래 명령은 "액션 추가"가 아니라 **온라인 기능 전체의 첫 운영 배포**다: +- 친구·랭크 PvP·5인 월드 채널·섬 공유가 **함께** 켜진다. 이미 설치된 클라이언트도 로그인하면 이 서버를 쓰기 시작한다. +- Cloud Functions 배포는 종량제(Blaze) 요금제가 필요하다. 월드 채널은 접속자마다 초당 1회, PvP는 2.5초마다 호출한다. +- 단위·에뮬레이터 테스트는 통과했지만 운영에서 돌아간 적은 없다. + +같은 날 사용자가 **배포하지 않기로** 정했다. 다시 배포를 검토할 때 이 절부터 읽을 것. + Firebase CLI 로그인 후 프로젝트 루트에서 실행한다. ```powershell @@ -38,10 +47,121 @@ Blaze 요금제 없이 Firebase Auth·Firestore·Functions 에뮬레이터에서 ./Tools/Test-SocialPvpEmulator.ps1 ``` -첫 실행은 프로젝트 전용 Java 21 런타임과 Firebase 에뮬레이터를 내려받기 때문에 시간이 걸린다. 시스템 Java는 변경하지 않으며 다운로드 파일은 `.codex/tools/`에만 저장된다. 테스트는 두 가상 계정 생성, 친구 요청/수락, 친선 3:3, 랭크 매칭, 승패·Elo 반영, 리더보드를 순서대로 검증하고 종료 시 모든 로컬 데이터를 폐기한다. +첫 실행은 프로젝트 전용 Java 21 런타임과 Firebase 에뮬레이터를 내려받기 때문에 시간이 걸린다. 시스템 Java는 변경하지 않으며 다운로드 파일은 `.codex/tools/`에만 저장된다. 테스트는 두 가상 계정 생성, 친구 요청/수락, 친선 3:3, 랭크 매칭, 승패·Elo 반영, 리더보드, 섬 공유(올리기·방문·좋아요·차단·삭제)를 순서대로 검증하고 종료 시 모든 로컬 데이터를 폐기한다. + +오랜만에 돌리면 Functions 로드(`googleapis` 초기화)가 60초를 넘겨 `Cannot determine backend specification. Timeout after 60000`으로 실패할 수 있다. 코드 결함이 아니라 디스크 캐시가 식어서다 — 한 번 더 실행하면 통과한다(2026-10-02 실측: 첫 실행 실패, 재실행 통과). ## 실기기 검증 - 서로 다른 Firebase 계정 두 개와 각각 3마리 이상 편성된 배틀 팀이 필요하다. - 친구 요청/수락 양방향 표시, 친선전 도전 수락, 랭크 매칭, 세 마리 전멸, 기권, 앱 재접속 후 진행 중 매치 복구를 확인한다. - Functions 로그에서 `not_your_turn`, `processedActionIds`, 랭크전 종료 시 `ratingApplied=true`를 확인한다. + +## 섬 공유 + +"나의 섬"의 공개·방문·좋아요는 `socialPvpApi`에 얹은 액션 5개로 한다. Firestore 규칙상 클라이언트는 +타인 문서를 못 읽으므로 전부 이 함수를 거친다. 기획은 `Docs/IslandDesign.md`, 검증 로직의 단일 출처는 +`functions/island.js`(순수 함수 — `functions/test/island.test.js`가 에뮬레이터 없이 돈다). + +### 요청·응답 + +요청은 다른 액션과 같다(POST, `Authorization: Bearer `). 본문 필드는 +`action`, `island`(섬 스냅샷 JSON을 **문자열로**), `isPublic`(bool), `friendCode`, `targetUid`. +클라이언트는 액션과 무관하게 다섯 필드를 전부 보내므로 **빈 문자열(공백만 있는 것 포함)은 없는 값**이다. +`isPublic`은 진짜 `true`일 때만 공개다. + +응답은 `{ success: true, island: IslandInfo }`(`deleteIsland`만 `{ success: true }`). + +``` +IslandInfo = { + ownerUid: string, + ownerName: string, // 서버가 정한다 — 클라이언트 값을 믿지 않는다 + friendCode: string, + isPublic: boolean, + likes: number, + visits: number, + likedByMe: boolean, // 요청자가 오늘(UTC) 이미 좋아요를 눌렀는가 + updatedAtMs: number, // 마지막 publish 시각(ms). 없으면 0 + snapshot: string // 섬 스냅샷 JSON 문자열. getIsland만 채우고 나머지는 "" +} +``` + +| action | 입력 | 동작 | +|---|---|---| +| `publishIsland` | `island`, `isPublic` | 내 `islands/{uid}`를 저장(merge). `likes`/`visits`는 건드리지 않는다(없을 때만 0) | +| `getMyIsland` | 없음 | 내 IslandInfo(`snapshot: ""`). 올린 적이 없어도 성공 — `isPublic:false`, 수치 0, 이름·친구 코드는 계산해서 채운다 | +| `getIsland` | `friendCode` 또는 `targetUid`(둘 다 있으면 `targetUid`) | 대상의 IslandInfo + `snapshot`. 본인이 아니면 `visits` +1(매 호출마다) | +| `likeIsland` | `targetUid` | `islands/{target}/likes/{요청자}`에 오늘 날짜를 적고 `likes` +1. 같은 UTC 날짜엔 한 번만 | +| `deleteIsland` | 없음 | 내 섬 문서와 `likes` 서브컬렉션(앞 400건)을 지운다. 없어도 성공 | + +- `friendCode`는 대소문자를 가리지 않는다(앞뒤 공백 제거 후 대문자). `islands`의 `friendCode` 필드로 찾으므로 + 주인이 PVP 창을 한 번도 안 열어 `socialProfiles`가 없어도 찾힌다. 단일 필드 색인은 자동이라 색인 설정이 따로 없다. +- `ownerName`은 `syncProfile`과 같은 규칙이다 — `socialProfiles/{uid}.displayName`, 없으면 토큰의 `name`, + 그것도 없으면 "탐험가". publish 때 문서에 저장하고 읽을 때는 저장된 값을 쓴다(이름을 바꾸면 다시 올려야 반영된다). +- 하루의 경계(`dayKey`)는 UTC `YYYY-MM-DD`다 — 한국 시간 오전 9시에 좋아요가 다시 열린다. + +### 오류 코드 + +| 코드 | HTTP | 언제 | +|---|---|---| +| `island_invalid` | 400 | `island`가 문자열이 아니거나, JSON 파싱 실패, 48,000자 초과, 최상위가 객체가 아님 | +| `island_target_required` | 400 | `getIsland`에 `targetUid`·`friendCode`가 둘 다 없음 / `likeIsland`에 `targetUid` 없음 | +| `island_not_found` | 400 | 그 친구 코드·uid의 섬 문서가 없음(올린 적 없음·삭제됨) | +| `cannot_like_self` | 400 | 자기 섬에 좋아요 | +| `island_private` | 409 | `isPublic`이 false이고 본인이 아님 | +| `user_blocked` | 409 | 어느 방향이든 차단 관계(`socialBlocks`) | +| `already_liked_today` | 409 | 같은 UTC 날짜에 이미 누름 | + +판정 순서는 `island_not_found` → `island_private` → `user_blocked` → `already_liked_today`다. +새 코드를 늘리면 `createSocialPvpHandler`의 `conflicts`/`clientErrors` 집합에도 넣을 것 — 빠뜨리면 500 `social_pvp_failed`로 뭉개진다. + +### 스냅샷 검증 + +서버는 카탈로그를 모른다. **형식만** 보고 정리해 저장하며, 모르는 id는 클라이언트가 스스로 버린다. + +- `version`은 1로 다시 쓴다. `sizeLevel` 0~3, `comfort` 0~9999(정수 clamp). `ownerName`과 모르는 필드는 버린다. +- `placed`: 앞 400개까지. `id`가 `/^[a-z][a-z0-9_]{1,31}$/`이 아니면 그 항목을 버린다. `x`·`z`는 반올림 후 -16~15, + `rot`은 0~3으로 정규화(음수 포함). +- `insects`: 앞 10개까지. `insectId`는 `/^[a-z][a-z0-9_]{1,47}$/`, `level` 1~80, `shiny`는 진짜 `true`만. +- 숫자 칸은 숫자와 숫자 문자열만 읽는다. 그 외(null·bool·배열·NaN)는 기본값(좌표·회전 0, 레벨 1)이다. + +### 저장 구조 + +``` +islands/{uid} + uid, ownerName, friendCode, isPublic, + snapshot // 정리한 스냅샷을 JSON.stringify한 문자열 + likes, visits, + updatedAt // serverTimestamp + updatedAtMs // Date.now() +islands/{uid}/likes/{likerUid} + dayKey // 마지막으로 누른 UTC 날짜. 누른 사람당 문서 하나를 덮어쓴다 + likedAtMs +``` + +둘 다 `firestore.rules`에서 `allow read, write: if false`다(Admin SDK 전용). + +### 계정 삭제 + +**계정을 지울 때 클라이언트가 `deleteIsland`를 먼저 불러야 한다** — ID 토큰이 살아 있을 때만 호출할 수 있고, +안 부르면 공개 섬이 주인 없이 남아 친구 코드로 계속 찾힌다. 서버에는 액션만 있고 자동 정리는 없다. + +한계 둘: +- `likes`는 앞 400건까지만 섬 문서와 한 배치로 지운다. 좋아요를 누른 사람이 그보다 많으면 나머지는 고아 + 문서로 남는다(uid와 날짜뿐이고 클라이언트가 읽을 수 없다). +- 탈퇴자가 **남의 섬에 누른** 좋아요 문서(`islands/*/likes/{탈퇴자 uid}`)는 지우지 않는다. + +### 배포 (사용자가 직접 실행) + +섬 액션은 함수 `socialPvpApi`에 들어 있고 `islands` 규칙은 `firestore.rules`에 있으므로 명령은 위 「배포」와 같다. +**코드만 작성된 상태다.** `socialPvpApi` 자체가 운영에 올라간 적이 없으므로(위 「배포」 머리말) 섬만 따로 켤 수는 없다 — +섬 공유를 켜면 친구·PvP·월드가 함께 켜진다. 프로젝트 루트에서: + +```powershell +npx --yes firebase-tools@15.22.1 login +npx --yes firebase-tools@15.22.1 deploy --only functions:socialPvpApi,firestore:rules --project insect-exploration-8f0ca +``` + +배포 전 확인: `cd functions; npm test`(단위)와 `./Tools/Test-SocialPvpEmulator.ps1`(에뮬레이터 통합, 결과에 `"islandShare": "passed"`). +배포 전에는 함수 주소 자체가 없어 **404**가 온다(클라이언트는 "섬 공유 서버가 아직 준비되지 않았습니다"로 보여 준다). +`unknown_action`(400)은 섬 액션을 모르는 **옛 버전의 함수**가 올라가 있을 때만 나온다. diff --git a/Docs/StoryBible.md b/Docs/StoryBible.md index d79a1cb..dc0afca 100644 --- a/Docs/StoryBible.md +++ b/Docs/StoryBible.md @@ -3,15 +3,27 @@ `StorySystemDesign.md`가 **그릇**(데이터 모델·트리거·세이브)이라면, 이 문서는 **내용**이다. 플롯·인물·용어·비트 배치가 여기 있고, 저작된 실체는 `Assets/Resources/Story.json`이다. -> **원칙 — 1막을 부정하지 않는다.** 이미 배포된 24비트를 본 세이브가 존재한다. -> `oneShot` 비트는 열람되면 `story_progress.json`의 `seenBeatIds`에 박히고 **다시는 안 뜬다.** -> 그러므로 기존 대사를 고쳐도 그걸 이미 본 유저에게는 닿지 않는다. -> 2막은 1막을 **재해석**할 뿐 뒤엎지 않는다. +> **원칙 — 본 비트는 다시 안 뜬다.** 열람한 비트는 `story_progress.json`의 `seenBeatIds`에 박히고 **다시는 안 뜬다.** +> 그러므로 대사를 고쳐도 그걸 이미 본 세이브에는 닿지 않는다. +> 예전엔 이 때문에 "1막을 부정하지 않는다"를 원칙으로 두었으나, **게임이 아직 출시 전이라**(2026-10-04 사용자 확인) +> 실사용자 세이브가 없다 — 15장의 쉬운 이야기 개편에서 1막까지 다시 썼다. 출시 뒤에는 이 원칙이 다시 산다. --- ## 0. 1막 요약 (ver1 · ch1~ch6 + side/npc) +### 2026-09-19 연결성 보정 + +비트 ID·보상·열람 기록은 유지한다. 이미 읽은 대사는 다시 재생하지 않으며, 아직 읽지 않은 +대사가 늦게 열려도 현재 진행을 부정하지 않도록 다음 표현과 진입점만 보정했다. + +- `ch1_guardian_call`은 초원 수문장 재도전을 요구하지 않고 앞으로의 팀 운용을 조언한다. +- `ch2_water`는 라온의 짧은 재소개를 포함한다. 초원 소개를 놓친 세이브도 동행자를 알 수 있다. +- `bl_forest_sign`·`bl_mountain_sign`·`bl_mountain_clash`·`bl_ruins_sign`은 남획의 흔적과 회복을 + 이야기한다. 정화 뒤 늦게 들어도 그물·상자가 지금 가동 중이라고 주장하지 않는다. +- `ch12_clash`는 `fin_seal` 뒤 이름 없는 자리에서 하월에게 다시 말 걸어 듣는다. + 최종 승리의 여운을 위해 별도 전투를 한 번 더 요구하지 않는다. + 초원에서 시작한 채집가가 곤충들이 사라지는 현상 **「잦아듦」**을 쫓아 연못·숲·습지·산을 지나 고대 유적에 이른다. 유적 신전에서 세라가 진실에 닿는다 — **봉인의 정체는 '기록'이었다.** 고대인은 모든 곤충을 벽에 새겨 이 세계에 붙들어 두었고, 세월에 기록이 바래며 종이 하나둘 @@ -59,42 +71,59 @@ ch1의 5비트가 **`QuestComplete` 체인**으로 묶여 있었다: --- -## 1. 2막의 중심 사건 — 「봉인이 열린 날」 +## 1. 2막의 중심 사건 — 「채워지지 않는 한 칸」 -플레이어가 신전의 기록을 복원했다. **그 행위가 2막을 연다.** - -> 고대인의 기록은 곤충을 **지키기 위한** 것이 아니었다. -> 기록은 **울타리**였다. 이름을 가진 것들로 세계를 빈틈없이 메워, **이름 없는 것**이 -> 비집고 들어올 자리를 없앤 것이다. +> **세상은 울타리 안이다.** 옛사람들이 곤충 이름을 하나도 빠짐없이 돌벽에 새겼다 — **이름 벽**. +> 이름 하나가 울타리 말뚝 하나다. 이 울타리가 바깥에 사는 **그림자**를 막는다. +> +> 세월에 이름이 바래면 그 곤충이 사라지고(**잦아듦**) 울타리에 구멍(**빈칸**)이 난다. +> 그림자는 그 구멍으로 들어와 **곤충의 이름을 빼앗아 그 곤충인 척 숨는다.** 이름을 빼앗긴 곤충은 +> 색과 무늬를 잃은 **이름 잃은 곤충**이 된다. > -> 종이 하나 지워질 때마다 세계에 **빈칸**이 생겼고, 그 빈칸으로 「무명」이 스며들었다. -> 그리고 플레이어가 기록을 되살리자 — 빈칸이 메워지며 **이미 안에 들어와 있던 것이 갇혔다.** -> 갇힌 것은 이제, 나가려 한다. +> 1막에서 주인공이 도감으로 벽의 이름을 되살린다. 벽이 다 차자 **딱 한 칸만 비고, 그 칸이 움직인다** — +> 숨을 데가 없어진 그림자가 드러난 것이다. 2막은 빼앗긴 이름을 돌려주고 남은 빈칸을 메워 +> 그림자를 **울타리 밖으로 밀어내는** 여정이다. ### 이 반전이 성립하는 이유 -1. **1막 대사를 한 줄도 부정하지 않는다.** 세라의 "봉인은 기록이었다"는 전부 맞다 — - 무엇을 봉인했는지가 틀렸을 뿐이다. 옛 대사를 고칠 필요가 없다. -2. **핵심 루프의 의미가 뒤집힌다.** 도감 채우기는 여전히 옳지만, 그 옳음이 사건을 불렀다. - 선의가 재앙의 방아쇠였다는 책임이 주인공에게 남는다. -3. **꽃밭 떡밥이 회수된다.** 꽃밭은 고대인이 남긴 **예비 울타리**다. 기록이 한 번도 바래지 - 않은 유일한 땅이라 잦아듦이 닿지 않았다 (ch11에서 밝혀진다). +1. **1막의 일이 옳았다.** 도감 채우기가 재앙을 부른 게 아니라, 숨어 있던 적을 드러냈다. 아이가 "내가 잘못했나?"가 아니라 + "그래서 들켰구나!"로 읽는다. +2. **할 일이 바뀌지 않고 깊어진다.** 2막에서도 만나서 도감에 적는다 — 이제 그게 "이름을 돌려주는 일"이 된다. + 2막 필드의 검은 `???` 개체(이름 잃은 곤충)를 다시 만나는 플레이와 그대로 맞물린다. +3. **꽃밭 떡밥이 회수된다.** 꽃밭은 옛사람들이 남긴 **비상 울타리**다(우듬지와 둘). 이름이 한 번도 바래지 않은 땅이라 + 잦아듦이 닿지 않았다(ch11에서 밝혀진다). + +**예전 설정(2026-10-04 이전) — 버렸다.** 「봉인이 열린 날」: 기록을 되살리자 "이미 안에 들어와 있던 것이 **갇혔고** 이제 **나가려** 한다", +"우리가 **연** 문". 그런데 같은 이야기의 다른 장면(꽃밭 문지기·집게·최종장)은 "**들어오는** 것을 막는다"로 쓰여 있어 +그림자가 안에 있는지 밖에 있는지가 장면마다 뒤집혔다. 아이에게는 "가둔 걸 쫓아가면 좋은 건가?"로 읽혔다. +지금은 **바깥 → 구멍으로 들어옴 → 밖으로 밀어냄** 한 방향뿐이다. --- ## 2. 세계관 용어 -| 용어 | 뜻 | 게임 내 표현 | -|---|---|---| -| **잦아듦** | 곤충이 사라지는 현상 (1막) | — | -| **빈칸** | 종이 지워진 자리. 세계에 뚫린 구멍 | 도감의 회색 항목 | -| **무명(無名)** | 이름 없는 것. 다른 곤충의 이름을 빼앗아 세상에 나온다 | 최종 보스 | -| **지워진 개체** | 이름을 빼앗긴 곤충. 검은 실루엣이 되어 자기 이름을 잊었다 | 2막 필드 변종. 다시 포획하면 "이름을 되찾아준다" | -| **장부** | 명부회가 만드는 강제 기록 | 적의 도감 | +**처음 나올 때 풀어 주고 그대로 쓰는 말**과 **쉬운 말로 바꾼 말**이 있다(15장). 바꾼 말의 옛 표현은 `story_lint` 검사 32가 막는다. -**「무명」의 표기 규칙**: 대사에서 이름으로 부르지 않는다. "그것", "이름 없는 것", -"자리를 찾는 것"으로만 지칭한다. 이름을 붙이는 순간 그것이 이름을 갖게 된다는 게 이 이야기의 규칙이다. -최종장에서 플레이어가 **이름을 주지 않고 이기는 것**이 승리 조건의 서사적 의미다. +| 용어 | 뜻 | 처음 풀어 주는 곳 | +|---|---|---| +| **잦아듦** | 곤충이 소리 없이 사라지는 일 | `ch1_intro` 어르신 | +| **장부** | 명부회가 곤충을 잡아 숫자를 적는 책 | `ch2_watchers` 라온 | +| **명부회** | 곤충 이름을 남김없이 장부에 적는 무리 | `ch6_ledger_hint` 사내 | +| **이름 벽** (옛 「봉인」·「기록」) | 곤충 이름을 새긴 신전의 벽. 이름 하나가 말뚝 하나인 울타리 | `ch3_reach_forest`(소문) → `ch6_secret`(발견) → `ch7_opening`(울타리) | +| **빈칸** | 이름이 바래 빈 자리 = 울타리의 구멍 | `ch6_secret` 세라 | +| **그림자** (옛 「그것」·「무명」·「이름 없는 것」) | 울타리 바깥에 사는 이름 없는 존재. 빈칸으로 들어와 곤충 이름을 빼앗는다 | `ch7_opening` 세라 | +| **이름 잃은 곤충** (옛 「지워진 개체」) | 그림자에게 이름을 빼앗긴 검은 곤충. 도감에 적으면 이름이 돌아온다 | `ch7_confront` 세라 | +| **비상 울타리** (옛 「예비 울타리」) | 큰 울타리가 무너질 때를 대비한 두 곳 — 꽃밭과 우듬지 | `ch11_confront` 세라 | + +**「그림자」는 이름이 아니라 생김새를 부르는 말이다.** 그림자가 원하는 「이름」은 울타리 안의 자리를 주는 곤충 이름이고, +최종장에서 플레이어가 **이름을 주지 않고 이기는 것**이 승리 조건의 서사적 의미다(그대로다). 영상 자막은 「무명」을 쓰지 않는다 +(`StoryVideoLibraryTests.Library_Cues_NeverNameTheNameless`). **「그림자」를 다른 뜻으로 쓰지 않는다** — 잠자리 그림자·물 위의 그림자 같은 +묘사가 있으면 아이가 그림자(적)와 헷갈린다(15장에서 셋을 지웠다). + +**「거둬들이다」는 명부회의 말이다.** 곤충을 받아 가두는 사람의 어휘다 — `ch3_warning`에서 세라가 "채집가는 그런 말을 +안 써요. 수확하는 사람의 말이에요"라고 짚는다. 그래서 라온·세라·어르신·지문은 **만나다·새기다·기록하다**로 쓴다. +`story_lint` 검사 30이 잡는다(인용부호가 든 줄과 명부회 화자는 제외). 이 규칙이 생기기 전엔 `ch1_first_capture`·`ch2_water`· +`ch4_bond`·`ch4_decay`·`ch6_approach`가 같은 말을 써서, 세라가 짚는 그 순간의 대사가 거짓이었다. --- @@ -129,7 +158,7 @@ ch1의 5비트가 **`QuestComplete` 체인**으로 묶여 있었다: | 인물 | 2막에서 | |---|---| | **세라** | 스승과 맞서는 축. ch9(서릿길)에서 자기가 한때 명부회의 방식을 옳다고 믿었음을 고백한다. 1막의 "당신이 마지막 희망"이 여기서 "우리가 함께 틀릴 수도 있다"로 성숙한다 | -| **라온** | 최전선. ch10(잿불 골짜기)에서 다쳐 전력에서 이탈했다가 최종장에 복귀한다. 이탈 구간에는 **앵커를 두지 않는다** — 배치 자체가 서사를 실어 나른다 | +| **라온** | 최전선. ch10(잿불 골짜기)에서 다쳐 전력에서 이탈했다가 최종장에 복귀한다. 이탈 구간에는 **앵커를 두지 않는다** — 배치 자체가 서사를 실어 나른다. 예외는 부상 직후 잿불 골짜기 전초기지의 한 장면(`camp_ember`)뿐이다 — 라온이 실제로 누워 있는 곳(앵커)이라 몸과 대사가 어긋나지 않는다 | | **마을 어르신** | 초원에 남는다. 2막에서 유일하게 움직이지 않는 사람. ch11에서 자기가 흘리던 옛이야기의 출처가 명부회의 옛 문서였음을 털어놓는다 | --- @@ -176,6 +205,10 @@ ID·리전만 갈면 된다. 세이브는 캠페인이 영구 정지한다.** `RegionEnter`/`SubAreaEnter`는 재진입할 때마다 다시 발화하므로 놓쳐도 회복된다. leaf 비트(2·4·5)는 어떤 비트의 prereq도 아니라서 놓쳐도 체인이 안 끊긴다. +**2026-10-04부터 2막 간부 장(ch8·ch9·ch12)은 여섯째 칸이 스파인이다** — 간부 승리(`DuelWin`, `duel_*_win`)가 다음 장의 문이다(16장). +이긴 순간의 이벤트는 일생 1회지만 **격파 기록을 보고 다시 흘리는 재확인**(`StoryDirector.ResweepPersistentConditions`)이 있어 재발화형으로 친다. +`story_lint` 검사 8이 그 재확인 줄을 소스에서 확인한다. + ### 무param 트리거의 리전 잠금 2·4번(`CaptureInsect ""` / `BattleWin ""`)은 위치를 못 실어 **엉뚱한 리전에서 늦발화**한다. @@ -237,9 +270,9 @@ ch8·ch10엔 세라 여운이, ch7·ch9·ch11엔 라온 여운이 없었다. 라 | 비트 | prereq(유지) | requiredBeatId | |---|---|---| | `ch7_echo` | `talk_scholar` | `ch7_arrive` | -| `ch8_echo` | `talk_rival` | `ch8_arrive` | +| `ch8_echo` | `talk_rival` | `ch8_confront` | | `ch9_echo` | `ch7_echo` | `ch9_arrive` | -| `ch10_echo` | (없음) | `ch10_arrive` | +| `ch10_echo` | (없음) | `ch10_confront` | | `ch11_echo` | `talk_elder` | `ch11_arrive` | | `ch12_echo` | `ch8_echo` | `ch12_confront` | | `fin_epilogue` | `ch9_echo` | `fin_seal` | @@ -248,6 +281,10 @@ ch8·ch10엔 세라 여운이, ch7·ch9·ch11엔 라온 여운이 없었다. 라 `QuestComplete`·`GuardianDefeat`를 지목하면 그 순간을 놓친 세이브가 영구 정지한다. `story_lint` 검사 16이 막고, 검사 17(챕터 도달 순서)이 위 7건 같은 조기 발화를 통째로 잡는다. +`ch8_echo`·`ch10_echo`의 게이트는 2026-09-28에 도착에서 **대치**로 옮겼다(10장 「2막 보강 내역」). +여운이 대치에서 처음 드러나는 사실을 말하는데 도착만으로 열려 있었다 — 집게가 목적을 밝히기 전에 +라온이 "쟤들 목적"을, 먹이 이탈하기 전에 "장부는 두고 왔다"를 말할 수 있었다. + --- ### 선택지 규칙 — 표현이지 분기가 아니다 (2026-09-09) @@ -303,7 +340,7 @@ ch8·ch10엔 세라 여운이, ch7·ch9·ch11엔 라온 여운이 없었다. 라 신규 20비트 + 동행자 앵커 5곳으로 메웠다. **새 trigger.type도 새 인물도 만들지 않았다** — 기존 3인(어르신·라온·세라)만 재배치했으므로 `NpcManager.StoryNpcDisplayName` / -`NpcVisualBuilder.StoryNpcAppearance`의 무증상 함정(검사 22)을 애초에 안 건드린다. +`NpcVisualBuilder.StoryNpcFace`의 무증상 함정(검사 22)을 애초에 안 건드린다. ### 꽃밭 — 분기로 두되 서사를 채운다 @@ -428,17 +465,19 @@ meadow―swamp(101m)이고 사슬인 `pond→forest`는 280m라, 지도를 훑 |---|---|---|---|---|---| | `ch8_arrive` | `RegionEnter dunes` | `ch7_confront` | 라온 | 모래에 파묻힌 상자들. 안에 살아 있는 곤충이 가득한데 아무도 돌보지 않는다 | 캔디40 / XP100 | | `ch8_sign` | `CaptureInsect ""` (req `dunes`) | `ch8_arrive` | 라온 | 놓아주고 새기는 것과 가둬 놓고 세는 것은 다르다 | 캔디35 / XP80 | -| `ch8_confront` | `SubAreaEnter dunes_vault` | `ch8_arrive` | **집게** | 창고. "빈칸이 재앙을 부른다. 우린 그걸 막는 거다." 명부회 첫 대면 | 캔디55 / XP140 | +| `ch8_confront` | `SubAreaEnter dunes_vault` | `ch8_arrive` | **집게** | 창고. "빈칸마다 구멍이 뚫린다. 그래서 전부 장부에 올린다." 명부회 첫 대면 — "상자를 열고 싶으면 실력으로 와라"로 맺고 **곧바로 대결**(`duelAfter: ledger_grip`, 16장) | 캔디55 / XP140 | +| `duel_grip_win` | `DuelWin ledger_grip` (게이트 `ch8_confront`) | — | **집게** | "실력으로 증명했군." 상자는 네가 열어라. **서릿길의 문** — `ch9_arrive`의 선행 | 캔디45 / XP100 | | `ch8_clash` | `BattleWin ""` (req `dunes`) | `ch8_arrive` | 세라 | 저 장부를 봤어요. 우리 도감과 똑같이 생겼는데… 이름 옆에 **날짜와 수량**이 적혀 있어요 | 캔디35 / XP80 | -| `ch8_echo` | `NpcTalk catcher_rival` | `talk_rival` | 라온 | 쟤들 목적은 우리랑 같아. 그래서 더 화가 나 | 캔디25 / XP50 | +| `ch8_echo` | `NpcTalk catcher_rival` (게이트 `ch8_confront`) | `talk_rival` | 라온 | 쟤들 목적은 우리랑 같아. 그래서 더 화가 나 | 캔디25 / XP50 | ### ch9 — 서릿길 (`frostline`) | beatId | trigger | prereq | 화자 | 요지 | 보상 | |---|---|---|---|---|---| -| `ch9_arrive` | `RegionEnter frostline` | `ch8_confront` | 세라 | 얼음 속에 고대의 기록이 그대로 남아 있다. 여긴 시간이 멈춰 빈칸이 안 생긴 땅 | 캔디45 / XP110 | +| `ch9_arrive` | `RegionEnter frostline` | `duel_grip_win` | 세라 | 얼음 속에 고대의 기록이 그대로 남아 있다. 여긴 시간이 멈춰 빈칸이 안 생긴 땅 | 캔디45 / XP110 | | `ch9_sign` | `CaptureInsect ""` (req `frostline`) | `ch9_arrive` | 세라 | 얼음이 붙든 것과 도감이 붙든 것은 무엇이 다를까요 | 캔디40 / XP90 | -| `ch9_confront` | `SubAreaEnter frostline_archive` | `ch9_arrive` | **저울** | 얼음 서고. 저울이 세라를 알아본다 — 옛 동문. "너도 한때는 우리 방식이 옳다고 했잖아" | 캔디60 / XP160 | +| `ch9_confront` | `SubAreaEnter frostline_archive` | `ch9_arrive` | **저울** | 얼음 서고. 저울이 세라를 알아본다 — 옛 동문. "무엇이 달라졌지?"(선택지) → 고른 결과 대사가 "실력을 재 보자"로 맺고 **곧바로 대결**(`duelAfter: ledger_scale`) | 캔디60 / XP160 | +| `duel_scale_win` | `DuelWin ledger_scale` (게이트 `ch9_confront`) | — | **저울** | "내 자로 쟀고, 내 자로 졌다." **잿불 골짜기의 문** — `ch10_arrive`의 선행 | 캔디50 / XP110 | | `ch9_clash` | `BattleWin ""` (req `frostline`) | `ch9_arrive` | 세라 | 아까 저울 선배 물음에 대답을 못 했어요. 달라진 건 속도가 아니라 방향이에요 | 캔디40 / XP90 | | `ch9_echo` | `NpcTalk ruins_scholar` | `ch7_echo` | 세라 | 스승님은 제자를 잦아듦으로 잃었어요. 그래서 기다리는 걸 못 견디시는 거예요 | 캔디30 / XP60 | @@ -446,11 +485,11 @@ meadow―swamp(101m)이고 사슬인 `pond→forest`는 280m라, 지도를 훑 | beatId | trigger | prereq | 화자 | 요지 | 보상 | |---|---|---|---|---|---| -| `ch10_arrive` | `RegionEnter emberfall` | `ch9_confront` | 라온 | 기록이 불탄 땅. 여긴 빈칸이 가장 많아서 그것이 가장 두껍게 스며 있다 | 캔디50 / XP120 | +| `ch10_arrive` | `RegionEnter emberfall` | `duel_scale_win` | 라온 | 기록이 불탄 땅. 여긴 빈칸이 가장 많아서 그것이 가장 두껍게 스며 있다 | 캔디50 / XP120 | | `ch10_sign` | `CaptureInsect ""` (req `emberfall`) | `ch10_arrive` | **먹** | 장부를 쓰는 손으로 묻는다. 내가 적은 이름 중에 아직 살아 있는 게 몇이나 될까 | 캔디45 / XP100 | | `ch10_confront` | `SubAreaEnter emberfall_kiln` | `ch10_arrive` | **먹** | 라온이 무너지는 갱도에서 곤충들을 빼내다 다친다. 먹이 그를 끌어낸다 — 그리고 장부를 덮는다 | 캔디70 / XP180 | -| `ch10_clash` | `BattleWin ""` (req `emberfall`) | `ch10_arrive` | 세라 | 라온은 괜찮을 거예요. 대신 여기서부턴… 우리 둘이에요 | 캔디45 / XP100 | -| `ch10_echo` | `NpcTalk ledger_ink` | (없음) | **먹** | 장부는 두고 왔다, 태우진 않았다. 관장님은 30년째 같은 날에 서 계신다 — 나는 다음 날로 가고 싶다 ("적는 사람이지 가두는 사람이 아니었다"는 `ch10_confront`의 대사다) | 캔디35 / XP70 | +| `ch10_clash` | `BattleWin ""` (req `emberfall`) | `ch10_confront` | 세라 | 라온은 괜찮을 거예요. 대신 여기서부턴… 우리 둘이에요 | 캔디45 / XP100 | +| `ch10_echo` | `NpcTalk ledger_ink` (게이트 `ch10_confront`) | (없음) | **먹** | 장부는 두고 왔다, 태우진 않았다. 관장님은 30년째 같은 날에 서 계신다 — 나는 다음 날로 가고 싶다 ("적는 사람이지 가두는 사람이 아니었다"는 `ch10_confront`의 대사다) | 캔디35 / XP70 | ### ch11 — 우듬지 (`canopy`) @@ -459,7 +498,7 @@ meadow―swamp(101m)이고 사슬인 `pond→forest`는 280m라, 지도를 훑 | `ch11_arrive` | `RegionEnter canopy` | `ch10_confront` | 세라 | 거대수 수관층. 꽃밭처럼 여기도 멀쩡하다. 왜 이 두 곳만? | 캔디55 / XP140 | | `ch11_sign` | `CaptureInsect ""` (req `canopy`) | `ch11_arrive` | 세라 | 여기 곤충들은 한 번도 이름을 잃은 적이 없어요. 기록이 바랜 적이 없으니까 | 캔디50 / XP110 | | `ch11_confront` | `SubAreaEnter canopy_crown` | `ch11_arrive` | 세라 | **떡밥 회수** — 꽃밭과 우듬지는 고대인이 남긴 **예비 울타리**. 본 기록이 무너질 때를 대비해 이중으로 친 것 | 캔디80 / XP200 | -| `ch11_clash` | `BattleWin ""` (req `canopy`) | `ch11_arrive` | 세라 | 고대인은 자기들이 실패할 걸 알고 있었어요. 그래서 우리한테 두 칸을 남긴 거예요 | 캔디50 / XP110 | +| `ch11_clash` | `BattleWin ""` (req `canopy`) | `ch11_confront` | 세라 | 고대인은 자기들이 실패할 걸 알고 있었어요. 그래서 우리한테 두 칸을 남긴 거예요 | 캔디50 / XP110 | | `ch11_echo` | `NpcTalk village_elder` | `talk_elder` | 마을 어르신 | 내가 흘리던 옛이야기는 사실 명부회의 옛 문서에서 온 거란다. 나도 한때 그 사람들과 같은 자리에 있었어 | 캔디40 / XP80 | ### ch12 — 이름 없는 자리 (`nameless`) + 최종장 @@ -468,10 +507,11 @@ meadow―swamp(101m)이고 사슬인 `pond→forest`는 280m라, 지도를 훑 |---|---|---|---|---|---| | `ch12_arrive` | `RegionEnter nameless` | `ch11_confront` | 세라 | 지도에 없는 땅. 여기가 원래 그것이 있던 자리 | 캔디60 / XP160 | | `ch12_sign` | `CaptureInsect ""` (req `nameless`) | `ch12_arrive` | 세라 | 한 이름씩, 한 자리씩. 그것이 설 곳이 줄고 있어요 | 캔디55 / XP130 | -| `ch12_confront` | `SubAreaEnter nameless_ledger` | `ch12_arrive` | **관장 하월** | 결착. "내 장부는 3,000종을 적었다. 그중 절반이 창고에서 죽었지." 자기 방식이 빈칸을 키웠음을 인정한다 | 캔디100 / XP240 | -| `ch12_clash` | `BattleWin ""` (req `nameless`) | `ch12_arrive` | 관장 하월 | 나는 30년을 서둘렀고 너는 한 마리씩 만났다. 결과가 이 꼴이라니 | 캔디55 / XP130 | +| `ch12_confront` | `SubAreaEnter nameless_ledger` | `ch12_arrive` | **관장 하월** | 결착. "내 장부는 3,000종을 적었다. 그중 절반이 창고에서 죽었지." 자기 방식이 빈칸을 키웠음을 인정하고, 이름 잃은 나방을 내민다 — "이 아이를 넘어 보게"로 맺고 **곧바로 대결**(`duelAfter: ledger_chief`) | 캔디100 / XP240 | +| `duel_chief_win` | `DuelWin ledger_chief` (게이트 `ch12_confront`) | — | **관장 하월** | "스승 노릇은 여기까지다. 길을 비켜 주마. 안쪽에서 그림자가 기다린다." 이름을 돌려받은 나방 Lv.62 + 장부의 방 영상(`vid_ch12_ledger`). **빈칸의 문** — `fin_unnamed`의 선행 | 캔디80 / XP180 | +| `ch12_clash` | `NpcTalk ledger_chief` (req `nameless`) | `fin_seal` | 관장 하월 | 최종 승리 뒤 다시 만난 관장의 여운. 추가 전투 없이 본인에게 말을 걸면 이어진다 | 캔디55 / XP130 | | `ch12_echo` | `NpcTalk catcher_rival` | `ch8_echo` | 라온 | (복귀) 늦어서 미안. 마지막은 같이 가야지 | 캔디45 / XP90 | -| `fin_unnamed` | `SubAreaEnter nameless_core` | `ch12_confront` | 세라 | **무명과의 대면.** 그것은 이름을 달라고 한다. 주면 자리를 얻는다. **주지 않는 것이 이기는 것** | 캔디150 / XP300 | +| `fin_unnamed` | `SubAreaEnter nameless_core` | `duel_chief_win` | 세라 | **무명과의 대면.** 그것은 이름을 달라고 한다. 주면 자리를 얻는다. **주지 않는 것이 이기는 것** | 캔디150 / XP300 | | `fin_seal` | `BattleWin mantis_unnamed` (req `nameless`) | `fin_unnamed` | 세라 | 마지막 빈칸이 메워진다. 이름 없는 것은 설 자리를 잃고 물러난다 | 캔디150 / XP300 + 곤충 보상 | | `fin_epilogue` | `NpcTalk ruins_scholar` | `ch9_echo` | 세라 | **에필로그.** 빈칸은 사라지지 않았어요. 종이 사라지면 또 생기죠. 그러니 계속 만나요 — 3막의 문 | 캔디120 / XP250 | @@ -584,11 +624,12 @@ Lv.50~64에서 이미 잡은 종이 절반 가까이 나왔다. 리전당 4종 | 컷신 | 붙은 비트 | 길이 | 내용 | |---|---|---|---| -| `cs_story_prologue` | `ch1_intro` (초원) | 10.0s | 1막 개막. 이야기를 듣고 **첫 파트너를 받은 직후** 초원으로 내보낸다 — 「이 아이와 함께라면, 그 이유를 찾을 수 있을지도 모른다」 | -| `cs_seal_discovery` | `ch6_secret` (유적 신전) | 10.8s | **1막 클라이맥스.** 벽을 훑고 물러난다 — 「벽은 장식이 아니었다. 목록이었다」. `cs_seal_opening`이 같은 자리에서 **바닥**으로 내려가는 것과 짝을 이룬다 | -| `cs_seal_opening` | `ch7_opening` (유적 지하) | 14.4s | 기록이 제자리를 찾자 바닥이 갈라진다. 「울타리는 지키려고 세운 것이 아니라 가두려고 세운 것이었다 — 그리고 방금, 안에 있던 것이 갇혔다」 | -| `cs_nameless_confront` | `fin_unnamed` (빈칸) | 8.6s | 무명과의 대치. 시선 지점을 어긋나게 두어 「보고 있는데도 무엇을 보고 있는지 알 수 없는」 인상을 만든다 | -| `cs_final_seal` | `fin_seal` (빈칸) | 9.0s | **결말.** 대치가 딤을 쌓으며 들어갔으므로 여기서는 걷으며 나온다(0.40 → 0). 「그것은 죽지 않았다. 들어올 틈이 없어졌을 뿐이다」 | +| `cs_story_prologue` | (쓰지 않음 — 옛 `ch1_intro`) | 10.0s | 1막 개막. `vid_ch1_prologue`로 대체됐고 폴백 후보로만 남았다 | +| `cs_bl_cleanse` | 오염 거점 정화 비트 셋 | 9.4s | 거점이 무너진 땅 — 전 거점이 공유한다(7-4) | + +**2026-10-05에 이야기 장면 컷신 넷을 지웠다** — `cs_seal_discovery`(ch6 이름 벽)·`cs_seal_opening`(ch7 울타리)·`cs_nameless_confront`(그림자 대치)· +`cs_final_seal`(결말). 같은 비트에 **대사 앞 그림책 영상**(`introVideoId`, 7-1b)이 붙어 설명을 맡게 됐고, 정지 월드를 훑는 카메라가 +대사 뒤에 한 번 더 오면 아이가 같은 이야기를 세 번(영상·대사·컷신) 보게 된다. 다른 곳이 쓰지 않아 상수·case·빌더를 함께 지웠다. **대사가 끝난 뒤**(`StoryBeatCompleted`) 재생한다 — 발화 시점에 걸면 대사 모달과 화면을 다투고, 읽는 순서도 "장면을 보고 나서 대사"가 되어 어색하다. @@ -609,6 +650,62 @@ Lv.50~64에서 이미 잡은 종이 절반 가까이 나왔다. 리전당 4종 --- +## 7-1b. 영상 (StreamingAssets · 그림책 mp4 · 소리 포함) + +실제 영상 파일을 재생한다. `StoryVideoLibrary.cs`가 파일명·자막 큐를, `StoryVideoDirector`가 재생·복구를 맡는다. +샷 리스트·렌더·인코딩·검증 절차의 단일 출처는 **`Docs/StoryVideos.md`**다. 2026-10-05부터 15편 전부 +**그림책 화풍 + 소리**다 — `Tools/Video/storybook/render.py`가 그리고(편마다 `v_<파일명>.py`), 소리는 mp4 안 AAC로 들어 있어 +지휘자가 `AudioSource`로 보낸다(그 전의 11편은 `Tools/Video/story_silhouettes.py`의 무음 실루엣 삽화였다). + +**영상 먼저 — 설명은 영상이, 대사는 반응만(2026-10-05).** 대상이 초등 고학년이라 이야기의 큰 설명(이름 벽·울타리·그림자·결말)은 +**대사 앞 영상**(`introVideoId`)이 자막 한 줄씩 보여 주고, 뒤의 대사는 6줄 이하로 인물의 반응만 맡는다(`story_lint` 검사 36). +순서는 **영상 → NPC 등장 연출 → (장을 여는 비트면) 「지난 이야기」 카드 → 대사**다 — 드라마의 콜드 오픈처럼 장면을 먼저 보고 요약을 본다. +대화창의 연출 슬롯이 하나라 `StoryPreludeChain`이 영상·등장 연출을 차례로 잇는다. 영상은 어떤 경로로 끝나든(끝·건너뛰기·파일 없음· +디코더 오류·시간 초과·비활성) 대사를 **정확히 한 번** 연다 — 못 열면 그 비트가 `pendingBeatId`에 갇혀 캠페인이 멈춘다. +나머지 11편은 그대로 **대사 뒤**(`videoId`, `StoryBeatCompleted`) 마무리다. + +| 영상 | 붙은 비트 | 길이 | 내용 | +|---|---|---|---| +| `vid_ch6_wall` | `ch6_secret` (유적 신전) · **대사 앞** | 15.0s | 거대한 이름 벽 → 옛사람이 이름을 새겨 곤충을 지킴(회상) → 이름이 바래면 곤충이 사라짐 → 도감에 적으면 다시 빛남 → 고대의 잠자리가 벽에서 돌아온다 | +| `vid_ch7_fence` | `ch7_opening` (유적 지하) · **대사 앞** · 7장을 연다 | 14.0s | 이름 벽은 울타리였다 → 바깥의 그림자를 막는다 → 바랜 자리가 구멍 → 그림자가 숨어듦 → 벽이 다 차자 숨은 한 칸이 드러난다 | +| `vid_fin_shadow` | `fin_unnamed` (빈칸) · **대사 앞** · 종장을 연다 | 14.4s | 마지막 빈칸의 그림자 → 빼앗은 모습으로 바뀜 → "이름을 줘" → **만약** 주면 울타리 안을 덮는다 → 그러니 주지 않는다 | +| `vid_fin_return` | `fin_seal` (그림자를 이긴 뒤) · **대사 앞** | 14.6s | 빌린 모습들이 빛으로 빠져나와 주인에게 → 그림자가 울타리 밖으로 → 마지막 구멍에 새 말뚝 → 초록 사마귀와 해돋이. 전투 결과 화면이 닫힌 뒤에 뜬다(`BattleWin`은 결과 화면 뒤로 미뤄진다) | +| `vid_ch1_prologue` | `ch1_intro` (초원) | 12.0s | **`cs_story_prologue`를 대체.** 곤충이 흐려져 사라지는 초원 → 은빛 그물 → 손등에 앉는 첫 파트너 | +| `vid_ch2_watchers` | `ch2_watchers` (연못) | 10.0s | 갈대 너머 검은 옷 실루엣이 수첩에 적고 안개로 사라진다 — 적의 첫 그림자 | +| `vid_ch3_scholar` | `ch3_reach_forest` (숲) | 12.0s | 숲 동굴 각인 → 손끝 → 문양이 빛남 → 먼 유적. 세라 첫 등장에 연출이 없던 자리 | +| `vid_ch4_crates` | `ch4_harvest` (습지) | 12.0s | 반쯤 잠긴 상자 열 지어, 틈으로 더듬이. 무대 연출(들킨 몸짓)이 못 보여주는 **상자 안쪽** | +| `vid_ch5_summit` | `ch5_summit` (산 정상) | 12.0s | 구름이 갈라지고 안개 아래 유적이 빛난다 — 카메라 없이는 성립하지 않는 장면 | +| `vid_ch8_vault` | `ch8_confront` (모래언덕 창고) | 12.0s | 천장까지 쌓인 상자, 조용히 멈춘 상자 하나 — **창고의 규모** | +| `vid_ch9_archive` | `ch9_confront` (얼음 서고) | 14.0s | 얼음 속 곤충 → 두 실루엣 → 목록을 적던 젊은 손의 회상. 무대엔 배우가 저울뿐이라 못 세운 세라 | +| `vid_ch10_kiln` | `ch10_confront` (잿불 가마) | 15.0s | 갱도 붕괴 → 검은 소매가 팔을 끌어냄 → 상자 하나 → 장부가 덮인다. **지문 한 줄**이던 액션 | +| `vid_ch11_crown` | `ch11_confront` (우듬지) | 12.0s | 껍질의 문양 → 멀리 온실 → 둘이 한 화면에. 세라가 **가리킨 곳** | +| `vid_ch12_ledger` | `ch12_confront` (장부방) | 14.0s | 빈 석판 → 삼천 칸 선반 → 절반이 먼지 → 노인이 길을 비킨다 | +| `vid_fin_epilogue` | `fin_epilogue` (에필로그) | 15.0s | 후일담 몽타주 — 놓아주는 손·두 실루엣·수량 칸 없는 장부·빈 석판·그물 없는 손. 1편과 수미상관 | + +대사 뒤 영상이 없는 대치는 무대 연출이 등장을 잡고 있으므로 영상은 **NPC 몸짓이 못 보여주는 것**만 맡는다. + +영상을 더 붙일 때 지킬 것(컷신과 같은 규칙 + 넷): +- **총 길이 15초 이하.** `AutoUnfreezeTime`(20s)에서 여유 4초 + 1초. `StoryVideoLibraryTests`가 강제한다. +- **자막 한 줄 24자 이하** — 아이가 2~3초에 한 번에 읽는 길이(같은 테스트). 길면 두 큐로 나눈다. +- **`videoId`는 `cutsceneId`·`stageExitId`와 같은 비트에 두지 않는다** — 셋 다 같은 이벤트를 구독해 조작·모달을 뺏는다. + `story_lint` 검사 13이 막고, 런타임에도 `StoryStageDirector`가 양보한다. **`introVideoId`와 `videoId`도 함께 두지 않는다**(같은 검사) — + 설명은 앞 영상 하나로 한다. +- **대사 앞 영상 비트의 대사는 1~6줄**(검사 36). 영상이 한 설명을 대사가 되풀이하지 않는다 — 대사는 "보세요!"처럼 방금 본 장면을 받는다. +- **자막은 영상에 굽지 않는다** — 게임이 IMGUI로 얹는다(영상을 다시 그리지 않고 문구를 고친다). 그래서 「무명」 + 금칙을 테스트(`Library_Cues_NeverNameTheNameless`)로 걸 수 있다. +- **파일이 없어도 진행은 산다.** 경고 1줄 뒤 즉시 `Stop()` — 여기서 멈추면 모달 가드에 막혀 다음 비트가 + 영영 안 온다. 대사 앞 영상이면 시작도 안 하고 대사가 곧바로 열린다. `story_lint` 검사 25가 미배치를 WARN으로 세고, 배포 전엔 0건이어야 한다. +- 상수 선언과 `TryGet`의 `case`를 반드시 함께 넣는다(검사 25). +- 대사 **뒤** 영상은 컷신처럼 **마무리**다 — 직전 대사를 되풀이하지 않는다(7-1의 프롤로그 사고). 대사 **앞** 영상은 설명이고, + 뒤 대사가 그 마지막 샷을 받는다(`vid_ch6_wall`의 돌아온 잠자리 → "보세요! 사라졌던 고대의 잠자리예요!"). +- 저널의 「▶ 영상」은 `StoryVideoDirector.PlayReplay`로 같은 재생기를 쓴다 — 스토리 부수효과(대사·대결·완료)가 없다. + +> `cs_story_prologue`를 왜 대체했나: 게임의 첫 1분이고, 정지 월드를 훑는 카메라보다 "사라지는 곤충"을 +> 그림으로 보여주는 편이 「잦아듦」을 한 번에 심는다. 상수·case·테스트는 남겨 두어 파일이 없는 +> 환경에서 되돌릴 수 있게 했다(`ch1_intro`의 `cutsceneId`만 비웠다). + +--- + ## 7-2. NPC 연출 (프로시저럴 · 에셋 0) 애니메이션 클립도 없다. `StoryStageLibrary.cs`가 **배우의 이동 + 몸짓 + 사이(間)**만으로 @@ -758,7 +855,7 @@ Lv.50~64에서 이미 잡은 종이 절반 가까이 나왔다. 리전당 4종 |---|---|---| | 1 | `VillageBuilder` 앵커 (`storyNpcId` **리터럴**) | 월드에 안 선다. 헬퍼로 묶으면 `story_lint` 검사 3이 눈이 먼다 | | 2 | `NpcManager.StoryNpcDisplayName` switch | **"마을 어르신"으로 뜬다**(default) | -| 3 | `NpcVisualBuilder.StoryNpcAppearance` switch | **마을 어르신 외형으로 뜬다**(default) | +| 3 | `NpcVisualBuilder.StoryNpcFace` switch(얼굴·색) + `StorySignature`(옷차림·소품) | **마을 어르신 외형으로 뜬다**(default) — 옷차림을 빠뜨리면 어르신의 챙 넓은 모자·지팡이를 든다 | | 4 | `NpcDialogueDatabase.StoryNpcLines` | 비트가 없을 때 **주민 잡담**으로 떨어진다 | | 5 | `NpcBossDuels.Table` | 도전이 안 열려 **거점이 영원히 안 무너진다** | | 6 | Story.json 소개 비트(`speakerNpcId` 또는 `NpcTalk` param) | `HasMetStoryNpc`가 false라 **보스전에 못 들어간다** | @@ -928,6 +1025,9 @@ Lv.50~64에서 이미 잡은 종이 절반 가까이 나왔다. 리전당 4종 - **이미 열람된 비트의 대사를 고치지 않는다.** 그 유저에겐 갱신이 닿지 않아 두 버전이 갈린다. 뒤집으면 — **아직 아무도 못 본 비트는 고쳐도 안전하다.** ch1 재배선에서 `ch1_first_capture` 첫 줄을 손본 게 그 경우다(그 비트를 본 유저는 이미 seen이라 영향이 없다). + **출시 전 예외(2026-09-28)** — 아직 정식 출시 전이라 본 사람은 내부 테스트 세이브뿐이어서, 10장 「대사 보강」 + 때 기존 장면의 대사를 **늘렸다**(사실은 바꾸지 않고 주고받음·지문·연출만 더했다). 정식 출시 뒤엔 이 규칙이 다시 + 그대로 선다 — 그때부터 장면을 두텁게 하려면 새 비트를 더한다. - **스파인을 일생 1회 트리거에 걸지 않는다** — `QuestComplete`·`GuardianDefeat`. leaf로만 쓴다. `story_lint` 검사 8이 GuardianDefeat를 FAIL로 막고, QuestComplete가 prereq로 다시 쓰이면 측정값에 드러난다. **`requiredBeatId`도 같다**(검사 16, 이쪽은 FAIL). @@ -949,17 +1049,625 @@ Lv.50~64에서 이미 잡은 종이 절반 가까이 나왔다. 리전당 4종 | 묶음 | 비트 | 무엇이 비어 있었나 | |---|---|---| -| 간부 대결 승리 | `duel_grip_win` · `duel_scale_win` · `duel_chief_win` | `talk_grip/scale/chief`가 "준비되면 오라"고 도전을 부추기는데 이겨도 **아무 말이 없었다.** 트리거는 **`DuelWin <간부 storyNpcId>`**다 — 소스는 `NpcDuelController.BossDuelWon`. 처음엔 `BattleWin <보스 종>`으로 걸었는데, 간부 종 셋이 전부 자기 리전 야생 풀에 있어 **같은 종의 야생 레이드를 이겨도** 관장 패배 대사와 Lv.62 보상이 나왔다(교차 리뷰에서 잡힘). 하월은 `moth_effaced`를 "이름을 돌려받은 나방"으로 준다(첫 장의 종) | +| 간부 대결 승리 | `duel_grip_win` · `duel_scale_win` · `duel_chief_win` | `talk_grip/scale/chief`(2026-10-04에 지웠다 — 대치 직후 곧바로 대결, 16장)가 "준비되면 오라"고 도전을 부추기는데 이겨도 **아무 말이 없었다.** 트리거는 **`DuelWin <간부 storyNpcId>`**다 — 소스는 `NpcDuelController.BossDuelWon`. 처음엔 `BattleWin <보스 종>`으로 걸었는데, 간부 종 셋이 전부 자기 리전 야생 풀에 있어 **같은 종의 야생 레이드를 이겨도** 관장 패배 대사와 Lv.62 보상이 나왔다(교차 리뷰에서 잡힘). 하월은 `moth_effaced`를 "이름을 돌려받은 나방"으로 준다(첫 장의 종) | | 서브에리어 10곳 | `ch1_pond_edge`·`ch2_deep`·`ch3_deep`·`ch4_cave`·`ch5_cave` / `ch7_burrow`·`ch8_pit`·`ch9_ridge`·`ch10_vent`·`ch11_bough` | 26곳 중 10곳이 스토리 0건 — **2막 둘째 서브에리어는 전부 빈 스폰 창고**였다. 2막 종은 표시명이 영문이라 대사는 종명 대신 특징(찢어진 거미줄·개미귀신 구덩이·재 속의 매미)으로 부른다. `ch10_vent`는 라온 부재 구간이라 먹이 말한다 | | 1막 도감 | `dex_20`(어르신) · `dex_40`(라온) | "기록이 곧 봉인"이 1막 주제인데 도감 수를 세는 비트가 `ch7_arrive` 뒤(`dex_60`)에야 있었다 | | 엔딩 뒤 | `dex_160`(세라) · `post_rival_rematch`(라온 → `s_npc_duel`) | `fin_epilogue`가 "계속 만나요"로 문을 열고 그 뒤가 비었다. 도감 임계는 **풀에 실재하는 종수 172**(`game_facts.region_pools`)에서 잡았다 — 180은 영영 못 닿는다 | ### 알려진 불일치 — 고치지 않는 것 -- 어르신이 `ch6_reach_ruins`·`ch6_approach`·`ch6_relic`에서 유적 화자인데 앵커는 초원뿐이고(몸 없는 목소리), - `ch11_echo`는 "초원만 지켰구나"라 한다. 셋 다 열람됐을 수 있는 비트라 **대사를 고치지 않는다**(8장). - 읽는 법: ch6의 어르신은 "여기까지 왔구나"를 멀리서 전하는 목소리이고, ch11의 "네가 없는 동안"은 2막을 가리킨다. +- ~~어르신이 `ch6_reach_ruins`·`ch6_approach`·`ch6_relic`에서 유적 화자인데 앵커는 초원뿐이다(몸 없는 목소리).~~ + **2026-09-28 해소** — 출시 전 예외(8장)로 셋을 세라·라온의 대사로 옮기고, 어르신의 당부는 `ch6_reach_ruins`의 + 지문("초원을 떠나던 날, 어르신이 했던 말")으로 되새겼다. 같은 이유로 `ch2_emperor`(3장 전에 세라가 말하던 자리)는 + 라온이 어르신에게 들은 이야기로 옮겼다. 10장 참조. - 2막 6리전에 튜토리얼 퀘스트가 0건이다(`q_complete`에서 끝, 이후 `q_blight_*`뿐). 스토리 HUD가 안내를 맡고 있어 진행은 안 막히지만 퀘스트 칩·보상 경로는 비어 있다 — **별건**(`/add-quest`). + 2026-09-28에 이름 없는 자리를 뺀 다섯 리전에 **지역 의뢰**(서브, `s_town_*`)가 생겨 보상 경로는 채워졌다(13장). + 스토리 퀘스트 체인은 여전히 `q_complete`에서 끝난다. - `ch11_echo`(어르신 여운)는 Lv.58 구간에 초원 복귀를 요구한다. leaf이고 챕터 순위상 ch12 스파인이 늘 앞서므로 HUD가 초원을 가리키는 건 `fin_seal` 뒤뿐이다 — 실측 후 그대로 둔다. + +## 10. 대사 보강과 무대 연출 — 2026-09-28 + +사용자 피드백: "극적인 효과가 부족하고, 캐릭터 간 대화가 부족하고, 스토리가 너무 얇다." +실측이 그대로였다 — 144비트 평균 **2.9줄**, 2줄짜리가 62개, 두 사람 이상이 말하는 장면이 **23개(16%)**. +주고받는 장면도 대사창이 초상 하나를 왼쪽에 붙여 두고 얼굴만 갈아 끼워, **주고받아도 주고받는 것처럼 보이지 않았다.** + +### 무대 — 대사창이 이제 장면을 그린다 + +`NpcDialogueUI`의 스토리 모드는 딤 위에 대사 상자를 아래에 두고, 인물이 그 위에 **좌우로 서서 마주 본다**. +규칙은 `StoryDialogueStaging`(순수 계산, `StoryDialogueStagingTests`)이 정한다. + +| 규칙 | 내용 | +|---|---| +| 좌우 배치 | 먼저 말한 사람 왼쪽, 다음 사람 오른쪽. 셋째가 끼면 **더 오래 말하지 않은 쪽**과 자리를 바꾼다 | +| 말하는 쪽 | 밝고 크게, 화자가 바뀌면 한 번 뛰어오른다. 듣는 쪽은 어둡게 한 발 물러선다 | +| 이름표 | 말하는 사람 쪽 상자 윗변. `NpcTalk`로 연 장면의 첫 줄은 ✦ | +| 타자 | 글자당 약 1/38초. [다음]·탭·Space는 덜 나왔으면 마저 보여주고, 다 나왔으면 넘긴다 | +| 선택지 | 마지막 줄이 **다 나온 뒤에만** 뜬다. 선택지가 있는 장면엔 [건너뛰기]가 없다 | + +타자 효과는 남은 글자를 **투명 리치 텍스트로 자리만 잡아 두고** 그린다(`UIHelper.LabelFitReveal`) — +잘라서 그리면 글자가 늘 때마다 단어가 윗줄에서 아랫줄로 튀고, 폰트 축소가 다 나오는 순간 갑자기 걸린다. + +### 저작 — `speaker`와 `fx` + +- **`"speaker": "지문"`은 해설이다.** 초상·이름표 없이 가운데 기울임으로 뜬다. 3인칭 현재형, 한 문장, 소리·빛·몸짓만. + 플레이어는 여전히 말하지 않는다 — 지문도 "당신"의 속마음을 적지 않고 눈에 보이는 것만 적는다. +- **`fx`는 쉼표로 여럿.** `story_lint` 검사 28이 모르는 토큰을 FAIL로 잡는다(해석기는 모르는 토큰을 조용히 버린다). + +| fx | 연출 | 쓰는 자리 | +|---|---|---| +| `pause` | 0.7초 사이 뒤에 글자가 나온다 | 망설임·무게·농담의 박자 | +| `shout` | 크게·굵게·빠르게, 짧게 흔들림 | 외침·흥분 | +| `whisper` | 작게·흐리게·기울임·느리게 | 속삭임·혼잣말·비밀 | +| `slow` | 반 속도 | 곱씹는 줄 | +| `shake` | 화면이 흔들린다 | 충격·등장·붕괴 | +| `flash` | 흰 번쩍임 | 깨달음·빛·수문장이 물러나는 순간 | +| `dark` | 딤이 짙어진다 | 명부회의 말·불길함 | + +**아껴 쓴다** — 2막까지 761줄 중 연출 233줄이고 그중 가장 많은 것이 가벼운 `pause`다. 한 장면에 `shake`·`flash`는 한 번이면 충분하다. `slow`는 장면의 무게가 걸린 한 줄에만 — 2막 초안에 32줄을 달았더니 장면마다 글자가 늘어져 11줄로 줄였다. +`speaker`는 `NpcDialogueDatabase.StorySpeakerName`의 표시명·"하월"·"지문"만 된다(검사 27) — 오타면 조용히 +초상이 사라지고, 무대 배치가 화자로 정해지므로 장면 전체의 자리가 흔들린다. + +### 1막 보강 내역 + +기존 장면은 **사실을 바꾸지 않고** 주고받음·지문·연출을 더했다(8장 출시 전 예외). 419 → 554줄(2막 전 기준), +여러 인물이 말하는 장면 23 → 43. 연속성 교정 둘: + +| 비트 | 무엇이 틀렸나 | 어떻게 | +|---|---|---| +| `ch2_emperor` | 세라는 `ch3_reach_forest`에서 처음 만나는데 연못(2장)에서 말했다 | 라온이 "어르신이 그러셨어"로 전하는 이야기로 | +| `ch6_reach_ruins`·`ch6_approach`·`ch6_relic` | 초원을 떠나지 않는 어르신이 유적에서 말했다(몸 없는 목소리) | 세라·라온의 대사로. 어르신의 당부는 지문으로 되새김 | + +새 장면 셋 — **동료의 밤**. 1막엔 동행자가 **자기 얘기**를 하는 자리가 하나도 없었다. + +| beatId | trigger | 게이트 | 내용 | +|---|---|---|---| +| `camp_pond` | `LevelReach 9` | prereq `ch1_rival_intro` · req `ch1_reach_pond` | 라온 — 풀벌레 소리로 종을 맞히던 할머니, "올해는 들을 게 없구나". 경쟁심의 정체 | +| `camp_swamp` | `LevelReach 23` | prereq `camp_pond` · req `ch4_reach_swamp` | 세라·라온 — 스승(하월)의 벽 탁본, "느린 건 죄라고"(9장 여운의 씨앗) | +| `camp_ruins` | `LevelReach 38` | prereq `camp_swamp` · req `ch6_reach_ruins` | 신전 전날 밤 — 끝나면 무엇을 할지. 파트너까지 "넷" | + +전부 `side`(순위 2000)·leaf라 본편 목표를 가리지 않고, 앞 장면을 prereq로 물어 한 번에 하나만 열린다. +레벨은 각 리전 요구 레벨 바로 위다(연못 6·습지 20·유적 36). 이미 그 레벨을 넘긴 세이브는 게이트가 열린 뒤 +**다음 레벨업**(또는 재시작 시 일괄 평가)에 뜬다. + +### 2막 보강 내역 + +2막 본편 61비트 185 → 356줄(평균 3.0 → 5.8), 여러 인물이 말하는 장면 9 → 34. 전체는 150비트 761줄이다. + +**순서가 뒤집히는 자리를 먼저 막았다.** 2막의 leaf(징후·격전·여운·수문장)는 대치(스파인)보다 먼저 뜰 수 있는데, +대치에서 처음 드러나는 사실을 말하는 leaf가 여럿이었다. 대사를 늘리기 전에 전수로 추렸다. + +| 비트 | 무엇이 먼저 나올 수 있었나 | 어떻게 | +|---|---|---| +| `ch10_clash` | 라온이 다치기 전에 "라온은 괜찮을 거예요" | prereq `ch10_arrive` → `ch10_confront` (`ch9_clash`가 이미 이 형태다) | +| `ch11_clash` | 우듬지 꼭대기의 계시 전에 "고대인이 남긴 두 칸" | prereq → `ch11_confront` | +| `ch8_echo` | 집게가 목적을 밝히기 전에 "쟤들 목적" | 게이트 → `ch8_confront` | +| `ch10_echo` | 먹이 이탈하기 전에 "장부는 두고 왔다" | 게이트 → `ch10_confront` | +| `gd_dunes`·`gd_frostline`·`gd_emberfall` | 수문장은 스토리와 무관하게 늘 서 있다(`CreateGuardians`). 집게·저울을 만나기 전, 라온이 다치기 전에 이길 수 있다 | 일생 1회 트리거라 게이트를 옮기면 놓친 세이브에선 영영 안 뜬다 — **문구를 바꿨다.** 대치 전제를 뺐고 `gd_emberfall`은 먹 대신 세라가 받는다(먹을 만나기 전일 수 있다) | +| `ch7_confront` | `ch7_sign`보다 먼저 들어가면 "라온이 봤다던 그림자" | 라온이 직접 말한다 | +| `ch8_pit` | 창고(「명부회」가 처음 불리는 곳)보다 먼저 들어가면 "명부회 상자" | "저 상자들" | +| `ch8_clash` | 창고 전이면 "저 장부"가 가리킬 것이 없다 | "상자 더미 옆에 떨어져 있던 장부" | +| `talk_scale` | 선택지에서 "…대답 못 하겠어요"를 골라도 "세라는 대답을 찾았더군" | 대답 여부와 무관한 말로(2026-10-04에 비트째 지웠다 — 도전은 선택 결과 대사가 맡는다, 16장) | +| `dex_100` | 100종은 8장 전에도 닿는데 12장의 사실("관장님 장부가 삼천") | 누구의 장부인지 말하지 않는다 | + +게이트 넷은 전부 재발화 트리거(`SubAreaEnter`)를 가리킨다(검사 16). 원래 사실(집게가 창고를 떠났다·저울이 서고에 남는다· +먹이 골짜기에 남는다)은 지워지지 않았다 — `duel_grip_win`·`duel_scale_win`·`camp_ember`·`ch11_echo_ink`가 같은 사실을 든다. + +**라온의 부재는 그대로 둔다.** `ch10_confront` 뒤로 라온이 말하는 곳은 잿불 골짜기 전초기지(앵커가 실제로 있는 곳)의 +`camp_ember`와 복귀 `ch12_echo`뿐이다. **`fin_*`에는 라온을 세우지 않았다** — `ch12_echo`는 말을 걸어야 뜨는 선택 대화라 +안 거친 플레이어가 있다. 게이트로 강제하면 여운 체인(`ch9_echo_rival` ← … ← `talk_rival`) 일곱 칸을 되짚어야 한다. + +**그것의 목소리는 지문으로 적는다.** `fin_unnamed`에서 "이름을"이라는 물음이 지문으로 두 번 뜨고, 선택지는 그 뒤에 온다. +화자로 세우면 이름표가 필요해진다 — 이름을 붙이지 않는다는 규칙은 이름표에도 선다. + +**세라의 과거가 한 줄기로 이어진다.** 8장 집게 "먼저 나간 쪽이라고" → 9장 라온 "너 명부회였어?" / "…네" → +`camp_frost`(왜 나왔나 — 목록을 다시 세어 보니 절반이 없었다). 12장 하월의 "절반이 창고에서 죽었다"와 같은 숫자로 맞물린다. +4장 표의 "우리가 함께 틀릴 수도 있다"가 `camp_frost`의 "저도 틀릴 수 있어요" / "그럼 같이 틀리자"다. + +새 장면 셋 — **2막 동료의 밤**: + +| beatId | trigger | 게이트 | 내용 | +|---|---|---|---| +| `camp_frost` | `LevelReach 52` · 리전 `frostline` | prereq `ch9_confront` | 세라가 명부회를 나온 이유. 라온의 "그럼 같이 틀리자" | +| `camp_ember` | `LevelReach 56` · 리전 `emberfall` | prereq `ch10_confront` | 전초기지의 라온 — "내 몫까지 새겨 와"(`ch1_first_battle`의 "내 몫까지!"), 먹이 라온을 맡는다 | +| `camp_canopy` | `LevelReach 60` · 리전 `canopy` | prereq `ch11_confront` | 라온 없는 밤 — 수첩 낙서 "세라 느림. 그래도 정확함.", "이번엔 도망치지 않을 거예요"(저울의 "겁먹고 도망친 거다") | + +1막 셋과 달리 **서로 체인으로 묶지 않고 리전으로 잠갔다.** 라온이 다치는 장면을 사이에 두고 있어 어디서 뜨느냐가 +중요하다 — 리전 잠금(`RegionGateSatisfied`는 트리거 종류와 무관하게 걸린다)이 그 땅에 있을 때만 열어 주고, 셋이 서로 다른 +리전이라 동시에 자격을 갖는 일도 없다. 체인이었다면 서릿길에서 레벨업을 한 번도 못 한 세이브는 뒤 두 장면까지 잃는다. +레벨은 1막처럼 각 리전 요구 레벨 바로 위다(서릿길 50·잿불 54·우듬지 58). + +## 11. 전투 연출 — 대결 컷인·전투 중 한마디·라온 라이벌 — 2026-09-28 + +사용자 피드백의 "전투씬이 부족하다"를 실측하면 이랬다: 관장과의 마지막 대결도, 초원의 수호자도, 필드의 인분무와 +똑같이 "○○와 마주쳤다" 한 줄(2초)로 시작하고 **전투가 끝날 때까지 상대가 한마디도 안 했다.** 대결 전후의 말은 +스토리 비트(대치 `chN_confront` — 2026-10-04 전엔 `talk_*` — ·`duel_*_win`)가 들고 있었지만 전투 한가운데는 비어 있었다. 그리고 1장 "승부다!"부터 엔딩 뒤 +"이번엔 진짜 겨루자"(`post_rival_rematch`)까지, **라온과 실제로 붙는 길이 하나도 없었다.** + +### 무엇이 어디에 있나 + +| 조각 | 파일 | 하는 일 | +|---|---|---| +| 대사·순간 판정 | `NPC/DuelBanter.cs` | 상대별 칭호·도발·전투 중 한마디 셋·결과 한마디 둘. HP 선을 넘는 순간을 고른다(순수 계산) | +| 그리기 | `UI/BattleScreenUI.Duel.cs` | 컷인·말풍선·결과 한마디. 모놀리스 본체는 네 군데서 부르기만 한다 | +| 상대 전달 | `InsectBattleController.SetDuelOpponent` | `StartDuel` 직후에 건다 — `ArmLedger`와 같은 이유(아이 대결이 같은 시작 함수를 쓴다). 새 전투마다 비운다 | +| 라온 단계 | `NPC/NpcRivalDuels.cs` + `NpcDuelController.TryStartRivalDuel` | 단계 표와 선택, 진입·종료 처리 | + +### 컷인 + +- **대결**: 띠를 반으로 갈라 왼쪽은 나(의상 그대로의 캐릭터), 오른쪽은 상대(대사창과 같은 초상 — `NpcDialogueUI.TryDrawStoryPortrait`). + 양쪽이 밀려 들어와 VS에서 부딪히고, 칭호와 첫마디가 뜬다. 인트로가 2초 → 3.2초(전투 배속을 따른다). +- **수문장**: 사람이 없다 — 띠에 글자만 선다. 칭호는 "○○의 수문장", 이름은 `RegionData.guardianDisplayName`. + 처음엔 2D 곤충 스프라이트를 세웠는데 반투명 네모 배경이 그대로 보여 조잡했고, 곤충은 뒤 아레나에 이미 서 있다. +- **1막 하수의 칭호는 조직명을 쓰지 않는다**("숲 그물터 당번"·"산길을 막는 자"·"이름을 묻지 않는 자") — 정체가 밝혀지기 전이다. + `DuelBanterTests`가 막는다. + +### 전투 중 한마디 + +| 순간 | 선 | 비고 | +|---|---|---| +| 흔들림 | 상대 HP ≤ 50% | 한 방에 20% 아래까지 가면 건너뛴다 | +| 위기 | 상대 HP ≤ 20% | 0(쓰러짐)은 말하지 않는다 — 결과 한마디가 맡는다 | +| 몰아붙임 | 내 HP ≤ 25% | 상대가 먼저 — 같은 교환에서 둘 다 넘으면 다음 차례에 | + +각 순간은 한 전투에 한 번, 보이는 HP 막대가 선을 넘을 때 뜬다(맞는 순간과 맞춘다). 말풍선은 **실제 시간 2.8초**다 — +읽는 시간이라 전투 배속을 따르지 않는다. 떠 있는 동안 다음 순간은 미룬다. + +결과 화면 위에는 이기면 상대의 패배 대사, 지면 승리 대사가 얹힌다(도주는 말이 없다). **간부전은 그 뒤에 `duel_*_win` +스토리 비트가 바로 이어지므로** 패배 대사를 짧게 끊고 같은 말을 반복하지 않는다(집게: 결과 "크윽… 지네가 먼저 물러서다니." +→ 비트 "…손으로 증명했군."). + +### 라온 라이벌 — 장마다 한 번 + +라온은 전부 **여치**를 부린다 — 풀벌레 소리만 듣고 "저건 여치" 하던 할머니(`camp_pond`)의 손자다. + +| 단계 | 리전 | 열림 | 닫힘 | 상대 | 첫 승리 보상 | +|---|---|---|---|---|---| +| `rival_meadow` | 초원 | `ch1_rival_intro` | `ch10_confront` | `katydid_leaf` Lv.4 | `net_basic` ×3 | +| `rival_pond` | 연못 | `ch2_water` | `ch10_confront` | `katydid_leaf` Lv.14 | `net_silver` ×2 | +| `rival_swamp` | 습지 | `ch4_reach_swamp` | `ch10_confront` | `katydid_leaf` Lv.30 | `wound_salve_great` ×3 | +| `rival_hollow` | 텅 빈 들 | `ch7_arrive` | `ch10_confront` | `katydid_leaf` Lv.47 | `wound_salve_great` ×4 | +| `rival_dunes` | 모래언덕 | `ch8_arrive` | `ch10_confront` | `katydid_leaf` Lv.51 | `net_gold` ×1 | +| `rival_frost` | 서릿길 | `ch9_arrive` | `ch10_confront` | `katydid_snowfield` Lv.55 | `full_restore` ×1 | +| `rival_ember` | 잿불 골짜기 | `ch10_arrive` | `ch10_confront` | `katydid_snowfield` Lv.58 | `full_restore` ×2 | +| `rival_final` | 어디서든 | `post_rival_rematch` | — | `katydid_canopy` Lv.76 | `full_restore` ×3 | + +2026-10-04에 넷 → 여덟로 늘렸다(16장 「라온 — 장마다 한 번」). 라온이 서 있는 여덟 리전 중 이름 없는 자리만 단계가 없다 — 복귀(`ch12_echo`) 뒤라 엔딩 재대결이 맡는다. + +- **여는 곳은 평소 대화창의 [승부하기] 버튼이다**(초원 첫 대결만 첫 만남 대사 직후 곧바로 — 16장). 라온에게 말을 걸어 스토리 비트가 안 뜨면 잡담이 뜨고, 걸 수 있는 단계가 + 있으면 버튼이 붙는다. 누르면 대화를 닫고 **다음 Update에서** 대결을 건다(OnGUI 안에서 시작하면 모달이 닫히는 프레임과 + 전투 화면이 열리는 프레임이 엉킨다). +- **다치기 전 단계는 전부 갱도(`ch10_confront`, `NpcRivalDuels.InjuryBeatId`)에서 닫힌다.** 월드의 라온 앵커는 부상 뒤에도 서 있어서, 닫지 않으면 뼈가 상한 라온과 싸운다. + 2026-10-04 전엔 서릿길만 닫혀서 엔딩 뒤에도 연못·습지 단계가 열려 있었다. +- **단계는 서로 독립이다** — 앞 단계를 건너뛰어도 뒤 단계가 막히지 않는다. 한 곳에서 둘 이상 열려 있으면 표 순서대로 먼저 것. +- 레벨은 각 리전 구간(요구~수문장) 안이고, 재대결은 관장(72)보다 위다. `NpcRivalDuelTests`가 앵커·구간·비트 실재·상승을 고정한다. +- **격파 기록은 간부 격파 집합(`DefeatedLedgerBosses`)에 단계 ID로 적는다.** 그 집합이 이미 클라우드에 오르므로 세이브 + 구조를 늘리지 않았다. 단계 ID는 `rival_`로 시작해 인물 ID와 안 겹치고, 스토리 쪽 조회(`HasDefeatedStoryNpc`)는 인물 ID로만 묻는다. +- 이기면 '동네 최강자' 서브 퀘스트(`s_npc_duel`)에 센다 — `post_rival_rematch`가 바로 그 퀘스트를 연다. +- 보상 아이템 실재성은 `quest_lint`의 대결 보상 검사가 간부와 함께 본다. +- `NpcDuelController`가 `StoryDirector`를 직접 참조하지 않는다 — 반대 방향 참조가 이미 있어 순환이 된다. 부트스트랩이 + `StoryDirector.HasSeen`을 함수로 넘긴다(`AutoWireStoryGate`). + +### 검증과 빈자리 + +IMGUI라 테스트로는 못 보고 QA 캡처 빌드로 본다 — `-battleScenario boss`(집게: 컷인·장부·한마디·결과) · +`guardian`(초원의 수호자) · `rival`(라온 재대결). 레이드로 치르는 수문장(Epic·Legendary)과 아이 대결에는 컷인이 없다 — +레이드는 `RaidBattleUI`가 따로 그리고, 아이는 이름 없는 주민이라 할 말을 정해 두지 않았다. + +--- + +## 12. 수문장 배지 — 2026-09-28 + +리전마다 수문장이 하나씩, 이기면 **배지**를 하나 얻는다. 13개다. 이야기 안의 뜻은 "수문장이 길을 내주며 남긴 표식"이고, +배지마다 그 수문장과의 일을 떠올리게 하는 새김글이 한 줄 있다(`gd_*` 대사와 결이 같다 — "진 게 아니라 물러난 뿔의 무게"). + +| 순서 | 리전 | 배지 | 금속 | +|---|---|---|---| +| 1~7 | 초원·연못·숲·꽃밭·습지·산·유적 | 풀잎·물결·뿔·꽃잎·안개·능선·태양 | 금 | +| 8~12 | 텅 빈 들·모래언덕·서릿길·잿불 골짜기·우듬지 | 침묵·모래·서리·잿불·우듬지 | 은 | +| 13 | 이름 없는 자리 | **빈칸** — 점선 네모, 안이 비어 있다 | 검은 쇠 | + +표시 순서는 리전 요구 레벨 순서다(꽃밭이 숲과 습지 사이). 「무명」은 여기서도 이름으로 부르지 않는다 — +마지막 배지는 "빈칸"이고 새김글은 "칸은 있는데, 안이 비어 있다."다. + +### 어디에 있나 + +| 파일 | 맡는 것 | +|---|---| +| `Core/GuardianBadges.cs` | 배지 표(리전·이름·새김글)·이정표 표·순수 계산(개수·받을 이정표·CSV) | +| `Core/GuardianBadgeService.cs` | 새 배지 알림, 이정표 보상 지급·수령 기록(계정 스코프 PlayerPrefs + 클라우드) | +| `UI/BadgeCeremonyUI.cs` | 획득 연출(모달) | +| `UI/BadgeCaseUI.cs` | 배지 케이스 — 퀵메뉴 [배지](K) | +| `UI/BadgeArt.cs` | 배지 PNG 캐시·잠김 틴트·배율 안전 회전 | +| `Tools/Badges/guardian_badges.py` | 배지 그림 13장 + 연출용 빛살·번짐·반짝이(금속 양각·법랑을 4배 슈퍼샘플로) | + +### 저장 — 배지는 적지 않는다 + +**배지 = 수문장 격파 기록**(`DefeatedGuardians`)이다. 따로 적으면 두 기록이 어긋나는 순간(클라우드 병합·계정 전환) +"이겼는데 배지가 없다"가 생긴다. 그 집합은 이미 클라우드에 오르므로 배지도 기기를 따라간다. + +적는 건 **이정표 보상 수령 상태** 하나다(`BadgeMilestonesClaimed`, "4,8") — 파생이 안 된다. 클라우드 DTO +`badgeMilestonesClaimed`로 오르고, 부트 로드는 **합집합**이다(받은 걸 도로 안 받는다 — 낡은 클라우드가 기록을 지우면 +같은 보상을 다시 받는다). 계정 스코프 목록(`SaveScope`)에도 올려 계정 삭제 뒤 같은 uid로 돌아와도 부활하지 않는다. + +### 순서 — 배지가 대사보다 먼저 + +수문장전이 끝나면 **결과 화면 → 배지 연출 → `gd_*` 대사** 순서다. 장치는 하나다: `StoryDirector.NotifyBattlePresentationClosed`가 +미뤄 둔 대사를 흘리기 **직전에** `BattlePresentationClosed`를 울리고, 연출이 거기서 모달을 먼저 연다. 드레인은 모달 가드에 +막혔다가 연출이 닫히면 `Update`가 이어 흘린다. 폴링만 하면 드레인이 같은 호출 안에서 대사를 먼저 열어 배지가 대사 뒤로 밀린다. + +**결과 화면은 눌러야 닫힌다(2026-10-04) — 얼마나 오래 보든 미뤄 둔 이야기는 그 뒤에 나온다.** 대사 큐·컷신·영상의 "굳은 전투 화면" +상한(12초)은 결과 화면과 모달이 떠 있는 시간을 세지 않는다(`StoryBattleWait`, 결과 화면 여부는 부트스트랩이 `Func`로 넘긴다). +예전 규칙대로 셌다면 보상을 12초 넘게 본 사람의 미뤄 둔 컷신·영상이 조용히 사라졌다. 결과 화면이 **아닌데** 전투 카메라가 안 풀리면 +(진짜로 멈춘 화면) 그대로 센다 — 컷신·영상은 버리고, 카메라가 배선되지 않은 대사 큐는 그냥 쏜다. 카메라가 배선된 대사 큐는 전투 +화면 위로 대사를 쏘지 않는다(상한 없이 기다린다). + +새 배지 신호는 `RegionManager.GuardianBadgeEarned`다 — `GuardianDefeated`가 아니다. 후자는 이미 깬 수문장이 필드에 남았을 때 +봉인을 걷으려고 `TryDefeatGuardian`이 **다시** 울려서, 그걸 들으면 같은 배지가 두 번 뜬다. + +### 이정표 보상 — 한 번만 + +| 배지 | 칭호 | 보상 | +|---|---|---| +| 4 | 길잡이 | 황금 채집망 ×3 · 기술 디스크: 광란 공격 ×1 | +| 8 | 먼 길의 여행자 | 기술 디스크: 메가 스트라이크 ×1 · 수호의 토템 ×1 | +| 13 | 모든 길을 연 자 | 기술 디스크: 파멸의 독침 ×1 · 황금 향로 ×1 | + +배지를 얻는 순간 도달한 이정표를 연출과 함께 준다(앞서 못 받은 것까지). 이 기능 전에 배지를 모아 둔 세이브는 새 배지를 +더 얻지 않을 수 있어서, 케이스의 [받기]가 수령 경로로 남아 있고 퀵메뉴 [배지]에 "!" 점이 찍힌다. + +### 검증과 빈자리 + +- `GuardianBadgeTests` — 수문장 리전과 배지가 1:1, 레벨 순서, 이름·새김글 길이와 「무명」 금칙, **그림 파일 실재**, + 이정표 오름차순·보상 아이템 실재, 첫 격파에서만 알리고 이정표는 한 번만 주는지, 클라우드 필드·계정 스코프 등록. +- `StoryBeatWalkthrough -walkMode campaign` — 수문장 6곳 모두 "배지 연출 → `gd_*` 발화" 순서(로그의 `[WALK] 배지 연출`). +- QA 캡처 `-battleScenario badge` — 연출 타임라인·케이스(가로·세로). 연출 시간은 프레임당 0.05초로 자른다 — 전투 화면을 걷는 + 프레임의 멈칫이 한꺼번에 들어오면 배지가 내려앉는 장면을 통째로 건너뛰었다(캡처에서 실제로 났다). +- 기기에서만 볼 수 있는 것: 효과음 타이밍, 터치로 건너뛰기/닫기. + +## 13. 마을 이야기 — 본편 옆을 나란히 가는 짧은 이야기 — 2026-09-28 + +사용자 요청: "마을마다 짧은 스토리, 게임에 도움이 되는 서브 퀘스트, 메인 스토리에 도움을 주는 것." 그리고 +"퀘스트를 가진 NPC 위치를 지도에 띄우고 따라가기." + +실측이 그대로였다 — 150비트 중 마을 사람의 이야기는 **0건**(화자는 세라 76 · 라온 33 · 어르신 12와 명부회뿐), +전초기지 주민 12명은 이름이 무작위이고 날씨 얘기만 했다. 서브 퀘스트 10개는 전부 "아무 데서나 N마리" 반복 파밍이라 +**특정 지역을 겨냥한 의뢰를 표현할 수단 자체가 없었다.** + +### 한 편의 모양 — 스토리 4비트 + 지역 의뢰 1개 + +| # | 비트 | trigger | 게이트 | 역할 | +|---|---|---|---|---| +| 1 | `town_{r}_meet` | `NpcTalk town_{r}` | `requiredBeatId` = 그 리전 도착 비트 | 부탁. 소개 비트이기도 하다 | +| 2 | `town_{r}_sign` | `CaptureInsect <그 리전의 흔한 종>` + req `{r}` | prereq = meet | 곁들임(동행자 한마디·지문). 따라가지 않는다 | +| 3 | `town_{r}_close` | `NpcTalk town_{r}` | prereq = meet · **`requiredQuestId: s_town_{r}`** | 매듭. 다음 수문장 대비 아이템 | +| 4 | `town_{r}_after` | `NpcTalk town_{r}` | prereq = close · `requiredBeatId` = **본편의 뒷 장면** | 후일담. 본편의 반전을 이 사람의 눈으로 | + +의뢰 `s_town_{r}`은 `TutorialQuest`의 **서브·1회·지역 한정**이다(새 필드 `requiredRegionId`). 그 리전이 열려야 목록에 +뜨고, 그 리전 안(서브에리어 포함)에서 한 포획·전투만 센다. 진행 표시는 퀘스트 목록이, 서사는 비트가 맡는다. + +**스토리가 퀘스트를 만들지 않는다**(`StorySystemDesign.md` 결정 5 — 단방향 관찰). 의뢰는 리전 해금으로 스스로 열리고, +매듭 비트가 `requiredQuestId`로 그 완료를 **관찰**할 뿐이다. 그래서 주민을 만나기 전에 의뢰를 끝낸 사람도 막히지 않는다 — +말을 걸면 만남, 한 번 더 걸면 매듭이 순서대로 나온다. + +**의뢰는 반복형이면 안 된다.** 반복 서브는 `completedQuests`에 들어가지 않고 목표만 올려 재시작하므로(`CompleteSideQuest`), +매듭의 퀘스트 게이트가 영영 안 열린다 — `story_lint` 검사 29가 잡는다. + +### 1막 일곱 마을 + +| 마을 | 주민 (`storyNpcId`) | 부탁 → 의뢰 | 매듭 보상(본편 대비) | 후일담 게이트 → 닿는 본편 | +|---|---|---|---|---| +| 초원 본 마을 | 꼬마 화가 달래 (`town_meadow`) | 그림책의 빈 페이지 → 초원 포획 6 | 은빛 채집망 ×3 | `ch7_arrive` → 「빈칸」 — 다 그린 페이지 사이에 하얀 데가 또 생긴다 | +| 연못 | 물결 할머니 (`town_pond`) | 영감이 기둥에 새기던 소금쟁이 수 → 연못 포획 5 | 수호의 토템 (연못 수문장) | `ch8_confront` → 명부회 — 셈이 같다고 마음까지 같은 건 아니다 | +| 숲 | 나무꾼 솔 (`town_forest`) | 그물에서 풀려나 성난 곤충 → 숲 전투 3 | 포박의 그물 (가두지 말고 얼굴만 보고 놓아주라며) | `bl_forest_restore` → 그물터의 청년 — "치라니까 쳤으면 이제 걷을 차례" | +| 습지 | 약초꾼 이끼 (`town_swamp`) | 안개 속 약초 바구니 → 습지 포획 5 | 마비 치료약 ×3 (산 대비) | `ch7_confront` → 「이름 잃은 곤충」 — 이름을 잊으면 약초와 독초가 같아 보인다 | +| 산 | 봉수지기 너울 (`town_mountain`) | 사십 년 곤충 달력 → 산 전투 4 | 경험치 부스터 ×2 | `ch9_arrive` → 얼음 속 기록 — "번지는 쪽이 좋아. 내일 고쳐 쓰면 되니까" | +| 꽃밭 | 정원사 누리 (`town_garden`) | 가훈 "미로의 모양을 바꾸지 마라" → 꽃밭 포획 5 | 종합 치료제 ×2 | `ch11_confront` → **비상 울타리** — 모르고 지킨 집안 규칙의 이유 (+ 황금 향로) | +| 고대 유적 | 탁본꾼 결 (`town_ruins`) | 세라의 옛 조수, 탁본의 빈칸 → 유적 희귀 포획 3 | 맹수의 표식 (유적 수문장) | `ch7_opening` → 빈칸이 움직인 날 — 탁본 속 빈칸이 먼저 움직였다("그림자가 숨을 틈이 없게 도감을 채워 주세요") | + +### 2막 다섯 마을 + +| 마을 | 주민 (`storyNpcId`) | 부탁 → 의뢰 | 매듭 보상 | 후일담 게이트 → 닿는 본편 | +|---|---|---|---|---| +| 텅 빈 들 | 풍경지기 메아리 (`town_hollow`) | 소리 없는 들에 건 풍경 → 텅 빈 들 포획 5 | 종합 치료제 ×3 (소리 없이 덤비는 수문장) | `fin_seal` → 빈칸이 메워진 뒤 — "이젠 풍경 소리를 찾아야 해요, 애들 소리에 묻혀서" | +| 모래언덕 | 떠돌이 상인 모래 (`town_dunes`) | 몰래 열어 둔 명부회 상자 → 모래언덕 포획 5 | 맹수의 표식 | `ch12_confront` → 관장의 자백 — "3천 종의 셈 앞에서 몇 상자는 셈이 아니었다" | +| 서릿길 | 필사생 서리 (`town_frostline`) | 얼음 벽의 글을 베낀다, 번호를 붙이는 검은 옷(저울) → 서릿길 전투 4 | 기술 디스크: 메가 스트라이크 | `ch10_arrive` → 불탄 기록 — "한 곳에만 있는 기록은 그곳과 함께 끝난다. 두 벌씩 베낀다" | +| 잿불 골짜기 | 광부 숯 (`town_emberfall`) | 불탄 기록 대신 사람들의 기억을 받아 적는다 → 잿불 골짜기 포획 5 | 정령의 가호 | `ch12_echo` → 라온의 복귀 — "누가 누굴 구했는지는 적어 둬야 한다" | +| 우듬지 | 나무타기 잎새 (`town_canopy`) | 아무것도 사라진 적 없는 땅의 아이, 누에가 "없어졌다" → 우듬지 희귀 포획 3 | 기술 디스크: 이클립스 | `fin_seal` → 고치에서 나온 나방 — "이름은 제가 안 지을래요. 만나는 애들이 불러 주겠죠" | + +**이름 없는 자리에는 주민을 두지 않는다** — 결말 톤(잡기 아이를 두지 않는 것과 같은 이유). 2막 종은 표시명이 영문이라 +대사는 종명 대신 특징으로 부른다(9장 서브에리어 보강과 같은 규칙) — 숨죽인 귀뚜라미, 모래를 파는 벌, 등에 서리가 앉은 딱정벌레. + +**라온 이탈 구간(ch10 대치 뒤 ~ ch12 복귀 전)에 라온의 말을 두지 않는다**(4장). 징후는 포획 트리거라 만남 뒤 +**언제든** 뜬다 — 서릿길에서 만난 뒤 부상 구간에 돌아와 서리딱정벌레를 잡아도 뜬다. 그래서 징후엔 라온을 쓰지 않는다. +1막 초원·연못은 세라를 만나기 전(`ch3_reach_forest` 전)일 수 있어 **지문**, 습지부터는 **세라**가 맡는다(잿불 골짜기·꽃밭은 +지문). `TownTaleTests.TownSignBeats_NeverSpokenByRaon`이 고정한다. + +`fin_unnamed`의 규칙("그림자에게 이름을 붙이지 않는다")이 잎새의 후일담에서 한 번 더 울린다 — 이름을 주지 않는 것이 이기는 것이었듯, +아이는 새로 난 나방의 이름을 짓지 않는다. 그림자 자체는 언급하지 않는다. + +후일담이 본편의 **뒷** 장면을 게이트로 무는 것이 이 연작의 요점이다. 본편은 세계의 사건을 말하고, 마을 이야기는 같은 +사건이 그 땅에 사는 한 사람에게 어떻게 닿았는지를 말한다. 게이트 대상은 전부 재발화 트리거(`RegionEnter`·`SubAreaEnter`· +`RegionCleansed`·종 지정 `BattleWin`(`fin_seal`)·`NpcTalk`(`ch12_echo`))라 검사 16을 통과한다. 지난 마을로 돌아와야 +듣지만 leaf라 진행을 막지 않는다. + +`chapterId: town`은 본편 진행 축이 아니다 — `ChapterRank` 2000(곁이야기와 같은 자리)이라 **같은 급(스파인/leaf)끼리는** +본편이 먼저다. 다만 순위의 첫 기준은 스파인이라, 본편 스파인이 전부 끝난 `fin_seal` 뒤엔 leaf인 에필로그가 주민 만남(스파인)에 +밀렸다 — 그래서 **목표 도출에서만** 종장(fin) leaf를 스파인과 같은 급으로 친다(`CompareObjectivePriority`). 발화 순서까지 올리면 +같은 인물의 12장 leaf보다 종장 leaf가 먼저 뜬다(엔딩 뒤 하월의 화해 `ch12_clash`와 도발 `talk_chief`가 그 예였다 — 도발은 2026-10-04에 지웠다). 캠페인 도중엔 본편 스파인이 늘 자격을 가져 +주민이 HUD 목표를 가로채지 않고, +검사 17(챕터 도달 순서)의 대상도 아니다. 저널에는 「마을 이야기」 탭으로 따로 모인다. + +### 주민을 세우는 곳 — **일곱** 군데 + +6-1장·7-4장이 적은 여섯 곳에 하나가 더 있었다. 대사창 초상(`NpcDialogueUI.GetStoryPortrait`)이다. 빠지면 대사창에 +**얼굴 없이 이름표만** 뜬다(2막 간부가 한동안 그랬다). 검사기가 보지 않아 `TownTaleTests`가 잡는다. + +| # | 곳 | 검사 | +|---|---|---| +| 1 | `VillageBuilder.AddTownStoryNpc` (전초기지) / `AddVillageVillagers` 끝 (본 마을) | story_lint 3 | +| 2 | `NpcDialogueDatabase.StorySpeakerName` — **역할 + 이름**("나무꾼 솔"). 처음 보는 사람이라 이름만 두면 동행자와 헷갈린다 | story_lint 22·27 | +| 3 | `NpcVisualBuilder.StoryNpcFace` + `StorySignature` | story_lint 22 · `TownTaleTests` · `FieldThemeTests` | +| 4 | `NpcDialogueUI.GetStoryPortrait` — 월드 외형과 같은 상의·모자색 | `TownTaleTests` | +| 5 | `NpcDialogueDatabase.StoryNpcLines` | story_lint 20 | +| 6 | 만남 비트(`speakerNpcId`) | story_lint 19 | +| 7 | 지역 의뢰 `s_town_{r}` (1회·Side·`requiredRegionId`) | quest_lint 12 · story_lint 29 · `TownTaleTests` | + +**자리는 전초기지 모닥불 오른편(로컬 3.4, 0, 0.6)이다.** 1막 여섯 곳을 월드 좌표로 재서 서브에리어 진입 반경 밖으로 ++9.0m 이상, 같은 리전의 다른 스토리 NPC와 14.2m 이상 떨어지는 자리를 골랐다. 왼편(-2.2, 3.4)은 꽃밭 온실까지 +5.6m라 버렸다. +본 마을의 달래는 광장 안 110°/6m(가챠 오두막과 집3 사이)다. + +**2막은 그 자리를 못 쓴다.** 전초기지 대부분이 230°라 **라온 앵커(240°/0.28R)가 모닥불 바로 앞**(1막 자리에서 2.2m)에 서 있고, +모래언덕·우듬지는 가판대(상점·병원 ±4.6, 훈련소 0,6.4)가 좌우를 막으며, 모래언덕 천막은 기둥이 (±1.7, -0.2)에 있다. +그래서 오두막·기둥·모닥불·가판대·상호작용 지점(3.5m)을 피하고 모닥불 7.5m 안에서 **진입 반경 밖 8m 이상 · 다른 스토리 +NPC 12m 이상**을 만족하는 자리를 리전마다 격자로 골랐다(좌표와 여유는 `AddTownStoryNpc` 주석). + +### 따라가기 · `!`/`?` 표식 + +- **표식**: 마을 주민에게만 붙는다(동행자는 리전마다 서 있어 지도에 같은 사람의 표식이 열 개씩 뜬다 — 사용자 결정). + `!` = 말을 걸면 새 이야기, `?` = 의뢰를 끝냈으니 알리러 오라. 머리 위(3D, 몸이 보일 때만) · 지도 배지 · 미니맵 세 곳이 + `StoryObjectiveTracker.TaleMarkers` 하나를 읽는다. +- **따라가기**: 퀘스트 목록에서 의뢰를 펼쳐 [▶ 따라가기], 또는 지도에서 배지를 누른다. 목표 행이 **본편 대신** 그 이야기를 + 가리킨다(사용자 결정) — `[의뢰] 물결 할머니에게 말 걸기` → `[의뢰] 연못에서 곤충 포획 3/5` → `[의뢰] 물결 할머니에게 알리기`. + 미니맵 쐐기·지도 목표 마커·원터치 이동이 전부 따라온다. 목표 행의 ✕로 해제한다. +- **판정의 단일 출처**는 `StoryTaleResolver.ResolveStep`이다(Talk/Report/Errand/Waiting/Done). 발화 쪽과 **같은 게이트** + (prereq·`requiredBeatId`·`requiredQuestId`)를 건다 — 한쪽만 다르면 `!`를 따라가 말을 걸어도 아무 일도 없다. +- **자동 해제**: 다 들었거나(Done), 이번 세션에 할 일이 있던 이야기가 본편을 기다리는 단계로 넘어가면(Waiting) 이유를 한 줄 + 알리고 푼다. 세션 시작 직후의 Waiting은 **풀지 않는다** — 로그인·클라우드 적재가 트래커보다 늦어 열람 기록이 비어 보이는 + 순간이 있고, 거기서 풀면 저장해 둔 따라가기가 재시작마다 지워진다. +- 따라가는 이야기는 `PlayerPrefs`(`TrackedTale`, 계정 스코프) **로컬 전용**이다 — `QuestUnseen`과 같은 편의 상태. + +### 함께 고친 것 + +`StoryDirector.OnQuestCompleted`가 목표 캐시를 비우지 않았다. `requiredQuestId`가 걸린 비트(`ch1_intro`·매듭 비트)는 퀘스트가 +끝나는 순간 자격을 얻는데, 캐시는 `MarkSeen`·클라우드 재적재에서만 비워져서 다음 비트를 볼 때까지 HUD가 낡은 목표에 굳었다. + +### 검증 + +- `TownTaleTests` — 단계 판정(Talk/Errand/Report/Waiting/Done)·주민 목록·의뢰↔주민 매핑·진행 문구 + + **실제 저작**: 주민마다 만남(도착 게이트)·매듭(1회·Side·지역 의뢰)·징후 리전 = 의뢰 리전, 표시명·초상·잡담·외형. +- `StoryBeatWalkthrough -walkMode town` — 12마을, 156걸음(마을당 13, 첫 마을은 리전 밖 확인이 빠져 12). + 리전 밖 의뢰 행동이 **안 세어지는지**부터 후일담 뒤 Done·표식 소거까지 실제 진입점으로 걷는다. 매듭 뒤 걸음은 + 두 갈래가 **둘 다 실제로 난다** — 앞 마을 후일담을 채우며 본편 사슬이 같이 채워져, 습지(`ch7_confront`)· + 유적(`ch7_opening`)·모래언덕·서릿길·우듬지(텅 빈 들 후일담이 `fin_seal`까지 채운다)는 후일담이 이미 열린 채로 + "계속 따라감"을, 나머지는 "대기 → 자동 해제"를 탄다. 징후 비트가 2회에 뜨는 마을이 있는 건 정상이다 — + 같은 리전의 본편 포획 비트가 순위상 먼저 나간다(`rules/testing.md` 「한 걸음에 시도가 2회」). +- quest_lint 12 · story_lint 29 — 둘 다 결함을 심어 FAIL이 나는 것을 확인했다(검출력). + +### 쉬운 말로 다시 쓰기 — 2026-10-05 + +15장과 같은 규칙(한 줄 30자 안팎·최대 40자, 장면 7줄)으로 48비트를 다시 썼다. 183줄 · 평균 42.4자 · 40자 넘는 줄 96개 · 최장 80자 → **201줄 · 평균 26.0자 · 최장 37자** +(글자 7,766 → 5,231, −33%). 긴 줄을 나누느라 줄 수는 조금 늘었다. 이야기·보상·게이트·화자는 그대로다. + +- **어려운 역할 이름은 바꾸지 않고 첫 만남에서 풀었다** — 봉수대("불을 피워 먼 마을에 소식을 알리던 곳"), 탁본("돌에 종이를 대고 두드려 무늬를 떠요"), + 풍경("바람에 우는 풍경" — 경치와 헷갈리지 않게), 필사생("얼음 벽 글을 공책에 베껴 써요"). 표시명(`StorySpeakerName`)이 그대로라 등록 일곱 곳을 건드리지 않는다. +- 아이가 모를 말을 뺐다 — 양반·늙은이·영감(→ 할아버지)·욕봤네·칼금·한지·뙤약볕·과분한·가훈(→ 집안 규칙)·서고·기록소(→ 기록을 모아 둔 집). +- 「먹」은 등장인물(먹 아저씨) 이름이라 결의 대사와 잡담에서 **먹물**로 쓴다. +- 새 설정에 맞췄다: 유적 후일담 끝줄은 "그림자가 숨을 틈이 없게요"(`ch7_opening` — 그림자는 빈칸에 숨는다), 꽃밭은 「비상 울타리」. +- 서릿길 의뢰 제목 「두 벌의 필사」 → 「얼음 벽의 글씨」(두 벌은 후일담 이야기라 의뢰 제목과 맞지 않았다). +- `story_lint` 검사 31·32가 이제 `town`도 본다. + +### 빈자리 + +- **마을을 더 늘리려면** 주민 등록 일곱 곳(위 표) + 4비트 + 의뢰 1개를 복제하고, `StoryBeatWalkthrough.Tales`에 한 줄을 더한다 + (그 표가 걸음의 기대값이다). 자리는 **반드시 다시 잰다** — 2막에서 보듯 전초기지마다 먼저 선 사람과 가판대가 다르다. +- 머리 위 표식·지도 배지·목록 버튼은 IMGUI·월드 연출이라 배치 캡처로 확인할 수 없다(`rules/testing.md`) — 기기 확인 대상. + +## 14. 대사·퀘스트·주민 문구 점검 — 2026-10-03 + +전수(198비트 944줄 + 퀘스트 52개 + 주민 잡담·컷신·영상 자막)를 읽었다. **본편 2막·마을 이야기·동료의 밤·거점 아크는 손댈 곳이 거의 +없었다.** 결함은 오래된 자리에 몰려 있었다 — 1막 초반 비트, 퀘스트 안내문, 주민 잡담. + +| 자리 | 무엇이 틀렸나 | 어떻게 | +|---|---|---| +| `ch1_first_capture`·`ch2_water`·`ch4_bond`·`ch4_decay`·`ch6_approach`·`ch2_watchers` | 명부회의 말 「거둬들이다」를 주인공 쪽이 썼다(2장) | 만나다·새기다로. `story_lint` 검사 30이 지킨다 | +| `ch2_watchers` | 끝줄이 앞줄과 같은 말(「뭐야」)을 되풀이 | 지문(빈 채집통이 부딪는 소리)으로 바꿔 불길한 그림으로 끝낸다 | +| `ch1_intro` | 파트너·그물을 한 줄에 몰아 말했다 | 파트너 → 그물을 건네는 지문 → 그물 한마디로 나눴다(+2줄) | +| `ch1_growth` | 레벨이 올랐다는 계기 없이 강의로 시작 · 「사탕」(UI는 캔디) | 계기를 받는 첫 줄 추가, 「캔디」로 | +| `ch3_reach_forest` | 세라 첫 등장이 설명문("결코 무관하지 않아요"·"열쇠일지도") | 말로 풀었다. `ch5_thesis`의 「마지막 희망」은 4장 아크의 닻이라 **그대로 둔다** | +| `talk_elder` | 「이 시국에」 | 「요즘」 | + +**주민 잡담(`NpcDialogueDatabase`).** 일반 주민 한 명은 개인·리전·힌트 세 줄을 **무작위로 조합**해 받는다. 개인 풀이 열 줄이고 그중 +"산책하기 딱 좋은 날씨예요"는 밤비에 거짓이었다. 본 마을 주민은 전부 `meadow` 소속이라 초원 풀(4줄)이 가장 많이 읽혔다. + +- **개인 풀은 어느 리전·시각·날씨에도 참이어야 한다.** 사라짐을 말하면 사라진 적 없는 꽃밭·우듬지 주민과 부딪치고, 「이름」을 말하면 + 이름 없는 자리의 "이름을 함부로 말하지 마세요"와 부딪친다. 그런 말은 리전 줄이 맡는다. 10 → 16줄. +- 초원 4 → 8줄(어르신·라온 뒷말·채집망 여분·조용해진 풀밭), 1막 다섯 리전에 사라짐의 그림자를 한 줄씩. 산 줄의 "정상은 수문장을 + 이겨야"는 틀렸다(정상은 서브에리어고 수문장은 유적으로 가는 문이다) — 고쳤다. +- 공용 힌트는 **실제로 있는 기능만** 말한다(속성 상성·지속 HP와 병원·팀 5칸·섬 방목·주간 크기 대결). 12 → 17줄. + +**퀘스트 안내문.** 같은 것을 다르게 부르던 말을 맞췄다 — 등급은 「고급(Uncommon)」·「영웅(Epic)」(영문만 있던 두 곳), 「배틀」→「전투」, +「목표가 상승합니다」→「다음 목표가 늘어납니다」(반복 서브 8개), 「서브구역」(개발 용어) → 「숨은 장소」. 튜토리얼 끝 「축하합니다!」는 +`ch2_resolve`에서 어르신이 하는 말과 이어지게 바꿨다. + +**건드리지 않은 것.** 컷신·영상 자막(`CutsceneLibrary`·`StoryVideoLibrary`)은 영상 큐 타이밍에 묶여 있고 이미 문장이 선다. 마을 이야기 12편은 +부탁 수·보상·후일담이 의뢰와 맞물려 있어(`TownTaleTests`) 그대로다. `StoryNpcLines`의 스토리 인물 잡담도 인상만 남기는 규칙에 맞는다. +**초원 수문장 표기** — 대사는 「수문장」, 데이터(`RegionDefinitions`)와 퀘스트 제목은 「수호자」다. 둘 다 이름으로 굳어 있어 맞추지 않았다. + +--- + +## 15. 쉬운 이야기 — 2026-10-04 + +사용자 요청: "어린아이들도 이해할 수 있게." 대상은 **초등 고학년(10~12세)**이다. 9월에 "극적 효과가 부족하다"는 피드백으로 대사를 늘린 결과 +(본편 605줄·한 줄 평균 34자·40자 넘는 줄 177개) 아이에게 어려워졌다 — 이번엔 **말을 줄이고, 극적인 순간은 영상·전투로 보여 준다.** + +### 규칙 — `story_lint` 검사 31~34가 지킨다 + +| 규칙 | 기준 | 검사 | +|---|---|---| +| 한 줄 | 30자 안팎, **최대 40자**(공백 포함) | 31 | +| 한 장면 | **7줄 이내**. 연출이 붙은 장면(`stageEnterId`·`cutsceneId`·`videoId`·`introVideoId`·선택지)은 10줄 | 31 | +| 대사 앞 영상 장면 | `introVideoId`가 붙으면 **1~6줄** — 설명은 영상이 한다(2026-10-05) | 36 | +| 쓰지 않는 말 | 봉인·무명·지워진 개체·예비 울타리 → 이름 벽·그림자·이름 잃은 곤충·비상 울타리 | 32 | +| HUD 목표의 「왜」 | 본편 목표 비트에 `why` 필수, **20자 이하**(목표 행 둘째 줄 한 줄 — 더 길면 글자가 18pt 밑으로 줄어 잘린다) | 33 | +| 장 요약 | `chapters` 13장(ch1~ch12·fin) — 여는 비트·지난 이야기 ≤4줄·이번 목표 | 34 | + +대상은 본편(`ch*`·`fin`)과 곁가지(`bl`·`side`·`npc`)·마을 이야기(`town`)다. 마을 이야기는 처음엔 옛 문체라 뺐다가 2026-10-05에 같은 규칙으로 다시 쓰고 넣었다(13장 「쉬운 말로 다시 쓰기」). + +### 결과 + +| | 전 | 후 | +|---|---|---| +| 본편 114비트 | 605줄 · 평균 33.8자 · 40자 넘는 줄 177개 · 최장 71자 | **496줄 · 평균 24.0자 · 최장 39자** | +| 곁가지 36비트 | 159줄 · 평균 33.5자 · 40자 넘는 줄 48개 | 139줄 · 평균 22.9자 · 최장 36자 | +| 마을 이야기 48비트 (2026-10-05) | 183줄 · 평균 42.4자 · 40자 넘는 줄 96개 · 최장 80자 | 201줄 · 평균 26.0자 · 최장 37자 | +| 본편 전체 글자 | 20,463자 | 11,918자(**−42%**) | + +줄이는 기준: 대치·반전·최종장(연출이 붙은 장면)은 사건을 다 말하게 두고, 반응·여운·수문장·동굴 장면을 2~4줄로 덜었다. +대사가 끝나면 다음에 할 일이 보이게 장면을 맺는다(예: `ch7_opening` 끝줄 "그림자는 '텅 빈 들'로 갔어요. 따라가요!"). +라온의 할머니·세라의 느림·먹의 장부 같은 인물의 결은 한 줄씩 남겼다. + +### 「지난 이야기」 카드 — `Story.json`의 `chapters` + +장마다 **여는 비트**(`openingBeatId`)의 대사 앞에 카드 한 장(장 제목 · 지난 이야기 3줄 · 이번 목표)이 뜨고, 저널의 장 탭 머리에도 보인다 +(그 장을 연 뒤에만 — 스포일러 금지). 며칠 만에 돌아온 아이가 "어디까지 왔고 왜 여기 왔나"를 세 줄로 되짚게 한다. 1장은 지난 이야기가 없어 카드가 없다. + +- **지난 이야기에는 여는 비트까지 반드시 지나온 사건만 적는다.** 여는 비트의 선행(`prerequisiteBeatId` 사슬)과 리전 해금(앞 리전 수문장)만 + 믿을 수 있다 — 여운·곁가지·선택에 따라 갈리는 일(라온의 귀환, 저울에게 한 대답)을 적으면 그걸 안 본 아이에게 거짓이 된다. +- 장 ID가 아니라 여는 비트로 거는 이유: `gd_ruins`(7장)는 7장 개막보다 먼저 뜰 수 있다. "처음 뜬 7장 비트"에 걸면 반전 전에 7장 요약이 나온다. + +### HUD 목표의 「왜」 — 비트의 `why` + +목표 문구는 지금처럼 트리거가 만들고(「모래언덕(으)로」), 그 아래 작은 줄에 이유를 붙인다(「수레바퀴 자국이 모래언덕으로 나 있어」). +`StoryObjectiveTracker.Why`가 읽고, 앞 리전 수문장으로 돌려 친 안내(잠긴 리전)에는 붙이지 않는다 — 그 이유는 수문장이 아니라 원래 목표의 것이라서다. + +### 영상과의 관계 (5단계) + +이야기의 핵심 장면 넷(`ch6_secret` 이름 벽 · `ch7_opening` 움직이는 빈칸 · `fin_unnamed` 이름을 달라는 그림자 · `fin_seal` 이름이 돌아간다)은 +처음엔 **대사만으로 서도록** 10줄 안팎으로 썼다. **5단계(2026-10-05)에서 그림책 영상이 그 설명을 맡았다** — 네 비트에 대사 앞 영상 +(`introVideoId`: `vid_ch6_wall`·`vid_ch7_fence`·`vid_fin_shadow`·`vid_fin_return`)을 붙이고 대사를 각 5줄로 줄였으며, 대사 뒤 컷신 넷은 지웠다(7-1·7-1b). +7장 목표도 영상을 따라 「그림자를 따라 텅 빈 들로 가자」로 바꿨다. + +--- + +## 16. 이야기 전투 — 2026-10-04 + +사용자 결정: **명부회 간부전은 본편 필수다. 대치 대사가 끝나면 곧바로 대결하고, 이겨야 다음 지역이 열린다.** 1막 하수 셋은 +지금처럼 오염 거점 정화로 이끈다(7-4장 — 바꾸지 않았다). 라온은 다치기 전까지 **장마다 한 번** 겨룬다. 출시 전 게임이지만 +"이미 그 간부를 이긴 세이브는 자동 통과"를 지킨다. + +예전엔 대치가 "한 번 더 말을 걸면 붙는 거다"(`talk_grip`)로 끝나고, 간부를 안 이겨도 다음 장이 열렸다. 아이들은 그 한 박자를 +몰라 대사만 보고 지나갔다 — 이야기 속 가장 큰 싸움이 선택 사항이었다. + +### 대사 직후 대결 — `StoryBeat.duelAfter` + +비트에 `duelAfter: `를 적으면 그 장면이 다 끝난 첫 순간에 그 상대와 대결이 열린다. 여는 쪽은 `StoryDuelLauncher`(Story/)다. + +``` +대사 닫힘 → CompleteBeat → StoryBeatCompleted ─┬─ 영상·컷신·NPC 연출(같은 비트) ─┐ + └─ StoryDuelLauncher 대기열 ────────┤ 모달·전투 화면·스토리 큐(선택 결과 대사)·조작 잠금이 + └ 모두 빈 첫 순간 → TryStartBossDuel / TryStartRivalDuel +``` + +| 비트 | 상대 | 맺는 줄 | +|---|---|---| +| `ch1_rival_intro` | 라온(`rival_meadow`) | "좋아, 승부다!" — 끝나면 포획 내기(`RivalRaceController`)가 이어진다 | +| `ch8_confront` | 집게 | "상자를 열고 싶으면 실력으로 와라." → 창고 영상 → 대결 | +| `ch9_confront` | 저울 | 선택지 장면이다 — 창고 영상 → **고른 결과 대사**("이제 실력을 재 보자") → 대결 | +| `ch12_confront` | 관장 하월 | "이 아이를 넘어 보게. 마지막 스승 노릇이다." → 대결(장부의 방 영상은 이긴 뒤로 옮겼다) | + +- **스토리 큐를 기다린다**(`StoryDirector.IsBusy`). 모달만 보면 영상이 끝난 프레임과 큐가 선택 결과를 여는 프레임 사이가 비어 보여 + `ch9_confront`의 결과 대사보다 대결이 먼저 열린다. +- **못 열면 버린다** — 재도전 대기·출전 곤충 없음·자리를 떠남·120초 넘게 막힘. 로그 한 줄만 남긴다. 다시 열 길이 따로 있다: + 간부는 다시 말을 걸면(`WorldInteractionController` → `TryStartBossDuel`), 라온은 대화창 [승부하기]. +- **꿈 안에서는 아무것도 안 한다**(`DreamPrologueState.Active`). 샌드박스 전투는 꿈에서만 열려 같은 표지로 걸러진다. +- **`stageExitId`와 함께 두지 않는다** — 퇴장 연출이 상대를 걸어 나가게 한다. `ch1_rival_intro`의 `st_ch1_rival_exit`를 그래서 뺐다 + (라온은 플레이어가 멀어지면 `StoryStageDirector`가 조용히 제자리로 돌려보낸다). +- **`talk_grip`·`talk_scale`·`talk_chief`는 지웠다.** 남겨 두면 진 뒤 재도전하러 말을 걸 때 그 대사가 대결을 가로막는다 + (`WorldInteractionController`는 `NpcTalk` 비트가 뜨면 대결을 안 연다). 관장의 '이름 잃은 나방' 줄은 대치에 녹였다. +- 대화창(ui-dev)은 `beat.duelAfter`를 읽어 마지막 버튼을 「승부!」로 바꾼다. 선택지 결과 대사는 자기 `duelAfter`가 비어 있으므로 + `StoryDuelLauncher.PendingNpcId`(선택지 비트가 끝난 순간 이미 대기열에 있다)를 본다. + +`story_lint` 검사 35가 상대 실재·퇴장 연출 금지·소개(화자)·거점 리전·라온 단계 열림·런처 배선을 본다. + +### 이겨야 다음이 열린다 — 스토리 선행과 지역 잠금 + +| 간부 | 승리 비트 | 그 비트를 선행으로 무는 것 | 지역 잠금 | +|---|---|---|---| +| 집게 | `duel_grip_win` (게이트 `ch8_confront`) | `ch9_arrive` | **서릿길** — 모래언덕 수문장 + 집게 | +| 저울 | `duel_scale_win` (게이트 `ch9_confront`) | `ch10_arrive` | **잿불 골짜기** — 서릿길 수문장 + 저울 | +| 하월 | `duel_chief_win` (게이트 `ch12_confront`) | `fin_unnamed` | 없음 — 같은 리전 안의 최종장이라 스토리 선행으로만 막는다 | + +- **지역 잠금의 단일 출처는 `RegionManager.IsRegionAccessible`**이다(울타리 통행·지도·HUD·지역 의뢰·섬 손님이 전부 읽는다). 이야기 잠금 표 + (`RegionManager.StoryLocks`)와 판정 함수(`IsOpenByProgress`)를 두고, 간부 격파 기록은 부트스트랩이 넘기는 조회 함수(`AutoWireDuelGate` + ← `NpcDuelController.IsBossDefeated`)로만 읽는다 — Core가 NPC 모듈을 참조하지 않는다. +- **수문장을 먼저 이기면 해금 기록을 미룬다**(`DefeatGuardian`). 간부를 이기는 순간 두 격파 기록으로 열린다 — 둘 다 저장·클라우드 동기다. +- **이미 열린 지역은 닫지 않는다.** 저장된 해금 기록이 있으면 간부를 안 이겼어도 열려 있다. 그 안을 돌던 플레이어를 가두거나 밀어내면 + (끼임 복구가 마을로 보낸다) 진행 중인 세이브가 고장 난 것처럼 보인다. 그 세이브의 진행은 스토리 선행이 따로 막는다 — 도착 비트를 + 이미 봤다면 막을 것도 없다. +- **잠긴 이유는 한 곳에서 낸다**(`RegionManager.GetLockKind`·`DescribeLock`) — "서릿길 — 집게에게 이겨야 열립니다". 필드 차단 배너 + (`PlayerMovement`)가 쓰고, HUD 목표는 잠긴 리전을 가리킬 때 수문장 대신 그 간부에게 안내한다(`StoryObjectiveTracker`, + "서릿길(으)로 가려면 집게에게 이기기"). 본편 목표는 대개 이미 `duel_grip_win`(스파인 — "집게에게 대결 신청")이라 같은 사람을 가리킨다. + 지도(`RegionMapUI`)의 잠김 안내 문구는 아직 "이전 지역의 수문장 격파"다 — `DescribeLock`으로 바꾸는 건 ui-dev 몫이다. + +### 자동 통과 — 이미 이긴 세이브 + +`DuelWin`은 이긴 순간 한 번 울리는 이벤트다. 그 순간을 놓치면(결과 화면 중 앱 종료, 게이트 미충족) 스파인이 영영 안 열릴 것 같지만, +`StoryDirector.ResweepPersistentConditions`가 **격파 기록**을 보고 시작·리전 이동·비트 완료·클라우드 재적재 때마다 다시 흘린다. + +- 이번에 승리 비트들의 **리전 게이트(`requiredRegionId`)를 지웠다.** 그 게이트가 있으면 재확인이 그 리전에서만 통해, 서릿길에 서 있는 + 옛 세이브가 모래언덕까지 돌아가야 이어졌다. `DuelWin`의 소스는 그 간부 대결뿐이라 엉뚱한 리전에서 뜰 일이 없다. +- **나중에 끼워 넣은 스파인은 뒤를 다 봤으면 목표 1순위에서 내린다**(`StoryObjectiveResolver.CollectLiveSpineBeatIds`). 서릿길을 + 이미 지나온 세이브에서 `duel_grip_win`이 스파인으로 남아 HUD가 지난 장으로 되돌려 보냈다. +- `story_lint` 검사 8은 `DuelWin` 스파인을 막지 않는 대신 그 재확인 줄을 소스에서 확인한다. `StoryDuelFlowTests`가 실제 비트로 + "다른 리전에서도 재확인만으로 이어지고, 두 번 뜨지 않는다"를 본다. + +### 라온 — 장마다 한 번 + +단계 표는 11장. 라온 앵커가 있는 리전마다(이름 없는 자리 제외) 단계가 있고, 다치기 전 단계는 전부 갱도에서 닫힌다. 초원 첫 대결은 +Lv.4(첫 파트너 Lv.6 아래 — 첫 전투에서 막히면 이야기가 시작도 전에 선다). 승리하면 `BattleWin`도 함께 울려 그 장의 격전 leaf가 뜰 수 +있다(대결도 전투다 — 초원에서는 `ch1_first_battle`이 레벨 3 뒤라면 이 승리로 뜬다). + +`DuelBanter`에 새 단계 넷(`rival_meadow`·`rival_hollow`·`rival_dunes`·`rival_ember`)을 썼고, 간부 팀 대결용 **교체 한마디**(`Lines.sendOut`, +`DuelBanter.SendOutLine(lines, incomingIndex)`)를 집게·저울(2줄)·하월(4줄)에 채웠다 — ui-dev가 교체 순간(`InsectBattleController.EnemySwitched`)에 띄운다. +팀 대결에서는 상대 HP로 정하는 한마디(흔들림·위기)를 **에이스(마지막 곤충)에서만** 말한다(`DuelBanter.EnemyMomentsAllowed`· +`Next(ref tracker, enemy, player, teamSize, teamIndex)`) — 대사가 그 사람의 마지막 패를 두고 쓴 말이라 첫 곤충에서 나오면 아직 둘이 남았는데 +무너지는 소리를 한다. 앞 곤충들 사이는 교체 한마디가 맡는다. + +### 수문장 등장 화면 — `GuardianIntros` + +수문장전은 대부분 레이드라 대결 컷인이 없어 "나타났다" 한 줄로 시작했다. 리전마다 **별칭**(≤16자)과 **등장 한 줄**(≤30자)을 둔다 +(`Core/GuardianIntros.cs` — 수문장 리전 13곳 전부, `GuardianIntroTests`). ui-dev가 레이드 시작 때 「○○의 수문장 · 별칭 / 곤충 이름 / +등장 한 줄」을 그린다 — 칭호는 리전 표시명, 곤충 이름은 `RegionData.guardianDisplayName`에서 온다. + +### 「지난 이야기」 + +간부전이 필수가 되어 ch9·ch10·종장 카드의 셋째 줄을 이긴 사실로 바꿨다("집게와의 대결에서 이겼다" 등) — 여는 비트의 선행 사슬에 +승리 비트가 들어와 "반드시 지나온 사건"이 됐다(15장 규칙). + +### 검증 + +``` +python -X utf8 .claude/scripts/story_lint.py # 검사 8(DuelWin 재확인) · 35(대사 직후 대결) +-testPlatform PlayMode -testFilter InsectGame.Tests.StoryDuelFlowTests +-testPlatform PlayMode -testFilter InsectGame.Tests.RegionStoryLockTests +-testPlatform PlayMode -testFilter InsectGame.Tests.GuardianIntroTests +-testPlatform PlayMode -testFilter InsectGame.Tests.NpcRivalDuelTests +-testPlatform PlayMode -testFilter InsectGame.Tests.DuelBanterTests +``` + +걸음 도구(`-walkMode campaign`)는 대결 런처를 멈춰 두고(`StoryDuelLauncher.Suspended` — 진짜 전투가 열리면 걸음이 막힌다) 관장 대치 뒤 +**대기열에 관장전이 들어왔는지** 본 다음 승리를 실제 구독 경로(`BossDuelWon`)로 흘려 `duel_chief_win` → `fin_unnamed`를 걷는다. +본편 걸음은 간부 격파 기록을 지우고 시작한다(남아 있으면 자동 통과가 먼저 떠 무엇을 검증했는지 흐려진다 — 걸음 뒤 원복된다). +진짜 전투가 대사 뒤에 실제로 열리는지·결과 화면 뒤 승리 대사가 이어지는지는 기기·QA 빌드로 본다. diff --git a/Docs/StoryFieldContinuityReview.md b/Docs/StoryFieldContinuityReview.md new file mode 100644 index 0000000..abfeabb --- /dev/null +++ b/Docs/StoryFieldContinuityReview.md @@ -0,0 +1,42 @@ +# 대화·스토리·마을 연결 검수 — 2026-09-19 + +## 이번 수정의 기준 + +기존 줄거리와 저장 계약을 유지하면서, 이미 한 행동을 다시 요구하거나 인물과 화면이 어긋나는 연결 결함을 먼저 고친다. 건물은 지붕·문·실제 보행 동선이 일치하는지를 확인한다. 144개 스토리 비트 전체의 문학적 완성도나 전체 캠페인 실플레이를 통과했다는 의미는 아니다. + +## 대화와 진행 + +- 선행 대사를 보기 전에 레벨·포획 종 수·간부 격파·거점 정화 조건을 달성하면 다음 이벤트까지 후속 이야기가 기다리던 문제를 수정했다. 시작, 선행 대사 완료, 지역 이동, 진행 로드에서 이미 저장된 누적 상태를 다시 평가한다. +- 대화 준비 중 들어온 트리거를 버리지 않고 보관한다. 다른 비트가 진행 중이면 실제로 새 대사를 열지 않았는데 성공했다고 반환하지 않는다. +- 화면의 초상화는 비트 대표 인물이 아니라 현재 줄의 화자를 따른다. `하월` 별칭과 검은 옷의 청년 초상화도 포함한다. 내레이션에는 다른 인물의 얼굴을 붙이지 않는다. +- 일반 NPC 대화가 진행 중인 스토리를 덮어쓰지 않게 했다. 적 NPC 재대화는 실제 격파·정화 상태를 읽는다. +- 일반 대화 아래에 기존 `StoryObjectiveTracker`의 다음 목표를 안내한다. 진행 중인 스토리 본문에는 목표 행을 넣지 않는다. +- 정화 뒤에도 현재 가동 중인 그물·상자를 가리키던 4개 비트를 남획의 흔적과 회복에 관한 표현으로 보정했다. 수문장 조언은 앞으로의 팀 운용을 이야기하며, 연못에서 만나는 라온은 짧게 자신을 소개한다. +- 최종 승리 후 `ch12_clash`는 이름 없는 자리의 하월과 재대화하면 이어진다. 후일담을 위해 추가 전투를 요구하지 않는다. 144개 비트 ID와 기존 보상은 그대로다. + +## 필드 건물 + +수정 전 고정 구도 촬영: `Artifacts/village-20260919-001821-022`. + +확인한 결함은 주택과 우물의 뒤집힌 지붕 경사, 떠 있는 용마루, 통나무집 문을 가리는 벽재, 지역 바닥 아래에 놓인 내부 길, 시작 위치와 광장 사이의 막힌 직선 동선이다. 건물·NPC 좌표를 옮기지 않고 출입구와 연결 보행로를 맞춘다. + +## 검증 결과 + +Unity PlayMode **1,087/1,087 통과**, 실패·건너뜀 0. 결과 파일: `.claude/cache/design-validation/PlayMode-20260919-002937-361.xml`. + +이번에 추가된 23케이스는 진행 8, 대화 11, 건물 4다. 누적 조건·지역 제한·중복 발화와 최종장 실제 저작의 대화 트리거, 화자 이름·초상화·대화 재진입, 지붕과 보행 동선을 포함한다. 담당 커버리지는 247/247이다. + +저장소 CI 333개 C# 검사 통과, 스토리 정합성 26 PASS, `git diff --check` 통과. + +최종 건물 촬영: `Artifacts/village-20260919-003035-492` (9장). 전후 비교: `Artifacts/village-continuity-comparison.png` (왼쪽 이전, 오른쪽 이후). 주택·광장·숲 전초기지를 직접 확인했고 지붕 경사/문 가림 및 입구 길 연결부 틈을 보정했다. + +실제 대화 촬영: `Artifacts/story-dialogue-169` (1280×720), `Artifacts/story-dialogue-43` (1024×768). 각 13장, 검은 이미지 0장. 실제 저작의 집게→세라, 라온→세라→먹 화자 전환과 일반 대화 목표 행을 촬영했다. 집게/세라 초상화 전환, 긴 한국어 대사, 일반 목표 안내와 버튼의 분리를 직접 확인했다. 목표 안내 촬영 문구는 배치 검사용 주입 값이며 캠페인에서 목표 선택을 확인했다는 뜻은 아니다. + +재현: `Tools/Start-UnityValidation.ps1 -Mode Village`로 건물을 촬영한다. 대화는 `-Mode BattleBuild`로 만든 검수 실행 파일을 `-battleScenario story -battleCaptureOut <새 빈 폴더>`와 창 모드로 실행한다. 기존 사용자 데이터 대신 격리 장면을 사용하며 촬영 완료 시 종료한다. + +## 검증 범위와 다음 단계 + +- 건물 전후 이미지는 실제 `VillageBuilder`를 격리 장면에서 실행하고 같은 카메라·조명을 사용한다. NPC·주변 지형 전체와 플레이어 이동 영상은 포함하지 않는다. +- 대화 촬영은 실제 Story.json 대사와 IMGUI 렌더러를 사용하되 저장·보상 서비스 없는 다시보기다. 배경 마을은 촬영용 배경이며 해당 챕터의 장소가 아니다. +- 진행 회귀 테스트는 누적 조건 선달성, 지역 제한, 중복 발화, 선택지 순서를 확인한다. 전체 캠페인 버튼 입력·음향·Android 검증과 구분한다. +- 다음 검수는 초원→연못의 장 전환, 후반 간부 대치→승리→정화 후 재방문의 실제 캠페인 흐름과 지형이 함께 생성된 필드의 이동 동선을 우선한다. diff --git a/Docs/StoryVideos.md b/Docs/StoryVideos.md new file mode 100644 index 0000000..1d1708b --- /dev/null +++ b/Docs/StoryVideos.md @@ -0,0 +1,373 @@ +# 스토리 영상 — 제작·삽입 단일 출처 + +스토리에 넣는 **실제 영상 파일(mp4)** 15편의 편성·샷·자막 큐·소리·렌더(§4-1 그림책 렌더러)·AI로 바꿀 때(§5)·검증 절차. +자막 문구를 고치면 `Assets/Scripts/Story/StoryVideoLibrary.cs`도 함께 고친다(그쪽이 런타임 출처 — 렌더러도 그 파일을 읽는다). + +- 재생: `StoryVideoDirector`가 화면을 통째로 덮고 영상 안의 소리를 튼다. 두 자리가 있다 — + **대사 앞**(`StoryBeat.introVideoId`, 4편 — 그 장면의 설명을 영상이 맡는다. `StoryPreludeChain`이 영상 → NPC 등장 연출 → 대사 순으로 잇는다)과 + **대사 뒤**(`StoryBeat.videoId`, 11편 — 마무리, `StoryBeatCompleted`). +- 파일: `Assets/StreamingAssets/Video/.mp4` — `VideoPlayer.url` 스트리밍(임포터를 안 탄다). +- 다시 보기: 스토리 저널의 본 비트 행 「▶ 영상」(`StoryVideoDirector.PlayReplay`). +- 검증: `story_lint` 검사 13·25·36, `StoryVideoLibraryTests`·`StoryPreludeTests`, QA 빌드 `-battleScenario story-video`(§6). + +## 0. 오프닝 프롤로그 — 게임을 켜면 처음 보는 20초 (스토리 비트와 별개) + +스토리 비트에 붙는 영상이 아니다. `OpeningSceneController`가 첫 실행(cold start)과 설정의 「오프닝 다시보기」에서 +틀고, 내레이션 네 줄·타이틀·건너뛰기는 IMGUI로 얹는다. **원화는 기존 오프닝 일러스트 3장** +(`Resources/UI/Opening/opening_0{1,2,3}_{landscape,portrait}.jpg`)이다 — 새 그림 없이 움직임만 입혔다. + +| 파일 | 만드는 스크립트 | 규격 | +|---|---|---| +| `StreamingAssets/Video/opening_prologue_landscape.mp4` | `python -X utf8 Tools/Video/opening_prologue.py landscape` | 1280×720 · 20s · 무음 | +| `StreamingAssets/Video/opening_prologue_portrait.mp4` | `python -X utf8 Tools/Video/opening_prologue.py portrait` | 720×1280 · 20s · 무음 | +| `Resources/Audio/Opening/opening_prologue.wav` | `python -X utf8 Tools/Video/opening_audio.py` | 44.1kHz 스테레오 · 20s | + +세로 원화는 가로를 잘라 만든 그림이 아니라 **따로 구도를 잡은 그림**이라 영상도 방향별 두 편이다. +재생 중 방향이 바뀌면 갈아타지 않고 지금 영상을 가운데 잘라(cover) 계속 튼다. + +| 시각 | 그림 | 내용 | 내레이션 | +|---|---|---|---| +| 0.0~9.6 | ① 밤숲 길 | 빛이 하나씩 꺼지고 숲이 가라앉는다. 4.3초부터 길 끝에 그물을 든 검은 코트가 서고, 남은 빛이 그물로 빨려 들어가 차갑게 번쩍이며 사라진다. 8.3~9.4 암전 | 곤충이 사라지고 있다. / 그리고 그것을 남김없이 거두려는 자들이 있다. | +| 9.8~15.0 | ② 파트너 | 어둠에서 떠오르고, 꺼졌던 그림 속 빛이 하나둘 돌아온다 | 사라지는 이름을 기록하는 일. | +| 14.0~15.0 | ②→③ | **맞춤 컷** — 두 그림의 눈 중점·간격을 재서 겹치는 순간 같은 자리·크기에 온다 | | +| 15.0~20.0 | ③ 빛나는 잎 | 16.0 곡의 강세에 빛이 번지고 타이틀. 19.2~20.0 페이드아웃 | 거기서부터 시작된다. | + +- **소리**: 테마곡(10초, D음 드론, 6초에 강세)을 10.0초에 놓고, 앞 10초는 같은 D조 패드·바람·빛의 반짝임을 + 합성했다. 빛이 꺼지는 만큼 반짝임이 잦아들고, 빛이 그물에 닿을 때마다 핑, 암전(9.36)에 낮은 붐. + 사건 시각은 `opening_audio.py`가 영상 스크립트를 모듈로 읽어 공유한다(같은 난수 스케줄). +- **시각의 단일 출처는 `OpeningSequenceState`다.** 두 스크립트의 상수가 거기와 같고, `OpeningSequenceTests`가 + 내레이션이 암전·타이틀과 겹치지 않는지 고정한다. 한쪽을 바꾸면 셋을 함께 고친다. +- **영상을 못 틀어도 오프닝은 선다.** 파일 없음·디코더 오류·준비 3초 초과·재생 중 1.2초 멈춤이면 같은 시계로 + 정지 그림 3장을 넘긴다. 다시보기가 거는 `timeScale = 0`·`AudioListener.pause`에 멈추지 않도록 영상 시계는 + `UnscaledGameTime`이다. + +## 0-1. 꿈 프롤로그 도입 — 경기장 입장 8초 (스토리 비트와 별개) + +「챔피언의 꿈」(`DreamPrologueDirector`)의 첫 장면이다. 비트에 붙는 영상이 아니라 `DreamIntroVideo`가 직접 튼다. +규칙·실패 경로는 `.claude/rules/dream-prologue.md` 「도입 영상」. + +| 파일 | 만드는 스크립트 | 규격 | +|---|---|---| +| `StreamingAssets/Video/dream_arena.mp4` | `python -X utf8 Tools/Video/dream_arena.py [--preview 1.0,5.0 \| --sheet]` | 1280×720 · 8s · 무음 · 약 1.6MB | +| `Resources/Audio/Dream/dream_arena.wav` | `python -X utf8 Tools/Video/dream_arena_audio.py` | 44.1kHz 스테레오 · 8s | + +| 시각 | 장면 | 소리 | +|---|---|---| +| 0.0~3.0 | **선수 입장 터널** — 아치형 갈빗대가 바깥으로 날아가고(걷는 느낌) 끝의 빛이 점점 커진다. 그 빛을 향해 걷는 챔피언의 뒷모습. 2.35부터 빛이 차올라 흰 화면 | 먹먹한 환호(저역만)가 커진다 · 걸음마다 발소리와 울림 · 낮은 드론 · 2.0부터 상승음 | +| 3.0~3.9 | **흰 섬광 → 경기장** — 흰빛이 걷히며 관중석 전체·서치라이트·색종이가 드러난다 | 큰 '쿵' + 막혔던 환호가 한꺼번에 터진다(먹먹함이 걷힘) | +| 3.9~5.75 | 카메라가 맞대결 쪽으로 밀고 들어간다. 바닥 빛무리 속에 챔피언·파트너(헤라클레스)·맞은편 상대(태고의 비천룡) | 박수가 점점 촘촘해진다 · 휘파람 · 환호가 일렁이며 부푼다 | +| 5.2~6.0 | 서치라이트가 두 곤충으로 모인다. 챔피언이 팔을 든다 | 5.75 숨죽임(환호가 푹 꺼진다) | +| 6.1 | **맞대결 번쩍** — 빛이 한 번 터지고 화면이 흔들린다 | 가장 큰 타격 + 환호 정점 + 금속성 울림 | +| 7.2~8.0 | 암전 → 게임이 「챔피언 결정전」 카드를 얹는다 | 페이드아웃 | + +- 화풍은 오프닝과 같은 실루엣 삽화(`silhouette_kit`) — 사람은 뒷모습뿐이고 글자는 굽지 않는다. (스토리 영상 15편은 2026-10-05에 그림책 화풍으로 바뀌었다 — §2.) +- **세로 화면**은 가운데 약 32%(x 437~843)만 보인다. 맞대결 쌍(뿔 ↔ 머리)이 그 안에 들어가게 배치했다. +- 길이를 바꾸면 `T_TOTAL`(영상 스크립트)과 `DreamPrologueData.IntroSeconds`를 함께 — `DreamIntroVideoTests`가 헤더로 잡는다. + +## 1. 제약 (전부 코드가 강제한다) + +| 제약 | 값 | 강제 지점 | +|---|---|---| +| 길이 | **≤ 15초** (목표 10~15) | `StoryVideoLibraryTests` — `AutoUnfreezeTime(20) − 4 − 1`. 넘으면 재생 중 조작이 살아난다 | +| 해상도·코덱 | 1280×720, H.264 **Main** 프로파일, 24fps, CRF 23(상한 2 Mbps), **AAC 96k 스테레오**, `+faststart` | minSdk 25 하드웨어 디코더. 편당 ≤4 MB(렌더러가 넘으면 실패로 끝낸다), 15편 약 30 MB | +| 화면비 | 16:9 마스터 1본. 세로 화면은 **중앙 cover-crop** — 보이는 건 **가운데 약 32%(x 437~843)**다 | `UIHelper.CalculateCoverUv`. 각 샷의 주인공 피사체를 그 안에 둔다(예전 문서의 "가운데 56%"는 틀린 값이었다) | +| 자막 | **영상에 굽지 않는다** — 게임이 IMGUI로 얹는다. 한 큐 ≤ 24자 | `StoryVideoLibrary` 큐 · `StoryVideoLibraryTests`. 아래 약 150px는 자막 띠라 중요한 것을 두지 않는다 | +| 금칙 | 자막에 「무명」·「봉인」 등 쓰지 않는 말 없음 | `StoryVideoLibraryTests` | +| 배타 | 한 비트에 `videoId`와 `introVideoId`를 함께 두지 않는다. `videoId`는 `cutsceneId`·`stageExitId`와 함께 두지 않는다 | `story_lint` 검사 13 | +| 대사 앞 영상 | `introVideoId` 비트의 대사는 **1~6줄** — 설명은 영상이 맡는다 | `story_lint` 검사 36 | +| 누락 | 파일이 없으면 경고 1줄 + 건너뜀(진행 유지). 대사 앞 영상이면 곧바로 대사가 열린다 | `StoryVideoDirector` · `StoryPreludeTests` | + +## 2. 화풍 — 그림책 (2026-10-05) + +대상이 초등 고학년이라 **영상이 설명하고 대사는 짧게** 간다(StoryBible 7-1b). 영상 15편은 전부 같은 그림책 화풍이다 — +0단계에서 두 화풍(그림자·그림책)을 같은 구도로 비교해 골랐다(`Tools/Video/sample_ch7_fence.py`). + +- **진한 갈색 윤곽 + 밝은 단색 + 큰 눈의 곤충**, 종이 결, 약한 비네트. 무서운 장면도 아이용이다 — 겁주기보다 신비롭게. +- **그림자(이름 잃은 것)**: 보랏빛으로 일렁이는 덩어리에 노란 눈 둘(세로 동공). 빌린 곤충 모습은 검보랏빛 몸 + 노란 눈. 그림자는 + 울타리 **바깥**에 산다 — 울타리 장면에서는 말뚝 뒤에서 엿본다. +- **사람은 뒷모습·옆모습만**(얼굴 정면 금지). 인물 표지를 색으로도 구분한다 — 주인공(노란 모자·초록 티·채집망), 세라(긴 갈색 머리·크림 코트·어깨 가방), + 라온(뾰족 머리·빨간 웃옷, 다친 뒤 팔걸이), 명부회(남색 코트·챙 모자), 먹(검은 코트·붉은 목도리), 하월(흰머리·지팡이), 어르신(흰 머리 쪽·초록 옷). +- **글자 금지** — 이름·장부·수첩은 판독 불가 무늬(`glyphs`)·긁적임(`scribble`)·눈금뿐이다. 「이름을 줘」 같은 말도 자막이 한다. +- **「이름」은 곤충 그림이다** — 이름 벽의 칸, 말뚝 이름표에 그 곤충의 작은 그림이 있다. 이름이 바래면 그림이 회색으로 바래고, 돌아오면 금빛으로 켜진다. +- 회상은 세피아 색조 + 둥근 테두리, 「만약」 장면은 구름 모양 테두리 + 흐린 색. +- **소리가 들어 있다** — 칼림바·종·오르골 같은 작은 소리, 부드러운 패드, 자연음(바람·물·새·풀벌레·불). 이름이 빛날 때·돌아올 때 쓰는 + **「이름의 동기」**(칼림바 D5 F#5 A5 B5 A5)가 15편을 잇는다. 그림자는 낮은 단조 패드와 흐린 두 음. 외부 음원·테마곡은 쓰지 않는다(저작권·용량). + 재생 중 월드 BGM은 0.2로 낮아진다(`StoryVideoDirector`). + +## 3. 편성표 + +**대사 앞**(`introVideoId`)은 그 장면의 설명을 맡는다 — 영상 → (NPC 등장 연출) → (장을 여는 비트면 「지난 이야기」 카드) → 짧은 대사. +장을 여는 ch7·마지막 장은 드라마의 콜드 오픈처럼 영상이 카드보다 먼저 온다. **대사 뒤**(`videoId`)는 마무리다 — 직전 대사를 되풀이하지 않고 +그 다음 화면에 남을 것을 보여 준다. + +| # | 비트 | 언제 | videoId | 파일 | 길이 | +|---|---|---|---|---|---| +| 1 | `ch1_intro` | 대사 뒤 | `vid_ch1_prologue` | `ch1_prologue.mp4` | 12.0s | +| 2 | `ch2_watchers` | 대사 뒤 | `vid_ch2_watchers` | `ch2_watchers.mp4` | 10.0s | +| 3 | `ch3_reach_forest` | 대사 뒤 | `vid_ch3_scholar` | `ch3_scholar.mp4` | 12.0s | +| 4 | `ch4_harvest` | 대사 뒤 | `vid_ch4_crates` | `ch4_crates.mp4` | 12.0s | +| 5 | `ch5_summit` | 대사 뒤 | `vid_ch5_summit` | `ch5_summit.mp4` | 12.0s | +| 6 | `ch6_secret` | **대사 앞** | `vid_ch6_wall` | `ch6_wall.mp4` | 15.0s | +| 7 | `ch7_opening` | **대사 앞** | `vid_ch7_fence` | `ch7_fence.mp4` | 14.0s | +| 8 | `ch8_confront` | 대사 뒤(→ 간부전) | `vid_ch8_vault` | `ch8_vault.mp4` | 12.0s | +| 9 | `ch9_confront` | 대사 뒤(→ 간부전) | `vid_ch9_archive` | `ch9_archive.mp4` | 14.0s | +| 10 | `ch10_confront` | 대사 뒤 | `vid_ch10_kiln` | `ch10_kiln.mp4` | 15.0s | +| 11 | `ch11_confront` | 대사 뒤 | `vid_ch11_crown` | `ch11_crown.mp4` | 12.0s | +| 12 | `duel_chief_win` | 대사 뒤 | `vid_ch12_ledger` | `ch12_ledger.mp4` | 14.0s | +| 13 | `fin_unnamed` | **대사 앞**(→ 선택지) | `vid_fin_shadow` | `fin_shadow.mp4` | 14.4s | +| 14 | `fin_seal` | **대사 앞**(최종전 결과 화면 뒤) | `vid_fin_return` | `fin_return.mp4` | 14.6s | +| 15 | `fin_epilogue` | 대사 뒤 | `vid_fin_epilogue` | `fin_epilogue.mp4` | 15.0s | + +새 4편(6·7·13·14)은 예전 프로시저럴 컷신(`cs_seal_discovery`·`cs_seal_opening`·`cs_nameless_confront`·`cs_final_seal`)을 대신한다 — +대사 뒤에 카메라가 같은 설명을 자막으로 되풀이하던 것을, 대사 앞의 그림 설명으로 바꾸고 대사를 10줄 안팎에서 5줄로 줄였다. 그 컷신 넷은 지웠다. +저널에서 본 비트의 영상을 다시 볼 수 있다(「▶ 영상」 — `StoryJournalUI` → `StoryVideoDirector.PlayReplay`). + +## 4. 편별 스토리보드 + +각 편: 직전(또는 뒤) 대사 → 샷(영상 전체 기준 초 — 샷 k≥1은 디졸브 0.6초만큼 앞당겨 시작) → 자막 큐(`at`, `dur`) → 소리. +**자막·길이의 런타임 출처는 `StoryVideoLibrary.cs`다** — 여기를 고치면 그쪽도, 그 반대도. 렌더러가 그 파일을 읽어 미리보기 자막을 얹고 길이를 맞춰 본다. + +### 1. `vid_ch1_prologue` — 12.0s · 새벽 초원 +직전: 어르신 "곤충들이 하나둘 사라지고 있어 … 잦아듦 … 은빛 그물 … 그 아이와 함께 곤충을 만나 도감에 적어 주렴". +| 샷 | 초 | 그림 | +|---|---|---| +| 1 | 0~3.5 | 새벽 초원 광각 — 둥근 언덕·해 뜨는 하늘. 곤충이 하나도 안 보이는 고요한 풀밭, 카메라가 옆으로 흐른다 | +| 2 | 2.9~6.5 | 풀잎 위 곤충 셋이 하나씩 안개처럼 흐려져 사라진다 | +| 3 | 5.9~9.0 | 은빛 채집망을 쥔 주인공 손(초록 소매), 역광 | +| 4 | 8.4~12.0 | 펼친 손등에 작은 곤충이 내려앉는다 | + +자막: (0.8, 2.6) 어제 있던 곤충이 오늘은 안 보인다. / (3.8, 2.6) 하나둘, 흐려지듯 사라지고 있다. / (6.4, 2.2) 그래서 이 그물을 받았다. / (9.0, 2.6) 이 아이와 함께 찾으러 가자. +소리: 새벽 바람·멀리 새(**풀벌레 소리는 없다** — 조용함), 사라질 때 내려가는 종, 착지에 따뜻한 이름의 동기. + +### 2. `vid_ch2_watchers` — 10.0s · 연못 +직전: 검은 옷의 사내가 종만 묻고 "곧 전부 장부에 오를 테니". 라온 "잡은 곤충 숫자를 적는 책?". 사내가 돌아선다. +| 샷 | 초 | 그림 | +|---|---|---| +| 1 | 0~3.0 | 연못 — 연잎·갈대·물결, 잠자리가 스친다 | +| 2 | 2.4~7.0 | 갈대 너머 명부회 뒷모습 — 작은 수첩에 눈금을 그어 센다 | +| 3 | 6.4~10.0 | 사내가 돌아서 안개 속으로 걸어 들어가 흐려진다 | + +자막: (3.0, 3.0) 수첩에는 곤충 숫자만 가득했다. / (6.8, 2.6) 그는 안개 속으로 사라졌다. +소리: 물·개구리·잠자리 날갯소리, 펜 긁는 소리, 걸음·빈 통 달각, 낮은 패드. + +### 3. `vid_ch3_scholar` — 12.0s · 숲 +직전: 세라 합류. "곤충 이름이 잔뜩 새겨진 벽 … 이름이 바래면 곤충도 사라지는 것 같아요". +| 샷 | 초 | 그림 | +|---|---|---| +| 1 | 0~3.0 | 이끼 바위에 새겨진 이름 무늬(칸마다 곤충 그림 + 무늬) | +| 2 | 2.4~6.0 | 몇 칸은 회색으로 바래 있다. 세라 손끝이 무늬를 훑는다 | +| 3 | 5.4~9.0 | 손끝이 닿은 칸이 금빛으로 반짝이고 그림이 또렷해진다 | +| 4 | 8.4~12.0 | 숲 너머 산등성이 위 유적, 앞에 세라와 주인공 뒷모습 | + +자막: (0.8, 2.2) 바위에 곤충 이름이 새겨져 있다. / (3.2, 2.2) 바랜 이름도 군데군데 보였다. / (5.8, 2.6) 손끝이 닿자, 이름이 반짝였다. / (8.9, 2.6) 답은 저 너머 유적에 있다. +무늬는 ch11 나무껍질과 **같은 씨앗**으로 그린다(「숲 바위와 같은 무늬」가 실제로 같은 모양이다). + +### 4. `vid_ch4_crates` — 12.0s · 저녁 습지 +직전: 사내 등 뒤 상자에서 파닥이는 소리. "많은 건 세어도 티가 안 나거든". 세라 "상자가 열 개도 넘었어요". +| 샷 | 초 | 그림 | +|---|---|---| +| 1 | 0~3.0 | 안개 낀 저녁 습지 | +| 2 | 2.4~6.5 | 반쯤 잠긴 상자 여럿이 줄지어 — 카메라가 줄을 따라 | +| 3 | 5.9~9.5 | 상자 틈 — 어둠 속 곤충의 큰 눈과 더듬이 | +| 4 | 8.9~12.0 | 뚜껑의 이름표(무늬·눈금), 물방울 | + +자막: (1.0, 2.6) 습지 곳곳에 상자가 숨겨져 있었다. / (6.2, 2.6) 틈 사이로 더듬이가 움직였다. / (9.2, 2.4) 모두 살아 있는 곤충이었다. + +### 5. `vid_ch5_summit` — 12.0s · 산 새벽 +직전: 라온 "지나온 데가 다 보여". 세라 "안개 너머로 빛나는 게 고대 유적 … 마지막 장만 남았어요". +| 샷 | 초 | 그림 | +|---|---|---| +| 1 | 0~3.0 | 능선 위 세 사람 뒷모습(주인공·세라·라온), 멀리 지나온 초원·연못 | +| 2 | 2.4~6.0 | 발아래 구름 바다가 갈라진다 | +| 3 | 5.4~9.5 | 안개 아래 골짜기에 고대 유적이 금빛으로 빛난다 | +| 4 | 8.9~12.0 | 유적으로 느린 줌 | + +자막: (1.0, 2.6) 지나온 길이 모두 내려다보였다. / (5.6, 2.8) 안개 너머로 고대 유적이 빛난다. / (9.0, 2.6) 곤충 이름의 비밀이 저기 있다. + +### 6. `vid_ch6_wall` — 15.0s · 신전 — **대사 앞(설명)** +뒤 대사: 세라 "보세요! 사라졌던 고대의 잠자리예요!" / 라온 "벽에서 나왔어! 이게 다 곤충 이름이야?" / 세라 "네. 여기가 '이름 벽'이에요." / +라온 "그럼 잦아듦은 이름이 바래서 생긴 거였네!" / 세라 "맞아요. 그 빈칸을 당신 도감이 메웠어요." +| 샷 | 초 | 그림 | +|---|---|---| +| 1 | 0~3.0 | 빛줄기 아래 거대한 이름 벽, 카메라가 올라가며 크기를 보여 준다. 아래에 세 사람이 작게 올려다본다 | +| 2 | 2.4~6.2 | 옛날(세피아·둥근 테두리) — 옛사람의 손이 끌로 칸에 이름을 새기면 칸이 켜지고, 칸 속 나비가 빠져나와 날아간다 | +| 3 | 5.6~9.2 | 한 칸이 회색으로 바래고, 아래 풀잎 위 딱정벌레가 흐려져 사라진다. 칸은 빈칸 | +| 4 | 8.6~12.2 | 펼친 도감에 펜이 곤충을 그리면 금빛 실이 벽으로 날아가 빈칸이 다시 켜진다 | +| 5 | 11.6~15.0 | 벽 한쪽이 크게 빛나고 날개 네 장 고대 잠자리가 빠져나와 날아오른다 | + +자막: (0.6, 2.2) 벽 가득, 곤충 이름이 새겨져 있다. / (3.0, 2.8) 옛사람들은 이름을 새겨 곤충을 지켰다. / (6.1, 2.6) 이름이 바래면, 그 곤충이 사라졌다. / (9.1, 2.6) 도감에 적으면, 이름이 다시 빛난다. / (12.1, 2.6) 그리고 사라졌던 곤충이 돌아온다! + +### 7. `vid_ch7_fence` — 14.0s · 이름 벽 → 울타리 — **대사 앞(설명)** +뒤 대사: 라온 "방금 봤어? 빈칸이 움직였어!" / 세라 "그림자가 이름 하나를 빼앗아 숨어 있던 거예요." / 라온 "벽이 다 차서 숨을 데가 없으니 들킨 거구나!" / +세라 "빼앗긴 이름을 돌려주면 그림자는 밖으로 밀려나요." / 세라 "그림자는 '텅 빈 들'로 갔어요. 따라가요!" +| 샷 | 초 | 그림 | +|---|---|---| +| 1 | 0~2.6 | 이름 벽 — 금빛이 왼쪽에서 오른쪽으로 훑는다 | +| 2 | 2.0~5.4 | 울타리 — 말뚝마다 곤충 그림 이름표. 안쪽은 볕 드는 풀밭, 바깥은 보랏빛 안개. 그림자가 울타리 너머에서 엿본다 | +| 3 | 4.8~8.2 | 가운데 이름표가 바래고, 나비가 흩어지고, 말뚝이 쓰러진다(구멍) | +| 4 | 7.6~11.0 | 그림자가 구멍으로 미끄러져 들어와 딱정벌레에 겹친다 — 벌레가 검게 바래고 노란 눈이 뜬다 | +| 5 | 10.4~14.0 | 벽의 칸이 차례로 켜지는데 한 칸만 빈칸 — 그 빈칸이 옆으로 미끄러지고 눈을 뜬다 | + +자막: (0.3, 2.3) 이름 벽은 사실 울타리였다. / (2.8, 2.5) 바깥의 그림자를 막는 울타리. / (5.4, 2.5) 이름이 바래면, 구멍이 난다. / (8.2, 2.6) 그림자는 그 구멍으로 숨어들었다. / (11.2, 2.6) 벽이 다 차자, 숨은 한 칸이 드러났다. + +### 8. `vid_ch8_vault` — 12.0s · 모래 창고 +직전: 집게 "빈칸마다 구멍이 뚫린다. 그래서 전부 장부에 올린다" … "…장부에는 올라가 있다". 이 뒤 곧바로 간부전. +| 샷 | 초 | 그림 | +|---|---|---| +| 1 | 0~3.0 | 모래언덕에 반쯤 묻힌 창고 입구 — 카메라가 안으로 | +| 2 | 2.4~6.5 | 천장까지 쌓인 상자, 먼지 속 빛줄기 | +| 3 | 5.9~9.5 | 상자 줄을 따라 — 몇 개는 뚜껑이 열린 채 비어 있다 | +| 4 | 8.9~12.0 | 상자 하나가 조용히 놓여 있다(움직임 없음) | + +자막: (1.0, 2.6) 창고 안은 끝이 보이지 않았다. / (4.2, 2.4) 천장까지 상자가 쌓여 있었다. / (6.6, 2.4) 어떤 상자는 텅 비어 있었다. / (9.4, 2.2) 이 상자는 너무 조용했다. + +### 9. `vid_ch9_archive` — 14.0s · 얼음 서고 +직전: 저울 "이 서고 목록을 만든 게 너였지. 그럼 뭐가 달라졌지?" 세라가 대답하지 못한다. 이 뒤 저울과 대결 — **영상은 대답하지 않는다**. +| 샷 | 초 | 그림 | +|---|---|---| +| 1 | 0~3.0 | 얼음 벽 속에 멈춘 곤충들 | +| 2 | 2.4~6.0 | 얼음 벽 앞 세라와 저울이 옆모습으로 마주 선다 | +| 3 | 5.4~10.0 | 얼음에 비친 회상(세피아) — 어린 세라의 손이 긴 목록을 적는다 | +| 4 | 9.4~14.0 | 지금의 세라 손이 반영에 닿다가 멈춘다 | + +자막: (1.0, 2.6) 얼음 속에 곤충들이 멈춰 있다. / (3.6, 2.2) 세라와 저울이 마주 섰다. / (6.0, 3.0) 예전에 이 목록을 적은 건 세라였다. / (10.2, 3.0) 세라는 아직 대답하지 못했다. + +### 10. `vid_ch10_kiln` — 15.0s · 잿불 갱도 +직전: 라온이 뛰어들고 천장이 무너진다. 먹이 라온을 끌어낸다. 먹 "상자는 하나밖에 못 꺼냈다 … 나는 이름을 적는 사람이지, 가두는 사람이 아니었다". +| 샷 | 초 | 그림 | +|---|---|---| +| 1 | 0~3.0 | 갱도 안, 불똥이 떠다닌다 | +| 2 | 2.4~6.5 | 들보가 무너진다 — 흔들림·먼지 | +| 3 | 5.9~10.0 | 먹의 검은 소매 손이 돌무더기 속 라온의 팔(빨간 소매)을 끌어낸다 | +| 4 | 9.4~12.5 | 재 위에 놓인 상자 하나 | +| 5 | 11.9~15.0 | 먹의 손이 두꺼운 장부를 덮는다 | + +자막: (2.8, 2.6) 천장이 무너져 내렸다. / (6.2, 3.0) 먹이 라온을 끌어냈다. / (9.8, 2.4) 꺼낸 상자는 하나뿐이었다. / (12.2, 2.4) 먹은 장부를 덮었다. + +### 11. `vid_ch11_crown` — 12.0s · 우듬지 +직전: 세라 "이 나무 껍질에도 이름이 … 꽃밭과 여기. '비상 울타리'예요". +| 샷 | 초 | 그림 | +|---|---|---| +| 1 | 0~3.0 | 거대한 나무 꼭대기, 신록 사이 햇살 | +| 2 | 2.4~6.0 | 나무껍질의 이름 무늬 — ch3 바위와 **같은 무늬**가 금빛으로 빛난다 | +| 3 | 5.4~9.0 | 시선이 내려가 멀리 꽃밭 유리 온실이 반짝인다 | +| 4 | 8.4~12.0 | 나무와 온실이 한 화면, 둘 다 금빛 테 | + +자막: (1.0, 2.4) 나무 꼭대기에도 이름이 있었다. / (3.8, 2.2) 숲 바위와 같은 무늬였다. / (6.0, 2.6) 저 멀리 꽃밭 온실에도. / (9.0, 2.6) 둘 다 비상 울타리였다. + +### 12. `vid_ch12_ledger` — 14.0s · 이름 없는 자리 +직전: 하월과의 대결에서 이긴 뒤(`duel_chief_win`) "스승 노릇은 여기까지다 … 길을 비켜 주마. 안쪽에서 그림자가 기다린다". 고백은 대결 앞 +`ch12_confront`가 했다 — 노인이 비켜서는 그림이 대결 앞에 오면 이야기가 거꾸로 가서 이긴 뒤로 옮겼다(2026-10-04). +| 샷 | 초 | 그림 | +|---|---|---| +| 1 | 0~3.0 | 빈 석판들이 둥글게 둘러선 방 | +| 2 | 2.4~6.5 | 벽면 가득 장부 선반 | +| 3 | 5.9~10.0 | 선반 절반이 먼지에 덮여 빛바래 있다 | +| 4 | 9.4~14.0 | 하월이 옆으로 비켜서고, 안쪽 문 너머 보랏빛 어둠에 노란 눈 둘 | + +자막: (1.0, 2.6) 삼십 년 동안 쓴 장부들. / (4.2, 2.6) 그 속 곤충의 절반이 죽었다. / (7.4, 2.6) 지키려다, 오히려 잃었다. / (10.6, 2.6) 이제 안쪽으로 가는 길이 열렸다. + +### 13. `vid_fin_shadow` — 14.4s · 텅 빈 들 — **대사 앞(설명)** +뒤 대사(5줄 + 선택지): 세라 "저게 그림자예요. 모습을 계속 바꾸고 있어요." / "전부 그림자가 빼앗은 이름들이에요." / 지문 "'이름을 줘.'" / +세라 "절대 주지 마세요. 울타리 안에 자리를 얻게 돼요." / 지문 "그림자가 다시 묻는다." → [이름을 주지 않는다 / 이름을 준다] +| 샷 | 초 | 그림 | +|---|---|---| +| 1 | 0~2.8 | 색이 빠진 들판, 울타리 맨 끝 빠진 말뚝 자리에 그림자. 주인공과 파트너가 작게 | +| 2 | 2.2~5.8 | 그림자가 사마귀 → 나비 → 반딧불이로 모습을 바꾼다 | +| 3 | 5.2~8.6 | 그림자가 다가와 빈 이름표를 내민다(노란 눈이 크게) | +| 4 | 8.0~11.6 | 「만약」(구름 테두리·흐린 색) — 빈 이름표에 그림자 무늬가 새겨지자 그림자가 울타리 안쪽에서 부풀어 풀밭을 덮는다 | +| 5 | 11.0~14.4 | 다시 지금 — 주인공 뒤로 만난 곤충들이 빛나며 울타리 칸을 채운다. 마지막 한 칸만 남고 주인공이 그림자를 마주 선다 | + +자막: (0.5, 2.2) 울타리의 맨 끝, 마지막 빈칸. / (2.8, 2.6) 그림자는 빼앗은 모습으로 바뀐다. / (5.6, 2.4) 그리고 묻는다. '이름을 줘.' / (8.4, 2.8) 이름을 받으면, 울타리 안에 자리를 얻는다. / (11.4, 2.6) 그러니 절대, 이름을 주지 않는다. + +### 14. `vid_fin_return` — 14.6s · 텅 빈 들 → 색이 돌아온 들 — **대사 앞(최종전 결과 화면 뒤)** +뒤 대사: 세라 "마지막 칸이 메워졌어요!" / "그림자는 밖으로 밀려났어요. 다시 들어올 틈도 없어요." / 지문 "텅 빈 들에 다시 풀벌레 소리가 들린다." / +지문 "남은 자리에 초록 사마귀 한 마리가 앉아 있다." / 세라 "이름을 되찾았네요. 당신이 데려가 주세요." +`BattleWin`은 늘 대기열을 거치고, 대기열은 결과 화면·전투 카메라·모달이 모두 내려간 뒤에만 흐르므로 영상이 결과 화면 위에 뜨지 않는다. +| 샷 | 초 | 그림 | +|---|---|---| +| 1 | 0~2.8 | 그림자에서 빌린 모습들이 빛으로 빠져나온다. 그림자가 쪼그라든다 | +| 2 | 2.2~5.8 | 빛들이 들판으로 흩어지고, 말뚝 이름표가 하나씩 금빛으로 켜진다 | +| 3 | 5.2~8.8 | 작아진 그림자가 울타리 바깥 안개로 밀려나 사라진다 | +| 4 | 8.2~11.8 | 마지막 구멍 자리에 새 말뚝이 서고 이름표가 빛난다 — 들판에 색이 퍼진다 | +| 5 | 11.2~14.6 | 그 자리 풀잎 위 초록 사마귀, 해가 뜬다(자막 없음) | + +자막: (0.5, 2.2) 빼앗긴 이름들이 빠져나온다. / (2.9, 2.6) 이름은 하나씩 주인에게 돌아갔다. / (5.9, 2.6) 그림자는 울타리 밖으로 밀려났다. / (8.9, 2.6) 마지막 구멍까지, 이제 다 메워졌다. + +### 15. `vid_fin_epilogue` — 15.0s · 초원 새벽(1편과 수미상관) +직전: 세라의 후일담 — 하월은 장부의 곤충을 평생 찾아다님, 라온은 어르신을 도우러 초원에, 먹은 숫자 칸 없는 새 장부, "곤충이 사라지면 빈칸은 또 생겨요", "계속 만나요". +| 샷 | 초 | 그림 | +|---|---|---| +| 1 | 0~3.0 | 하월이 들판에서 손을 펴 딱정벌레를 놓아준다 | +| 2 | 2.4~6.0 | 초원에서 라온(팔걸이)과 어르신이 나란히 걷는다 | +| 3 | 5.4~9.0 | 먹의 손이 새 장부에 이름 칸만 쓴다(숫자 칸 없음) | +| 4 | 8.4~12.0 | 꽃 핀 들판 속 빈 석판 하나 | +| 5 | 11.4~15.0 | 새벽 초원, 그물 없이 뻗은 주인공 손에 작은 곤충이 앉는다(자막 없음) | + +자막: (1.0, 2.6) 하월은 곤충을 하나씩 놓아주었다. / (4.0, 2.6) 라온은 어르신 곁으로 돌아갔다. / (7.0, 2.6) 새 장부에는 숫자 칸이 없다. / (10.0, 2.6) 빈칸은 또 생길지도 모른다. +소리: 1편과 같은 새벽 — **이번엔 풀벌레 소리가 있다**(돌아왔다). + +## 4-1. 렌더러 — `Tools/Video/storybook/` + +``` +python -X utf8 Tools/Video/storybook/render.py all # 15편 전부(편당 약 2분) +python -X utf8 Tools/Video/storybook/render.py ch6_wall # 한 편 → StreamingAssets/Video/ch6_wall.mp4 +python -X utf8 Tools/Video/storybook/render.py ch6_wall --sheet --subs # 샷마다 두 장 + 자막(미리보기에만) → Artifacts/storybook-preview/ +python -X utf8 Tools/Video/storybook/render.py ch6_wall --sheet --subs --portrait # 세로 화면(가운데 32%)으로 잘라 본다 +python -X utf8 Tools/Video/storybook/render.py ch6_wall --preview 3.0,9.5 # 그 시각 프레임만 +``` + +| 파일 | 맡는 것 | +|---|---| +| `kit.py` | 그림 부품 — 윤곽 칠(`paint`), 하늘·구름·언덕·풀밭·물·갈대·나무·돌벽·빛줄기, 곤충 9종(`draw_insect`·`InsectSprite`), 그림자(`draw_shadow`·`shadow_insect`·`draw_eyes`), 이름 벽(`NameWall`), 울타리·이름표, 인물 8명(`draw_person`), 손(`draw_hand`), 상자·장부·석판·반짝임, 정적 층 굽기(`bake_layer`·`put_layer`) | +| `sound.py` | 소리 부품 — 뜯는 소리(칼림바·종·실로폰·오르골)·패드·멜로디, 효과음(쿵·휙·부서짐·발소리·책장·긁기·물방울·눈 깜빡), 바탕음(바람·물·새·풀벌레·불·실내), 잔향 | +| `render.py` | 편성(`NAMES`)·디졸브·페이드·종이 결·인코딩(영상 + 소리), 미리보기·시트·세로 크롭. **StoryVideoLibrary.cs를 읽어** 길이가 다르면 멈추고 자막을 미리보기에 얹는다 | +| `v_<이름>.py` | 편 하나 — `DURS`(샷 길이, 합 = 저작 길이)·`SHOTS`·`TINT`·`score(sc)`·`CUES`(라이브러리와 같은 자막 — 다르면 렌더러가 알린다) | +| `bk_v1~v4.py` | 편들이 함께 쓰는 장면 도우미(넷이 나눠 그리며 생긴 것) | + +옛 실루엣 렌더러(`Tools/Video/story_silhouettes.py`·`sil_act1/2.py`)는 비교용으로만 남았고 이제 `Artifacts/story-silhouettes/`에 쓴다 +(StreamingAssets에 쓰면 새 영상을 무음 실루엣으로 덮는다). 실루엣 부품(`silhouette_kit.py`)은 오프닝·꿈 영상과 그림책 키트의 기초 도형이 계속 쓴다. + +## 5. AI 영상으로 바꿀 때 + +1. §4의 샷 표로 **샷 단위** 생성(도구: Veo / Sora / Runway 등) — 프롬프트 접두: + ``` + STYLE: children's picture book illustration in motion, bold dark-brown outlines, bright flat colors, big-eyed cute insects, + paper texture, gentle slow camera, 24fps, 16:9, main subject inside the central third of the frame (portrait-safe). + No text, no letters, no captions, no logos. People only from behind or in profile, never front faces. + ``` + 고를 때 볼 것: 글자가 찍히지 않았는가 / 얼굴이 정면으로 나오지 않았는가 / 피사체가 가운데 32% 안에 있는가. +2. 이어붙이기 + 디졸브 + 페이드아웃 + 소리 + 인코딩(ffmpeg) — 소리는 렌더러가 남기는 `Artifacts/storybook-preview/<이름>.wav`를 그대로 써도 된다 + (사건 시각이 샷 경계에 맞춰져 있다): + ``` + ffmpeg -i s1.mp4 -i s2.mp4 -i ch2_watchers.wav -filter_complex \ + "[0:v]scale=1280:720,fps=24,setsar=1[a];[1:v]scale=1280:720,fps=24,setsar=1[b];\ + [a][b]xfade=transition=dissolve:duration=0.6:offset=2.4[v];[v]fade=t=out:st=9.4:d=0.6[vo]" \ + -map "[vo]" -map 2:a -c:v libx264 -profile:v main -level 4.0 -pix_fmt yuv420p -crf 23 -maxrate 2000k -bufsize 4000k \ + -c:a aac -b:a 96k -movflags +faststart ch2_watchers.mp4 + ``` + `offset` = 앞 샷 길이 − 디졸브 길이. **디졸브는 총 길이를 그만큼 깎는다** — 샷을 디졸브 길이만큼 길게 뽑아 합이 `expectedDuration`과 같게 맞춘다. +3. 길이·크기 확인(`ffprobe -show_entries format=duration:stream=codec_name,profile,width,height`, ≤ 4 MB) → `Assets/StreamingAssets/Video/`에 배치 → `story_lint`. + +## 6. 검증 + +- **정적**: `story_lint`(검사 13 배타·25 실재성·36 대사 앞 영상 줄 수) → `ci_check.py`. +- **단위**: PlayMode 러너 — `StoryVideoLibraryTests`(15편 길이·큐·글자 수·금칙·대사 앞 영상 비트), `StoryPreludeTests`(대사 앞 영상의 콜백이 + 모든 종료 경로에서 정확히 한 번 — 끝·건너뛰기·ESC·오류·준비 초과·재생 초과·비활성·재진입, 체인 순서, `PlayReplay`), `StoryJournalVideoTests`(저널 버튼 자리). + 배치모드에는 디코더가 없어 재생 자체는 못 본다. +- **발화**: `StoryBeatWalkthrough -walkMode campaign` — 재생 중인 영상을 `CloseModal`로 건너뛰며(로그 남김) 대사 앞 영상 → 대사가 실제 종료 경로로 이어지는지 본다. +- **실제 재생(Windows QA 빌드)**: `BattleVisualCaptureBuilder.Build` 뒤 + ``` + Builds/Windows/BattleVisualQA/BattleVisualQA.exe -battleCaptureOut <빈 폴더> -battleScenario story-video [-videoOnly ch6_wall,ch7_fence] \ + -screen-fullscreen 0 -screen-width 1280 -screen-height 720 # 세로는 720×1280으로 한 번 더 + ``` + 진짜 `StoryVideoDirector`로 15편을 차례로 틀어 자막마다 한 장씩 찍고, README에 편마다 재생 여부·첫 프레임까지 걸린 시간·실제 길이를 적는다. + 저널 「▶ 영상」 → 재생 → ESC로 저널 복귀까지 찍는다(`00-journal-*`). 종료 코드 0 = 전부 재생. +- **기기**(subst ASCII 드라이브에서 `AndroidReleaseBuilder.BuildDeviceApkFromCommandLine`): + 1. APK 크기 증분 — 영상 15편 약 30 MB. + 2. 대사 앞 영상 ch6(`ruins_temple` 진입)·ch7(`ruins_underground` 진입): 영상 → (ch7은 「지난 이야기」 카드) → 대사 순서, 소리, 세로 크롭 구도, 자막 타이밍. + 3. 최종전 승리(`fin_seal`): 결과 화면을 오래 띄워 둔 뒤 닫아도 영상 → 대사가 이어지는지. + 4. 건너뛰기 버튼·Back키 → 대사 앞 영상이면 곧바로 대사, 대사 뒤 영상이면 조작 복구. + 5. 재생 중 홈 → 복귀 시 이어서 재생. + 6. 저널 「▶ 영상」 → 재생 → Back키로 저널 복귀. + 7. 파일 하나를 지운 빌드: 경고 1줄 + 대사(또는 다음 비트) 정상. diff --git a/Docs/WorldMapExpansionReview.md b/Docs/WorldMapExpansionReview.md new file mode 100644 index 0000000..ebe4701 --- /dev/null +++ b/Docs/WorldMapExpansionReview.md @@ -0,0 +1,39 @@ +# 전체 맵 연결·이동 검수 — 2026-09-19 + +## 범위 + +13개 본 지역과 공통 서브 지역 진입·복귀 경로로 이전 마을 검수를 확장했다. 기존 레벨·수문장 해금과 지역당 단일 외부 입구 조건을 유지한다. 사용자 저장 데이터의 구조와 진행 기록은 바꾸지 않는다. + +## 개선 + +- `WorldRouteLayout`이 실제 필드 길과 전체 지도의 연결선·입구 좌표를 함께 제공한다. 진행 중 아직 잠긴 다른 지역을 관통하던 중심 직선을 외부 우회 경로로 바꾼다. 경로는 유한한 후보점으로 계산하고 캐시한다. +- 지역의 원형 판정과 다른 사각 바닥을 원형 메시로 맞추고, 겹치는 지역 바닥의 높이를 소폭 분리해 겹침 깜빡임을 줄였다. 중복 직선 길과 고도차 없는 경사 길을 제거했다. +- 연못 진입 길과 강을 직각으로 교차시키고, 다리를 길 방향에 맞췄다. 다리 통행 폭에는 강 충돌벽이 들어오지 않는다. +- 주 경로 위에 놓이는 충돌 소품은 생성 위치에서 회피한다. 전체 조합의 실제 충돌 결과는 별도로 기록한다. +- 전체 지도에서 서브 공간의 좌표 `(2000, 0, 2000)`를 그대로 투영하지 않고 해당 입구를 표시한다. 지도 이동은 서브 세계를 복원한 뒤 목적지를 적용하며 이전 클릭·자동 이동을 취소한다. +- 미니맵에 NPC와 입구를 표시하고, 서브 공간에서 메인 세계의 잘못된 목표 방향을 숨긴다. +- 작은 서브 맵의 안전 좌표 탐색이 외벽 바깥을 선택하지 않게 제한했다. 복귀는 원래 접근 위치를 기준으로 하며 환경 이벤트 재구독·태양 조명 선택·안개 모드를 복원한다. + +## 검증 + +Unity PlayMode 최종 **1103/1103 통과**: `.claude/cache/design-validation/PlayMode-20260919-015942-765.xml` (물 콜라이더·분수 이동·지도 배율 회귀 4건 포함). + +수정 전 전체 지형: `Artifacts/map-20260919-013156-695` (13개 지역 + 마을·강 연결부 2장). 실제 `EnsureGround`와 `VillageBuilder`를 함께 실행하며, Bootstrap을 비활성 상태로 유지해 계정·저장·보상 시스템을 시작하지 않는다. + +실제 경로 충돌 검사는 0.75m 이하 간격으로 플레이어 이동 판정과 같은 높이·반경의 구체를 겹쳐 본다. 지역 잠금은 경로 정책 회귀 테스트가 검사한다. 콜라이더 샘플 검사는 물리적 키 입력이나 전체 캠페인 완주를 대신하지 않는다. + +수정 후 전체 지형: `Artifacts/map-20260919-014813-574` (15장). `route-collisions.tsv`의 13개 연결 경로, 총 5838개 중심선 샘플에서 충돌 장애물 0건. 최초 검사에서 발견한 납작한 물 원통의 캡슐 콜라이더는 제거하고, 정원 분수는 입구 길 옆으로 이동했다. 이 결과는 고정 시드 8173의 정적 지형에 한정하며, 길 전체 폭·NPC·수문장·임의 시드 통과를 보장하지 않는다. + +전후 비교: `Artifacts/map-expansion-comparison.png`. 동일 카메라로 촬영한 실제 지형이며, 구름 위치는 실행 시점에 따라 달라진다. + +저장소 `ci_check.py` 전체 통과 (338개 C# 파일 검사). + +## 실제 지도 UI 검수 + +저장·계정 시스템을 호출하지 않는 Windows 개발 빌드에서 실제 IMGUI를 촬영했다. 지역 해금·NPC·목표·서브 지역 상태는 메모리에 주입한 검수용 상태이며 실제 진입 조작이나 스토리 진행을 재현한 것은 아니다. 최초 캡처에서 경로선의 패널 밖 그리기와 과밀한 이름 표시를 발견했다. UI 배율을 보존하는 로컬 회전 행렬로 경로선·미니맵 화살표를 수정하고, 지역명은 원 내부에 맞추며 세부 명칭은 선택·가리키기 때 표시하도록 정리했다. + +재촬영은 `Artifacts/map-ui-169-corrected` (1280×720)와 `Artifacts/map-ui-43-corrected` (1024×768)에 각각 일반 지도·서브 지역 지도·미니맵 2종으로 보관했다. 경로선이 지도 내부에 연결되고 목표 화살표가 미니맵의 실제 목표 위치에 표시됨을 확인했다. 4:3에서도 패널과 닫기 버튼·범례가 화면 안에 들어온다. 작은 해상도에서 세부 지명 가독성과 모든 입력·노치 조건은 추가 개선·실기기 검수 대상이다. + +## 남은 범위 + +오브젝트 밀도·조명·지역 랜드마크의 최종 아트 방향, 모든 서브 공간의 실제 플레이 영상, Android 실기기 프레임 시간·터치·노치 검증은 별도다. 부감 촬영은 평소 카메라 위의 구름을 포함할 수 있으므로 그 가림을 일반 플레이의 가림으로 단정하지 않는다. diff --git a/Docs/battle_pacing_measure.py b/Docs/battle_pacing_measure.py new file mode 100644 index 0000000..6a20416 --- /dev/null +++ b/Docs/battle_pacing_measure.py @@ -0,0 +1,39 @@ +"""Portable deterministic proxy, not a Unity test or full live-species simulation. +Run: python Docs/battle_pacing_measure.py +Fixtures mirror BattlePacingTests neutral synthetic equal-stat matrix. +""" +import re +import statistics +from pathlib import Path + +constants = Path("Assets/Scripts/Core/GameConstants.cs").read_text(encoding="utf-8-sig") +def value(name): + return float(re.search(r"\b" + name + r" = ([0-9.]+)f?;", constants).group(1)) + +def measure(multiplier): + rounds = [] + for rarity in range(5): + for level in (5, 15, 30, 50): + for power in (18, 30, 45): + hp = 49 + rarity * 18 + level * value("HpPerLevel") + attack = 19 + rarity * 9 + level * 2 + defense = 14 + rarity * 7 + level + ratio = min(value("MaxAtkDefRatio"), max(value("MinAtkDefRatio"), attack / defense)) + player = enemy = hp + count = 0 + while min(player, enemy) > 0 and count < 30: + count += 1 + damage = power + level * value("LevelDamageScale") if count % 2 else round(attack * .7) + enemy -= max(1, round(max(1, round(damage * multiplier)) * ratio)) + if enemy <= 0: + break + damage = (power if count % 2 else 10) + level * value("LevelDamageScale") + player -= max(1, round(max(1, round(damage * multiplier)) * ratio)) + rounds.append(count) + return rounds + +for label, multiplier in (("baseline", 1), ("wild pacing", value("WildDamageMultiplier"))): + rounds = measure(multiplier) + print(label, "n=", len(rounds), "median=", statistics.median(rounds), "min=", min(rounds), "max=", max(rounds)) + print({n: rounds.count(n) for n in sorted(set(rounds))}) +assert 4 <= statistics.median(measure(value("WildDamageMultiplier"))) <= 6 diff --git a/ProjectSettings/GraphicsSettings.asset b/ProjectSettings/GraphicsSettings.asset index 1a13e5a..d7e49a2 100644 --- a/ProjectSettings/GraphicsSettings.asset +++ b/ProjectSettings/GraphicsSettings.asset @@ -34,7 +34,6 @@ GraphicsSettings: - {fileID: 10753, guid: 0000000000000000f000000000000000, type: 0} - {fileID: 10770, guid: 0000000000000000f000000000000000, type: 0} - {fileID: 10783, guid: 0000000000000000f000000000000000, type: 0} - - {fileID: 46, guid: 0000000000000000f000000000000000, type: 0} - {fileID: 103, guid: 0000000000000000f000000000000000, type: 0} m_PreloadedShaders: [] m_PreloadShadersBatchTimeLimit: -1 @@ -46,7 +45,7 @@ GraphicsSettings: m_DefaultMobileRenderingPath: 1 m_TierSettings: [] m_LightmapStripping: 0 - m_FogStripping: 0 + m_FogStripping: 1 m_InstancingStripping: 0 m_BrgStripping: 0 m_LightmapKeepPlain: 1 diff --git a/Tools/Badges/guardian_badges.py b/Tools/Badges/guardian_badges.py new file mode 100644 index 0000000..b9724c7 --- /dev/null +++ b/Tools/Badges/guardian_badges.py @@ -0,0 +1,750 @@ +"""수문장 배지 렌더러 — 13개 배지와 획득 연출용 보조 텍스처를 PNG로 굽는다. + + python -X utf8 Tools/Badges/guardian_badges.py [--sheet] + +산출: Assets/Resources/UI/Badges/badge_.png (256×256 RGBA) 13장 + + badge_rays.png · badge_glow.png · badge_sparkle.png (획득 연출). +--sheet면 Artifacts/badges-preview.png에 밝은/어두운 바탕 대조 시트도 떨군다. + +**배지 목록의 단일 출처는 코드다**(`Assets/Scripts/Core/GuardianBadges.cs`). 여기 ID가 그쪽과 어긋나면 +`GuardianBadgeTests`가 "그림 없음"으로 잡는다 — 리전을 늘리면 두 곳을 함께 고친다. + +그리는 법: 1024 캔버스에 도형 마스크를 그리고(4배 슈퍼샘플), 마스크를 흐린 높이맵의 기울기로 +빛을 먹여 금속 테두리·문양을 양각으로 만든다. 법랑(가운데 색 면)은 동심원 무늬로 결을 준다. +numpy + PIL만 쓴다(scipy 없음 — 침식은 "흐림 후 문턱"으로 근사한다). + +알파 가장자리: Unity 기본 임포트는 alphaIsTransparency가 꺼져 있어 투명 픽셀의 RGB(검정)가 +쌍선형 필터로 번져 검은 테두리가 생긴다. 저장 직전에 가장자리 색을 투명 영역으로 번져 둔다. +""" +import math +import os +import sys + +import numpy as np +from PIL import Image, ImageDraw, ImageFilter + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +OUT_DIR = os.path.join(ROOT, "Assets", "Resources", "UI", "Badges") +SHEET = os.path.join(ROOT, "Artifacts", "badges-preview.png") + +S = 1024 # 작업 캔버스(슈퍼샘플) +C = S / 2 +FINAL = 256 + +# ── 금속 팔레트 (위 → 아래) ── +GOLD = ((1.00, 0.89, 0.55), (0.62, 0.42, 0.14)) +SILVER = ((0.96, 0.97, 1.00), (0.48, 0.53, 0.62)) +IRON = ((0.62, 0.58, 0.70), (0.16, 0.14, 0.20)) + + +# ────────────────────────── 마스크 도구 ────────────────────────── + +def canvas(): + img = Image.new("L", (S, S), 0) + return img, ImageDraw.Draw(img) + + +def arr(img): + return np.asarray(img, dtype=np.float32) / 255.0 + + +def to_img(a): + return Image.fromarray(np.clip(a * 255.0 + 0.5, 0, 255).astype(np.uint8), "L") + + +def blur(a, r): + if r <= 0: + return a + return arr(to_img(a).filter(ImageFilter.GaussianBlur(r))) + + +def smooth(a, lo, hi): + t = np.clip((a - lo) / (hi - lo), 0.0, 1.0) + return t * t * (3.0 - 2.0 * t) + + +def rounded(a, r): + """모서리를 둥글린다 — 흐린 뒤 반값 문턱.""" + return smooth(blur(a, r), 0.42, 0.58) + + +def erode(a, d): + """d픽셀 안쪽으로 줄인다(근사). 직선 가장자리에서 거리 d의 가우시안 값 ≈ 0.84.""" + return smooth(blur(a, d), 0.80, 0.88) + + +def dilate(a, d): + return smooth(blur(a, d), 0.12, 0.20) + + +def pts_ellipse(cx, cy, rx, ry, rot=0.0, n=120): + cr, sr = math.cos(rot), math.sin(rot) + out = [] + for i in range(n): + t = 2 * math.pi * i / n + x, y = rx * math.cos(t), ry * math.sin(t) + out.append((cx + x * cr - y * sr, cy + x * sr + y * cr)) + return out + + +def ellipse(d, cx, cy, rx, ry, rot=0.0, fill=255): + d.polygon(pts_ellipse(cx, cy, rx, ry, math.radians(rot)), fill=fill) + + +def circle(d, cx, cy, r, fill=255): + d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=fill) + + +def bezier(p0, p1, p2, p3, n=48): + out = [] + for i in range(n + 1): + t = i / n + u = 1 - t + x = u ** 3 * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t ** 3 * p3[0] + y = u ** 3 * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t ** 3 * p3[1] + out.append((x, y)) + return out + + +def stroke(d, pts, w, fill=255): + """둥근 끝 선 — PIL 선은 끝이 각져서 양 끝에 원을 얹는다.""" + d.line(pts, fill=fill, width=int(w), joint="curve") + r = w / 2 + for p in (pts[0], pts[-1]): + circle(d, p[0], p[1], r, fill) + + +def taper(d, pts, w0, w1, fill=255): + """굵기가 줄어드는 선 — 뿔·꼬리.""" + n = len(pts) - 1 + for i in range(n): + w = w0 + (w1 - w0) * (i / max(1, n - 1)) + stroke(d, [pts[i], pts[i + 1]], max(2.0, w), fill) + + +def mirror(fn): + """좌우 대칭 문양 — fn(d, sign)을 양쪽으로.""" + def both(d): + fn(d, -1) + fn(d, 1) + return both + + +# ────────────────────────── 겉모양 13종 ────────────────────────── + +def shape_leaf(): + img, d = canvas() + a, b = 300.0, 430.0 + R = (a * a + b * b) / (2 * a) + off = R - a + m1, d1 = canvas() + circle(d1, C - off, C, R) + m2, d2 = canvas() + circle(d2, C + off, C, R) + lens = np.minimum(arr(m1), arr(m2)) + return rounded(lens, 10) + + +def shape_drop(): + img, d = canvas() + oy, R = C + 80, 330 + circle(d, C, oy, R) + d.polygon([(C, C - 425), (C + 248, oy - 218), (C - 248, oy - 218)], fill=255) + return rounded(arr(img), 14) + + +def shape_shield(): + img, d = canvas() + top, side = C - 390, C + 40 + pts = [(C - 350, top), (C - 120, top - 18), (C, top + 12), (C + 120, top - 18), (C + 350, top), (C + 350, side)] + pts += bezier((C + 350, side), (C + 350, C + 250), (C + 150, C + 360), (C, C + 425))[1:] + pts += bezier((C, C + 425), (C - 150, C + 360), (C - 350, C + 250), (C - 350, side))[1:] + d.polygon(pts, fill=255) + return rounded(arr(img), 16) + + +def shape_flower(): + img, d = canvas() + for k in range(5): + t = math.radians(-90 + 72 * k) + circle(d, C + 235 * math.cos(t), C + 235 * math.sin(t), 190) + circle(d, C, C, 270) + return rounded(arr(img), 12) + + +def shape_cloud(): + img, d = canvas() + for cx, cy, r in ((C - 210, C + 40, 210), (C + 210, C + 40, 210), (C - 90, C - 130, 250), + (C + 140, C - 120, 230), (C, C + 120, 270)): + circle(d, cx, cy, r) + d.rectangle([C - 330, C + 40, C + 330, C + 330], fill=255) + return rounded(arr(img), 30) + + +def shape_mountain(): + img, d = canvas() + d.polygon([(C - 405, C + 350), (C - 405, C + 60), (C - 60, C - 425), (C + 130, C - 150), + (C + 245, C - 270), (C + 405, C + 30), (C + 405, C + 350)], fill=255) + return rounded(arr(img), 26) + + +def shape_sun(): + img, d = canvas() + circle(d, C, C, 305) + for k in range(16): + t = math.radians(22.5 * k - 90) + tip = 425 if k % 2 == 0 else 370 + w = math.radians(8.5) + d.polygon([(C + tip * math.cos(t), C + tip * math.sin(t)), + (C + 290 * math.cos(t + w), C + 290 * math.sin(t + w)), + (C + 290 * math.cos(t - w), C + 290 * math.sin(t - w))], fill=255) + return rounded(arr(img), 8) + + +def shape_circle(): + img, d = canvas() + circle(d, C, C, 410) + return arr(img) + + +def shape_hexagon(): + img, d = canvas() + d.polygon([(C + 425 * math.cos(math.radians(60 * k - 90)), C + 425 * math.sin(math.radians(60 * k - 90))) + for k in range(6)], fill=255) + return rounded(arr(img), 34) + + +def shape_snowflake(): + img, d = canvas() + for k in range(6): + t = math.radians(60 * k - 90) + w = math.radians(17) + d.polygon([(C + 432 * math.cos(t), C + 432 * math.sin(t)), + (C + 250 * math.cos(t + w), C + 250 * math.sin(t + w)), + (C + 250 * math.cos(t - w), C + 250 * math.sin(t - w))], fill=255) + circle(d, C, C, 318) + return rounded(arr(img), 12) + + +def shape_flame(): + img, d = canvas() + circle(d, C, C + 120, 300) + pts = [(C - 300, C + 120)] + pts += bezier((C - 300, C + 120), (C - 330, C - 60), (C - 240, C - 150), (C - 190, C - 250))[1:] + pts += bezier((C - 190, C - 250), (C - 150, C - 160), (C - 110, C - 150), (C - 80, C - 170))[1:] + pts += bezier((C - 80, C - 170), (C - 90, C - 300), (C - 20, C - 390), (C + 20, C - 430))[1:] + pts += bezier((C + 20, C - 430), (C + 40, C - 330), (C + 110, C - 250), (C + 150, C - 240))[1:] + pts += bezier((C + 150, C - 240), (C + 170, C - 280), (C + 190, C - 300), (C + 230, C - 330))[1:] + pts += bezier((C + 230, C - 330), (C + 310, C - 180), (C + 330, C - 20), (C + 300, C + 120))[1:] + d.polygon(pts, fill=255) + return rounded(arr(img), 16) + + +def shape_crown(): + img, d = canvas() + for k in range(5): + t = math.radians(-160 + 35 * k) + circle(d, C + 250 * math.cos(t), C - 10 + 250 * math.sin(t), 175) + circle(d, C, C + 20, 300) + d.rectangle([C - 360, C + 20, C + 360, C + 250], fill=255) + d.rounded_rectangle([C - 360, C + 60, C + 360, C + 380], radius=120, fill=255) + return rounded(arr(img), 20) + + +def shape_squircle(): + img, d = canvas() + d.rounded_rectangle([C - 385, C - 385, C + 385, C + 385], radius=150, fill=255) + return rounded(arr(img), 8) + + +# ────────────────────────── 문양 13종 ────────────────────────── + +def emb_mantis(d, cy=0.0, scale=1.0): + k = scale + + def P(x, y): + return (C + x * k, C + (y + cy) * k) + + # 머리(역삼각) + 겹눈 + d.polygon([P(-92, -228), P(92, -228), P(0, -120)], fill=255) + circle(d, *P(-86, -232), 40 * k) + circle(d, *P(86, -232), 40 * k) + # 더듬이 + stroke(d, bezier(P(-26, -260), P(-50, -330), P(-110, -360), P(-150, -370)), 12 * k) + stroke(d, bezier(P(26, -260), P(50, -330), P(110, -360), P(150, -370)), 12 * k) + # 가슴 · 배 + stroke(d, [P(0, -130), P(0, 40)], 46 * k) + d.polygon(pts_ellipse(*P(0, 175), 72 * k, 150 * k), fill=255) + # 접은 낫다리(겉) + for s in (-1, 1): + stroke(d, [P(18 * s, -80), P(128 * s, -10)], 34 * k) + stroke(d, [P(128 * s, -10), P(70 * s, -150)], 30 * k) + d.polygon([P(70 * s, -150), P(40 * s, -205), P(92 * s, -160)], fill=255) + # 걷는 다리 + stroke(d, [P(12 * s, 10), P(120 * s, 90), P(150 * s, 190)], 14 * k) + stroke(d, [P(12 * s, 40), P(100 * s, 170), P(118 * s, 280)], 14 * k) + + +def emb_dragonfly(d): + y0 = 40 + circle(d, C, C - 175 + y0, 50) + circle(d, C - 40, C - 190 + y0, 34) + circle(d, C + 40, C - 190 + y0, 34) + stroke(d, [(C, C - 140 + y0), (C, C - 40 + y0)], 58) + for i in range(7): + y = C - 30 + y0 + i * 44 + w = 42 - i * 3.5 + stroke(d, [(C, y), (C, y + 40)], w) + for s in (-1, 1): + ellipse(d, C + s * 140, C - 95 + y0, 150, 44, rot=-10 * s) + ellipse(d, C + s * 130, C - 20 + y0, 132, 40, rot=12 * s) + + +def emb_beetle(d): + # 딱지날개 · 앞가슴 · 머리 + 긴 윗뿔 + ellipse(d, C, C + 110, 150, 185) + ellipse(d, C, C - 95, 118, 82) + ellipse(d, C, C - 170, 58, 40) + taper(d, bezier((C, C - 150), (C - 10, C - 250), (C + 40, C - 330), (C + 95, C - 360), n=24), 58, 16) + stroke(d, [(C + 95, C - 360), (C + 110, C - 330)], 16) + for s in (-1, 1): + stroke(d, [(C + 100 * s, C - 60), (C + 205 * s, C - 120), (C + 235 * s, C - 200)], 18) + stroke(d, [(C + 120 * s, C + 40), (C + 235 * s, C + 50), (C + 265 * s, C + 120)], 18) + stroke(d, [(C + 120 * s, C + 170), (C + 215 * s, C + 250), (C + 225 * s, C + 330)], 18) + + +def emb_beetle_grooves(d): + d.line([(C, C - 60), (C, C + 290)], fill=255, width=10) + d.line([(C - 110, C - 30), (C + 110, C - 30)], fill=255, width=8) + + +def emb_butterfly(d, y0=0.0, s_=1.0, tails=True): + def P(x, y): + return (C + x * s_, C + (y + y0) * s_) + + for s in (-1, 1): + d.polygon(pts_ellipse(*P(135 * s, -70), 150 * s_, 105 * s_, math.radians(-32 * s)), fill=255) + d.polygon(pts_ellipse(*P(100 * s, 85), 102 * s_, 86 * s_, math.radians(28 * s)), fill=255) + if tails: + stroke(d, [P(118 * s, 140), P(140 * s, 250)], 24 * s_) + stroke(d, bezier(P(8 * s, -150), P(20 * s, -220), P(50 * s, -260), P(80 * s, -280)), 10 * s_) + circle(d, *P(82 * s, -282), 16 * s_) + stroke(d, [P(0, -140), P(0, 160)], 36 * s_) + circle(d, *P(0, -160), 30 * s_) + + +def emb_butterfly_veins(d, y0=0.0, s_=1.0): + def P(x, y): + return (C + x * s_, C + (y + y0) * s_) + for s in (-1, 1): + d.line([P(30 * s, -60), P(220 * s, -150)], fill=255, width=int(8 * s_)) + d.line([P(30 * s, -40), P(200 * s, -20)], fill=255, width=int(8 * s_)) + d.line([P(30 * s, 40), P(150 * s, 120)], fill=255, width=int(8 * s_)) + + +def emb_swamp(d): + emb_mantis_head(d, -95) + for i, y in enumerate((C + 45, C + 130, C + 215)): + amp, span = 22, 250 - i * 30 + pts = [(C - span + 2 * span * t / 60, y + amp * math.sin(t / 60 * math.pi * 3 + i)) for t in range(61)] + stroke(d, pts, 30) + + +def emb_mantis_head(d, y0): + # 사마귀 머리 정면 — 넓은 역삼각 + 원뿔 겹눈이 위로 솟고, 입 쪽은 좁고 뾰족하다. + d.polygon([(C - 175, C - 40 + y0), (C - 60, C - 70 + y0), (C + 60, C - 70 + y0), (C + 175, C - 40 + y0), + (C + 40, C + 110 + y0), (C, C + 150 + y0), (C - 40, C + 110 + y0)], fill=255) + for s in (-1, 1): + ellipse(d, C + 150 * s, C - 80 + y0, 52, 82, rot=28 * s) + stroke(d, bezier((C + 30 * s, C - 70 + y0), (C + 50 * s, C - 170 + y0), (C + 120 * s, C - 230 + y0), + (C + 200 * s, C - 250 + y0)), 12) + + +def emb_swamp_eyes(d): + # 겹눈의 세로 동공 — 안개 속에서 이쪽을 보는 눈. + for s in (-1, 1): + ellipse(d, C + 150 * s, C - 172, 12, 40, rot=28 * s) + d.line([(C - 60, C - 70), (C, C + 20), (C + 60, C - 70)], fill=255, width=8) + + +def emb_moth(d, y0=0.0, s_=1.0): + def P(x, y): + return (C + x * s_, C + (y + y0) * s_) + for s in (-1, 1): + d.polygon(pts_ellipse(*P(165 * s, -40), 185 * s_, 118 * s_, math.radians(-14 * s)), fill=255) + d.polygon(pts_ellipse(*P(118 * s, 88), 128 * s_, 102 * s_, math.radians(22 * s)), fill=255) + # 갈고리 날개 끝(아틀라스) + d.polygon(pts_ellipse(*P(318 * s, -92), 42 * s_, 34 * s_, math.radians(-30 * s)), fill=255) + # 깃털 더듬이 + d.polygon(pts_ellipse(*P(62 * s, -168), 22 * s_, 62 * s_, math.radians(38 * s)), fill=255) + stroke(d, [P(0, -135), P(0, 150)], 44 * s_) + circle(d, *P(0, -145), 32 * s_) + + +def emb_moth_eyes(d, y0=0.0, s_=1.0): + def P(x, y): + return (C + x * s_, C + (y + y0) * s_) + for s in (-1, 1): + circle(d, *P(175 * s, -40), 34 * s_) + circle(d, *P(120 * s, 92), 24 * s_) + + +def emb_scarab(d): + ellipse(d, C, C + 110, 128, 150) + ellipse(d, C, C - 40, 112, 66) + d.pieslice([C - 70, C - 150, C + 70, C - 30], 180, 360, fill=255) + for k in range(-2, 3): + d.polygon([(C + k * 28 - 12, C - 88), (C + k * 28 + 12, C - 88), (C + k * 28, C - 122)], fill=255) + circle(d, C, C - 220, 62) + for s in (-1, 1): + stroke(d, [(C + 60 * s, C - 80), (C + 130 * s, C - 150), (C + 58 * s, C - 225)], 20) + stroke(d, [(C + 110 * s, C + 20), (C + 210 * s, C + 20), (C + 238 * s, C + 90)], 18) + stroke(d, [(C + 110 * s, C + 150), (C + 205 * s, C + 220), (C + 205 * s, C + 300)], 18) + + +def emb_scarab_grooves(d): + d.line([(C, C - 10), (C, C + 255)], fill=255, width=10) + circle(d, C, C - 220, 36) + + +def emb_scythe(d): + img, dd = canvas() + circle(dd, C + 30, C + 10, 250) + cut, dc = canvas() + circle(dc, C + 125, C - 45, 232) + blade = np.clip(arr(img) - arr(cut), 0, 1) + d.bitmap((0, 0), to_img(blade), fill=255) + # 이빨 세 개 + for t in (200, 235, 270): + a = math.radians(t) + x, y = C + 30 + 250 * math.cos(a), C + 10 + 250 * math.sin(a) + d.polygon([(x, y), (x + 34 * math.cos(a + 1.9), y + 34 * math.sin(a + 1.9)), + (x + 40 * math.cos(a), y + 40 * math.sin(a))], fill=255) + stroke(d, [(C + 60, C + 250), (C + 150, C + 150)], 44) + + +def emb_hornet(d, y0=0.0, s_=1.0): + def P(x, y): + return (C + x * s_, C + (y + y0) * s_) + circle(d, *P(0, -205), 56 * s_) + d.polygon(pts_ellipse(*P(0, -108), 78 * s_, 70 * s_), fill=255) + d.polygon(pts_ellipse(*P(0, 110), 98 * s_, 155 * s_), fill=255) + d.polygon([P(-26, 250), P(26, 250), P(0, 318)], fill=255) + for s in (-1, 1): + d.polygon(pts_ellipse(*P(145 * s, -95), 150 * s_, 42 * s_, math.radians(-28 * s)), fill=255) + d.polygon(pts_ellipse(*P(130 * s, -40), 118 * s_, 34 * s_, math.radians(-10 * s)), fill=255) + stroke(d, [P(20 * s, -250), P(60 * s, -300), P(98 * s, -300)], 12 * s_) + stroke(d, [P(50 * s, -70), P(150 * s, 20), P(170 * s, 110)], 14 * s_) + + +def emb_hornet_stripes(d, y0=0.0, s_=1.0): + def P(x, y): + return (C + x * s_, C + (y + y0) * s_) + for yy in (40, 105, 170): + d.line([P(-110, yy), P(110, yy)], fill=255, width=int(20 * s_)) + + +def emb_aurora(d): + emb_moth(d, 60, 0.78) + for i, r in enumerate((255, 300)): + box = [C - r, C + 40 - r, C + r, C + 40 + r] + d.arc(box, 205, 335, fill=255, width=26) + + +def emb_ember(d): + # 용암말벌 — 날개가 옆으로 뻗은 불꽃 두 갈래다(위로 세우면 박쥐처럼 읽힌다). + for s in (-1, 1): + pts = [(C + 30 * s, C - 40)] + pts += bezier((C + 30 * s, C - 40), (C + 90 * s, C - 120), (C + 180 * s, C - 170), (C + 275 * s, C - 195))[1:] + pts += bezier((C + 275 * s, C - 195), (C + 235 * s, C - 150), (C + 215 * s, C - 128), (C + 205 * s, C - 108))[1:] + pts += bezier((C + 205 * s, C - 108), (C + 235 * s, C - 98), (C + 265 * s, C - 78), (C + 290 * s, C - 38))[1:] + pts += bezier((C + 290 * s, C - 38), (C + 220 * s, C - 18), (C + 120 * s, C + 12), (C + 30 * s, C + 12))[1:] + d.polygon(pts, fill=255) + d.polygon(pts_ellipse(C, C + 105, 84, 130), fill=255) + d.polygon([(C - 26, C + 215), (C + 26, C + 215), (C, C + 305)], fill=255) + ellipse(d, C, C - 10, 62, 56) + circle(d, C, C - 95, 50) + for s in (-1, 1): + stroke(d, bezier((C + 22 * s, C - 130), (C + 40 * s, C - 200), (C + 80 * s, C - 230), (C + 120 * s, C - 232)), 11) + stroke(d, [(C + 24 * s, C - 60), (C + 44 * s, C - 38)], 12) + for x, y, r in ((C - 150, C + 170, 18), (C + 170, C + 140, 14), (C + 120, C + 230, 11), (C - 95, C + 250, 12)): + d.polygon([(x, y - r * 1.6), (x + r, y), (x, y + r * 1.6), (x - r, y)], fill=255) + + +def emb_ember_stripes(d): + for yy in (60, 115, 170): + d.line([(C - 95, C + yy), (C + 95, C + yy)], fill=255, width=16) + for s in (-1, 1): + circle(d, C + 26 * s, C - 105, 12) + + +def emb_worldtree(d): + # 몸통이 곧 나무 줄기 — 뿌리로 내려간다. 날개는 잎 모양. + stroke(d, [(C, C - 170), (C, C + 230)], 46) + circle(d, C, C - 190, 32) + for s in (-1, 1): + stroke(d, bezier((C, C + 200), (C + 30 * s, C + 250), (C + 90 * s, C + 270), (C + 150 * s, C + 300)), 20) + leaf(d, C + 165 * s, C - 105, 165, 84, -32 * s) + leaf(d, C + 135 * s, C + 85, 118, 64, 34 * s) + stroke(d, bezier((C + 8 * s, C - 210), (C + 20 * s, C - 270), (C + 60 * s, C - 300), (C + 95 * s, C - 305)), 10) + stroke(d, [(C, C + 220), (C, C + 305)], 20) + + +def leaf(d, cx, cy, rx, ry, rot): + # 뾰족한 잎 — 두 원의 교집합을 회전 + img, dd = canvas() + a, b = ry, rx + R = (a * a + b * b) / (2 * a) + off = R - a + m1, d1 = canvas() + circle(d1, C, C - off, R) + m2, d2 = canvas() + circle(d2, C, C + off, R) + lens = to_img(np.minimum(arr(m1), arr(m2))) + lens = lens.rotate(rot, resample=Image.Resampling.BICUBIC, center=(C, C), translate=(cx - C, cy - C)) + d.bitmap((0, 0), lens, fill=255) + + +def emb_worldtree_veins(d): + # PIL의 rotate는 반시계 — 잎 축 방향은 (cos, -sin)이다. + for s in (-1, 1): + for cx, cy, half, deg in ((C + 165 * s, C - 105, 140, -32 * s), (C + 135 * s, C + 85, 96, 34 * s)): + a = math.radians(deg) + ux, uy = math.cos(a), -math.sin(a) + d.line([(cx - half * ux, cy - half * uy), (cx + half * ux, cy + half * uy)], fill=255, width=8) + for t in (-0.45, 0.0, 0.45): + bx, by = cx + t * half * ux, cy + t * half * uy + d.line([(bx, by), (bx + 0.32 * half * (ux * 0.6 - uy), by + 0.32 * half * (uy * 0.6 + ux))], fill=255, width=6) + d.line([(bx, by), (bx + 0.32 * half * (ux * 0.6 + uy), by + 0.32 * half * (uy * 0.6 - ux))], fill=255, width=6) + + +def emb_blank(d): + # 빈칸 — 점선 네모. 안은 비어 있다. + h = 165 + seg = 2 * h / 5 + for i in range(5): + if i % 2 == 1: + continue + a, b = -h + i * seg, -h + (i + 1) * seg + for (x0, y0, x1, y1) in ((a, -h, b, -h), (a, h, b, h), (-h, a, -h, b), (h, a, h, b)): + stroke(d, [(C + x0, C + y0), (C + x1, C + y1)], 26) + + +# ────────────────────────── 배지 표 ────────────────────────── +# (regionId, 겉모양, 금속, 법랑색, 문양, 홈(음각), 테 두께) +BADGES = [ + ("meadow", shape_leaf, GOLD, (0.34, 0.70, 0.28), lambda d: emb_mantis(d, 30, 0.78), None, 34), + ("pond", shape_drop, GOLD, (0.18, 0.50, 0.90), emb_dragonfly, None, 36), + ("forest", shape_shield, GOLD, (0.15, 0.42, 0.20), emb_beetle, emb_beetle_grooves, 38), + ("garden", shape_flower, GOLD, (0.92, 0.46, 0.63), lambda d: emb_butterfly(d, 30, 0.82), lambda d: emb_butterfly_veins(d, 30, 0.82), 34), + ("swamp", shape_cloud, GOLD, (0.24, 0.40, 0.34), emb_swamp, emb_swamp_eyes, 38), + ("mountain", shape_mountain, GOLD, (0.56, 0.46, 0.38), lambda d: emb_moth(d, 110, 0.86), lambda d: emb_moth_eyes(d, 110, 0.86), 36), + ("ruins", shape_sun, GOLD, (0.14, 0.28, 0.64), emb_scarab, emb_scarab_grooves, 34), + ("hollow", shape_circle, SILVER, (0.17, 0.17, 0.19), emb_scythe, None, 44), + ("dunes", shape_hexagon, SILVER, (0.86, 0.64, 0.28), lambda d: emb_hornet(d, 0, 0.9), lambda d: emb_hornet_stripes(d, 0, 0.9), 38), + ("frostline", shape_snowflake, SILVER, (0.50, 0.76, 0.92), emb_aurora, lambda d: emb_moth_eyes(d, 60, 0.78), 32), + ("emberfall", shape_flame, SILVER, (0.78, 0.24, 0.13), emb_ember, emb_ember_stripes, 34), + ("canopy", shape_crown, SILVER, (0.22, 0.58, 0.34), emb_worldtree, emb_worldtree_veins, 36), + ("nameless", shape_squircle, IRON, (0.12, 0.10, 0.16), emb_blank, None, 40), +] + + +# ────────────────────────── 조명 ────────────────────────── + +L = np.array([-0.52, -0.66, 0.54], dtype=np.float32) +L /= np.linalg.norm(L) +H = L + np.array([0, 0, 1], dtype=np.float32) +H /= np.linalg.norm(H) +YY, XX = np.mgrid[0:S, 0:S].astype(np.float32) +RR = np.sqrt((XX - C) ** 2 + (YY - C) ** 2) + + +def lighting(height, strength): + gy, gx = np.gradient(height) + nx, ny, nz = -gx * strength, -gy * strength, np.ones_like(height) + inv = 1.0 / np.sqrt(nx * nx + ny * ny + nz * nz) + nx, ny, nz = nx * inv, ny * inv, nz * inv + lam = np.clip(nx * L[0] + ny * L[1] + nz * L[2], 0, 1) + spec = np.clip(nx * H[0] + ny * H[1] + nz * H[2], 0, 1) ** 36 + return lam, spec + + +def metal(palette, lam, spec, tint=1.0): + top, bot = np.array(palette[0]), np.array(palette[1]) + t = np.clip((YY - (C - 430)) / 860.0, 0, 1)[..., None] + base = top * (1 - t) + bot * t + # 금속은 명암 폭이 넓다 — 어두운 쪽을 깊게, 밝은 쪽은 흰 하이라이트로. + col = base * (0.28 + 0.95 * lam[..., None]) * tint + spec[..., None] * 0.85 + return np.clip(col, 0, 1) + + +def render(region, shape_fn, pal, enamel, emb_fn, groove_fn, rim): + outer = shape_fn() + inner = erode(outer, rim) + rim_band = np.clip(outer - inner, 0, 1) + + # 문양 마스크 — 법랑 안쪽 여백 안으로 자른다. + em_img, ed = canvas() + emb_fn(ed) + emblem = arr(em_img) + safe = erode(inner, 18) + emblem = np.minimum(rounded(emblem, 3), safe) + groove = np.zeros_like(emblem) + if groove_fn is not None: + g_img, gd = canvas() + groove_fn(gd) + groove = np.minimum(blur(arr(g_img), 2), emblem) + + rgb = np.zeros((S, S, 3), np.float32) + alpha = np.zeros((S, S), np.float32) + + # 1) 그림자 + sh = blur(np.roll(np.roll(outer, 16, axis=0), 6, axis=1), 22) + alpha = sh * 0.55 + + # 2) 테두리 금속 — 띠를 흐린 높이맵. 바깥 가장자리 쪽이 둥글게 솟는다. + h_rim = blur(rim_band, rim * 0.45) + blur(outer, 6) * 0.35 + lam, spec = lighting(h_rim, 60.0) + rim_col = metal(pal, lam, spec) + + # 3) 법랑 — 동심원 결 + 위 밝고 아래 어두운 기울기 + 테가 드리운 안쪽 그림자 + en = np.array(enamel, np.float32) + grad = 1.12 - 0.30 * np.clip((YY - (C - 400)) / 800.0, 0, 1) + guil = 1.0 + 0.045 * np.sin(RR * 0.16) * smooth(RR, 40, 120) + vign = 1.0 - 0.22 * smooth(RR, 180, 420) + en_col = en[None, None, :] * (grad * guil * vign)[..., None] + inner_sh = blur(np.roll(np.roll(1 - inner, 14, axis=0), 10, axis=1), 12) * inner + en_col *= (1 - 0.55 * inner_sh)[..., None] + + # 4) 문양 금속 — 문양을 흐린 높이맵. 음각 홈은 높이를 깎는다. + h_em = blur(emblem, 11) + blur(emblem, 4) * 0.5 - groove * 0.7 + lam_e, spec_e = lighting(h_em, 70.0) + em_col = metal(pal, lam_e, spec_e, tint=1.06) + em_sh = blur(np.roll(np.roll(emblem, 9, axis=0), 6, axis=1), 6) * inner * (1 - emblem) + en_col *= (1 - 0.5 * em_sh)[..., None] + + # 합성 (아래 → 위) + face = inner[..., None] * en_col + (1 - inner[..., None]) * rim_col + face = emblem[..., None] * em_col + (1 - emblem[..., None]) * face + + # 5) 유광 — 위쪽에 넓고 흐린 흰 반사 + gloss_img, gd2 = canvas() + ellipse(gd2, C - 70, C - 190, 330, 210, rot=-18) + gloss = blur(arr(gloss_img), 40) * outer * np.clip(1.0 - (YY - (C - 420)) / 700.0, 0, 1) + face = face + gloss[..., None] * 0.20 + + rgb = face + a_face = outer + out_a = a_face + alpha * (1 - a_face) + out_rgb = rgb * (a_face / np.maximum(out_a, 1e-4))[..., None] # 그림자는 검정 + return np.clip(out_rgb, 0, 1), np.clip(out_a, 0, 1) + + +def downsample(rgb, a, size=FINAL): + """프리멀티플라이 상태로 줄여야 가장자리에 검은 띠가 안 생긴다.""" + pm = np.concatenate([rgb * a[..., None], a[..., None]], axis=2) + img = Image.fromarray(np.clip(pm * 255 + 0.5, 0, 255).astype(np.uint8), "RGBA") + small = np.asarray(img.resize((size, size), Image.Resampling.LANCZOS), np.float32) / 255.0 + a2 = small[..., 3] + rgb2 = small[..., :3] / np.maximum(a2[..., None], 1e-4) + return np.clip(rgb2, 0, 1), a2 + + +def bleed(rgb, a, passes=6): + """투명 영역 RGB를 가장자리 색으로 채운다(쌍선형 필터의 검은 번짐 방지).""" + rgb = rgb.copy() + known = a > 0.02 + for i in range(passes): + r = 2 ** i + w = known.astype(np.float32) + acc = np.stack([blur_f(rgb[..., c] * w, r) for c in range(3)], axis=2) + ww = blur_f(w, r) + fill = acc / np.maximum(ww[..., None], 1e-5) + take = (~known) & (ww > 1e-4) + rgb[take] = fill[take] + known = known | take + return rgb + + +def blur_f(a, r): + # 0..1 범위 밖 값(가중 합)도 흐려야 해서 16비트 대신 float 박스블러 3회로 가우시안 근사 + out = a + k = max(1, int(r)) + for _ in range(3): + c = np.cumsum(np.pad(out, ((k + 1, k), (0, 0)), mode="edge"), axis=0) + out = (c[2 * k + 1:] - c[:-2 * k - 1]) / (2 * k + 1) + c = np.cumsum(np.pad(out, ((0, 0), (k + 1, k)), mode="edge"), axis=1) + out = (c[:, 2 * k + 1:] - c[:, :-2 * k - 1]) / (2 * k + 1) + return out + + +def save_rgba(path, rgb, a): + rgb = bleed(rgb, a) + out = np.concatenate([rgb, a[..., None]], axis=2) + Image.fromarray(np.clip(out * 255 + 0.5, 0, 255).astype(np.uint8), "RGBA").save(path, optimize=True) + + +# ────────────────────────── 연출용 보조 텍스처 ────────────────────────── + +def make_rays(size=512): + y, x = np.mgrid[0:size, 0:size].astype(np.float32) + cx = cy = size / 2 + ang = np.arctan2(y - cy, x - cx) + r = np.sqrt((x - cx) ** 2 + (y - cy) ** 2) / (size / 2) + rays = np.clip(np.cos(ang * 12), 0, 1) ** 6 * 0.9 + np.clip(np.cos(ang * 12 + math.pi / 2), 0, 1) ** 10 * 0.45 + fall = np.clip(1 - r, 0, 1) ** 1.6 * smooth(r, 0.08, 0.25) + a = np.clip(rays * fall, 0, 1) + return np.ones((size, size, 3), np.float32), a + + +def make_glow(size=256): + y, x = np.mgrid[0:size, 0:size].astype(np.float32) + r = np.sqrt((x - size / 2) ** 2 + (y - size / 2) ** 2) / (size / 2) + a = np.clip(1 - r, 0, 1) ** 2.2 + return np.ones((size, size, 3), np.float32), a + + +def make_sparkle(size=128): + y, x = np.mgrid[0:size, 0:size].astype(np.float32) + dx, dy = (x - size / 2) / (size / 2), (y - size / 2) / (size / 2) + r = np.sqrt(dx * dx + dy * dy) + star = np.clip(1 - (np.abs(dx) ** 0.5 + np.abs(dy) ** 0.5), 0, 1) ** 1.4 + core = np.clip(1 - r * 3.2, 0, 1) ** 1.5 + halo = np.clip(1 - r, 0, 1) ** 3 * 0.35 + a = np.clip(star + core + halo, 0, 1) + return np.ones((size, size, 3), np.float32), a + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + tiles = [] + for region, shape_fn, pal, enamel, emb_fn, groove_fn, rim in BADGES: + rgb, a = render(region, shape_fn, pal, enamel, emb_fn, groove_fn, rim) + rgb, a = downsample(rgb, a) + save_rgba(os.path.join(OUT_DIR, f"badge_{region}.png"), rgb, a) + tiles.append((region, rgb, a)) + print("badge", region) + for name, (rgb, a) in (("badge_rays", make_rays()), ("badge_glow", make_glow()), ("badge_sparkle", make_sparkle())): + save_rgba(os.path.join(OUT_DIR, f"{name}.png"), rgb, a) + print("fx", name) + + if "--sheet" in sys.argv: + cols, cell = 7, FINAL + 16 + rows = (len(tiles) + cols - 1) // cols + sheet = Image.new("RGB", (cols * cell, rows * cell * 3), (0, 0, 0)) + for band, bg in enumerate(((236, 232, 222), (34, 38, 48), None)): + for i, (region, rgb, a) in enumerate(tiles): + x, y = (i % cols) * cell + 8, (band * rows + i // cols) * cell + 8 + back = np.full((FINAL, FINAL, 3), (bg or (34, 38, 48)), np.float32) / 255.0 + src = rgb + if bg is None: # 잠김 미리보기 — 게임은 어두운 틴트를 곱해 그린다 + src = rgb * np.array([0.16, 0.17, 0.2]) + comp = src * a[..., None] + back * (1 - a[..., None]) + sheet.paste(Image.fromarray((comp * 255).astype(np.uint8)), (x, y)) + os.makedirs(os.path.dirname(SHEET), exist_ok=True) + sheet.save(SHEET) + print("sheet", SHEET) + + +if __name__ == "__main__": + main() diff --git a/Tools/Export-BattleCapture.py b/Tools/Export-BattleCapture.py new file mode 100644 index 0000000..4328a51 --- /dev/null +++ b/Tools/Export-BattleCapture.py @@ -0,0 +1,69 @@ +"""Export actual ScreenCapture frames with their measured, variable durations. + +Usage: python Tools/Export-BattleCapture.py Artifacts/battle-final-169 +Requires Pillow and an ffmpeg executable (PATH or the local QA cache). +""" +import csv +import json +from pathlib import Path +import shutil +import subprocess +import sys +from PIL import Image, ImageStat + +root = Path(__file__).resolve().parents[1] +capture = Path(sys.argv[1]).resolve() +rows = list(csv.DictReader((capture / "timeline.csv").open(encoding="utf-8"))) +if not rows: + raise SystemExit("No frames recorded") +bad_frames = [] +for row in rows: + path = capture / f"frame-{int(row['frame']):05d}.jpg" + with Image.open(path) as shot: + if max(ImageStat.Stat(shot.convert("RGB")).mean) < 1: + bad_frames.append(int(row["frame"])) +if len(bad_frames) > len(rows) // 2: + raise SystemExit("Capture is predominantly black; do not report visual QA success") + +early_hp_changes = [] +impact_samples = 0 +for previous, current in zip(rows, rows[1:]): + if (current["phase"] in ("PlayerAttack", "EnemyAttack") + and current["phase"] == previous["phase"] + and current.get("impactRevealed") == "False"): + impact_samples += 1 + for key in ("displayPlayerHp", "displayEnemyHp"): + if current.get(key) != previous.get(key): + early_hp_changes.append({"frame": current["frame"], "field": key}) +summary = { + "frames": len(rows), "blackFrames": bad_frames, + "width": int(rows[0]["width"]), "height": int(rows[0]["height"]), + "durationSeconds": float(rows[-1]["elapsed"]) - float(rows[0]["elapsed"]), + "resultReached": any(r["phase"] == "Result" for r in rows), + "impactSyncSampleCount": impact_samples, + "preImpactDisplayedHpChanges": early_hp_changes if impact_samples else None, + "standbySideInversions": [r['frame'] for r in rows + if r['phase'] in ('PlayerTurn', 'SwapSelect') and r.get('playerX') + and float(r['playerX']) >= float(r['enemyX'])], + "limits": "Scripted UI action methods; no physical button hit-test, audio or Android performance validation." +} +(capture / "verification.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") +lines = [] +for i, row in enumerate(rows): + lines.append(f"file 'frame-{int(row['frame']):05d}.jpg'") + duration = float(rows[i + 1]["elapsed"]) - float(row["elapsed"]) if i + 1 < len(rows) else 0.1 + lines.append(f"duration {max(0.001, duration):.6f}") +lines.append(f"file 'frame-{int(rows[-1]['frame']):05d}.jpg'") +(capture / "frames.ffconcat").write_text("\n".join(lines), encoding="utf-8") +ffmpeg = shutil.which("ffmpeg") +if not ffmpeg: + candidates = list((root / ".claude/cache/ffmpeg-runtime").glob("**/ffmpeg*.exe")) + if candidates: + ffmpeg = str(candidates[0]) +if not ffmpeg: + raise SystemExit("Verification written; ffmpeg unavailable for MP4 export") +subprocess.run([ffmpeg, "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", + "-i", "frames.ffconcat", "-fps_mode", "vfr", "-c:v", "libx264", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", "battle-review.mp4"], + cwd=capture, check=True) +print(json.dumps(summary, indent=2)) diff --git a/Tools/Start-UnityValidation.ps1 b/Tools/Start-UnityValidation.ps1 new file mode 100644 index 0000000..8a03085 --- /dev/null +++ b/Tools/Start-UnityValidation.ps1 @@ -0,0 +1,45 @@ +param( + [ValidateSet('Compile', 'Test', 'Models', 'Village', 'Map', 'BattleBuild')][string]$Mode = 'Compile', + [string]$EditorPath = 'D:/Unity/Unity Hub/6000.3.10f1/Editor/Unity.exe' +) +$ErrorActionPreference = 'Stop' +$projectRoot = Split-Path $PSScriptRoot -Parent +$outputRoot = Join-Path $projectRoot '.claude/cache/design-validation' +New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff' +$logPath = Join-Path $outputRoot "$Mode-$stamp.log" +$arguments = '-batchmode -projectPath "{0}" -logFile "{1}"' -f $projectRoot, $logPath +switch ($Mode) { + 'Compile' { $arguments += ' -quit' } + 'BattleBuild' { $arguments += ' -quit -executeMethod InsectGame.EditorTools.BattleVisualCaptureBuilder.Build' } + 'Test' { + $resultPath = Join-Path $outputRoot "PlayMode-$stamp.xml" + $arguments += ' -runTests -testPlatform PlayMode -testFilter InsectGame.Tests -testResults "{0}"' -f $resultPath + } + 'Map' { + $resultPath = Join-Path $projectRoot "Artifacts/map-$stamp" + $arguments += ' -executeMethod InsectGame.EditorTools.WorldMapDesignCapture.Run -mapCaptureOut "{0}"' -f $resultPath + } + 'Village' { + $resultPath = Join-Path $projectRoot "Artifacts/village-$stamp" + $arguments += ' -executeMethod InsectGame.EditorTools.VillageDesignCapture.Run -villageCaptureOut "{0}"' -f $resultPath + } + 'Models' { + $resultPath = Join-Path $projectRoot "Artifacts/models-$stamp" + $arguments += ' -executeMethod InsectGame.EditorTools.ModelDesignCapture.Run -modelCaptureOut "{0}"' -f $resultPath + } +} +$startInfo = New-Object System.Diagnostics.ProcessStartInfo +$startInfo.FileName = $EditorPath +$startInfo.Arguments = $arguments +$startInfo.WorkingDirectory = $projectRoot +$startInfo.UseShellExecute = $false +$startInfo.CreateNoWindow = $true +$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden +# Codex may omit this variable; UPM requires it for its global configuration path. +# Restore only the child environment, never machine/user environment settings. +if (-not $startInfo.EnvironmentVariables['ALLUSERSPROFILE']) { + $startInfo.EnvironmentVariables['ALLUSERSPROFILE'] = [Environment]::GetFolderPath('CommonApplicationData') +} +$editorProcess = [System.Diagnostics.Process]::Start($startInfo) +[pscustomobject]@{ ProcessId = $editorProcess.Id; Log = $logPath; Result = $resultPath } diff --git a/Tools/Verify-DesignCompile.py b/Tools/Verify-DesignCompile.py new file mode 100644 index 0000000..fa7b3d1 --- /dev/null +++ b/Tools/Verify-DesignCompile.py @@ -0,0 +1,48 @@ +"""Compile current C# sources against cached Unity references without launching the editor. + +This checks C# compilation only, not Unity imports, tests, visuals, or Android builds. +Requires an existing Unity Bee editor response file from this checkout. +""" +import argparse +from pathlib import Path +import subprocess + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--unity-data', default='D:/Unity/Unity Hub/6000.3.10f1/Editor/Data') + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + data = Path(args.unity_data) + output = root / '.claude/cache/design-validation' + output.mkdir(parents=True, exist_ok=True) + candidates = list((root / 'Library/Bee/artifacts').glob('*E.dag/Assembly-CSharp.rsp')) + if not candidates: + raise SystemExit('No cached editor references. Open the project in a licensed Unity editor first.') + source = max(candidates, key=lambda p: p.stat().st_mtime).parent + for name, directories in [('Assembly-CSharp', ('Assets/Scripts', 'Assets/Tests')), + ('Assembly-CSharp-Editor', ('Assets/Editor',))]: + response = source / (name + '.rsp') + lines = response.read_text(encoding='utf-8-sig').splitlines() + lines = [line for line in lines if not line.startswith(('-out:', '-refout:')) + and not (line.startswith('"Assets/') and line.endswith('.cs"'))] + if name.endswith('-Editor'): + lines = [('-r:"' + (output / 'Assembly-CSharp.dll').as_posix() + '"') + if line.startswith('-r:') and ('Assembly-CSharp.dll' in line or 'Assembly-CSharp.ref.dll' in line) + else line for line in lines] + for directory in directories: + lines.extend('"' + p.relative_to(root).as_posix() + '"' for p in sorted((root / directory).rglob('*.cs'))) + lines.append('-out:"' + (output / (name + '.dll')).as_posix() + '"') + generated = output / (name + '.rsp') + generated.write_text('\n'.join(lines), encoding='utf-8') + result = subprocess.run([str(data / 'NetCoreRuntime/dotnet.exe'), str(data / 'DotNetSdkRoslyn/csc.dll'), + '@' + str(generated)], cwd=root, capture_output=True, text=True, encoding='utf-8', errors='replace') + (output / (name + '.compile.log')).write_text(result.stdout + result.stderr, encoding='utf-8') + print(result.stdout + result.stderr) + if result.returncode: + raise SystemExit(result.returncode) + print(name + ': C# compilation PASS (runtime not executed)') + + +if __name__ == '__main__': + main() diff --git a/Tools/Video/dream_arena.py b/Tools/Video/dream_arena.py new file mode 100644 index 0000000..bdf1c79 --- /dev/null +++ b/Tools/Video/dream_arena.py @@ -0,0 +1,436 @@ +"""「챔피언의 꿈」 도입 영상 — 경기장 입장 8초. 꿈 프롤로그의 Opening 단계에서 틀린다. + + python -X utf8 Tools/Video/dream_arena.py # 영상(mp4) 인코딩 + python -X utf8 Tools/Video/dream_arena.py --preview 0.5,3.4 # 지정 시각의 프레임만 PNG로 + python -X utf8 Tools/Video/dream_arena.py --sheet # 대표 시각 12장 컨택트 시트 + +산출: Assets/StreamingAssets/Video/dream_arena.mp4 (무음 — 소리는 dream_arena_audio.py가 만든 wav를 게임의 +AudioSource가 튼다. 오프닝 프롤로그와 같은 방식이라 영상이 못 틀려도 소리·시계가 어긋나지 않는다). + + 0.0 ~ 3.0 선수 입장 터널 — 어둠 속 끝에서 빛이 커지고, 그 빛을 향해 걷는 챔피언의 뒷모습. 2.4부터 빛이 차올라 흰 화면. + 3.0 ~ 7.3 경기장 — 흰빛이 걷히며 관중석 전체가 환호한다. 서치라이트가 훑고, 바닥의 빛무리 속에 챔피언과 + 파트너(헤라클레스), 맞은편 상대(태고의 비천룡)가 선다. 카메라는 맞대결 쪽으로 밀고 들어간다. + 6.1 맞대결 — 서치라이트가 두 곤충에 모이고 한 번 번쩍한다(소리의 큰 타격과 같은 시각). + 7.2 ~ 8.0 암전. 이어서 게임이 타이틀 카드를 얹는다. + +**시각의 단일 출처는 이 파일의 상수다.** `dream_arena_audio.py`가 모듈로 읽고, 게임의 `DreamPrologueData.IntroSeconds`가 +같은 길이를 쓴다(`DreamPrologueTests`가 어긋남을 잡는다). 글씨는 굽지 않는다 — 카드는 게임이 얹는다. +피사체는 가운데 세로 안전영역(가로의 56%)에 둔다 — 세로 화면은 중앙 cover-crop이다. + +numpy + PIL + ffmpeg만 쓴다(`silhouette_kit` 재사용). +""" +import math +import os +import subprocess +import sys +import time + +import numpy as np +from PIL import Image + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +from silhouette_kit import * # noqa: F401,F403,E402 + +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) +OUT = os.path.join(ROOT, "Assets", "StreamingAssets", "Video", "dream_arena.mp4") +FF = os.environ.get("FFMPEG", "C:/Users/kss11/AppData/Local/Programs/Python/Python312/Scripts/ffmpeg.exe") +PREVIEW_DIR = os.environ.get("PREVIEW_DIR", os.path.join(ROOT, "Artifacts", "dream-arena-preview")) + +# ── 타임라인 (오디오·게임과 공유) ── +T_TOTAL = 8.0 +TUNNEL_END = 3.0 # 흰 화면의 정점 — 큰 '쿵'과 환호가 터지는 시각 +WHITE_IN = (2.35, 3.0) # 터널 끝 빛이 차오른다 +WHITE_OUT = (3.0, 3.9) # 흰빛이 걷히며 경기장이 드러난다 +PUSH_IN = (3.0, 7.3) # 카메라가 맞대결 쪽으로 밀고 들어간다 +CONVERGE = (5.2, 6.0) # 서치라이트가 두 곤충으로 모인다 +FACEOFF = 6.1 # 맞대결 번쩍 + 큰 타격 +FADE_OUT = (7.2, 8.0) +STEP_PERIOD = 0.52 # 터널 걸음 간격(걸음 소리가 같은 시각을 쓴다) +STEP_FIRST = 0.26 + +# 장면 배치(출력 픽셀). 가로 화면은 전부 보이고, 세로 화면(cover-crop)은 가운데 약 32%(x 437~843)만 보인다 — +# 그래서 맞대결 쌍(뿔 ↔ 머리)을 화면 한가운데에 둔다. 챔피언은 곁가지라 세로에선 잘려도 이야기가 선다. +DUEL_X = 640.0 +POOL = (640.0, 612.0) +BEETLE = (470.0, 600.0, 86.0) # x, 발끝 y, 크기 +DRAGONFLY = (800.0, 452.0, 108.0) +CHAMP = (290.0, 712.0, 330.0) # x, 발끝 y, 키 + + +def ease_in(t): + t = min(1.0, max(0.0, t)) + return t * t + + +def hash01(i, k): + """결정적 의사난수 0..1 — 같은 (i, k)는 늘 같은 값이라 프레임 순서와 무관하다.""" + x = math.sin(i * 12.9898 + k * 78.233) * 43758.5453 + return x - math.floor(x) + + +# ══════════════════════════ 장면 A — 선수 입장 터널 ══════════════════════════ + +def make_tunnel(): + ys, xs = np.mgrid[0:H, 0:W].astype(np.float32) + nx = (xs - W / 2) / (W / 2) + ny = (ys - H * 0.5) / (H / 2) + # 아치형(초타원) 거리 — 터널 단면이 둥근 사각형처럼 보여야 벽·바닥·천장이 읽힌다 + d = np.maximum((np.abs(nx * 0.8) ** 4 + np.abs(ny) ** 4) ** 0.25, 1e-3) + depth = 1.0 / (d + 0.10) + grain = fbm(W, H, 5, (90, 30), (0.7, 0.3)) + wall_col = col("#3a2d66") + floor_mask = (ys > H * 0.5).astype(np.float32) + dust = Particles(46, 21, (0, 0, W, H), vel=((-6, 6), (-5, 5)), size=(0.8, 1.8), wobble=5.0) + + def frame(t): + R = 0.075 * math.exp(0.95 * t) + phase = depth * 3.2 + t * 3.0 # 갈빗대가 바깥으로 날아간다(앞으로 걷는 느낌) + rib = np.exp(-(((phase - np.floor(phase)) - 0.5) / 0.085) ** 2) + halo = np.exp(-np.maximum(d - R * 0.6, 0.0) * 1.7 / max(R, 0.3)) + core = np.clip((R - d) / max(R * 0.4, 0.02), 0, 1) + lit = 0.22 + 0.78 * halo + f = wall_col[None, None, :] * (lit * (0.8 + 0.4 * grain) * (0.6 + 0.4 * (1 - rib)))[..., None] + add(f, col("#ffb877"), rib * (0.18 + 0.82 * halo) * 0.95) # 불빛에 선 갈빗대 모서리 + add(f, col("#ff9a52"), halo * (0.12 + 0.3 * floor_mask) * 0.55) # 바닥에 번지는 빛 + f = np.clip(f, 0, 1) + add(f, col("#fff0d2"), core) + dust.draw(f, t, col("#ffd9a0"), 0.55 * float(np.clip(R * 2.0, 0.2, 1.0))) + + # 걷는 챔피언 — 빛을 향해 멀어진다(작아지고 올라간다). 빛이 커져서 실루엣이 흰빛에 먹힌다. + u = span(t, 0.0, TUNNEL_END) + h = lerp(238.0, 142.0, u) + feet = lerp(528.0, 436.0, u) + m = Mask() + person(m, W / 2, feet, h, kind="kid", t=t, walk=t * (2 * math.pi / (STEP_PERIOD * 2)), face=0.0) + a = m.arr(0.4) + over(f, col("#05040b"), a) + glow = 0.5 + 0.5 * span(t, 0.0, 2.6) + add(f, col("#ffd49a"), rim(a, 3, 0, 1.8) * 0.55 * glow) + add(f, col("#ffd49a"), rim(a, -3, 0, 1.8) * 0.55 * glow) + + w = smooth(span(t, *WHITE_IN)) + if w > 0: + f = f * (1 - w) + col("#fff4dc")[None, None, :] * w + return f + + return frame + + +# ══════════════════════════ 장면 B — 경기장 ══════════════════════════ + +def hercules_side(m, x, y, s, mirror=False, bob=0.0): + """옆에서 본 헤라클레스장수풍뎅이 — 머리가 +x, 위로 길게 휜 뿔과 아래 짧은 뿔이 집게처럼 맞선다. y는 발끝.""" + def X(pts): + return xf([(-px, py) for px, py in pts] if mirror else pts, x, y - 0.62 * s - bob, s, 0.0) + + m.ellipse(*X([(-0.18, 0.0)])[0], 0.84 * s, 0.5 * s, 0.0) # 딱지날개 + m.ellipse(*X([(0.6, -0.08)])[0], 0.4 * s, 0.4 * s, 0.0) # 앞가슴 + m.ellipse(*X([(1.02, 0.12)])[0], 0.21 * s, 0.18 * s, 0.0) # 머리 + m.taper(X(bezier((0.78, -0.36), (1.2, -1.05), (1.9, -1.12), (2.28, -0.55), 16)), 0.27 * s, 0.09 * s) # 위 뿔(가슴에서) + m.taper(X(bezier((1.14, 0.04), (1.5, -0.24), (1.86, -0.3), (2.14, -0.1), 12)), 0.18 * s, 0.07 * s) # 아래 뿔(머리에서) + for lx, spread in ((-0.62, -0.12), (0.0, 0.0), (0.58, 0.14)): # 다리 + m.line(X([(lx, 0.36), (lx + spread * 0.5, 0.6), (lx + spread, 0.74)]), 0.11 * s) + return m + + +def dragonfly_side(m, x, y, s, flap=0.0): + """옆에서 본 잠자리 — 머리가 -x, 날개 두 쌍이 위로 퍼진다. flap(-1..1)이 날갯짓 위상이다.""" + m.circle(x - 0.95 * s, y, 0.17 * s) # 머리(겹눈) + m.ellipse(x - 0.6 * s, y - 0.02 * s, 0.27 * s, 0.2 * s, 0.0) # 가슴 + m.taper([(x - 0.5 * s, y + 0.02 * s), (x + 0.45 * s, y + 0.07 * s), (x + 1.25 * s, y + 0.1 * s)], 0.13 * s, 0.04 * s) + for k, (bx, a0, ln) in enumerate(((-0.66, -1.95, 1.3), (-0.5, -1.15, 1.2))): + a = a0 + flap * (0.42 if k == 0 else -0.42) + cx, cy = x + bx * s + math.cos(a) * ln * 0.5 * s, y - 0.08 * s + math.sin(a) * ln * 0.5 * s + m.ellipse(cx, cy, ln * 0.5 * s, 0.1 * s, a, v=200) + for lx in (-0.7, -0.55, -0.4): # 앞으로 모은 다리 + m.line([(x + lx * s, y + 0.12 * s), (x + (lx - 0.08) * s, y + 0.36 * s)], 0.04 * s) + return m + + +def build_stands(): + """관중석 — 여섯 줄(먼 줄은 옅고 푸르다). 줄마다 정적 실루엣 + 흔들리는 팔 목록.""" + rng = np.random.default_rng(7) + rows = [] + for r in range(6): + yb = 270 + r * 30 + r * r * 0.9 + rad = 4.0 + r * 1.55 + step = rad * 2.25 + base = np.arange(-24, W + 24, step) + xs = base + rng.uniform(-step * 0.3, step * 0.3, len(base)) + m = Mask() + pts_top = [] + arms = [] + for x in xs: + y = yb - 0.00009 * (x - W / 2) ** 2 + rng.uniform(-2.0, 2.0) + m.circle(x, y, rad) + m.ellipse(x, y + rad * 2.1, rad * 1.8, rad * 1.5) + pts_top.append((x, y + rad * 1.2)) + if rng.random() < 0.42 and r >= 1: + side = 1 if rng.random() < 0.5 else -1 + arms.append((x + side * rad * 1.3, y + rad * 1.6, side, rng.uniform(0, 6.28), rng.uniform(2.2, 4.2), + rad * rng.uniform(2.6, 4.2))) + band = pts_top + [(W + 24, yb + 70), (-24, yb + 70)] + m.poly(band) + rows.append((m.arr(0.35), arms, rad)) + return rows + + +def make_arena(): + ys, xs = np.mgrid[0:H, 0:W].astype(np.float32) + + # ── 하늘(경기장 지붕 아래 어둠 + 먼 조명 빛망울) ── + bg = sky(W, H, [(0, "#0a0720"), (0.28, "#251a48"), (0.42, "#6b3d5a"), (0.52, "#d98450"), (0.62, "#ffb36e"), (1, "#3a2646")], + seed=3, texture=0.03) + for cx, cy, rr, c, a in ((180, 150, 260, "#ffd9a0", 0.55), (1100, 150, 260, "#ffd9a0", 0.55), + (640, 90, 330, "#a8c8ff", 0.28), (640, 400, 520, "#ff9a5a", 0.30)): + add(bg, col(c), radial(W, H, cx, cy, rr, 2.0) * a) + bg += bokeh(W, H, 9, 46, col("#ffe0b0"), 6, 24, 0.55) + bg = np.clip(bg, 0, 1) + + # ── 관중석 ── + rows = build_stands() + row_cols = [haze(col("#4a3a74"), col("#0b0818"), r / 5.0) for r in range(6)] + + # ── 난간 벽 + 바닥 ── + wall = Mask().rect(0, 492, W, 528).arr(0.3) + floor_m = np.clip((ys - 524) / 6.0, 0, 1) + floor_tex = fbm(W, H, 13, (140, 50), (0.7, 0.3)) + floor_col = col("#1b1430")[None, None, :] * (0.8 + 0.5 * floor_tex[..., None]) + pool = np.clip(1 - np.sqrt(((xs - POOL[0]) / 620) ** 2 + ((ys - POOL[1]) / 112) ** 2), 0, 1) ** 1.25 + ring = Mask().ellipse(POOL[0], POOL[1], 520, 96).ellipse(POOL[0], POOL[1], 511, 90, v=0).arr(0.8) + ring_glow = blur_arr(ring, 4.0) + + # 파트너·상대·챔피언은 프레임마다 흔들려서 정적 마스크가 아니다 + vig = vignette(W, H, 0.5) + confetti_a = Particles(70, 31, (0, -40, W, H + 40), vel=((-14, 14), (22, 58)), size=(1.4, 2.8), wobble=14.0, twinkle=0.6) + confetti_b = Particles(50, 32, (0, -40, W, H + 40), vel=((-14, 14), (18, 48)), size=(1.4, 2.6), wobble=12.0, twinkle=0.6) + motes = Particles(60, 33, (140, 380, 1160, 700), vel=((-5, 5), (-10, -3)), size=(0.8, 1.8), wobble=4.0) + flash_spots = [] + rng = np.random.default_rng(41) + for _ in range(64): + r = rng.integers(0, 6) + x = rng.uniform(40, W - 40) + y = 270 + r * 30 + r * r * 0.9 - 0.00009 * (x - W / 2) ** 2 - rng.uniform(0, 4) + flash_spots.append((x, y, 4 + r * 1.6)) + + beams = ( # (광원 x, 기본 각도(라디안, 0=아래), 흔들림 폭, 위상, 색, 수렴 목표) + (150, -0.12, 0.34, 0.0, "#ffe2a6", "beetle"), + (470, 0.0, 0.30, 1.7, "#a8d4ff", "beetle"), + (830, 0.0, 0.30, 3.1, "#ffe2a6", "dragon"), + (1130, 0.12, 0.34, 4.6, "#a8d4ff", "dragon"), + ) + + def beam_mask(t, converge): + warm = Mask(ss=1) + cool = Mask(ss=1) + for sx, base, amp, ph, color, tgt in beams: + sway = base + amp * math.sin(t * 0.85 + ph) + tx = sx + math.tan(sway) * 760 + ty = 760.0 + goal = (BEETLE[0], BEETLE[1] - 60) if tgt == "beetle" else (DRAGONFLY[0], DRAGONFLY[1]) + tx = lerp(tx, goal[0] + (sx - W / 2) * 0.05, converge) + ty = lerp(ty, goal[1], converge) if converge > 0 else ty + dx, dy = tx - sx, ty - (-20.0) + ln = math.hypot(dx, dy) + px, py = -dy / ln, dx / ln + far = 1.0 + 0.9 * (1.0 - converge * 0.55) # 모일수록 빛줄기가 가늘어진다 + wd = 34 * far + tip = lerp(1.45, 1.0, converge) # 모이면 목표에서 멎는다(뚫고 나가지 않는다) + ex, ey = sx + dx * tip, -20.0 + dy * tip + poly = [(sx - px * 7, -20.0 - py * 7), (sx + px * 7, -20.0 + py * 7), + (ex + px * wd, ey + py * wd), (ex - px * wd, ey - py * wd)] + (warm if color == "#ffe2a6" else cool).poly(poly) + return blur_arr(warm.arr(), 9.0), blur_arr(cool.arr(), 9.0) + + def frame(t): + f = bg.copy() + roar = smooth(span(t, 3.0, 3.35)) # 환호가 터진 뒤 + conv = smooth(span(t, *CONVERGE)) + face = math.exp(-max(0.0, t - FACEOFF) * 6.0) if t >= FACEOFF else 0.0 + + # 관중석 — 먼 줄부터. 환호 뒤엔 윗면에 조명 테두리가 선다. + for r, (a, arms, rad) in enumerate(rows): + over(f, row_cols[r], a) + if roar > 0: + add(f, col("#ffcf8a"), rim(a, 0, -2.2, 1.4) * (0.55 * roar) * (1.0 - r * 0.1)) + if arms and roar > 0: + am = Mask(ss=1) + lift = roar + for (ax, ay, side, ph, sp, ln) in arms: + sway = math.sin(t * sp + ph) * rad * 0.9 + am.line([(ax, ay), (ax + side * rad * 0.6 + sway * 0.5, ay - ln * 0.55 * lift), + (ax + side * rad * 0.9 + sway, ay - ln * lift)], max(1.6, rad * 0.42), caps=True) + aa = am.arr(0.3) + over(f, row_cols[r], aa) + + # 카메라 플래시 — 환호 뒤에 객석 곳곳에서 번쩍인다 + if roar > 0: + k = int(t * 11) + for i, (fx, fy, fr) in enumerate(flash_spots): + if hash01(i, k) < 0.12 * roar + 0.05 * face: + life = 1.0 - ((t * 11) - k) + dot(f, fx, fy, fr * 3.2, col("#fff6e0"), 0.55 * life) + dot(f, fx, fy, fr * 1.2, col("#ffffff"), 0.9 * life) + + # 난간 + LED 띠 + over(f, col("#0c0816"), wall) + led = Mask(ss=1).rect(0, 497, W, 500).arr(0.6) + pulse = 0.65 + 0.35 * math.sin(t * 3.0) + add(f, col("#ff7a3a"), led * (0.5 + 0.5 * roar) * pulse) + add(f, col("#ff7a3a"), blur_arr(led, 5.0) * 0.5 * roar) + + # 바닥 + 빛무리 + over(f, floor_col, floor_m) + add(f, col("#ffd9a0"), pool * floor_m * (0.8 + 0.25 * roar + 0.3 * face)) + add(f, col("#ffb470"), ring_glow * (0.35 + 0.35 * roar)) + add(f, col("#ffe4b8"), ring * (0.35 + 0.35 * roar)) + + # 서치라이트(가산) — 객석·바닥 위를 훑다가 맞대결 쪽으로 모인다 + warm, cool = beam_mask(t, conv) + grad = np.clip(1.15 - ys / (H * 1.25), 0.15, 1.0) + amount = (0.24 + 0.2 * roar + 0.12 * conv + 0.22 * face) * smooth(span(t, 3.0, 3.5)) + add(f, col("#ffe2a6"), warm * grad * amount) + add(f, col("#a8d4ff"), cool * grad * amount * 0.9) + + # 상대 뒤 후광 — 어두운 객석 앞에서도 실루엣이 읽히도록 빛이 모인 자리 + dx, dy, ds = DRAGONFLY + hover = math.sin(t * 2.6) * 7.0 + add(f, col("#ffe6b8"), radial(W, H, dx, dy + hover, 230, 1.7) * (0.55 + 0.4 * conv + 0.5 * face)) + add(f, col("#ffffff"), radial(W, H, dx, dy + hover, 90, 1.5) * (0.25 + 0.4 * conv)) + add(f, col("#ffe6b8"), radial(W, H, BEETLE[0] + 40, BEETLE[1] - 70, 240, 1.7) * (0.35 * conv + 0.4 * face)) + + # 입자 — 색종이·먼지 + confetti_a.draw(f, t, col("#ffd36a"), 0.9 * roar, 0, 0) + confetti_b.draw(f, t, col("#9fd0ff"), 0.8 * roar, 0, 0) + + # 실루엣 — 파트너·상대·챔피언 + bx, by, bs = BEETLE + bm = Mask() + hercules_side(bm, bx, by, bs, bob=math.sin(t * 2.2) * 2.0 + 6.0 * face) + ba = bm.arr(0.35) + over(f, col("#06040c"), ba) + add(f, col("#ffe0a8"), rim(ba, 2, -2, 1.6) * 0.5) + + fm = Mask() + dragonfly_side(fm, dx, dy + hover, ds, flap=math.sin(t * 19.0) * 0.9) + fa = fm.arr(0.35) + over(f, col("#08050e"), fa) + + cx, cf, ch = CHAMP + raise_arm = smooth(span(t, FACEOFF - 0.5, FACEOFF - 0.1)) + arm = (cx + ch * 0.13 + raise_arm * ch * 0.36, cf - ch * 0.45 - raise_arm * ch * 0.5) if raise_arm > 0 else None + pm = Mask() + person(pm, cx, cf, ch, kind="kid", t=t, face=0.35, arm=arm) + pa = pm.arr(0.4) + over(f, col("#04030a"), pa) + add(f, col("#ffcf8a"), rim(pa, 3, -1, 2.0) * 0.5) + + motes.draw(f, t, col("#ffe0b0"), 0.5 * roar) + + # 맞대결 번쩍 — 한 번 크게, 빠르게 걷힌다 + if face > 0.001: + f = f + face * 0.22 + # 카메라: 맞대결 쪽으로 밀고 들어간다 + 맞대결 순간 흔들림 + z = lerp(1.0, 1.26, ease_in_out(span(t, *PUSH_IN))) + f = np.clip(f * vig, 0, 1) + # 맞대결 흔들림은 확대 중심을 흔들어 만든다 — 프레임을 굴리면(np.roll) 반대편 가장자리가 말려 들어온다. + # 중심 이동은 화면에서 약 1/5로 줄어 보이므로(1 - 1/z) 크게 준다. + shake_x = math.sin(t * 90.0) * 38.0 * face + shake_y = math.cos(t * 77.0) * 26.0 * face + f = zoom(f, z, DUEL_X + shake_x, 470.0 + shake_y) + + # 흰빛이 걷힌다 + w = 1.0 - smooth(span(t, *WHITE_OUT)) + if w > 0: + f = f * (1 - w) + col("#fff4dc")[None, None, :] * w + return f + + return frame + + +# ══════════════════════════ 합성 ══════════════════════════ + +def build(): + tunnel = None + arena = None + + def frame_at(t): + nonlocal tunnel, arena + if t < TUNNEL_END: + if tunnel is None: + tunnel = make_tunnel() + f = tunnel(t) + else: + if tunnel is not None: + tunnel = None + if arena is None: + arena = make_arena() + f = arena(t) + if t < 0.35: + f = f * smooth(t / 0.35) + if t > FADE_OUT[0]: + f = f * (1 - smooth(span(t, *FADE_OUT))) + return np.clip(f, 0, 1) + + return frame_at + + +def to_u8(f): + return (np.clip(f, 0, 1) * 255 + 0.5).astype(np.uint8) + + +def render(times=None): + frame_at = build() + if times is not None: + os.makedirs(PREVIEW_DIR, exist_ok=True) + for t in times: + path = os.path.join(PREVIEW_DIR, f"dream-arena-{t:05.2f}.png") + Image.fromarray(to_u8(frame_at(t)), "RGB").save(path) + print("preview", path) + return + + os.makedirs(os.path.dirname(OUT), exist_ok=True) + cmd = [FF, "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(FPS), + "-i", "-", "-vf", "noise=alls=5:allf=t,setsar=1", "-an", "-c:v", "libx264", "-profile:v", "main", + "-level", "4.0", "-pix_fmt", "yuv420p", "-b:v", "1800k", "-maxrate", "2000k", "-bufsize", "4000k", + "-movflags", "+faststart", OUT] + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE) + n = int(round(T_TOTAL * FPS)) + t0 = time.time() + for i in range(n): + proc.stdin.write(to_u8(frame_at(i / FPS)).tobytes()) + if i % 24 == 0: + print(f" {i}/{n} ({time.time() - t0:.0f}s)", flush=True) + proc.stdin.close() + rc = proc.wait() + print(f"dream_arena.mp4 {T_TOTAL:.1f}s {n}f {os.path.getsize(OUT) / 1e6:.2f}MB rc={rc} ({time.time() - t0:.0f}s)") + + +def sheet(): + """대표 시각 12장 — 3열 4행 컨택트 시트.""" + times = [0.4, 1.2, 2.0, 2.7, 3.1, 3.5, 4.2, 5.0, 5.8, 6.15, 6.8, 7.6] + frame_at = build() + tw, th = 640, 360 + img = Image.new("RGB", (tw * 3, th * 4)) + for i, t in enumerate(times): + tile = Image.fromarray(to_u8(frame_at(t)), "RGB").resize((tw, th), Image.Resampling.LANCZOS) + img.paste(tile, ((i % 3) * tw, (i // 3) * th)) + os.makedirs(PREVIEW_DIR, exist_ok=True) + path = os.path.join(PREVIEW_DIR, "dream-arena-sheet.png") + img.save(path) + print("sheet", path) + + +def main(): + args = sys.argv[1:] + if "--sheet" in args: + sheet() + elif "--preview" in args: + render([float(v) for v in args[args.index("--preview") + 1].split(",")]) + else: + render() + + +if __name__ == "__main__": + main() diff --git a/Tools/Video/dream_arena_audio.py b/Tools/Video/dream_arena_audio.py new file mode 100644 index 0000000..8545703 --- /dev/null +++ b/Tools/Video/dream_arena_audio.py @@ -0,0 +1,228 @@ +"""「챔피언의 꿈」 도입 음원 — 경기장 입장 8초(환호·발소리·타격)를 합성한다. + + python -X utf8 Tools/Video/dream_arena_audio.py + +산출: Assets/Resources/Audio/Dream/dream_arena.wav (44.1kHz 스테레오 16bit, 8초) + +영상(dream_arena.py)과 **사건 시각을 공유한다** — 그 파일을 모듈로 읽어 상수를 그대로 쓴다. + + 0.0 ~ 3.0 터널 — 벽 너머에서 먹먹하게 새어 드는 환호(저역만)가 점점 커진다. 걸음마다 발소리와 울림. + 낮은 드론이 깔리고 2.0부터 상승음이 차오른다. + 3.0 흰 화면 — 큰 '쿵' + 막혔던 환호가 한꺼번에 터진다(먹먹함이 걷힌다). + 3.4 ~ 5.75 박수가 점점 촘촘해진다. 휘파람 몇 번. 환호는 일렁이며 부푼다. + 5.75 ~ 6.1 숨죽임 — 환호가 푹 꺼진다(상승음도 여기서 끊긴다). + 6.1 맞대결 — 가장 큰 타격 + 환호 정점 + 금속성 울림. + 7.2 ~ 8.0 페이드아웃. 이어서 게임이 타이틀 카드를 얹는다. + +외부 음원·scipy 없이 numpy만 쓴다(주파수 영역 필터). +""" +import os +import sys +import wave + +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import dream_arena as vp # noqa: E402 (사건 시각의 단일 출처) + +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) +OUT = os.path.join(ROOT, "Assets", "Resources", "Audio", "Dream", "dream_arena.wav") +SR = 44100 +T = vp.T_TOTAL +N = int(SR * T) +t = np.arange(N) / SR + + +def span(a, b, x): + return np.clip((x - a) / (b - a), 0.0, 1.0) + + +def sstep(a, b, x): + u = span(a, b, x) + return u * u * (3 - 2 * u) + + +def band_noise(lo, hi, seed): + """대역 제한 잡음 — 주파수 영역에서 부드러운 경사로 깎는다(scipy 없이).""" + r = np.random.default_rng(seed) + x = r.normal(0, 1, N) + X = np.fft.rfft(x) + f = np.fft.rfftfreq(N, 1 / SR) + hp = 1 / np.sqrt(1 + (lo / np.maximum(f, 1e-3)) ** 4) + lp = 1 / np.sqrt(1 + (f / hi) ** 4) + y = np.fft.irfft(X * hp * lp, N) + return y / (np.std(y) + 1e-9) + + +def lowpass(x, fc): + X = np.fft.rfft(x) + f = np.fft.rfftfreq(len(x), 1 / SR) + return np.fft.irfft(X / np.sqrt(1 + (f / fc) ** 4), len(x)) + + +def smooth_env(points, win=0.06): + """키프레임 (시각, 값) 목록 → 샘플 단위 포락선(이동평균으로 모서리를 눕힌다).""" + xs, ys = zip(*points) + env = np.interp(t, xs, ys) + k = max(1, int(SR * win)) + return np.convolve(env, np.ones(k) / k, mode="same") + + +def slow_random(seed, rate, lo, hi): + """느리게 일렁이는 0..1 난수 포락선 — 환호가 한결같은 잡음이 아니라 사람들의 숨처럼 부푼다.""" + r = np.random.default_rng(seed) + n = int(T * rate) + 3 + pts = r.uniform(lo, hi, n) + return np.interp(t, np.arange(n) / rate, pts) + + +def reverb(x, rt60=1.8, seed=9): + """합성 잔향 — 감쇠하는 잡음 IR과 FFT 합성곱.""" + n = int(SR * rt60) + r = np.random.default_rng(seed) + tt = np.arange(n) / SR + ir = r.normal(0, 1, n) * np.exp(-6.9 * tt / rt60) + ir[: int(SR * 0.015)] = 0.0 + ir /= np.sqrt(np.sum(ir ** 2)) + 1e-9 + m = 1 << int(np.ceil(np.log2(len(x) + n))) + return np.fft.irfft(np.fft.rfft(x, m) * np.fft.rfft(ir, m), m)[: len(x)] + + +def burst(at, dur, lo, hi, seed, decay, amp=1.0): + """at초에 시작하는 짧은 잡음 덩어리(타격·박수).""" + out = np.zeros(N) + i0 = int(at * SR) + n = int(dur * SR) + if i0 >= N: + return out + n = min(n, N - i0) + r = np.random.default_rng(seed) + x = r.normal(0, 1, n) + X = np.fft.rfft(x) + f = np.fft.rfftfreq(n, 1 / SR) + X *= 1 / np.sqrt(1 + (lo / np.maximum(f, 1e-3)) ** 4) / np.sqrt(1 + (f / hi) ** 4) + y = np.fft.irfft(X, n) + y /= np.max(np.abs(y)) + 1e-9 + out[i0:i0 + n] = y * np.exp(-np.arange(n) / SR / decay) * amp + return out + + +def boom(at, f0, f1, dur, amp): + """내려앉는 저음 '쿵' — 사인 주파수가 f0→f1로 미끄러진다.""" + out = np.zeros(N) + i0 = int(at * SR) + n = min(int(dur * SR), N - i0) + if n <= 0: + return out + tt = np.arange(n) / SR + freq = f1 + (f0 - f1) * np.exp(-tt / (dur * 0.22)) + phase = 2 * np.pi * np.cumsum(freq) / SR + env = np.exp(-tt / (dur * 0.42)) * np.minimum(1.0, tt / 0.004) + out[i0:i0 + n] = np.sin(phase) * env * amp + return out + + +def whistle(at, dur, f0, f1, amp): + out = np.zeros(N) + i0 = int(at * SR) + n = min(int(dur * SR), N - i0) + if n <= 0: + return out + tt = np.arange(n) / SR + freq = f0 + (f1 - f0) * (tt / dur) + 28 * np.sin(2 * np.pi * 6.5 * tt) + phase = 2 * np.pi * np.cumsum(freq) / SR + env = np.sin(np.pi * np.minimum(1.0, tt / dur)) ** 0.7 + out[i0:i0 + n] = (np.sin(phase) + 0.25 * np.sin(2 * phase)) * env * amp + return out + + +def main(): + rng = np.random.default_rng(17) + + # ── 터널 → 경기장 환호 ── + # 총 크기: 터널에서 점점 → 3.12 폭발 → 부풂 → 5.75 숨죽임 → 6.16 정점 → 페이드 + level = smooth_env([(0.0, 0.07), (1.0, 0.13), (2.0, 0.26), (2.9, 0.44), (3.0, 0.48), (3.12, 1.0), (3.6, 0.66), + (4.6, 0.74), (5.6, 0.88), (5.75, 0.68), (6.0, 0.26), (6.08, 0.24), (6.16, 1.18), (6.5, 0.96), + (7.2, 0.78), (8.0, 0.14)], 0.05) + sway = 0.78 + 0.22 * slow_random(3, 2.6, 0.0, 1.0) + chans = [] + for side in (0, 1): + lo_body = band_noise(220, 1300, 100 + side) # 사람 목소리 몸통 + voice = band_noise(500, 2600, 110 + side) * (0.5 + 0.5 * slow_random(7 + side, 3.2, 0.0, 1.0)) + air = band_noise(2600, 6000, 120 + side) * 0.08 * sstep(3.0, 3.4, t) # 아레나에선 환호의 윗결이 열린다 + raw = (lo_body * 0.8 + voice * 0.55 + air) * level * sway + low = lowpass(raw, 300.0) + mid = lowpass(raw, 1500.0) + w_low = 1 - sstep(0.0, 2.6, t) + w_mid = sstep(0.0, 2.6, t) * (1 - sstep(2.95, 3.12, t)) + w_full = sstep(2.95, 3.12, t) + chans.append(low * w_low + mid * w_mid * 0.9 + raw * w_full) + crowd_l, crowd_r = chans + + # ── 박수 — 점점 촘촘해진다 ── + rate = np.interp(t, [3.4, 5.75, 6.0, 6.16, 7.0], [14, 120, 25, 160, 90]) + rate = rate * sstep(3.3, 3.5, t) * (1 - sstep(7.2, 8.0, t)) + p = rate / SR + hits = (rng.random(N) < p) * rng.uniform(0.4, 1.0, N) + k = int(0.03 * SR) + kt = np.arange(k) / SR + kern = np.random.default_rng(4).normal(0, 1, k) * np.exp(-kt / 0.007) + Kf = np.fft.rfft(kern, 1 << 20) + claps = np.fft.irfft(np.fft.rfft(hits, 1 << 20) * Kf, 1 << 20)[:N] + claps = np.convolve(claps, np.array([1.0, -0.6]), mode="same") # 약한 고역 강조 — 손뼉의 날 + claps *= 0.055 / (np.std(claps) + 1e-9) + claps_r = np.roll(claps, 37) * 0.9 # 좌우를 살짝 어긋나게 + + # ── 휘파람·함성 ── + whistles = (whistle(3.5, 0.4, 1900, 2500, 0.03) + whistle(4.85, 0.35, 2100, 2800, 0.028) + + whistle(6.3, 0.5, 2300, 3000, 0.035)) + + # ── 터널: 발소리·드론·상승음 ── + steps = np.zeros(N) + st = vp.STEP_FIRST + while st < 2.95: + u = st / vp.TUNNEL_END + steps += boom(st, 110, 58, 0.16, 0.34 * (1 - 0.45 * u)) + burst(st, 0.05, 250, 2200, int(st * 1000), 0.012, 0.16 * (1 - 0.4 * u)) + st += vp.STEP_PERIOD + steps_wet = reverb(steps, 1.1, 5) * 0.55 * (1 - sstep(2.9, 3.1, t)) + steps = steps * (1 - sstep(2.95, 3.05, t)) + drone = (np.sin(2 * np.pi * 73.4 * t) * 0.6 + np.sin(2 * np.pi * 110.0 * t) * 0.35 + np.sin(2 * np.pi * 146.8 * t) * 0.18) + drone *= 0.07 * sstep(0.0, 1.6, t) * (1 - sstep(2.98, 3.2, t)) + riser = band_noise(300, 5000, 33) * sstep(1.8, 3.0, t) ** 2 * (1 - sstep(2.98, 3.0, t)) * 0.2 + riser2 = band_noise(400, 7000, 34) * sstep(4.6, 5.75, t) ** 2 * (1 - sstep(5.75, 5.82, t)) * 0.16 + + # ── 타격 ── + hit1 = boom(vp.TUNNEL_END + 0.03, 78, 28, 1.0, 0.95) + burst(vp.TUNNEL_END + 0.03, 0.9, 90, 2800, 61, 0.28, 0.5) + hit2 = boom(vp.FACEOFF, 66, 24, 1.15, 1.0) + burst(vp.FACEOFF, 1.0, 120, 3200, 62, 0.34, 0.55) + clash = burst(vp.FACEOFF, 1.3, 3500, 11000, 63, 0.42, 0.22) + sub = band_noise(25, 90, 71) * (0.08 + 0.1 * sstep(2.0, 3.0, t) * (1 - sstep(2.98, 3.1, t)) + 0.1 * sstep(3.1, 3.6, t)) + + # ── 합성 ── + arena_mask = sstep(2.95, 3.1, t) + left = crowd_l + claps + whistles + steps + steps_wet + drone + riser + riser2 + hit1 + hit2 + clash + sub + right = crowd_r + claps_r + whistles + steps + steps_wet + drone + riser + riser2 + hit1 + hit2 + clash + sub + # 경기장 잔향 — 환호·박수에만 얹는다(터널 발소리는 따로 울림을 받았다) + wet = reverb(crowd_l + claps + whistles, 1.9, 8) * 0.22 * arena_mask + wet_r = reverb(crowd_r + claps_r + whistles, 1.9, 9) * 0.22 * arena_mask + left = left + wet + right = right + wet_r + + fade = 1 - sstep(*vp.FADE_OUT, t) + head = sstep(0.0, 0.12, t) + stereo = np.stack([left, right], axis=1) * (fade * head)[:, None] + peak = float(np.max(np.abs(stereo))) + stereo = np.tanh(stereo / (peak + 1e-9) * 1.25) / np.tanh(1.25) * 0.88 # 부드러운 리미터 + 정규화 + + pcm = (np.clip(stereo, -1, 1) * 32767).astype("= N: + return e + tt = t[i0:] - at + e[i0:] = np.minimum(1.0, tt / attack) * np.exp(-tt / tau) + return e + + +def main(): + L = np.zeros(N) + R = np.zeros(N) + + # ── 패드: D2·A2·D3 (+F3 단3도, +D1 서브) ── + base = sstep(0.0, 2.2, t) * (1 + 0.6 * sstep(7.0, 9.2, t)) * (1 - sstep(9.22, 9.45, t)) + for f, amp, ph in ((73.42, 0.14, 0.0), (110.0, 0.10, 1.1), (146.83, 0.09, 2.3)): + L += pad(f, 0.22, phase=ph) * amp * base + R += pad(f, 0.29, phase=ph + 0.7) * amp * base + minor = sstep(4.6, 6.6, t) * (1 - sstep(9.22, 9.45, t)) + L += pad(174.61, 0.2, phase=0.4) * 0.065 * minor + R += pad(174.61, 0.26, phase=1.3) * 0.065 * minor + sub = sstep(7.0, 9.2, t) * (1 - sstep(9.25, 9.4, t)) + L += np.sin(2 * np.pi * 36.71 * t) * 0.07 * sub + R += np.sin(2 * np.pi * 36.71 * t + 0.3) * 0.07 * sub + + # ── 바람 ── + wind_env = sstep(0.0, 2.0, t) * (1 - sstep(8.8, 9.5, t)) * (0.62 + 0.38 * np.sin(2 * np.pi * 0.12 * t) + * np.sin(2 * np.pi * 0.31 * t + 1.0)) + L += band_noise(160, 900, 1) * 0.036 * wind_env + R += band_noise(160, 900, 2) * 0.036 * wind_env + + # ── 빛의 잔향 — 반짝임은 빛이 꺼지는 만큼 잦아든다(영상 VANISH1과 같은 구간) ── + life = sstep(0.0, 1.2, t) * (1 - sstep(vp.VANISH1[0], vp.VANISH1[1], t)) + for f in (1174.66, 1479.98, 1760.0, 2349.32): + trem_f = rng.uniform(3.0, 7.0) + trem = (0.5 + 0.5 * np.sin(2 * np.pi * trem_f * t + rng.uniform(0, 6.28))) ** 2 + pan = rng.uniform(-0.6, 0.6) + s = np.sin(2 * np.pi * f * t) * 0.015 * life * trem + L += s * (1 - pan) + R += s * (1 + pan) + + # ── 그물에 닿는 빛 — 영상의 Collected와 같은 난수 스케줄 ── + col = vp.Collected(10, 7, (0.08, 0.92, 0.25, 0.9)) + for i, at in enumerate(col.arrive): + pan = float(np.clip((col.p0[i][0] / vp.W - 0.5) * 0.8, -0.5, 0.5)) + e = env_decay(at, 0.22) + # 차가운 핑 — A6에서 E6로 살짝 떨어진다 + f = 1318.5 + (1760.0 - 1318.5) * np.exp(-np.maximum(t - at, 0) / 0.05) + ph = 2 * np.pi * np.cumsum(f) / SR + ping = np.sin(ph) * e * 0.028 + thud = np.sin(2 * np.pi * 62 * t) * env_decay(at, 0.12) * 0.045 + L += (ping + thud) * (1 - pan) + R += (ping + thud) * (1 + pan) + + # ── 마지막 빛이 꺼진다 — 낮은 붐 ── + tb = np.maximum(t - BOOM_AT, 0) + boom_f = 38 + 12 * np.exp(-tb / 0.35) + boom = np.sin(2 * np.pi * np.cumsum(boom_f) / SR) * env_decay(BOOM_AT, 0.9, attack=0.012) * 0.34 + rumble = band_noise(30, 260, 5) * env_decay(BOOM_AT, 0.28, attack=0.01) * 0.1 + L += boom + rumble + R += boom + rumble * 0.9 + + # 전주에만 공간감을 준다 — 테마곡은 이미 믹스가 끝난 음원이다 + L = L + reverb(L, seed=11) * 0.32 + R = R + reverb(R, seed=12) * 0.32 + + # ── 테마곡 ── + w = wave.open(THEME, "rb") + assert w.getframerate() == SR and w.getsampwidth() == 2, (w.getframerate(), w.getsampwidth()) + raw = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float64) / 32768.0 + ch = w.getnchannels() + raw = raw.reshape(-1, ch) + th_l, th_r = raw[:, 0], raw[:, -1] + i0 = int(THEME_AT * SR) + n = min(len(th_l), N - i0) + L[i0:i0 + n] += th_l[:n] + R[i0:i0 + n] += th_r[:n] + + # 전주 피크만 누른다(테마곡 음량은 그대로) + pre = slice(0, i0) + peak = max(np.abs(L[pre]).max(), np.abs(R[pre]).max()) + if peak > 0.85: + L[pre] *= 0.85 / peak + R[pre] *= 0.85 / peak + L[-int(0.05 * SR):] *= np.linspace(1, 0, int(0.05 * SR)) + R[-int(0.05 * SR):] *= np.linspace(1, 0, int(0.05 * SR)) + + out = np.stack([L, R], 1) + out = np.clip(out, -1, 1) + pcm = (out * 32767).astype(np.int16) + with wave.open(OUT, "wb") as wo: + wo.setnchannels(2) + wo.setsampwidth(2) + wo.setframerate(SR) + wo.writeframes(pcm.tobytes()) + mono = out.mean(1) + for k in range(0, int(T * 2)): + seg = mono[int(k * 0.5 * SR):int((k + 1) * 0.5 * SR)] + rms = np.sqrt((seg ** 2).mean()) + print(f"{k * 0.5:4.1f}s {20 * np.log10(rms + 1e-9):6.1f} dB peak {20 * np.log10(np.abs(seg).max() + 1e-9):6.1f}") + print("->", OUT) + + +if __name__ == "__main__": + main() diff --git a/Tools/Video/opening_prologue.py b/Tools/Video/opening_prologue.py new file mode 100644 index 0000000..6ab0a56 --- /dev/null +++ b/Tools/Video/opening_prologue.py @@ -0,0 +1,570 @@ +"""오프닝 프롤로그 영상 렌더러 — 오프닝 일러스트 3장(가로/세로)을 20초 2.5D 모션 영상으로 만든다. + + python -X utf8 Tools/Video/opening_prologue.py landscape|portrait [--preview 2.0,6.5,...] + +산출: Assets/StreamingAssets/Video/opening_prologue_.mp4 (무음 — 소리는 opening_audio.py가 만든 wav를 +게임의 AudioSource가 튼다). --preview면 영상 대신 지정 시각의 프레임만 PNG로 스크래치에 떨군다. + +**타임라인은 OpeningSequenceState(Assets/Scripts/Opening)의 상수와 맞물린다.** 내레이션·타이틀은 게임이 +IMGUI로 얹으므로 여기서는 그 자리를 비워 두기만 한다. 한쪽 시각을 바꾸면 반드시 다른 쪽도 고친다. + + 0.0 ~ 9.6 그림1 밤숲 길 — 빛이 하나씩 꺼지고(잦아듦), 길 끝에 그물을 든 검은 코트가 선다. + 남은 빛은 그 그물로 빨려 들어가 사라진다. 8.3~9.4 암전. + 9.6 ~ 15.0 그림2 파트너 — 어둠 속에서 떠오르고, 꺼졌던 빛이 하나둘 돌아온다(테마곡 10.0 시작). + 14.0~ 15.0 그림2→3 맞춤 컷(두 그림의 눈을 같은 자리·같은 크기에 겹친다). + 15.0 ~ 20.0 그림3 빛나는 잎 — 16.0 곡의 강세에 빛이 번쩍이고 타이틀이 뜬다. 19.2~20.0 페이드아웃. + +numpy + PIL + ffmpeg만 쓴다. 메모리를 아끼려고 샷은 필요한 구간에서만 올렸다가 버린다. +""" +import os +import subprocess +import sys +import time + +import numpy as np +from PIL import Image, ImageDraw, ImageFilter + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +SRC = os.path.join(ROOT, "Assets", "Resources", "UI", "Opening", "opening_0{}_{}.jpg") +OUT_DIR = os.path.join(ROOT, "Assets", "StreamingAssets", "Video") +FF = os.environ.get("FFMPEG", "C:/Users/kss11/AppData/Local/Programs/Python/Python312/Scripts/ffmpeg.exe") +FPS = 24 +T_TOTAL = 20.0 + +# ── 타임라인 (OpeningSequenceState와 같은 값) ── +S1_END = 9.6 +DIM = (1.5, 7.5) # 숲이 가라앉는 구간 +VANISH1 = (1.2, 6.6) # 그림 속 빛이 꺼지는 구간 +FIGURE_IN = (4.3, 5.9) # 길 끝 실루엣 +COLLECT = (4.9, 6.1, 8.1) # 빨려가기 시작 / 첫 도착 / 마지막 도착 +DIP1 = (8.3, 9.4) # 암전 +S2_IN = (9.8, 10.9) +REVIVE2 = (10.8, 14.0) # 그림2의 빛이 돌아오는 구간 +D23 = (14.0, 15.0) # 맞춤 컷 +TITLE_HIT = 16.0 +FADE_OUT = (19.2, 20.0) + +ORIENT = sys.argv[1] if len(sys.argv) > 1 else "landscape" +if ORIENT not in ("landscape", "portrait"): + raise SystemExit("orientation must be landscape|portrait") +PREVIEW = None +if "--preview" in sys.argv: + PREVIEW = [float(v) for v in sys.argv[sys.argv.index("--preview") + 1].split(",")] +SCRATCH = os.environ.get("PREVIEW_DIR", os.path.join(ROOT, "Artifacts", "opening-prologue-preview")) + +if ORIENT == "landscape": + SW, SH, W, H = 1920, 1080, 1280, 720 +else: + SW, SH, W, H = 1080, 1920, 720, 1280 + +rng = np.random.default_rng(11) + + +# ───────────────────────── 수학 도우미 ───────────────────────── +def smooth(u): + u = np.clip(u, 0.0, 1.0) + return u * u * u * (u * (u * 6 - 15) + 10) + + +def sstep(a, b, x): + t = np.clip((x - a) / (b - a), 0.0, 1.0) + return t * t * (3 - 2 * t) + + +def _box1d(a, r, axis): + if r < 1: + return a + pad = [(0, 0)] * a.ndim + pad[axis] = (r + 1, r) + p = np.pad(a, pad, mode="edge") + c = np.cumsum(p, axis=axis, dtype=np.float64) + n = a.shape[axis] + hi = np.take(c, np.arange(2 * r + 1, 2 * r + 1 + n), axis=axis) + lo = np.take(c, np.arange(0, n), axis=axis) + return ((hi - lo) / (2 * r + 1)).astype(np.float32) + + +def blur_f(arr, sigma): + """float 가우시안 근사(박스 3회) — 이 PC의 Pillow는 'F' 모드 GaussianBlur를 지원하지 않는다.""" + r = max(1, int(round((np.sqrt(12 * sigma * sigma / 3 + 1) - 1) / 2))) + out = arr.astype(np.float32) + for _ in range(3): + out = _box1d(_box1d(out, r, 0), r, 1) + return out + + +def lum(a): + return a[..., 0] * 0.299 + a[..., 1] * 0.587 + a[..., 2] * 0.114 + + +# ───────────────────────── 카메라 ───────────────────────── +def crop_box(cam, u): + (z0, cx0, cy0), (z1, cx1, cy1) = cam + z = z0 + (z1 - z0) * u + cx = cx0 + (cx1 - cx0) * u + cy = cy0 + (cy1 - cy0) * u + w, h = SW / z, SH / z + cx = min(max(cx, w / 2), SW - w / 2) + cy = min(max(cy, h / 2), SH - h / 2) + return (cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2) + + +def clamped_center(z, cx, cy): + w, h = SW / z, SH / z + return min(max(cx, w / 2), SW - w / 2), min(max(cy, h / 2), SH - h / 2) + + +def to_screen(cam, u, p): + x0, y0, x1, y1 = crop_box(cam, u) + return np.array([(p[0] - x0) * W / (x1 - x0), (p[1] - y0) * H / (y1 - y0)], np.float32) + + +def warp(arr, cam, u): + box = crop_box(cam, u) + if arr.ndim == 2: + return np.asarray(Image.fromarray(arr.astype(np.float32)).resize((W, H), Image.Resampling.BICUBIC, box=box)) + u8 = Image.fromarray((np.clip(arr, 0, 1) * 255 + 0.5).astype(np.uint8)) + return np.asarray(u8.resize((W, H), Image.Resampling.LANCZOS, box=box)).astype(np.float32) / 255.0 + + +# ───────────────────────── 길 끝의 실루엣 ───────────────────────── +def figure_layer(height): + """그물을 든 검은 코트(뒷모습에 가까운 3/4) — 명부회 하수의 인상. 얼굴은 그리지 않는다. + 반환: (rgb, alpha, 발끝 기준 오프셋, 그물테 중심 오프셋). 4배로 그려 줄여 가장자리를 부드럽게.""" + ss = 4 + Hf = height * ss + cw, ch = int(Hf * 1.3), int(Hf * 1.55) + ox, oy = int(cw * 0.36), int(ch * 0.97) # 발끝 기준점 + im = Image.new("L", (cw, ch), 0) + d = ImageDraw.Draw(im) + + def P(x, y): + return (ox + x * Hf, oy + y * Hf) + + # 다리(밑단 아래로 조금) + d.polygon([P(-0.075, 0), P(-0.03, 0), P(-0.035, -0.2), P(-0.08, -0.2)], fill=255) + d.polygon([P(0.03, 0), P(0.078, 0), P(0.075, -0.2), P(0.03, -0.2)], fill=255) + # 코트 — 밑단이 퍼지고 허리가 들어간 긴 외투. 오른쪽 자락이 바람에 조금 더 벌어진다 + coat = [(-0.175, -0.13), (-0.12, -0.155), (-0.02, -0.14), (0.09, -0.15), (0.205, -0.12), + (0.16, -0.32), (0.12, -0.5), (0.15, -0.66), (0.155, -0.74), (0.11, -0.785), + (0.06, -0.8), (-0.06, -0.8), (-0.12, -0.78), (-0.16, -0.73), (-0.15, -0.64), + (-0.12, -0.5), (-0.14, -0.3)] + d.polygon([P(x, y) for x, y in coat], fill=255) + # 그물 쪽 팔 — 어깨에서 앞으로 내민 소매 + d.polygon([P(0.1, -0.74), P(0.16, -0.7), P(0.2, -0.56), P(0.17, -0.52), P(0.12, -0.6)], fill=255) + # 머리 + 챙 깊은 모자 + d.ellipse([P(-0.055, -0.93), P(0.055, -0.8)], fill=255) + d.ellipse([P(-0.135, -0.925), P(0.135, -0.885)], fill=255) + d.polygon([P(-0.07, -0.905), P(0.07, -0.905), P(0.062, -0.99), P(0.03, -1.01), P(-0.03, -1.01), P(-0.062, -0.99)], fill=255) + # 그물채 — 손에서 비스듬히 위로 + hand, top = P(0.19, -0.54), P(0.52, -1.2) + d.line([hand, top], fill=255, width=max(2, int(0.014 * Hf))) + hoop_c = (0.575, -1.27) + rx, ry = 0.1, 0.055 + d.ellipse([P(hoop_c[0] - rx, hoop_c[1] - ry), P(hoop_c[0] + rx, hoop_c[1] + ry)], + outline=255, width=max(2, int(0.012 * Hf))) + # 그물 자루(반투명) — 테 아래로 처진다 + bag = Image.new("L", (cw, ch), 0) + db = ImageDraw.Draw(bag) + db.polygon([P(hoop_c[0] - rx, hoop_c[1]), P(hoop_c[0] + rx, hoop_c[1]), P(hoop_c[0] + 0.04, hoop_c[1] + 0.19), + P(hoop_c[0] - 0.01, hoop_c[1] + 0.22)], fill=110) + mask = np.maximum(np.asarray(im, np.float32), np.asarray(bag, np.float32)) / 255.0 + small = Image.fromarray((mask * 255).astype(np.uint8)).resize((cw // ss, ch // ss), Image.Resampling.LANCZOS) + a = np.asarray(small, np.float32) / 255.0 + a = blur_f(a, 0.6) # 원경 — 아주 살짝 흐리게 + # 등 뒤 하늘빛이 윤곽을 스친다 — 팽창 윤곽의 위쪽 절반만 + dil = np.asarray(Image.fromarray((a * 255).astype(np.uint8)).filter(ImageFilter.MaxFilter(5)), np.float32) / 255.0 + rim = np.clip(dil - a, 0, 1) + yy = np.linspace(0, 1, a.shape[0], dtype=np.float32)[:, None] + rim *= (1 - sstep(0.35, 0.8, yy)) + body = np.array([0.012, 0.018, 0.04], np.float32) + rimc = np.array([0.30, 0.46, 0.85], np.float32) + rgb = body * a[..., None] + rimc * rim[..., None] * 0.55 + alpha = np.clip(a * 0.9 + rim * 0.45, 0, 1) + foot = (ox // ss, oy // ss) + hoop = (foot[0] + hoop_c[0] * height, foot[1] + hoop_c[1] * height) + return rgb.astype(np.float32), alpha.astype(np.float32), foot, hoop + + +# ───────────────────────── 샷(그림 한 장) ───────────────────────── +class Shot: + def __init__(self, idx, cam_bg, cam_fg, mask_cfg, t0, t1, spot_mode, spot_window=None, figure=None): + self.idx, self.t0, self.t1 = idx, t0, t1 + self.cam_bg, self.cam_fg = cam_bg, cam_fg + img = np.asarray(Image.open(SRC.format(idx, ORIENT)).convert("RGB")).astype(np.float32) / 255.0 + assert img.shape[:2] == (SH, SW), img.shape + self.src = img + self.mask = self.build_mask(img, **mask_cfg) + self.detect_spots(img) + mh = np.clip(self.mask * 1.6, 0, 1) + keep = 1.0 - mh + num = blur_f(img * keep[..., None], 48) + den = blur_f(keep, 48)[..., None] + 1e-4 + self.fill = num / den * 0.85 + self.mh = mh + self.spot_mode, self.spot_window = spot_mode, spot_window + self.setup_spot_schedule() + self.figure = None + if figure: + fx, fy, fh = figure + rgb, a, foot, hoop = figure_layer(fh) + x0, y0 = int(fx - foot[0]), int(fy - foot[1]) + self.figure = (x0, y0, rgb, a) + self.hoop_src = (x0 + hoop[0], y0 + hoop[1]) + + @staticmethod + def build_mask(img, cx=0.5, cy=0.5, rx=0.5, ry=0.5, inner=0.62, outer=1.02, bottom=0.0, green_w=0.5, blur=30): + ys, xs = np.mgrid[0:SH, 0:SW].astype(np.float32) + r = np.sqrt(((xs / SW - cx) / rx) ** 2 + ((ys / SH - cy) / ry) ** 2) + pos = sstep(inner, outer, r) + if bottom > 0: + pos = np.maximum(pos, sstep(1.0 - bottom, 1.0, ys / SH) * 0.9) + g_dom = sstep(-0.02, 0.10, img[..., 1] - img[..., 2]) + near = np.clip(g_dom * sstep(0.03, 0.14, lum(img)), 0, 1) + m = pos * ((1 - green_w) + green_w * near) + m = blur_f(m, blur) + m = sstep(0.18, 0.55, m) + m = blur_f(m, blur * 0.6) + return np.clip(m, 0, 1).astype(np.float32) + + def detect_spots(self, img, max_spots=70): + l = lum(img) + warm = (img[..., 0] > 0.55) & (img[..., 1] > 0.42) & (img[..., 2] < img[..., 1] * 0.8) + cand = (l > 0.55) & warm + lu8 = Image.fromarray((np.clip(l, 0, 1) * 255).astype(np.uint8)) + mx = np.asarray(lu8.filter(ImageFilter.MaxFilter(9))).astype(np.float32) / 255.0 + peaks = cand & (np.abs(l - mx) < 1e-3) + ys, xs = np.nonzero(peaks) + order = np.argsort(-l[ys, xs]) + spots = [] + for k in order: + y, x = int(ys[k]), int(xs[k]) + if any((x - sx) ** 2 + (y - sy) ** 2 < 18 ** 2 for sx, sy, _ in spots): + continue + y0, y1, x0, x1 = max(0, y - 20), min(SH, y + 21), max(0, x - 20), min(SW, x + 21) + if int(cand[y0:y1, x0:x1].sum()) > 260: # 큰 광원(뿔·노을)은 반딧불이 아니다 + continue + area = int(cand[y0:y1, x0:x1].sum()) + spots.append((x, y, float(np.clip(np.sqrt(max(area, 1) / np.pi) * 2.2 + 5, 6, 20)))) + if len(spots) >= max_spots: + break + self.spots = spots + s = np.zeros((SH, SW), np.float32) + boxes = [] + for x, y, rad in spots: + y0, y1 = max(0, int(y - rad * 2)), min(SH, int(y + rad * 2) + 1) + x0, x1 = max(0, int(x - rad * 2)), min(SW, int(x + rad * 2) + 1) + yy, xx = np.mgrid[y0:y1, x0:x1] + d2 = (xx - x) ** 2 + (yy - y) ** 2 + s[y0:y1, x0:x1] = np.maximum(s[y0:y1, x0:x1], np.exp(-d2 / (2 * (rad * 0.75) ** 2))) + boxes.append(((y0, y1, x0, x1), np.clip(np.exp(-d2 / (2 * (rad * 0.8) ** 2)) * 1.4, 0, 1)[..., None].astype(np.float32))) + keep = 1 - np.clip(s * 1.5, 0, 1) + num = blur_f(img * keep[..., None], 14) + den = blur_f(keep, 14)[..., None] + 1e-4 + self.nofly = num / den + self.spot_masks = boxes + + def setup_spot_schedule(self): + n = len(self.spots) + self.tw_phase = rng.uniform(0, 2 * np.pi, n) + self.tw_freq = rng.uniform(0.6, 1.8, n) + self.death = np.full(n, 1e9) + self.birth = np.full(n, -1e9) + if self.spot_mode == "vanish": + a, b = self.spot_window + k = int(n * 0.78) + order = rng.permutation(n)[:k] + self.death[order] = np.sort(np.linspace(a, b, k) + rng.uniform(-0.08, 0.08, k)) + elif self.spot_mode == "revive": + a, b = self.spot_window + order = rng.permutation(n) + self.birth[order] = np.sort(np.linspace(a, b, n) + rng.uniform(-0.06, 0.06, n)) + + def spot_off_weight(self, t): + w = 0.28 * (0.5 + 0.5 * np.sin(self.tw_phase + t * self.tw_freq * 2 * np.pi)) ** 2 + dead = sstep(0.0, 0.45, t - self.death) + flick = np.where((t > self.death - 0.35) & (t < self.death), 0.35 * (np.sin((t - self.death) * 60) > 0), 0.0) + unborn = 1 - sstep(0.0, 0.5, t - self.birth) + return np.clip(np.maximum(np.maximum(w, dead), unborn) + flick * (1 - dead), 0, 1) + + def source_at(self, t): + src = self.src.copy() + for (box, m), w in zip(self.spot_masks, self.spot_off_weight(t)): + if w < 0.01: + continue + y0, y1, x0, x1 = box + a = m * w + src[y0:y1, x0:x1] = src[y0:y1, x0:x1] * (1 - a) + self.nofly[y0:y1, x0:x1] * a + if self.figure is not None: + fa = float(sstep(*FIGURE_IN, t)) + if fa > 0.001: + x0, y0, rgb, a = self.figure + h, w = a.shape + ya, yb, xa, xb = max(0, y0), min(SH, y0 + h), max(0, x0), min(SW, x0 + w) + sub_a = a[ya - y0:yb - y0, xa - x0:xb - x0][..., None] * fa + sub_rgb = rgb[ya - y0:yb - y0, xa - x0:xb - x0] + # 원경이라 대기색을 20% 섞는다 — 새까만 오려붙이기로 보이지 않게 + region = src[ya:yb, xa:xb] + haze = region * 0.2 + sub_rgb * 0.8 + src[ya:yb, xa:xb] = region * (1 - sub_a) + haze * sub_a + return src + + def u(self, t): + return smooth((t - self.t0) / (self.t1 - self.t0)) + + def render(self, t): + u = self.u(t) + src = self.source_at(t) + bgsrc = src * (1 - self.mh[..., None]) + self.fill * self.mh[..., None] + bg = warp(bgsrc, self.cam_bg, u) + fg = warp(src, self.cam_fg, u) + m = warp(self.mask, self.cam_fg, u) + return bg * (1 - m[..., None]) + fg * m[..., None] + + +# ───────────────────────── 떠다니는 빛 ───────────────────────── +def make_sprite(sig_core, sig_halo, size): + r = size // 2 + yy, xx = np.mgrid[-r:r + 1, -r:r + 1].astype(np.float32) + d2 = xx ** 2 + yy ** 2 + return np.exp(-d2 / (2 * sig_core ** 2)) + 0.35 * np.exp(-d2 / (2 * sig_halo ** 2)) + + +SCALE = min(W, H) / 720.0 +SPRITES = [make_sprite((1.2 + 0.5 * k) * SCALE, (5 + 2.5 * k) * SCALE, int((41 + 8 * k) * SCALE) | 1) for k in range(4)] +FLY_COL = np.array([1.0, 0.84, 0.38], np.float32) +COLD_COL = np.array([0.62, 0.78, 1.0], np.float32) + + +def blit(canvas, spr, x, y, col, b): + r = spr.shape[0] // 2 + xi, yi = int(round(x)), int(round(y)) + ya, yb, xa, xb = yi - r, yi + r + 1, xi - r, xi + r + 1 + sy0, sx0 = max(0, -ya), max(0, -xa) + ya, xa = max(ya, 0), max(xa, 0) + yb, xb = min(yb, H), min(xb, W) + if ya >= yb or xa >= xb: + return + canvas[ya:yb, xa:xb] += spr[sy0:sy0 + yb - ya, sx0:sx0 + xb - xa, None] * col * b + + +class Fireflies: + def __init__(self, n, t0, t1, rise=0.0, vanish=None, birth_window=None, seed=0, region=(0.05, 0.95, 0.1, 0.9)): + r = np.random.default_rng(seed) + x0, x1, y0, y1 = region + self.p = np.stack([r.uniform(x0, x1, n) * W, r.uniform(y0, y1, n) * H], 1) + self.v = r.normal(0, 9 * SCALE, (n, 2)) + np.array([0, -rise * SCALE]) + self.ph = r.uniform(0, 2 * np.pi, (n, 3)) + self.fr = r.uniform(0.3, 0.9, (n, 3)) + self.size = r.integers(0, 4, n) + self.base = r.uniform(0.35, 1.0, n) + self.t0, self.t1 = t0, t1 + if birth_window: + a, b = birth_window + self.birth = np.sort(np.linspace(a, b, n) + r.uniform(-0.1, 0.1, n)) + r.shuffle(self.birth) + else: + self.birth = t0 + r.uniform(0, 0.8, n) + self.death = np.full(n, 1e9) + if vanish: + a, b = vanish + k = int(n * 0.8) + idx = r.permutation(n)[:k] + self.death[idx] = np.sort(np.linspace(a, b, k) + r.uniform(-0.1, 0.1, k)) + + def draw(self, canvas, t, strength): + if t < self.t0 or t > self.t1 or strength <= 0.001: + return + tt = t - self.t0 + for i in range(len(self.p)): + a = sstep(0, 0.6, t - self.birth[i]) * (1 - sstep(0, 0.5, t - self.death[i])) + if a <= 0.005: + continue + wob = np.array([np.sin(self.ph[i, 0] + tt * self.fr[i, 0] * 2 * np.pi) * 14, + np.sin(self.ph[i, 1] + tt * self.fr[i, 1] * 2 * np.pi) * 9]) * SCALE + x, y = self.p[i] + self.v[i] * tt + wob + blink = (0.5 + 0.5 * np.sin(self.ph[i, 2] + tt * self.fr[i, 2] * 2 * np.pi * 1.6)) ** 1.5 + blit(canvas, SPRITES[self.size[i]], x, y, FLY_COL, self.base[i] * (0.25 + 0.75 * blink) * a * strength) + + +class Collected: + """그물로 빨려 드는 빛 — 제자리에서 떨다가 끌려가고, 테에 닿는 순간 차갑게 한 번 번쩍이고 꺼진다.""" + + def __init__(self, n, seed, region): + r = np.random.default_rng(seed) + x0, x1, y0, y1 = region + self.p0 = np.stack([r.uniform(x0, x1, n) * W, r.uniform(y0, y1, n) * H], 1) + self.start = np.sort(r.uniform(COLLECT[0], COLLECT[0] + 1.6, n)) + self.arrive = np.sort(np.linspace(COLLECT[1], COLLECT[2], n) + r.uniform(-0.08, 0.08, n)) + self.arrive = np.maximum(self.arrive, self.start + 1.0) + self.size = r.integers(1, 4, n) + self.swirl = r.uniform(-1, 1, n) * 60 * SCALE + self.ph = r.uniform(0, 2 * np.pi, n) + + def draw(self, canvas, t, target, strength): + for i in range(len(self.p0)): + if t > self.arrive[i] + 0.35: + continue + born = sstep(0.0, 0.6, t - 0.3) + if t <= self.arrive[i]: + s = smooth((t - self.start[i]) / (self.arrive[i] - self.start[i])) + d = target - self.p0[i] + perp = np.array([-d[1], d[0]]) / (np.linalg.norm(d) + 1e-3) + pos = self.p0[i] + d * (s ** 1.6) + perp * self.swirl[i] * np.sin(np.pi * s) + pos = pos + np.array([np.sin(self.ph[i] + t * 5.0), np.cos(self.ph[i] + t * 4.1)]) * 5 * SCALE * (1 - s) + blink = 0.7 + 0.3 * np.sin(self.ph[i] + t * 9.0) + blit(canvas, SPRITES[self.size[i]], pos[0], pos[1], FLY_COL, 0.9 * blink * born * strength) + else: + k = 1 - (t - self.arrive[i]) / 0.35 # 테에 닿은 뒤 0.35초 차가운 번쩍임 + blit(canvas, SPRITES[3], target[0], target[1], COLD_COL, 0.55 * k * k * strength) + + +# ───────────────────────── 샷 구성 ───────────────────────── +def shot_configs(): + """방향별 카메라·마스크·실루엣 좌표. 좌표는 원화 픽셀(가로 1920×1080 / 세로 1080×1920) 기준.""" + if ORIENT == "landscape": + e2, sp2 = (1040.0, 715.0), 245.0 # 그림2·3의 두 눈 중점과 간격 — 맞춤 컷의 기준 + e3, sp3 = (1015.5, 744.0), 148.0 + s1 = dict(cam_bg=((1.06, 960, 540), (1.40, 1060, 560)), + cam_fg=((1.06, 960, 560), (1.56, 1085, 625)), + mask_cfg=dict(cx=0.55, cy=0.45, rx=0.46, ry=0.5, inner=0.55, outer=0.98, bottom=0.18, green_w=0.55), + figure=(1068, 642, 150)) + s2_start = ((1.22, 1060, 600), (1.30, 1100, 625)) + z2e = 1.06 + s3_end = (1.10, 960, 525) + else: + e2, sp2 = (594.5, 1367.0), 196.0 + e3, sp3 = (578.5, 1307.5), 147.0 + s1 = dict(cam_bg=((1.04, 540, 960), (1.48, 528, 960)), + cam_fg=((1.04, 540, 980), (1.64, 528, 1050)), + mask_cfg=dict(cx=0.5, cy=0.42, rx=0.5, ry=0.52, inner=0.55, outer=0.98, bottom=0.25, green_w=0.55), + figure=(522, 950, 150)) + s2_start = ((1.24, 594, 1250), (1.32, 600, 1290)) + z2e = 1.06 + s3_end = (1.08, 540, 1010) + # 그림2 끝 카메라는 눈 중점을 겨누되 화면 밖으로 못 나가게 잘린다 — 잘린 중심으로 그림3 시작을 역산한다. + c2 = clamped_center(z2e, *e2) + z3s = z2e * sp2 / sp3 + c3 = (e3[0] - (e2[0] - c2[0]) * z2e / z3s, e3[1] - (e2[1] - c2[1]) * z2e / z3s) + s2 = dict(cam_bg=(s2_start[0], (z2e, c2[0], c2[1])), cam_fg=(s2_start[1], (z2e, c2[0], c2[1])), + mask_cfg=dict(cx=0.5, cy=0.5, rx=0.40, ry=0.62, inner=0.62, outer=1.0, bottom=0.12, green_w=0.6)) + s3 = dict(cam_bg=((z3s, c3[0], c3[1]), s3_end), + cam_fg=((z3s * 1.06, c3[0], c3[1] + 10), s3_end), + mask_cfg=dict(cx=0.5, cy=0.42, rx=0.48, ry=0.55, inner=0.60, outer=1.0, bottom=0.2, green_w=0.5)) + return s1, s2, s3 + + +def make_shot(k, cfgs): + s1, s2, s3 = cfgs + if k == 1: + return Shot(1, s1["cam_bg"], s1["cam_fg"], s1["mask_cfg"], 0.0, S1_END, "vanish", VANISH1, figure=s1["figure"]) + if k == 2: + return Shot(2, s2["cam_bg"], s2["cam_fg"], s2["mask_cfg"], S1_END, D23[1], "revive", REVIVE2) + return Shot(3, s3["cam_bg"], s3["cam_fg"], s3["mask_cfg"], D23[0], T_TOTAL, None) + + +def shot_weights(t): + w1 = 1.0 if t < S1_END else 0.0 + w3 = float(sstep(*D23, t)) if t >= D23[0] else 0.0 + w2 = (1.0 - w3) if S1_END <= t < D23[1] else 0.0 + return w1, w2, w3 + + +# ───────────────────────── 렌더 루프 ───────────────────────── +def main(): + cfgs = shot_configs() + shots = {} + flies1 = Fireflies(22, 0.0, S1_END, vanish=(1.4, 6.0), seed=1) + collected = Collected(10, 7, (0.08, 0.92, 0.25, 0.9)) + flies2 = Fireflies(16, S1_END, D23[1], birth_window=(10.9, 14.2), seed=2, region=(0.1, 0.9, 0.12, 0.8)) + flies3 = Fireflies(34, D23[0], T_TOTAL, rise=22, seed=3, region=(0.12, 0.88, 0.35, 1.0)) + + ys, xs = np.mgrid[0:H, 0:W].astype(np.float32) + rr = np.sqrt(((xs / W - 0.5) / 0.5) ** 2 + ((ys / H - 0.5) / 0.5) ** 2) + vign = (1 - 0.38 * sstep(0.45, 1.25, rr))[..., None] + grain_rng = np.random.default_rng(5) + + times = PREVIEW if PREVIEW else [f / FPS for f in range(int(round(T_TOTAL * FPS)))] + proc = None + out = os.path.join(OUT_DIR, f"opening_prologue_{ORIENT}.mp4") + if not PREVIEW: + os.makedirs(OUT_DIR, exist_ok=True) + cmd = [FF, "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(FPS), + "-i", "-", "-c:v", "libx264", "-profile:v", "main", "-level", "4.0", "-pix_fmt", "yuv420p", + "-b:v", "1600k", "-maxrate", "2000k", "-bufsize", "4000k", "-movflags", "+faststart", "-an", out] + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE) + else: + os.makedirs(SCRATCH, exist_ok=True) + + t_start = time.time() + for fi, t in enumerate(times): + weights = shot_weights(t) + # 필요한 샷만 올리고, 지난 샷은 버린다(메모리) + for k, w in zip((1, 2, 3), weights): + if w > 0.001 and k not in shots: + shots[k] = make_shot(k, cfgs) + for k in list(shots): + if weights[k - 1] <= 0.001: + del shots[k] + + frame = np.zeros((H, W, 3), np.float32) + for k, w in zip((1, 2, 3), weights): + if w <= 0.001: + continue + layer = shots[k].render(t) + if k == 1: + layer = layer * (1.0 - 0.25 * sstep(*DIM, t)) + frame += layer * w + + glow = np.zeros_like(frame) + if weights[0] > 0: + flies1.draw(glow, t, 0.9) + s1 = shots[1] + target = to_screen(s1.cam_bg, s1.u(t), s1.hoop_src) + collected.draw(glow, t, target, 1.0) + if weights[1] > 0: + flies2.draw(glow, t, 0.85 * min(1.0, weights[1] * 1.5)) + if weights[2] > 0: + flies3.draw(glow, t, 0.9 * min(1.0, weights[2] * 1.5)) + frame = frame + glow + + # 밝기 봉투 — 페이드인 / 암전 / 그림2 떠오름 / 페이드아웃 + env = float(sstep(0.0, 0.9, t)) + if t < S1_END: + env *= 1.0 - float(sstep(*DIP1, t)) + elif t < D23[1]: + env *= float(sstep(*S2_IN, t)) + env *= 1.0 - float(sstep(*FADE_OUT, t)) + + # 타이틀 강세 — 곡의 강세(16.0)에 빛이 한 번 번진다 + hit = 0.0 + if t >= TITLE_HIT - 0.12: + dt = t - TITLE_HIT + hit = float(sstep(-0.12, 0.05, dt)) * float(np.exp(-max(dt, 0.0) / 0.7)) + bright = np.clip(frame - (0.5 - 0.15 * hit), 0, None) + small = Image.fromarray((np.clip(bright, 0, 1) * 255).astype(np.uint8)).resize((W // 4, H // 4), Image.Resampling.BILINEAR) + small = small.filter(ImageFilter.GaussianBlur(5)) + bloom = np.asarray(small.resize((W, H), Image.Resampling.BICUBIC)).astype(np.float32) / 255.0 + pulse = 0.55 + 0.12 * np.sin(t * 2 * np.pi * 0.35) + 0.65 * hit + frame = frame * (1.0 + 0.08 * hit) + bloom * pulse + + flicker = 1.0 + 0.012 * np.sin(t * 2 * np.pi * 0.7) + 0.006 * np.sin(t * 2 * np.pi * 2.3) + frame = frame * flicker * vign + frame = frame + grain_rng.normal(0, 0.011, (H, W, 1)).astype(np.float32) + frame = frame * env + u8 = (np.clip(frame, 0, 1) * 255 + 0.5).astype(np.uint8) + if proc: + proc.stdin.write(u8.tobytes()) + if fi % 48 == 0: + print(f"frame {fi}/{len(times)} {time.time() - t_start:.1f}s", flush=True) + else: + Image.fromarray(u8).save(os.path.join(SCRATCH, f"{ORIENT}_t{t:05.2f}.png")) + print(f"preview t={t:.2f} {time.time() - t_start:.1f}s", flush=True) + if proc: + proc.stdin.close() + proc.wait() + print(f"render {time.time() - t_start:.1f}s -> {out} ({os.path.getsize(out) / 1e6:.2f} MB)") + + +if __name__ == "__main__": + main() diff --git a/Tools/Video/sample_ch7_fence.py b/Tools/Video/sample_ch7_fence.py new file mode 100644 index 0000000..b993f4f --- /dev/null +++ b/Tools/Video/sample_ch7_fence.py @@ -0,0 +1,858 @@ +"""0단계 화풍 비교 샘플 — 2막 개막 「움직이는 구멍」을 두 화풍으로 렌더한다(게임에 연결하지 않는다). + + python -X utf8 Tools/Video/sample_ch7_fence.py [sil|book|all] [--preview 1.0,3.5,...] [--sheet] + +산출: Artifacts/style-sample/ch7_fence_