-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathComponent.cs
More file actions
383 lines (334 loc) · 21 KB
/
Copy pathComponent.cs
File metadata and controls
383 lines (334 loc) · 21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
namespace CreatorEngine;
/// <summary>
/// 컴포넌트. 사용자 스크립트가 이것을 상속한다.
///
/// ── 이름이 왜 Component인가 ──
///
/// 네이티브 <c>Component</c>(Component.h)와 같은 층이기 때문이다. 그쪽에서 Transform도
/// MeshRenderer도 스크립트를 담는 ScriptComponent도 전부 Component를 상속하고,
/// 6단계 생명주기를 갖는 것도 그 층이다. 관리 측이 같은 이름·같은 축을 쓰면 두 계층을
/// 대조할 때 옮겨 적을 것이 없다.
///
/// 예전 이름은 <c>Behaviour</c>였다. Unity 관례를 따른 것으로, 옛 C++ 스크립트 348개를
/// 구조 변경 없이 옮기려던 계획(설계 문서 02절)의 산물이다. 그 스크립트들은 포팅되지
/// 않고 폐기됐고, 남은 것은 엔진 구조와 어긋나는 이름뿐이라 버렸다.
///
/// 라이프사이클은 네이티브가 스크립트마다 부르지 않는다. 틱마다 한 번만 경계를 넘고,
/// 순회는 <see cref="ScriptRegistry"/>가 관리 영역에서 수행한다.
/// </summary>
public abstract class Component
{
/// <summary>이 컴포넌트가 붙어 있는 오브젝트.</summary>
public Entity Entity { get; internal set; }
private Transform? _transform;
/// <summary>
/// 자기 오브젝트의 Transform.
///
/// ── 없는 오브젝트에서 부르면 예외다 ──
///
/// UI/Canvas는 Transform을 갖지 않는다(S3). 예전에는 이 자리가 값 뷰라 그 경우에도
/// 멀쩡한 객체를 돌려줬고, 거기에 값을 쓰면 네이티브가 공유 더미로 받아 로그 한 줄만
/// 남기고 버렸다 — 스크립트에서는 성공과 구분되지 않았다. 예외는 그 조용한 소실을
/// 소란스러운 실패로 바꾼다.
///
/// 없을 수 있는 자리를 다루려면 <see cref="HasTransform"/>으로 먼저 묻거나
/// <c>Entity.GetComponent<Transform>()</c>을 쓴다(그쪽은 null을 돌려준다).
///
/// 생성 시 한 번 잡아 둔다 — 매번 조회하면 실측 1위인 이 경로에 부재 판정
/// P/Invoke가 접근마다 하나씩 붙는다. 오브젝트가 Transform을 갖는지는 생성 시점에
/// 정해지므로 캐시가 낡을 여지는 없다.
/// </summary>
public Transform Transform => _transform ?? throw new InvalidOperationException(
$"'{Entity.Name}'에는 Transform이 없다(UI/Canvas는 RectTransformComponent만 갖는다). " +
$"{GetType().Name}이 공간을 다루려면 HasTransform으로 먼저 확인하라.");
/// <summary>Transform을 갖는가. UI/Canvas는 갖지 않는다(S3).</summary>
public bool HasTransform => _transform is not null;
/// <summary>팩토리가 생성 직후 한 번 채운다. 없으면 null을 넣는다.</summary>
internal void BindTransform(Transform? transform) => _transform = transform;
/// <summary>
/// 이 인스턴스의 id. 네이티브 ScriptComponent가 들고 있는 값과 같고,
/// <see cref="ScriptFactory"/>가 생성 직후 채운다. 0이면 아직 등록 전이다.
/// </summary>
internal int InstanceId { get; set; }
private bool _enabled = true;
/// <summary>
/// 꺼진 스크립트는 순회에서 건너뛴다.
///
/// ── 정본은 네이티브다 ──
///
/// 예전에는 이 setter가 관리 측 필드를 직접 뒤집고 훅까지 스스로 불렀다.
/// 그래서 활성 상태의 정본이 둘이 되어 갈라졌다 — 인스펙터 체크박스와
/// <c>Entity::SetEnabled</c>는 네이티브 쪽만 바꾸고, 이 setter는 관리 쪽만
/// 바꿨다. 그 결과 오브젝트를 꺼도 스크립트는 계속 틱을 받았다(틱 게이트가
/// 보는 것이 이 필드였다).
///
/// 지금은 요청만 보낸다. 전이 판정과 훅 발화는 네이티브
/// <c>Component::SetEnabled</c>가 하고, 그 결과가
/// <see cref="ScriptRegistry.DispatchEnabled"/>로 되돌아와 이 필드를 세운다 —
/// 6단계에서 드라이버를 네이티브 하나로 모은 것과 같은 이유다
/// (ScriptLifecyclePhase.h 상단).
/// </summary>
public bool Enabled
{
get => _enabled;
set
{
if (_enabled == value) return;
// 성공하면 훅과 필드 갱신이 되돌아오는 길에 일어난다.
if (Native.ScriptSetEnabled(Entity.Handle, InstanceId, value)) return;
// ★ 전달 실패에는 두 종류가 있다(LC5-c 후속 · 2026-09-05).
//
// 진입 검사가 도입되기 전에는 한 종류뿐이었다 — 표가 아직 없거나
// 대상을 못 찾은 경우. 지금은 **게임 스레드 밖이라 거부된 경우**가
// 더해졌고, 둘의 반환값이 같다(거부는 일부러 "표가 없을 때"의 길을
// 그대로 탄다).
//
// 그 둘을 여기서 갈라야 한다. 아래 폴백은 관리 상태를 바꾸고
// OnDisable 사용자 훅까지 그 자리에서 돌리는데, 워커에서 그것을 하면
// 방금 거부로 막은 일의 축소판을 관리 측에 다시 만드는 셈이다.
// 실측으로 확인했다 — `await ....ConfigureAwait(false)` 뒤의
// `Enabled = false`가 워커에서 OnDisable을 돌리고 있었다.
if (!Native.IsGameThread)
{
LogError($"[{GetType().Name}] Enabled={value} 를 게임 스레드 밖에서 바꾸려 했다 " +
$"— 아무것도 적용하지 않는다(instanceId={InstanceId}). " +
$"await 뒤의 재개는 프레임 경계로 돌아오지만 ConfigureAwait(false)는 그것을 " +
$"포기한다 — 그 뒤에서는 엔진 상태를 만지지 마라.");
return;
}
// 전달하지 못한 경우: 아직 등록 전(InstanceId 0)이거나, 오브젝트가
// 이미 사라졌거나, 표에 이 진입점이 없는 낡은 호스트다. 조용히
// 무시하면 '껐는데 계속 도는' 바로 그 증상으로 되돌아가므로,
// 관리 측에만 적용하고 소란스럽게 남긴다.
LogWarning($"[{GetType().Name}] Enabled={value} 를 네이티브에 전달하지 못했다 " +
$"— 관리 측에만 적용한다(instanceId={InstanceId}).");
ScriptRegistry.ApplyEnabled(this, value);
}
}
/// <summary>
/// 활성 필드만 세운다. 훅은 부르지 않는다 — 예외 격리가 필요해서
/// <see cref="ScriptRegistry"/>가 <c>Invoke</c>로 감싸 부른다.
/// </summary>
/// <returns>실제로 값이 바뀌었으면 true.</returns>
internal bool SetEnabledState(bool value)
{
if (_enabled == value) return false;
_enabled = value;
return true;
}
/// <summary>파괴 표시가 되었거나 대상 오브젝트가 사라졌으면 false.</summary>
public bool IsAlive => !_destroyed && Entity.IsAlive;
private bool _destroyed;
internal void MarkDestroyed() => _destroyed = true;
/// <summary>
/// 파괴 표시만 따로 본다. <see cref="IsAlive"/>는 소유자 생존까지 함께 보므로
/// '표시는 됐는가'와 '소유자가 사라졌는가'를 가를 수 없다.
///
/// 고아 청소(<see cref="ScriptRegistry.SweepOrphans"/>)가 그 구분을 필요로 한다 —
/// 정상 경로로 제거된 것은 Flush가 OnUninitializing(옛 OnDestroy)을 부를 예정이라
/// 건드리면 두 번 불린다.
/// </summary>
internal bool IsMarkedDestroyed => _destroyed;
/// <summary>
/// OnInitialized가 실제로 불렸는지. 만들어지자마자 초기화 전에 파괴되는 경우가 있어서
/// 둔다 — 재생을 시작하면 엔진이 에디터 씬 사본으로 갈아타면서 원본 쪽 인스턴스를
/// 접는데, 그때 아직 한 번도 초기화되지 않은 인스턴스가 생긴다.
///
/// "Initialized 없이 Uninitializing 없음"이 이 값이 **뜻하던** 계약이다(설계 문서
/// SceneGraphRedesignPlan §4 트랙 L) — OnInitialized를 받은 적 없는 인스턴스는
/// 나머지 다섯 훅도 받지 않는다. 짝이 맞지 않으면 스크립트가 초기화하지 않은 것을
/// 정리하려 든다.
///
/// 그 계약을 실제로 지키는 것은 이제 <see cref="InitializeSucceeded"/>다(LC1 ·
/// 2026-09-05). 이 값은 훅을 부르기 전에 서므로 **던진 초기화도 참**이 되어,
/// 위 문장이 막으려던 바로 그 상황을 통과시키고 있었다. 이 값의 일은
/// 재진입 방지 하나로 좁혔다.
/// </summary>
internal bool IsInitialized { get; private set; }
internal void MarkInitialized() => IsInitialized = true;
/// <summary>
/// 여는 훅이 <b>예외 없이</b> 끝났는지 (LC1 · 2026-09-05).
///
/// <see cref="IsInitialized"/>와 나누어 둔다. 그쪽은 <b>재진입 방지</b>가 일이라
/// 훅을 부르기 **전에** 서야 하고(그래야 두 번 초기화하지 않는다), 이쪽은
/// <b>짝 맞추기</b>가 일이라 훅이 돌아온 **뒤에** 선다. 하나로 합치면 둘 중
/// 하나가 반드시 틀린 시점에 서게 된다.
///
/// 위 <see cref="IsInitialized"/> 주석이 규정한 "짝이 맞지 않으면 스크립트가
/// 초기화하지 않은 것을 정리하려 든다"는 계약은 원래 이 축으로 재야 했다 —
/// **불렸는가**가 아니라 **성공했는가**다. 예외로 끝난 초기화는 아무것도
/// 잡지 못했을 수 있고, 그 상태로 도는 정리 코드가 새 예외를 만든다.
///
/// 셋을 따로 두는 이유는 짝이 셋이기 때문이다:
/// Initialized↔Uninitializing · AddedToScene↔RemovingFromScene ·
/// BeginSimulation↔EndSimulation.
/// </summary>
internal bool InitializeSucceeded { get; private set; }
internal void MarkInitializeSucceeded() => InitializeSucceeded = true;
/// <summary>OnAddedToScene이 예외 없이 끝났는지. 짝은 OnRemovingFromScene이다.</summary>
internal bool EnterSucceeded { get; private set; }
internal void MarkEnterSucceeded() => EnterSucceeded = true;
/// <summary>OnBeginSimulation이 예외 없이 끝났는지. 짝은 OnEndSimulation이다.</summary>
internal bool BeginSucceeded { get; private set; }
internal void MarkBeginSucceeded() => BeginSucceeded = true;
/// <summary>
/// 최초 씬 진입(OnAddedToScene)이 한 번 전달됐는지. 활성 축(OnEnable)을 그 뒤에
/// 딱 한 번만 이어 붙이기 위해 둔다 — DontDestroyOnLoad 이송의 재부착도 같은
/// 단계를 보내오므로, '최초인가'를 IsInitialized로는 가를 수 없다.
/// </summary>
internal bool EnterDelivered { get; private set; }
internal void MarkEnterDelivered() => EnterDelivered = true;
/// <summary>
/// 축소 삼단(OnEndSimulation → OnRemovingFromScene → OnUninitializing)이 이미
/// 네이티브 구동으로 전달됐는지 (설계 문서 §4 트랙 L · L3 완결).
///
/// 단계별이 아니라 **묶음 하나**로 두는 이유: OnRemovingFromScene은 DontDestroyOnLoad
/// 이송에서 **정상적으로 여러 번** 온다. 단계별 플래그로 막으면 두 번째 이송의
/// 통지가 사라진다. 축소는 인스턴스당 한 번뿐이므로 그 시작(OnEndSimulation)에서
/// 한 번 세우면 충분하다.
///
/// 이 값이 서면 관리 측 TearDown은 훅을 건너뛴다 — 고아 청소·어셈블리 리로드는
/// 구동할 네이티브 컴포넌트가 없어 그때만 TearDown이 직접 발화한다.
/// </summary>
internal bool TeardownDelivered { get; private set; }
internal void MarkTeardownDelivered() => TeardownDelivered = true;
// ── 씬 그래프 6단계 생명주기 (SceneGraphRedesignPlan §4 트랙 L) ──
//
// 기준점이 오브젝트가 아니라 컴포넌트다 — 옛 Awake는 "오브젝트가 태어남"이었지만
// OnInitialized는 "이 컴포넌트가 초기화됨"이다. 네이티브 Component.h와 같은 매핑을
// 쓴다. 디스패치는 ScriptRegistry가 한다.
//
// ★ L3 — 옛 Awake/Start/OnDestroy와 그 브리지를 걷어냈다.
// 전환기에는 기본 구현이 옛 훅을 불러 주었지만(`OnInitialized() => Awake()`),
// 살아있는 소비자를 전부 새 이름으로 옮겼으므로 다리를 치운다.
// 네이티브와 같은 회차에 같은 근거로 정리했다 — 자세한 것은 Component.h 주석.
public virtual void OnInitialized() { }
public virtual void OnAddedToScene() { }
public virtual void OnBeginSimulation() { }
public virtual void OnEndSimulation() { }
public virtual void OnRemovingFromScene() { }
public virtual void OnUninitializing() { }
// ── 활성/비활성 축 (6단계와 직교) ──
// 씬 페이즈와 무관하게 "지금 켜져 있는가"를 다룬다 — 대응물이 없어 남는다.
public virtual void OnEnable() { }
public virtual void OnDisable() { }
// ── 틱 축 ──
// ── 틱 축 (설계 문서 §4 트랙 L5) ──
//
// 옛 FixedUpdate/Update/LateUpdate 셋을 **물리 기준 두 지점**으로 대체했다.
// 프레임에서 무엇이 언제 도는지가 이름에 드러나야 한다는 것이 요지다 —
// "Update가 물리 앞인가 뒤인가"는 옛 이름으로는 알 수 없었고, 실제로는
// 셋 다 물리 뒤였다(EditorMain의 프레임 루프).
//
// 은퇴 시점 실측: FixedUpdate 오버라이드 0곳 · Update 17곳 · LateUpdate 1곳,
// 그리고 **Update와 LateUpdate를 함께 쓰는 파일 0개** — 둘을 PostPhysics 하나로
// 합쳐도 잃는 순서가 없다.
/// <summary>물리 스텝 **앞**. 이번 프레임의 물리에 영향을 주려면 여기서 한다.</summary>
public virtual void PrePhysics(float tick) { }
/// <summary>물리 스텝 **뒤**. 물리 결과를 보고 판단하는 대부분의 게임 로직 자리다.</summary>
public virtual void PostPhysics(float tick) { }
/// <summary>
/// 시뮬레이션 본문 (설계 문서 §4 트랙 L5). <see cref="OnBeginSimulation"/> 직후
/// 한 번 시작하고, 엔티티가 제거될 때 <see cref="Scope"/> 취소가 이것을 **먼저**
/// 끊는다(취소 → OnEndSimulation → OnRemovingFromScene).
///
/// AI 시퀀스·애니메이션 시퀀스·대기·비동기 상호작용처럼 **여러 프레임에 걸친
/// 흐름**을 상태 머신 대신 직선으로 쓰는 자리다:
/// <code>
/// public override async Task OnSimulate()
/// {
/// await Scope.Delay(1f);
/// Play("charge");
/// await Scope.Delay(0.5f);
/// Fire();
/// }
/// </code>
///
/// ── 반드시 지킬 것 ──
///
/// <b>await 대상은 <see cref="Scope"/>의 것이어야 한다.</b> Scope.Delay는
/// 엔진 dt로 흐르고 완료 콜백이 게임 스레드에서 <b>동기로</b> 재개된다
/// (SimulationScope.Tick이 TaskCompletionSource를 그 자리에서 완료시킨다).
/// Task.Delay나 임의의 라이브러리 Task를 await하면 재개가 스레드풀로 넘어가
/// 그 뒤 코드가 게임 스레드 밖에서 엔진 API를 만진다 — 관리 코드 호출 규약
/// 위반이고, 증상은 산발적 크래시다.
///
/// 취소는 협조적이다(.NET은 태스크를 강제 종료할 수 없다). Scope.Delay를
/// await하고 있으면 그 대기가 취소되며 OperationCanceledException으로 풀린다.
/// 긴 동기 루프를 도는 본문은 <see cref="SimulationScope.Token"/>을 스스로 봐야 한다.
/// </summary>
public virtual Task OnSimulate() => Task.CompletedTask;
/// <summary>
/// 이 컴포넌트의 시뮬레이션 스코프. OnBeginSimulation에서 시작한 태스크·이벤트
/// 구독은 여기 걸어 두면 OnEndSimulation 직전에 <see cref="ScriptRegistry"/>가
/// 일괄 취소한다(Verse spawn/suspends의 C# 대응물, 설계 문서 §4 트랙 L2).
/// </summary>
public SimulationScope Scope { get; } = new();
// 물리 콜백. 발생 시점에 바로 불리지 않고 틱 경계에서 일괄 전달된다 —
// 충돌마다 경계를 넘으면 "틱당 1회" 원칙이 무너지기 때문이다(설계 문서 02절).
// 따라서 이 안에서 본 상태는 물리 시뮬레이션 시점이 아니라 틱 경계의 상태다.
/// <summary>
/// 이름으로 부르는 콜백(애니메이션 키프레임 이벤트·입력 액션)의 진입점.
///
/// 구현은 ScriptGenerator가 만든다 — 스크립트의 public 무인자 void 메서드를
/// 이름으로 찾아 직접 호출하는 switch다. 리플렉션을 쓰지 않으므로 AOT에서도 동작한다.
/// 에셋에 저장된 메서드 이름 데이터가 그대로 유효한 것도 이 구조 덕분이다.
/// </summary>
/// <returns>해당 이름의 메서드를 찾아 불렀으면 true.</returns>
public virtual bool InvokeMessage(string message) => false;
public virtual void OnTriggerEnter(in Collision collision) { }
public virtual void OnTriggerStay(in Collision collision) { }
public virtual void OnTriggerExit(in Collision collision) { }
public virtual void OnCollisionEnter(in Collision collision) { }
public virtual void OnCollisionStay(in Collision collision) { }
public virtual void OnCollisionExit(in Collision collision) { }
// ── 컴포넌트 조회 ──
// 자기 오브젝트를 대상으로 하는 지름길. 실측에서 GetComponent와 GetOwner가
// 나란히 상위권(734·497회)인데, 대부분 "내 오브젝트의 다른 스크립트"를 찾는 형태다.
// 이 경로는 경계를 넘지 않는다.
public T? GetComponent<T>() where T : Component => Entity.GetComponent<T>();
public List<T> GetComponents<T>() where T : Component => Entity.GetComponents<T>();
public bool TryGetComponent<T>(out T component) where T : Component => Entity.TryGetComponent(out component);
public bool HasComponent<T>() where T : Component => Entity.HasComponent<T>();
public List<T> GetComponentsInChildren<T>(bool includeSelf = true) where T : Component
=> Entity.GetComponentsInChildren<T>(includeSelf);
public T? GetComponentInChildren<T>(bool includeSelf = true) where T : Component
=> Entity.GetComponentInChildren<T>(includeSelf);
public T? GetComponentInParent<T>(bool includeSelf = true) where T : Component
=> Entity.GetComponentInParent<T>(includeSelf);
// ── 편의 ──
/// <summary>엔진 프레임 번호. 라이프사이클 시점을 비교할 때 쓴다.</summary>
public static ulong FrameCount => Native.FrameCount;
protected static void Log(string message) => Native.Log(1, message);
protected static void LogWarning(string message) => Native.Log(2, message);
protected static void LogError(string message) => Native.Log(3, message);
// ── 노출 필드 접근자 ──
//
// 아래 메서드들은 소스 제너레이터가 [SerializeField] 필드를 보고 재정의한다.
// 직접 구현하지 말 것.
//
// 필드 오프셋을 넘기는 방식(설계 문서 04절 초안)이 아니라 인덱스 + 접근자로 간 이유:
// C#에서 관리 객체의 필드 오프셋은 런타임이 정하고 GC가 객체를 옮길 수도 있어,
// 네이티브가 주소로 직접 읽고 쓰는 것이 안전하지 않다. 생성된 switch는 AOT에서도
// 그대로 남고 비용도 인덱스 분기 한 번뿐이다.
//
// 인스펙터와 직렬화가 같은 접근자를 공유한다 — 별도의 __Serialize를 만들 필요가 없다.
public virtual int FieldCount => 0;
public virtual string GetFieldName(int index) => string.Empty;
public virtual FieldType GetFieldType(int index) => FieldType.Unknown;
public virtual float GetFloat(int index) => 0f;
public virtual void SetFloat(int index, float value) { }
public virtual int GetInt32(int index) => 0;
public virtual void SetInt32(int index, int value) { }
public virtual bool GetBool(int index) => false;
public virtual void SetBool(int index, bool value) { }
public virtual Float2 GetFloat2(int index) => default;
public virtual void SetFloat2(int index, Float2 value) { }
public virtual Float3 GetFloat3(int index) => default;
public virtual void SetFloat3(int index, Float3 value) { }
public virtual string GetString(int index) => string.Empty;
public virtual void SetString(int index, string value) { }
// 오브젝트 참조는 핸들로 주고받는다. 파일에 적을 때만 네이티브가 instanceID로 바꾼다.
public virtual Entity GetObject(int index) => default;
public virtual void SetObject(int index, Entity value) { }
}