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 +178 -2
- package/main/ai-bridge.js +36 -0
- package/main/ai-connector.js +37 -0
- package/main/index.js +14 -0
- package/main/ipc-handlers.js +6 -0
- package/main/platform.js +172 -2
- package/package.json +1 -1
- package/preload/preload.js +3 -0
- package/renderer/css/pet.css +11 -0
- package/renderer/index.html +4 -0
- package/renderer/js/ai-controller.js +39 -1
- package/renderer/js/character.js +3 -0
- package/renderer/js/interactions.js +24 -3
- package/renderer/js/pet-engine.js +748 -98
- package/renderer/js/speech.js +52 -5
- package/renderer/js/state-machine.js +24 -2
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.
|
|
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
|
-
|
|
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
|
// 현재 펫 상태 요청
|
package/main/ai-connector.js
CHANGED
|
@@ -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 {
|
package/main/ipc-handlers.js
CHANGED
|
@@ -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
|
-
|
|
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
package/preload/preload.js
CHANGED
|
@@ -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'),
|
package/renderer/css/pet.css
CHANGED
package/renderer/index.html
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/renderer/js/character.js
CHANGED
|
@@ -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;
|