@7ots/cli 0.1.4 → 0.1.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 +14 -20
- package/cli/7ots.mjs +55 -5
- package/cli/lib/account.mjs +89 -0
- package/cli/lib/assistant.mjs +122 -16
- package/cli/lib/brain.mjs +41 -11
- package/cli/lib/card-shot.mjs +84 -0
- package/cli/lib/config.mjs +28 -5
- package/cli/lib/i18n.mjs +57 -0
- package/cli/lib/inferences.mjs +76 -0
- package/cli/lib/integrations.mjs +414 -0
- package/cli/lib/orquesta.mjs +53 -0
- package/cli/lib/pet-core.mjs +7 -10
- package/cli/lib/pet-server.mjs +365 -22
- package/cli/lib/screen.mjs +125 -0
- package/cli/lib/stt-local.py +14 -0
- package/cli/lib/stt.mjs +68 -0
- package/cli/lib/tts-local.mjs +110 -0
- package/cli/lib/tts-local.py +18 -0
- package/cli/lib/vm.mjs +309 -0
- package/cli/lib/watchers.mjs +151 -0
- package/cli/pet/card.html +294 -0
- package/cli/pet/electron/director.cjs +175 -0
- package/cli/pet/electron/main.cjs +515 -59
- package/cli/pet/pet.html +414 -21
- package/cli/pet/settings.html +991 -57
- package/dist/7ots.esm.js +3 -3
- package/dist/7ots.esm.js.map +3 -3
- package/dist/7ots.iife.js +3 -3
- package/dist/7ots.iife.js.map +3 -3
- package/llms.txt +31 -17
- package/package.json +1 -1
- package/server/llm.mjs +2 -0
- package/server/platform/connect.mjs +3 -1
- package/server/platform/db.mjs +77 -0
- package/server/platform/device.mjs +125 -0
- package/server/platform/orquesta.mjs +5 -0
- package/server/platform/routes.mjs +100 -1
- package/server/platform/sites.mjs +146 -0
- package/server/platform/store.mjs +3 -1
- package/server/platform/vm.mjs +386 -0
- package/src/character3d/index.js +2 -1
- package/src/character3d/stage.js +15 -4
- package/src/i18n/messages/character.js +3 -3
- package/src/i18n/messages/platform.js +42 -3
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Desktop mode: the pet page in a transparent, frameless, always-on-top window that strolls
|
|
3
|
-
* along the bottom of the screen. Launched by `7ots pet --mode desktop` with
|
|
3
|
+
* along the bottom of the screen. It resizes to what is in use and lets clicks through its empty parts. Launched by `7ots pet --mode desktop` with
|
|
4
4
|
* ~/.7ots/electron/…/electron cli/pet/electron/main.cjs (installed once by the CLI)
|
|
5
5
|
* and SEVENOTS_PET_URL / SEVENOTS_PET_TOKEN in the environment.
|
|
6
|
-
* Menu: right click on the pet, or the tray icon (Show / Hide / Talk / care / Settings / Quit).
|
|
6
|
+
* Menu: right click on the pet, or the tray icon (Show / Hide / Talk / Listen / care / Settings / Quit).
|
|
7
|
+
* Voice: Ctrl+Alt+Space (anywhere) makes it listen; the page records and the server transcribes (/stt).
|
|
8
|
+
* With a purpose: when something needs you (the agent finished or waits, a reminder opened a site) it walks to
|
|
9
|
+
* that window (found with xdotool on X11; elsewhere it comes to the cursor) and points at it.
|
|
7
10
|
* SEVENOTS_PET_OPAQUE=1 paints a solid card instead of transparency (Linux setups without a compositor).
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
const path = require('node:path');
|
|
11
|
-
const {
|
|
14
|
+
const { execFile } = require('node:child_process');
|
|
15
|
+
const { app, BrowserWindow, Menu, Tray, globalShortcut, nativeImage, screen, session, shell } = require('electron');
|
|
12
16
|
|
|
13
17
|
const URL_ = process.env.SEVENOTS_PET_URL;
|
|
14
18
|
const TOKEN = process.env.SEVENOTS_PET_TOKEN;
|
|
@@ -16,16 +20,20 @@ const OPAQUE = process.env.SEVENOTS_PET_OPAQUE === '1';
|
|
|
16
20
|
const VIEW3D = process.env.SEVENOTS_PET_3D === '1';
|
|
17
21
|
const LANG = String(process.env.SEVENOTS_PET_LANG || 'en').slice(0, 2);
|
|
18
22
|
const ICON = path.join(__dirname, '..', '..', '..', 'brand', 'icon-512.png');
|
|
19
|
-
const
|
|
20
|
-
const
|
|
23
|
+
const BIG = { w: 240, h: 270 }; // with room for the bubble, the buttons and the talk box
|
|
24
|
+
const SMALL = { w: 160, h: 164 }; // just the ot
|
|
25
|
+
// The smallest the window gets: the ot with room for its hands, a hop and small effects. Every resize of a
|
|
26
|
+
// transparent window flickers (and in 3D rescales the canvas), so it stays at least this big; click-through
|
|
27
|
+
// keeps the empty parts out of the way.
|
|
28
|
+
const MIN = { w: 288, h: 272 };
|
|
21
29
|
const linux = process.platform === 'linux';
|
|
22
30
|
|
|
23
31
|
const L = {
|
|
24
|
-
en: { settings: '⚙️ Settings', talk: '💬 Talk', show: 'Show pet', hide: 'Hide (keeps living)', feed: '🍎
|
|
25
|
-
es: { settings: '⚙️ Configuración', talk: '💬 Hablar', show: 'Mostrar mascota', hide: 'Ocultar (sigue viva)', feed: '🍎
|
|
26
|
-
pt: { settings: '⚙️ Configurações', talk: '💬 Falar', show: 'Mostrar bichinho', hide: 'Esconder (segue vivo)', feed: '🍎
|
|
32
|
+
en: { settings: '⚙️ Settings', talk: '💬 Talk', listen: '🎙️ Listen (Ctrl+Alt+Space)', show: 'Show pet', hide: 'Hide (keeps living)', feed: '🍎 Treat', play: '🎾 Play', sleep: '💤 Sleep', wake: '☀️ Wake up', still: 'Stay still', quit: 'Quit pet' },
|
|
33
|
+
es: { settings: '⚙️ Configuración', talk: '💬 Hablar', listen: '🎙️ Escúchame (Ctrl+Alt+Espacio)', show: 'Mostrar mascota', hide: 'Ocultar (sigue viva)', feed: '🍎 Mimar', play: '🎾 Jugar', sleep: '💤 Dormir', wake: '☀️ Despertar', still: 'Quedarse quieta', quit: 'Cerrar mascota' },
|
|
34
|
+
pt: { settings: '⚙️ Configurações', talk: '💬 Falar', listen: '🎙️ Me escute (Ctrl+Alt+Espaço)', show: 'Mostrar bichinho', hide: 'Esconder (segue vivo)', feed: '🍎 Mimar', play: '🎾 Brincar', sleep: '💤 Dormir', wake: '☀️ Acordar', still: 'Ficar parado', quit: 'Fechar bichinho' },
|
|
27
35
|
}[LANG] || {};
|
|
28
|
-
const tr = (k) => L[k] || { show: 'Show pet', hide: 'Hide', talk: 'Talk', settings: 'Settings', feed: 'Feed', play: 'Play', sleep: 'Sleep', wake: 'Wake up', still: 'Stay still', quit: 'Quit pet' }[k];
|
|
36
|
+
const tr = (k) => L[k] || { listen: 'Listen', show: 'Show pet', hide: 'Hide', talk: 'Talk', settings: 'Settings', feed: 'Feed', play: 'Play', sleep: 'Sleep', wake: 'Wake up', still: 'Stay still', quit: 'Quit pet' }[k];
|
|
29
37
|
|
|
30
38
|
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); // it talks without a click
|
|
31
39
|
if (linux && !OPAQUE) {
|
|
@@ -39,13 +47,14 @@ const post = (p, body) =>
|
|
|
39
47
|
|
|
40
48
|
app.whenReady().then(() => setTimeout(start, linux ? 400 : 0)); // Linux: the compositor needs a moment
|
|
41
49
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
// Every ot on the desktop: the one that lives here ('host') and visitors from the same account.
|
|
51
|
+
const DEBUG = !!process.env.SEVENOTS_PET_DEBUG;
|
|
52
|
+
const ots = new Map();
|
|
53
|
+
let tray = null;
|
|
54
|
+
let relaunching = false; // a 2D → 3D switch that needs the GPU (see setView)
|
|
46
55
|
|
|
56
|
+
function start() {
|
|
47
57
|
// Tray first: if there is one, the window can stay out of the taskbar; if not, it shows there so it can be found.
|
|
48
|
-
let tray = null;
|
|
49
58
|
try {
|
|
50
59
|
const img = nativeImage.createFromPath(ICON);
|
|
51
60
|
if (!img.isEmpty()) {
|
|
@@ -53,15 +62,79 @@ function start() {
|
|
|
53
62
|
tray.setToolTip('7ots pet');
|
|
54
63
|
}
|
|
55
64
|
} catch {}
|
|
65
|
+
ots.set('host', makeOt({ host: true }));
|
|
66
|
+
// Visitors come and go from the settings (Account → invite): polled, a window each.
|
|
67
|
+
const syncVisitors = async () => {
|
|
68
|
+
const list = await fetch(new URL('/visitors', URL_), { headers: { 'X-7ots-Token': TOKEN } }).then((r) => (r.ok ? r.json() : null), () => null);
|
|
69
|
+
if (!Array.isArray(list)) return;
|
|
70
|
+
const want = new Set(list.map((v) => v.id));
|
|
71
|
+
for (const [k, o] of ots) if (k !== 'host' && !want.has(k)) (o.close(), ots.delete(k));
|
|
72
|
+
list.forEach((v, i) => ots.has(v.id) || ((!v.url || v.token) && ots.set(v.id, makeOt({ id: v.id, name: v.name, index: i, url: v.url, token: v.token }))));
|
|
73
|
+
};
|
|
74
|
+
setTimeout(syncVisitors, 2500);
|
|
75
|
+
setInterval(syncVisitors, 4000);
|
|
76
|
+
require('./director.cjs').createDirector({ ots, lang: LANG });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Settings in a window of our own (not a browser tab); links elsewhere open in the browser.
|
|
80
|
+
const settingsWins = new Map(); // one per ot that has its own daemon
|
|
81
|
+
function openSettings(base = URL_, tok = TOKEN) {
|
|
82
|
+
let settingsWin = settingsWins.get(base);
|
|
83
|
+
if (settingsWin && !settingsWin.isDestroyed()) return settingsWin.show(), settingsWin.focus();
|
|
84
|
+
settingsWin = new BrowserWindow({ width: 1120, height: 820, minWidth: 420, minHeight: 480, title: '7ots', icon: ICON, autoHideMenuBar: true, backgroundColor: '#0f0d14', show: false });
|
|
85
|
+
settingsWin.removeMenu?.();
|
|
86
|
+
settingsWins.set(base, settingsWin);
|
|
87
|
+
settingsWin.loadURL(`${base}/settings?token=${encodeURIComponent(tok)}`);
|
|
88
|
+
settingsWin.once('ready-to-show', () => settingsWin.show());
|
|
89
|
+
const ours = (u) => String(u || '').startsWith(base);
|
|
90
|
+
settingsWin.webContents.setWindowOpenHandler(({ url }) => {
|
|
91
|
+
if (!ours(url)) return shell.openExternal(url).catch(() => {}), { action: 'deny' };
|
|
92
|
+
return { action: 'allow', overrideBrowserWindow: { width: 760, height: 900, icon: ICON, autoHideMenuBar: true, backgroundColor: '#0f0d14' } }; // e.g. an ot's card
|
|
93
|
+
});
|
|
94
|
+
settingsWin.webContents.on('will-navigate', (e, url) => ours(url) || (e.preventDefault(), shell.openExternal(url).catch(() => {})));
|
|
95
|
+
settingsWin.on('closed', () => settingsWins.delete(base));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function makeOt(opt = {}) {
|
|
99
|
+
const HOST = !!opt.host;
|
|
100
|
+
// a second ot with everything (its own daemon: brain, voice, memory, settings) or a visitor drawn by the host's
|
|
101
|
+
const base = opt.url || URL_;
|
|
102
|
+
const tok = opt.token || TOKEN;
|
|
103
|
+
const FULL = HOST || !!opt.url;
|
|
104
|
+
const postOt = (p, body) =>
|
|
105
|
+
fetch(new URL(p, base), { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-7ots-Token': tok }, body: JSON.stringify(body || {}) }).catch(() => {});
|
|
106
|
+
const area = screen.getPrimaryDisplay().workArea;
|
|
107
|
+
const right = area.x + area.width;
|
|
108
|
+
const floor = area.y + area.height;
|
|
109
|
+
|
|
110
|
+
// The ot lives at an anchor (bottom-center of the window). The window is sized to what is in use:
|
|
111
|
+
// SMALL is just the body; BIG (room for the bubble, the buttons and the talk box) only while needed.
|
|
112
|
+
// Home: where it lives on screen. By default the bottom-right corner; wherever you drop it, after a drag.
|
|
113
|
+
const HOME_FILE = path.join(require('node:os').homedir(), '.7ots', HOST ? 'pet-home.json' : `pet-home-${opt.id}.json`);
|
|
114
|
+
let home = null;
|
|
115
|
+
try {
|
|
116
|
+
const h = JSON.parse(require('node:fs').readFileSync(HOME_FILE, 'utf8'));
|
|
117
|
+
if (h.x >= area.x && h.x <= right && h.y >= area.y + 80 && h.y <= floor) home = { x: h.x, y: h.y };
|
|
118
|
+
} catch {}
|
|
119
|
+
let homeSave = null;
|
|
120
|
+
const setHome = (p) => {
|
|
121
|
+
home = { x: Math.round(p.x), y: Math.round(p.y) };
|
|
122
|
+
clearTimeout(homeSave);
|
|
123
|
+
homeSave = setTimeout(() => require('node:fs').promises.writeFile(HOME_FILE, JSON.stringify(home)).catch(() => {}), 800);
|
|
124
|
+
};
|
|
125
|
+
// a visitor turns up beside the host, a bit to the left
|
|
126
|
+
const hostHome = () => ots.get('host')?.home() || { x: right - BIG.w / 2 - 24, y: floor };
|
|
127
|
+
const homeAnchor = () => (home ? { ...home } : HOST ? { x: right - BIG.w / 2 - 24, y: floor } : { x: Math.max(area.x + 140, hostHome().x - 230 * ((opt.index || 0) + 1)), y: hostHome().y });
|
|
128
|
+
if (!HOST && !home) home = homeAnchor(); // a visitor roams beside the host, at its height (saved once dragged)
|
|
129
|
+
const pos = homeAnchor();
|
|
130
|
+
let size = BIG; // it says hi on start
|
|
131
|
+
const bounds = (s = size, a = pos, lift = 0) => ({ x: Math.round(a.x - s.w / 2), y: Math.round(a.y - s.h - lift), width: s.w, height: s.h });
|
|
56
132
|
|
|
57
133
|
const win = new BrowserWindow({
|
|
58
|
-
|
|
59
|
-
height: H,
|
|
60
|
-
x,
|
|
61
|
-
y,
|
|
134
|
+
...bounds(),
|
|
62
135
|
transparent: !OPAQUE,
|
|
63
136
|
frame: false,
|
|
64
|
-
resizable:
|
|
137
|
+
resizable: true, // resized by code only; frameless, so no edges to grab
|
|
65
138
|
hasShadow: OPAQUE,
|
|
66
139
|
alwaysOnTop: true,
|
|
67
140
|
skipTaskbar: !!tray,
|
|
@@ -72,39 +145,73 @@ function start() {
|
|
|
72
145
|
});
|
|
73
146
|
win.setAlwaysOnTop(true, 'floating');
|
|
74
147
|
win.setVisibleOnAllWorkspaces?.(true);
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
// The page's ⚙️ button opens the settings
|
|
148
|
+
let view3d = VIEW3D; // switched live from the settings (see setView)
|
|
149
|
+
const petURL = () => `${base}/pet?desktop=1${FULL ? '' : `&guest=${encodeURIComponent(opt.id)}`}${OPAQUE ? '&opaque=1' : ''}${view3d ? '&3d=1' : ''}&token=${encodeURIComponent(tok)}`;
|
|
150
|
+
win.loadURL(petURL());
|
|
151
|
+
// The page's ⚙️ button opens the settings window; anything else is denied.
|
|
79
152
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
|
80
|
-
if (url.startsWith(`${
|
|
153
|
+
if (url.startsWith(`${base}/settings`)) openSettings(base, tok);
|
|
81
154
|
return { action: 'deny' };
|
|
82
155
|
});
|
|
83
|
-
|
|
156
|
+
// Only our own pet page may load (its reload after a settings change or another ot: Electron routes
|
|
157
|
+
// location.reload() through will-navigate too, so blocking everything froze the old look until a restart).
|
|
158
|
+
win.webContents.on('will-navigate', (e, url) => String(url || e?.url || '').startsWith(`${base}/pet?`) || e.preventDefault());
|
|
159
|
+
win.webContents.on('console-message', (e, level, msg) => /\[7ots/.test(msg ?? e?.message) && console.log(msg ?? e?.message)); // to ~/.7ots/electron.log
|
|
160
|
+
// The microphone, only for our own page (voice control).
|
|
161
|
+
const ours = (u) => /^http:\/\/127\.0\.0\.1:\d+\//.test(String(u || '')); // our daemons
|
|
162
|
+
session.defaultSession.setPermissionRequestHandler((wc, perm, cb, d) => cb(perm === 'media' && ours(d?.requestingUrl || wc.getURL())));
|
|
163
|
+
session.defaultSession.setPermissionCheckHandler((wc, perm, origin) => perm === 'media' && ours(origin));
|
|
164
|
+
const alive = () => !win.isDestroyed();
|
|
165
|
+
const page = (js) => alive() && win.webContents.executeJavaScript(js).catch(() => {});
|
|
84
166
|
|
|
85
167
|
let still = false;
|
|
168
|
+
let lastBounds = null;
|
|
169
|
+
const place = (lift = 0) => {
|
|
170
|
+
const b = bounds(size, pos, lift);
|
|
171
|
+
if (lastBounds && b.x === lastBounds.x && b.y === lastBounds.y && b.width === lastBounds.width && b.height === lastBounds.height) return;
|
|
172
|
+
lastBounds = b;
|
|
173
|
+
win.setBounds(b);
|
|
174
|
+
};
|
|
86
175
|
const show = () => {
|
|
87
|
-
|
|
88
|
-
|
|
176
|
+
Object.assign(pos, homeAnchor());
|
|
177
|
+
place();
|
|
89
178
|
win.showInactive();
|
|
90
179
|
win.moveTop();
|
|
91
180
|
};
|
|
181
|
+
const dismiss = () => post('/account/visitor', { id: opt.id, on: false }).finally(() => close());
|
|
182
|
+
const guestMenu = () =>
|
|
183
|
+
Menu.buildFromTemplate([
|
|
184
|
+
{ label: `${opt.name || opt.id}`, enabled: false },
|
|
185
|
+
{ type: 'separator' },
|
|
186
|
+
{ label: { es: '👋 Despedir', pt: '👋 Mandar embora' }[LANG] || '👋 Send home', click: dismiss },
|
|
187
|
+
{ label: tr('settings'), click: () => openSettings(base, tok) },
|
|
188
|
+
]);
|
|
92
189
|
const menu = () =>
|
|
93
190
|
Menu.buildFromTemplate([
|
|
94
191
|
win.isVisible() ? { label: tr('hide'), click: () => win.hide() } : { label: tr('show'), click: show },
|
|
95
192
|
{ type: 'separator' },
|
|
96
|
-
{ label: tr('talk'), click: () => { show(); win.focus();
|
|
97
|
-
{ label: tr('
|
|
98
|
-
{ label: tr('
|
|
99
|
-
{ label: tr('sleep'), click: () =>
|
|
100
|
-
{ label: tr('wake'), click: () =>
|
|
193
|
+
{ label: tr('talk'), click: () => { show(); win.focus(); page('postMessage({ talk: true })'); } },
|
|
194
|
+
{ label: tr('listen'), click: listen },
|
|
195
|
+
{ label: tr('feed'), click: () => postOt('/event', { type: 'feed' }) },
|
|
196
|
+
{ label: tr('sleep'), click: () => postOt('/event', { type: 'sleep' }) },
|
|
197
|
+
{ label: tr('wake'), click: () => postOt('/event', { type: 'wake' }) },
|
|
101
198
|
{ type: 'separator' },
|
|
102
|
-
{ label: tr('settings'), click: openSettings },
|
|
199
|
+
{ label: tr('settings'), click: () => openSettings(base, tok) },
|
|
103
200
|
{ label: tr('still'), type: 'checkbox', checked: still, click: (i) => (still = i.checked) },
|
|
104
|
-
{ label: tr('quit'), click: () => post('/quit').finally(() => app.quit()) },
|
|
201
|
+
HOST ? { label: tr('quit'), click: () => post('/quit').finally(() => app.quit()) } : { label: { es: '👋 Despedir', pt: '👋 Mandar embora' }[LANG] || '👋 Send home', click: dismiss },
|
|
105
202
|
]);
|
|
106
|
-
|
|
107
|
-
|
|
203
|
+
function listen() {
|
|
204
|
+
if (!win.isVisible()) win.showInactive();
|
|
205
|
+
page('postMessage({ listen: true })');
|
|
206
|
+
}
|
|
207
|
+
if (HOST) {
|
|
208
|
+
try {
|
|
209
|
+
globalShortcut.register('CommandOrControl+Alt+Space', listen);
|
|
210
|
+
} catch {}
|
|
211
|
+
app.on('will-quit', () => globalShortcut.unregisterAll());
|
|
212
|
+
}
|
|
213
|
+
win.webContents.on('context-menu', () => (FULL ? menu() : guestMenu()).popup({ window: win }));
|
|
214
|
+
if (HOST && tray) {
|
|
108
215
|
const refresh = () => tray.setContextMenu(menu()); // Linux trays only show the context menu
|
|
109
216
|
refresh();
|
|
110
217
|
win.on('show', refresh);
|
|
@@ -112,38 +219,387 @@ function start() {
|
|
|
112
219
|
tray.on('click', () => (win.isVisible() ? win.hide() : show()));
|
|
113
220
|
}
|
|
114
221
|
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
222
|
+
// The user dragged it: that spot becomes its home (it roams around it, and comes back there after errands),
|
|
223
|
+
// remembered across restarts. Linux only emits 'move' (not 'moved'): our own setBounds also emits it, so
|
|
224
|
+
// only a position that differs from the last one we set counts as a drag.
|
|
118
225
|
let dragged = 0;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
226
|
+
const onMove = () => {
|
|
227
|
+
if (!alive() || !lastBounds) return;
|
|
228
|
+
const [wx, wy] = win.getPosition();
|
|
229
|
+
if (Math.abs(wx - lastBounds.x) < 3 && Math.abs(wy - lastBounds.y) < 3) return;
|
|
230
|
+
dragged = Date.now();
|
|
231
|
+
droppedSaid = false;
|
|
232
|
+
pos.x = wx + size.w / 2;
|
|
233
|
+
pos.y = wy + size.h;
|
|
234
|
+
lastBounds = { ...lastBounds, x: wx, y: wy };
|
|
235
|
+
plan = { mode: 'idle', until: Date.now() + 6000 };
|
|
236
|
+
setHome(pos);
|
|
237
|
+
};
|
|
238
|
+
win.on('move', onMove);
|
|
239
|
+
// Our own drag (the page has no drag region, so clicks reach it): the window follows the cursor while the
|
|
240
|
+
// button is down on the ot. Past 4 px it is a drag: the drop spot becomes home, as with a system drag.
|
|
241
|
+
let grabbing = null;
|
|
242
|
+
let grabTimer = null;
|
|
243
|
+
function grab(on) {
|
|
244
|
+
clearInterval(grabTimer);
|
|
245
|
+
if (!on) {
|
|
246
|
+
if (grabbing?.moved) (setHome(pos), (droppedSaid = false), (plan = { mode: 'idle', until: Date.now() + 6000 }));
|
|
247
|
+
grabbing = null;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const c = screen.getCursorScreenPoint();
|
|
251
|
+
grabbing = { dx: pos.x - c.x, dy: pos.y - c.y, x: c.x, y: c.y, moved: false };
|
|
252
|
+
grabTimer = setInterval(() => {
|
|
253
|
+
if (!alive() || !grabbing) return clearInterval(grabTimer);
|
|
254
|
+
const p = screen.getCursorScreenPoint();
|
|
255
|
+
if (!grabbing.moved && Math.hypot(p.x - grabbing.x, p.y - grabbing.y) < 4) return;
|
|
256
|
+
grabbing.moved = true;
|
|
257
|
+
dragged = Date.now();
|
|
258
|
+
pos.x = Math.max(area.x + size.w / 2 - 40, Math.min(right - size.w / 2 + 40, p.x + grabbing.dx));
|
|
259
|
+
pos.y = Math.max(area.y + size.h - 40, Math.min(floor + 40, p.y + grabbing.dy));
|
|
260
|
+
place();
|
|
261
|
+
}, 16);
|
|
262
|
+
}
|
|
263
|
+
win.on('moved', onMove);
|
|
264
|
+
|
|
265
|
+
// ── What the page is showing (polled): hit areas for click-through, and whether it needs the big window ──
|
|
266
|
+
let rects = [];
|
|
267
|
+
let need = null; // what the page really shows: { w, h } (bubble, effects around the body, talk box)
|
|
268
|
+
let busy = false; // bubble or talk box on screen
|
|
269
|
+
let asleep = false; // asleep it stays put (an urgent stir wakes it for a moment, see pet.html)
|
|
270
|
+
const HIT = `(() => {
|
|
271
|
+
const out = [];
|
|
272
|
+
for (const e of document.querySelectorAll('#stage, .actions, .ask')) {
|
|
273
|
+
const cs = getComputedStyle(e);
|
|
274
|
+
// the menu counts while faded out too: reaching for it must not let the mouse through (and hide it)
|
|
275
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || (+cs.opacity < 0.3 && !e.classList.contains('actions'))) continue;
|
|
276
|
+
const r = e.getBoundingClientRect();
|
|
277
|
+
if (r.width && r.height) out.push([r.left, r.top, r.right, r.bottom]);
|
|
278
|
+
}
|
|
279
|
+
const goal = window.__goal || null;
|
|
280
|
+
window.__goal = null;
|
|
281
|
+
return { rects: out, goal, need: window.__need?.(), busy: document.body.classList.contains('talking') || document.body.classList.contains('listening') || !!document.querySelector('.bubble.on'), asleep: document.body.classList.contains('asleep') };
|
|
282
|
+
})()`;
|
|
283
|
+
const hitTimer = setInterval(() => {
|
|
284
|
+
if (!alive()) return;
|
|
285
|
+
win.webContents.executeJavaScript(HIT).then((r) => {
|
|
286
|
+
({ rects, busy, asleep } = r);
|
|
287
|
+
need = r.need || null;
|
|
288
|
+
if (r.goal) goTo(r.goal);
|
|
289
|
+
}).catch(() => {});
|
|
290
|
+
}, 120);
|
|
291
|
+
|
|
292
|
+
// Click-through: only the ot, its buttons and the talk box take the mouse; the transparent rest lets
|
|
293
|
+
// clicks reach the window behind. The cursor is polled because `forward: true` is macOS/Windows only.
|
|
294
|
+
let passing = null;
|
|
295
|
+
let hoverUntil = 0;
|
|
296
|
+
let stillUntil = 0;
|
|
297
|
+
const pass = (p) => {
|
|
298
|
+
if (p === passing || !alive()) return;
|
|
299
|
+
passing = p;
|
|
300
|
+
win.setIgnoreMouseEvents(p, { forward: true });
|
|
301
|
+
};
|
|
302
|
+
let cursorNear = false;
|
|
303
|
+
let hovered = false;
|
|
304
|
+
let lastLook = null;
|
|
305
|
+
let shrinkAt = 0;
|
|
306
|
+
let shrinkTo = null;
|
|
307
|
+
let droppedSaid = true;
|
|
308
|
+
let dbgT = 0;
|
|
309
|
+
const mouseTimer = setInterval(() => {
|
|
310
|
+
if (!alive() || !win.isVisible()) return;
|
|
311
|
+
const c = screen.getCursorScreenPoint();
|
|
312
|
+
const [wx, wy] = win.getPosition();
|
|
313
|
+
const x = c.x - wx;
|
|
314
|
+
const y = c.y - wy;
|
|
315
|
+
const over = rects.some(([l, t, r, b]) => x >= l - 14 && x <= r + 14 && y >= t - 14 && y <= b + 14);
|
|
316
|
+
if (over) hoverUntil = Date.now() + 900;
|
|
317
|
+
// anywhere over its window (not only the drawn parts): it stays put so it can be clicked or dragged
|
|
318
|
+
if (x >= 0 && y >= 0 && x <= size.w && y <= size.h) stillUntil = Date.now() + 1200;
|
|
319
|
+
// the eyes follow the cursor anywhere on screen (the page only sees it inside the window)
|
|
320
|
+
if (!lastLook || Math.abs(x - lastLook[0]) + Math.abs(y - lastLook[1]) > 6) {
|
|
321
|
+
lastLook = [x, y];
|
|
322
|
+
page(`postMessage({ look: [${x}, ${y}] })`);
|
|
323
|
+
}
|
|
324
|
+
// dropped after a drag: a little dizzy
|
|
325
|
+
if (dragged && !droppedSaid && Date.now() - dragged > 350) {
|
|
326
|
+
droppedSaid = true;
|
|
327
|
+
page('postMessage({ dropped: true })');
|
|
328
|
+
}
|
|
329
|
+
cursorNear = Math.hypot(c.x - pos.x, c.y - (pos.y - SMALL.h / 2)) < 260;
|
|
330
|
+
if (DEBUG && Date.now() - (dbgT || 0) > 1000) (dbgT = Date.now()), console.log('[7ots dbg]', HOST ? 'host' : opt.id, 'cur', x, y, 'over', over, 'busy', busy, 'pass', passing, 'rects', JSON.stringify(rects.map((q) => q.map(Math.round))), 'size', size.w, size.h, 'win', wx, wy);
|
|
331
|
+
pass(!(over || busy || grabbing) && rects.length > 0); // while dragged it keeps the mouse (the button-up must reach the page)
|
|
332
|
+
// the menu shows on hover: CSS :hover alone misses it when the window starts taking the mouse (or resizes)
|
|
333
|
+
// under a cursor that has not moved since
|
|
334
|
+
const h = Date.now() < hoverUntil;
|
|
335
|
+
if (h !== hovered) (hovered = h), page(`document.body.classList.toggle('hover', ${h})`);
|
|
336
|
+
resize();
|
|
337
|
+
}, 60);
|
|
338
|
+
// testing: SEVENOTS_PET_FX=thought plays that effect every 5 s
|
|
339
|
+
if (process.env.SEVENOTS_PET_FX) setInterval(() => page(`postMessage(${JSON.stringify({ effect: process.env.SEVENOTS_PET_FX })})`), 5000);
|
|
340
|
+
let held = null; // a scene holds one size from start to end (see director.cjs)
|
|
341
|
+
function resize() {
|
|
342
|
+
// Size: what the page shows (the bubble's real height, room for effects), big enough for the buttons on hover.
|
|
343
|
+
// Grows at once, shrinks only after it has wanted less for 2.5 s: an animation that swings the arms in and
|
|
344
|
+
// out would otherwise resize the window on every swing, and each resize of a transparent window flickers.
|
|
345
|
+
// 16 px steps to avoid resizing for every pixel.
|
|
346
|
+
const hover = Date.now() < hoverUntil;
|
|
347
|
+
const step = (v) => Math.ceil(v / 16) * 16;
|
|
348
|
+
const want = {
|
|
349
|
+
w: step(Math.min(area.width, Math.max(MIN.w, need?.w || 0, hover ? BIG.w : 0, held?.w || 0))),
|
|
350
|
+
h: step(Math.min(area.height, Math.max(MIN.h, need?.h || 0, hover ? BIG.h : 0, held?.h || 0))),
|
|
351
|
+
};
|
|
352
|
+
const grow = want.w > size.w || want.h > size.h;
|
|
353
|
+
const differs = want.w !== size.w || want.h !== size.h;
|
|
354
|
+
if (!differs) (shrinkAt = 0), (shrinkTo = null);
|
|
355
|
+
else if (!grow) shrinkTo = shrinkTo ? { w: Math.max(shrinkTo.w, want.w), h: Math.max(shrinkTo.h, want.h) } : want; // the most it wanted while waiting
|
|
356
|
+
if (differs && Date.now() - dragged > 400 && (grow || (!held && (shrinkAt ||= Date.now() + 6000) < Date.now()))) {
|
|
357
|
+
size = grow ? { w: Math.max(want.w, size.w), h: Math.max(want.h, size.h) } : shrinkTo || want;
|
|
358
|
+
shrinkAt = 0;
|
|
359
|
+
shrinkTo = null;
|
|
360
|
+
place();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// 2D ↔ 3D from the settings. A 2D start on Linux turned the GPU off (see the top), and WebGL cannot come
|
|
364
|
+
// back without it: then the whole shell relaunches as 3D (the daemons live on; visitors reopen from /visitors).
|
|
365
|
+
function setView(on) {
|
|
366
|
+
if (on === view3d) return;
|
|
367
|
+
if (on && linux && !OPAQUE && !VIEW3D) {
|
|
368
|
+
if (relaunching) return;
|
|
369
|
+
relaunching = true;
|
|
370
|
+
process.env.SEVENOTS_PET_3D = '1';
|
|
371
|
+
app.relaunch();
|
|
372
|
+
return app.exit(0);
|
|
373
|
+
}
|
|
374
|
+
view3d = on;
|
|
375
|
+
if (alive()) win.loadURL(petURL());
|
|
376
|
+
}
|
|
377
|
+
// An effect just started: the page says how much room it needs right away (see tellNeed in pet.html).
|
|
378
|
+
win.webContents.on('console-message', (e, level, msg) => {
|
|
379
|
+
const m = String(e?.message ?? msg ?? '');
|
|
380
|
+
if (m.startsWith('\u00a77drag')) return grab(JSON.parse(m.slice(6)).on);
|
|
381
|
+
if (m.startsWith('\u00a77bye')) return HOST || dismiss();
|
|
382
|
+
if (m.startsWith('\u00a77view')) return setView(m.slice(6) === '3d');
|
|
383
|
+
if (!m.startsWith('\u00a77need')) return;
|
|
384
|
+
try {
|
|
385
|
+
need = JSON.parse(m.slice(6));
|
|
386
|
+
if (alive() && win.isVisible()) resize();
|
|
387
|
+
} catch {}
|
|
122
388
|
});
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
389
|
+
|
|
390
|
+
// ── Movement: steering with ease-in/ease-out, a little gait bounce, hops, pauses, curiosity ──
|
|
391
|
+
const rnd = (a, b) => a + Math.random() * (b - a);
|
|
392
|
+
const BAND = 200; // how far above the taskbar it roams
|
|
393
|
+
const minX = area.x + 136; // half the widest window (bubble + effects) stays on screen
|
|
394
|
+
const maxX = right - 136;
|
|
395
|
+
const homeY = () => home?.y ?? floor; // it roams in a band above its home's floor
|
|
396
|
+
const clampY = (y) => Math.max(homeY() - BAND, Math.min(homeY(), y));
|
|
397
|
+
const vel = { x: 0, y: 0 };
|
|
398
|
+
let plan = { mode: 'idle', until: Date.now() + 2500 };
|
|
399
|
+
let gait = 0;
|
|
400
|
+
let facing = 0; // what the page last heard: -1..1 (3D yaw follows it smoothly)
|
|
401
|
+
let lastT = Date.now();
|
|
402
|
+
|
|
403
|
+
function next() {
|
|
404
|
+
const now = Date.now();
|
|
405
|
+
const r = Math.random();
|
|
406
|
+
if (r < 0.38) return { mode: 'idle', until: now + rnd(1800, 7000) };
|
|
407
|
+
if (r < 0.48) return { mode: 'hop', start: now, hops: 1 + Math.floor(Math.random() * 3), then: { mode: 'idle', until: now + 2500 } };
|
|
408
|
+
if (r < 0.6 && cursorNear) return { mode: 'follow', until: now + rnd(3000, 6000), speed: rnd(70, 110) };
|
|
409
|
+
// a stroll: sometimes short and lazy, sometimes across the screen
|
|
410
|
+
const far = Math.random() < 0.3;
|
|
411
|
+
const dx = (far ? rnd(300, 900) : rnd(60, 260)) * (Math.random() < 0.5 ? -1 : 1);
|
|
412
|
+
// ...but never wanders far from home: past 320 px it heads back toward it
|
|
413
|
+
const hx = home?.x ?? pos.x;
|
|
414
|
+
const tx = Math.max(minX, Math.min(maxX, Math.abs(pos.x + dx - hx) > 320 ? hx + rnd(-120, 120) : pos.x + dx));
|
|
415
|
+
return { mode: 'go', tx, ty: clampY(pos.y + rnd(-60, 60)), speed: far ? rnd(110, 170) : rnd(45, 95) };
|
|
416
|
+
}
|
|
417
|
+
|
|
130
418
|
const stepTimer = setInterval(() => {
|
|
131
419
|
// isDestroyed first: once the window is gone, isVisible() throws ("Object has been destroyed").
|
|
132
|
-
if (!alive() || !
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
420
|
+
if (!alive() || !win.isVisible()) return;
|
|
421
|
+
const now = Date.now();
|
|
422
|
+
const dt = Math.min(0.05, (now - lastT) / 1000);
|
|
423
|
+
lastT = now;
|
|
424
|
+
const errand = plan.mode === 'point';
|
|
425
|
+
const scene = plan.mode === 'scene' || plan.mode === 'fling';
|
|
426
|
+
// the cursor on it stops it, scenes too (the director sees interrupted() and ends the scene): something
|
|
427
|
+
// that walks away from the mouse can't be grabbed or clicked
|
|
428
|
+
if (now - dragged < 6000 || now < stillUntil || (!errand && now < hoverUntil) || (!scene && (still || (!errand && (busy || asleep))))) {
|
|
429
|
+
// stop gently
|
|
430
|
+
vel.x *= 0.8;
|
|
431
|
+
vel.y *= 0.8;
|
|
432
|
+
if (Math.abs(vel.x) < 1 && facing !== 0 && plan.mode !== 'show') tellFacing(0); // keeps facing what it points at
|
|
138
433
|
return;
|
|
139
434
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
435
|
+
|
|
436
|
+
let lift = 0;
|
|
437
|
+
let tx = pos.x;
|
|
438
|
+
let ty = pos.y;
|
|
439
|
+
let speed = 0;
|
|
440
|
+
if (plan.mode === 'show' && now > plan.until) plan = { mode: 'idle', until: now + rnd(2000, 5000) };
|
|
441
|
+
if (plan.mode === 'idle' && now > plan.until) {
|
|
442
|
+
plan = next();
|
|
443
|
+
if (plan.mode === 'follow') page('postMessage({ follow: true })');
|
|
444
|
+
}
|
|
445
|
+
if (plan.mode === 'go' || plan.mode === 'point' || plan.mode === 'scene') ({ tx, ty, speed } = plan);
|
|
446
|
+
else if (plan.mode === 'fling') {
|
|
447
|
+
// knocked back: an arc, sliding to a stop where it lands
|
|
448
|
+
const t = (now - plan.start) / plan.ms;
|
|
449
|
+
if (t >= 1) plan = { mode: 'scene', tx: pos.x, ty: pos.y, speed: 0, dir: plan.dir };
|
|
450
|
+
else {
|
|
451
|
+
pos.x = Math.max(minX, Math.min(maxX, pos.x + plan.vx * dt * (1 - t)));
|
|
452
|
+
lift = Math.sin(Math.PI * t) * plan.height;
|
|
453
|
+
vel.x = vel.y = 0;
|
|
454
|
+
tx = pos.x;
|
|
455
|
+
ty = pos.y;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
else if (plan.mode === 'follow') {
|
|
459
|
+
const c = screen.getCursorScreenPoint();
|
|
460
|
+
// stays a polite distance from the cursor, on the floor band
|
|
461
|
+
tx = Math.max(minX, Math.min(maxX, c.x + (c.x > pos.x ? -90 : 90)));
|
|
462
|
+
ty = clampY(c.y + SMALL.h / 2);
|
|
463
|
+
speed = plan.speed;
|
|
464
|
+
if (now > plan.until) plan = { mode: 'idle', until: now + rnd(1500, 4000) };
|
|
465
|
+
} else if (plan.mode === 'hop') {
|
|
466
|
+
const t = (now - plan.start) / 480;
|
|
467
|
+
if (t >= plan.hops) plan = plan.then;
|
|
468
|
+
else if (Math.floor(t) !== plan.said) (plan.said = Math.floor(t), page('postMessage({ hop: true })'));
|
|
469
|
+
if (plan.mode === 'hop') lift = Math.sin(Math.PI * (t % 1)) * 34 * (1 - Math.floor(t) * 0.25);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// arrive: full speed far away, easing to a stop in the last 140 px; velocity eases too (no jerks)
|
|
473
|
+
const dx = tx - pos.x;
|
|
474
|
+
const dy = ty - pos.y;
|
|
475
|
+
const dist = Math.hypot(dx, dy);
|
|
476
|
+
const want = speed * Math.min(1, dist / 140);
|
|
477
|
+
const vx = dist > 0.5 ? (dx / dist) * want : 0;
|
|
478
|
+
const vy = dist > 0.5 ? (dy / dist) * want : 0;
|
|
479
|
+
const k = Math.min(1, dt * 2.6);
|
|
480
|
+
vel.x += (vx - vel.x) * k;
|
|
481
|
+
vel.y += (vy - vel.y) * k;
|
|
482
|
+
pos.x = Math.max(minX, Math.min(maxX, pos.x + vel.x * dt));
|
|
483
|
+
pos.y = Math.max(minY, Math.min(floor, pos.y + vel.y * dt)); // roams near the floor, errands go anywhere
|
|
484
|
+
if (plan.mode === 'go' && dist < 3 && Math.hypot(vel.x, vel.y) < 6) plan = { mode: 'idle', until: now + rnd(1500, 6000) };
|
|
485
|
+
if (plan.mode === 'point' && ((dist < 4 && Math.hypot(vel.x, vel.y) < 8) || now > plan.until)) arrived(plan);
|
|
486
|
+
|
|
487
|
+
// gait: a small bounce per step, scaled by speed
|
|
488
|
+
const v = Math.hypot(vel.x, vel.y);
|
|
489
|
+
gait += dt * (4 + v / 18);
|
|
490
|
+
if (v > 4) lift += Math.abs(Math.sin(gait * Math.PI)) * Math.min(5, v / 22);
|
|
491
|
+
|
|
492
|
+
// facing: turn toward where it walks, back to the front when it stops
|
|
493
|
+
const f = v > 8 ? Math.max(-1, Math.min(1, vel.x / 70)) : plan.mode === 'show' || scene ? plan.dir || 0 : 0;
|
|
494
|
+
if (Math.abs(f - facing) > 0.15 || (f === 0 && facing !== 0)) tellFacing(Math.round(f * 10) / 10);
|
|
495
|
+
|
|
496
|
+
place(Math.round(lift));
|
|
497
|
+
}, 16);
|
|
498
|
+
|
|
499
|
+
// ── Errands: walk to what needs you and point at it ──
|
|
500
|
+
const minY = area.y + BIG.h;
|
|
501
|
+
const TERM = /terminal|kitty|alacritty|konsole|tilix|wezterm|xterm|ghostty|warp|terminator|code|cursor|windsurf|zed|jetbrains|idea|pycharm|webstorm/i;
|
|
502
|
+
const BROWSER = /chrom|firefox|brave|vivaldi|opera|edge|epiphany|librewolf|zen/i;
|
|
503
|
+
const run = (cmd, args) => new Promise((res) => execFile(cmd, args, { timeout: 1500 }, (e, out) => res(e ? '' : String(out))));
|
|
504
|
+
// The top-most visible window whose class matches, if it is not mostly covered by others (no use pointing
|
|
505
|
+
// at a terminal hidden behind the browser: then it comes to the cursor instead).
|
|
506
|
+
async function findWindow(re) {
|
|
507
|
+
if (!linux || !process.env.DISPLAY) return null;
|
|
508
|
+
const root = await run('xprop', ['-root', '_NET_CLIENT_LIST_STACKING', '_NET_CURRENT_DESKTOP']);
|
|
509
|
+
const desk = root.match(/_NET_CURRENT_DESKTOP\(CARDINAL\) = (\d+)/)?.[1];
|
|
510
|
+
const ids = (root.match(/0x[0-9a-f]+/gi) || []).reverse(); // bottom → top, reversed
|
|
511
|
+
const above = [];
|
|
512
|
+
for (const hex of ids) {
|
|
513
|
+
const id = String(parseInt(hex, 16));
|
|
514
|
+
const props = await run('xprop', ['-id', id, 'WM_CLASS', '_NET_WM_STATE', '_NET_WM_DESKTOP', '_NET_WM_WINDOW_TYPE']);
|
|
515
|
+
const cls = props.match(/WM_CLASS\(STRING\) = (.*)/)?.[1] || '';
|
|
516
|
+
const d = props.match(/_NET_WM_DESKTOP\(CARDINAL\) = (\d+)/)?.[1];
|
|
517
|
+
if (/HIDDEN/.test(props) || /7ots|electron/i.test(cls) || (d != null && desk != null && d !== desk && d !== '4294967295')) continue;
|
|
518
|
+
if (/_TYPE_(DOCK|DESKTOP)/.test(props)) continue;
|
|
519
|
+
const g = await run('xdotool', ['getwindowgeometry', '--shell', id]);
|
|
520
|
+
const n = (k) => Number(g.match(new RegExp(`^${k}=(-?\\d+)`, 'm'))?.[1]);
|
|
521
|
+
const r = { x: n('X'), y: n('Y'), w: n('WIDTH'), h: n('HEIGHT') };
|
|
522
|
+
if (!(r.w > 50)) continue;
|
|
523
|
+
if (re.test(cls)) {
|
|
524
|
+
// how much of it the windows above cover (approximate: sum of overlaps, capped)
|
|
525
|
+
let hid = 0;
|
|
526
|
+
for (const o of above) hid += Math.max(0, Math.min(r.x + r.w, o.x + o.w) - Math.max(r.x, o.x)) * Math.max(0, Math.min(r.y + r.h, o.y + o.h) - Math.max(r.y, o.y));
|
|
527
|
+
return hid / (r.w * r.h) > 0.6 ? null : r;
|
|
528
|
+
}
|
|
529
|
+
above.push(r);
|
|
530
|
+
}
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
async function goTo(goal) {
|
|
534
|
+
if (still) return;
|
|
535
|
+
if (goal === 'browser') await new Promise((r) => setTimeout(r, 900)); // let the site open first
|
|
536
|
+
const c = screen.getCursorScreenPoint();
|
|
537
|
+
const rect = (goal === 'agent' ? await findWindow(TERM) : goal === 'browser' ? await findWindow(BROWSER) : null) || { x: c.x - 1, y: c.y - 1, w: 2, h: 2, cursor: true };
|
|
538
|
+
const cx = rect.x + rect.w / 2;
|
|
539
|
+
// stand beside the window if there is room, otherwise inside it, near its lower corner closest to us
|
|
540
|
+
const half = SMALL.w / 2 + 12;
|
|
541
|
+
const roomL = rect.x - half >= minX;
|
|
542
|
+
const roomR = rect.x + rect.w + half <= maxX;
|
|
543
|
+
let tx;
|
|
544
|
+
if (rect.cursor) tx = c.x + (c.x > pos.x ? -110 : 110);
|
|
545
|
+
else if (roomL && (!roomR || pos.x < cx)) tx = rect.x - half;
|
|
546
|
+
else if (roomR) tx = rect.x + rect.w + half;
|
|
547
|
+
else tx = pos.x < cx ? rect.x + half + 20 : rect.x + rect.w - half - 20;
|
|
548
|
+
tx = Math.max(minX, Math.min(maxX, tx));
|
|
549
|
+
const ty = Math.max(minY, Math.min(floor, rect.cursor ? c.y + SMALL.h / 2 : Math.min(rect.y + rect.h, rect.y + rect.h * 0.75 + SMALL.h / 2)));
|
|
550
|
+
const spot = rect.cursor ? c : { x: cx, y: rect.y + Math.min(rect.h / 2, 160) };
|
|
551
|
+
plan = { mode: 'point', tx, ty, speed: 260, until: Date.now() + 9000, spot };
|
|
552
|
+
page('postMessage({ errand: true })');
|
|
553
|
+
}
|
|
554
|
+
function arrived(p) {
|
|
555
|
+
const dir = p.spot.x >= pos.x ? 1 : -1;
|
|
556
|
+
plan = { mode: 'show', dir, until: Date.now() + 5000 };
|
|
557
|
+
tellFacing(dir);
|
|
558
|
+
const [wx, wy] = win.getPosition();
|
|
559
|
+
page(`postMessage({ point: ${dir}, at: [${Math.round(p.spot.x - wx)}, ${Math.round(p.spot.y - wy)}] })`);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function tellFacing(f) {
|
|
563
|
+
facing = f;
|
|
564
|
+
page(`postMessage({ walk: ${f} })`);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function close() {
|
|
568
|
+
if (alive()) win.destroy();
|
|
569
|
+
}
|
|
143
570
|
win.on('closed', () => {
|
|
144
|
-
clearInterval(walkTimer);
|
|
145
571
|
clearInterval(stepTimer);
|
|
572
|
+
clearInterval(hitTimer);
|
|
573
|
+
clearInterval(mouseTimer);
|
|
146
574
|
});
|
|
575
|
+
|
|
576
|
+
// ── What the director (scenes between ots, see director.cjs) can do with this one ──
|
|
577
|
+
return {
|
|
578
|
+
id: HOST ? 'host' : opt.id,
|
|
579
|
+
host: HOST,
|
|
580
|
+
home: () => homeAnchor(),
|
|
581
|
+
pos: () => ({ ...pos }),
|
|
582
|
+
bounds: () => ({ minX, maxX, floor }),
|
|
583
|
+
close,
|
|
584
|
+
// free to join a scene: shown, loaded, not held still, dragged, hovered, talking or on an errand
|
|
585
|
+
free: () => alive() && win.isVisible() && !win.webContents.isLoading() && !still && !asleep && Date.now() - dragged > 8000 && Date.now() > hoverUntil + 1500 && !busy && /idle|go|hop/.test(plan.mode),
|
|
586
|
+
walkTo: (x, y = pos.y, speed = 120) => (plan = { mode: 'scene', tx: Math.max(minX, Math.min(maxX, x)), ty: Math.max(minY, Math.min(floor, y)), speed, dir: plan.dir || 0 }),
|
|
587
|
+
arrivedAt: () => plan.mode === 'scene' && Math.abs(plan.tx - pos.x) < 6 && Math.abs(plan.ty - pos.y) < 6 && Math.hypot(vel.x, vel.y) < 10,
|
|
588
|
+
face: (dir) => {
|
|
589
|
+
if (plan.mode === 'scene' || plan.mode === 'fling') plan.dir = dir;
|
|
590
|
+
tellFacing(dir);
|
|
591
|
+
},
|
|
592
|
+
fling: (vx, height = 46, ms = 650) => (plan = { mode: 'fling', vx, height, ms, start: Date.now(), dir: plan.dir || 0 }),
|
|
593
|
+
say: (msg) => page(`postMessage(${JSON.stringify(msg)})`),
|
|
594
|
+
release: () => {
|
|
595
|
+
plan = { mode: 'idle', until: Date.now() + rnd(2500, 6000) };
|
|
596
|
+
tellFacing(0);
|
|
597
|
+
},
|
|
598
|
+
hold: (s) => ((held = s), resize()),
|
|
599
|
+
inScene: () => plan.mode === 'scene' || plan.mode === 'fling',
|
|
600
|
+
// the mouse on it (to click or drag it) ends the scene
|
|
601
|
+
interrupted: () => !alive() || !win.isVisible() || Date.now() - dragged < 1000 || Date.now() < hoverUntil || Date.now() < stillUntil,
|
|
602
|
+
};
|
|
147
603
|
}
|
|
148
604
|
|
|
149
605
|
// Hidden is not closed: the pet lives on in the tray until Quit.
|