Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { addDisposableGenericMouseDownListener, addDisposableGenericMouseMoveListener, addDisposableListener, EventType, getWindow, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js';
7
import { createInstantHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';
8
import { Codicon } from '../../../../base/common/codicons.js';
9
import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
10
import { ThemeIcon } from '../../../../base/common/themables.js';
11
import { localize } from '../../../../nls.js';
12
import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js';
13
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
14
import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
15
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
16
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
17
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
18
import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js';
19
import { SessionsAquariumActiveContext } from '../../../common/contextkeys.js';
20
import { disposeSharedFishDefs, Fish, pickRandomSpecies } from './fish.js';
21
22
export const SESSIONS_DEVELOPER_JOY_ENABLED_SETTING = 'sessions.developerJoy.enabled';
23
24
const FISH_COUNT = 50;
25
const FISH_MIN_SIZE = 22;
26
const FISH_MAX_SIZE = 48;
27
28
const SCATTER_RADIUS = 145;
29
const SCATTER_RADIUS_SQ = SCATTER_RADIUS * SCATTER_RADIUS;
30
const EAT_RADIUS = 14;
31
const FOOD_DETECT_RADIUS = 160;
32
const FOOD_DETECT_RADIUS_SQ = FOOD_DETECT_RADIUS * FOOD_DETECT_RADIUS;
33
const MAX_FOOD = 12;
34
/** Soft margin where fish start to turn back. */
35
const WALL_MARGIN = 36;
36
37
const BASE_SPEED = 24;
38
const MAX_SPEED = 50;
39
const MAX_SPEED_SQ = MAX_SPEED * MAX_SPEED;
40
const PANIC_MAX_SPEED = 240;
41
const PANIC_MAX_SPEED_SQ = PANIC_MAX_SPEED * PANIC_MAX_SPEED;
42
const PANIC_DURATION_MS = 600;
43
const EXIT_DURATION_MS = 900;
44
45
/** Decorative effect: 30Hz keeps motion smooth enough while halving JS work. */
46
const ACTIVE_FRAME_INTERVAL_MS = 1000 / 30;
47
48
/** Per-fish per-second probability of starting a spontaneous burst. */
49
const DART_RATE_PER_SECOND = 0.04;
50
const DART_IMPULSE = 150;
51
52
const ENABLED_STORAGE_KEY = 'sessions.developerJoy.enabled';
53
54
interface IFoodPellet {
55
readonly element: HTMLDivElement;
56
positionX: number;
57
positionY: number;
58
fallSpeed: number;
59
}
60
61
/**
62
* Owns the toggle button(s), the persisted on/off preference, and the active
63
* aquarium. Hosts call {@link IAquariumService.mountToggle} to attach a button
64
* as a child of their container; the active aquarium itself is mounted inside
65
* the chat bar part so the chat input naturally paints on top of the water.
66
*/
67
export const IAquariumService = createDecorator<IAquariumService>('aquariumService');
68
69
export interface IAquariumService {
70
readonly _serviceBrand: undefined;
71
72
/**
73
* Mount a toggle button into `parent`. Returns a disposable that removes
74
* the button and tears down the active aquarium if it was the last mount.
75
*/
76
mountToggle(parent: HTMLElement): IDisposable;
77
}
78
79
interface IMountedToggle {
80
readonly button: HTMLButtonElement;
81
}
82
83
export class AquariumService extends Disposable implements IAquariumService {
84
85
declare readonly _serviceBrand: undefined;
86
87
private readonly mainContainer: HTMLElement;
88
89
private readonly mounts = new Set<IMountedToggle>();
90
private readonly activeRef = this._register(new MutableDisposable<IActiveAquarium>());
91
private readonly pendingExit = this._register(new MutableDisposable<IDisposable>());
92
private readonly activeContextKey: IContextKey<boolean>;
93
94
constructor(
95
@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,
96
@IContextKeyService contextKeyService: IContextKeyService,
97
@IHoverService private readonly hoverService: IHoverService,
98
@IStorageService private readonly storageService: IStorageService,
99
@IConfigurationService private readonly configurationService: IConfigurationService,
100
@IAccessibilityService private readonly accessibilityService: IAccessibilityService,
101
) {
102
super();
103
104
this.mainContainer = layoutService.mainContainer;
105
this.activeContextKey = SessionsAquariumActiveContext.bindTo(contextKeyService);
106
107
this._register(this.configurationService.onDidChangeConfiguration(e => {
108
if (e.affectsConfiguration(SESSIONS_DEVELOPER_JOY_ENABLED_SETTING)) {
109
this.applyFeatureEnabledState();
110
}
111
}));
112
}
113
114
mountToggle(parent: HTMLElement): IDisposable {
115
const doc = parent.ownerDocument;
116
const button = doc.createElement('button');
117
button.className = 'agents-aquarium-toggle';
118
button.type = 'button';
119
this.updateToggleButtonVisual(button, !!this.activeRef.value);
120
121
const store = new DisposableStore();
122
store.add(addDisposableListener(button, EventType.CLICK, e => {
123
// Don't bubble into the chat widget's own click handlers.
124
e.preventDefault();
125
e.stopPropagation();
126
this.toggle();
127
}));
128
const hoverDelegate = store.add(createInstantHoverDelegate());
129
store.add(this.hoverService.setupManagedHover(
130
hoverDelegate,
131
button,
132
() => this.getToggleLabel(!!this.activeRef.value),
133
));
134
135
parent.appendChild(button);
136
137
const mount: IMountedToggle = { button };
138
this.mounts.add(mount);
139
this.applyFeatureEnabledStateForButton(button);
140
141
// First mount with the user's stored preference on — auto-restore.
142
if (this.isFeatureEnabled() && this.isStoredEnabled() && !this.activeRef.value) {
143
this.activate(/* persist */ false);
144
}
145
146
return toDisposable(() => {
147
store.dispose();
148
button.remove();
149
this.mounts.delete(mount);
150
// Last host gone — tear down without persisting so the user's
151
// preference for next time stays as it was.
152
if (this.mounts.size === 0 && this.activeRef.value) {
153
this.deactivate(/* persist */ false);
154
}
155
});
156
}
157
158
private isFeatureEnabled(): boolean {
159
return this.configurationService.getValue<boolean>(SESSIONS_DEVELOPER_JOY_ENABLED_SETTING) === true;
160
}
161
162
private isStoredEnabled(): boolean {
163
return this.storageService.getBoolean(ENABLED_STORAGE_KEY, StorageScope.APPLICATION, false);
164
}
165
166
private setStoredEnabled(enabled: boolean): void {
167
this.storageService.store(ENABLED_STORAGE_KEY, enabled, StorageScope.APPLICATION, StorageTarget.USER);
168
}
169
170
private applyFeatureEnabledState(): void {
171
for (const mount of this.mounts) {
172
this.applyFeatureEnabledStateForButton(mount.button);
173
}
174
if (!this.isFeatureEnabled() && this.activeRef.value) {
175
// Setting turned off — don't persist so the prior preference survives a re-enable.
176
this.deactivate(/* persist */ false);
177
} else if (this.isFeatureEnabled() && this.isStoredEnabled() && !this.activeRef.value && this.mounts.size > 0) {
178
this.activate(/* persist */ false);
179
}
180
}
181
182
private applyFeatureEnabledStateForButton(button: HTMLButtonElement): void {
183
button.style.display = this.isFeatureEnabled() ? '' : 'none';
184
}
185
186
private updateToggleButtonVisual(button: HTMLButtonElement, active: boolean): void {
187
button.classList.toggle('active', active);
188
// Build the icon as a real DOM child instead of innerHTML to satisfy Trusted Types.
189
button.replaceChildren();
190
const iconSpan = button.ownerDocument.createElement('span');
191
if (active) {
192
const iconClasses = ThemeIcon.asClassName(Codicon.close).split(/\s+/).filter(Boolean);
193
for (const cls of iconClasses) {
194
iconSpan.classList.add(cls);
195
}
196
} else {
197
iconSpan.classList.add('agents-aquarium-toggle-logo');
198
}
199
button.appendChild(iconSpan);
200
const label = this.getToggleLabel(active);
201
button.setAttribute('aria-pressed', String(active));
202
button.setAttribute('aria-label', label);
203
}
204
205
private getToggleLabel(active: boolean): string {
206
return active ? localize('aquarium.hide', "Hide Aquarium") : localize('aquarium.show', "Show Aquarium");
207
}
208
209
private toggle(): void {
210
if (this.activeRef.value) {
211
this.deactivate(/* persist */ true);
212
} else {
213
this.activate(/* persist */ true);
214
}
215
}
216
217
private updateAllToggleButtonsVisual(active: boolean): void {
218
for (const mount of this.mounts) {
219
this.updateToggleButtonVisual(mount.button, active);
220
}
221
}
222
223
/** @param persist false when restoring previously-stored state. */
224
private activate(persist: boolean): void {
225
if (this.activeRef.value) {
226
return;
227
}
228
// Cancel any in-flight exit so its delayed dispose can't tear down
229
// the new aquarium's shared SVG defs.
230
this.pendingExit.clear();
231
let active: IActiveAquarium | undefined;
232
try {
233
active = createActiveAquarium(this.mainContainer, this.layoutService, this.accessibilityService);
234
} catch (e) {
235
console.error('[aquarium] failed to activate', e);
236
return;
237
}
238
// No host (e.g. chat bar isn't visible yet) — leave the toggle
239
// untouched and don't persist; a later toggle attempt will retry.
240
if (!active) {
241
return;
242
}
243
this.activeRef.value = active;
244
this.activeContextKey.set(true);
245
this.updateAllToggleButtonsVisual(true);
246
if (persist) {
247
this.setStoredEnabled(true);
248
}
249
}
250
251
/** @param persist false when tearing down for non-user reasons. */
252
private deactivate(persist: boolean): void {
253
// Detach from activeRef WITHOUT disposing (clearAndLeak) so the exit
254
// animation can run; the returned handle from active.exit() is parked
255
// in `pendingExit` and disposes the underlying store either when the
256
// animation completes, when the service tears down, or when a rapid
257
// re-activate replaces it.
258
const active = this.activeRef.clearAndLeak();
259
if (!active) {
260
return;
261
}
262
this.activeContextKey.set(false);
263
this.updateAllToggleButtonsVisual(false);
264
const pending = active.exit(() => {
265
if (this.pendingExit.value === pending) {
266
this.pendingExit.clear();
267
}
268
});
269
this.pendingExit.value = pending;
270
if (persist) {
271
this.setStoredEnabled(false);
272
}
273
}
274
}
275
276
interface IActiveAquarium extends IDisposable {
277
/**
278
* Trigger the exit animation and dispose when it completes. Disposing the
279
* returned handle before the animation finishes disposes immediately.
280
*/
281
exit(onDidComplete: () => void): IDisposable;
282
}
283
284
/**
285
* Build the live aquarium: water, fish, food, mouse handling, RAF loop.
286
* Returns `undefined` if the chat bar isn't available so callers can bail
287
* without leaving the toggle button stuck in an "active but invisible" state.
288
*/
289
function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbenchLayoutService, accessibilityService: IAccessibilityService): IActiveAquarium | undefined {
290
const targetWindow = getWindow(mainContainer);
291
292
// Host inside the chat bar so chat input UI naturally paints on top —
293
// no z-index gymnastics required.
294
const chatBar = layoutService.getContainer(targetWindow, Parts.CHATBAR_PART);
295
if (!chatBar || !layoutService.isVisible(Parts.CHATBAR_PART, targetWindow)) {
296
return undefined;
297
}
298
299
const store = new DisposableStore();
300
const doc = targetWindow.document;
301
const water = doc.createElement('div');
302
water.className = 'agents-aquarium-water';
303
// Decorative: hide the entire subtree from a11y tree.
304
water.setAttribute('aria-hidden', 'true');
305
// First child so subsequent chat bar content paints over it.
306
chatBar.insertBefore(water, chatBar.firstChild);
307
store.add(toDisposable(() => water.remove()));
308
309
const fishLayer = doc.createElement('div');
310
fishLayer.className = 'agents-aquarium-fish-layer';
311
water.appendChild(fishLayer);
312
313
const foodLayer = doc.createElement('div');
314
foodLayer.className = 'agents-aquarium-food-layer';
315
water.appendChild(foodLayer);
316
317
const bounds = { width: 0, height: 0 };
318
// Cached so the per-mousemove handler doesn't trigger a layout flush.
319
const waterScreenOffset = { left: 0, top: 0 };
320
const updateBounds = () => {
321
bounds.width = water.clientWidth;
322
bounds.height = water.clientHeight;
323
const rect = water.getBoundingClientRect();
324
waterScreenOffset.left = rect.left;
325
waterScreenOffset.top = rect.top;
326
};
327
328
const fish: Fish[] = [];
329
330
updateBounds();
331
const resizeObserver = new ResizeObserver(() => {
332
updateBounds();
333
for (const f of fish) {
334
f.positionX = Math.min(f.positionX, Math.max(0, bounds.width - f.size));
335
f.positionY = Math.min(f.positionY, Math.max(0, bounds.height - f.size));
336
}
337
});
338
resizeObserver.observe(water);
339
store.add(toDisposable(() => resizeObserver.disconnect()));
340
341
for (let i = 0; i < FISH_COUNT; i++) {
342
const size = randomBetween(FISH_MIN_SIZE, FISH_MAX_SIZE);
343
const angle = Math.random() * Math.PI * 2;
344
const speed = randomBetween(BASE_SPEED * 0.6, BASE_SPEED * 1.2);
345
const f = new Fish({
346
species: pickRandomSpecies(),
347
size,
348
positionX: randomBetween(0, Math.max(1, bounds.width - size)),
349
positionY: randomBetween(0, Math.max(1, bounds.height - size)),
350
velocityX: Math.cos(angle) * speed,
351
velocityY: Math.sin(angle) * speed,
352
}, targetWindow.document);
353
fish.push(f);
354
}
355
// Spawn in two batches: first half synchronous (single layout pass via
356
// DocumentFragment), rest on the next frame so the toggle click stays snappy.
357
const SYNC_BATCH = Math.ceil(FISH_COUNT / 2);
358
const firstBatch = targetWindow.document.createDocumentFragment();
359
for (let i = 0; i < Math.min(SYNC_BATCH, fish.length); i++) {
360
firstBatch.appendChild(fish[i].element);
361
}
362
fishLayer.appendChild(firstBatch);
363
let exiting = false;
364
365
if (SYNC_BATCH < fish.length) {
366
const deferred = scheduleAtNextAnimationFrame(targetWindow, () => {
367
if (exiting) {
368
return;
369
}
370
const restBatch = targetWindow.document.createDocumentFragment();
371
for (let i = SYNC_BATCH; i < fish.length; i++) {
372
restBatch.appendChild(fish[i].element);
373
}
374
fishLayer.appendChild(restBatch);
375
// Add `.visible` on the NEXT frame so a paint at opacity:0 happens
376
// first — guarantees the CSS transition fires.
377
const fadeIn = scheduleAtNextAnimationFrame(targetWindow, () => {
378
if (exiting) {
379
return;
380
}
381
for (let i = SYNC_BATCH; i < fish.length; i++) {
382
const localIndex = i - SYNC_BATCH;
383
const delay = Math.min(localIndex * 12, 400);
384
fish[i].element.style.transitionDelay = `${delay}ms`;
385
fish[i].element.classList.add('visible');
386
}
387
});
388
store.add(fadeIn);
389
});
390
store.add(deferred);
391
}
392
store.add(toDisposable(() => {
393
for (const f of fish) {
394
f.element.remove();
395
}
396
// Tear down shared SVG defs so we don't leak across reloads.
397
disposeSharedFishDefs(targetWindow.document);
398
}));
399
400
const food: IFoodPellet[] = [];
401
const removeFood = (pellet: IFoodPellet) => {
402
const idx = food.indexOf(pellet);
403
if (idx !== -1) {
404
food.splice(idx, 1);
405
pellet.element.remove();
406
}
407
};
408
409
// Listen on the main container so we always know cursor position even
410
// when over the chat input (water has pointer-events:none).
411
//
412
// Coalesce updateBounds() across scroll/resize storms: scroll with capture
413
// fires for ANY descendant scroll, and updateBounds() reads layout. Mark
414
// dirty here and let the RAF tick refresh at most once per frame.
415
let boundsDirty = false;
416
const markBoundsDirty = () => { boundsDirty = true; };
417
store.add(addDisposableListener(targetWindow, EventType.RESIZE, markBoundsDirty, { passive: true }));
418
store.add(addDisposableListener(targetWindow, 'scroll', markBoundsDirty, { passive: true, capture: true }));
419
420
let mouseX = -1e6;
421
let mouseY = -1e6;
422
const resetMousePosition = () => {
423
mouseX = -1e6;
424
mouseY = -1e6;
425
};
426
// Generic helpers so this also works under iOS pointer events.
427
store.add(addDisposableGenericMouseMoveListener(mainContainer, (e: MouseEvent) => {
428
mouseX = e.clientX - waterScreenOffset.left;
429
mouseY = e.clientY - waterScreenOffset.top;
430
}));
431
// Both mouseleave AND pointerleave so reset works on touch/pointer-only platforms.
432
store.add(addDisposableListener(mainContainer, EventType.MOUSE_LEAVE, resetMousePosition, { passive: true }));
433
store.add(addDisposableListener(mainContainer, EventType.POINTER_LEAVE, resetMousePosition, { passive: true }));
434
435
store.add(addDisposableGenericMouseDownListener(mainContainer, (e: MouseEvent) => {
436
// Only spawn food on plain left clicks against background-ish surfaces.
437
if (e.button !== 0) {
438
return;
439
}
440
const target = e.target as HTMLElement | null;
441
if (!isBackgroundClick(target)) {
442
return;
443
}
444
// Refresh once to be safe (mousedown is rare).
445
updateBounds();
446
const dropX = e.clientX - waterScreenOffset.left;
447
const dropY = e.clientY - waterScreenOffset.top;
448
if (dropX < 0 || dropY < 0 || dropX > bounds.width || dropY > bounds.height) {
449
return;
450
}
451
spawnFood(dropX, dropY);
452
}));
453
454
function spawnFood(dropX: number, dropY: number): void {
455
// Cap concurrent food: drop the oldest pellet to make room.
456
while (food.length >= MAX_FOOD) {
457
const oldest = food[0];
458
removeFood(oldest);
459
}
460
const el = doc.createElement('div');
461
el.className = 'agents-aquarium-food';
462
el.style.transform = `translate(${dropX}px, ${dropY}px)`;
463
foodLayer.appendChild(el);
464
food.push({ element: el, positionX: dropX, positionY: dropY, fallSpeed: randomBetween(20, 35) });
465
}
466
467
let lastFrame = performance.now();
468
let rafDisposable: IDisposable | undefined;
469
470
const stopAnimation = () => {
471
rafDisposable?.dispose();
472
rafDisposable = undefined;
473
};
474
const startAnimation = () => {
475
if (rafDisposable || accessibilityService.isMotionReduced()) {
476
return;
477
}
478
lastFrame = performance.now();
479
rafDisposable = scheduleAtNextAnimationFrame(targetWindow, tick);
480
};
481
482
const tick = () => {
483
rafDisposable = undefined;
484
const now = performance.now();
485
const elapsedMs = now - lastFrame;
486
if (elapsedMs < ACTIVE_FRAME_INTERVAL_MS) {
487
rafDisposable = scheduleAtNextAnimationFrame(targetWindow, tick);
488
return;
489
}
490
491
const dtMs = Math.min(elapsedMs, 100); // clamp big stalls
492
const dt = dtMs / 1000;
493
lastFrame = now;
494
495
if (boundsDirty) {
496
boundsDirty = false;
497
updateBounds();
498
}
499
500
// Skip work when window is hidden (RAF stays alive lazily).
501
if (!accessibilityService.isMotionReduced() && targetWindow.document.visibilityState !== 'hidden') {
502
updateFood(dt);
503
updateFish(dt);
504
}
505
506
if (!accessibilityService.isMotionReduced()) {
507
rafDisposable = scheduleAtNextAnimationFrame(targetWindow, tick);
508
}
509
};
510
511
function updateFood(dt: number): void {
512
for (let i = food.length - 1; i >= 0; i--) {
513
const pellet = food[i];
514
pellet.positionY += pellet.fallSpeed * dt;
515
pellet.element.style.transform = `translate(${pellet.positionX.toFixed(1)}px, ${pellet.positionY.toFixed(1)}px)`;
516
if (pellet.positionY > bounds.height + 10) {
517
removeFood(pellet);
518
}
519
}
520
}
521
522
function updateFish(dt: number): void {
523
const now = performance.now();
524
for (const f of fish) {
525
const centerX = f.positionX + f.size / 2;
526
const centerY = f.positionY + f.size / 2;
527
528
// Wall steering: turn the heading (not just acceleration) away from
529
// walls, otherwise fish park against the edge with their thrust
530
// pinning them in place.
531
const wallEscapeAngle = computeWallAvoidAngle(centerX, centerY, bounds.width, bounds.height);
532
if (wallEscapeAngle !== undefined) {
533
// Turn at up to 4 rad/s toward the safe direction.
534
const turnDelta = shortestAngleDelta(f.wanderAngle, wallEscapeAngle);
535
const maxTurnPerFrame = 4 * dt;
536
f.wanderAngle += Math.max(-maxTurnPerFrame, Math.min(maxTurnPerFrame, turnDelta));
537
} else {
538
// Free water: drift the heading by a small random delta.
539
f.wanderAngle += (Math.random() - 0.5) * 1.2 * dt + (Math.random() - 0.5) * 0.04;
540
}
541
542
const thrust = 32;
543
let accelX = Math.cos(f.wanderAngle) * thrust;
544
let accelY = Math.sin(f.wanderAngle) * thrust;
545
546
// Spontaneous dart with brief panic so it can exceed normal max speed.
547
if (Math.random() < DART_RATE_PER_SECOND * dt) {
548
const dartAngle = Math.random() * Math.PI * 2;
549
f.velocityX += Math.cos(dartAngle) * DART_IMPULSE;
550
f.velocityY += Math.sin(dartAngle) * DART_IMPULSE;
551
f.panicUntil = now + PANIC_DURATION_MS;
552
}
553
554
// Wall repel — backstop so a fish entering the margin is pushed inward immediately.
555
if (centerX < WALL_MARGIN) {
556
accelX += (WALL_MARGIN - centerX) * 6;
557
} else if (centerX > bounds.width - WALL_MARGIN) {
558
accelX -= (centerX - (bounds.width - WALL_MARGIN)) * 6;
559
}
560
if (centerY < WALL_MARGIN) {
561
accelY += (WALL_MARGIN - centerY) * 6;
562
} else if (centerY > bounds.height - WALL_MARGIN) {
563
accelY -= (centerY - (bounds.height - WALL_MARGIN)) * 6;
564
}
565
566
// Mouse scatter
567
const mouseDeltaX = centerX - mouseX;
568
const mouseDeltaY = centerY - mouseY;
569
const mouseDistSq = mouseDeltaX * mouseDeltaX + mouseDeltaY * mouseDeltaY;
570
if (mouseDistSq < SCATTER_RADIUS_SQ) {
571
const mouseDist = Math.max(Math.sqrt(mouseDistSq), 1);
572
const force = (1 - mouseDist / SCATTER_RADIUS) * 1100;
573
accelX += (mouseDeltaX / mouseDist) * force;
574
accelY += (mouseDeltaY / mouseDist) * force;
575
f.panicUntil = now + PANIC_DURATION_MS;
576
}
577
578
// Seek nearest food within FOOD_DETECT_RADIUS
579
let nearestPellet: IFoodPellet | undefined;
580
let nearestDistSq = FOOD_DETECT_RADIUS_SQ;
581
for (const pellet of food) {
582
const foodDeltaX = pellet.positionX - centerX;
583
const foodDeltaY = pellet.positionY - centerY;
584
const distSq = foodDeltaX * foodDeltaX + foodDeltaY * foodDeltaY;
585
if (distSq < nearestDistSq) {
586
nearestDistSq = distSq;
587
nearestPellet = pellet;
588
}
589
}
590
if (nearestPellet) {
591
const nearestDist = Math.max(Math.sqrt(nearestDistSq), 1);
592
if (nearestDist < EAT_RADIUS) {
593
removeFood(nearestPellet);
594
} else {
595
accelX += (nearestPellet.positionX - centerX) / nearestDist * 200;
596
accelY += (nearestPellet.positionY - centerY) / nearestDist * 200;
597
}
598
}
599
600
f.velocityX += accelX * dt;
601
f.velocityY += accelY * dt;
602
603
const speedSq = f.velocityX * f.velocityX + f.velocityY * f.velocityY;
604
const maxSpeed = now < f.panicUntil ? PANIC_MAX_SPEED : MAX_SPEED;
605
const maxSpeedSq = now < f.panicUntil ? PANIC_MAX_SPEED_SQ : MAX_SPEED_SQ;
606
if (speedSq > maxSpeedSq) {
607
const speed = Math.sqrt(speedSq);
608
f.velocityX = (f.velocityX / speed) * maxSpeed;
609
f.velocityY = (f.velocityY / speed) * maxSpeed;
610
}
611
612
f.positionX += f.velocityX * dt;
613
f.positionY += f.velocityY * dt;
614
615
// Hard clamp safety net.
616
f.positionX = clamp(f.positionX, -f.size * 0.25, bounds.width - f.size * 0.75);
617
f.positionY = clamp(f.positionY, -f.size * 0.25, bounds.height - f.size * 0.75);
618
619
f.applyTransform(dt);
620
}
621
}
622
623
store.add(accessibilityService.onDidChangeReducedMotion(() => {
624
if (accessibilityService.isMotionReduced()) {
625
stopAnimation();
626
} else {
627
startAnimation();
628
}
629
}));
630
store.add(toDisposable(() => stopAnimation()));
631
startAnimation();
632
633
// First-batch fade-in (the deferred batch fades in when it mounts).
634
const fadeIn = scheduleAtNextAnimationFrame(targetWindow, () => {
635
if (exiting) {
636
return;
637
}
638
water.classList.add('visible');
639
for (let i = 0; i < Math.min(SYNC_BATCH, fish.length); i++) {
640
const f = fish[i];
641
// Slight stagger, capped at ~400ms so it doesn't drag on.
642
const delay = Math.min(i * 12, 400);
643
f.element.style.transitionDelay = `${delay}ms`;
644
f.element.classList.add('visible');
645
}
646
});
647
store.add(fadeIn);
648
649
const result = new class extends Disposable implements IActiveAquarium {
650
651
constructor() {
652
super();
653
this._register(store);
654
}
655
656
exit(onDidComplete: () => void): IDisposable {
657
if (exiting) {
658
return toDisposable(() => this.dispose());
659
}
660
exiting = true;
661
662
for (let i = 0; i < fish.length; i++) {
663
const f = fish[i];
664
const delay = Math.min(i * 12, 400);
665
f.element.style.transitionDelay = `${delay}ms`;
666
f.element.classList.remove('visible');
667
}
668
water.classList.remove('visible');
669
670
let timer: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
671
timer = undefined;
672
this.dispose();
673
onDidComplete();
674
}, EXIT_DURATION_MS);
675
return toDisposable(() => {
676
if (timer !== undefined) {
677
clearTimeout(timer);
678
timer = undefined;
679
}
680
this.dispose();
681
});
682
}
683
};
684
685
return result;
686
}
687
688
/** True for clicks not on a control — i.e. safe targets for spawning food. */
689
function isBackgroundClick(target: HTMLElement | null): boolean {
690
if (!target) {
691
return false;
692
}
693
if (target.closest('input, textarea, select, button, a, [role="button"], [role="link"], [role="textbox"], [role="combobox"], [role="menuitem"], [role="tab"], .monaco-editor, .scroll-decoration, .monaco-list-row')) {
694
return false;
695
}
696
return true;
697
}
698
699
function randomBetween(min: number, max: number): number {
700
return min + Math.random() * (max - min);
701
}
702
703
function clamp(value: number, min: number, max: number): number {
704
if (max < min) {
705
return min;
706
}
707
return Math.min(Math.max(value, min), max);
708
}
709
710
/**
711
* If the fish is inside the wall margin, return the heading (radians) pointing
712
* back into open water. Returns `undefined` when the fish is comfortably away
713
* from all walls. Direction sums per-wall vectors weighted by encroachment,
714
* with a small tangential perturbation so neighbors don't all converge to the
715
* same heading.
716
*/
717
function computeWallAvoidAngle(centerX: number, centerY: number, width: number, height: number): number | undefined {
718
let escapeX = 0;
719
let escapeY = 0;
720
if (centerX < WALL_MARGIN) {
721
escapeX += (WALL_MARGIN - centerX) / WALL_MARGIN;
722
} else if (centerX > width - WALL_MARGIN) {
723
escapeX -= (centerX - (width - WALL_MARGIN)) / WALL_MARGIN;
724
}
725
if (centerY < WALL_MARGIN) {
726
escapeY += (WALL_MARGIN - centerY) / WALL_MARGIN;
727
} else if (centerY > height - WALL_MARGIN) {
728
escapeY -= (centerY - (height - WALL_MARGIN)) / WALL_MARGIN;
729
}
730
if (escapeX === 0 && escapeY === 0) {
731
return undefined;
732
}
733
return Math.atan2(escapeY, escapeX) + (Math.random() - 0.5) * 0.4;
734
}
735
736
/** Smallest signed angular delta from `from` to `to`, in [-PI, PI]. */
737
function shortestAngleDelta(from: number, to: number): number {
738
let delta = (to - from) % (Math.PI * 2);
739
if (delta > Math.PI) {
740
delta -= Math.PI * 2;
741
} else if (delta < -Math.PI) {
742
delta += Math.PI * 2;
743
}
744
return delta;
745
}
746
747