2026-09-13 07:18:40 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// WLIslandGateFlow.cs — 「초기에는 밭·던전을 숨기고, 밭 자리의 발판에 10 골드를 내면 던전이 열린다」
/ /
// PD 지시 원문(2026-09-13 · 발주서 WL-816g §2·§3)
// 「최초에는 작물을 심을 수 있는 밭과 던전을 모두 안보이게 하고,
// 현재 밭 위치에 골드를 납품할 수 있는 발판 1개만 배치해줘.
// 초기 보유 골드는 10 골드이고, 이 발판 위로 올라와 10 골드를 지불하면 던전이 생성되어야 해.」
/ /
// ■ 🔴 PD 정정(2026-09-13) 「**던전 열기 골드 발판은 원래 에셋에 있던 것을 활용하면 돼.**」
// → 발판 = FI 가 잠긴 섬 옆에 세우는 **그 가격판 프리팹 그대로**다(`Purchaser.prefab`).
// 모양·숫자·지불 감각이 전부 섬 확장과 같다. **새 모델을 만들지 않는다**(더미 원판은 기본 꺼짐).
// 실측: 그 가격판(`Price`)은 루트보다 1 m 앞에서 **바닥에 눕는 4× 2 m 판**이다 — 그 자체가 발판이다.
/ /
// ■ 🔴 지불 방식 = FI `Purchaser` **그대로 재사용**한다(발주서 §2 지시).
// FI 섬 확장과 **똑같은 조작·똑같은 연출**이다 — 발판(트리거) 위에 서 있으면 코인이 초당
// 빨려 들어가고(최대 5초 · 피치가 올라가는 Pop SFX · 가격판이 줄어듦), 다 내면 물보라가
// 터지고 대상이 `Activate()` 된다. 학습 비용 0.
// 재사용 방법: `Purchaser.Init(island, price, data)` 가 요구하는 `Island` 자리에
// **보이지 않는 더미 `Island`**(메시 없음)를 물려 주고, 그 `Island.OnActivated`(public event)를
// 받아 「던전 1개 켜기」를 한다. → **FI 코드 0줄 · FI 프리팹 0줄**.
/ /
// ■ 되돌리기(C8) — `gateFlowEnabled = 0` 하나면 지금 상태(밭·던전 처음부터 보임) 100 % 복귀.
/ /
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(ErrorLogHookManager 팝업 방지).
// 🔴 `Assets/FarmingIsland/**` 와 `Assets/Script/**` 는 이 파일 때문에 한 줄도 바뀌지 않는다.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections ;
using System.Collections.Generic ;
using UnityEngine ;
using UnityEngine.SceneManagement ;
using FIFarm = CryingSnow . FarmingIsland . Farm ;
using FIIsland = CryingSnow . FarmingIsland . Island ;
using FIIslandManager = CryingSnow . FarmingIsland . IslandManager ;
2026-09-15 05:59:53 +00:00
using FISoil = CryingSnow . FarmingIsland . Soil ;
2026-09-13 07:18:40 +00:00
using FIPurchaser = CryingSnow . FarmingIsland . Purchaser ;
using FIPurchaserData = CryingSnow . FarmingIsland . PurchaserData ;
namespace WL.Island
{
/// <summary>초기 상태(밭·던전 숨김) + 골드 납품 발판 + 던전 해금 카운터. 정적 · 씬에 남는 상태 없음.</summary>
public static class WLIslandGateFlow
{
// ── 진단(프로브·보고가 읽는다)
public static int FarmsHidden , RenderersHidden , PlatformsMade , Unlocks ;
public static string LastLog = "" ;
public static Vector3 LastPlatformPos ;
/// <summary>해금된 던전 수(PlayerPrefs · FI 세이브를 건드리지 않는다).</summary>
public static int UnlockedCount ( WLIslandSettings cfg )
{
if ( cfg = = null | | cfg . gateFlowEnabled = = 0 ) return int . MaxValue ; // 꺼져 있으면 전부 보인다
return Mathf . Max ( 0 , PlayerPrefs . GetInt ( cfg . dungeonUnlockPrefsKey , 0 ) ) ;
}
public static void SetUnlockedCount ( WLIslandSettings cfg , int n )
{
if ( cfg = = null ) return ;
PlayerPrefs . SetInt ( cfg . dungeonUnlockPrefsKey , Mathf . Max ( 0 , n ) ) ;
PlayerPrefs . Save ( ) ;
}
/// <summary>다음 던전 1개를 여는 값(표에 없으면 마지막 값을 이어 쓴다).</summary>
public static int PriceFor ( WLIslandSettings cfg , int nextIndex )
{
if ( cfg = = null | | cfg . dungeonUnlockPrices = = null | | cfg . dungeonUnlockPrices . Length = = 0 ) return 10 ;
int i = Mathf . Clamp ( nextIndex , 0 , cfg . dungeonUnlockPrices . Length - 1 ) ;
return Mathf . Max ( 0 , cfg . dungeonUnlockPrices [ i ] ) ;
}
/// <summary>표에서 실제로 쓸 수 있는(켜져 있는) 던전 행 수 · `maxDungeons` 로 더 줄일 수 있다.</summary>
public static int UsableDungeonCount ( WLIslandSettings cfg )
{
if ( cfg = = null | | cfg . dungeons = = null ) return 0 ;
int n = 0 ;
for ( int i = 0 ; i < cfg . dungeons . Length ; i + + )
if ( cfg . dungeons [ i ] ! = null & & cfg . dungeons [ i ] . enabled_ ! = 0 ) n + + ;
if ( cfg . maxDungeons > 0 ) n = Mathf . Min ( n , cfg . maxDungeons ) ;
return n ;
}
// ─────────────────────────────────────────────────────────────────
// 진입점 — 섬 준비가 끝난 뒤 1회
// ─────────────────────────────────────────────────────────────────
public static void Apply ( WLIslandSettings cfg , Scene scene )
{
if ( cfg = = null | | cfg . gateFlowEnabled = = 0 ) return ;
Vector3 farmCenter ;
bool haveFarm = HideFarms ( cfg , scene , out farmCenter ) ;
2026-09-15 05:59:53 +00:00
HideIslandFences ( cfg , scene , null ) ;
2026-09-15 14:15:50 +00:00
// 2026-09-15 Lead 실측(광선·전수 조회) — PD 화면의 「허공에 뜬 울타리·바위」 = 전투 스테이지 「보이는 장벽」(WL815c · HideAndDontSave)이
// 던전 → 섬 전환 뒤에도 남은 것. 섬 진입마다 즉시 풀에 반납한다(슬롯 없으면 비용 0).
2026-09-16 10:55:01 +00:00
WL . Combat . Stage . StageBarrier . Clear ( ) ; // 2026-09-16 — 던전에서 열린 채 돌아온 장벽 상태부터 닫는다
2026-09-15 14:15:50 +00:00
int barrier = WL . Combat . Stage . StageBarrierVisual . HideImmediate ( ) ;
if ( barrier > 0 ) WLIslandBridge . Log ( cfg , "전투 장벽 그림 " + barrier + "개 즉시 정리(섬 진입)" ) ;
2026-09-15 09:17:03 +00:00
WLIslandTileFenceStrip . Apply ( cfg , scene ) ; // 2026-09-15 Lead 실측 — 타일 메시에 구워진 난간 제거
2026-09-15 09:50:16 +00:00
HookFenceGuard ( cfg , scene ) ; // 2026-09-15 — 그리기 직전마다 fence 렌더러 차단 + 정체 기록
2026-09-15 06:34:10 +00:00
// 2026-09-15 PD 「게임 시작 시 섬에 울타리가 여전히 남아 있다」 — 시작 직후 FI 타일 활성화가 렌더러를 다시 켜므로
// farmReHideDelaySeconds 간격으로 3회 더 훑는다(이미 꺼진 것은 건너뛴다 · 값 0 이면 무동작).
if ( cfg . hideIslandFences ! = 0 & & WLIslandBridge . Runner ! = null )
WLIslandBridge . Runner . StartCoroutine ( Co_ReHideFences ( cfg , scene ) ) ;
2026-09-15 09:22:10 +00:00
// 2026-09-15 PD 「에러 나는 부분 수정」 — 섬에서 원본 일꾼(WorkerActor · Goblin_Baby)이 DropItemInfo 없이
// 매 프레임 NullReference 를 냈다(실측 199회/3초) → 나타나는 즉시 끈다(처음 20초는 0.2 s · 이후 3 s 간격).
if ( WLIslandBridge . Runner ! = null )
WLIslandBridge . Runner . StartCoroutine ( Co_HideWorkers ( scene ) ) ;
2026-09-15 05:59:53 +00:00
2026-09-13 07:18:40 +00:00
HookIslandActivation ( cfg , scene ) ;
s_farmCenter = farmCenter ;
s_haveFarm = haveFarm ;
2026-09-15 07:04:08 +00:00
// WL-816zc ③ — 원본 섬 확장 가격판은 첫 던전 승리 전까지 숨긴다(켜고 끄기만 · FI 순서 그대로).
WLIslandExpansionGate . Apply ( cfg , scene ) ;
2026-09-13 07:18:40 +00:00
RefreshPlatform ( cfg , scene ) ;
2026-09-15 07:04:08 +00:00
// v2 는 RefreshPlatform 안에서 캠프까지 같이 그린다 — 두 번 그리지 않는다.
if ( ! WLIslandFlowV2 . Active ( cfg ) )
WLIslandCampDecor . Refresh ( cfg , scene ) ; // WL-816y — 세이브 복원(해금 ≥ 1 이면 캠프 재생성)
2026-09-13 07:18:40 +00:00
}
static Vector3 s_farmCenter ;
static bool s_haveFarm ;
2026-09-15 07:04:08 +00:00
/// <summary>밭 구역 중심(WL-816zc 플로우가 첫 자리로 쓴다).</summary>
public static Vector3 FarmCenter { get { return s_farmCenter ; } }
public static bool HaveFarmCenter { get { return s_haveFarm ; } }
2026-09-13 07:18:40 +00:00
/// <summary>
/// 🔴 던전 하나 = **발판 한 자리**(WL-816g · PD 정정 2026-09-13
/// 「던전 열기는 한 번만 · 이후 같은 자리에 또 등장해서는 안 된다」).
/// 값을 치르면 그 자리는 영영 비고, 다음 던전의 발판은 **표가 지정한 다른 자리**에 선다.
/// </summary>
public static Vector3 PlatformPosFor ( WLIslandSettings cfg , int index )
{
var d = NthEnabled ( cfg , index ) ;
Vector3 p ;
if ( d ! = null & & d . platformUseFarm = = 0 ) p = d . platformPosition ;
else if ( cfg . platformUseFarmPosition ! = 0 & & s_haveFarm ) p = s_farmCenter ;
else if ( d ! = null ) p = d . platformPosition ;
else p = cfg . platformPosition ;
p + = cfg . platformOffset ;
return SnapToGround ( cfg , p ) ;
}
/// <summary>좌표 위에서 아래로 쏘아 지면 높이를 맞춘다(맞는 것이 없으면 준 값 그대로).</summary>
2026-09-15 07:04:08 +00:00
public static Vector3 SnapToGround ( WLIslandSettings cfg , Vector3 p )
2026-09-13 07:18:40 +00:00
{
if ( cfg . platformSnapToGround = = 0 ) return p ;
RaycastHit hit ;
if ( Physics . Raycast ( new Vector3 ( p . x , p . y + 30f , p . z ) , Vector3 . down , out hit , 60f ,
~ 0 , QueryTriggerInteraction . Ignore ) )
return new Vector3 ( p . x , hit . point . y , p . z ) ;
return p ;
}
/// <summary>발판을 지금 해금 상태에 맞게 만들거나 지운다.</summary>
public static void RefreshPlatform ( WLIslandSettings cfg , Scene scene )
{
2026-09-15 07:04:08 +00:00
// WL-816zc — 초기 플로우 v2 가 켜져 있으면 발판 순서는 단계 표가 정한다(flowV2Enabled = 0 이면 아래 그대로).
if ( WLIslandFlowV2 . Active ( cfg ) ) { WLIslandFlowV2 . Refresh ( cfg , scene ) ; return ; }
2026-09-13 07:18:40 +00:00
int unlocked = UnlockedCount ( cfg ) ;
int usable = UsableDungeonCount ( cfg ) ;
if ( WLGoldPlatform . Instance ! = null ) { Object . Destroy ( WLGoldPlatform . Instance . gameObject ) ; WLGoldPlatform . Instance = null ; }
if ( unlocked > = usable )
{
LastLog = "발판 없음 — 던전 " + unlocked + "/" + usable + " 전부 해금됨" ;
WLIslandBridge . Log ( cfg , LastLog ) ;
return ;
}
int price = PriceFor ( cfg , unlocked ) ;
Vector3 pos = PlatformPosFor ( cfg , unlocked ) ;
// 🔴 값을 치른 자리에 다음 발판이 곧바로 생기면, **가만히 서 있기만 해도** 던전이 연달아 팔린다
// (골드가 많을 때 실측됨). FI 섬 확장처럼 「한 번 내려왔다가 다시 올라오기」를 요구한다.
var runner = WLIslandBridge . Runner ;
if ( cfg . platformRequireStepOff ! = 0 & & runner ! = null & & PlayerInRange ( cfg , pos ) )
{
runner . StartCoroutine ( Co_SpawnWhenClear ( cfg , scene , pos , price , unlocked , usable ) ) ;
LastLog = "발판 대기 — 플레이어가 자리에서 내려오면 다음 발판(가격 " + price + ")을 놓는다" ;
WLIslandBridge . Log ( cfg , LastLog ) ;
return ;
}
var p = WLGoldPlatform . Create ( cfg , scene , pos , price , unlocked ) ;
if ( p ! = null ) { PlatformsMade + + ; LastPlatformPos = pos ; }
LastLog = "골드 납품 발판 배치 — " + pos . ToString ( "F2" ) + " · 가격 " + price +
" · 다음 던전 #" + ( unlocked + 1 ) + "/" + usable ;
WLIslandBridge . Log ( cfg , LastLog ) ;
}
static IEnumerator Co_SpawnWhenClear ( WLIslandSettings cfg , Scene scene , Vector3 pos , int price , int unlocked , int usable )
{
float t = 0f ;
while ( t < cfg . platformStepOffTimeoutSeconds & & PlayerInRange ( cfg , pos ) )
{
t + = Time . unscaledDeltaTime ;
yield return null ;
}
if ( WLGoldPlatform . Instance ! = null ) yield break ; // 그 사이 다른 경로로 이미 생겼다
var p = WLGoldPlatform . Create ( cfg , scene , pos , price , unlocked ) ;
if ( p ! = null ) { PlatformsMade + + ; LastPlatformPos = pos ; }
LastLog = "골드 납품 발판 배치 — " + pos . ToString ( "F2" ) + " · 가격 " + price +
" · 다음 던전 #" + ( unlocked + 1 ) + "/" + usable ;
WLIslandBridge . Log ( cfg , LastLog ) ;
}
2026-09-15 07:04:08 +00:00
public static bool PlayerInRange ( WLIslandSettings cfg , Vector3 pos )
2026-09-13 07:18:40 +00:00
{
var fi = Object . FindFirstObjectByType < CryingSnow . FarmingIsland . PlayerController > ( ) ;
if ( fi = = null ) return false ;
float r = cfg . platformTriggerSize * 0.5f + 0.6f ;
var d = fi . transform . position - pos ;
d . y = 0f ;
return d . sqrMagnitude < = r * r ;
}
// ─────────────────────────────────────────────────────────────────
// 지불 완료 → 던전 1개 생성
// ─────────────────────────────────────────────────────────────────
public static void OnPaid ( WLIslandSettings cfg , Vector3 platformPos )
{
if ( cfg = = null ) return ;
2026-09-15 07:04:08 +00:00
// WL-816zc — 초기 플로우 v2 가 켜져 있으면 「다음 단계」가 이 자리를 이어받는다.
if ( WLIslandFlowV2 . Active ( cfg ) ) { WLIslandFlowV2 . OnPaid ( cfg , platformPos ) ; return ; }
2026-09-13 07:18:40 +00:00
int unlocked = UnlockedCount ( cfg ) ;
int usable = UsableDungeonCount ( cfg ) ;
if ( unlocked > = usable ) return ;
SetUnlockedCount ( cfg , unlocked + 1 ) ;
Unlocks + + ;
var d = NthEnabled ( cfg , unlocked ) ;
var scene = SceneManager . GetSceneByName ( cfg . islandSceneName ) ;
if ( ! scene . IsValid ( ) | | ! scene . isLoaded ) scene = SceneManager . GetActiveScene ( ) ;
if ( d ! = null ) WLIslandBridge . SpawnOneGate ( cfg , d , scene ) ;
LastLog = "🔴 지불 완료 — 던전 생성 " + ( d ! = null ? d . displayName : "(표 없음)" ) +
" · 해금 " + ( unlocked + 1 ) + "/" + usable +
" · 이 자리(" + platformPos . ToString ( "F1" ) + ")의 발판은 영영 사라진다" ;
WLIslandBridge . Log ( cfg , LastLog ) ;
RefreshPlatform ( cfg , scene ) ;
2026-09-15 06:12:29 +00:00
WLIslandCampDecor . Refresh ( cfg , scene ) ; // WL-816y — 값을 치른 그 자리에 캠프가 남는다
2026-09-13 07:18:40 +00:00
}
public static WLDungeonDef NthEnabled ( WLIslandSettings cfg , int n )
{
if ( cfg = = null | | cfg . dungeons = = null ) return null ;
int k = 0 ;
for ( int i = 0 ; i < cfg . dungeons . Length ; i + + )
{
var d = cfg . dungeons [ i ] ;
if ( d = = null | | d . enabled_ = = 0 ) continue ;
if ( k = = n ) return d ;
k + + ;
}
return null ;
}
// ─────────────────────────────────────────────────────────────────
// 밭 숨기기
// 🔴 `GameObject.SetActive(false)` 를 쓰지 않는다 — 나중에 섬이 열릴 때
// `Farm.Animate(instant:false)` 가 그 오브젝트에서 코루틴을 시작하는데,
// 꺼져 있으면 Unity 가 「inactive 라 코루틴을 못 돌린다」 에러를 찍는다(콘솔 error 0 위반).
// 그래서 **렌더러·콜라이더만** 끈다 = FI 흐름은 그대로 돌고 화면에서만 사라진다.
// ─────────────────────────────────────────────────────────────────
static readonly List < Renderer > s_hiddenRends = new List < Renderer > ( 128 ) ;
static readonly List < Collider > s_hiddenCols = new List < Collider > ( 128 ) ;
static readonly HashSet < FIIsland > s_hooked = new HashSet < FIIsland > ( ) ;
static bool HideFarms ( WLIslandSettings cfg , Scene scene , out Vector3 center )
{
center = Vector3 . zero ;
if ( cfg . hideFarms = = 0 ) return false ;
var farms = Object . FindObjectsByType < FIFarm > ( FindObjectsInactive . Include , FindObjectsSortMode . None ) ;
bool haveCenter = false ;
2026-09-15 04:49:23 +00:00
// 🔴 WL-816u — 「열림 구역」 = 이 발판이 여는 자리 **전부**(보이는 밭 전체)다.
// 816g 는 **처음 만난 밭 하나**의 중심을 썼다 → 밭이 여러 칸이면 구역 정중앙이 아니다(PD 지적).
Bounds zone = new Bounds ( ) ; bool zoneSet = false ;
ZoneBoundsValid = false ;
2026-09-13 07:18:40 +00:00
for ( int i = 0 ; i < farms . Length ; i + + )
{
var f = farms [ i ] ;
if ( f = = null ) continue ;
if ( scene . IsValid ( ) & & f . gameObject . scene ! = scene ) continue ;
// 발판 자리 = **지금 보이는 밭**(= 해금된 섬 위의 밭) **하나**의 한가운데
bool visible = f . gameObject . activeInHierarchy ;
Bounds b = new Bounds ( ) ;
bool boundsSet = false ;
var rends = f . GetComponentsInChildren < Renderer > ( true ) ;
for ( int r = 0 ; r < rends . Length ; r + + )
{
2026-09-15 10:06:15 +00:00
var rr = rends [ r ] ;
if ( rr = = null ) continue ;
// 🔴 WL-816zf — 구역 바운즈는 **이미 꺼 둔 렌더러까지 센다**.
// 3 s 재훑기에서는 밭 렌더러가 전부 꺼져 있어, 켜진 것만 세면 두 번째 훑기부터
// 구역을 통째로 잃는다(ZoneBoundsValid = false → 흙 데칼·발판 자리 소실).
// 끈 렌더러도 `bounds` 는 그대로 유효하다.
2026-09-13 07:18:40 +00:00
if ( visible )
{
2026-09-15 10:06:15 +00:00
if ( ! boundsSet ) { b = rr . bounds ; boundsSet = true ; }
else b . Encapsulate ( rr . bounds ) ;
if ( ! zoneSet ) { zone = rr . bounds ; zoneSet = true ; } // WL-816u — 구역 전체
else zone . Encapsulate ( rr . bounds ) ;
2026-09-13 07:18:40 +00:00
}
2026-09-15 10:06:15 +00:00
if ( ! rr . enabled ) continue ;
2026-09-15 05:59:53 +00:00
// 🔴 WL-816w ③ — 흙칸(Soil)은 남긴다(밭 자리가 흙으로 보이게). 콜라이더는 아래에서 끈다.
2026-09-15 10:06:15 +00:00
// 816zf 데칼이 켜져 있으면 `IsSoilRenderer` 가 false 라 흙칸도 여기서 꺼진다(배타).
if ( IsSoilRenderer ( cfg , rr ) ) { SoilRenderersKept + + ; continue ; }
rr . enabled = false ; s_hiddenRends . Add ( rr ) ; RenderersHidden + + ;
2026-09-13 07:18:40 +00:00
}
var cols = f . GetComponentsInChildren < Collider > ( true ) ;
for ( int c = 0 ; c < cols . Length ; c + + )
{
if ( cols [ c ] = = null | | ! cols [ c ] . enabled ) continue ;
cols [ c ] . enabled = false ; s_hiddenCols . Add ( cols [ c ] ) ;
}
FarmsHidden + + ;
if ( visible & & ! haveCenter )
{
center = boundsSet ? new Vector3 ( b . center . x , b . min . y , b . center . z ) : f . transform . position ;
haveCenter = true ;
}
}
2026-09-15 04:49:23 +00:00
// 🔴 WL-816u — 구역 전체의 XZ 정중앙으로 덮어쓴다(값 1개로 816g 동작 복귀).
if ( zoneSet )
{
ZoneBounds = zone ;
ZoneBoundsValid = true ;
if ( cfg . platformCenterOnZone ! = 0 & & haveCenter )
center = new Vector3 ( zone . center . x , zone . min . y , zone . center . z ) ;
2026-09-15 09:03:31 +00:00
// 2026-09-15 PD 「흙 지역도 라운딩」 — 밭 사각형 네 모서리 근처의 흙칸을 숨겨(값 soilCornerRadius · 0 이면 무동작)
// 흙 자리가 둥글게 읽히게 한다(모서리 자리는 816za 의 풀이 채운다). 콜라이더는 이미 전부 꺼져 있다.
if ( cfg . showFarmSoil ! = 0 & & cfg . soilCornerRadius > 0f )
{
for ( int i = 0 ; i < farms . Length ; i + + )
{
var f = farms [ i ] ;
if ( f = = null | | ( scene . IsValid ( ) & & f . gameObject . scene ! = scene ) ) continue ;
var rends = f . GetComponentsInChildren < Renderer > ( true ) ;
for ( int r = 0 ; r < rends . Length ; r + + )
{
var rr = rends [ r ] ;
if ( rr = = null | | ! rr . enabled | | ! IsSoilRenderer ( cfg , rr ) ) continue ;
var c = rr . bounds . center ;
float dx = Mathf . Min ( c . x - zone . min . x , zone . max . x - c . x ) ;
float dz = Mathf . Min ( c . z - zone . min . z , zone . max . z - c . z ) ;
if ( dx + dz < cfg . soilCornerRadius ) { rr . enabled = false ; s_hiddenRends . Add ( rr ) ; }
}
}
}
2026-09-15 04:49:23 +00:00
}
2026-09-13 07:18:40 +00:00
return haveCenter ;
}
2026-09-15 04:49:23 +00:00
/// <summary>🔴 WL-816u 진단 — 「열림 구역」(숨긴 밭 전부)의 월드 바운즈. 보고·프로브가 오차를 잰다.</summary>
public static Bounds ZoneBounds ;
public static bool ZoneBoundsValid ;
2026-09-15 05:59:53 +00:00
/// <summary>WL-816w 진단 — 남겨 둔 흙칸 렌더러 수 · 끈 울타리 렌더러 수.</summary>
public static int SoilRenderersKept , FenceRenderersHidden ;
2026-09-15 10:06:15 +00:00
/// <summary>
/// WL-816w ③ — 이 렌더러가 흙칸(FI `Soil`)의 것인가(자기 자신 또는 부모).
/// 🔴 WL-816zf — 부드러운 흙 데칼(`WLSoftDirt`)이 켜져 있으면 **false 를 돌려 흙 타일을 숨긴다**.
/// 각진 1× 1 타일과 둥근 데칼이 겹치면 데칼 가장자리 밖으로 타일 모서리가 삐져나온다(배타).
/// </summary>
2026-09-15 05:59:53 +00:00
static bool IsSoilRenderer ( WLIslandSettings cfg , Renderer r )
{
if ( cfg . showFarmSoil = = 0 | | r = = null ) return false ;
2026-09-15 10:06:15 +00:00
if ( global :: WL . Look . Farm . WLSoftDirt . Active ) return false ;
2026-09-15 05:59:53 +00:00
return r . GetComponentInParent < FISoil > ( true ) ! = null ;
}
/// <summary>
/// 🔴 WL-816w ② — FI 섬 타일의 장식 **울타리**를 화면에서 없앤다(렌더러만 · 원본 0줄).
/// 실측(816w): `IslandManager/Island (0)/Fence01`·`(1)`·`(2)` 3개가 밭 옆 (6·8·10, 0, 4) 에 서 있고
/// **Farm 자식이 아니라 섬 타일 자식**이라 816g 의 밭 숨김이 훑지 못했다.
/// `isl` 이 null 이면 씬 전체, 아니면 그 섬만 훑는다(확장으로 새로 열린 타일).
/// </summary>
static void HideIslandFences ( WLIslandSettings cfg , Scene scene , FIIsland isl )
{
if ( cfg = = null | | cfg . hideIslandFences = = 0 ) return ;
var pre = cfg . islandFenceNamePrefixes ;
if ( pre = = null | | pre . Length = = 0 ) return ;
if ( isl ! = null ) { HideFencesUnder ( pre , isl . transform ) ; return ; }
var islands = Object . FindObjectsByType < FIIsland > ( FindObjectsInactive . Include , FindObjectsSortMode . None ) ;
for ( int i = 0 ; i < islands . Length ; i + + )
{
if ( islands [ i ] = = null ) continue ;
if ( scene . IsValid ( ) & & islands [ i ] . gameObject . scene ! = scene ) continue ;
HideFencesUnder ( pre , islands [ i ] . transform ) ;
}
}
static void HideFencesUnder ( string [ ] pre , Transform root )
{
2026-09-15 06:47:34 +00:00
// 2026-09-15 PD 「기본 섬에 울타리가 여전히 남아 있다」 — 직접 자식만 보던 것을 **모든 하위**로 넓히고,
// 이름 접두어뿐 아니라 이름 **어디든** 접두어 문자열이 들어가면 울타리로 본다(Props/Fence_Wood_01 등).
var all = root . GetComponentsInChildren < Transform > ( true ) ;
for ( int c = 0 ; c < all . Length ; c + + )
2026-09-15 05:59:53 +00:00
{
2026-09-15 06:47:34 +00:00
var ch = all [ c ] ;
if ( ch = = null | | ch = = root ) continue ;
2026-09-15 05:59:53 +00:00
bool hit = false ;
for ( int p = 0 ; p < pre . Length ; p + + )
{
if ( string . IsNullOrEmpty ( pre [ p ] ) ) continue ;
2026-09-15 06:47:34 +00:00
if ( ch . name . IndexOf ( pre [ p ] , System . StringComparison . OrdinalIgnoreCase ) > = 0 ) { hit = true ; break ; }
2026-09-15 05:59:53 +00:00
}
if ( ! hit ) continue ;
var rends = ch . GetComponentsInChildren < Renderer > ( true ) ;
for ( int r = 0 ; r < rends . Length ; r + + )
{
if ( rends [ r ] = = null | | ! rends [ r ] . enabled ) continue ;
rends [ r ] . enabled = false ; s_hiddenRends . Add ( rends [ r ] ) ; FenceRenderersHidden + + ;
}
2026-09-15 08:05:42 +00:00
// 2026-09-15 PD 「플레이어 위치에 투명 벽」 — 렌더러만 끈 울타리의 콜라이더가 보이지 않는 벽으로 남았다 → 같이 끈다.
var fcols = ch . GetComponentsInChildren < Collider > ( true ) ;
for ( int r = 0 ; r < fcols . Length ; r + + )
{
if ( fcols [ r ] = = null | | ! fcols [ r ] . enabled ) continue ;
fcols [ r ] . enabled = false ; s_hiddenCols . Add ( fcols [ r ] ) ;
}
2026-09-15 05:59:53 +00:00
}
}
2026-09-13 07:41:43 +00:00
/// <summary>섬이 나중에 열리면 ① 그 섬의 밭을 다시 숨기고 ② 타일 재질을 기존 타일과 맞춘다(§1).</summary>
2026-09-13 07:18:40 +00:00
static void HookIslandActivation ( WLIslandSettings cfg , Scene scene )
{
2026-09-13 07:41:43 +00:00
if ( cfg . hideFarms = = 0 & & cfg . matchNewTileMaterials = = 0 ) return ;
2026-09-13 07:18:40 +00:00
var islands = Object . FindObjectsByType < FIIsland > ( FindObjectsInactive . Include , FindObjectsSortMode . None ) ;
for ( int i = 0 ; i < islands . Length ; i + + )
{
var isl = islands [ i ] ;
if ( isl = = null ) continue ;
if ( scene . IsValid ( ) & & isl . gameObject . scene ! = scene ) continue ;
if ( s_hooked . Contains ( isl ) ) continue ;
s_hooked . Add ( isl ) ;
var captured = isl ;
isl . OnActivated + = ( ) = > OnIslandActivated ( cfg , captured ) ;
}
}
static void OnIslandActivated ( WLIslandSettings cfg , FIIsland isl )
{
2026-09-13 07:41:43 +00:00
if ( cfg = = null | | cfg . gateFlowEnabled = = 0 ) return ;
if ( cfg . hideFarms = = 0 & & cfg . matchNewTileMaterials = = 0 ) return ;
2026-09-13 07:18:40 +00:00
var runner = WLIslandBridge . Runner ;
if ( runner = = null | | isl = = null ) return ;
runner . StartCoroutine ( Co_ReHide ( cfg , isl ) ) ;
}
2026-09-15 06:34:10 +00:00
2026-09-15 09:22:10 +00:00
public static int WorkersHidden ;
2026-09-15 09:50:16 +00:00
// ─────────────────────────────────────────────────────────────────
// 2026-09-15 Lead 실측 — 울타리는 오브젝트 숨김·타일 메시 자르기 뒤에도 화면에 남았고, isVisible 목록에도 안 잡혔다.
// 마지막 수단: 카메라가 그리기 **직전**(beginCameraRendering)마다 섬 씬의 렌더러를 훑어 이름 또는 메시 이름에
// 「fence」 가 들어간 것을 끄고, 무엇을 껐는지 `FenceGuardLog` 에 남긴다(정체 확인용). 씬이 내려가면 해제.
// ─────────────────────────────────────────────────────────────────
public static string FenceGuardLog = "" ;
public static int FenceGuardHidden ;
static bool s_guardHooked ;
static Scene s_guardScene ;
static WLIslandSettings s_guardCfg ;
static void HookFenceGuard ( WLIslandSettings cfg , Scene scene )
{
s_guardCfg = cfg ; s_guardScene = scene ;
if ( s_guardHooked ) return ;
UnityEngine . Rendering . RenderPipelineManager . beginCameraRendering + = OnBeginCameraFenceGuard ;
s_guardHooked = true ;
}
static void OnBeginCameraFenceGuard ( UnityEngine . Rendering . ScriptableRenderContext ctx , Camera cam )
{
if ( s_guardCfg = = null | | s_guardCfg . hideIslandFences = = 0 ) return ;
if ( ! s_guardScene . IsValid ( ) | | ! s_guardScene . isLoaded )
{
UnityEngine . Rendering . RenderPipelineManager . beginCameraRendering - = OnBeginCameraFenceGuard ;
s_guardHooked = false ;
return ;
}
var rends = Object . FindObjectsByType < Renderer > ( FindObjectsInactive . Exclude , FindObjectsSortMode . None ) ;
for ( int i = 0 ; i < rends . Length ; i + + )
{
var r = rends [ i ] ;
if ( r = = null | | ! r . enabled ) continue ;
bool hit = r . gameObject . name . IndexOf ( "fence" , System . StringComparison . OrdinalIgnoreCase ) > = 0 ;
if ( ! hit )
{
var mf = r . GetComponent < MeshFilter > ( ) ;
2026-09-16 14:55:11 +00:00
// 🔴 2026-09-16 실측: 난간을 잘라낸 복제본 이름이 「…_nofence」라 「fence」에 걸려 **다리(Bridge_nofence)** 가 매 프레임 꺼졌다 → nofence 는 제외
if ( mf ! = null & & mf . sharedMesh ! = null & & mf . sharedMesh . name . IndexOf ( "fence" , System . StringComparison . OrdinalIgnoreCase ) > = 0
& & mf . sharedMesh . name . IndexOf ( "nofence" , System . StringComparison . OrdinalIgnoreCase ) < 0 ) hit = true ;
2026-09-15 09:50:16 +00:00
}
if ( ! hit ) continue ;
r . enabled = false ; FenceGuardHidden + + ;
var t = r . transform ; string path = t . name ; while ( t . parent ! = null ) { t = t . parent ; path = t . name + "/" + path ; }
if ( FenceGuardLog . Length < 1200 ) FenceGuardLog + = " | " + path + " @" + r . transform . position . ToString ( "F1" ) + " scn=" + r . gameObject . scene . name ;
}
}
2026-09-15 09:22:10 +00:00
static IEnumerator Co_HideWorkers ( Scene scene )
{
float t = 0f ;
while ( t < 600f )
{
if ( ! scene . IsValid ( ) | | ! scene . isLoaded ) yield break ;
var ws = Object . FindObjectsByType < WorkerActor > ( FindObjectsInactive . Exclude , FindObjectsSortMode . None ) ;
for ( int i = 0 ; i < ws . Length ; i + + )
if ( ws [ i ] ! = null & & ws [ i ] . gameObject . activeSelf ) { ws [ i ] . gameObject . SetActive ( false ) ; WorkersHidden + + ; }
float wait = t < 20f ? 0.2f : 3f ;
t + = wait ;
yield return new WaitForSeconds ( wait ) ;
}
}
2026-09-15 06:34:10 +00:00
static IEnumerator Co_ReHideFences ( WLIslandSettings cfg , Scene scene )
{
float wait = Mathf . Max ( 0.5f , cfg . farmReHideDelaySeconds ) ;
2026-09-15 08:05:42 +00:00
// 섬이 살아 있는 동안 3 s 마다 계속 훑는다(이미 꺼진 것은 건너뛰어 비용 0에 가깝다) — 늦게 활성화되는 타일·밭까지 덮는다.
2026-09-16 10:55:01 +00:00
// 2026-09-16 PD 「울타리가 또 보인다(던전 갔다 섬으로 복귀 후)」 실측 원인: 이 훑기가 400회(20분)에서 끝나 그 뒤 복귀한 장벽 그림을 아무도 안 지웠다
// → 섬 씬이 살아 있는 동안 **무한** 반복(3 s 간격 · 지울 게 없으면 비용 0). 장벽 상태(StageBarrier)도 함께 닫아 재표시를 막는다.
for ( ; ; )
2026-09-15 06:34:10 +00:00
{
yield return new WaitForSeconds ( wait ) ;
2026-09-15 08:05:42 +00:00
if ( cfg = = null | | cfg . hideIslandFences = = 0 | | ! scene . IsValid ( ) | | ! scene . isLoaded ) yield break ;
2026-09-15 06:34:10 +00:00
HideIslandFences ( cfg , scene , null ) ;
2026-09-16 10:55:01 +00:00
WL . Combat . Stage . StageBarrier . Clear ( ) ; // 던전에서 열린 채 돌아온 장벽 상태 닫기(이미 닫혀 있으면 무동작)
2026-09-15 14:15:50 +00:00
WL . Combat . Stage . StageBarrierVisual . HideImmediate ( ) ; // 늦게 페이드 중이던 전투 장벽 그림까지(슬롯 없으면 비용 0)
2026-09-15 09:17:03 +00:00
WLIslandTileFenceStrip . Apply ( cfg , scene ) ; // 확장·재구성으로 타일 메시가 바뀌면 다시 잘라낸다(멱등)
2026-09-15 08:05:42 +00:00
if ( cfg . hideFarms ! = 0 ) { Vector3 _c ; HideFarms ( cfg , scene , out _c ) ; } // 늦게 생긴 밭의 울타리·소품도
2026-09-16 14:55:11 +00:00
WLIslandExpansionGate . PlacePurchasers ( cfg , scene ) ; // 2026-09-16 — 확장 뒤 새로 생긴 FI 가격판도 이웃 칸 가장자리로(표식 있으면 비용 0)
2026-09-16 15:18:43 +00:00
WLIslandPrompt . ReconcileEventSystems ( ) ; // 2026-09-17 — 원본 EventSystem 이 올라오면 우리 것을 지운다(경고 홍수 차단)
2026-09-15 06:34:10 +00:00
}
}
2026-09-13 07:18:40 +00:00
static IEnumerator Co_ReHide ( WLIslandSettings cfg , FIIsland isl )
{
2026-09-13 07:41:43 +00:00
// 확장 애니메이션(DOScale 1초) + 소품 배치(AnimateSoils 0.05초 × 30칸) + 룩 재적용
// (`WLIslandLookSettings.rebuildDelaySeconds` = 1.4 실측)이 끝나기를 기다린다.
2026-09-13 07:18:40 +00:00
yield return new WaitForSeconds ( cfg . farmReHideDelaySeconds ) ;
if ( isl = = null ) yield break ;
2026-09-13 07:41:43 +00:00
MatchTileMaterials ( cfg , isl ) ;
2026-09-15 05:59:53 +00:00
HideIslandFences ( cfg , isl . gameObject . scene , isl ) ; // WL-816w ② — 새로 열린 타일의 울타리도
2026-09-13 07:41:43 +00:00
if ( cfg . hideFarms = = 0 ) yield break ;
2026-09-13 07:18:40 +00:00
var farms = isl . GetComponentsInChildren < FIFarm > ( true ) ;
for ( int i = 0 ; i < farms . Length ; i + + )
{
if ( farms [ i ] = = null ) continue ;
var rends = farms [ i ] . GetComponentsInChildren < Renderer > ( true ) ;
for ( int r = 0 ; r < rends . Length ; r + + )
2026-09-15 05:59:53 +00:00
{
if ( rends [ r ] = = null | | ! rends [ r ] . enabled ) continue ;
if ( IsSoilRenderer ( cfg , rends [ r ] ) ) { SoilRenderersKept + + ; continue ; } // WL-816w ③
rends [ r ] . enabled = false ; s_hiddenRends . Add ( rends [ r ] ) ; RenderersHidden + + ;
}
2026-09-13 07:18:40 +00:00
var cols = farms [ i ] . GetComponentsInChildren < Collider > ( true ) ;
for ( int c = 0 ; c < cols . Length ; c + + )
if ( cols [ c ] ! = null & & cols [ c ] . enabled ) { cols [ c ] . enabled = false ; s_hiddenCols . Add ( cols [ c ] ) ; }
}
}
2026-09-13 07:41:43 +00:00
// ─────────────────────────────────────────────────────────────────
// §1 새 타일 경계 색차 — 남은 몫(외곽선 패스)
/ /
// 실측(2026-09-13 · Play):
// · **바닥 색 자체는 Lead 의 `b1e58d795`(대비 복제 제외)로 해결됐다** — 새로 연 타일도
// 기존 타일과 **같은 머티리얼 인스턴스** `Farm_IslandTop_Demo#83780` 을 쓴다.
// · 다만 새로 연 타일은 슬롯이 **1개**뿐이다(기존 타일은 2개 = 바닥 + `WL/HullOutline`).
// `WLReferenceLook.Apply` 가 씬 로드 때 **활성 렌더러만** 훑는데 잠긴 타일은 꺼져 있어
// 외곽선 패스를 못 받는다. 그래서 **경계에서 테두리 유무가 갈린다.**
// 조치: 열린 직후, 이미 열려 있는 타일의 머티리얼 배열을 **그대로** 물려준다(새 머티리얼 0개).
// ─────────────────────────────────────────────────────────────────
public static int TilesMatched , SlotsMatched ;
static void MatchTileMaterials ( WLIslandSettings cfg , FIIsland isl )
{
2026-09-16 14:55:11 +00:00
if ( cfg . matchNewTileMaterials = = 0 | | isl = = null | | isl . IsBridge ) return ; // 다리 타일은 원본 재질 유지(2026-09-16)
2026-09-13 07:41:43 +00:00
var mine = isl . GetComponentsInChildren < Renderer > ( true ) ;
if ( mine . Length = = 0 ) return ;
// 기준 = 이미 열려 있고 **슬롯이 더 많은** 타일(= 외곽선을 받은 타일)
FIIsland refIsl = null ;
int bestSlots = 0 ;
var all = Object . FindObjectsByType < FIIsland > ( FindObjectsInactive . Include , FindObjectsSortMode . None ) ;
for ( int i = 0 ; i < all . Length ; i + + )
{
var o = all [ i ] ;
if ( o = = null | | o = = isl | | ! o . IsUnlocked | | ! o . gameObject . activeInHierarchy ) continue ;
if ( o . gameObject . scene ! = isl . gameObject . scene ) continue ;
var r = o . GetComponent < MeshRenderer > ( ) ;
if ( r = = null ) continue ;
int n = r . sharedMaterials ! = null ? r . sharedMaterials . Length : 0 ;
if ( n > bestSlots ) { bestSlots = n ; refIsl = o ; }
}
if ( refIsl = = null ) return ;
// 🔴 이름으로 짝을 지으면 **타일 본체**(GameObject 이름이 `Island (3)` / `Island (6)` 처럼 다 다르다)가
// 안 맞는다(실측 — 첫 시도에서 본체만 슬롯 1개로 남았다). 루트 기준 **상대 경로**로 짝 짓는다.
var refs = refIsl . GetComponentsInChildren < Renderer > ( true ) ;
var byPath = new Dictionary < string , Renderer > ( refs . Length ) ;
for ( int j = 0 ; j < refs . Length ; j + + )
if ( refs [ j ] ! = null ) byPath [ RelPath ( refIsl . transform , refs [ j ] . transform ) ] = refs [ j ] ;
int changed = 0 ;
for ( int i = 0 ; i < mine . Length ; i + + )
{
var m = mine [ i ] ;
if ( m = = null | | m is ParticleSystemRenderer ) continue ;
var mm = m . sharedMaterials ;
if ( mm = = null | | mm . Length = = 0 | | mm [ 0 ] = = null ) continue ;
Renderer r ;
if ( ! byPath . TryGetValue ( RelPath ( isl . transform , m . transform ) , out r ) | | r = = null ) continue ;
var rm = r . sharedMaterials ;
if ( rm = = null | | rm . Length < = mm . Length | | rm [ 0 ] ! = mm [ 0 ] ) continue ;
m . sharedMaterials = rm ; // 같은 인스턴스 배열 = 픽셀이 같아진다
changed + + ; SlotsMatched + = rm . Length - mm . Length ;
}
if ( changed > 0 )
{
TilesMatched + + ;
WLIslandBridge . Log ( cfg , "새 타일 재질 맞춤 — " + isl . name + " 렌더러 " + changed +
"개를 기존 타일(" + refIsl . name + ")과 같은 배열로(외곽선 포함)" ) ;
}
}
/// <summary>타일 루트 기준 상대 경로(프리팹 인스턴스끼리 짝 짓는 열쇠 · 루트 자신은 빈 문자열).</summary>
static string RelPath ( Transform root , Transform t )
{
if ( t = = root ) return "" ;
string s = t . name ;
var p = t . parent ;
while ( p ! = null & & p ! = root ) { s = p . name + "/" + s ; p = p . parent ; }
return s ;
}
2026-09-13 07:18:40 +00:00
/// <summary>우리가 끈 것만 되살린다(C8).</summary>
public static void RestoreFarms ( )
{
for ( int i = 0 ; i < s_hiddenRends . Count ; i + + ) if ( s_hiddenRends [ i ] ! = null ) s_hiddenRends [ i ] . enabled = true ;
for ( int i = 0 ; i < s_hiddenCols . Count ; i + + ) if ( s_hiddenCols [ i ] ! = null ) s_hiddenCols [ i ] . enabled = true ;
s_hiddenRends . Clear ( ) ;
s_hiddenCols . Clear ( ) ;
s_hooked . Clear ( ) ;
}
}
// ─────────────────────────────────────────────────────────────────────
// 골드 납품 발판 — 더미 모양 + FI Purchaser 재사용
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 「골드 납품 발판」 1개. 모양은 **더미**(원판 + 테두리 + 안내)로 충분하다(PD 합의).
/// 지불은 **FI `Purchaser` 원본 프리팹**이 그대로 한다 — 우리는 보이지 않는 더미 `Island` 를
/// 물려 주고 `OnActivated` 만 받는다. 교체는 `platformPrefabPath` 값 하나.
/// </summary>
public sealed class WLGoldPlatform : MonoBehaviour
{
public static WLGoldPlatform Instance ;
public int Price ;
public int NextDungeonIndex ;
public FIPurchaser Purchaser ;
public FIIsland DummyTarget ;
public bool Paid ;
public const string ObjectName = "~WL_GoldPlatform" ;
2026-09-15 07:04:08 +00:00
/// <param name="labelOverride">비우면 `platformLabelFormat`(던전 열기) · 채우면 그 글자를 그대로 띄운다(WL-816zc 단계 문구).</param>
public static WLGoldPlatform Create ( WLIslandSettings cfg , Scene scene , Vector3 pos , int price , int nextIndex ,
string labelOverride = null )
2026-09-13 07:18:40 +00:00
{
GameObject root = null ;
if ( ! string . IsNullOrEmpty ( cfg . platformPrefabPath ) )
{
var prefab = WLIslandAssets . LoadPrefab ( cfg . platformPrefabPath ) ;
if ( prefab ! = null ) root = Object . Instantiate ( prefab ) ;
}
// 🔴 PD 정정(2026-09-13) 「던전 열기 골드 발판은 **원래 에셋에 있던 것**을 활용하면 돼.」
// → 기본값은 **빈 루트 + FI `Purchaser` 프리팹 하나**다. 새 모델을 만들지 않는다.
// platformShowPad = 1 로 켜면 예전의 더미 원판이 다시 깔린다(기본 0).
if ( root = = null ) root = cfg . platformShowPad ! = 0 ? BuildPad ( cfg ) : new GameObject ( "platform" ) ;
root . name = ObjectName ;
root . transform . position = pos ;
if ( scene . IsValid ( ) ) SceneManager . MoveGameObjectToScene ( root , scene ) ;
var self = root . AddComponent < WLGoldPlatform > ( ) ;
self . Price = price ;
self . NextDungeonIndex = nextIndex ;
Instance = self ;
self . AttachPurchaser ( cfg , price ) ;
// 🔴 가격 숫자는 **FI 가격판 그대로**다(위 프리팹이 「10」을 띄운다). 여기 글자는 **무엇이 열리는지**만 알린다
// — 모델이 아니라 글자 하나고, `platformShowLabel = 0` 이면 FI 원본 모습 100 % 가 된다.
if ( cfg . platformShowLabel ! = 0 )
WLIslandLabel . Attach ( root . transform , new Vector3 ( 0f , cfg . platformLabelHeight , 0f ) ,
2026-09-15 07:04:08 +00:00
string . IsNullOrEmpty ( labelOverride )
? string . Format ( cfg . platformLabelFormat , price )
: labelOverride ) ;
2026-09-13 07:18:40 +00:00
return self ;
}
/// <summary>
/// 선택 사항 — 낮은 원판(발 디딜 자리 표시). 🔴 기본은 **꺼져 있다**(PD: 새 모델 금지).
/// FI 가격판이 바닥에 눕는 판(`Price` · 4× 2 m · X 90°)이라 원판을 깔면 **그 판을 덮어 버린다**(실측).
/// </summary>
static GameObject BuildPad ( WLIslandSettings cfg )
{
float r = cfg . platformRadius < = 0f ? 1.6f : cfg . platformRadius ;
var root = new GameObject ( "platform" ) ;
var baseMat = WLIslandLook . ToonMaterial ( cfg . platformColor * 0.55f , cfg . platformUseToon ! = 0 ) ;
MakeDisc ( root . transform , "rim" , new Vector3 ( 0f , 0.005f , 0f ) , r * 2f , 0.01f , baseMat ) ;
return root ;
}
static void MakeDisc ( Transform parent , string name , Vector3 pos , float diameter , float height , Material mat )
{
var go = GameObject . CreatePrimitive ( PrimitiveType . Cylinder ) ;
go . name = name ;
go . transform . SetParent ( parent , false ) ;
go . transform . localPosition = pos ;
go . transform . localScale = new Vector3 ( diameter , height * 0.5f , diameter ) ; // Cylinder 는 높이가 2 단위
var col = go . GetComponent < Collider > ( ) ;
if ( col ! = null ) Object . Destroy ( col ) ; // 더미 — 길·NavMesh 를 건드리지 않는다
var rd = go . GetComponent < Renderer > ( ) ;
if ( rd ! = null & & mat ! = null ) rd . sharedMaterial = mat ;
}
// ─────────────────────────────────────────────────────────────
// 🔴 FI Purchaser 재사용
// ─────────────────────────────────────────────────────────────
void AttachPurchaser ( WLIslandSettings cfg , int price )
{
var mgr = FIIslandManager . Instance ;
if ( mgr = = null ) { WLIslandBridge . Log ( cfg , "발판: IslandManager 없음 — Purchaser 재사용 불가" ) ; return ; }
var prefab = PurchaserPrefab ( mgr ) ;
if ( prefab = = null ) { WLIslandBridge . Log ( cfg , "발판: FI Purchaser 프리팹을 못 찾았다 — 지불 불가" ) ; return ; }
// 보이지 않는 더미 `Island` — Purchaser 가 요구하는 자리만 채운다(메시 없음 = 안 보인다).
// 🔴 `IslandManager` 의 자식이 **아니다** → 섬 격자·가격·세이브에 절대 끼어들지 않는다
// (`IslandManager.Awake` 는 `GetComponentsInChildren<Island>()` 로만 섬을 모은다).
var dummyGo = new GameObject ( "~WL_GoldPlatformTarget" ) ;
dummyGo . SetActive ( false ) ; // Awake 를 Activate() 시점까지 미룬다
dummyGo . transform . SetParent ( transform , false ) ;
var dummy = dummyGo . AddComponent < FIIsland > ( ) ;
dummy . HasRoad = true ; // 🔴 false 면 Awake 가 비어 있는 roadMesh 를 건드려 죽는다
dummy . IsBridge = false ;
dummy . IsUnlocked = false ;
var mf = dummyGo . GetComponent < MeshFilter > ( ) ;
if ( mf ! = null ) mf . sharedMesh = null ; // 메시 0 = 화면에 아무것도 안 나온다
DummyTarget = dummy ;
dummy . OnActivated + = OnPurchaseComplete ;
var p = Object . Instantiate ( prefab , transform . position , Quaternion . identity ) ;
p . name = "purchaser" ;
p . transform . SetParent ( transform , true ) ;
Purchaser = p ;
2026-09-15 04:49:23 +00:00
// 🔴 WL-816u — PD 「프레임과 글자 아이콘 크기 모두 50%로 줄여줘」.
// **인스턴스 스케일만** 건드린다(프리팹 원본 0줄). 프레임·가격 글자·코인 아이콘이 한 덩어리로 줄어든다.
// 스케일은 **바운즈 재기 전에** 적용해야 중심 보정이 줄어든 크기 기준으로 맞는다.
float ps = cfg . platformScale > 0.001f ? cfg . platformScale : 1f ;
AppliedScale = ps ;
if ( ps ! = 1f ) p . transform . localScale = p . transform . localScale * ps ;
2026-09-16 16:44:09 +00:00
if ( cfg . expansionPadOverlay ! = 0 ) WLIslandExpansionGate . ApplyOverlay ( p . gameObject , cfg . expansionPadOverlay > = 2 ) ; // 2026-09-17 PD — 우리 발판도 같은 규칙(1 = 깊이 검사 · 캐릭터가 가림)
2026-09-15 04:49:23 +00:00
2026-09-13 07:18:40 +00:00
// 🔴 FI 가격판은 프리팹 안에서 **루트보다 앞·아래**에 놓여 있다(실측 2026-09-13:
// Frame/Price/Coin 이 전부 y = -0.49 · z = 루트+(-1/0/+1) — 잠긴 섬 자리의 **물 위**에 눕는 판이다).
// 섬 위(y≈0)에 그대로 놓으면 **지면 아래로 들어가 안 보인다** → 프리팹 전체의 렌더 범위를 재서
// 「밭 한가운데 · 바닥 바로 위」로 맞춘다. 프리팹은 한 글자도 고치지 않는다.
if ( cfg . platformCenterOnPlate ! = 0 )
{
2026-09-15 04:49:23 +00:00
Bounds b ;
if ( VisibleBounds ( p . transform , cfg . platformCenterVisibleOnly ! = 0 , out b ) )
2026-09-13 07:18:40 +00:00
{
var t = transform . position ;
p . transform . position + = new Vector3 ( t . x - b . center . x ,
t . y + cfg . platformPlateLift - b . min . y ,
t . z - b . center . z ) ;
2026-09-15 04:49:23 +00:00
VisibleBounds ( p . transform , cfg . platformCenterVisibleOnly ! = 0 , out PlateBounds ) ;
PlateBoundsValid = true ;
2026-09-13 07:18:40 +00:00
}
}
// 트리거를 **발판(가격판) 크기**로 줄인다(원본 프리팹은 섬 1칸 8 m 를 덮는 十자 두 개다).
2026-09-15 04:49:23 +00:00
// 🔴 WL-816u — 판을 50 % 로 줄여도 **밟는 자리는 그대로**여야 한다(PD 는 크기만 말했다).
// size 는 부모(p) 로컬 단위라 스케일만큼 역보정하면 월드 미터가 유지된다.
2026-09-13 07:18:40 +00:00
var boxes = p . GetComponentsInChildren < BoxCollider > ( true ) ;
float s = cfg . platformTriggerSize < = 0f ? 3.2f : cfg . platformTriggerSize ;
2026-09-15 04:49:23 +00:00
float inv = 1f / ps ;
2026-09-13 07:18:40 +00:00
var localCenter = p . transform . InverseTransformPoint ( transform . position ) ;
for ( int i = 0 ; i < boxes . Length ; i + + )
{
if ( boxes [ i ] = = null ) continue ;
2026-09-16 15:46:15 +00:00
// 🔴 2026-09-17 — 원본 십자 상자 2개를 같은 크기로 겹치면 OnTriggerEnter 가 2번 나 지불 코루틴이 2개 돌고 Activate() 가 2회 호출된다 → 하나만 남긴다
if ( i > 0 ) { boxes [ i ] . enabled = false ; continue ; }
boxes [ i ] . enabled = true ;
2026-09-15 04:49:23 +00:00
boxes [ i ] . size = new Vector3 ( s * inv , cfg . platformTriggerHeight * inv , s * inv ) ;
boxes [ i ] . center = new Vector3 ( localCenter . x , cfg . platformTriggerHeight * inv * 0.5f , localCenter . z ) ;
2026-09-13 07:18:40 +00:00
}
// 🔴 `Init` 은 `Purchaser.Start()` 보다 먼저여야 한다(Start 가 가격판을 그린다) — 같은 프레임이라 안전.
p . Init ( dummy , price , new FIPurchaserData ( Vector3Int . RoundToInt ( transform . position ) , price ) ) ;
WLIslandBridge . Log ( cfg , "발판: FI Purchaser 재사용 — 가격 " + price +
" · 트리거 " + s . ToString ( "F1" ) + " m · 더미 Island 연결" ) ;
}
2026-09-15 04:49:23 +00:00
// ── WL-816u 진단(프로브·보고가 읽는다 · 실측만)
public static float AppliedScale = 1f ;
public static Bounds PlateBounds ;
public static bool PlateBoundsValid ;
/// <summary>
/// 🔴 WL-816u — 「**보이는** 프레임」의 월드 바운즈.
/// visibleOnly 면 파티클·트레일·꺼진 렌더러·꺼진 오브젝트를 제외한다(FI Purchaser 는 물보라 파티클을 품고 있다).
/// </summary>
public static bool VisibleBounds ( Transform root , bool visibleOnly , out Bounds b )
{
b = new Bounds ( ) ;
bool set = false ;
var rends = root . GetComponentsInChildren < Renderer > ( true ) ;
for ( int i = 0 ; i < rends . Length ; i + + )
{
var r = rends [ i ] ;
if ( r = = null ) continue ;
if ( visibleOnly )
{
if ( r is ParticleSystemRenderer | | r is TrailRenderer | | r is LineRenderer ) continue ;
if ( ! r . enabled | | ! r . gameObject . activeInHierarchy ) continue ;
}
if ( ! set ) { b = r . bounds ; set = true ; } else b . Encapsulate ( r . bounds ) ;
}
return set ;
}
2026-09-13 07:18:40 +00:00
static System . Reflection . FieldInfo s_purchaserField ;
/// <summary>FI 가 섬 구입에 쓰는 **바로 그 프리팹**(가격판·코인 아이콘·물보라·SFX 가 전부 들어 있다).</summary>
static FIPurchaser PurchaserPrefab ( FIIslandManager mgr )
{
if ( s_purchaserField = = null )
s_purchaserField = typeof ( FIIslandManager ) . GetField ( "purchaserPrefab" ,
System . Reflection . BindingFlags . Instance | System . Reflection . BindingFlags . NonPublic ) ;
if ( s_purchaserField = = null ) return null ;
return s_purchaserField . GetValue ( mgr ) as FIPurchaser ;
}
void OnPurchaseComplete ( )
{
if ( Paid ) return ;
Paid = true ;
var cfg = WLIslandSettings . Instance ;
var pos = transform . position ;
var runner = WLIslandBridge . Runner ;
// 🔴 이 오브젝트는 곧 지워진다 — 코루틴은 씬을 넘어 사는 러너가 돌린다.
if ( runner ! = null ) runner . StartCoroutine ( Co_AfterPaid ( cfg , pos ) ) ;
else WLIslandGateFlow . OnPaid ( cfg , pos ) ;
}
static IEnumerator Co_AfterPaid ( WLIslandSettings cfg , Vector3 pos )
{
// 물보라·DOJump(1초)가 끝난 뒤에 발판을 갈아 끼운다(진행 중 트윈의 대상이 사라지지 않게).
float w = cfg ! = null ? cfg . platformRespawnDelaySeconds : 0.4f ;
if ( w > 0f ) yield return new WaitForSeconds ( w ) ;
WLIslandGateFlow . OnPaid ( cfg , pos ) ;
}
void OnDestroy ( ) { if ( Instance = = this ) Instance = null ; }
}
}