[WL] 다리 생성 에러(원본 활성화 경쟁 → 해금·비활성) 3겹 방어 · 확장 가격판 0.5배·판 크기 트리거·항상 위에(WL/SpriteOverlay) · EventSystem 경고 홍수 차단 · WLErrorTrace (#816)

- WLDemoTerrain.Update: 활성화 첫 프레임에 IsUnlocked 선반영 · 활성화 중 꺼지면 재활성 · 해금·비활성 칸 5 s 스캔 복구(InitializeProps 리플렉션)
- WLIslandFlowV2: PlacePurchasers 배율 expansionPadScale 0.5 · 트리거 expansionPadTriggerSize 3.4 m(십자 겹침 제거) · ApplyOverlay(스프라이트 WL/SpriteOverlay · TMP Overlay) — 우리 발판에도
- WLIslandUi: ReconcileEventSystems(원본 EventSystem 있으면 우리 것 삭제) · WLErrorTrace: 에러·예외·Coroutine 경고 최근 40건 보관
- 실측: 같은 프레임 땅+다리 활성화 오류 0 · 가격판 scale 0.50 · 상자 3.4 m · overlayApplied 9 · 콘솔 에러 0

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-17 00:18:43 +09:00
parent 3e6b94db45
commit 28be65ada8
11 changed files with 273 additions and 9 deletions

View File

@ -97,6 +97,9 @@ MonoBehaviour:
expansionPadPlace: 1
expansionPadInset: 2.2
expansionPadLift: 0.06
expansionPadTriggerSize: 3.4
expansionPadScale: 0.5
expansionPadOverlay: 1
campDecorSnapToGround: 0
applyStartCoin: 1
startCoin: 10

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 90beba3ec3bc4f0469656aa21524963a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,55 @@
// WLSpriteOverlay.shader — 스프라이트를 **항상 위에**(깊이 무시) 그리는 언릿 · 알파 블렌드 셰이더
// 2026-09-17 PD 「가격판이 풀·오브젝트에 가려지지 않게 상단에 보이게」 — 확장 가격판(FI Purchaser 의 Frame/Coin 스프라이트)에 런타임 복사본 재질로 물린다.
// SpriteRenderer 가 _MainTex 에 스프라이트 텍스처를 넣어 주고 정점색을 곱한다. 모바일: 텍스처 1장 · 조명 없음.
Shader "WL/SpriteOverlay"
{
Properties
{
[PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
_Color ("Tint", Color) = (1,1,1,1)
}
SubShader
{
Tags { "Queue"="Transparent+100" "RenderType"="Transparent" "IgnoreProjector"="True" "PreviewType"="Plane" "CanUseSpriteAtlas"="True" }
Cull Off
Lighting Off
ZWrite Off
ZTest Always
Blend SrcAlpha OneMinusSrcAlpha
Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex);
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
half4 _Color;
CBUFFER_END
struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; half4 color : COLOR; };
struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; half4 color : COLOR; };
Varyings vert(Attributes v)
{
Varyings o;
o.positionHCS = TransformObjectToHClip(v.positionOS.xyz);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.color = v.color * _Color;
return o;
}
half4 frag(Varyings i) : SV_Target
{
half4 c = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv) * i.color;
c.rgb *= c.a;
return half4(c.rgb / max(c.a, 1e-4), c.a);
}
ENDHLSL
}
}
Fallback Off
}

View File

