[WL] 가격판 = 최소제곱 평면 눕힘(경사·다리 아치 밀착) · 잠긴 칸 자리 기억(다리 위 판 고정 · OnActivated 즉시 배치) · 십자 간격(inset 1.3 · 0.45배 · 트리거 2.6 · 장식/판/소품 회피) · 글자 깊이 검사(캐릭터가 가림) · 자동 진입 단일 인스턴스 + 8 s 대기

- WLIslandFlowV2: PadSurfaceY 최소제곱 평면 + 테두리 묻힘 0 · IsGroundCollider(소품·벽·난간 제외) · 자리 기억 PlayerPrefs WL_Island_PadSpots · Watch(OnActivated 즉시 PlacePurchasers · 다음 프레임/Baked 뒤 RefitPlaced) · IsSpotBlocked(캠프 장식·던전 문·발판·다른 판·IProp 콜라이더) 가장자리 따라 ±1/±2/끝 비킴 · ApplyOverlay(textAlwaysOnTop)
- WLIslandSettings: expansionPadInset 1.3 · Scale 0.45 · TriggerSize 2.6 · Lift 0.05 · TiltMax 20 · padClearRadius 1.4 · expansionPadOverlay 1 = 스프라이트만 깊이 검사
- WLSpriteOverlay.shader: Offset -1,-1 → 0,-2
- WLIslandSave: 세이브 초기화 때 자리 기억 삭제 · WLIslandCampDecor.Made 공개
- WLLeadAutoEnter: 단일 인스턴스(Create/Exists) · 로그인 완료 뒤 EnterDelay 8 s · 재시도 90 s

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-17 01:44:09 +09:00
parent edaadc18fa
commit c313dd88fd
8 changed files with 344 additions and 31 deletions

View File

@ -95,11 +95,12 @@ MonoBehaviour:
installPushMargin: 0.75 installPushMargin: 0.75
islandPlayerScale: 1.3 islandPlayerScale: 1.3
expansionPadPlace: 1 expansionPadPlace: 1
expansionPadInset: 1 expansionPadInset: 1.3
padClearRadius: 1.6 padClearRadius: 1.4
expansionPadLift: 0.06 expansionPadLift: 0.05
expansionPadTriggerSize: 3.4 expansionPadTriggerSize: 2.6
expansionPadScale: 0.5 expansionPadScale: 0.45
expansionPadTiltMax: 20
expansionPadOverlay: 1 expansionPadOverlay: 1
campDecorSnapToGround: 0 campDecorSnapToGround: 0
applyStartCoin: 1 applyStartCoin: 1

View File

