clawmate 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,607 @@
1
+ /**
2
+ * 펫 동작 품질 실시간 계측기 (Self-Observation System)
3
+ *
4
+ * OpenClaw이 자신의 동작 품질을 관찰하고 계량할 수 있도록
5
+ * 렌더러 측에서 다양한 메트릭을 수집하여 main process로 전송한다.
6
+ *
7
+ * 수집 메트릭:
8
+ * - frameRate: 실제 FPS (requestAnimationFrame 기반)
9
+ * - stateTransitions: 상태 전환 횟수/패턴 (최근 60초)
10
+ * - movementSmoothness: 이동 부드러움 (연속 위치 변화의 분산)
11
+ * - wallContactAccuracy: 벽면 밀착 정확도 (edge offset 효과)
12
+ * - interactionResponseTime: 클릭 → 반응까지 시간
13
+ * - animationFrameConsistency: 프레임 전환 일관성
14
+ * - idleRatio: 전체 시간 중 idle 비율
15
+ * - explorationCoverage: 화면 탐험 커버리지 (방문한 영역 비율)
16
+ * - speechFrequency: 말풍선 빈도
17
+ * - userEngagement: 사용자 상호작용 빈도
18
+ *
19
+ * 성능 주의:
20
+ * - requestAnimationFrame 루프에 직접 개입하지 않음
21
+ * - 가벼운 샘플링 방식 (매 프레임 수집 X, 주기적 폴링 O)
22
+ * - 30초마다 요약 전송
23
+ */
24
+ const Metrics = (() => {
25
+ // --- 설정 상수 ---
26
+ const REPORT_INTERVAL = 30000; // 메트릭 보고 주기 (30초)
27
+ const SAMPLE_INTERVAL = 200; // 위치 샘플링 주기 (200ms)
28
+ const FPS_SAMPLE_INTERVAL = 1000; // FPS 측정 주기 (1초)
29
+ const GRID_SIZE = 8; // 탐험 커버리지 그리드 (8x8 = 64칸)
30
+ const TRANSITION_WINDOW = 60000; // 상태 전환 기록 유지 시간 (60초)
31
+
32
+ // --- 내부 상태 ---
33
+ let initialized = false;
34
+ let reportTimer = null;
35
+ let sampleTimer = null;
36
+ let fpsRafId = null;
37
+
38
+ // FPS 측정
39
+ let fpsFrameCount = 0;
40
+ let fpsLastTime = 0;
41
+ let currentFps = 60;
42
+ let fpsHistory = []; // 최근 30초간 FPS 기록
43
+
44
+ // 상태 전환 추적
45
+ let stateTransitions = []; // [{ from, to, timestamp }]
46
+ let stateTimeAccum = {}; // { 'idle': totalMs, 'walking': totalMs, ... }
47
+ let lastStateChangeTime = 0;
48
+ let lastObservedState = null;
49
+
50
+ // 이동 부드러움 측정
51
+ let positionSamples = []; // [{ x, y, timestamp }]
52
+
53
+ // 벽면 밀착 정확도
54
+ let wallContactSamples = 0; // 벽면 접촉 중 샘플 수
55
+ let wallContactAccurateSamples = 0; // 정확한 밀착 샘플 수
56
+
57
+ // 상호작용 응답 시간
58
+ let lastClickTime = 0; // 마지막 클릭 시각
59
+ let interactionResponseTimes = []; // [ms] 응답 시간 기록
60
+
61
+ // 프레임 전환 일관성
62
+ let animFrameTimestamps = []; // 애니메이션 프레임 전환 시각 기록
63
+
64
+ // 탐험 커버리지 (8x8 그리드)
65
+ let visitedGrid = new Set(); // 방문한 그리드 셀 (문자열 키)
66
+ let screenW = 0;
67
+ let screenH = 0;
68
+
69
+ // 말풍선 빈도
70
+ let speechCount = 0;
71
+
72
+ // 사용자 상호작용 빈도
73
+ let userClickCount = 0;
74
+
75
+ // 보고 기간 시작 시각
76
+ let periodStartTime = 0;
77
+
78
+ // ===================================
79
+ // 초기화
80
+ // ===================================
81
+
82
+ /**
83
+ * 메트릭 시스템 초기화
84
+ * 기존 엔진/FSM에 간섭하지 않고 외부에서 관찰만 수행
85
+ */
86
+ function init() {
87
+ if (initialized) return;
88
+ initialized = true;
89
+
90
+ screenW = window.innerWidth;
91
+ screenH = window.innerHeight;
92
+ periodStartTime = Date.now();
93
+ lastStateChangeTime = Date.now();
94
+
95
+ window.addEventListener('resize', () => {
96
+ screenW = window.innerWidth;
97
+ screenH = window.innerHeight;
98
+ });
99
+
100
+ // FPS 측정 루프 (별도 rAF — 기존 엔진 루프에 무개입)
101
+ _startFpsMeasurement();
102
+
103
+ // 주기적 위치/상태 샘플링 (200ms 간격)
104
+ sampleTimer = setInterval(_sampleState, SAMPLE_INTERVAL);
105
+
106
+ // 30초마다 요약 보고
107
+ reportTimer = setInterval(_reportSummary, REPORT_INTERVAL);
108
+
109
+ // StateMachine 상태 변화 감시 (기존 콜백 체인 비파괴적 래핑)
110
+ _hookStateChanges();
111
+
112
+ // 사용자 클릭 이벤트 감시
113
+ _hookUserInteractions();
114
+
115
+ // 말풍선 이벤트 감시
116
+ _hookSpeechEvents();
117
+
118
+ console.log('[Metrics] 자기 관찰 시스템 초기화 완료');
119
+ }
120
+
121
+ // ===================================
122
+ // FPS 측정
123
+ // ===================================
124
+
125
+ /**
126
+ * 별도의 rAF 루프로 실제 프레임레이트를 측정
127
+ * PetEngine의 rAF 루프와 독립적으로 동작
128
+ */
129
+ function _startFpsMeasurement() {
130
+ fpsFrameCount = 0;
131
+ fpsLastTime = performance.now();
132
+
133
+ function fpsLoop(timestamp) {
134
+ fpsFrameCount++;
135
+
136
+ // 1초마다 FPS 계산
137
+ const elapsed = timestamp - fpsLastTime;
138
+ if (elapsed >= FPS_SAMPLE_INTERVAL) {
139
+ currentFps = Math.round((fpsFrameCount / elapsed) * 1000 * 10) / 10;
140
+ fpsHistory.push(currentFps);
141
+
142
+ // 최근 30개 (30초) 유지
143
+ if (fpsHistory.length > 30) fpsHistory.shift();
144
+
145
+ fpsFrameCount = 0;
146
+ fpsLastTime = timestamp;
147
+ }
148
+
149
+ fpsRafId = requestAnimationFrame(fpsLoop);
150
+ }
151
+
152
+ fpsRafId = requestAnimationFrame(fpsLoop);
153
+ }
154
+
155
+ // ===================================
156
+ // 주기적 상태 샘플링
157
+ // ===================================
158
+
159
+ /**
160
+ * 200ms마다 펫의 위치/상태를 샘플링
161
+ * PetEngine과 StateMachine에서 읽기 전용으로 데이터를 가져옴
162
+ */
163
+ function _sampleState() {
164
+ const now = Date.now();
165
+
166
+ // 위치 샘플링 (이동 부드러움 계산용)
167
+ if (typeof PetEngine !== 'undefined') {
168
+ const pos = PetEngine.getPosition();
169
+ positionSamples.push({ x: pos.x, y: pos.y, timestamp: now });
170
+
171
+ // 최근 30초분만 유지 (150개)
172
+ if (positionSamples.length > 150) positionSamples.shift();
173
+
174
+ // 탐험 커버리지: 현재 위치를 그리드에 기록
175
+ if (screenW > 0 && screenH > 0) {
176
+ const gridX = Math.floor((pos.x / screenW) * GRID_SIZE);
177
+ const gridY = Math.floor((pos.y / screenH) * GRID_SIZE);
178
+ const clampedX = Math.max(0, Math.min(GRID_SIZE - 1, gridX));
179
+ const clampedY = Math.max(0, Math.min(GRID_SIZE - 1, gridY));
180
+ visitedGrid.add(`${clampedX},${clampedY}`);
181
+ }
182
+
183
+ // 벽면 밀착 정확도 샘플링
184
+ if (pos.onSurface && pos.movementMode === 'crawling') {
185
+ wallContactSamples++;
186
+ // 가장자리에 정확히 밀착해 있는지 확인 (CHAR_SIZE = 64 기준)
187
+ const charSize = PetEngine.CHAR_SIZE || 64;
188
+ let isAccurate = false;
189
+
190
+ switch (pos.edge) {
191
+ case 'bottom':
192
+ isAccurate = pos.y >= (screenH - charSize - 6); // EDGE_OFFSET(4) + 2 허용오차
193
+ break;
194
+ case 'top':
195
+ isAccurate = pos.y <= 6;
196
+ break;
197
+ case 'left':
198
+ isAccurate = pos.x <= 6;
199
+ break;
200
+ case 'right':
201
+ isAccurate = pos.x >= (screenW - charSize - 6);
202
+ break;
203
+ case 'surface':
204
+ isAccurate = true; // 표면 위는 항상 정확
205
+ break;
206
+ }
207
+ if (isAccurate) wallContactAccurateSamples++;
208
+ }
209
+ }
210
+
211
+ // 상태별 누적 시간 갱신
212
+ if (typeof StateMachine !== 'undefined') {
213
+ const state = StateMachine.getState();
214
+ if (state !== lastObservedState) {
215
+ // 이전 상태의 시간 누적
216
+ if (lastObservedState) {
217
+ const duration = now - lastStateChangeTime;
218
+ stateTimeAccum[lastObservedState] = (stateTimeAccum[lastObservedState] || 0) + duration;
219
+ }
220
+ lastObservedState = state;
221
+ lastStateChangeTime = now;
222
+ }
223
+ }
224
+ }
225
+
226
+ // ===================================
227
+ // 이벤트 훅 (비파괴적)
228
+ // ===================================
229
+
230
+ /**
231
+ * StateMachine 상태 전환 감시
232
+ * 기존 onStateChange 콜백을 래핑하여 메트릭도 수집
233
+ */
234
+ function _hookStateChanges() {
235
+ if (typeof StateMachine === 'undefined') return;
236
+
237
+ // 기존 콜백 보존
238
+ const originalCallback = StateMachine._metricsOriginalCallback;
239
+
240
+ StateMachine.setOnStateChange((prevState, newState) => {
241
+ // 메트릭 수집: 상태 전환 기록
242
+ const now = Date.now();
243
+ stateTransitions.push({ from: prevState, to: newState, timestamp: now });
244
+
245
+ // TRANSITION_WINDOW 이전 기록 제거
246
+ while (stateTransitions.length > 0 &&
247
+ stateTransitions[0].timestamp < now - TRANSITION_WINDOW) {
248
+ stateTransitions.shift();
249
+ }
250
+
251
+ // 이전 상태 시간 누적
252
+ if (prevState) {
253
+ const duration = now - lastStateChangeTime;
254
+ stateTimeAccum[prevState] = (stateTimeAccum[prevState] || 0) + duration;
255
+ }
256
+ lastObservedState = newState;
257
+ lastStateChangeTime = now;
258
+
259
+ // 기존 app.js 콜백 체인 실행
260
+ // (app.js에서 setOnStateChange를 먼저 호출했으므로,
261
+ // Metrics.init()가 그 뒤에 호출되면 기존 콜백이 덮어씌워짐.
262
+ // 따라서 app.js의 콜백 로직을 여기서 재현한다.)
263
+ _invokeOriginalStateChangeHandler(prevState, newState);
264
+ });
265
+ }
266
+
267
+ /**
268
+ * app.js에서 설정한 기존 상태 변화 콜백 로직 재현
269
+ * Metrics가 setOnStateChange를 덮어쓰므로, 원래 동작을 보존한다.
270
+ */
271
+ function _invokeOriginalStateChangeHandler(prevState, newState) {
272
+ // 수면 'z' 파티클
273
+ if (newState === 'sleeping') {
274
+ const pet = document.getElementById('pet-container');
275
+ if (pet) {
276
+ for (let i = 0; i < 3; i++) {
277
+ const z = document.createElement('div');
278
+ z.className = 'sleep-z';
279
+ z.textContent = 'z';
280
+ pet.appendChild(z);
281
+ }
282
+ }
283
+ }
284
+ if (prevState === 'sleeping') {
285
+ document.querySelectorAll('.sleep-z').forEach(el => el.remove());
286
+ }
287
+ if (newState === 'excited') {
288
+ if (typeof Interactions !== 'undefined') {
289
+ Interactions.spawnStarEffect();
290
+ }
291
+ }
292
+
293
+ // OpenClaw에 상태 변화 리포트
294
+ if (window.clawmate && window.clawmate.reportToAI) {
295
+ window.clawmate.reportToAI('state_change', {
296
+ from: prevState, to: newState,
297
+ });
298
+ }
299
+ }
300
+
301
+ /**
302
+ * 사용자 상호작용 감시 (클릭 이벤트)
303
+ * 클릭 → 상태 변화까지의 응답 시간을 측정
304
+ */
305
+ function _hookUserInteractions() {
306
+ const petContainer = document.getElementById('pet-container');
307
+ if (!petContainer) return;
308
+
309
+ petContainer.addEventListener('mousedown', () => {
310
+ lastClickTime = Date.now();
311
+ userClickCount++;
312
+ });
313
+
314
+ // 클릭 후 상태 변화가 발생하면 응답 시간 기록
315
+ // (stateTransitions에 새 항목이 추가될 때 체크)
316
+ const origPush = Array.prototype.push;
317
+ const responseTimes = interactionResponseTimes;
318
+ const clickTimeRef = { get: () => lastClickTime };
319
+
320
+ // MutationObserver 방식 대신, _hookStateChanges 내부에서 처리
321
+ // 상태 전환 시점에 클릭으로부터의 시간을 계산
322
+ setInterval(() => {
323
+ if (lastClickTime > 0 && stateTransitions.length > 0) {
324
+ const lastTransition = stateTransitions[stateTransitions.length - 1];
325
+ if (lastTransition.timestamp > lastClickTime) {
326
+ const responseTime = lastTransition.timestamp - lastClickTime;
327
+ // 3초 이내의 응답만 유효 (그 이상은 클릭과 무관한 전환)
328
+ if (responseTime < 3000) {
329
+ interactionResponseTimes.push(responseTime);
330
+ if (interactionResponseTimes.length > 50) {
331
+ interactionResponseTimes.shift();
332
+ }
333
+ }
334
+ lastClickTime = 0; // 측정 완료, 초기화
335
+ }
336
+ }
337
+ }, 500);
338
+ }
339
+
340
+ /**
341
+ * 말풍선 이벤트 감시
342
+ * Speech 모듈의 show() 호출을 감지하여 카운트
343
+ */
344
+ function _hookSpeechEvents() {
345
+ if (typeof Speech === 'undefined') return;
346
+
347
+ // Speech.show를 래핑하여 호출 횟수 카운트
348
+ const originalShow = Speech.show;
349
+ if (typeof originalShow === 'function') {
350
+ Speech.show = function(...args) {
351
+ speechCount++;
352
+ return originalShow.apply(this, args);
353
+ };
354
+ }
355
+ }
356
+
357
+ // ===================================
358
+ // 메트릭 계산
359
+ // ===================================
360
+
361
+ /**
362
+ * 이동 부드러움 계산
363
+ * 연속 위치 변화의 분산이 작을수록 부드러움
364
+ *
365
+ * @returns {number} 0~1 (1이 가장 부드러움)
366
+ */
367
+ function _calcMovementSmoothness() {
368
+ if (positionSamples.length < 3) return 1.0;
369
+
370
+ // 연속 이동 벡터의 크기 변화 분산을 계산
371
+ const deltas = [];
372
+ for (let i = 1; i < positionSamples.length; i++) {
373
+ const dx = positionSamples[i].x - positionSamples[i - 1].x;
374
+ const dy = positionSamples[i].y - positionSamples[i - 1].y;
375
+ deltas.push(Math.sqrt(dx * dx + dy * dy));
376
+ }
377
+
378
+ if (deltas.length < 2) return 1.0;
379
+
380
+ // 연속 delta 간 차이의 분산 (가속도의 변화)
381
+ const accelChanges = [];
382
+ for (let i = 1; i < deltas.length; i++) {
383
+ accelChanges.push(Math.abs(deltas[i] - deltas[i - 1]));
384
+ }
385
+
386
+ const avgAccelChange = accelChanges.reduce((a, b) => a + b, 0) / accelChanges.length;
387
+
388
+ // 분산을 0~1 점수로 변환 (avgAccelChange가 클수록 덜 부드러움)
389
+ // 10px 이상의 급격한 변화 = 0점, 0 = 1점
390
+ const smoothness = Math.max(0, Math.min(1, 1 - (avgAccelChange / 10)));
391
+ return Math.round(smoothness * 100) / 100;
392
+ }
393
+
394
+ /**
395
+ * 프레임 전환 일관성 계산
396
+ * 애니메이션 프레임 간격의 일관성 (FPS 안정성)
397
+ *
398
+ * @returns {number} 0~1 (1이 가장 일관)
399
+ */
400
+ function _calcFrameConsistency() {
401
+ if (fpsHistory.length < 2) return 1.0;
402
+
403
+ const avg = fpsHistory.reduce((a, b) => a + b, 0) / fpsHistory.length;
404
+ if (avg === 0) return 0;
405
+
406
+ // FPS 표준편차 / 평균 (변동계수)
407
+ const variance = fpsHistory.reduce((sum, fps) => sum + Math.pow(fps - avg, 2), 0) / fpsHistory.length;
408
+ const stdDev = Math.sqrt(variance);
409
+ const cv = stdDev / avg; // 변동계수
410
+
411
+ // cv가 0이면 완벽한 일관성, 0.5 이상이면 매우 불안정
412
+ const consistency = Math.max(0, Math.min(1, 1 - cv * 2));
413
+ return Math.round(consistency * 100) / 100;
414
+ }
415
+
416
+ /**
417
+ * 상태 전환 카운트 집계
418
+ * 최근 60초 내 각 상태별 전환 횟수
419
+ *
420
+ * @returns {object} { idle: n, walking: n, ... }
421
+ */
422
+ function _calcStateTransitionCounts() {
423
+ const counts = {};
424
+ const now = Date.now();
425
+ for (const t of stateTransitions) {
426
+ if (now - t.timestamp <= TRANSITION_WINDOW) {
427
+ counts[t.to] = (counts[t.to] || 0) + 1;
428
+ }
429
+ }
430
+ return counts;
431
+ }
432
+
433
+ /**
434
+ * idle 비율 계산
435
+ * 보고 기간 내 idle 상태로 보낸 시간 비율
436
+ *
437
+ * @returns {number} 0~1
438
+ */
439
+ function _calcIdleRatio() {
440
+ const totalTime = Date.now() - periodStartTime;
441
+ if (totalTime <= 0) return 0;
442
+
443
+ const idleTime = stateTimeAccum['idle'] || 0;
444
+ const ratio = idleTime / totalTime;
445
+ return Math.round(Math.min(1, ratio) * 100) / 100;
446
+ }
447
+
448
+ /**
449
+ * 탐험 커버리지 계산
450
+ * 8x8 그리드 중 방문한 셀의 비율
451
+ *
452
+ * @returns {number} 0~1
453
+ */
454
+ function _calcExplorationCoverage() {
455
+ const totalCells = GRID_SIZE * GRID_SIZE;
456
+ const coverage = visitedGrid.size / totalCells;
457
+ return Math.round(coverage * 100) / 100;
458
+ }
459
+
460
+ /**
461
+ * 벽면 밀착 정확도 계산
462
+ *
463
+ * @returns {number} 0~1
464
+ */
465
+ function _calcWallContactAccuracy() {
466
+ if (wallContactSamples === 0) return 1.0;
467
+ const accuracy = wallContactAccurateSamples / wallContactSamples;
468
+ return Math.round(accuracy * 100) / 100;
469
+ }
470
+
471
+ /**
472
+ * 상호작용 평균 응답 시간 계산
473
+ *
474
+ * @returns {number} ms (응답 기록이 없으면 0)
475
+ */
476
+ function _calcAvgInteractionResponse() {
477
+ if (interactionResponseTimes.length === 0) return 0;
478
+ const avg = interactionResponseTimes.reduce((a, b) => a + b, 0) / interactionResponseTimes.length;
479
+ return Math.round(avg);
480
+ }
481
+
482
+ // ===================================
483
+ // 스냅샷 및 요약
484
+ // ===================================
485
+
486
+ /**
487
+ * 현재 시점의 메트릭 스냅샷 반환 (실시간)
488
+ * @returns {object}
489
+ */
490
+ function getSnapshot() {
491
+ return {
492
+ timestamp: Date.now(),
493
+ fps: currentFps,
494
+ stateTransitions: _calcStateTransitionCounts(),
495
+ movementSmoothness: _calcMovementSmoothness(),
496
+ wallContactAccuracy: _calcWallContactAccuracy(),
497
+ interactionResponseMs: _calcAvgInteractionResponse(),
498
+ animationFrameConsistency: _calcFrameConsistency(),
499
+ idleRatio: _calcIdleRatio(),
500
+ explorationCoverage: _calcExplorationCoverage(),
501
+ speechCount: speechCount,
502
+ userClicks: userClickCount,
503
+ };
504
+ }
505
+
506
+ /**
507
+ * 30초 기간 요약 생성 + 카운터 리셋
508
+ * @returns {object} 메트릭 요약 데이터
509
+ */
510
+ function getSummary() {
511
+ const now = Date.now();
512
+ const period = now - periodStartTime;
513
+
514
+ // 마지막 관찰 상태의 시간도 누적
515
+ if (lastObservedState) {
516
+ const duration = now - lastStateChangeTime;
517
+ stateTimeAccum[lastObservedState] = (stateTimeAccum[lastObservedState] || 0) + duration;
518
+ lastStateChangeTime = now;
519
+ }
520
+
521
+ // 평균 FPS 계산
522
+ const avgFps = fpsHistory.length > 0
523
+ ? Math.round((fpsHistory.reduce((a, b) => a + b, 0) / fpsHistory.length) * 10) / 10
524
+ : 60;
525
+
526
+ const summary = {
527
+ timestamp: now,
528
+ fps: avgFps,
529
+ stateTransitions: _calcStateTransitionCounts(),
530
+ movementSmoothness: _calcMovementSmoothness(),
531
+ wallContactAccuracy: _calcWallContactAccuracy(),
532
+ interactionResponseMs: _calcAvgInteractionResponse(),
533
+ animationFrameConsistency: _calcFrameConsistency(),
534
+ idleRatio: _calcIdleRatio(),
535
+ explorationCoverage: _calcExplorationCoverage(),
536
+ speechCount: speechCount,
537
+ userClicks: userClickCount,
538
+ period: period,
539
+ };
540
+
541
+ // 기간 카운터 리셋 (누적 데이터는 유지, 기간 카운터만 초기화)
542
+ periodStartTime = now;
543
+ speechCount = 0;
544
+ userClickCount = 0;
545
+ stateTimeAccum = {};
546
+ fpsHistory = [];
547
+ interactionResponseTimes = [];
548
+ // visitedGrid는 리셋하지 않음 (누적 탐험 기록)
549
+ // positionSamples는 자동으로 오래된 것 제거됨
550
+
551
+ return summary;
552
+ }
553
+
554
+ // ===================================
555
+ // 보고
556
+ // ===================================
557
+
558
+ /**
559
+ * 30초마다 메트릭 요약을 main process로 전송
560
+ */
561
+ function _reportSummary() {
562
+ const summary = getSummary();
563
+
564
+ // main process로 전송 (preload 브릿지 경유)
565
+ if (window.clawmate && typeof window.clawmate.reportMetrics === 'function') {
566
+ window.clawmate.reportMetrics(summary);
567
+ }
568
+
569
+ // 콘솔에도 간략히 출력 (디버그용)
570
+ console.log(
571
+ `[Metrics] FPS:${summary.fps} | ` +
572
+ `부드러움:${summary.movementSmoothness} | ` +
573
+ `idle:${(summary.idleRatio * 100).toFixed(0)}% | ` +
574
+ `탐험:${(summary.explorationCoverage * 100).toFixed(0)}% | ` +
575
+ `클릭:${summary.userClicks} | ` +
576
+ `말풍선:${summary.speechCount}`
577
+ );
578
+ }
579
+
580
+ /**
581
+ * 탐험 커버리지 그리드 리셋
582
+ * 외부에서 새 탐험 세션을 시작할 때 사용
583
+ */
584
+ function resetExplorationGrid() {
585
+ visitedGrid.clear();
586
+ }
587
+
588
+ /**
589
+ * 시스템 정리
590
+ */
591
+ function destroy() {
592
+ if (reportTimer) clearInterval(reportTimer);
593
+ if (sampleTimer) clearInterval(sampleTimer);
594
+ if (fpsRafId) cancelAnimationFrame(fpsRafId);
595
+ initialized = false;
596
+ console.log('[Metrics] 자기 관찰 시스템 종료');
597
+ }
598
+
599
+ // --- 공개 API ---
600
+ return {
601
+ init,
602
+ getSnapshot,
603
+ getSummary,
604
+ resetExplorationGrid,
605
+ destroy,
606
+ };
607
+ })();