agenticros 0.5.4 → 0.5.6
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/README.md +5 -2
- package/dist/__tests__/eyes.test.d.ts +2 -0
- package/dist/__tests__/eyes.test.d.ts.map +1 -0
- package/dist/__tests__/eyes.test.js +30 -0
- package/dist/__tests__/eyes.test.js.map +1 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +53 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/down.d.ts +3 -3
- package/dist/commands/down.js +4 -4
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/eyes.d.ts +16 -0
- package/dist/commands/eyes.d.ts.map +1 -0
- package/dist/commands/eyes.js +113 -0
- package/dist/commands/eyes.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +14 -6
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/logs.d.ts +1 -0
- package/dist/commands/logs.d.ts.map +1 -1
- package/dist/commands/logs.js +2 -1
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/status.js +1 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/up.d.ts +6 -0
- package/dist/commands/up.d.ts.map +1 -1
- package/dist/commands/up.js +8 -0
- package/dist/commands/up.js.map +1 -1
- package/dist/index.js +28 -3
- package/dist/index.js.map +1 -1
- package/dist/menu.d.ts.map +1 -1
- package/dist/menu.js +5 -0
- package/dist/menu.js.map +1 -1
- package/dist/util/eyes.d.ts +23 -0
- package/dist/util/eyes.d.ts.map +1 -0
- package/dist/util/eyes.js +53 -0
- package/dist/util/eyes.js.map +1 -0
- package/dist/util/pidfile.d.ts +1 -1
- package/dist/util/pidfile.d.ts.map +1 -1
- package/dist/util/pidfile.js.map +1 -1
- package/package.json +12 -11
- package/runtime/BUNDLE.json +1 -1
- package/runtime/README.md +4 -2
- package/runtime/docs/cli.md +15 -5
- package/runtime/docs/eyes.md +83 -0
- package/runtime/packages/robot-eyes/README.md +25 -0
- package/runtime/packages/robot-eyes/package.json +49 -0
- package/runtime/packages/robot-eyes/public/eyes.js +246 -0
- package/runtime/packages/robot-eyes/public/index.html +14 -0
- package/runtime/packages/robot-eyes/public/style.css +22 -0
- package/runtime/packages/robot-eyes/public/teleop.js +102 -0
- package/runtime/packages/robot-eyes/src/index.js +410 -0
- package/runtime/pnpm-lock.yaml +30 -1
- package/runtime/scripts/configure_agenticros.sh +0 -0
- package/runtime/scripts/configure_for_sim.sh +0 -0
- package/runtime/scripts/fix-openclaw-control-ui-path.sh +0 -0
- package/runtime/scripts/install_cli.sh +0 -0
- package/runtime/scripts/install_rosbridge_from_source.sh +0 -0
- package/runtime/scripts/onboard_robot.sh +0 -0
- package/runtime/scripts/pack-runtime.mjs +4 -3
- package/runtime/scripts/refresh-skill-deps.mjs +0 -0
- package/runtime/scripts/run_demo_native.sh +0 -0
- package/runtime/scripts/run_nemoclaw_host_stack.sh +0 -0
- package/runtime/scripts/run_robot_rosbridge.sh +0 -0
- package/runtime/scripts/setup_gateway_plugin.sh +0 -0
- package/runtime/scripts/setup_robot.sh +0 -0
- package/runtime/scripts/setup_workspace.sh +0 -0
- package/runtime/scripts/sim/run_sim.sh +0 -0
- package/runtime/scripts/smoke_test_nemoclaw.sh +0 -0
- package/runtime/scripts/start_demo.sh +0 -0
- package/runtime/scripts/use-openclaw-2026.2.26.sh +0 -0
- package/runtime/scripts/use-openclaw-2026.3.11.sh +0 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agenticros/eyes",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Fullscreen robot eyes for tablet/display devices, driven by ROS 2 cmd_vel Twist",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"agenticros-eyes": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"public",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "node -e \"process.exit(0)\"",
|
|
17
|
+
"typecheck": "node -e \"process.exit(0)\"",
|
|
18
|
+
"start": "node src/index.js",
|
|
19
|
+
"start:no-browser": "node src/index.js --no-browser",
|
|
20
|
+
"test": "node -e \"process.exit(0)\""
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"ws": "^8.18.0"
|
|
27
|
+
},
|
|
28
|
+
"optionalDependencies": {
|
|
29
|
+
"rclnodejs": "^1.9.0"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"agenticros",
|
|
33
|
+
"ros2",
|
|
34
|
+
"robot",
|
|
35
|
+
"eyes",
|
|
36
|
+
"cmd_vel",
|
|
37
|
+
"teleop"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/agenticros/agenticros.git",
|
|
45
|
+
"directory": "packages/robot-eyes"
|
|
46
|
+
},
|
|
47
|
+
"author": "PlaiPin",
|
|
48
|
+
"license": "MIT"
|
|
49
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
const canvas = document.getElementById('eyes');
|
|
2
|
+
const ctx = canvas.getContext('2d');
|
|
3
|
+
|
|
4
|
+
const state = {
|
|
5
|
+
// Target from ROS (+/-1); idle offsets applied on top when not driving
|
|
6
|
+
rosGazeX: 0,
|
|
7
|
+
driving: false,
|
|
8
|
+
// Smoothed pupil position in [-1, 1]
|
|
9
|
+
gazeX: 0,
|
|
10
|
+
gazeY: 0,
|
|
11
|
+
// Idle wander target
|
|
12
|
+
idleX: 0,
|
|
13
|
+
idleY: 0,
|
|
14
|
+
nextIdleAt: 0,
|
|
15
|
+
idleHoldUntil: 0,
|
|
16
|
+
// Blink: 0 open → 1 closed
|
|
17
|
+
blink: 0,
|
|
18
|
+
blinkPhase: 'open', // open | closing | closed | opening
|
|
19
|
+
nextBlinkAt: 0,
|
|
20
|
+
blinkHoldUntil: 0,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const BLINK_CLOSE_MS = 90;
|
|
24
|
+
const BLINK_OPEN_MS = 110;
|
|
25
|
+
const BLINK_HOLD_MS = 40;
|
|
26
|
+
const GAZE_LERP = 0.12;
|
|
27
|
+
const IDLE_LERP = 0.04;
|
|
28
|
+
|
|
29
|
+
function rand(min, max) {
|
|
30
|
+
return min + Math.random() * (max - min);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function scheduleBlink(now) {
|
|
34
|
+
state.nextBlinkAt = now + rand(2200, 5200);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function scheduleIdle(now) {
|
|
38
|
+
state.nextIdleAt = now + rand(2500, 6000);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
scheduleBlink(performance.now());
|
|
42
|
+
scheduleIdle(performance.now());
|
|
43
|
+
|
|
44
|
+
function connectWs() {
|
|
45
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
46
|
+
const ws = new WebSocket(`${proto}://${location.host}`);
|
|
47
|
+
|
|
48
|
+
ws.addEventListener('message', (ev) => {
|
|
49
|
+
try {
|
|
50
|
+
const msg = JSON.parse(ev.data);
|
|
51
|
+
if (msg.type !== 'gaze') return;
|
|
52
|
+
state.rosGazeX = Number(msg.gazeX) || 0;
|
|
53
|
+
state.driving = Boolean(msg.driving);
|
|
54
|
+
if (state.driving) {
|
|
55
|
+
state.idleX = 0;
|
|
56
|
+
state.idleY = 0;
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// ignore malformed
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
ws.addEventListener('close', () => {
|
|
64
|
+
setTimeout(connectWs, 1000);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
ws.addEventListener('error', () => {
|
|
68
|
+
ws.close();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function enterFullscreen() {
|
|
73
|
+
const el = document.documentElement;
|
|
74
|
+
if (document.fullscreenElement) return;
|
|
75
|
+
try {
|
|
76
|
+
if (el.requestFullscreen) await el.requestFullscreen();
|
|
77
|
+
} catch {
|
|
78
|
+
// Kiosk browsers often already cover the screen
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
document.addEventListener('click', () => {
|
|
83
|
+
enterFullscreen();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
document.addEventListener('keydown', (e) => {
|
|
87
|
+
if (e.key === 'Escape') {
|
|
88
|
+
// Allow escape while developing; kiosk may ignore it
|
|
89
|
+
window.close();
|
|
90
|
+
}
|
|
91
|
+
if (e.key === 'f' || e.key === 'F') {
|
|
92
|
+
enterFullscreen();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
function resize() {
|
|
97
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
98
|
+
canvas.width = Math.floor(window.innerWidth * dpr);
|
|
99
|
+
canvas.height = Math.floor(window.innerHeight * dpr);
|
|
100
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
window.addEventListener('resize', resize);
|
|
104
|
+
resize();
|
|
105
|
+
|
|
106
|
+
function updateBlink(now, dt) {
|
|
107
|
+
if (state.blinkPhase === 'open') {
|
|
108
|
+
if (now >= state.nextBlinkAt) {
|
|
109
|
+
state.blinkPhase = 'closing';
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (state.blinkPhase === 'closing') {
|
|
115
|
+
state.blink = Math.min(1, state.blink + dt / BLINK_CLOSE_MS);
|
|
116
|
+
if (state.blink >= 1) {
|
|
117
|
+
state.blink = 1;
|
|
118
|
+
state.blinkPhase = 'closed';
|
|
119
|
+
state.blinkHoldUntil = now + BLINK_HOLD_MS;
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (state.blinkPhase === 'closed') {
|
|
125
|
+
if (now >= state.blinkHoldUntil) {
|
|
126
|
+
state.blinkPhase = 'opening';
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (state.blinkPhase === 'opening') {
|
|
132
|
+
state.blink = Math.max(0, state.blink - dt / BLINK_OPEN_MS);
|
|
133
|
+
if (state.blink <= 0) {
|
|
134
|
+
state.blink = 0;
|
|
135
|
+
state.blinkPhase = 'open';
|
|
136
|
+
scheduleBlink(now);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function updateIdle(now) {
|
|
142
|
+
if (state.driving) {
|
|
143
|
+
state.idleX = 0;
|
|
144
|
+
state.idleY = 0;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (now >= state.nextIdleAt && now >= state.idleHoldUntil) {
|
|
149
|
+
// Sometimes glance around, sometimes return to center
|
|
150
|
+
if (Math.random() < 0.35) {
|
|
151
|
+
state.idleX = 0;
|
|
152
|
+
state.idleY = 0;
|
|
153
|
+
} else {
|
|
154
|
+
state.idleX = rand(-0.45, 0.45);
|
|
155
|
+
state.idleY = rand(-0.25, 0.25);
|
|
156
|
+
}
|
|
157
|
+
state.idleHoldUntil = now + rand(600, 1800);
|
|
158
|
+
scheduleIdle(now);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function updateGaze() {
|
|
163
|
+
const targetX = state.driving ? state.rosGazeX : state.idleX;
|
|
164
|
+
const targetY = state.driving ? 0 : state.idleY;
|
|
165
|
+
const lerp = state.driving ? GAZE_LERP : IDLE_LERP;
|
|
166
|
+
state.gazeX += (targetX - state.gazeX) * lerp;
|
|
167
|
+
state.gazeY += (targetY - state.gazeY) * lerp;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function drawEye(cx, cy, eyeW, eyeH, gazeX, gazeY, blink) {
|
|
171
|
+
const rx = eyeW / 2;
|
|
172
|
+
const ry = eyeH / 2;
|
|
173
|
+
|
|
174
|
+
// sclera
|
|
175
|
+
ctx.beginPath();
|
|
176
|
+
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
|
177
|
+
ctx.fillStyle = '#f5f5f5';
|
|
178
|
+
ctx.fill();
|
|
179
|
+
|
|
180
|
+
// iris + pupil
|
|
181
|
+
const maxOffsetX = rx * 0.32;
|
|
182
|
+
const maxOffsetY = ry * 0.28;
|
|
183
|
+
const px = cx + gazeX * maxOffsetX;
|
|
184
|
+
const py = cy + gazeY * maxOffsetY;
|
|
185
|
+
const irisR = Math.min(rx, ry) * 0.42;
|
|
186
|
+
const pupilR = irisR * 0.55;
|
|
187
|
+
|
|
188
|
+
ctx.beginPath();
|
|
189
|
+
ctx.arc(px, py, irisR, 0, Math.PI * 2);
|
|
190
|
+
ctx.fillStyle = '#1a1a1a';
|
|
191
|
+
ctx.fill();
|
|
192
|
+
|
|
193
|
+
ctx.beginPath();
|
|
194
|
+
ctx.arc(px, py, pupilR, 0, Math.PI * 2);
|
|
195
|
+
ctx.fillStyle = '#000';
|
|
196
|
+
ctx.fill();
|
|
197
|
+
|
|
198
|
+
// highlight
|
|
199
|
+
ctx.beginPath();
|
|
200
|
+
ctx.arc(px - irisR * 0.35, py - irisR * 0.35, pupilR * 0.35, 0, Math.PI * 2);
|
|
201
|
+
ctx.fillStyle = 'rgba(255,255,255,0.85)';
|
|
202
|
+
ctx.fill();
|
|
203
|
+
|
|
204
|
+
// eyelids (cover from top and bottom)
|
|
205
|
+
if (blink > 0) {
|
|
206
|
+
const cover = ry * blink + 2;
|
|
207
|
+
ctx.fillStyle = '#000';
|
|
208
|
+
// clip to eye bounds roughly with rects over the ellipse region
|
|
209
|
+
ctx.fillRect(cx - rx - 2, cy - ry - 2, eyeW + 4, cover + 2);
|
|
210
|
+
ctx.fillRect(cx - rx - 2, cy + ry - cover, eyeW + 4, cover + 2);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function draw() {
|
|
215
|
+
const w = window.innerWidth;
|
|
216
|
+
const h = window.innerHeight;
|
|
217
|
+
|
|
218
|
+
ctx.fillStyle = '#000';
|
|
219
|
+
ctx.fillRect(0, 0, w, h);
|
|
220
|
+
|
|
221
|
+
const eyeW = Math.min(w * 0.28, h * 0.55);
|
|
222
|
+
const eyeH = eyeW * 0.62;
|
|
223
|
+
const gap = w * 0.14;
|
|
224
|
+
const cy = h * 0.5;
|
|
225
|
+
const leftCx = w * 0.5 - gap / 2 - eyeW / 2;
|
|
226
|
+
const rightCx = w * 0.5 + gap / 2 + eyeW / 2;
|
|
227
|
+
|
|
228
|
+
drawEye(leftCx, cy, eyeW, eyeH, state.gazeX, state.gazeY, state.blink);
|
|
229
|
+
drawEye(rightCx, cy, eyeW, eyeH, state.gazeX, state.gazeY, state.blink);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let last = performance.now();
|
|
233
|
+
|
|
234
|
+
function frame(now) {
|
|
235
|
+
const dt = Math.min(50, now - last);
|
|
236
|
+
last = now;
|
|
237
|
+
updateBlink(now, dt);
|
|
238
|
+
updateIdle(now);
|
|
239
|
+
updateGaze();
|
|
240
|
+
draw();
|
|
241
|
+
requestAnimationFrame(frame);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
connectWs();
|
|
245
|
+
enterFullscreen();
|
|
246
|
+
requestAnimationFrame(frame);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
|
6
|
+
<title>Robot Eyes</title>
|
|
7
|
+
<link rel="stylesheet" href="/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<canvas id="eyes"></canvas>
|
|
11
|
+
<script type="module" src="/eyes.js"></script>
|
|
12
|
+
<script type="module" src="/teleop.js"></script>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
* {
|
|
2
|
+
margin: 0;
|
|
3
|
+
padding: 0;
|
|
4
|
+
box-sizing: border-box;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
html,
|
|
8
|
+
body {
|
|
9
|
+
width: 100%;
|
|
10
|
+
height: 100%;
|
|
11
|
+
overflow: hidden;
|
|
12
|
+
background: #000;
|
|
13
|
+
cursor: none;
|
|
14
|
+
touch-action: none;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
canvas#eyes {
|
|
18
|
+
display: block;
|
|
19
|
+
width: 100vw;
|
|
20
|
+
height: 100vh;
|
|
21
|
+
background: #000;
|
|
22
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invisible keyboard teleop — no on-screen UI.
|
|
3
|
+
* WASD drive, Q faster, Z slower. Messages go to the server over WebSocket.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MOVE_KEYS = new Set(['w', 'a', 's', 'd']);
|
|
7
|
+
|
|
8
|
+
/** @type {WebSocket | null} */
|
|
9
|
+
let socket = null;
|
|
10
|
+
let reconnectTimer = 0;
|
|
11
|
+
|
|
12
|
+
const pressed = {
|
|
13
|
+
w: false,
|
|
14
|
+
a: false,
|
|
15
|
+
s: false,
|
|
16
|
+
d: false,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function send(payload) {
|
|
20
|
+
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
21
|
+
socket.send(JSON.stringify(payload));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sendKeys() {
|
|
26
|
+
send({
|
|
27
|
+
type: 'keys',
|
|
28
|
+
keys: { ...pressed },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function connect() {
|
|
33
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
34
|
+
const ws = new WebSocket(`${proto}://${location.host}`);
|
|
35
|
+
socket = ws;
|
|
36
|
+
|
|
37
|
+
ws.addEventListener('open', () => {
|
|
38
|
+
sendKeys();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
ws.addEventListener('close', () => {
|
|
42
|
+
socket = null;
|
|
43
|
+
clearTimeout(reconnectTimer);
|
|
44
|
+
reconnectTimer = setTimeout(connect, 1000);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
ws.addEventListener('error', () => {
|
|
48
|
+
ws.close();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeKey(e) {
|
|
53
|
+
return e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
window.addEventListener('keydown', (e) => {
|
|
57
|
+
const key = normalizeKey(e);
|
|
58
|
+
|
|
59
|
+
if (MOVE_KEYS.has(key)) {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
if (!pressed[key]) {
|
|
62
|
+
pressed[key] = true;
|
|
63
|
+
sendKeys();
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (key === 'q' && !e.repeat) {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
send({ type: 'speed', delta: 1 });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (key === 'z' && !e.repeat) {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
send({ type: 'speed', delta: -1 });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
window.addEventListener('keyup', (e) => {
|
|
81
|
+
const key = normalizeKey(e);
|
|
82
|
+
if (!MOVE_KEYS.has(key)) return;
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
if (pressed[key]) {
|
|
85
|
+
pressed[key] = false;
|
|
86
|
+
sendKeys();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Release all keys if the window loses focus (safety)
|
|
91
|
+
window.addEventListener('blur', () => {
|
|
92
|
+
let changed = false;
|
|
93
|
+
for (const k of MOVE_KEYS) {
|
|
94
|
+
if (pressed[k]) {
|
|
95
|
+
pressed[k] = false;
|
|
96
|
+
changed = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (changed) sendKeys();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
connect();
|