@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: d6fd8c10b72144e4980dd1090d915bec
ShaderImporter:
externalObjects: {}
defaultTextures: []
nonModifiableTextures: []
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,61 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLErrorTrace.cs — 런타임 에러·예외·「Coroutine couldn't be started」 경고를 **메모리에 보관**한다(최근 40건 · 스택 포함).
//
// 2026-09-17 PD 「다리가 생성될 때 에러」 실측: 화면 팝업은 보였는데 에디터 콘솔(파이프라인 조회)은 비어 있어 원문·스택을 못 잡았다
// (Play 재시작·Clear 로 사라짐). 이후로는 Lead 프로브가 `WLErrorTrace.Dump()` 로 언제든 읽는다. 원본 코드 0줄 · 화면 출력 없음.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
namespace WL.Island
{
public static class WLErrorTrace
{
public struct Entry { public float time; public LogType type; public string message; public string stack; }
static readonly List<Entry> s_entries = new List<Entry>(64);
static bool s_hooked;
public static int Total;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void Hook()
{
if (s_hooked) return;
s_hooked = true;
Application.logMessageReceived += OnLog;
}
static void OnLog(string message, string stack, LogType type)
{
bool keep = type == LogType.Error || type == LogType.Exception || type == LogType.Assert
|| (type == LogType.Warning && message != null && message.IndexOf("Coroutine couldn't be started", System.StringComparison.Ordinal) >= 0);
if (!keep) return;
Total++;
if (s_entries.Count >= 40) s_entries.RemoveAt(0);
s_entries.Add(new Entry { time = Time.realtimeSinceStartup, type = type, message = message, stack = stack });
}
/// <summary>최근 항목을 한 문자열로(프로브용). 같은 메시지는 횟수만 센다.</summary>
public static string Dump(int maxEach = 400)
{
var sb = new System.Text.StringBuilder();
sb.Append("total=").Append(Total).Append(" kept=").Append(s_entries.Count);
var counts = new Dictionary<string, int>();
for (int i = 0; i < s_entries.Count; i++)
{
var e = s_entries[i];
string key = e.message.Length > 100 ? e.message.Substring(0, 100) : e.message;
int c; counts.TryGetValue(key, out c);
counts[key] = c + 1;
if (c > 0) continue;
sb.Append("\n[").Append(e.time.ToString("F1")).Append("s ").Append(e.type).Append("] ")
.Append(e.message.Length > maxEach ? e.message.Substring(0, maxEach) : e.message);
if (!string.IsNullOrEmpty(e.stack)) sb.Append("\n ").Append(e.stack.Length > maxEach ? e.stack.Substring(0, maxEach) : e.stack);
}
foreach (var kv in counts) if (kv.Value > 1) sb.Append("\n x").Append(kv.Value).Append(" ").Append(kv.Key);
return sb.ToString();
}
public static void Clear() { s_entries.Clear(); }
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d5cabada8fb5b024a965629f583e026e

View File

@ -501,8 +501,25 @@ namespace WL.Island
dir = Mathf.Abs(dir.x) >= Mathf.Abs(dir.z) ? new Vector3(Mathf.Sign(dir.x), 0f, 0f) : new Vector3(0f, 0f, Mathf.Sign(dir.z));
var target = c + dir * (half - Mathf.Clamp(cfg.expansionPadInset, 0.5f, half));
float gy = PadGroundY(target, c.y);
float plateDrop = 0f; var frame = p.transform.Find("Frame"); if (frame != null) plateDrop = -frame.localPosition.y; // 판 자식들이 루트보다 아래(0.49)
p.transform.position = new Vector3(target.x, gy + plateDrop + cfg.expansionPadLift, target.z);
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)
float ps = cfg.expansionPadScale > 0.05f ? cfg.expansionPadScale : 1f;
p.transform.localScale = Vector3.one * ps;
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」).
// 판 크기(expansionPadTriggerSize · 월드 m)만큼의 상자 하나로 줄인다 = 판을 밟아야 산다(우리 발판과 같은 규칙). 로컬 치수는 배율로 역보정.
if (cfg.expansionPadTriggerSize > 0.1f)
{
var boxes = p.GetComponents<BoxCollider>();
float ts = cfg.expansionPadTriggerSize / ps;
for (int b = 0; b < boxes.Length; b++)
{
if (boxes[b] == null) continue;
boxes[b].size = new Vector3(ts, 4f / ps, ts);
boxes[b].center = new Vector3(0f, -plateDropLocal + 2f / ps, 0f); // 판 높이에서 위로 4 m(월드)
}
}
if (cfg.expansionPadOverlay != 0) ApplyOverlay(p.gameObject); // 풀·소품에 가려지지 않게 항상 위에
var mk = p.gameObject.AddComponent<WLPlacedPurchaser>(); mk.original = pos;
PurchasersPlaced++;
LastPlaceLog = "확장 가격판 " + pos.ToString("F0") + " → " + p.transform.position.ToString("F2") + "(이웃 " + c.ToString("F0") + " 가장자리 안쪽 " + cfg.expansionPadInset.ToString("F1") + " m · 지면 " + gy.ToString("F2") + ")";
@ -510,6 +527,46 @@ namespace WL.Island
}
}
// ─────────────────────────────────────────────────────────────────
// 🔴 2026-09-17 PD 「가격판이 풀·오브젝트에 가려지지 않게 상단에 보이게」 — 스프라이트(Frame·Coin)는 깊이 무시 셰이더 `WL/SpriteOverlay`,
// 가격 글자(TMP)는 TMP 의 Overlay 변형 셰이더로 바꾼 **런타임 복사본** 재질을 물린다(에셋 0줄 · 큐 3100 = 물·거품 뒤).
// ─────────────────────────────────────────────────────────────────
static Material s_spriteOverlay;
static readonly Dictionary<Material, Material> s_tmpOverlay = new Dictionary<Material, Material>();
public static int OverlayApplied;
public static void ApplyOverlay(GameObject root)
{
if (root == null) return;
var srs = root.GetComponentsInChildren<SpriteRenderer>(true);
if (srs.Length > 0)
{
if (s_spriteOverlay == null)
{
var sh = Shader.Find("WL/SpriteOverlay");
if (sh != null) s_spriteOverlay = new Material(sh) { name = "WL_SpriteOverlay(runtime)", hideFlags = HideFlags.DontSave };
}
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++; }
}
var texts = root.GetComponentsInChildren<TMPro.TMP_Text>(true);
for (int i = 0; i < texts.Length; i++)
{
var t = texts[i]; if (t == null) continue;
var src = t.fontSharedMaterial; if (src == null || src.shader == null) continue;
if (src.shader.name.IndexOf("Overlay", System.StringComparison.OrdinalIgnoreCase) >= 0) continue;
Material dst;
if (!s_tmpOverlay.TryGetValue(src, out dst) || dst == null)
{
var sh = Shader.Find("TextMeshPro/Mobile/Distance Field Overlay");
if (sh == null) return;
dst = new Material(src) { name = src.name + "(WL overlay)", hideFlags = HideFlags.DontSave, shader = sh };
s_tmpOverlay[src] = dst;
}
t.fontSharedMaterial = dst; OverlayApplied++;
}
}
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);

