clawmate 1.2.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.
@@ -12,12 +12,11 @@ const PetEngine = (() => {
12
12
  // --- 물리 상수 ---
13
13
  const GRAVITY = 0.3; // 중력 가속도 (px/frame^2)
14
14
  const STEP_SIZE = 4; // 한 걸음 크기 (px)
15
- const STEP_PAUSE = 80; // 걸음 사이 멈춤 시간 (ms)
16
15
  const JUMP_VX = 3; // 점프 수평 초기 속도
17
16
  const JUMP_VY = -7; // 점프 수직 초기 속도 (위로)
18
17
  const BOUNCE_FACTOR = 0.3; // 착지 바운스 계수
19
18
  const CHAR_SIZE = 64; // 캐릭터 크기 (px)
20
- const ANIM_INTERVAL = 200; // 애니메이션 프레임 전환 간격 (ms)
19
+ const ANIM_INTERVAL = 150; // 애니메이션 프레임 전환 간격 (ms) — 부드러운 전환
21
20
  const THREAD_SPEED = 0.8; // 레펠 하강 속도 (px/frame)
22
21
 
23
22
  // --- 위치 및 속도 ---
@@ -28,6 +27,10 @@ const PetEngine = (() => {
28
27
  let edge = 'bottom'; // 현재 부착된 가장자리 (bottom, left, right, top, surface)
29
28
  let direction = 1; // 이동 방향: 1=오른쪽/아래, -1=왼쪽/위
30
29
  let flipX = false; // 캐릭터 좌우 반전 여부
30
+ let prevFlipX = false; // 이전 프레임 flipX (전환 감지용)
31
+ let flipTransition = 0; // flipX 전환 진행도 (0~1, 1이면 완료)
32
+ const FLIP_DURATION = 120; // flipX 전환 시간 (ms)
33
+ let flipStartTime = 0; // flipX 전환 시작 시각
31
34
  let screenW, screenH;
32
35
 
33
36
  // --- 엔진 상태 ---
@@ -36,14 +39,13 @@ const PetEngine = (() => {
36
39
  let speedMultiplier = 1.0;
37
40
  let animFrame = 0;
38
41
  let lastAnimTime = 0;
42
+ let animFrameChanged = false; // 애니메이션 프레임 전환 플래그 (이동과 동기화)
39
43
 
40
44
  // --- 이동 모드 ---
41
45
  let movementMode = 'crawling'; // crawling | jumping | falling | rappelling
42
46
  let onSurface = true; // 표면 위에 있는지 여부
43
47
 
44
- // --- 스텝 시스템 (뚝뚝 끊어 걷기) ---
45
- let stepPhase = 'move'; // 'move' 또는 'pause'
46
- let lastStepTime = 0;
48
+ // --- 스텝 시스템 (애니메이션 프레임 동기화) ---
47
49
 
48
50
  // --- 레펠(thread) 시스템 ---
49
51
  // 부착점에서 실을 내려 진자운동하며 하강
@@ -114,6 +116,25 @@ const PetEngine = (() => {
114
116
  function updateVisual() {
115
117
  if (!petContainer) return;
116
118
 
119
+ // --- flipX 부드러운 전환 처리 ---
120
+ if (flipX !== prevFlipX) {
121
+ // 방향이 바뀌었으면 전환 시작
122
+ flipStartTime = Date.now();
123
+ flipTransition = 0;
124
+ prevFlipX = flipX;
125
+ }
126
+ if (flipTransition < 1) {
127
+ const elapsed = Date.now() - flipStartTime;
128
+ flipTransition = Math.min(1, elapsed / FLIP_DURATION);
129
+ }
130
+
131
+ // 전환 중일 때 CSS transition 적용
132
+ if (flipTransition < 1) {
133
+ petContainer.style.transition = `transform ${FLIP_DURATION}ms ease-in-out`;
134
+ } else {
135
+ petContainer.style.transition = 'none';
136
+ }
137
+
117
138
  let renderX = x;
118
139
  let renderY = y;
119
140
 
@@ -200,22 +221,17 @@ const PetEngine = (() => {
200
221
  // ===================================
201
222
 
202
223
  /**
203
- * 스텝 이동: STEP_SIZE만큼 이동 STEP_PAUSE만큼 멈춤 반복
204
- * 발자국씩 끊어서 기어가는 느낌을
224
+ * 스텝 이동: 애니메이션 프레임 전환 시에만 걸음 이동
225
+ * 다리 움직임(프레임 변화)과 실제 위치 이동이 1:1 동기화되어
226
+ * 다리가 움직일 때만 몸이 이동하는 자연스러운 보행 구현
205
227
  *
206
228
  * @param {number} stepScale - 스텝 크기 배율 (0.6 = 짐 들고 느리게, 1.0 = 기본)
207
- * @param {number} now - 현재 시각 (Date.now())
208
229
  */
209
- function stepMove(stepScale, now) {
210
- // 멈춤 단계: 아직 대기 시간이 남았으면 아무것도 안 함
211
- if (stepPhase === 'pause') {
212
- if (now - lastStepTime >= STEP_PAUSE) {
213
- stepPhase = 'move';
214
- }
215
- return;
216
- }
230
+ function stepMove(stepScale) {
231
+ // 애니메이션 프레임이 바뀌지 않았으면 이동 안 함
232
+ if (!animFrameChanged) return;
217
233
 
218
- // 이동 단계: 한 걸음 전진
234
+ // 한 걸음 전진
219
235
  const stepDist = STEP_SIZE * stepScale * speedMultiplier;
220
236
 
221
237
  if (edge === 'bottom' || edge === 'top' || edge === 'surface') {
@@ -229,10 +245,6 @@ const PetEngine = (() => {
229
245
  // 오른쪽 벽: y축 이동
230
246
  y += stepDist * direction;
231
247
  }
232
-
233
- // 한 걸음 완료, 멈춤 단계로 전환
234
- stepPhase = 'pause';
235
- lastStepTime = now;
236
248
  }
237
249
 
238
250
  // ===================================
@@ -287,8 +299,6 @@ const PetEngine = (() => {
287
299
  * @param {string} state - StateMachine의 현재 상태 (walking, idle 등)
288
300
  */
289
301
  function moveForState(state) {
290
- const now = Date.now();
291
-
292
302
  switch (movementMode) {
293
303
 
294
304
  // --- 포물선 점프 ---
@@ -445,7 +455,7 @@ const PetEngine = (() => {
445
455
  switch (state) {
446
456
  case 'walking':
447
457
  case 'ceiling_walk':
448
- stepMove(1.0, now);
458
+ stepMove(1.0);
449
459
 
450
460
  // 수평 이동 시 경계 처리
451
461
  if (edge === 'bottom' || edge === 'top') {
@@ -483,7 +493,7 @@ const PetEngine = (() => {
483
493
 
484
494
  // 벽에서 위로 기어오름: y 감소
485
495
  if (edge === 'left' || edge === 'right') {
486
- stepMove(0.7, now);
496
+ stepMove(0.7);
487
497
  // stepMove가 direction(-1)을 적용하므로 y가 감소함
488
498
  }
489
499
 
@@ -501,7 +511,7 @@ const PetEngine = (() => {
501
511
  // direction을 1(아래)로 설정해서 stepMove
502
512
  const prevDir = direction;
503
513
  direction = 1;
504
- stepMove(0.7, now);
514
+ stepMove(0.7);
505
515
  direction = prevDir;
506
516
  } else if (edge === 'top') {
507
517
  // 천장에서 벽으로 내려가기 시작
@@ -535,7 +545,7 @@ const PetEngine = (() => {
535
545
 
536
546
  case 'carrying':
537
547
  // 짐 들고 느리게 이동
538
- stepMove(0.6, now);
548
+ stepMove(0.6);
539
549
  if (edge === 'bottom' || edge === 'top' || edge === 'surface') {
540
550
  if (x <= 0) { x = 0; direction = 1; }
541
551
  if (x >= screenW - CHAR_SIZE) { x = screenW - CHAR_SIZE; direction = -1; }
@@ -579,6 +589,14 @@ const PetEngine = (() => {
579
589
  }
580
590
  break;
581
591
 
592
+ // 커스텀 이동 패턴 실행 중
593
+ case 'custom':
594
+ if (activeCustomMovement) {
595
+ updateCustomMovement(now - (updateCustomMovement._lastTime || now));
596
+ updateCustomMovement._lastTime = now;
597
+ }
598
+ break;
599
+
582
600
  case 'idle':
583
601
  case 'sleeping':
584
602
  case 'interacting':
@@ -735,6 +753,7 @@ const PetEngine = (() => {
735
753
  if (timestamp - lastAnimTime > ANIM_INTERVAL) {
736
754
  animFrame++;
737
755
  lastAnimTime = timestamp;
756
+ animFrameChanged = true; // 이동 시스템에 프레임 전환 알림
738
757
  }
739
758
 
740
759
  // 이동 모드에 따라 적절한 프레임셋으로 매핑
@@ -844,6 +863,305 @@ const PetEngine = (() => {
844
863
  return thread;
845
864
  }
846
865
 
866
+ // ===================================
867
+ // 커스텀 이동 패턴 레지스트리
868
+ // ===================================
869
+
870
+ // 등록된 커스텀 이동 패턴 저장소
871
+ // 각 핸들러: { init(params), update(deltaTime), isComplete(), cleanup() }
872
+ let customMovements = {};
873
+ let activeCustomMovement = null; // 현재 실행 중인 커스텀 이동 { name, handler, state }
874
+
875
+ /**
876
+ * 커스텀 이동 패턴 등록
877
+ * @param {string} name - 패턴 이름 (예: 'zigzag', 'patrol')
878
+ * @param {object} handler - { init, update, isComplete, cleanup }
879
+ */
880
+ function registerMovement(name, handler) {
881
+ if (!handler || typeof handler.update !== 'function') {
882
+ console.error(`[PetEngine] 이동 패턴 '${name}' 등록 실패: update 함수 필수`);
883
+ return false;
884
+ }
885
+ // 기본 메서드 보완
886
+ handler.init = handler.init || (() => {});
887
+ handler.isComplete = handler.isComplete || (() => false);
888
+ handler.cleanup = handler.cleanup || (() => {});
889
+ customMovements[name] = handler;
890
+ console.log(`[PetEngine] 커스텀 이동 패턴 등록: ${name}`);
891
+ return true;
892
+ }
893
+
894
+ /**
895
+ * 등록된 커스텀 이동 패턴 제거
896
+ * @param {string} name - 패턴 이름
897
+ */
898
+ function unregisterMovement(name) {
899
+ if (activeCustomMovement && activeCustomMovement.name === name) {
900
+ stopCustomMovement();
901
+ }
902
+ delete customMovements[name];
903
+ }
904
+
905
+ /**
906
+ * 커스텀 이동 패턴 실행
907
+ * @param {string} name - 등록된 패턴 이름
908
+ * @param {object} params - 패턴 초기화 파라미터
909
+ * @returns {boolean} 실행 성공 여부
910
+ */
911
+ function executeCustomMovement(name, params = {}) {
912
+ const handler = customMovements[name];
913
+ if (!handler) {
914
+ console.warn(`[PetEngine] 미등록 이동 패턴: ${name}`);
915
+ return false;
916
+ }
917
+
918
+ // 기존 커스텀 이동이 있으면 정리
919
+ if (activeCustomMovement) {
920
+ activeCustomMovement.handler.cleanup();
921
+ }
922
+
923
+ // 패턴 초기화 시 현재 위치/화면 정보 전달
924
+ const context = {
925
+ x, y, screenW, screenH,
926
+ charSize: CHAR_SIZE,
927
+ direction, edge, flipX,
928
+ };
929
+
930
+ const state = handler.init(Object.assign({}, params, context)) || {};
931
+ activeCustomMovement = { name, handler, state };
932
+
933
+ // CUSTOM 상태로 전환
934
+ if (typeof StateMachine !== 'undefined') {
935
+ StateMachine.forceState('custom');
936
+ }
937
+
938
+ console.log(`[PetEngine] 커스텀 이동 실행: ${name}`);
939
+ return true;
940
+ }
941
+
942
+ /**
943
+ * 커스텀 이동 매 프레임 갱신
944
+ * @param {number} deltaTime - 프레임 간 경과 시간 (ms)
945
+ */
946
+ function updateCustomMovement(deltaTime) {
947
+ if (!activeCustomMovement) return;
948
+
949
+ const { handler, state } = activeCustomMovement;
950
+ const context = {
951
+ x, y, screenW, screenH,
952
+ charSize: CHAR_SIZE,
953
+ direction, edge, flipX,
954
+ setPos: (nx, ny) => { x = nx; y = ny; },
955
+ setFlip: (f) => { flipX = f; },
956
+ setDir: (d) => { direction = d; },
957
+ };
958
+
959
+ handler.update(deltaTime, state, context);
960
+
961
+ // 핸들러가 setPos로 위치를 설정했을 수 있음
962
+ clampPosition();
963
+
964
+ // 완료 확인
965
+ if (handler.isComplete(state)) {
966
+ stopCustomMovement();
967
+ }
968
+ }
969
+
970
+ /**
971
+ * 현재 커스텀 이동 강제 중지 → IDLE 복귀
972
+ */
973
+ function stopCustomMovement() {
974
+ if (!activeCustomMovement) return;
975
+ activeCustomMovement.handler.cleanup(activeCustomMovement.state);
976
+ activeCustomMovement = null;
977
+
978
+ if (typeof StateMachine !== 'undefined') {
979
+ StateMachine.forceState('idle');
980
+ }
981
+ }
982
+
983
+ /**
984
+ * 등록된 커스텀 이동 패턴 목록 반환
985
+ */
986
+ function getRegisteredMovements() {
987
+ return Object.keys(customMovements);
988
+ }
989
+
990
+ // --- 사전 등록 이동 패턴 ---
991
+
992
+ // 지그재그: 대각선 방향 교대로 이동
993
+ registerMovement('zigzag', {
994
+ init(params) {
995
+ return {
996
+ amplitude: params.amplitude || 40, // 좌우 진폭 (px)
997
+ speed: params.speed || 2, // 전진 속도
998
+ segmentLength: params.segmentLength || 60, // 한 구간 길이
999
+ traveled: 0,
1000
+ totalDistance: params.distance || 300, // 총 이동 거리
1001
+ zigDir: 1, // 지그재그 방향
1002
+ startX: params.x,
1003
+ startY: params.y,
1004
+ };
1005
+ },
1006
+ update(dt, state, ctx) {
1007
+ const step = state.speed * (dt / 16);
1008
+ state.traveled += step;
1009
+
1010
+ // 수평 전진
1011
+ const moveX = step * (ctx.direction || 1);
1012
+ // 수직 지그재그
1013
+ const segProgress = (state.traveled % state.segmentLength) / state.segmentLength;
1014
+ if (segProgress < 0.05) state.zigDir *= -1;
1015
+ const moveY = state.zigDir * step * 0.7;
1016
+
1017
+ ctx.setPos(ctx.x + moveX, ctx.y + moveY);
1018
+ ctx.setFlip(moveX < 0);
1019
+ },
1020
+ isComplete(state) {
1021
+ return state.traveled >= state.totalDistance;
1022
+ },
1023
+ cleanup() {},
1024
+ });
1025
+
1026
+ // 순찰: 두 지점 사이를 왕복
1027
+ registerMovement('patrol', {
1028
+ init(params) {
1029
+ return {
1030
+ pointA: { x: params.pointAX || 100, y: params.pointAY || params.y },
1031
+ pointB: { x: params.pointBX || params.screenW - 164, y: params.pointBY || params.y },
1032
+ speed: params.speed || 1.5,
1033
+ laps: params.laps || 3, // 왕복 횟수
1034
+ currentLap: 0,
1035
+ targetIdx: 0, // 0=A, 1=B
1036
+ };
1037
+ },
1038
+ update(dt, state, ctx) {
1039
+ const target = state.targetIdx === 0 ? state.pointA : state.pointB;
1040
+ const dx = target.x - ctx.x;
1041
+ const dy = target.y - ctx.y;
1042
+ const dist = Math.hypot(dx, dy);
1043
+
1044
+ if (dist < 5) {
1045
+ // 목표 도달 → 방향 전환
1046
+ state.targetIdx = 1 - state.targetIdx;
1047
+ if (state.targetIdx === 0) state.currentLap++;
1048
+ return;
1049
+ }
1050
+
1051
+ const step = state.speed * (dt / 16);
1052
+ const ratio = step / dist;
1053
+ ctx.setPos(ctx.x + dx * ratio, ctx.y + dy * ratio);
1054
+ ctx.setFlip(dx < 0);
1055
+ },
1056
+ isComplete(state) {
1057
+ return state.currentLap >= state.laps;
1058
+ },
1059
+ cleanup() {},
1060
+ });
1061
+
1062
+ // 원형 회전: 중심점 기준 회전
1063
+ registerMovement('circle', {
1064
+ init(params) {
1065
+ return {
1066
+ centerX: params.centerX || params.x,
1067
+ centerY: params.centerY || params.y - 50,
1068
+ radius: params.radius || 50,
1069
+ speed: params.speed || 0.03, // 각속도 (rad/frame)
1070
+ angle: 0,
1071
+ totalAngle: params.revolutions ? params.revolutions * Math.PI * 2 : Math.PI * 4,
1072
+ traveled: 0,
1073
+ };
1074
+ },
1075
+ update(dt, state, ctx) {
1076
+ const step = state.speed * (dt / 16);
1077
+ state.angle += step;
1078
+ state.traveled += Math.abs(step);
1079
+
1080
+ const nx = state.centerX + Math.cos(state.angle) * state.radius;
1081
+ const ny = state.centerY + Math.sin(state.angle) * state.radius;
1082
+ ctx.setPos(nx, ny);
1083
+ ctx.setFlip(Math.sin(state.angle) < 0);
1084
+ },
1085
+ isComplete(state) {
1086
+ return state.traveled >= state.totalAngle;
1087
+ },
1088
+ cleanup() {},
1089
+ });
1090
+
1091
+ // 떨기: 빠르게 좌우 진동
1092
+ registerMovement('shake', {
1093
+ init(params) {
1094
+ return {
1095
+ intensity: params.intensity || 4, // 떨림 강도 (px)
1096
+ duration: params.duration || 800, // 지속 시간 (ms)
1097
+ elapsed: 0,
1098
+ originX: params.x,
1099
+ originY: params.y,
1100
+ phase: 0,
1101
+ };
1102
+ },
1103
+ update(dt, state, ctx) {
1104
+ state.elapsed += dt;
1105
+ state.phase += dt * 0.05;
1106
+
1107
+ // 감쇠하는 사인파 진동
1108
+ const decay = 1 - (state.elapsed / state.duration);
1109
+ const offsetX = Math.sin(state.phase) * state.intensity * decay;
1110
+ ctx.setPos(state.originX + offsetX, state.originY);
1111
+ },
1112
+ isComplete(state) {
1113
+ return state.elapsed >= state.duration;
1114
+ },
1115
+ cleanup() {},
1116
+ });
1117
+
1118
+ // 댄스: 여러 동작을 연속 수행 (점프 + 회전 + 떨기 조합)
1119
+ registerMovement('dance', {
1120
+ init(params) {
1121
+ return {
1122
+ duration: params.duration || 3000,
1123
+ elapsed: 0,
1124
+ originX: params.x,
1125
+ originY: params.y,
1126
+ phase: 0,
1127
+ };
1128
+ },
1129
+ update(dt, state, ctx) {
1130
+ state.elapsed += dt;
1131
+ state.phase += dt * 0.004;
1132
+
1133
+ const t = state.elapsed / state.duration;
1134
+
1135
+ // 구간별 다른 동작
1136
+ if (t < 0.25) {
1137
+ // 구간 1: 좌우 스윙
1138
+ const swingX = Math.sin(state.phase * 8) * 20;
1139
+ ctx.setPos(state.originX + swingX, state.originY);
1140
+ ctx.setFlip(swingX < 0);
1141
+ } else if (t < 0.5) {
1142
+ // 구간 2: 상하 바운스
1143
+ const bounceY = Math.abs(Math.sin(state.phase * 6)) * -30;
1144
+ ctx.setPos(state.originX, state.originY + bounceY);
1145
+ } else if (t < 0.75) {
1146
+ // 구간 3: 작은 원
1147
+ const angle = state.phase * 10;
1148
+ ctx.setPos(
1149
+ state.originX + Math.cos(angle) * 15,
1150
+ state.originY + Math.sin(angle) * 15
1151
+ );
1152
+ ctx.setFlip(Math.cos(angle) < 0);
1153
+ } else {
1154
+ // 구간 4: 빠른 좌우 떨기 (피니시)
1155
+ const shake = Math.sin(state.phase * 20) * 6 * (1 - t);
1156
+ ctx.setPos(state.originX + shake, state.originY);
1157
+ }
1158
+ },
1159
+ isComplete(state) {
1160
+ return state.elapsed >= state.duration;
1161
+ },
1162
+ cleanup() {},
1163
+ });
1164
+
847
1165
  // ===================================
848
1166
  // 메인 루프
849
1167
  // ===================================
@@ -853,17 +1171,37 @@ const PetEngine = (() => {
853
1171
  /**
854
1172
  * 엔진 시작: requestAnimationFrame 루프 가동
855
1173
  */
1174
+ let lastLoopTimestamp = 0; // 이전 루프 타임스탬프 (deltaTime 계산용)
1175
+
856
1176
  function start() {
857
1177
  if (running) return;
858
1178
  running = true;
859
1179
  lastAnimTime = performance.now();
860
- lastStepTime = Date.now();
1180
+ lastLoopTimestamp = performance.now();
861
1181
 
862
1182
  function loop(timestamp) {
863
1183
  if (!running) return;
1184
+
1185
+ // 커스텀 이동용 deltaTime 계산
1186
+ const deltaTime = timestamp - lastLoopTimestamp;
1187
+ lastLoopTimestamp = timestamp;
1188
+
864
1189
  const state = StateMachine.update();
865
- moveForState(state);
1190
+
1191
+ // 애니메이션을 먼저 갱신 → animFrameChanged 플래그 설정
866
1192
  updateAnimation(state, timestamp);
1193
+
1194
+ // 커스텀 이동이 활성 상태이면 전용 업데이트 실행
1195
+ if (activeCustomMovement && state === 'custom') {
1196
+ updateCustomMovement(deltaTime);
1197
+ clampPosition();
1198
+ updateVisual();
1199
+ } else {
1200
+ moveForState(state);
1201
+ }
1202
+
1203
+ // 프레임 전환 플래그 리셋 (다음 프레임까지 대기)
1204
+ animFrameChanged = false;
867
1205
  frameId = requestAnimationFrame(loop);
868
1206
  }
869
1207
  frameId = requestAnimationFrame(loop);
@@ -883,9 +1221,13 @@ const PetEngine = (() => {
883
1221
  getPosition, setPosition, setEdge, setDirection,
884
1222
  snapToNearestEdge, setSpeedMultiplier,
885
1223
  moveForState, updateAnimation,
886
- // 새 API: 물리 기반 이동
1224
+ // 물리 기반 이동
887
1225
  jumpTo, startRappel, releaseThread, moveToCenter,
888
1226
  setSurfaces, getThread, startFalling,
1227
+ // 커스텀 이동 패턴 시스템
1228
+ registerMovement, unregisterMovement,
1229
+ executeCustomMovement, stopCustomMovement,
1230
+ getRegisteredMovements,
889
1231
  CHAR_SIZE,
890
1232
  };
891
1233
  })();
@@ -23,6 +23,7 @@ const StateMachine = (() => {
23
23
  JUMPING: 'jumping', // 포물선 점프 중 (물리 엔진이 제어)
24
24
  RAPPELLING: 'rappelling', // 실(thread)을 타고 하강 중
25
25
  FALLING: 'falling', // 중력에 의한 자유 낙하 중
26
+ CUSTOM: 'custom', // 커스텀 이동 패턴 실행 중 (Movement Registry)
26
27
  };
27
28
 
28
29
  // 각 상태의 최소/최대 지속 시간(ms)
@@ -41,6 +42,7 @@ const StateMachine = (() => {
41
42
  [STATES.JUMPING]: { min: 500, max: 2000 }, // 점프 비행 시간
42
43
  [STATES.RAPPELLING]: { min: 2000, max: 8000 }, // 레펠 하강 시간
43
44
  [STATES.FALLING]: { min: 200, max: 1000 }, // 낙하 시간
45
+ [STATES.CUSTOM]: { min: 500, max: 30000 }, // 커스텀 이동 (패턴에 따라 가변)
44
46
  };
45
47
 
46
48
  let currentState = STATES.IDLE;
@@ -115,6 +117,11 @@ const StateMachine = (() => {
115
117
  [STATES.FALLING]: [
116
118
  { state: STATES.IDLE, weight: 1.0 },
117
119
  ],
120
+ // 커스텀 이동 완료 후: idle 또는 walking
121
+ [STATES.CUSTOM]: [
122
+ { state: STATES.IDLE, weight: 0.6 },
123
+ { state: STATES.WALKING, weight: 0.4 },
124
+ ],
118
125
  };
119
126
 
120
127
  function setPersonality(p) {
@@ -85,6 +85,116 @@ const MESSAGES = {
85
85
  '오늘 날씨 어때?',
86
86
  ],
87
87
 
88
+ // 브라우저 감시 코멘트 (키워드 → 코멘트 배열)
89
+ browsing: {
90
+ // 쇼핑
91
+ shopping: {
92
+ keywords: ['쿠팡', 'coupang', '11번가', 'gmarket', 'g마켓', '옥션', 'auction', '위메프', '티몬', 'amazon', '아마존', '알리', 'aliexpress', '무신사', '올리브영', 'oliveyoung', '네이버 쇼핑', 'shopping'],
93
+ comments: [
94
+ '또 쇼핑해? 지갑이 울고 있어...',
95
+ '뭐 사려고? 나도 보여줘!',
96
+ '충동구매 주의보!',
97
+ '이번엔 뭘 질러?',
98
+ '장바구니 비울 생각은 없어?',
99
+ '택배 언제 와? 기다려진다!',
100
+ ],
101
+ },
102
+ // 유튜브/동영상
103
+ video: {
104
+ keywords: ['youtube', '유튜브', 'twitch', '트위치', 'netflix', '넷플릭스', 'disney+', '디즈니', 'wavve', '웨이브', 'tving', '티빙', 'watcha', '왓챠'],
105
+ comments: [
106
+ '뭐 보는 거야? 나도 같이 볼래!',
107
+ '또 영상 보고 있구나~',
108
+ '이거 재미있어?',
109
+ '자막 켜줘, 나도 보게!',
110
+ '한 편만 더... 그러다 새벽이다!',
111
+ '구독 눌렀어?',
112
+ ],
113
+ },
114
+ // SNS
115
+ sns: {
116
+ keywords: ['instagram', '인스타', 'twitter', 'x.com', '트위터', 'facebook', '페이스북', 'threads', '쓰레드', 'tiktok', '틱톡', 'reddit'],
117
+ comments: [
118
+ 'SNS 또 보는 거야? 현실에 나도 있는데!',
119
+ '좋아요 누르고 있지?',
120
+ '무한 스크롤 주의!',
121
+ '댓글 달지 마, 싸움 나!',
122
+ '나 사진도 찍어줘~',
123
+ '피드 보느라 나 무시하지 마!',
124
+ ],
125
+ },
126
+ // 뉴스
127
+ news: {
128
+ keywords: ['뉴스', 'news', 'naver.com', '다음', 'daum', '한겨레', '조선일보', '중앙일보', 'bbc', 'cnn', '연합뉴스'],
129
+ comments: [
130
+ '세상에 무슨 일이야?',
131
+ '뉴스 읽고 있구나... 오늘 뭔 일 있어?',
132
+ '나쁜 뉴스만 보지 마, 기분 나빠져!',
133
+ '좋은 소식 있으면 알려줘!',
134
+ ],
135
+ },
136
+ // 개발/프로그래밍
137
+ dev: {
138
+ keywords: ['github', 'gitlab', 'stackoverflow', 'stack overflow', 'vscode', 'codepen', 'npm', 'developer', '개발', 'documentation', 'docs', 'api'],
139
+ comments: [
140
+ '코딩하고 있구나! 멋져!',
141
+ '에러 나면 나한테 말해, 위로해줄게!',
142
+ '버그 잡고 있어? 화이팅!',
143
+ 'Stack Overflow 복붙은 개발의 기본이지!',
144
+ '커밋은 자주 해야 해!',
145
+ ],
146
+ },
147
+ // 검색
148
+ search: {
149
+ keywords: ['google.com/search', '구글', 'google', 'naver.com/search', '네이버 검색', 'bing', '검색'],
150
+ comments: [
151
+ '뭐 찾고 있어?',
152
+ '검색하면 다 나와~',
153
+ '나한테 물어보지 왜 검색해!',
154
+ '궁금한 게 있어?',
155
+ ],
156
+ },
157
+ // 게임
158
+ game: {
159
+ keywords: ['steam', 'epic games', '리그 오브', 'league of', 'valorant', '발로란트', '오버워치', 'overwatch', 'minecraft', '마인크래프트', 'roblox', '게임'],
160
+ comments: [
161
+ '게임하고 있구나! 나도 끼워줘!',
162
+ '이겨야 해! 파이팅!',
163
+ '한 판만 더... 라고 했잖아!',
164
+ '게임 중독 주의보~',
165
+ ],
166
+ },
167
+ // 음악
168
+ music: {
169
+ keywords: ['spotify', '스포티파이', 'melon', '멜론', 'genie', '지니', 'bugs', '벅스', 'apple music', 'soundcloud'],
170
+ comments: [
171
+ '뭐 듣고 있어? 좋은 거야?',
172
+ '나도 들려줘~',
173
+ '노래 취향 좋다!',
174
+ '이 노래 좋아? 나도!',
175
+ ],
176
+ },
177
+ // 메일
178
+ mail: {
179
+ keywords: ['gmail', 'outlook', 'mail', '메일', 'naver mail', '네이버 메일'],
180
+ comments: [
181
+ '메일 확인 중이구나~',
182
+ '중요한 메일 있어?',
183
+ '스팸 조심해!',
184
+ ],
185
+ },
186
+ // 일반 브라우저 감지 (다른 카테고리에 해당 안 될 때)
187
+ general: {
188
+ keywords: ['chrome', 'edge', 'firefox', 'safari', 'brave', 'whale', '웨일'],
189
+ comments: [
190
+ '인터넷 서핑 중이구나~',
191
+ '재미있는 거 있으면 알려줘!',
192
+ '탭 좀 정리해... 너무 많아!',
193
+ '와이파이 잘 돼?',
194
+ ],
195
+ },
196
+ },
197
+
88
198
  // 진화 관련 메시지
89
199
  evolution: {
90
200
  stage_1: '뭔가 변하는 느낌이야...!',