@ -1,5 +1,6 @@
// WLSpriteOverlay.shader — 스프라이트를 **항상 위에**(깊이 무시) 그리는 언릿 · 알파 블렌드 셰이더 // WLSpriteOverlay.shader — 가격판 스프라이트용 언릿 · 알파 블렌드 셰이더(깊이 검사 · 바닥 밀착)
// 2026-09-17 PD 「가격판이 풀·오브젝트에 가려지지 않게 상단에 보이게」 — 확장 가격판(FI Purchaser 의 Frame/Coin 스프라이트)에 런타임 복사본 재질로 물린다. // 2026-09-17 PD 「가격판이 풀·오브젝트에 가려지지 않게 상단에 보이게」 → 같은 날 「가격 판이 캐릭터를 가리지 않게 · 바닥에 붙은 느낌」.
// 확장 가격판(FI Purchaser 의 Frame/Coin 스프라이트)·우리 발판에 런타임 복사본 재질로 물린다.
// SpriteRenderer 가 _MainTex 에 스프라이트 텍스처를 넣어 주고 정점색을 곱한다. 모바일: 텍스처 1장 · 조명 없음. // SpriteRenderer 가 _MainTex 에 스프라이트 텍스처를 넣어 주고 정점색을 곱한다. 모바일: 텍스처 1장 · 조명 없음.
Shader "WL/SpriteOverlay" Shader "WL/SpriteOverlay"
{ {
@ -14,10 +15,11 @@ Shader "WL/SpriteOverlay"
Cull Off Cull Off
Lighting Off Lighting Off
ZWrite Off ZWrite Off
// 2026-09-17 PD 「가격판은 캐릭터보다 아래」 — 깊이 검사는 하되(캐릭터·소품이 가린다) 바닥과 겹치지 않게 살짝 당긴다. // 2026-09-17 PD 「가격판은 캐릭터보다 아래」 — 깊이 검사(캐릭터·소품이 앞이면 가린다). 판은 지형 표본 위 5 cm 에 놓이므로 깊이 당김은
// 버퍼 단위 2 만(기울기 계수 1 은 비스듬한 바닥 면에서 크게 당겨져 코인이 캐릭터 발 위로 올라왔다 · 실측).
// 풀에 가려지는 문제는 지형 쪽에서 판 자리에 모래를 깔아(풀 없음) 해결한다(WLDemoTerrain 판 자리 모래). // 풀에 가려지는 문제는 지형 쪽에서 판 자리에 모래를 깔아(풀 없음) 해결한다(WLDemoTerrain 판 자리 모래).
ZTest LEqual ZTest LEqual
Offset -1, -1 Offset 0, -2
Blend SrcAlpha OneMinusSrcAlpha Blend SrcAlpha OneMinusSrcAlpha
Pass Pass

View File

@ -26,6 +26,9 @@ namespace WL.Island
static readonly List<GameObject> s_made = new List<GameObject>(4); static readonly List<GameObject> s_made = new List<GameObject>(4);
/// <summary>지금 놓여 있는 캠프 장식 루트(들) — 확장 가격판이 텐트·모닥불을 피해 앉을 때 읽는다(2026-09-17).</summary>
public static IReadOnlyList<GameObject> Made { get { return s_made; } }
/// <summary>진단 — 마지막으로 놓은 장식 수·중심.</summary> /// <summary>진단 — 마지막으로 놓은 장식 수·중심.</summary>
public static int DecorSpawned; public static int DecorSpawned;
public static Vector3 LastCenter; public static Vector3 LastCenter;

View File

@ -486,11 +486,15 @@ namespace WL.Island
var pos = p.transform.position; // 잠긴 칸 중앙(FI 원래 자리) var pos = p.transform.position; // 잠긴 칸 중앙(FI 원래 자리)
Vector3 target, c; Vector3 target, c;
if (!ComputePadSpot(cfg, pos, islands, size, out target, out c)) continue; // 이웃을 못 찾으면 FI 자리 그대로 if (!ComputePadSpot(cfg, pos, islands, size, out target, out c)) continue; // 이웃을 못 찾으면 FI 자리 그대로
float gy = PadGroundY(target, c.y);
float plateDropLocal = 0f; var frame = p.transform.Find("Frame"); if (frame != null) plateDropLocal = -frame.localPosition.y; // 판 자식들이 루트보다 아래(로컬 0.49) float plateDropLocal = 0f; var frame = p.transform.Find("Frame"); if (frame != null) plateDropLocal = -frame.localPosition.y; // 판 자식들이 루트보다 아래(로컬 0.49)
// 🔴 2026-09-17 PD 「확장 가격판이 너무 크다 → 캠프파이어 발판과 같은 크기」 = 우리 발판과 같은 배율(platformScale 0.5 = expansionPadScale) // 🔴 2026-09-17 PD 「확장 가격판이 너무 크다 → 캠프파이어 발판과 같은 크기」 = 우리 발판과 같은 배율(platformScale 0.5 = expansionPadScale)
float ps = cfg.expansionPadScale > 0.05f ? cfg.expansionPadScale : 1f; float ps = cfg.expansionPadScale > 0.05f ? cfg.expansionPadScale : 1f;
p.transform.localScale = Vector3.one * ps; p.transform.localScale = Vector3.one * ps;
// 🔴 2026-09-17 PD 「가격판이 경사에 가려지지 않게」 — 판 넓이(반지름 ≈ 2.56×배율) 안 5점의 지형·콜라이더 높이 중 **최고점** 위에 놓고,
// 지형 법선에 맞춰 살짝 눕힌다(최대 expansionPadTiltMax°) → 해변 경사·둔덕 어느 쪽에도 묻히지 않는다.
Quaternion rot;
float gy = PadSurfaceY(new Vector3(target.x, c.y, target.z), c.y, 2.56f * ps, cfg.expansionPadTiltMax, out rot);
p.transform.rotation = rot;
p.transform.position = new Vector3(target.x, gy + plateDropLocal * ps + cfg.expansionPadLift, target.z); p.transform.position = new Vector3(target.x, gy + plateDropLocal * ps + cfg.expansionPadLift, target.z);
// 🔴 2026-09-17 — 원본 트리거는 십자(10×5×5 두 개 · ±5 m)라 가장자리로 옮긴 판끼리 겹쳐 두 구매가 동시에 시작됐다(원본 활성화 경쟁 → 「Bridge (0) inactive」). // 🔴 2026-09-17 — 원본 트리거는 십자(10×5×5 두 개 · ±5 m)라 가장자리로 옮긴 판끼리 겹쳐 두 구매가 동시에 시작됐다(원본 활성화 경쟁 → 「Bridge (0) inactive」).
// 판 크기(expansionPadTriggerSize · 월드 m)만큼의 상자 하나로 줄인다 = 판을 밟아야 산다(우리 발판과 같은 규칙). 로컬 치수는 배율로 역보정. // 판 크기(expansionPadTriggerSize · 월드 m)만큼의 상자 하나로 줄인다 = 판을 밟아야 산다(우리 발판과 같은 규칙). 로컬 치수는 배율로 역보정.
@ -510,23 +514,25 @@ namespace WL.Island
boxes[b].center = new Vector3(0f, -plateDropLocal + 2f / ps, 0f); // 판 높이에서 위로 4 m(월드) boxes[b].center = new Vector3(0f, -plateDropLocal + 2f / ps, 0f); // 판 높이에서 위로 4 m(월드)
} }
} }
if (cfg.expansionPadOverlay != 0) ApplyOverlay(p.gameObject); // 풀·소품에 가려지지 않게 항상 위에 if (cfg.expansionPadOverlay != 0) ApplyOverlay(p.gameObject, cfg.expansionPadOverlay >= 2); // 1 = 깊이 검사(캐릭터가 가림) · 2 = 글자까지 항상 위
var mk = p.gameObject.AddComponent<WLPlacedPurchaser>(); mk.original = pos; var mk = p.gameObject.AddComponent<WLPlacedPurchaser>(); mk.original = pos;
PurchasersPlaced++; PurchasersPlaced++;
LastPlaceLog = "확장 가격판 " + pos.ToString("F0") + " → " + p.transform.position.ToString("F2") + "(이웃 " + c.ToString("F0") + " 가장자리 안쪽 " + cfg.expansionPadInset.ToString("F1") + " m · 지면 " + gy.ToString("F2") + ")"; LastPlaceLog = "확장 가격판 " + pos.ToString("F0") + " → " + p.transform.position.ToString("F2") + "(이웃 " + c.ToString("F0") + " 가장자리 · 옆으로 " + LastSlide.ToString("F1") + " m · 표면 " + gy.ToString("F2") + " · " + LastFitLog + ")";
WLIslandBridge.Log(cfg, LastPlaceLog); WLIslandBridge.Log(cfg, LastPlaceLog);
} }
} }
// ───────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────
// 🔴 2026-09-17 PD 「가격판이 풀·오브젝트에 가려지지 않게 상단에 보이게」 — 스프라이트(Frame·Coin)는 깊이 무시 셰이더 `WL/SpriteOverlay`, // 🔴 2026-09-17 PD 「가격판이 풀·오브젝트에 가려지지 않게」 → 「가격 판이 캐릭터를 가리지 않게 · 바닥에 붙은 느낌」(같은 날 두 번째 지시)
// 가격 글자(TMP)는 TMP 의 Overlay 변형 셰이더로 바꾼 **런타임 복사본** 재질을 물린다(에셋 0줄 · 큐 3100 = 물·거품 뒤). // 스프라이트(Frame·Coin)는 `WL/SpriteOverlay`(깊이 검사 LEqual + 깊이 오프셋 = 바닥과 z-파이팅 없이 붙고, 캐릭터·소품이 앞에 있으면 가려진다).
// 가격 글자(TMP)는 **원본 재질 그대로**(깊이 검사) — `expansionPadOverlay 2` 일 때만 Overlay 변형(항상 위) 복사본을 물린다(에셋 0줄).
// 풀은 판 자리 모래 원(padClearRadius)이 비우므로 항상-위 그리기가 더는 필요 없다.
// ───────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────
static Material s_spriteOverlay; static Material s_spriteOverlay;
static readonly Dictionary<Material, Material> s_tmpOverlay = new Dictionary<Material, Material>(); static readonly Dictionary<Material, Material> s_tmpOverlay = new Dictionary<Material, Material>();
public static int OverlayApplied; public static int OverlayApplied;
public static void ApplyOverlay(GameObject root) public static void ApplyOverlay(GameObject root, bool textAlwaysOnTop)
{ {
if (root == null) return; if (root == null) return;
var srs = root.GetComponentsInChildren<SpriteRenderer>(true); var srs = root.GetComponentsInChildren<SpriteRenderer>(true);
@ -540,6 +546,7 @@ namespace WL.Island
if (s_spriteOverlay != null) if (s_spriteOverlay != null)
for (int i = 0; i < srs.Length; i++) if (srs[i] != null && srs[i].sharedMaterial != s_spriteOverlay) { srs[i].sharedMaterial = s_spriteOverlay; OverlayApplied++; } for (int i = 0; i < srs.Length; i++) if (srs[i] != null && srs[i].sharedMaterial != s_spriteOverlay) { srs[i].sharedMaterial = s_spriteOverlay; OverlayApplied++; }
} }
if (!textAlwaysOnTop) return;
var texts = root.GetComponentsInChildren<TMPro.TMP_Text>(true); var texts = root.GetComponentsInChildren<TMPro.TMP_Text>(true);
for (int i = 0; i < texts.Length; i++) for (int i = 0; i < texts.Length; i++)
{ {
@ -563,9 +570,60 @@ namespace WL.Island
/// 그 칸 가장자리에서 `expansionPadInset` 안쪽 지점을 돌려준다. 가격판 배치와 지형(판 자리 모래·풀 없음)이 **같은 식**을 쓴다. /// 그 칸 가장자리에서 `expansionPadInset` 안쪽 지점을 돌려준다. 가격판 배치와 지형(판 자리 모래·풀 없음)이 **같은 식**을 쓴다.
/// 2026-09-17 PD 「가격판은 이동 가능한 범위 안에서 최대한 가장자리」 → inset 기본 1.0 m. /// 2026-09-17 PD 「가격판은 이동 가능한 범위 안에서 최대한 가장자리」 → inset 기본 1.0 m.
/// </summary> /// </summary>
// 🔴 2026-09-17 PD 「다리 위 가격판 자리가 저절로 바뀌지 않게」 ① — 잠긴 칸마다 처음 정한 자리를 기억한다(더 좋은 이웃이 나중에 열려도 · 던전에서 돌아와
// FI 가 세이브에서 가격판을 다시 만들어도 그대로). PlayerPrefs 에 같이 적어 두고, 세이브 초기화(WLIslandStart) 때 함께 비운다.
const string kSpotPrefsKey = "WL_Island_PadSpots";
static readonly Dictionary<Vector3Int, Vector3> s_spotByTile = new Dictionary<Vector3Int, Vector3>();
static readonly Dictionary<Vector3Int, Vector3> s_neighborByTile = new Dictionary<Vector3Int, Vector3>();
static bool s_spotsLoaded;
public static int SpotMemory { get { return s_spotByTile.Count; } }
public static void ClearSpotMemory() { s_spotByTile.Clear(); s_neighborByTile.Clear(); s_spotsLoaded = true; PlayerPrefs.DeleteKey(kSpotPrefsKey); }
static void LoadSpots()
{
if (s_spotsLoaded) return;
s_spotsLoaded = true;
var s = PlayerPrefs.GetString(kSpotPrefsKey, "");
if (string.IsNullOrEmpty(s)) return;
var ci = System.Globalization.CultureInfo.InvariantCulture;
var entries = s.Split(';');
for (int i = 0; i < entries.Length; i++)
{
var f = entries[i].Split(',');
if (f.Length != 6) continue;
try
{
var key = new Vector3Int(int.Parse(f[0], ci), 0, int.Parse(f[1], ci));
s_spotByTile[key] = new Vector3(float.Parse(f[2], ci), 0f, float.Parse(f[3], ci));
s_neighborByTile[key] = new Vector3(float.Parse(f[4], ci), 0f, float.Parse(f[5], ci));
}
catch (System.Exception) { }
}
}
static void SaveSpots()
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
var sb = new System.Text.StringBuilder();
foreach (var kv in s_spotByTile)
{
Vector3 n; if (!s_neighborByTile.TryGetValue(kv.Key, out n)) n = kv.Value;
if (sb.Length > 0) sb.Append(';');
sb.Append(kv.Key.x.ToString(ci)).Append(',').Append(kv.Key.z.ToString(ci)).Append(',')
.Append(kv.Value.x.ToString("F2", ci)).Append(',').Append(kv.Value.z.ToString("F2", ci)).Append(',')
.Append(n.x.ToString("F2", ci)).Append(',').Append(n.z.ToString("F2", ci));
}
PlayerPrefs.SetString(kSpotPrefsKey, sb.ToString());
PlayerPrefs.Save();
}
public static bool ComputePadSpot(WLIslandSettings cfg, Vector3 lockedPos, FIIsland[] islands, float size, out Vector3 spot, out Vector3 neighborCenter) public static bool ComputePadSpot(WLIslandSettings cfg, Vector3 lockedPos, FIIsland[] islands, float size, out Vector3 spot, out Vector3 neighborCenter)
{ {
spot = lockedPos; neighborCenter = lockedPos; spot = lockedPos; neighborCenter = lockedPos;
LoadSpots();
var key = Vector3Int.RoundToInt(lockedPos);
if (s_spotByTile.TryGetValue(key, out spot)) { neighborCenter = s_neighborByTile[key]; return true; }
float half = size * 0.5f; float half = size * 0.5f;
Vector3[] dirs = { Vector3.forward, Vector3.right, Vector3.back, Vector3.left }; Vector3[] dirs = { Vector3.forward, Vector3.right, Vector3.back, Vector3.left };
FIIsland best = null; Vector3 bestDir = Vector3.zero; int bestRank = int.MaxValue; FIIsland best = null; Vector3 bestDir = Vector3.zero; int bestRank = int.MaxValue;
@ -585,10 +643,93 @@ namespace WL.Island
} }
if (best == null) return false; if (best == null) return false;
neighborCenter = best.transform.position; neighborCenter = best.transform.position;
spot = neighborCenter + bestDir * (half - Mathf.Clamp(cfg.expansionPadInset, 0.3f, half)); // 🔴 2026-09-17 PD ③ 「확장 방향에 맞게 · 십자 방향 판끼리 안 겹치게 간격·크기」
// 자리 = 확장 방향 가장자리 **중앙**, 안쪽 거리 = max(설정, 판 반지름 + 0.15)(판·트리거가 칸 밖으로 안 나간다).
// 그 자리가 텐트·모닥불·던전 문·발판·다른 확장 판·소품과 겹치면 가장자리를 따라 옆으로(±1 · ±2 · 끝) 비킨다.
float ps = cfg.expansionPadScale > 0.05f ? cfg.expansionPadScale : 1f;
float padR = 2.56f * ps;
float inset = Mathf.Clamp(Mathf.Max(cfg.expansionPadInset, padR + 0.15f), 0.3f, half);
var basePos = neighborCenter + bestDir * (half - inset);
var perp = new Vector3(bestDir.z, 0f, bestDir.x);
float maxSlide = Mathf.Max(0f, half - padR - 0.25f);
float[] cands = { 0f, 1f, -1f, 2f, -2f, maxSlide, -maxSlide };
spot = basePos; LastSlide = 0f; bool found = false;
for (int k = 0; k < cands.Length; k++)
{
float s = Mathf.Clamp(cands[k], -maxSlide, maxSlide);
var cand = basePos + perp * s;
if (IsSpotBlocked(cfg, cand, padR, key, islands)) continue;
spot = cand; LastSlide = s; found = true; break;
}
if (!found) BlockedSpots++;
s_spotByTile[key] = new Vector3(spot.x, 0f, spot.z); s_neighborByTile[key] = new Vector3(neighborCenter.x, 0f, neighborCenter.z);
SaveSpots();
return true; return true;
} }
public static float LastSlide; public static int BlockedSpots;
/// <summary>판 자리가 막혔는가 — 다른 확장 판(잠긴 칸 기억 자리) · 캠프 장식 · 던전 문 · 우리 발판(렌더러 경계) · 콜라이더 있는 섬 소품(동물 제외).</summary>
static bool IsSpotBlocked(WLIslandSettings cfg, Vector3 c, float padR, Vector3Int selfKey, FIIsland[] islands)
{
float sep = Mathf.Max(2f * padR, cfg.expansionPadTriggerSize) + 0.4f;
foreach (var kv in s_spotByTile)
{
if (kv.Key == selfKey || IsTileUnlocked(kv.Key, islands)) continue;
float dx = kv.Value.x - c.x, dz = kv.Value.z - c.z;
if (dx * dx + dz * dz < sep * sep) return true;
}
float r = padR + 0.2f;
var made = WLIslandCampDecor.Made;
for (int i = 0; i < made.Count; i++)
{
if (made[i] == null) continue;
var t = made[i].transform;
if (t.childCount == 0) { if (RectHitsCircle(RendererBounds(made[i]), c, r)) return true; continue; }
for (int j = 0; j < t.childCount; j++) if (RectHitsCircle(RendererBounds(t.GetChild(j).gameObject), c, r)) return true; // 텐트·모닥불 하나씩
}
var gates = Object.FindObjectsByType<WLDungeonGate>(FindObjectsSortMode.None);
for (int i = 0; i < gates.Length; i++) if (gates[i] != null && RectHitsCircle(RendererBounds(gates[i].gameObject), c, r)) return true;
var plats = Object.FindObjectsByType<WLGoldPlatform>(FindObjectsSortMode.None);
for (int i = 0; i < plats.Length; i++) if (plats[i] != null && RectHitsCircle(RendererBounds(plats[i].gameObject), c, r)) return true;
var cols = Physics.OverlapSphere(new Vector3(c.x, c.y + 0.5f, c.z), r, ~0, QueryTriggerInteraction.Collide);
for (int i = 0; i < cols.Length; i++)
{
var col = cols[i]; if (col == null) continue;
if (col.GetComponentInParent<CryingSnow.FarmingIsland.IProp>() == null) continue;
if (col.GetComponentInParent<CryingSnow.FarmingIsland.AnimalController>() != null) continue;
return true;
}
return false;
}
static bool IsTileUnlocked(Vector3Int key, FIIsland[] islands)
{
if (islands == null) return false;
for (int i = 0; i < islands.Length; i++)
{
var isl = islands[i]; if (isl == null) continue;
if (Vector3Int.RoundToInt(isl.transform.position) == key) return isl.IsUnlocked;
}
return false;
}
static Bounds RendererBounds(GameObject go)
{
var rs = go.GetComponentsInChildren<Renderer>(false);
if (rs.Length == 0) return new Bounds(go.transform.position, Vector3.zero);
var b = rs[0].bounds;
for (int i = 1; i < rs.Length; i++) b.Encapsulate(rs[i].bounds);
return b;
}
static bool RectHitsCircle(Bounds b, Vector3 c, float r)
{
if (b.size.x <= 0f && b.size.z <= 0f) return false;
float dx = Mathf.Max(b.min.x - c.x, 0f, c.x - b.max.x), dz = Mathf.Max(b.min.z - c.z, 0f, c.z - b.max.z);
return dx * dx + dz * dz < r * r;
}
/// <summary> /// <summary>
/// 지금 가격판이 놓일(놓인) 자리 전부 — 잠긴 칸 중 「직교 이웃이 열려 있고 선행 조건(Constraint)이 풀린」 칸(FI 가 판을 만드는 조건과 같음). /// 지금 가격판이 놓일(놓인) 자리 전부 — 잠긴 칸 중 「직교 이웃이 열려 있고 선행 조건(Constraint)이 풀린」 칸(FI 가 판을 만드는 조건과 같음).
/// 지형이 이 자리에 모래를 깔아 풀을 비운다. /// 지형이 이 자리에 모래를 깔아 풀을 비운다.
@ -613,6 +754,60 @@ namespace WL.Island
return n; return n;
} }
// ─────────────────────────────────────────────────────────────────
// 🔴 2026-09-17 PD 「가격판이 경사에 가려지지 않게」
// 판 평면 = 중심 표면 높이 + 지형 법선 기울기(최대 expansionPadTiltMax°). 판 테두리 8점 어디서든 표면(지형·바닥 콜라이더)이
// 평면보다 높으면 그만큼 평면을 올린다 → 둔덕(안쪽) · 해변 경사(바깥쪽) 어느 쪽에도 판이 묻히지 않는다.
// ─────────────────────────────────────────────────────────────────
public static string LastFitLog = "";
static float PadSurfaceY(Vector3 center, float fallbackY, float r, float tiltMax, out Quaternion rot)
{
var dt = Object.FindFirstObjectByType<global::WL.Look.Farm.WLDemoTerrain>(FindObjectsInactive.Include);
var terrain = dt != null ? dt.TerrainComponent : null;
// 중심 + 테두리 8점의 표면 높이로 최소제곱 평면 y = a·x + b·z + c 를 맞춘다(테두리는 대칭 → Σx² = Σz² = 4r²) → 둔덕·해변 경사·다리 아치를 모두 따라 눕는다.
var os = new Vector3[9]; var ys = new float[9];
float sum = 0f, sx = 0f, sz = 0f;
for (int k = 0; k < 9; k++)
{
os[k] = k < 8 ? new Vector3(Mathf.Cos(k * Mathf.PI * 0.25f) * r, 0f, Mathf.Sin(k * Mathf.PI * 0.25f) * r) : Vector3.zero;
ys[k] = SurfaceAt(center + os[k], fallbackY, terrain);
sum += ys[k]; sx += ys[k] * os[k].x; sz += ys[k] * os[k].z;
}
float a = sx / (4f * r * r), b = sz / (4f * r * r), c = sum / 9f;
rot = PadTilt(new Vector3(-a, 1f, -b), tiltMax);
float raise = float.MinValue; // 어느 표본도 판 아래에 묻히지 않게 평면을 올린다
for (int k = 0; k < 9; k++) { float d = ys[k] - (c + (rot * os[k]).y); if (d > raise) raise = d; }
if (raise < 0f) raise = 0f;
LastFitLog = "평면 " + c.ToString("F2") + " 기울기 " + Quaternion.Angle(Quaternion.identity, rot).ToString("F1") + "° 올림 " + raise.ToString("F2");
return c + raise;
}
/// <summary>한 점의 표면 높이 = 바닥 콜라이더(레이 · 소품·플레이어·가격판·벽 제외)와 데모 지형 높이 중 큰 값.</summary>
static float SurfaceAt(Vector3 p, float fallbackY, Terrain terrain)
{
float h = PadGroundY(p, fallbackY);
if (terrain != null)
{
var td = terrain.terrainData; var tp = terrain.transform.position;
if (td != null && p.x >= tp.x && p.z >= tp.z && p.x <= tp.x + td.size.x && p.z <= tp.z + td.size.z)
{
float th = terrain.SampleHeight(p) + tp.y;
if (th > h) h = th;
}
}
return h;
}
static Quaternion PadTilt(Vector3 normal, float maxDeg)
{
if (maxDeg <= 0f || normal.sqrMagnitude < 1e-6f) return Quaternion.identity;
var q = Quaternion.FromToRotation(Vector3.up, normal.normalized);
float ang = Quaternion.Angle(Quaternion.identity, q);
if (ang > maxDeg) q = Quaternion.Slerp(Quaternion.identity, q, maxDeg / ang);
return q;
}
static float PadGroundY(Vector3 p, float fallbackY) static float PadGroundY(Vector3 p, float fallbackY)
{ {
var hits = Physics.RaycastAll(new Vector3(p.x, fallbackY + 5f, p.z), Vector3.down, 20f, ~0, QueryTriggerInteraction.Ignore); var hits = Physics.RaycastAll(new Vector3(p.x, fallbackY + 5f, p.z), Vector3.down, 20f, ~0, QueryTriggerInteraction.Ignore);
@ -621,11 +816,84 @@ namespace WL.Island
{ {
float y = hits[i].point.y; float y = hits[i].point.y;
if (y > fallbackY + 1.5f) continue; if (y > fallbackY + 1.5f) continue;
if (!IsGroundCollider(hits[i].collider)) continue;
if (float.IsNaN(best) || y > best) best = y; if (float.IsNaN(best) || y > best) best = y;
} }
return float.IsNaN(best) ? fallbackY : best; return float.IsNaN(best) ? fallbackY : best;
} }
/// <summary>바닥으로 치는 콜라이더 — 지형 · 우리 둔덕 콜라이더 · 섬 칸(타일·다리 바닥). 소품·가장자리 벽(세로 얇은 상자)·플레이어·가격판·발판·설치물은 제외(판이 그 위로 올라가면 안 된다).</summary>
static bool IsGroundCollider(Collider c)
{
if (c == null || c.isTrigger) return false;
if (c is TerrainCollider) return true;
if (c.gameObject.name.StartsWith("~WL_Relief")) return true;
if (c.GetComponentInParent<FIIsland>() == null) return false;
if (c.GetComponentInParent<CryingSnow.FarmingIsland.IProp>() != null) return false;
var box = c as BoxCollider;
if (box != null)
{
var s = Vector3.Scale(box.size, c.transform.lossyScale);
if (s.y > 0.6f && Mathf.Min(Mathf.Abs(s.x), Mathf.Abs(s.z)) < 3f) return false; // 가장자리 벽(8×2×0.3)·다리 난간(2.6×2×8)
}
return true;
}
// ─────────────────────────────────────────────────────────────────
// 🔴 2026-09-17 PD 「다리 위 가격판 자리가 저절로 바뀌지 않게」 ②
// FI 는 칸을 연 순간(OnActivated) 새 가격판을 잠긴 칸 중앙에 만든다 → 3 s 훑기가 오기 전까지 원래 자리(바다 쪽)에 보였다가 옮겨졌다.
// → 칸이 열리는 **그 프레임**에 바로 옮긴다. 지형이 다시 구워지면(솟아오르기 끝) 높이·기울기만 다시 맞춘다(자리 x·z 고정).
// ─────────────────────────────────────────────────────────────────
static readonly HashSet<FIIsland> s_watched = new HashSet<FIIsland>();
static bool s_bakedHooked; static WLIslandSettings s_cfgForBaked;
public static int Refits, ActivationPlaces;
static void Watch(WLIslandSettings cfg, Scene scene)
{
s_watched.RemoveWhere(i => i == null);
var islands = Object.FindObjectsByType<FIIsland>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < islands.Length; i++)
{
var isl = islands[i];
if (isl == null || s_watched.Contains(isl)) continue;
s_watched.Add(isl);
// 🔴 실측: OnActivated 는 DOScale 완료 콜백 — 같은 프레임의 DOJump 착지가 아직 안 끝나 칸이 ~9 cm 떠 있다 → 자리는 즉시, 높이는 다음 프레임에 한 번 더 맞춘다.
isl.OnActivated += () =>
{
if (cfg == null) return;
ActivationPlaces++;
PlacePurchasers(cfg, scene);
var runner = WLIslandBridge.Runner;
if (runner != null) runner.StartCoroutine(Co_RefitNextFrame(cfg));
};
}
s_cfgForBaked = cfg;
if (!s_bakedHooked) { s_bakedHooked = true; global::WL.Look.Farm.WLDemoTerrain.Baked += OnTerrainBaked; }
}
static void OnTerrainBaked() { if (s_cfgForBaked != null) RefitPlaced(s_cfgForBaked); }
static IEnumerator Co_RefitNextFrame(WLIslandSettings cfg) { yield return null; RefitPlaced(cfg); }
/// <summary>이미 옮긴 판의 높이·기울기만 지형에 다시 맞춘다(자리는 그대로).</summary>
public static void RefitPlaced(WLIslandSettings cfg)
{
if (cfg == null || cfg.expansionPadPlace == 0) return;
var marks = Object.FindObjectsByType<WLPlacedPurchaser>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < marks.Length; i++)
{
var m = marks[i]; if (m == null) continue;
var t = m.transform;
float ps = t.localScale.x > 0.05f ? t.localScale.x : 1f;
float plateDropLocal = 0f; var frame = t.Find("Frame"); if (frame != null) plateDropLocal = -frame.localPosition.y;
Quaternion rot; var pos = t.position;
float gy = PadSurfaceY(new Vector3(pos.x, m.original.y, pos.z), m.original.y, 2.56f * ps, cfg.expansionPadTiltMax, out rot);
t.rotation = rot;
t.position = new Vector3(pos.x, gy + plateDropLocal * ps + cfg.expansionPadLift, pos.z);
Refits++;
}
}
static readonly List<Renderer> s_rends = new List<Renderer>(64); static readonly List<Renderer> s_rends = new List<Renderer>(64);
static readonly List<Collider> s_cols = new List<Collider>(64); static readonly List<Collider> s_cols = new List<Collider>(64);
@ -657,6 +925,7 @@ namespace WL.Island
{ {
if (cfg == null || cfg.gateFlowEnabled == 0 || cfg.expansionAfterFirstWin == 0) return; if (cfg == null || cfg.gateFlowEnabled == 0 || cfg.expansionAfterFirstWin == 0) return;
Watch(cfg, scene); // 칸이 열리는 프레임에 바로 옮기기 · 지형 재굽기 뒤 높이 재맞춤
PlacePurchasers(cfg, scene); // 자리 옮기기(숨김 여부와 무관 · 1회) PlacePurchasers(cfg, scene); // 자리 옮기기(숨김 여부와 무관 · 1회)
if (IsUnlocked(cfg)) if (IsUnlocked(cfg))
{ {

View File

@ -797,7 +797,7 @@ namespace WL.Island
float ps = cfg.platformScale > 0.001f ? cfg.platformScale : 1f; float ps = cfg.platformScale > 0.001f ? cfg.platformScale : 1f;
AppliedScale = ps; AppliedScale = ps;
if (ps != 1f) p.transform.localScale = p.transform.localScale * ps; if (ps != 1f) p.transform.localScale = p.transform.localScale * ps;
if (cfg.expansionPadOverlay != 0) WLIslandExpansionGate.ApplyOverlay(p.gameObject); // 2026-09-17 PD — 우리 발판도 풀·소품 위에 보이게(같은 규칙) if (cfg.expansionPadOverlay != 0) WLIslandExpansionGate.ApplyOverlay(p.gameObject, cfg.expansionPadOverlay >= 2); // 2026-09-17 PD — 우리 발판도 같은 규칙(1 = 깊이 검사 · 캐릭터가 가림)
// 🔴 FI 가격판은 프리팹 안에서 **루트보다 앞·아래**에 놓여 있다(실측 2026-09-13: // 🔴 FI 가격판은 프리팹 안에서 **루트보다 앞·아래**에 놓여 있다(실측 2026-09-13:
// Frame/Price/Coin 이 전부 y = -0.49 · z = 루트+(-1/0/+1) — 잠긴 섬 자리의 **물 위**에 눕는 판이다). // Frame/Price/Coin 이 전부 y = -0.49 · z = 루트+(-1/0/+1) — 잠긴 섬 자리의 **물 위**에 눕는 판이다).

View File

@ -88,6 +88,7 @@ namespace WL.Island
// WL-816zc — 초기 플로우 단계·확장 개방도 같이 0 으로(시작마다 초기화 대상) // WL-816zc — 초기 플로우 단계·확장 개방도 같이 0 으로(시작마다 초기화 대상)
if (!string.IsNullOrEmpty(cfg.flowStepPrefsKey)) PlayerPrefs.SetInt(cfg.flowStepPrefsKey, 0); if (!string.IsNullOrEmpty(cfg.flowStepPrefsKey)) PlayerPrefs.SetInt(cfg.flowStepPrefsKey, 0);
if (!string.IsNullOrEmpty(cfg.expansionUnlockPrefsKey)) PlayerPrefs.SetInt(cfg.expansionUnlockPrefsKey, 0); if (!string.IsNullOrEmpty(cfg.expansionUnlockPrefsKey)) PlayerPrefs.SetInt(cfg.expansionUnlockPrefsKey, 0);
WLIslandExpansionGate.ClearSpotMemory(); // 2026-09-17 — 확장 가격판 자리 기억도 세이브와 함께 초기화
PlayerPrefs.Save(); PlayerPrefs.Save();
WLIslandBridge.ClearPending(); // 보류 보상도 비운다(정적 값까지) WLIslandBridge.ClearPending(); // 보류 보상도 비운다(정적 값까지)
} }

View File

@ -564,22 +564,25 @@ namespace WL.Island
[Tooltip("1 = FI 확장 가격판(잠긴 칸 중앙 · 물 위)을 확장 방향 이웃(열린) 칸 가장자리 안쪽 지면 위로 옮긴다(FI 격자·세이브 무변경 · 트리거도 함께). 0 = 원본 자리.")] [Tooltip("1 = FI 확장 가격판(잠긴 칸 중앙 · 물 위)을 확장 방향 이웃(열린) 칸 가장자리 안쪽 지면 위로 옮긴다(FI 격자·세이브 무변경 · 트리거도 함께). 0 = 원본 자리.")]
public int expansionPadPlace = 1; public int expansionPadPlace = 1;
[Tooltip("이웃 칸 가장자리에서 안쪽으로 이만큼(m) 들어온 자리에 놓는다. 2026-09-17 PD 「이동 가능한 범위 안에서 최대한 가장자리」 → 2.2 → 1.0(타일 콜라이더 끝 = 걸어갈 수 있는 끝).")] [Tooltip("이웃 칸 가장자리에서 안쪽으로 이만큼(m) 들어온 자리에 놓는다. 2026-09-17 PD 「이동 가능한 범위 안에서 최대한 가장자리」 → 1.0 → 판이 칸 밖으로 안 나가는 최소값(판 반지름 2.56×배율 + 0.15 = 1.3)이 하한.")]
public float expansionPadInset = 1.0f; public float expansionPadInset = 1.3f;
[Tooltip("가격판 자리(확장 판·현재 단계 발판)에 지형이 까는 모래 원 반지름(m) — 풀잎이 판을 덮지 않게(판은 깊이 검사로 캐릭터 아래에 그려진다). 0 = 안 깖.")] [Tooltip("가격판 자리(확장 판·현재 단계 발판)에 지형이 까는 모래 원 반지름(m) — 풀잎이 판을 덮지 않게(판은 깊이 검사로 캐릭터 아래에 그려진다). 0 = 안 깖.")]
public float padClearRadius = 1.6f; public float padClearRadius = 1.4f;
[Tooltip("판을 지면보다 이만큼(m) 띄운다(z-fighting 방지).")] [Tooltip("판을 지면보다 이만큼(m) 띄운다(z-fighting 방지).")]
public float expansionPadLift = 0.06f; public float expansionPadLift = 0.05f;
[Tooltip("옮긴 가격판의 트리거 한 변(m). 원본 십자(±5 m)는 가장자리로 옮긴 판끼리 겹쳐 두 구매가 동시에 시작돼 원본 활성화 경쟁 오류가 났다(2026-09-17). 0 = 원본 트리거 그대로.")] [Tooltip("옮긴 가격판의 트리거 한 변(m). 원본 십자(±5 m)는 가장자리로 옮긴 판끼리 겹쳐 두 구매가 동시에 시작돼 원본 활성화 경쟁 오류가 났다(2026-09-17). 십자 방향 판(가장자리 중앙 · 서로 4.24 m)끼리 모서리에서 안 겹치려면 < 2×(4 inset 1.5) → 2.6. 0 = 원본 트리거 그대로.")]
public float expansionPadTriggerSize = 3.4f; public float expansionPadTriggerSize = 2.6f;
[Tooltip("옮긴 확장 가격판의 크기 배율 — 2026-09-17 PD 「캠프파이어 발판과 같은 크기」 = platformScale 과 같은 0.5.")] [Tooltip("옮긴 확장 가격판의 크기 배율 — 2026-09-17 PD 「캠프파이어 발판과 같은 크기」 = 0.5 → 「십자 방향 판끼리 겹치지 않게 간격·크기 조정」 0.45.")]
public float expansionPadScale = 0.5f; public float expansionPadScale = 0.45f;
[Tooltip("1 = 가격판(프레임·코인 스프라이트·가격 글자)을 깊이 무시 셰이더로 그려 풀·소품에 가려지지 않게 한다(2026-09-17 PD). 0 = 원본 재질.")] [Tooltip("판을 지형 법선에 맞춰 눕히는 최대 각도(°) — 해변 경사·둔덕에 묻히지 않게(2026-09-17 PD). 0 = 항상 수평.")]
public float expansionPadTiltMax = 20f;
[Tooltip("가격판 재질: 0 = 원본 · 1 = 프레임·코인 스프라이트를 깊이 검사+깊이 오프셋 셰이더(바닥에 붙고 캐릭터·소품이 앞이면 가려짐 · 2026-09-17 PD 「캐릭터를 가리지 않게 · 바닥에 붙은 느낌」) · 2 = 가격 글자까지 항상 위(옛 방식).")]
public int expansionPadOverlay = 1; public int expansionPadOverlay = 1;
[Tooltip("한 줄 = 한 단계. 위에서부터 차례로 발판이 선다.")] [Tooltip("한 줄 = 한 단계. 위에서부터 차례로 발판이 선다.")]

View File

@ -20,19 +20,44 @@ namespace WL.Tools
public const string PrefKey = "WL_LeadAutoEnter"; public const string PrefKey = "WL_LeadAutoEnter";
public static string LastLog = ""; public static string LastLog = "";
public static bool Running; public static bool Running;
/// <summary>로그인 완료(AllComplete) 뒤 인게임으로 넘기기까지 기다리는 시간(s) — 사람이 화면을 터치하는 간격.
/// 🔴 2026-09-17 실측: 완료 직후 바로 넘기면 원본 로그인 사슬의 **두 번째** 완료 응답이 타이틀 씬이 내려간 뒤 도착해
/// `TitleInfo.ServerComplete` 가 지워진 글자를 만져 MissingReferenceException 창이 떴다(재시도 0회에서도 재현).</summary>
public static float EnterDelay = 8f;
public static float AllCompleteAt = -1f;
// 🔴 2026-09-17 실측: 표식(Boot)으로 만든 인스턴스와 프로브가 직접 만든 인스턴스가 **둘 다** DevLogin 을 불러 로그인 사슬이 2개 →
// 두 번째 사슬의 토큰 저장이 씬 전환 뒤 실패해 「서버 에러 · 토큰 저장 실패」 창(원본 모달)이 인게임에 떴다. 인스턴스는 항상 하나만.
static WLLeadAutoEnter s_instance;
public static bool Exists { get { return s_instance != null; } }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot() static void Boot()
{ {
if (PlayerPrefs.GetInt(PrefKey, 0) == 0) return; if (PlayerPrefs.GetInt(PrefKey, 0) == 0) return;
PlayerPrefs.SetInt(PrefKey, 0); PlayerPrefs.Save(); // 1회 소비 PlayerPrefs.SetInt(PrefKey, 0); PlayerPrefs.Save(); // 1회 소비
Create();
}
/// <summary>프로브가 직접 부를 때도 여기로 — 이미 있으면 만들지 않는다.</summary>
public static bool Create()
{
if (s_instance != null) return false;
var go = new GameObject("~WL_LeadAutoEnter"); var go = new GameObject("~WL_LeadAutoEnter");
go.hideFlags = HideFlags.DontSave; go.hideFlags = HideFlags.DontSave;
DontDestroyOnLoad(go); DontDestroyOnLoad(go);
go.AddComponent<WLLeadAutoEnter>(); s_instance = go.AddComponent<WLLeadAutoEnter>();
return true;
} }
void Start() { StartCoroutine(Run()); } void Start()
{
if (s_instance != null && s_instance != this) { Destroy(gameObject); return; }
s_instance = this;
StartCoroutine(Run());
}
void OnDestroy() { if (s_instance == this) s_instance = null; }
IEnumerator Run() IEnumerator Run()
{ {
@ -51,8 +76,14 @@ namespace WL.Tools
bool all = fAll != null && (bool)fAll.GetValue(title); bool all = fAll != null && (bool)fAll.GetValue(title);
if (all) if (all)
{ {
if (AllCompleteAt < 0f)
{
AllCompleteAt = Time.realtimeSinceStartup;
LastLog = "로그인 완료(" + (AllCompleteAt - t0).ToString("F1") + " s) → " + EnterDelay.ToString("F0") + " s 뒤 진입";
}
if (Time.realtimeSinceStartup - AllCompleteAt < EnterDelay) { yield return new WaitForSeconds(0.5f); continue; }
title.OnClick_Screen(); title.OnClick_Screen();
LastLog = "화면 터치 → 인게임 로드(" + (Time.realtimeSinceStartup - t0).ToString("F1") + " s)"; LastLog = "로그인 완료 " + (AllCompleteAt - t0).ToString("F1") + " s · 화면 터치 → 인게임 로드(" + (Time.realtimeSinceStartup - t0).ToString("F1") + " s)";
yield return new WaitForSeconds(2f); yield return new WaitForSeconds(2f);
if (SceneManager.GetActiveScene().name != "Title") break; if (SceneManager.GetActiveScene().name != "Title") break;
continue; continue;
@ -61,9 +92,12 @@ namespace WL.Tools
bool active = fActive != null && (bool)fActive.GetValue(title); bool active = fActive != null && (bool)fActive.GetValue(title);
int fail = fFail != null ? (int)fFail.GetValue(title) : 0; int fail = fFail != null ? (int)fFail.GetValue(title) : 0;
// 🔴 실측: 스토어 정보 대기 중에는 activeButtons 가 오래 false 인데 OnClick_DevLogin 은 그와 무관하게 동작한다(이전 수동 진입과 동일) // 🔴 실측: 스토어 정보 대기 중에는 activeButtons 가 오래 false 인데 OnClick_DevLogin 은 그와 무관하게 동작한다(이전 수동 진입과 동일)
// → 타이틀이 보이면 1 s 뒤 바로 호출하고, 완료가 안 되면 8 s 마다 다시 시도한다. // → 타이틀이 보이면 1 s 뒤 바로 호출한다.
// 🔴 2026-09-17 실측: 8 s 마다 다시 부르면 서버 응답(ServerComplete)이 여러 번 오고, 마지막 응답이 인게임 전환 **뒤**에 도착해
// 원본 TitleInfo 가 지워진 타이틀 글자를 만져 MissingReferenceException 창이 떴다 → 재시도는 실패 표시가 있거나 90 s 무응답일 때만
// (실측: 개발 로그인 완료까지 40~45 s 걸린다 — 40 s 재시도도 같은 창을 띄웠다).
bool first = !loginCalled && Time.realtimeSinceStartup - t0 > 1f; bool first = !loginCalled && Time.realtimeSinceStartup - t0 > 1f;
bool retry = loginCalled && (fail > 0 || Time.realtimeSinceStartup - lastLogin > 8f); bool retry = loginCalled && (fail > 0 || Time.realtimeSinceStartup - lastLogin > 90f);
if (first || retry) if (first || retry)
{ {
var input = fInput != null ? fInput.GetValue(title) as TMPro.TMP_InputField : null; var input = fInput != null ? fInput.GetValue(title) as TMPro.TMP_InputField : null;