View File

@ -554,6 +554,7 @@ namespace WL.Island
WLIslandTileFenceStrip.Apply(cfg, scene); // 확장·재구성으로 타일 메시가 바뀌면 다시 잘라낸다(멱등)
if (cfg.hideFarms != 0) { Vector3 _c; HideFarms(cfg, scene, out _c); } // 늦게 생긴 밭의 울타리·소품도
WLIslandExpansionGate.PlacePurchasers(cfg, scene); // 2026-09-16 — 확장 뒤 새로 생긴 FI 가격판도 이웃 칸 가장자리로(표식 있으면 비용 0)
WLIslandPrompt.ReconcileEventSystems(); // 2026-09-17 — 원본 EventSystem 이 올라오면 우리 것을 지운다(경고 홍수 차단)
}
}
@ -796,6 +797,7 @@ namespace WL.Island
float ps = cfg.platformScale > 0.001f ? cfg.platformScale : 1f;
AppliedScale = ps;
if (ps != 1f) p.transform.localScale = p.transform.localScale * ps;
if (cfg.expansionPadOverlay != 0) WLIslandExpansionGate.ApplyOverlay(p.gameObject); // 2026-09-17 PD — 우리 발판도 풀·소품 위에 보이게(같은 규칙)
// 🔴 FI 가격판은 프리팹 안에서 **루트보다 앞·아래**에 놓여 있다(실측 2026-09-13:
// Frame/Price/Coin 이 전부 y = -0.49 · z = 루트+(-1/0/+1) — 잠긴 섬 자리의 **물 위**에 눕는 판이다).

View File

