clawmate 1.1.0 → 1.2.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.
package/index.js CHANGED
@@ -30,10 +30,18 @@ let lastDesktopCheckTime = 0;
30
30
  let lastScreenCheckTime = 0;
31
31
  let lastGreetingDate = null; // 하루에 한번만 인사
32
32
 
33
+ // 공간 탐험 시스템 상태
34
+ let knownWindows = []; // 알고 있는 윈도우 목록
35
+ let lastWindowCheckTime = 0;
36
+ let homePosition = null; // "집" 위치 (자주 가는 곳)
37
+ let explorationHistory = []; // 탐험한 위치 기록
38
+ let lastExploreTime = 0;
39
+ let lastFolderCarryTime = 0;
40
+
33
41
  module.exports = {
34
42
  id: 'clawmate',
35
43
  name: 'ClawMate',
36
- version: '1.0.0',
44
+ version: '1.2.0',
37
45
  description: 'OpenClaw 데스크톱 펫 - AI가 조종하는 살아있는 Claw',
38
46
 
39
47
  /**
@@ -223,6 +231,11 @@ function setupConnectorEvents() {
223
231
  await handleUserEvent(event);
224
232
  });
225
233
 
234
+ // 윈도우 위치 정보 수신 → 탐험 시스템에서 활용
235
+ connector.on('window_positions', (data) => {
236
+ knownWindows = data.windows || [];
237
+ });
238
+
226
239
  connector.on('disconnected', () => {
227
240
  console.log('[ClawMate] 연결 끊김 — Think Loop 중단, 재연결 시도');
228
241
  stopThinkLoop();
@@ -241,6 +254,13 @@ function onConnected() {
241
254
  if (connector && connector.connected) {
242
255
  connector.speak('OpenClaw 연결됨! 같이 놀자!');
243
256
  connector.action('excited');
257
+
258
+ // "집" 위치 설정 — 화면 하단 왼쪽을 기본 홈으로
259
+ homePosition = { x: 100, y: 1000, edge: 'bottom' };
260
+
261
+ // 초기 윈도우 목록 조회
262
+ connector.queryWindows();
263
+
244
264
  startThinkLoop();
245
265
  }
246
266
  }
@@ -404,6 +424,12 @@ const IDLE_CHATTER = [
404
424
  '나한테 관심 좀 줘봐!',
405
425
  '데스크톱이 넓고 좋다~',
406
426
  '여기서 보이는 게 다 내 세상!',
427
+ // 공간 탐험 관련 멘트
428
+ '화면 위를 점프해볼까~!',
429
+ '천장에서 내려가보자!',
430
+ '이 창 위에 올라가봐야지~',
431
+ '여기가 내 집이야~ 편하다!',
432
+ '좀 돌아다녀볼까? 탐험 모드!',
407
433
  ];
408
434
 
409
435
  // 랜덤 행동 목록
@@ -414,6 +440,9 @@ const RANDOM_ACTIONS = [
414
440
  { action: 'climbing', weight: 8, minInterval: 20000 },
415
441
  { action: 'looking_around', weight: 20, minInterval: 8000 },
416
442
  { action: 'sleeping', weight: 7, minInterval: 60000 },
443
+ // 공간 이동 행동
444
+ { action: 'jumping', weight: 5, minInterval: 30000 },
445
+ { action: 'rappelling', weight: 3, minInterval: 45000 },
417
446
  ];
418
447
 
419
448
  /**
@@ -484,6 +513,15 @@ async function thinkCycle() {
484
513
 
485
514
  // --- 6) 화면 관찰 (2분 간격, 10% 확률) ---
486
515
  handleScreenObservation(now);
516
+
517
+ // --- 7) 공간 탐험 (20초 간격, 20% 확률) ---
518
+ handleExploration(now, state);
519
+
520
+ // --- 8) 윈도우 체크 (30초 간격) ---
521
+ handleWindowCheck(now);
522
+
523
+ // --- 9) 바탕화면 폴더 나르기 (3분 간격, 10% 확률) ---
524
+ handleFolderCarry(now);
487
525
  }
488
526
 
489
527
  /**
@@ -586,7 +624,21 @@ function handleRandomAction(now, hour, isNightMode, state) {
586
624
  // minInterval 체크
587
625
  if (now - lastActionTime < selected.minInterval) return;
588
626
 
589
- connector.action(selected.action);
627
+ // 공간 이동 행동은 전용 API로 처리
628
+ if (selected.action === 'jumping') {
629
+ // 랜덤 위치로 점프 또는 화면 중앙으로
630
+ if (Math.random() > 0.5) {
631
+ connector.moveToCenter();
632
+ } else {
633
+ const randomX = Math.floor(Math.random() * 1200) + 100;
634
+ const randomY = Math.floor(Math.random() * 800) + 100;
635
+ connector.jumpTo(randomX, randomY);
636
+ }
637
+ } else if (selected.action === 'rappelling') {
638
+ connector.rappel();
639
+ } else {
640
+ connector.action(selected.action);
641
+ }
590
642
  lastActionTime = now;
591
643
  }
592
644
 
@@ -679,6 +731,130 @@ function weightedRandom(items) {
679
731
  return items[items.length - 1];
680
732
  }
681
733
 
734
+ // =====================================================
735
+ // 공간 탐험 시스템 — OpenClaw이 컴퓨터를 "집"처럼 돌아다님
736
+ // =====================================================
737
+
738
+ /**
739
+ * 공간 탐험 처리 (20초 간격, 20% 확률)
740
+ * 윈도우 위를 걸어다니고, 레펠로 내려가고, 집으로 돌아가는 등
741
+ */
742
+ function handleExploration(now, state) {
743
+ const exploreInterval = 20000; // 20초
744
+ if (now - lastExploreTime < exploreInterval) return;
745
+
746
+ // 20% 확률
747
+ if (Math.random() > 0.2) return;
748
+ lastExploreTime = now;
749
+
750
+ // 가중치 기반 탐험 행동 선택
751
+ const actions = [
752
+ { type: 'jump_to_center', weight: 15, speech: '화면 중앙 탐험~!' },
753
+ { type: 'rappel_down', weight: 10, speech: '실 타고 내려가볼까~' },
754
+ { type: 'climb_wall', weight: 20 },
755
+ { type: 'visit_window', weight: 25, speech: '이 창 위에 올라가볼까?' },
756
+ { type: 'return_home', weight: 30, speech: '집에 가자~' },
757
+ ];
758
+
759
+ const selected = weightedRandom(actions);
760
+ if (!selected) return;
761
+
762
+ switch (selected.type) {
763
+ case 'jump_to_center':
764
+ connector.moveToCenter();
765
+ if (selected.speech) connector.speak(selected.speech);
766
+ break;
767
+
768
+ case 'rappel_down':
769
+ connector.rappel();
770
+ if (selected.speech) setTimeout(() => connector.speak(selected.speech), 500);
771
+ break;
772
+
773
+ case 'climb_wall':
774
+ connector.action('climbing_up');
775
+ break;
776
+
777
+ case 'visit_window':
778
+ // 알려진 윈도우 중 랜덤으로 하나 선택 후 타이틀바 위로 점프
779
+ if (knownWindows.length > 0) {
780
+ const win = knownWindows[Math.floor(Math.random() * knownWindows.length)];
781
+ connector.jumpTo(win.x + win.width / 2, win.y);
782
+ if (selected.speech) connector.speak(selected.speech);
783
+ }
784
+ break;
785
+
786
+ case 'return_home':
787
+ if (homePosition) {
788
+ connector.jumpTo(homePosition.x, homePosition.y);
789
+ } else {
790
+ connector.action('idle');
791
+ }
792
+ if (selected.speech) connector.speak(selected.speech);
793
+ break;
794
+ }
795
+
796
+ // 탐험 기록 저장 (최근 20개)
797
+ explorationHistory.push({ type: selected.type, time: now });
798
+ if (explorationHistory.length > 20) {
799
+ explorationHistory.shift();
800
+ }
801
+ }
802
+
803
+ /**
804
+ * 윈도우 위치 정보 주기적 갱신 (30초 간격)
805
+ * OS에서 열린 윈도우 목록을 가져와 탐험에 활용
806
+ */
807
+ function handleWindowCheck(now) {
808
+ const windowCheckInterval = 30000; // 30초
809
+ if (now - lastWindowCheckTime < windowCheckInterval) return;
810
+ lastWindowCheckTime = now;
811
+ connector.queryWindows();
812
+ }
813
+
814
+ /**
815
+ * 바탕화면 폴더 나르기 (3분 간격, 10% 확률)
816
+ * 바탕화면 폴더를 하나 집어서 잠시 들고 다니다가 내려놓음
817
+ */
818
+ function handleFolderCarry(now) {
819
+ const carryInterval = 3 * 60 * 1000; // 3분
820
+ if (now - lastFolderCarryTime < carryInterval) return;
821
+
822
+ // 10% 확률
823
+ if (Math.random() > 0.1) return;
824
+ lastFolderCarryTime = now;
825
+
826
+ try {
827
+ const desktopPath = path.join(os.homedir(), 'Desktop');
828
+ if (!fs.existsSync(desktopPath)) return;
829
+
830
+ const entries = fs.readdirSync(desktopPath, { withFileTypes: true });
831
+ // 폴더만 필터 (숨김 폴더 제외, 안전한 것만)
832
+ const folders = entries
833
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
834
+ .map(e => e.name);
835
+
836
+ if (folders.length === 0) return;
837
+
838
+ const folder = folders[Math.floor(Math.random() * folders.length)];
839
+ connector.decide({
840
+ action: 'carrying',
841
+ speech: `${folder} 폴더 들고 다녀볼까~`,
842
+ emotion: 'playful',
843
+ });
844
+ connector.carryFile(folder);
845
+
846
+ // 5초 후 내려놓기
847
+ setTimeout(() => {
848
+ if (connector && connector.connected) {
849
+ connector.dropFile();
850
+ connector.speak('여기 놔둘게~');
851
+ }
852
+ }, 5000);
853
+ } catch {
854
+ // 바탕화면 폴더 접근 실패 — 무시
855
+ }
856
+ }
857
+
682
858
  // =====================================================
683
859
  // npm 패키지 버전 체크 (npm install -g 사용자용)
684
860
  // =====================================================
package/main/ai-bridge.js CHANGED
@@ -152,6 +152,42 @@ class AIBridge extends EventEmitter {
152
152
  this.emit('accessorize', payload);
153
153
  break;
154
154
 
155
+ // === 공간 이동 명령 ===
156
+ case 'jump_to':
157
+ // 특정 위치로 점프
158
+ // payload: { x, y }
159
+ this.emit('jump_to', payload);
160
+ break;
161
+
162
+ case 'rappel':
163
+ // 레펠 (천장/벽에서 실 타고 내려가기)
164
+ // payload: {}
165
+ this.emit('rappel', payload);
166
+ break;
167
+
168
+ case 'release_thread':
169
+ // 레펠 실 해제 (낙하)
170
+ // payload: {}
171
+ this.emit('release_thread', payload);
172
+ break;
173
+
174
+ case 'move_to_center':
175
+ // 화면 중앙으로 이동
176
+ // payload: {}
177
+ this.emit('move_to_center', payload);
178
+ break;
179
+
180
+ case 'walk_on_window':
181
+ // 특정 윈도우 타이틀바 위로 이동
182
+ // payload: { windowId, x, y }
183
+ this.emit('walk_on_window', payload);
184
+ break;
185
+
186
+ case 'query_windows':
187
+ // 윈도우 위치 정보 요청 → main process에서 처리
188
+ this.emit('query_windows', payload);
189
+ break;
190
+
155
191
  // === 컨텍스트 질의 ===
156
192
  case 'query_state':
157
193
  // 현재 펫 상태 요청
@@ -90,6 +90,11 @@ class OpenClawConnector extends EventEmitter {
90
90
  this.emit('screen_capture', payload);
91
91
  break;
92
92
 
93
+ case 'window_positions':
94
+ // 윈도우 위치 정보 응답 → 탐험 시스템에서 사용
95
+ this.emit('window_positions', payload);
96
+ break;
97
+
93
98
  case 'heartbeat':
94
99
  break;
95
100
  }
@@ -160,6 +165,38 @@ class OpenClawConnector extends EventEmitter {
160
165
  return this._send('ai_decision', decision);
161
166
  }
162
167
 
168
+ // === 공간 이동 API (OpenClaw이 컴퓨터를 "집"처럼 돌아다님) ===
169
+
170
+ /** 특정 위치로 점프 */
171
+ jumpTo(x, y) {
172
+ return this._send('jump_to', { x, y });
173
+ }
174
+
175
+ /** 레펠 시작 (천장/벽에서 실 타고 내려감) */
176
+ rappel() {
177
+ return this._send('rappel', {});
178
+ }
179
+
180
+ /** 레펠 실 해제 (낙하) */
181
+ releaseThread() {
182
+ return this._send('release_thread', {});
183
+ }
184
+
185
+ /** 화면 중앙으로 이동 */
186
+ moveToCenter() {
187
+ return this._send('move_to_center', {});
188
+ }
189
+
190
+ /** 특정 윈도우 위로 점프 */
191
+ walkOnWindow(windowId, x, y) {
192
+ return this._send('walk_on_window', { windowId, x, y });
193
+ }
194
+
195
+ /** 열린 윈도우 목록 요청 */
196
+ queryWindows() {
197
+ return this._send('query_windows', {});
198
+ }
199
+
163
200
  /**
164
201
  * 현재 펫 상태 요청 (Promise 반환)
165
202
  * 서버에서 state_response가 오면 resolve, 타임아웃 시 캐시된 상태 반환
package/main/index.js CHANGED
@@ -78,6 +78,8 @@ function startAIBridge(win) {
78
78
  'action', 'move', 'emote', 'speak', 'think',
79
79
  'carry_file', 'drop_file', 'set_mode', 'evolve',
80
80
  'accessorize', 'ai_decision',
81
+ // 공간 이동 명령 (OpenClaw이 집처럼 돌아다니기)
82
+ 'jump_to', 'rappel', 'release_thread', 'move_to_center', 'walk_on_window',
81
83
  ];
82
84
 
83
85
  commandTypes.forEach((type) => {
@@ -88,6 +90,18 @@ function startAIBridge(win) {
88
90
  });
89
91
  });
90
92
 
93
+ // OpenClaw 윈도우 위치 정보 요청 처리
94
+ aiBridge.on('query_windows', async () => {
95
+ try {
96
+ const { getWindowPositions } = require('./platform');
97
+ const windows = await getWindowPositions();
98
+ aiBridge.send('window_positions', { windows });
99
+ } catch (err) {
100
+ console.error('[AI Bridge] 윈도우 목록 실패:', err.message);
101
+ aiBridge.send('window_positions', { windows: [] });
102
+ }
103
+ });
104
+
91
105
  // OpenClaw 화면 캡처 요청 처리 (main process에서 직접 캡처)
92
106
  aiBridge.on('query_screen', async () => {
93
107
  try {
@@ -128,6 +128,12 @@ function registerIpcHandlers(getMainWindow, getAIBridge) {
128
128
  const bridge = getAIBridge();
129
129
  return bridge ? bridge.isConnected() : false;
130
130
  });
131
+
132
+ // 열린 윈도우 위치/크기 조회
133
+ ipcMain.handle('get-window-positions', async () => {
134
+ const { getWindowPositions } = require('./platform');
135
+ return await getWindowPositions();
136
+ });
131
137
  }
132
138
 
133
139
  module.exports = { registerIpcHandlers };
package/main/platform.js CHANGED
@@ -1,6 +1,9 @@
1
1
  const os = require('os');
2
2
  const path = require('path');
3
- const { execSync } = require('child_process');
3
+ const { execSync, exec } = require('child_process');
4
+ const { promisify } = require('util');
5
+
6
+ const execAsync = promisify(exec);
4
7
 
5
8
  const platform = os.platform();
6
9
 
@@ -43,4 +46,171 @@ function isLinux() {
43
46
  return platform === 'linux';
44
47
  }
45
48
 
46
- module.exports = { getDesktopPath, getTrayIconExt, isWindows, isMac, isLinux, platform };
49
+ /**
50
+ * OS별 열린 윈도우의 위치/크기 정보를 가져옴
51
+ * 반환 형식: [{ id, title, x, y, width, height }]
52
+ * 실패 시 빈 배열 반환 (안전한 폴백)
53
+ */
54
+ async function getWindowPositions() {
55
+ if (platform === 'win32') {
56
+ return getWindowPositionsWindows();
57
+ } else if (platform === 'darwin') {
58
+ return getWindowPositionsMac();
59
+ } else {
60
+ return getWindowPositionsLinux();
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Windows: PowerShell로 보이는 윈도우 목록 + 위치/크기 조회
66
+ * Add-Type으로 Win32 API(GetWindowRect) 호출
67
+ */
68
+ async function getWindowPositionsWindows() {
69
+ try {
70
+ // PowerShell에서 C# 인라인 컴파일로 Win32 API 접근
71
+ const psScript = `
72
+ Add-Type @"
73
+ using System;
74
+ using System.Runtime.InteropServices;
75
+ using System.Collections.Generic;
76
+ using System.Diagnostics;
77
+ public class WinInfo {
78
+ [DllImport("user32.dll")]
79
+ static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
80
+ [DllImport("user32.dll")]
81
+ static extern bool IsWindowVisible(IntPtr hWnd);
82
+ [StructLayout(LayoutKind.Sequential)]
83
+ public struct RECT { public int Left, Top, Right, Bottom; }
84
+ public static string GetWindows() {
85
+ var results = new List<string>();
86
+ foreach (var p in Process.GetProcesses()) {
87
+ if (p.MainWindowHandle == IntPtr.Zero) continue;
88
+ if (!IsWindowVisible(p.MainWindowHandle)) continue;
89
+ if (string.IsNullOrEmpty(p.MainWindowTitle)) continue;
90
+ RECT r;
91
+ GetWindowRect(p.MainWindowHandle, out r);
92
+ int w = r.Right - r.Left;
93
+ int h = r.Bottom - r.Top;
94
+ if (w < 50 || h < 50) continue;
95
+ results.Add(p.MainWindowTitle + "|" + r.Left + "|" + r.Top + "|" + w + "|" + h);
96
+ }
97
+ return string.Join("\\n", results);
98
+ }
99
+ }
100
+ "@
101
+ [WinInfo]::GetWindows()
102
+ `.trim();
103
+
104
+ // PowerShell 스크립트를 임시 파일 없이 stdin으로 전달
105
+ const { stdout } = await execAsync(
106
+ `powershell -NoProfile -Command -`,
107
+ { input: psScript, timeout: 5000, encoding: 'utf-8' }
108
+ );
109
+
110
+ const result = (stdout || '').trim();
111
+ if (!result) return [];
112
+
113
+ return result.split('\n').filter(Boolean).map((line, i) => {
114
+ const parts = line.split('|');
115
+ if (parts.length < 5) return null;
116
+ const [title, left, top, width, height] = parts;
117
+ return {
118
+ id: `win_${i}`,
119
+ title: title.trim(),
120
+ x: parseInt(left, 10) || 0,
121
+ y: parseInt(top, 10) || 0,
122
+ width: parseInt(width, 10) || 0,
123
+ height: parseInt(height, 10) || 0,
124
+ };
125
+ }).filter(Boolean);
126
+ } catch {
127
+ return [];
128
+ }
129
+ }
130
+
131
+ /**
132
+ * macOS: AppleScript로 보이는 윈도우 위치/크기 조회
133
+ */
134
+ async function getWindowPositionsMac() {
135
+ try {
136
+ // AppleScript로 보이는 프로세스의 윈도우 정보 수집
137
+ const script = `
138
+ tell application "System Events"
139
+ set output to ""
140
+ repeat with proc in (processes whose visible is true)
141
+ try
142
+ set procName to name of proc
143
+ repeat with w in windows of proc
144
+ try
145
+ set {x, y} to position of w
146
+ set {ww, hh} to size of w
147
+ set output to output & procName & "|" & x & "|" & y & "|" & ww & "|" & hh & "\\n"
148
+ end try
149
+ end repeat
150
+ end try
151
+ end repeat
152
+ return output
153
+ end tell
154
+ `.trim();
155
+
156
+ const { stdout } = await execAsync(
157
+ `osascript -e '${script.replace(/'/g, "'\\''")}'`,
158
+ { timeout: 5000, encoding: 'utf-8' }
159
+ );
160
+
161
+ const result = (stdout || '').trim();
162
+ if (!result) return [];
163
+
164
+ return result.split('\n').filter(Boolean).map((line, i) => {
165
+ const parts = line.split('|');
166
+ if (parts.length < 5) return null;
167
+ const [title, x, y, width, height] = parts;
168
+ return {
169
+ id: `win_${i}`,
170
+ title: title.trim(),
171
+ x: parseInt(x, 10) || 0,
172
+ y: parseInt(y, 10) || 0,
173
+ width: parseInt(width, 10) || 0,
174
+ height: parseInt(height, 10) || 0,
175
+ };
176
+ }).filter(Boolean);
177
+ } catch {
178
+ return [];
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Linux: wmctrl -l -G 로 윈도우 위치/크기 조회
184
+ * wmctrl 미설치 시 빈 배열 반환
185
+ */
186
+ async function getWindowPositionsLinux() {
187
+ try {
188
+ const { stdout } = await execAsync('wmctrl -l -G', {
189
+ timeout: 5000,
190
+ encoding: 'utf-8',
191
+ });
192
+
193
+ const result = (stdout || '').trim();
194
+ if (!result) return [];
195
+
196
+ // wmctrl -l -G 출력 형식:
197
+ // 0x02000003 0 0 0 1920 1080 hostname 데스크톱
198
+ // ID desktop x y width height hostname title
199
+ return result.split('\n').filter(Boolean).map((line, i) => {
200
+ const parts = line.trim().split(/\s+/);
201
+ if (parts.length < 8) return null;
202
+ const x = parseInt(parts[2], 10) || 0;
203
+ const y = parseInt(parts[3], 10) || 0;
204
+ const width = parseInt(parts[4], 10) || 0;
205
+ const height = parseInt(parts[5], 10) || 0;
206
+ // hostname 이후가 타이틀 (공백 포함 가능)
207
+ const title = parts.slice(7).join(' ');
208
+ if (width < 50 || height < 50) return null;
209
+ return { id: `win_${i}`, title, x, y, width, height };
210
+ }).filter(Boolean);
211
+ } catch {
212
+ return [];
213
+ }
214
+ }
215
+
216
+ module.exports = { getDesktopPath, getTrayIconExt, isWindows, isMac, isLinux, platform, getWindowPositions };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmate",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "OpenClaw 데스크톱 펫 - AI가 조종하는 화면 위의 살아있는 Claw",
5
5
  "main": "main/index.js",
6
6
  "bin": {
@@ -26,6 +26,9 @@ contextBridge.exposeInMainWorld('clawmate', {
26
26
  // 창 정보
27
27
  getScreenSize: () => ipcRenderer.invoke('get-screen-size'),
28
28
 
29
+ // 열린 윈도우 위치/크기 조회
30
+ getWindowPositions: () => ipcRenderer.invoke('get-window-positions'),
31
+
29
32
  // 화면 캡처
30
33
  screen: {
31
34
  capture: () => ipcRenderer.invoke('capture-screen'),
@@ -1,3 +1,14 @@
1
+ /* 레펠 실(thread) SVG 레이어 */
2
+ #thread-svg {
3
+ position: absolute;
4
+ top: 0;
5
+ left: 0;
6
+ width: 100%;
7
+ height: 100%;
8
+ pointer-events: none;
9
+ z-index: 999;
10
+ }
11
+
1
12
  /* 펫 컨테이너 */
2
13
  #pet-container {
3
14
  position: absolute;
@@ -39,6 +39,10 @@
39
39
  </head>
40
40
  <body>
41
41
  <div id="world">
42
+ <!-- 레펠 실(thread) 시각화용 SVG 레이어 -->
43
+ <svg id="thread-svg" style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:999;">
44
+ <line id="thread-line" x1="0" y1="0" x2="0" y2="0" stroke="#888" stroke-width="1" style="display:none;" />
45
+ </svg>
42
46
  <div id="pet-container"></div>
43
47
  <div id="speech-container"></div>
44
48
  </div>
@@ -111,6 +111,35 @@ const AIController = (() => {
111
111
  // 종합 의사결정 — 여러 행동을 순서대로 실행
112
112
  executeDecision(payload);
113
113
  break;
114
+
115
+ // === 공간 이동 명령 ===
116
+
117
+ case 'jump_to':
118
+ // 특정 위치로 점프
119
+ // payload: { x, y }
120
+ PetEngine.jumpTo(payload.x, payload.y);
121
+ break;
122
+
123
+ case 'rappel':
124
+ // 레펠 시작 (천장/벽에서 실 타고 내려가기)
125
+ PetEngine.startRappel();
126
+ break;
127
+
128
+ case 'release_thread':
129
+ // 레펠 실 해제 (낙하)
130
+ PetEngine.releaseThread();
131
+ break;
132
+
133
+ case 'move_to_center':
134
+ // 화면 중앙으로 이동 (물리적 방법으로)
135
+ PetEngine.moveToCenter();
136
+ break;
137
+
138
+ case 'walk_on_window':
139
+ // 특정 윈도우 타이틀바 위로 이동
140
+ // payload: { windowId, x, y }
141
+ PetEngine.jumpTo(payload.x, payload.y);
142
+ break;
114
143
  }
115
144
  }
116
145
 
@@ -140,7 +169,16 @@ const AIController = (() => {
140
169
  }
141
170
 
142
171
  if (decision.moveTo) {
143
- PetEngine.setPosition(decision.moveTo.x, decision.moveTo.y);
172
+ // 이동 방법에 따라 다른 물리 동작 사용
173
+ if (decision.moveTo.method === 'jump') {
174
+ PetEngine.jumpTo(decision.moveTo.x, decision.moveTo.y);
175
+ } else if (decision.moveTo.method === 'rappel') {
176
+ PetEngine.startRappel();
177
+ } else if (decision.moveTo.method === 'center') {
178
+ PetEngine.moveToCenter();
179
+ } else {
180
+ PetEngine.setPosition(decision.moveTo.x, decision.moveTo.y);
181
+ }
144
182
  }
145
183
  }
146
184
 
@@ -332,6 +332,9 @@ const Character = (() => {
332
332
  interacting: 'excited',
333
333
  scared: 'scared',
334
334
  excited: 'excited',
335
+ jumping: 'excited', // 점프: excited 프레임셋 재활용
336
+ rappelling: 'climb', // 레펠: climb 프레임셋 재활용
337
+ falling: 'scared', // 낙하: scared 프레임셋 재활용
335
338
  };
336
339
 
337
340
  let currentCanvas = null;