@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,596 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentSlot.js — One agent in the multi-agent office
|
|
3
|
+
* Handles: unique appearance, workstation binding, smooth idle wandering with POI system, click-to-customize
|
|
4
|
+
*/
|
|
5
|
+
class AgentSlot {
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
this.agentId = options.agentId || 'main';
|
|
8
|
+
this.sessionKeys = options.sessionKeys || ['main'];
|
|
9
|
+
this.label = options.label || options.agentId || 'Agent';
|
|
10
|
+
this.name = options.name || this.label;
|
|
11
|
+
|
|
12
|
+
// Seeded random config per agent — deterministic per agentId
|
|
13
|
+
const savedConfigs = AgentSlot._loadConfigs();
|
|
14
|
+
let charConfig = savedConfigs[this.agentId];
|
|
15
|
+
if (!charConfig) {
|
|
16
|
+
charConfig = CharacterSprite.randomConfig();
|
|
17
|
+
savedConfigs[this.agentId] = charConfig;
|
|
18
|
+
AgentSlot._saveConfigs(savedConfigs);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
this.character = new CharacterSprite(charConfig, options.x || 0, options.y || 0);
|
|
22
|
+
this.stateMapper = new AgentStateMapper(this.character, {
|
|
23
|
+
agentId: this.agentId,
|
|
24
|
+
sessionKeys: this.sessionKeys,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Workstation reference (set by ClawSkinApp)
|
|
28
|
+
this.station = null;
|
|
29
|
+
this._scene = null; // reference to current scene (for POI list)
|
|
30
|
+
this.showNameTag = options.showNameTag !== false;
|
|
31
|
+
|
|
32
|
+
// ── Smooth movement system ──
|
|
33
|
+
this.isWandering = false;
|
|
34
|
+
this._isReturning = false; // walking back to desk (smooth)
|
|
35
|
+
this._wanderTimer = Math.random() * 20000 + 15000; // first wander 15-35s
|
|
36
|
+
this._wanderTarget = null;
|
|
37
|
+
this._wanderSpeed = 0.06 + Math.random() * 0.02; // faster than before
|
|
38
|
+
this._wanderReturnTimer = 0;
|
|
39
|
+
this._wobbleTime = Math.random() * 10000;
|
|
40
|
+
this._breatheTime = Math.random() * 10000;
|
|
41
|
+
this._manualY = false;
|
|
42
|
+
this._poiAction = null; // current POI interaction label
|
|
43
|
+
this._poiStayTimer = 0; // time spent at POI
|
|
44
|
+
this._returnWaypoints = null; // waypoint queue for desk return path
|
|
45
|
+
this._stuckTimer = 0; // detect stuck against obstacles
|
|
46
|
+
this._outsideOwnDesk = false; // true once agent has left own desk hitbox
|
|
47
|
+
|
|
48
|
+
// Reference to other agents (set by ClawSkinApp for social wandering)
|
|
49
|
+
this._otherAgents = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
update(dt) {
|
|
53
|
+
this.character.update(dt);
|
|
54
|
+
this._breatheTime += dt;
|
|
55
|
+
|
|
56
|
+
const state = this.stateMapper.currentState;
|
|
57
|
+
const isWorking = ['typing', 'executing', 'browsing', 'thinking', 'error'].includes(state);
|
|
58
|
+
|
|
59
|
+
if (isWorking) {
|
|
60
|
+
// Working — return to desk if wandering, cancel wander
|
|
61
|
+
if (this.isWandering || this._isReturning) this._snapToDesk();
|
|
62
|
+
this._wanderTimer = 8000 + Math.random() * 12000;
|
|
63
|
+
this._manualY = false;
|
|
64
|
+
} else if (state === 'sleeping') {
|
|
65
|
+
if (this.isWandering || this._isReturning) this._snapToDesk();
|
|
66
|
+
this._manualY = false;
|
|
67
|
+
} else {
|
|
68
|
+
// Idle — maybe wander
|
|
69
|
+
this._wanderTimer -= dt;
|
|
70
|
+
|
|
71
|
+
if (this._isReturning) {
|
|
72
|
+
this._updateMovement(dt);
|
|
73
|
+
} else if (this.isWandering) {
|
|
74
|
+
this._updateWander(dt);
|
|
75
|
+
} else if (this._wanderTimer <= 0 && this.station) {
|
|
76
|
+
this._startWander();
|
|
77
|
+
} else if (this.station && !this._isReturning) {
|
|
78
|
+
// Subtle breathing wobble at desk
|
|
79
|
+
const wobble = Math.sin(this._breatheTime * 0.0015) * 0.8;
|
|
80
|
+
this.character.y = this.station.charY + wobble;
|
|
81
|
+
this._manualY = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Agent-to-agent repulsion — push apart when overlapping ──
|
|
86
|
+
if (this._otherAgents && (this.isWandering || this._isReturning)) {
|
|
87
|
+
const cs = this.character.scale || 2.5;
|
|
88
|
+
const myW = 32 * cs;
|
|
89
|
+
const myCx = this.character.x + myW / 2;
|
|
90
|
+
const myCy = this.character.y + myW / 2;
|
|
91
|
+
const minDist = 50; // minimum distance between agent centers
|
|
92
|
+
|
|
93
|
+
for (const other of this._otherAgents) {
|
|
94
|
+
if (other === this) continue;
|
|
95
|
+
const ocs = other.character.scale || 2.5;
|
|
96
|
+
const oW = 32 * ocs;
|
|
97
|
+
const oCx = other.character.x + oW / 2;
|
|
98
|
+
const oCy = other.character.y + oW / 2;
|
|
99
|
+
|
|
100
|
+
const dx = myCx - oCx;
|
|
101
|
+
const dy = myCy - oCy;
|
|
102
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
103
|
+
|
|
104
|
+
if (dist < minDist && dist > 0.1) {
|
|
105
|
+
// Push this agent away from the other
|
|
106
|
+
const pushStrength = (minDist - dist) * 0.03;
|
|
107
|
+
this.character.x += (dx / dist) * pushStrength;
|
|
108
|
+
this.character.y += (dy / dist) * pushStrength;
|
|
109
|
+
this._manualY = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Wandering with POI system ──
|
|
116
|
+
|
|
117
|
+
/** Corridor Y — a safe horizontal lane below all desks where agents walk freely */
|
|
118
|
+
_getCorridorY() {
|
|
119
|
+
const h = this._scene?.canvas?.height || 400;
|
|
120
|
+
const floorY = h * 0.40;
|
|
121
|
+
// deskSurfaceY = floorY + 50, deskH = 42, so desk bottom ≈ floorY + 92
|
|
122
|
+
return Math.round(floorY + 110);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Plan a waypoint path from current position to (toX, toY).
|
|
127
|
+
* Uses a corridor below the desks for horizontal travel.
|
|
128
|
+
*
|
|
129
|
+
* When starting from behind a desk:
|
|
130
|
+
* 1. Sidestep LEFT or RIGHT out of desk range
|
|
131
|
+
* 2. Drop down to corridor
|
|
132
|
+
* 3. Walk horizontally
|
|
133
|
+
* 4. Rise up to target (sidestep in if target is behind a desk)
|
|
134
|
+
*/
|
|
135
|
+
_planPath(toX, toY) {
|
|
136
|
+
const cx = this.character.x;
|
|
137
|
+
const cy = this.character.y;
|
|
138
|
+
const corridorY = this._getCorridorY();
|
|
139
|
+
const waypoints = [];
|
|
140
|
+
|
|
141
|
+
const needsHorizontal = Math.abs(toX - cx) > 30;
|
|
142
|
+
const targetAboveCorridor = toY < corridorY - 10;
|
|
143
|
+
const currentAboveCorridor = cy < corridorY - 10;
|
|
144
|
+
|
|
145
|
+
if (needsHorizontal && (targetAboveCorridor || currentAboveCorridor)) {
|
|
146
|
+
// ── Step 1: Leave current desk area (if behind a desk) ──
|
|
147
|
+
let exitX = cx;
|
|
148
|
+
if (currentAboveCorridor && this.station) {
|
|
149
|
+
const s = this.station;
|
|
150
|
+
// Obstacles extend from deskX-5 to deskX+deskW+5
|
|
151
|
+
// Foot hitbox is 24px wide, centered at character.x + 40 (charPxW/2)
|
|
152
|
+
// So character.x must be < obs.x - 52 (left) or > obs.x+obs.w - 28 (right)
|
|
153
|
+
// Use generous margins to fully clear
|
|
154
|
+
const margin = 80; // fully clear 80px-wide character + desk obstacle padding
|
|
155
|
+
const deskL = s.deskX - margin;
|
|
156
|
+
const deskR = s.deskX + s.deskW + margin;
|
|
157
|
+
// If currently inside desk's horizontal range, sidestep out first
|
|
158
|
+
if (cx > deskL && cx < deskR) {
|
|
159
|
+
// Pick the closer side
|
|
160
|
+
exitX = (cx - deskL < deskR - cx) ? deskL : deskR;
|
|
161
|
+
waypoints.push({ x: exitX, y: cy }); // sidestep out
|
|
162
|
+
}
|
|
163
|
+
waypoints.push({ x: exitX, y: corridorY }); // drop to corridor
|
|
164
|
+
} else if (currentAboveCorridor) {
|
|
165
|
+
waypoints.push({ x: cx, y: corridorY }); // drop to corridor
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Step 2: Horizontal travel in corridor ──
|
|
169
|
+
// Determine entry X for target — if target is behind a desk, approach from the side
|
|
170
|
+
let entryX = toX;
|
|
171
|
+
if (targetAboveCorridor) {
|
|
172
|
+
// Check if target is inside any desk's horizontal range
|
|
173
|
+
const obstacles = this._scene?.obstacles || [];
|
|
174
|
+
for (const obs of obstacles) {
|
|
175
|
+
if (obs.deskIndex == null) continue;
|
|
176
|
+
const obsL = obs.x - 80;
|
|
177
|
+
const obsR = obs.x + obs.w + 80;
|
|
178
|
+
if (toX > obsL && toX < obsR && toY < obs.y + obs.h) {
|
|
179
|
+
// Target is behind this desk — approach from the closer side
|
|
180
|
+
entryX = (toX - obsL < obsR - toX) ? obsL : obsR;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
waypoints.push({ x: entryX, y: corridorY }); // walk horizontally
|
|
187
|
+
|
|
188
|
+
// ── Step 3: Rise up to target ──
|
|
189
|
+
if (targetAboveCorridor) {
|
|
190
|
+
if (Math.abs(entryX - toX) > 5) {
|
|
191
|
+
waypoints.push({ x: entryX, y: toY }); // rise up at the side
|
|
192
|
+
waypoints.push({ x: toX, y: toY }); // slide into position
|
|
193
|
+
} else {
|
|
194
|
+
waypoints.push({ x: toX, y: toY }); // rise directly
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
// Short horizontal or both below corridor — direct path is fine
|
|
199
|
+
waypoints.push({ x: toX, y: toY });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return waypoints;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_startWander() {
|
|
206
|
+
if (!this.station) return;
|
|
207
|
+
this.isWandering = true;
|
|
208
|
+
this._poiAction = null;
|
|
209
|
+
this._stuckTimer = 0;
|
|
210
|
+
this.character.setState('walking');
|
|
211
|
+
|
|
212
|
+
const roll = Math.random();
|
|
213
|
+
const poiList = this._scene?.poiList;
|
|
214
|
+
const canvasW = this._scene?.canvas?.width || 640;
|
|
215
|
+
const floorY = (this._scene?.canvas?.height || 400) * 0.40;
|
|
216
|
+
|
|
217
|
+
let targetX, targetY;
|
|
218
|
+
|
|
219
|
+
if (roll < 0.25 && poiList?.length) {
|
|
220
|
+
const poi = poiList[Math.floor(Math.random() * poiList.length)];
|
|
221
|
+
targetX = poi.x; targetY = poi.y;
|
|
222
|
+
this._poiAction = poi.label;
|
|
223
|
+
this._wanderReturnTimer = 5000 + Math.random() * 8000;
|
|
224
|
+
} else if (roll < 0.40 && this._otherAgents?.length) {
|
|
225
|
+
const others = this._otherAgents.filter(a => a !== this && !a.isWandering);
|
|
226
|
+
if (others.length > 0) {
|
|
227
|
+
const other = others[Math.floor(Math.random() * others.length)];
|
|
228
|
+
targetX = other.character.x + 40;
|
|
229
|
+
targetY = other.character.y + 10;
|
|
230
|
+
this._poiAction = 'colleague';
|
|
231
|
+
this._wanderReturnTimer = 4000 + Math.random() * 5000;
|
|
232
|
+
} else {
|
|
233
|
+
this._setRandomTarget(canvasW, floorY);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
this._setRandomTarget(canvasW, floorY);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Plan waypoint path to target
|
|
242
|
+
this._returnWaypoints = this._planPath(targetX, targetY);
|
|
243
|
+
this._wanderTarget = this._returnWaypoints.shift();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
_setRandomTarget(canvasW, floorY) {
|
|
247
|
+
const canvasH = this._scene?.canvas?.height || 400;
|
|
248
|
+
const floorH = canvasH - floorY;
|
|
249
|
+
const targetX = 40 + Math.random() * (canvasW - 80);
|
|
250
|
+
const targetY = floorY + 10 + Math.random() * (floorH - 50);
|
|
251
|
+
this._returnWaypoints = this._planPath(targetX, targetY);
|
|
252
|
+
this._wanderTarget = this._returnWaypoints.shift();
|
|
253
|
+
this._wanderReturnTimer = 3000 + Math.random() * 4000;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_updateWander(dt) {
|
|
257
|
+
if (!this._wanderTarget) { this._returnToDesk(); return; }
|
|
258
|
+
|
|
259
|
+
const arrived = this._moveToward(dt);
|
|
260
|
+
|
|
261
|
+
if (arrived) {
|
|
262
|
+
// Check if there are more waypoints in the path
|
|
263
|
+
if (this._returnWaypoints && this._returnWaypoints.length > 0) {
|
|
264
|
+
this._wanderTarget = this._returnWaypoints.shift();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// Fully arrived at final destination — do POI action
|
|
268
|
+
if (this._poiAction) {
|
|
269
|
+
this._doPOIAction();
|
|
270
|
+
}
|
|
271
|
+
// Stay for remaining time, then return
|
|
272
|
+
this._poiStayTimer += dt;
|
|
273
|
+
if (this._poiStayTimer > 2000) {
|
|
274
|
+
this._returnToDesk();
|
|
275
|
+
this._poiStayTimer = 0;
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this._wanderReturnTimer -= dt;
|
|
281
|
+
if (this._wanderReturnTimer <= 0) {
|
|
282
|
+
this._returnToDesk();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
_doPOIAction() {
|
|
287
|
+
switch (this._poiAction) {
|
|
288
|
+
case 'cooler':
|
|
289
|
+
this.character.setState('idle');
|
|
290
|
+
this.character.showBubble('💧', 'speech', 2000);
|
|
291
|
+
break;
|
|
292
|
+
case 'window':
|
|
293
|
+
this.character.setState('idle');
|
|
294
|
+
this.character.showBubble('🌤️', 'speech', 2000);
|
|
295
|
+
break;
|
|
296
|
+
case 'bookshelf':
|
|
297
|
+
this.character.setState('idle');
|
|
298
|
+
this.character.showBubble('📖', 'speech', 2000);
|
|
299
|
+
break;
|
|
300
|
+
case 'colleague':
|
|
301
|
+
this.character.setState('idle');
|
|
302
|
+
this.character.showBubble('👋', 'speech', 1500);
|
|
303
|
+
break;
|
|
304
|
+
case 'plant':
|
|
305
|
+
this.character.setState('idle');
|
|
306
|
+
this.character.showBubble('🌿', 'speech', 1500);
|
|
307
|
+
break;
|
|
308
|
+
case 'sofa':
|
|
309
|
+
this.character.setState('idle');
|
|
310
|
+
this.character.showBubble('😌', 'speech', 2500);
|
|
311
|
+
break;
|
|
312
|
+
case 'bar':
|
|
313
|
+
this.character.setState('idle');
|
|
314
|
+
this.character.showBubble('🍺', 'speech', 2000);
|
|
315
|
+
break;
|
|
316
|
+
case 'coffee_machine':
|
|
317
|
+
this.character.setState('idle');
|
|
318
|
+
this.character.showBubble('☕', 'speech', 2000);
|
|
319
|
+
break;
|
|
320
|
+
case 'fridge':
|
|
321
|
+
this.character.setState('idle');
|
|
322
|
+
this.character.showBubble('🧃', 'speech', 1500);
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
this._poiAction = null; // only trigger once
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Smooth return to desk — use corridor path planning */
|
|
329
|
+
_returnToDesk() {
|
|
330
|
+
if (!this.station) { this._snapToDesk(); return; }
|
|
331
|
+
this.isWandering = false;
|
|
332
|
+
this._isReturning = true;
|
|
333
|
+
this._poiAction = null;
|
|
334
|
+
this._poiStayTimer = 0;
|
|
335
|
+
this._stuckTimer = 0;
|
|
336
|
+
this.character.setState('walking');
|
|
337
|
+
|
|
338
|
+
// Use the same corridor-based path planning
|
|
339
|
+
this._returnWaypoints = this._planPath(this.station.charX, this.station.charY);
|
|
340
|
+
this._wanderTarget = this._returnWaypoints.shift();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Move smoothly toward _wanderTarget with collision avoidance. Returns true if arrived. */
|
|
344
|
+
_moveToward(dt) {
|
|
345
|
+
if (!this._wanderTarget) return true;
|
|
346
|
+
|
|
347
|
+
const dx = this._wanderTarget.x - this.character.x;
|
|
348
|
+
const dy = this._wanderTarget.y - this.character.y;
|
|
349
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
350
|
+
|
|
351
|
+
if (dist <= 3) {
|
|
352
|
+
this.character.x = this._wanderTarget.x;
|
|
353
|
+
this.character.y = this._wanderTarget.y;
|
|
354
|
+
this._manualY = true;
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Walking wobble
|
|
359
|
+
this._wobbleTime += dt;
|
|
360
|
+
const wobbleY = Math.sin(this._wobbleTime * 0.008) * 1.5;
|
|
361
|
+
|
|
362
|
+
const prevX = this.character.x;
|
|
363
|
+
const prevY = this.character.y;
|
|
364
|
+
const moveX = (dx / dist) * this._wanderSpeed * dt;
|
|
365
|
+
const moveY = (dy / dist) * this._wanderSpeed * dt + wobbleY * 0.05;
|
|
366
|
+
let newX = this.character.x + moveX;
|
|
367
|
+
let newY = this.character.y + moveY;
|
|
368
|
+
|
|
369
|
+
// ── Collision detection against scene obstacles ──
|
|
370
|
+
const obstacles = this._scene?.obstacles;
|
|
371
|
+
if (obstacles) {
|
|
372
|
+
const cs = this.character.scale || 2.5;
|
|
373
|
+
const charPxW = 32 * cs;
|
|
374
|
+
const charPxH = 32 * cs;
|
|
375
|
+
// Foot hitbox — small rect at character's feet (bottom-center)
|
|
376
|
+
const footW = 24, footH = 12;
|
|
377
|
+
const getFootRect = (cx, cy) => ({
|
|
378
|
+
x: cx + charPxW / 2 - footW / 2,
|
|
379
|
+
y: cy + charPxH - footH,
|
|
380
|
+
w: footW,
|
|
381
|
+
h: footH,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const ownDeskIdx = this.station?.index;
|
|
385
|
+
|
|
386
|
+
for (const obs of obstacles) {
|
|
387
|
+
// Own desk: 3-state logic
|
|
388
|
+
// 1. Sitting → leaving: _outsideOwnDesk=false, allow moving OUT (skip collision)
|
|
389
|
+
// 2. Wandering: _outsideOwnDesk=true, collide normally (can't walk through)
|
|
390
|
+
// 3. Returning to sit: _isReturning, skip collision for entire return journey
|
|
391
|
+
if (obs.deskIndex != null && obs.deskIndex === ownDeskIdx) {
|
|
392
|
+
if (this._isReturning) {
|
|
393
|
+
continue; // returning to own desk — skip own desk for entire path
|
|
394
|
+
}
|
|
395
|
+
if (!this._outsideOwnDesk) {
|
|
396
|
+
continue; // still leaving own desk — let agent walk out
|
|
397
|
+
}
|
|
398
|
+
// otherwise: wandering, collide with own desk normally
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const foot = getFootRect(newX, newY);
|
|
402
|
+
const hit = foot.x < obs.x + obs.w && foot.x + foot.w > obs.x
|
|
403
|
+
&& foot.y < obs.y + obs.h && foot.y + foot.h > obs.y;
|
|
404
|
+
|
|
405
|
+
if (hit) {
|
|
406
|
+
// Try X-only movement (slide vertically along obstacle)
|
|
407
|
+
const footXOnly = getFootRect(newX, this.character.y);
|
|
408
|
+
const xHit = footXOnly.x < obs.x + obs.w && footXOnly.x + footXOnly.w > obs.x
|
|
409
|
+
&& footXOnly.y < obs.y + obs.h && footXOnly.y + footXOnly.h > obs.y;
|
|
410
|
+
|
|
411
|
+
// Try Y-only movement (slide horizontally along obstacle)
|
|
412
|
+
const footYOnly = getFootRect(this.character.x, newY);
|
|
413
|
+
const yHit = footYOnly.x < obs.x + obs.w && footYOnly.x + footYOnly.w > obs.x
|
|
414
|
+
&& footYOnly.y < obs.y + obs.h && footYOnly.y + footYOnly.h > obs.y;
|
|
415
|
+
|
|
416
|
+
if (xHit && yHit) {
|
|
417
|
+
newX = this.character.x;
|
|
418
|
+
newY = this.character.y;
|
|
419
|
+
} else if (xHit) {
|
|
420
|
+
newX = this.character.x; // blocked in X, slide Y
|
|
421
|
+
} else if (yHit) {
|
|
422
|
+
newY = this.character.y; // blocked in Y, slide X
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── Track whether agent has left own desk area ──
|
|
428
|
+
if (!this._outsideOwnDesk) {
|
|
429
|
+
const ownObs = obstacles.find(o => o.deskIndex != null && o.deskIndex === ownDeskIdx);
|
|
430
|
+
if (ownObs) {
|
|
431
|
+
const foot = getFootRect(newX, newY);
|
|
432
|
+
const stillInside = foot.x < ownObs.x + ownObs.w && foot.x + foot.w > ownObs.x
|
|
433
|
+
&& foot.y < ownObs.y + ownObs.h && foot.y + foot.h > ownObs.y;
|
|
434
|
+
if (!stillInside) {
|
|
435
|
+
this._outsideOwnDesk = true; // left the desk — now it becomes a wall
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
this.character.x = newX;
|
|
442
|
+
this.character.y = newY;
|
|
443
|
+
this._manualY = true;
|
|
444
|
+
|
|
445
|
+
// ── Anti-stuck: if position barely changed, agent is stuck ──
|
|
446
|
+
const moved = Math.abs(newX - prevX) + Math.abs(newY - prevY);
|
|
447
|
+
if (moved < 0.1) {
|
|
448
|
+
this._stuckTimer += dt;
|
|
449
|
+
if (this._stuckTimer > 1500) {
|
|
450
|
+
// Stuck for 1.5s — give up on current target and go back to desk
|
|
451
|
+
this._stuckTimer = 0;
|
|
452
|
+
this._returnToDesk();
|
|
453
|
+
}
|
|
454
|
+
} else {
|
|
455
|
+
this._stuckTimer = 0;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Update during return-to-desk walk (with waypoints) */
|
|
462
|
+
_updateMovement(dt) {
|
|
463
|
+
const arrived = this._moveToward(dt);
|
|
464
|
+
if (arrived) {
|
|
465
|
+
// Check if there are more waypoints
|
|
466
|
+
if (this._returnWaypoints && this._returnWaypoints.length > 0) {
|
|
467
|
+
this._wanderTarget = this._returnWaypoints.shift();
|
|
468
|
+
return; // keep walking to next waypoint
|
|
469
|
+
}
|
|
470
|
+
this._isReturning = false;
|
|
471
|
+
this._manualY = false;
|
|
472
|
+
this._returnWaypoints = null;
|
|
473
|
+
this._outsideOwnDesk = false; // back at desk — reset for next departure
|
|
474
|
+
this.character.setState('idle');
|
|
475
|
+
this._wanderTimer = 15000 + Math.random() * 25000;
|
|
476
|
+
this._wanderTarget = null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Emergency snap — for when work starts mid-wander */
|
|
481
|
+
_snapToDesk() {
|
|
482
|
+
this.isWandering = false;
|
|
483
|
+
this._isReturning = false;
|
|
484
|
+
this._manualY = false;
|
|
485
|
+
this._poiAction = null;
|
|
486
|
+
this._poiStayTimer = 0;
|
|
487
|
+
this._wanderTarget = null;
|
|
488
|
+
this._returnWaypoints = null;
|
|
489
|
+
this.character.setState('idle');
|
|
490
|
+
if (this.station) {
|
|
491
|
+
this.character.x = this.station.charX;
|
|
492
|
+
this.character.y = this.station.charY;
|
|
493
|
+
}
|
|
494
|
+
this._wanderTimer = 15000 + Math.random() * 25000;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ── Rendering ──
|
|
498
|
+
|
|
499
|
+
render(ctx) {
|
|
500
|
+
this.character.render(ctx);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Render name tag — called in a separate top-layer phase */
|
|
504
|
+
renderNameTag(ctx) {
|
|
505
|
+
if (this.showNameTag) this._renderNameTag(ctx);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
_renderNameTag(ctx) {
|
|
509
|
+
const cs = this.character.scale;
|
|
510
|
+
const charW = 32 * cs;
|
|
511
|
+
const cx = this.character.x + charW / 2;
|
|
512
|
+
// Position tag well above character head — clear of any bubbles
|
|
513
|
+
const tagBottom = this.character.y - 6;
|
|
514
|
+
const displayName = this.name.length > 10 ? this.name.slice(0, 8) + '..' : this.name;
|
|
515
|
+
|
|
516
|
+
ctx.save();
|
|
517
|
+
ctx.font = '7px "Press Start 2P", monospace';
|
|
518
|
+
|
|
519
|
+
const stateColors = {
|
|
520
|
+
idle: '#00ff88', thinking: '#ffcc00', typing: '#00f0ff',
|
|
521
|
+
executing: '#b44aff', browsing: '#00f0ff', error: '#ff4466',
|
|
522
|
+
sleeping: '#555', walking: '#00ff88', waving: '#00ff88'
|
|
523
|
+
};
|
|
524
|
+
const dotColor = stateColors[this.stateMapper.currentState] || '#888';
|
|
525
|
+
|
|
526
|
+
// Measure for accurate centering
|
|
527
|
+
const textW = ctx.measureText(displayName).width;
|
|
528
|
+
const dotR = 2.5;
|
|
529
|
+
const dotGap = 5;
|
|
530
|
+
const padX = 7;
|
|
531
|
+
const ph = 14;
|
|
532
|
+
const pw = padX + dotR * 2 + dotGap + textW + padX;
|
|
533
|
+
|
|
534
|
+
const pillX = cx - pw / 2;
|
|
535
|
+
const pillY = tagBottom - ph;
|
|
536
|
+
|
|
537
|
+
// Pill background
|
|
538
|
+
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
|
539
|
+
ctx.beginPath();
|
|
540
|
+
if (ctx.roundRect) ctx.roundRect(pillX, pillY, pw, ph, 3);
|
|
541
|
+
else ctx.rect(pillX, pillY, pw, ph);
|
|
542
|
+
ctx.fill();
|
|
543
|
+
|
|
544
|
+
// State dot
|
|
545
|
+
const dotCx = pillX + padX + dotR;
|
|
546
|
+
const dotCy = pillY + ph / 2;
|
|
547
|
+
ctx.fillStyle = dotColor;
|
|
548
|
+
ctx.shadowColor = dotColor;
|
|
549
|
+
ctx.shadowBlur = 4;
|
|
550
|
+
ctx.beginPath();
|
|
551
|
+
ctx.arc(dotCx, dotCy, dotR, 0, Math.PI * 2);
|
|
552
|
+
ctx.fill();
|
|
553
|
+
ctx.shadowBlur = 0;
|
|
554
|
+
|
|
555
|
+
// Name
|
|
556
|
+
ctx.fillStyle = '#e0e0f0';
|
|
557
|
+
ctx.textAlign = 'left';
|
|
558
|
+
ctx.textBaseline = 'middle';
|
|
559
|
+
ctx.fillText(displayName, dotCx + dotR + dotGap, dotCy + 0.5);
|
|
560
|
+
ctx.restore();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
hitTest(clickX, clickY) {
|
|
564
|
+
const cs = this.character.scale;
|
|
565
|
+
const cw = 32 * cs;
|
|
566
|
+
const ch = 32 * cs;
|
|
567
|
+
// Generous hit area (include name tag space)
|
|
568
|
+
return (
|
|
569
|
+
clickX >= this.character.x - 10 &&
|
|
570
|
+
clickX <= this.character.x + cw + 10 &&
|
|
571
|
+
clickY >= this.character.y - 20 &&
|
|
572
|
+
clickY <= this.character.y + ch + 10
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
updateConfig(cfg) {
|
|
577
|
+
this.character.updateConfig(cfg);
|
|
578
|
+
const saved = AgentSlot._loadConfigs();
|
|
579
|
+
saved[this.agentId] = this.character.config;
|
|
580
|
+
AgentSlot._saveConfigs(saved);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
handleEvent(event) { this.stateMapper.handleEvent(event); }
|
|
584
|
+
destroy() { this.stateMapper.destroy(); }
|
|
585
|
+
|
|
586
|
+
static _loadConfigs() {
|
|
587
|
+
try { return JSON.parse(localStorage.getItem('clawskin_agent_configs') || '{}'); }
|
|
588
|
+
catch { return {}; }
|
|
589
|
+
}
|
|
590
|
+
static _saveConfigs(configs) {
|
|
591
|
+
try { localStorage.setItem('clawskin_agent_configs', JSON.stringify(configs)); }
|
|
592
|
+
catch {}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (typeof window !== 'undefined') window.AgentSlot = AgentSlot;
|