@ -570,6 +570,15 @@ namespace WL.Island
[Tooltip("판을 지면보다 이만큼(m) 띄운다(z-fighting 방지).")]
public float expansionPadLift = 0.06f;
[Tooltip("옮긴 가격판의 트리거 한 변(m). 원본 십자(±5 m)는 가장자리로 옮긴 판끼리 겹쳐 두 구매가 동시에 시작돼 원본 활성화 경쟁 오류가 났다(2026-09-17). 0 = 원본 트리거 그대로.")]
public float expansionPadTriggerSize = 3.4f;
[Tooltip("옮긴 확장 가격판의 크기 배율 — 2026-09-17 PD 「캠프파이어 발판과 같은 크기」 = platformScale 과 같은 0.5.")]
public float expansionPadScale = 0.5f;
[Tooltip("1 = 가격판(프레임·코인 스프라이트·가격 글자)을 깊이 무시 셰이더로 그려 풀·소품에 가려지지 않게 한다(2026-09-17 PD). 0 = 원본 재질.")]
public int expansionPadOverlay = 1;
[Tooltip("한 줄 = 한 단계. 위에서부터 차례로 발판이 선다.")]
public WLFlowStepDef[] flowSteps = new WLFlowStepDef[]
{

View File

@ -157,8 +157,26 @@ namespace WL.Island
if (s_root != null) s_root.SetActive(false);
}
static GameObject s_ownEventSystem;
public static int EventSystemsRemoved;
/// <summary>
/// 🔴 2026-09-17 실측: 우리가 「EventSystem 없음」일 때 만든 `~WL_EventSystem` 이 나중에 올라온 원본 씬의 EventSystem 과 겹쳐
/// 매 프레임 「There are 2 event systems」 경고가 콘솔을 가득 채웠다(에러 원문이 밀려남). 원본 것이 있으면 우리 것을 지운다.
/// </summary>
public static void ReconcileEventSystems()
{
if (s_ownEventSystem == null) return;
var all = Object.FindObjectsByType<EventSystem>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
if (all.Length <= 1) return;
Object.Destroy(s_ownEventSystem);
s_ownEventSystem = null;
EventSystemsRemoved++;
}
static void Ensure(WLIslandSettings cfg)
{
ReconcileEventSystems();
if (s_root != null) return;
s_root = new GameObject("~WL_IslandPrompt");
@ -179,6 +197,7 @@ namespace WL.Island
Object.DontDestroyOnLoad(es);
es.AddComponent<EventSystem>();
es.AddComponent<StandaloneInputModule>();
s_ownEventSystem = es;
}
// 버튼

View File

