@clawlabz/clawskin 1.0.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/CHANGELOG.md +98 -0
- package/CONTRIBUTING.md +40 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/docs/ARCHITECTURE.md +132 -0
- package/docs/PROGRESS.md +15 -0
- package/docs/ROADMAP.md +130 -0
- package/package.json +27 -0
- package/public/_headers +13 -0
- package/public/_redirects +1 -0
- package/public/app.html +595 -0
- package/public/css/app.css +379 -0
- package/public/css/style.css +415 -0
- package/public/index.html +324 -0
- package/public/js/app/AgentSlot.js +596 -0
- package/public/js/app/AgentStateMapper.js +213 -0
- package/public/js/app/ClawSkinApp.js +551 -0
- package/public/js/app/ConnectionPanel.js +142 -0
- package/public/js/app/DeviceIdentity.js +118 -0
- package/public/js/app/GatewayClient.js +284 -0
- package/public/js/app/SettingsManager.js +47 -0
- package/public/js/character/AnimationManager.js +69 -0
- package/public/js/character/BubbleManager.js +157 -0
- package/public/js/character/CharacterSprite.js +98 -0
- package/public/js/game.js +116 -0
- package/public/js/pets/Pet.js +303 -0
- package/public/js/pets/PetManager.js +102 -0
- package/public/js/scenes/CafeScene.js +594 -0
- package/public/js/scenes/HackerScene.js +527 -0
- package/public/js/scenes/OfficeScene.js +404 -0
- package/public/js/sprites/SpriteGenerator.js +927 -0
- package/public/js/state/AgentStateSync.js +89 -0
- package/public/js/state/DemoMode.js +65 -0
- package/public/js/ui/CharacterEditor.js +145 -0
- package/public/js/ui/ScenePicker.js +29 -0
- package/serve.cjs +132 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeviceIdentity.js — Ed25519 device identity for OpenClaw Gateway authentication
|
|
3
|
+
*
|
|
4
|
+
* The Gateway requires every WebSocket client to present a device identity:
|
|
5
|
+
* - Generate an Ed25519 keypair on first use (stored in localStorage)
|
|
6
|
+
* - Sign the connect message with the private key
|
|
7
|
+
* - Include deviceId, publicKey, signature in connect params
|
|
8
|
+
*
|
|
9
|
+
* Uses Web Crypto API (Ed25519 supported in Chrome 113+, Firefox 129+, Safari 17+).
|
|
10
|
+
*/
|
|
11
|
+
class DeviceIdentity {
|
|
12
|
+
static STORAGE_KEY = 'clawskin-device-identity-v1';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get or create device identity (keypair + deviceId)
|
|
16
|
+
*/
|
|
17
|
+
static async getOrCreate() {
|
|
18
|
+
// Try loading from localStorage
|
|
19
|
+
const stored = DeviceIdentity._load();
|
|
20
|
+
if (stored) return stored;
|
|
21
|
+
|
|
22
|
+
// Generate new keypair
|
|
23
|
+
const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']);
|
|
24
|
+
const rawPublic = await crypto.subtle.exportKey('raw', keyPair.publicKey);
|
|
25
|
+
const rawPrivate = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
|
26
|
+
|
|
27
|
+
const publicKeyB64 = DeviceIdentity._bufToB64url(rawPublic);
|
|
28
|
+
const deviceId = await DeviceIdentity._sha256hex(rawPublic);
|
|
29
|
+
|
|
30
|
+
const identity = {
|
|
31
|
+
deviceId,
|
|
32
|
+
publicKey: publicKeyB64,
|
|
33
|
+
privateKeyPkcs8: DeviceIdentity._bufToB64url(rawPrivate),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
DeviceIdentity._save(identity);
|
|
37
|
+
return identity;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Sign a connect message
|
|
42
|
+
* Format: "v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce"
|
|
43
|
+
*/
|
|
44
|
+
static async sign(identity, params) {
|
|
45
|
+
const { deviceId, privateKeyPkcs8 } = identity;
|
|
46
|
+
const { clientId, clientMode, role, scopes, token, nonce } = params;
|
|
47
|
+
|
|
48
|
+
const signedAtMs = Date.now();
|
|
49
|
+
const scopeStr = (scopes || []).join(',');
|
|
50
|
+
const message = [
|
|
51
|
+
'v2', deviceId, clientId, clientMode, role || '',
|
|
52
|
+
scopeStr, String(signedAtMs), token || '', nonce || ''
|
|
53
|
+
].join('|');
|
|
54
|
+
|
|
55
|
+
// Import private key
|
|
56
|
+
const rawKey = DeviceIdentity._b64urlToBuf(privateKeyPkcs8);
|
|
57
|
+
const privateKey = await crypto.subtle.importKey(
|
|
58
|
+
'pkcs8', rawKey, 'Ed25519', false, ['sign']
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Sign
|
|
62
|
+
const msgBytes = new TextEncoder().encode(message);
|
|
63
|
+
const sigBytes = await crypto.subtle.sign('Ed25519', privateKey, msgBytes);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
id: deviceId,
|
|
67
|
+
publicKey: identity.publicKey,
|
|
68
|
+
signature: DeviceIdentity._bufToB64url(sigBytes),
|
|
69
|
+
signedAt: signedAtMs,
|
|
70
|
+
nonce: nonce || '',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Helpers ──
|
|
75
|
+
|
|
76
|
+
static _bufToB64url(buf) {
|
|
77
|
+
const bytes = new Uint8Array(buf);
|
|
78
|
+
let binary = '';
|
|
79
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
80
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static _b64urlToBuf(b64) {
|
|
84
|
+
const standard = b64.replace(/-/g, '+').replace(/_/g, '/');
|
|
85
|
+
const padded = standard + '='.repeat((4 - standard.length % 4) % 4);
|
|
86
|
+
const binary = atob(padded);
|
|
87
|
+
const bytes = new Uint8Array(binary.length);
|
|
88
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
89
|
+
return bytes.buffer;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
static async _sha256hex(buf) {
|
|
93
|
+
const hash = await crypto.subtle.digest('SHA-256', buf);
|
|
94
|
+
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static _load() {
|
|
98
|
+
try {
|
|
99
|
+
const raw = localStorage.getItem(DeviceIdentity.STORAGE_KEY);
|
|
100
|
+
if (!raw) return null;
|
|
101
|
+
const data = JSON.parse(raw);
|
|
102
|
+
if (data?.deviceId && data?.publicKey && data?.privateKeyPkcs8) return data;
|
|
103
|
+
return null;
|
|
104
|
+
} catch { return null; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
static _save(identity) {
|
|
108
|
+
try {
|
|
109
|
+
localStorage.setItem(DeviceIdentity.STORAGE_KEY, JSON.stringify({
|
|
110
|
+
...identity,
|
|
111
|
+
version: 1,
|
|
112
|
+
createdAtMs: Date.now(),
|
|
113
|
+
}));
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (typeof window !== 'undefined') window.DeviceIdentity = DeviceIdentity;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GatewayClient.js — OpenClaw Gateway WebSocket protocol client
|
|
3
|
+
* Handles connection, authentication, RPC calls, and real-time events
|
|
4
|
+
*/
|
|
5
|
+
class GatewayClient {
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
this.url = options.url || null;
|
|
8
|
+
this.token = options.token || null;
|
|
9
|
+
// OpenClaw Gateway validates client.id against an allowlist.
|
|
10
|
+
// Allowed values: webchat-ui, webchat, openclaw-control-ui, cli,
|
|
11
|
+
// gateway-client, openclaw-macos, openclaw-ios, openclaw-android,
|
|
12
|
+
// node-host, test, fingerprint, openclaw-probe
|
|
13
|
+
// Default to "webchat" — lightweight client that doesn't require
|
|
14
|
+
// device identity signing (unlike webchat-ui / control-ui which need Ed25519).
|
|
15
|
+
// Users on custom Gateway builds can override this.
|
|
16
|
+
this.clientId = options.clientId || 'webchat';
|
|
17
|
+
this.clientMode = options.clientMode || 'webchat';
|
|
18
|
+
this.clientVersion = options.clientVersion || '1.0.0';
|
|
19
|
+
this.ws = null;
|
|
20
|
+
this.connected = false;
|
|
21
|
+
this.connecting = false;
|
|
22
|
+
this.pending = new Map();
|
|
23
|
+
this.backoffMs = 800;
|
|
24
|
+
this.maxBackoff = 15000;
|
|
25
|
+
this.connectNonce = null;
|
|
26
|
+
this.connectSent = false;
|
|
27
|
+
this.autoReconnect = options.autoReconnect !== false;
|
|
28
|
+
this.reconnectTimer = null;
|
|
29
|
+
this.seqCounter = 0;
|
|
30
|
+
|
|
31
|
+
this.lastError = null;
|
|
32
|
+
|
|
33
|
+
// Callbacks
|
|
34
|
+
this.onConnected = options.onConnected || null;
|
|
35
|
+
this.onDisconnected = options.onDisconnected || null;
|
|
36
|
+
this.onEvent = options.onEvent || null;
|
|
37
|
+
this.onError = options.onError || null;
|
|
38
|
+
this.onStateChange = options.onStateChange || null; // 'connecting'|'connected'|'disconnected'|'error'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_uuid() {
|
|
42
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
43
|
+
const r = Math.random() * 16 | 0;
|
|
44
|
+
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_setState(state, detail) {
|
|
49
|
+
if (state === 'error') this.lastError = detail || 'Unknown error';
|
|
50
|
+
else if (state === 'connected') this.lastError = null;
|
|
51
|
+
if (this.onStateChange) this.onStateChange(state, detail);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
connect(url, token) {
|
|
55
|
+
if (url) this.url = url;
|
|
56
|
+
if (token !== undefined) this.token = token;
|
|
57
|
+
if (!this.url) {
|
|
58
|
+
this._setState('error', 'No Gateway URL provided');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.disconnect(false);
|
|
63
|
+
this.connecting = true;
|
|
64
|
+
this.lastError = null;
|
|
65
|
+
this._setState('connecting');
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
this.ws = new WebSocket(this.url);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
this.connecting = false;
|
|
71
|
+
this._setState('error', `Invalid URL: ${e.message}`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.ws.addEventListener('open', () => {
|
|
76
|
+
// Wait for connect.challenge event from Gateway
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
this.ws.addEventListener('message', (e) => {
|
|
80
|
+
this._handleMessage(e.data);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
this.ws.addEventListener('close', (e) => {
|
|
84
|
+
const wasConnected = this.connected;
|
|
85
|
+
this.connected = false;
|
|
86
|
+
this.connecting = false;
|
|
87
|
+
this.connectSent = false;
|
|
88
|
+
this.connectNonce = null;
|
|
89
|
+
this._flushPending(new Error(`Connection closed (${e.code})`));
|
|
90
|
+
|
|
91
|
+
if (wasConnected && this.onDisconnected) {
|
|
92
|
+
this.onDisconnected({ code: e.code, reason: e.reason });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Don't overwrite error state — keep the auth/pairing error visible
|
|
96
|
+
if (!this.lastError) {
|
|
97
|
+
this._setState('disconnected', e.reason || `Code ${e.code}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (this.autoReconnect && wasConnected) {
|
|
101
|
+
this._scheduleReconnect();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
this.ws.addEventListener('error', () => {
|
|
106
|
+
if (!this.connected) {
|
|
107
|
+
this.connecting = false;
|
|
108
|
+
this._setState('error', 'Connection failed');
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
disconnect(permanent = true) {
|
|
114
|
+
if (permanent) this.autoReconnect = false;
|
|
115
|
+
if (this.reconnectTimer) {
|
|
116
|
+
clearTimeout(this.reconnectTimer);
|
|
117
|
+
this.reconnectTimer = null;
|
|
118
|
+
}
|
|
119
|
+
if (this.ws) {
|
|
120
|
+
this.ws.close();
|
|
121
|
+
this.ws = null;
|
|
122
|
+
}
|
|
123
|
+
this.connected = false;
|
|
124
|
+
this.connecting = false;
|
|
125
|
+
this.connectSent = false;
|
|
126
|
+
this.lastError = null;
|
|
127
|
+
this._flushPending(new Error('Disconnected'));
|
|
128
|
+
this._setState('disconnected');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_scheduleReconnect() {
|
|
132
|
+
if (this.reconnectTimer) return;
|
|
133
|
+
const delay = this.backoffMs;
|
|
134
|
+
this.backoffMs = Math.min(this.backoffMs * 1.7, this.maxBackoff);
|
|
135
|
+
this._setState('connecting', `Reconnecting in ${Math.round(delay/1000)}s...`);
|
|
136
|
+
this.reconnectTimer = setTimeout(() => {
|
|
137
|
+
this.reconnectTimer = null;
|
|
138
|
+
this.connect();
|
|
139
|
+
}, delay);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_handleMessage(raw) {
|
|
143
|
+
let msg;
|
|
144
|
+
try { msg = JSON.parse(raw); } catch { return; }
|
|
145
|
+
|
|
146
|
+
if (msg.type === 'event') {
|
|
147
|
+
// Handle connect challenge
|
|
148
|
+
if (msg.event === 'connect.challenge') {
|
|
149
|
+
const nonce = msg.payload?.nonce;
|
|
150
|
+
if (nonce) this.connectNonce = nonce;
|
|
151
|
+
this._sendConnect();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Forward all other events
|
|
155
|
+
if (this.onEvent) {
|
|
156
|
+
try { this.onEvent(msg); } catch (e) {
|
|
157
|
+
console.error('[GatewayClient] event handler error:', e);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (msg.type === 'res') {
|
|
164
|
+
const pending = this.pending.get(msg.id);
|
|
165
|
+
if (!pending) return;
|
|
166
|
+
this.pending.delete(msg.id);
|
|
167
|
+
if (msg.ok) {
|
|
168
|
+
pending.resolve(msg.payload);
|
|
169
|
+
} else {
|
|
170
|
+
pending.reject(new Error(msg.error?.message || 'Request failed'));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async _sendConnect() {
|
|
176
|
+
if (this.connectSent) return;
|
|
177
|
+
this.connectSent = true;
|
|
178
|
+
|
|
179
|
+
const role = 'operator';
|
|
180
|
+
const scopes = ['operator.admin'];
|
|
181
|
+
|
|
182
|
+
const params = {
|
|
183
|
+
minProtocol: 3,
|
|
184
|
+
maxProtocol: 3,
|
|
185
|
+
client: {
|
|
186
|
+
id: this.clientId,
|
|
187
|
+
version: this.clientVersion,
|
|
188
|
+
platform: navigator.platform || 'web',
|
|
189
|
+
mode: this.clientMode,
|
|
190
|
+
},
|
|
191
|
+
role,
|
|
192
|
+
scopes,
|
|
193
|
+
caps: [],
|
|
194
|
+
auth: {},
|
|
195
|
+
userAgent: navigator.userAgent,
|
|
196
|
+
locale: navigator.language,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if (this.token) {
|
|
200
|
+
params.auth.token = this.token;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Device identity — Gateway requires Ed25519 signed device identity.
|
|
204
|
+
// The signed message MUST use the same role/scopes/clientId/clientMode
|
|
205
|
+
// as the connect params, because Gateway reconstructs the message to verify.
|
|
206
|
+
if (typeof DeviceIdentity !== 'undefined') {
|
|
207
|
+
try {
|
|
208
|
+
const identity = await DeviceIdentity.getOrCreate();
|
|
209
|
+
const device = await DeviceIdentity.sign(identity, {
|
|
210
|
+
clientId: this.clientId,
|
|
211
|
+
clientMode: this.clientMode,
|
|
212
|
+
role,
|
|
213
|
+
scopes,
|
|
214
|
+
token: this.token || null,
|
|
215
|
+
nonce: this.connectNonce || '',
|
|
216
|
+
});
|
|
217
|
+
params.device = device;
|
|
218
|
+
} catch (e) {
|
|
219
|
+
console.warn('[GatewayClient] device identity failed:', e.message);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const result = await this.request('connect', params);
|
|
225
|
+
this.connected = true;
|
|
226
|
+
this.connecting = false;
|
|
227
|
+
this.backoffMs = 800;
|
|
228
|
+
this._setState('connected');
|
|
229
|
+
if (this.onConnected) this.onConnected(result);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
this.connecting = false;
|
|
232
|
+
const code = e.message || '';
|
|
233
|
+
if (code.includes('AUTH') || code.includes('auth') || code.includes('token')) {
|
|
234
|
+
this._setState('error', 'Authentication failed — check your token');
|
|
235
|
+
} else if (code.includes('PAIRING') || code.includes('pairing')) {
|
|
236
|
+
this._setState('error', 'Device pairing required — approve in Gateway dashboard');
|
|
237
|
+
} else if (code.includes('device identity') || code.includes('DEVICE_IDENTITY')) {
|
|
238
|
+
this._setState('error', 'Device identity required — your browser may not support Ed25519');
|
|
239
|
+
} else {
|
|
240
|
+
this._setState('error', e.message || 'Connection failed');
|
|
241
|
+
}
|
|
242
|
+
if (this.ws) this.ws.close();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
request(method, params = {}) {
|
|
247
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
248
|
+
return Promise.reject(new Error('Not connected'));
|
|
249
|
+
}
|
|
250
|
+
const id = this._uuid();
|
|
251
|
+
const msg = { type: 'req', id, method, params };
|
|
252
|
+
return new Promise((resolve, reject) => {
|
|
253
|
+
this.pending.set(id, { resolve, reject });
|
|
254
|
+
this.ws.send(JSON.stringify(msg));
|
|
255
|
+
// Timeout after 30s
|
|
256
|
+
setTimeout(() => {
|
|
257
|
+
if (this.pending.has(id)) {
|
|
258
|
+
this.pending.delete(id);
|
|
259
|
+
reject(new Error('Request timeout'));
|
|
260
|
+
}
|
|
261
|
+
}, 30000);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_flushPending(error) {
|
|
266
|
+
for (const [, p] of this.pending) p.reject(error);
|
|
267
|
+
this.pending.clear();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Convenience methods
|
|
271
|
+
async getStatus() { return this.request('status', {}); }
|
|
272
|
+
async getHealth() { return this.request('health', {}); }
|
|
273
|
+
async getAgentIdentity(agentId) {
|
|
274
|
+
return this.request('agent.identity.get', agentId ? { agentId } : {});
|
|
275
|
+
}
|
|
276
|
+
async getChatHistory(sessionKey, limit = 50) {
|
|
277
|
+
return this.request('chat.history', { sessionKey, limit });
|
|
278
|
+
}
|
|
279
|
+
async getSessionsList(opts = {}) {
|
|
280
|
+
return this.request('sessions.list', { activeMinutes: 120, ...opts });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (typeof window !== 'undefined') window.GatewayClient = GatewayClient;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SettingsManager.js — Persistent settings via localStorage
|
|
3
|
+
*/
|
|
4
|
+
class SettingsManager {
|
|
5
|
+
constructor(storageKey = 'clawskin_settings') {
|
|
6
|
+
this.storageKey = storageKey;
|
|
7
|
+
this.defaults = {
|
|
8
|
+
gatewayUrl: '',
|
|
9
|
+
token: '',
|
|
10
|
+
sessionKey: 'main',
|
|
11
|
+
autoConnect: true,
|
|
12
|
+
scene: 0,
|
|
13
|
+
character: null,
|
|
14
|
+
lastConnected: null,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
load() {
|
|
19
|
+
try {
|
|
20
|
+
const raw = localStorage.getItem(this.storageKey);
|
|
21
|
+
if (!raw) return { ...this.defaults };
|
|
22
|
+
const saved = JSON.parse(raw);
|
|
23
|
+
return { ...this.defaults, ...saved };
|
|
24
|
+
} catch { return { ...this.defaults }; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
save(settings) {
|
|
28
|
+
try {
|
|
29
|
+
localStorage.setItem(this.storageKey, JSON.stringify(settings));
|
|
30
|
+
} catch (e) {
|
|
31
|
+
console.warn('[SettingsManager] save failed:', e);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
update(patch) {
|
|
36
|
+
const current = this.load();
|
|
37
|
+
const updated = { ...current, ...patch };
|
|
38
|
+
this.save(updated);
|
|
39
|
+
return updated;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
clear() {
|
|
43
|
+
try { localStorage.removeItem(this.storageKey); } catch {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof window !== 'undefined') window.SettingsManager = SettingsManager;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnimationManager.js — Character animation state machine
|
|
3
|
+
* Manages transitions between agent states and animation frames
|
|
4
|
+
*/
|
|
5
|
+
class AnimationManager {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.currentState = 'idle';
|
|
8
|
+
this.frameIndex = 0;
|
|
9
|
+
this.frameTimer = 0;
|
|
10
|
+
this.transitionTimer = 0;
|
|
11
|
+
|
|
12
|
+
// Animation frame indices in sprite sheet (pairs for 2-frame anims)
|
|
13
|
+
this.stateFrames = {
|
|
14
|
+
idle: [0, 1],
|
|
15
|
+
typing: [2, 3],
|
|
16
|
+
thinking: [4, 5],
|
|
17
|
+
walking: [6, 7],
|
|
18
|
+
sleeping: [8, 8],
|
|
19
|
+
error: [9, 9],
|
|
20
|
+
waving: [10, 11],
|
|
21
|
+
coffee: [12, 13],
|
|
22
|
+
browsing: [14, 15],
|
|
23
|
+
executing: [6, 7] // reuse walking
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Frame durations (ms) per state
|
|
27
|
+
this.frameSpeeds = {
|
|
28
|
+
idle: 1200, typing: 200, thinking: 800, walking: 300,
|
|
29
|
+
sleeping: 2000, error: 500, waving: 400, coffee: 1500,
|
|
30
|
+
browsing: 1000, executing: 300
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// State display names
|
|
34
|
+
this.stateLabels = {
|
|
35
|
+
idle: '💤 Idle', typing: '⌨️ Writing', thinking: '🤔 Thinking',
|
|
36
|
+
walking: '🚶 Moving', sleeping: '😴 Sleeping', error: '❌ Error',
|
|
37
|
+
waving: '👋 Waving', coffee: '☕ Coffee Break', browsing: '🌐 Browsing',
|
|
38
|
+
executing: '⚡ Executing'
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
setState(newState) {
|
|
43
|
+
if (!this.stateFrames[newState] || newState === this.currentState) return;
|
|
44
|
+
this.currentState = newState;
|
|
45
|
+
this.frameIndex = 0;
|
|
46
|
+
this.frameTimer = 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
update(dt) {
|
|
50
|
+
this.frameTimer += dt;
|
|
51
|
+
const speed = this.frameSpeeds[this.currentState] || 500;
|
|
52
|
+
if (this.frameTimer >= speed) {
|
|
53
|
+
this.frameTimer = 0;
|
|
54
|
+
const frames = this.stateFrames[this.currentState];
|
|
55
|
+
this.frameIndex = (this.frameIndex + 1) % frames.length;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getCurrentFrame() {
|
|
60
|
+
const frames = this.stateFrames[this.currentState];
|
|
61
|
+
return frames[this.frameIndex];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
getLabel() {
|
|
65
|
+
return this.stateLabels[this.currentState] || this.currentState;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (typeof window !== 'undefined') window.AnimationManager = AnimationManager;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BubbleManager.js — Speech/thought bubble with typewriter effect
|
|
3
|
+
*/
|
|
4
|
+
class BubbleManager {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.active = false;
|
|
7
|
+
this.text = '';
|
|
8
|
+
this.displayText = '';
|
|
9
|
+
this.charIndex = 0;
|
|
10
|
+
this.timer = 0;
|
|
11
|
+
this.displayDuration = 0;
|
|
12
|
+
this.maxDuration = 5000;
|
|
13
|
+
this.typeSpeed = 50; // ms per char
|
|
14
|
+
this.bubbleType = 'speech'; // speech | thought | status
|
|
15
|
+
this.queue = [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
show(text, type = 'speech', duration = 5000) {
|
|
19
|
+
if (this.active) {
|
|
20
|
+
this.queue.push({ text, type, duration });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
this.active = true;
|
|
24
|
+
this.text = text.slice(0, 60); // max 60 chars
|
|
25
|
+
this.displayText = '';
|
|
26
|
+
this.charIndex = 0;
|
|
27
|
+
this.timer = 0;
|
|
28
|
+
this.displayDuration = 0;
|
|
29
|
+
this.maxDuration = duration;
|
|
30
|
+
this.bubbleType = type;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
update(dt) {
|
|
34
|
+
if (!this.active) return;
|
|
35
|
+
|
|
36
|
+
if (this.charIndex < this.text.length) {
|
|
37
|
+
this.timer += dt;
|
|
38
|
+
if (this.timer >= this.typeSpeed) {
|
|
39
|
+
this.timer = 0;
|
|
40
|
+
this.charIndex++;
|
|
41
|
+
this.displayText = this.text.slice(0, this.charIndex);
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
this.displayDuration += dt;
|
|
45
|
+
if (this.displayDuration >= this.maxDuration) {
|
|
46
|
+
this.active = false;
|
|
47
|
+
if (this.queue.length > 0) {
|
|
48
|
+
const next = this.queue.shift();
|
|
49
|
+
this.show(next.text, next.type, next.duration);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
render(ctx, x, y) {
|
|
56
|
+
if (!this.active || !this.displayText) return;
|
|
57
|
+
|
|
58
|
+
ctx.save();
|
|
59
|
+
ctx.font = '10px "Press Start 2P", monospace';
|
|
60
|
+
ctx.textBaseline = 'top';
|
|
61
|
+
|
|
62
|
+
const lines = this._wrapText(ctx, this.displayText, 120);
|
|
63
|
+
const lineHeight = 13;
|
|
64
|
+
const padding = 6;
|
|
65
|
+
const maxWidth = Math.max(...lines.map(l => ctx.measureText(l).width));
|
|
66
|
+
const bw = maxWidth + padding * 2;
|
|
67
|
+
const bh = lines.length * lineHeight + padding * 2;
|
|
68
|
+
const bx = x - bw / 2;
|
|
69
|
+
const by = y - bh - 12;
|
|
70
|
+
|
|
71
|
+
// Bubble background
|
|
72
|
+
ctx.fillStyle = this.bubbleType === 'thought' ? '#F0F0F0' : '#FFFFFF';
|
|
73
|
+
ctx.strokeStyle = '#333';
|
|
74
|
+
ctx.lineWidth = 2;
|
|
75
|
+
|
|
76
|
+
// Rounded rect
|
|
77
|
+
const r = 4;
|
|
78
|
+
ctx.beginPath();
|
|
79
|
+
ctx.moveTo(bx + r, by);
|
|
80
|
+
ctx.lineTo(bx + bw - r, by);
|
|
81
|
+
ctx.quadraticCurveTo(bx + bw, by, bx + bw, by + r);
|
|
82
|
+
ctx.lineTo(bx + bw, by + bh - r);
|
|
83
|
+
ctx.quadraticCurveTo(bx + bw, by + bh, bx + bw - r, by + bh);
|
|
84
|
+
ctx.lineTo(bx + r, by + bh);
|
|
85
|
+
ctx.quadraticCurveTo(bx, by + bh, bx, by + bh - r);
|
|
86
|
+
ctx.lineTo(bx, by + r);
|
|
87
|
+
ctx.quadraticCurveTo(bx, by, bx + r, by);
|
|
88
|
+
ctx.closePath();
|
|
89
|
+
ctx.fill();
|
|
90
|
+
ctx.stroke();
|
|
91
|
+
|
|
92
|
+
// Tail
|
|
93
|
+
if (this.bubbleType === 'thought') {
|
|
94
|
+
ctx.fillStyle = '#F0F0F0';
|
|
95
|
+
ctx.beginPath();
|
|
96
|
+
ctx.arc(x - 4, by + bh + 5, 3, 0, Math.PI * 2);
|
|
97
|
+
ctx.fill(); ctx.stroke();
|
|
98
|
+
ctx.beginPath();
|
|
99
|
+
ctx.arc(x - 2, by + bh + 10, 2, 0, Math.PI * 2);
|
|
100
|
+
ctx.fill(); ctx.stroke();
|
|
101
|
+
} else {
|
|
102
|
+
ctx.fillStyle = '#FFFFFF';
|
|
103
|
+
ctx.beginPath();
|
|
104
|
+
ctx.moveTo(x - 5, by + bh);
|
|
105
|
+
ctx.lineTo(x, by + bh + 8);
|
|
106
|
+
ctx.lineTo(x + 5, by + bh);
|
|
107
|
+
ctx.closePath();
|
|
108
|
+
ctx.fill();
|
|
109
|
+
ctx.strokeStyle = '#333';
|
|
110
|
+
ctx.beginPath();
|
|
111
|
+
ctx.moveTo(x - 5, by + bh);
|
|
112
|
+
ctx.lineTo(x, by + bh + 8);
|
|
113
|
+
ctx.lineTo(x + 5, by + bh);
|
|
114
|
+
ctx.stroke();
|
|
115
|
+
// Cover top of tail
|
|
116
|
+
ctx.fillStyle = '#FFFFFF';
|
|
117
|
+
ctx.fillRect(x - 4, by + bh - 1, 9, 2);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Text
|
|
121
|
+
ctx.fillStyle = '#333';
|
|
122
|
+
lines.forEach((line, i) => {
|
|
123
|
+
ctx.fillText(line, bx + padding, by + padding + i * lineHeight);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Cursor blink when typing
|
|
127
|
+
if (this.charIndex < this.text.length) {
|
|
128
|
+
const lastLine = lines[lines.length - 1];
|
|
129
|
+
const lw = ctx.measureText(lastLine).width;
|
|
130
|
+
if (Math.floor(Date.now() / 300) % 2 === 0) {
|
|
131
|
+
ctx.fillStyle = '#333';
|
|
132
|
+
ctx.fillRect(bx + padding + lw + 1, by + padding + (lines.length - 1) * lineHeight, 6, 10);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
ctx.restore();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_wrapText(ctx, text, maxWidth) {
|
|
140
|
+
const words = text.split(' ');
|
|
141
|
+
const lines = [];
|
|
142
|
+
let current = '';
|
|
143
|
+
for (const word of words) {
|
|
144
|
+
const test = current ? current + ' ' + word : word;
|
|
145
|
+
if (ctx.measureText(test).width > maxWidth && current) {
|
|
146
|
+
lines.push(current);
|
|
147
|
+
current = word;
|
|
148
|
+
} else {
|
|
149
|
+
current = test;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (current) lines.push(current);
|
|
153
|
+
return lines.length ? lines : [''];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (typeof window !== 'undefined') window.BubbleManager = BubbleManager;
|