@ -754,17 +754,20 @@ namespace WL.Look.Farm
// 새 칸 둘레만 이전 높이 → 새 높이로 `expandRiseSeconds` 동안 보간(SetHeightsDelayLOD · 부분 사각형). 풀 인스턴서·거품은 다 솟은 뒤.
// FI 의 OnActivated(1 s 뒤) → 우리 훅의 재굽기는 내용이 같아 생략된다(§6 비교).
// ─────────────────────────────────────────────────────────────────
public static int Rises, RiseFrames;
public static string LastRiseLog = "";
public static int Rises, RiseFrames, Reactivated, Recovered, EarlyUnlocked;
public static string LastRiseLog = "", LastGuardLog = "";
readonly List<FIIsland> _rising = new List<FIIsland>(4);
readonly List<FIIsland> _activating = new List<FIIsland>(4); // 활성화 애니 중인 칸(다리 포함) — 경쟁으로 꺼지면 다시 켠다
FIIsland[] _watch; float _watchNext;
bool _deferInstancer, _riseCancel;
Coroutine _riseCo;
static System.Reflection.MethodInfo s_initProps;
void Update()
{
if (cfg == null || cfg.enabled_ == 0 || cfg.demoTerrainEnabled == 0 || cfg.expandHideOriginal == 0) return;
if (_watch == null || Time.unscaledTime >= _watchNext)
if (cfg == null || cfg.enabled_ == 0 || cfg.demoTerrainEnabled == 0) return;
bool rescan = _watch == null || Time.unscaledTime >= _watchNext;
if (rescan)
{
_watch = Object.FindObjectsByType<FIIsland>(FindObjectsInactive.Include, FindObjectsSortMode.None);
_watchNext = Time.unscaledTime + 5f;
@ -772,16 +775,52 @@ namespace WL.Look.Farm
for (int i = 0; i < _watch.Length; i++)
{
var isl = _watch[i];
if (isl == null || isl.IsUnlocked || isl.IsBridge || !isl.gameObject.activeInHierarchy) continue;
if (isl.transform.localScale.x >= 0.999f) continue; // 잠긴 채 아직 비활성화 전(로드 직후)은 scale 1 → 해당 없음
if (_rising.Contains(isl)) continue;
if (isl == null) continue;
var mf = isl.GetComponent<MeshFilter>(); if (mf == null || mf.sharedMesh == null) continue; // 더미 제외
if (gameObject.scene.IsValid() && isl.gameObject.scene != gameObject.scene) continue;
// 🔴 2026-09-17 PD 화면 실측 「Coroutine couldn't be started because the game object 'Bridge (0)' is inactive!」
// = 원본 경쟁: 칸 A 의 해금 갱신(OnIslandUnlocked → 잠긴 칸 전부 RefreshIsland → SetActive(false))이
// 활성화 애니(1 s) 중인 칸 B 를 「잠김」으로 보고 꺼 버림 → B 의 애니 끝에 IsUnlocked·InitializeProps 코루틴이 꺼진 오브젝트에서 실패
// → B 는 해금인데 비활성(안 보이고 못 걷는 다리). 가격판을 가장자리로 옮긴 뒤 두 판을 잇달아 밟기 쉬워져 드러났다.
// ① 이미 깨진 칸(해금인데 비활성) = 5 s 스캔 때 복구 ② 활성화 중인 칸을 기억해 두었다가 꺼지면 그 프레임에 다시 켠다
if (rescan && isl.IsUnlocked && !isl.gameObject.activeSelf && isl.transform.localScale.x >= 0.999f) { Recover(isl, "스캔"); continue; }
if (_activating.Contains(isl) || isl.IsUnlocked) continue;
if (!isl.gameObject.activeInHierarchy || isl.transform.localScale.x >= 0.999f) continue; // 잠긴 채 아직 비활성화 전(로드 직후)은 scale 1 → 해당 없음
_activating.Add(isl);
// ③ 🔴 해금 표식을 **애니 첫 프레임에 미리** 세운다(원본은 1 s 뒤 OnComplete 에서 세운다) → 그 사이 다른 칸의 해금 갱신이 이 칸을
// 「잠김」으로 보고 끄는 일이 아예 없다(같은 프레임에 두 칸이 끝나는 경우도 방어). 원본이 1 s 뒤 같은 값을 다시 쓰고 OnActivated 를 낸다.
isl.IsUnlocked = true; EarlyUnlocked++;
if (cfg.expandHideOriginal == 0 || isl.IsBridge || _rising.Contains(isl)) continue;
StartRise(isl);
}
for (int i = _activating.Count - 1; i >= 0; i--)
{
var a = _activating[i];
if (a == null) { _activating.RemoveAt(i); continue; }
bool done = a.transform.localScale.x >= 0.999f; // 원본 DOScale 완료
if (!a.gameObject.activeSelf) { a.gameObject.SetActive(true); Reactivated++; LastGuardLog = "활성화 중 꺼진 칸 다시 켬 — " + a.name + "@" + a.transform.position.ToString("F0"); cfg.Log(LastGuardLog); }
if (done) _activating.RemoveAt(i);
}
for (int i = _rising.Count - 1; i >= 0; i--) if (_rising[i] == null || _rising[i].IsUnlocked) _rising.RemoveAt(i);
}
/// <summary>해금인데 비활성으로 남은 칸을 살린다 — 켜고 원본 `InitializeProps(true)`(private · 리플렉션)를 돌려 소품·낚시터를 초기화한다.</summary>
void Recover(FIIsland isl, string why)
{
isl.gameObject.SetActive(true);
isl.transform.localScale = Vector3.one;
if (s_initProps == null) s_initProps = typeof(FIIsland).GetMethod("InitializeProps", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
if (s_initProps != null)
{
var co = s_initProps.Invoke(isl, new object[] { true }) as IEnumerator;
if (co != null) isl.StartCoroutine(co);
}
Recovered++;
LastGuardLog = "해금·비활성 칸 복구(" + why + ") — " + isl.name + "@" + isl.transform.position.ToString("F0") + (isl.IsBridge ? " 다리" : "");
cfg.Log(LastGuardLog);
_dirty = true; Rebuild();
}
void StartRise(FIIsland isl)
{
_rising.Add(isl);