Path: blob/main/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { addDisposableGenericMouseDownListener, addDisposableGenericMouseMoveListener, addDisposableListener, EventType, getWindow, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js';6import { createInstantHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';7import { Codicon } from '../../../../base/common/codicons.js';8import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';9import { ThemeIcon } from '../../../../base/common/themables.js';10import { localize } from '../../../../nls.js';11import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js';12import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';13import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';14import { IHoverService } from '../../../../platform/hover/browser/hover.js';15import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';16import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';17import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js';18import { SessionsAquariumActiveContext } from '../../../common/contextkeys.js';19import { disposeSharedFishDefs, Fish, pickRandomSpecies } from './fish.js';2021export const SESSIONS_DEVELOPER_JOY_ENABLED_SETTING = 'sessions.developerJoy.enabled';2223const FISH_COUNT = 50;24const FISH_MIN_SIZE = 22;25const FISH_MAX_SIZE = 48;2627const SCATTER_RADIUS = 145;28const SCATTER_RADIUS_SQ = SCATTER_RADIUS * SCATTER_RADIUS;29const EAT_RADIUS = 14;30const FOOD_DETECT_RADIUS = 160;31const FOOD_DETECT_RADIUS_SQ = FOOD_DETECT_RADIUS * FOOD_DETECT_RADIUS;32const MAX_FOOD = 12;33/** Soft margin where fish start to turn back. */34const WALL_MARGIN = 36;3536const BASE_SPEED = 24;37const MAX_SPEED = 50;38const MAX_SPEED_SQ = MAX_SPEED * MAX_SPEED;39const PANIC_MAX_SPEED = 240;40const PANIC_MAX_SPEED_SQ = PANIC_MAX_SPEED * PANIC_MAX_SPEED;41const PANIC_DURATION_MS = 600;42const EXIT_DURATION_MS = 900;4344/** Decorative effect: 30Hz keeps motion smooth enough while halving JS work. */45const ACTIVE_FRAME_INTERVAL_MS = 1000 / 30;4647/** Per-fish per-second probability of starting a spontaneous burst. */48const DART_RATE_PER_SECOND = 0.04;49const DART_IMPULSE = 150;5051const ENABLED_STORAGE_KEY = 'sessions.developerJoy.enabled';5253interface IFoodPellet {54readonly element: HTMLDivElement;55positionX: number;56positionY: number;57fallSpeed: number;58}5960/**61* Owns the toggle button(s), the persisted on/off preference, and the active62* aquarium. Hosts call {@link IAquariumService.mountToggle} to attach a button63* as a child of their container; the active aquarium itself is mounted inside64* the chat bar part so the chat input naturally paints on top of the water.65*/66export const IAquariumService = createDecorator<IAquariumService>('aquariumService');6768export interface IAquariumService {69readonly _serviceBrand: undefined;7071/**72* Mount a toggle button into `parent`. Returns a disposable that removes73* the button and tears down the active aquarium if it was the last mount.74*/75mountToggle(parent: HTMLElement): IDisposable;76}7778interface IMountedToggle {79readonly button: HTMLButtonElement;80}8182export class AquariumService extends Disposable implements IAquariumService {8384declare readonly _serviceBrand: undefined;8586private readonly mainContainer: HTMLElement;8788private readonly mounts = new Set<IMountedToggle>();89private readonly activeRef = this._register(new MutableDisposable<IActiveAquarium>());90private readonly pendingExit = this._register(new MutableDisposable<IDisposable>());91private readonly activeContextKey: IContextKey<boolean>;9293constructor(94@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,95@IContextKeyService contextKeyService: IContextKeyService,96@IHoverService private readonly hoverService: IHoverService,97@IStorageService private readonly storageService: IStorageService,98@IConfigurationService private readonly configurationService: IConfigurationService,99@IAccessibilityService private readonly accessibilityService: IAccessibilityService,100) {101super();102103this.mainContainer = layoutService.mainContainer;104this.activeContextKey = SessionsAquariumActiveContext.bindTo(contextKeyService);105106this._register(this.configurationService.onDidChangeConfiguration(e => {107if (e.affectsConfiguration(SESSIONS_DEVELOPER_JOY_ENABLED_SETTING)) {108this.applyFeatureEnabledState();109}110}));111}112113mountToggle(parent: HTMLElement): IDisposable {114const doc = parent.ownerDocument;115const button = doc.createElement('button');116button.className = 'agents-aquarium-toggle';117button.type = 'button';118this.updateToggleButtonVisual(button, !!this.activeRef.value);119120const store = new DisposableStore();121store.add(addDisposableListener(button, EventType.CLICK, e => {122// Don't bubble into the chat widget's own click handlers.123e.preventDefault();124e.stopPropagation();125this.toggle();126}));127const hoverDelegate = store.add(createInstantHoverDelegate());128store.add(this.hoverService.setupManagedHover(129hoverDelegate,130button,131() => this.getToggleLabel(!!this.activeRef.value),132));133134parent.appendChild(button);135136const mount: IMountedToggle = { button };137this.mounts.add(mount);138this.applyFeatureEnabledStateForButton(button);139140// First mount with the user's stored preference on — auto-restore.141if (this.isFeatureEnabled() && this.isStoredEnabled() && !this.activeRef.value) {142this.activate(/* persist */ false);143}144145return toDisposable(() => {146store.dispose();147button.remove();148this.mounts.delete(mount);149// Last host gone — tear down without persisting so the user's150// preference for next time stays as it was.151if (this.mounts.size === 0 && this.activeRef.value) {152this.deactivate(/* persist */ false);153}154});155}156157private isFeatureEnabled(): boolean {158return this.configurationService.getValue<boolean>(SESSIONS_DEVELOPER_JOY_ENABLED_SETTING) === true;159}160161private isStoredEnabled(): boolean {162return this.storageService.getBoolean(ENABLED_STORAGE_KEY, StorageScope.APPLICATION, false);163}164165private setStoredEnabled(enabled: boolean): void {166this.storageService.store(ENABLED_STORAGE_KEY, enabled, StorageScope.APPLICATION, StorageTarget.USER);167}168169private applyFeatureEnabledState(): void {170for (const mount of this.mounts) {171this.applyFeatureEnabledStateForButton(mount.button);172}173if (!this.isFeatureEnabled() && this.activeRef.value) {174// Setting turned off — don't persist so the prior preference survives a re-enable.175this.deactivate(/* persist */ false);176} else if (this.isFeatureEnabled() && this.isStoredEnabled() && !this.activeRef.value && this.mounts.size > 0) {177this.activate(/* persist */ false);178}179}180181private applyFeatureEnabledStateForButton(button: HTMLButtonElement): void {182button.style.display = this.isFeatureEnabled() ? '' : 'none';183}184185private updateToggleButtonVisual(button: HTMLButtonElement, active: boolean): void {186button.classList.toggle('active', active);187// Build the icon as a real DOM child instead of innerHTML to satisfy Trusted Types.188button.replaceChildren();189const iconSpan = button.ownerDocument.createElement('span');190if (active) {191const iconClasses = ThemeIcon.asClassName(Codicon.close).split(/\s+/).filter(Boolean);192for (const cls of iconClasses) {193iconSpan.classList.add(cls);194}195} else {196iconSpan.classList.add('agents-aquarium-toggle-logo');197}198button.appendChild(iconSpan);199const label = this.getToggleLabel(active);200button.setAttribute('aria-pressed', String(active));201button.setAttribute('aria-label', label);202}203204private getToggleLabel(active: boolean): string {205return active ? localize('aquarium.hide', "Hide Aquarium") : localize('aquarium.show', "Show Aquarium");206}207208private toggle(): void {209if (this.activeRef.value) {210this.deactivate(/* persist */ true);211} else {212this.activate(/* persist */ true);213}214}215216private updateAllToggleButtonsVisual(active: boolean): void {217for (const mount of this.mounts) {218this.updateToggleButtonVisual(mount.button, active);219}220}221222/** @param persist false when restoring previously-stored state. */223private activate(persist: boolean): void {224if (this.activeRef.value) {225return;226}227// Cancel any in-flight exit so its delayed dispose can't tear down228// the new aquarium's shared SVG defs.229this.pendingExit.clear();230let active: IActiveAquarium | undefined;231try {232active = createActiveAquarium(this.mainContainer, this.layoutService, this.accessibilityService);233} catch (e) {234console.error('[aquarium] failed to activate', e);235return;236}237// No host (e.g. chat bar isn't visible yet) — leave the toggle238// untouched and don't persist; a later toggle attempt will retry.239if (!active) {240return;241}242this.activeRef.value = active;243this.activeContextKey.set(true);244this.updateAllToggleButtonsVisual(true);245if (persist) {246this.setStoredEnabled(true);247}248}249250/** @param persist false when tearing down for non-user reasons. */251private deactivate(persist: boolean): void {252// Detach from activeRef WITHOUT disposing (clearAndLeak) so the exit253// animation can run; the returned handle from active.exit() is parked254// in `pendingExit` and disposes the underlying store either when the255// animation completes, when the service tears down, or when a rapid256// re-activate replaces it.257const active = this.activeRef.clearAndLeak();258if (!active) {259return;260}261this.activeContextKey.set(false);262this.updateAllToggleButtonsVisual(false);263const pending = active.exit(() => {264if (this.pendingExit.value === pending) {265this.pendingExit.clear();266}267});268this.pendingExit.value = pending;269if (persist) {270this.setStoredEnabled(false);271}272}273}274275interface IActiveAquarium extends IDisposable {276/**277* Trigger the exit animation and dispose when it completes. Disposing the278* returned handle before the animation finishes disposes immediately.279*/280exit(onDidComplete: () => void): IDisposable;281}282283/**284* Build the live aquarium: water, fish, food, mouse handling, RAF loop.285* Returns `undefined` if the chat bar isn't available so callers can bail286* without leaving the toggle button stuck in an "active but invisible" state.287*/288function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbenchLayoutService, accessibilityService: IAccessibilityService): IActiveAquarium | undefined {289const targetWindow = getWindow(mainContainer);290291// Host inside the chat bar so chat input UI naturally paints on top —292// no z-index gymnastics required.293const chatBar = layoutService.getContainer(targetWindow, Parts.CHATBAR_PART);294if (!chatBar || !layoutService.isVisible(Parts.CHATBAR_PART, targetWindow)) {295return undefined;296}297298const store = new DisposableStore();299const doc = targetWindow.document;300const water = doc.createElement('div');301water.className = 'agents-aquarium-water';302// Decorative: hide the entire subtree from a11y tree.303water.setAttribute('aria-hidden', 'true');304// First child so subsequent chat bar content paints over it.305chatBar.insertBefore(water, chatBar.firstChild);306store.add(toDisposable(() => water.remove()));307308const fishLayer = doc.createElement('div');309fishLayer.className = 'agents-aquarium-fish-layer';310water.appendChild(fishLayer);311312const foodLayer = doc.createElement('div');313foodLayer.className = 'agents-aquarium-food-layer';314water.appendChild(foodLayer);315316const bounds = { width: 0, height: 0 };317// Cached so the per-mousemove handler doesn't trigger a layout flush.318const waterScreenOffset = { left: 0, top: 0 };319const updateBounds = () => {320bounds.width = water.clientWidth;321bounds.height = water.clientHeight;322const rect = water.getBoundingClientRect();323waterScreenOffset.left = rect.left;324waterScreenOffset.top = rect.top;325};326327const fish: Fish[] = [];328329updateBounds();330const resizeObserver = new ResizeObserver(() => {331updateBounds();332for (const f of fish) {333f.positionX = Math.min(f.positionX, Math.max(0, bounds.width - f.size));334f.positionY = Math.min(f.positionY, Math.max(0, bounds.height - f.size));335}336});337resizeObserver.observe(water);338store.add(toDisposable(() => resizeObserver.disconnect()));339340for (let i = 0; i < FISH_COUNT; i++) {341const size = randomBetween(FISH_MIN_SIZE, FISH_MAX_SIZE);342const angle = Math.random() * Math.PI * 2;343const speed = randomBetween(BASE_SPEED * 0.6, BASE_SPEED * 1.2);344const f = new Fish({345species: pickRandomSpecies(),346size,347positionX: randomBetween(0, Math.max(1, bounds.width - size)),348positionY: randomBetween(0, Math.max(1, bounds.height - size)),349velocityX: Math.cos(angle) * speed,350velocityY: Math.sin(angle) * speed,351}, targetWindow.document);352fish.push(f);353}354// Spawn in two batches: first half synchronous (single layout pass via355// DocumentFragment), rest on the next frame so the toggle click stays snappy.356const SYNC_BATCH = Math.ceil(FISH_COUNT / 2);357const firstBatch = targetWindow.document.createDocumentFragment();358for (let i = 0; i < Math.min(SYNC_BATCH, fish.length); i++) {359firstBatch.appendChild(fish[i].element);360}361fishLayer.appendChild(firstBatch);362let exiting = false;363364if (SYNC_BATCH < fish.length) {365const deferred = scheduleAtNextAnimationFrame(targetWindow, () => {366if (exiting) {367return;368}369const restBatch = targetWindow.document.createDocumentFragment();370for (let i = SYNC_BATCH; i < fish.length; i++) {371restBatch.appendChild(fish[i].element);372}373fishLayer.appendChild(restBatch);374// Add `.visible` on the NEXT frame so a paint at opacity:0 happens375// first — guarantees the CSS transition fires.376const fadeIn = scheduleAtNextAnimationFrame(targetWindow, () => {377if (exiting) {378return;379}380for (let i = SYNC_BATCH; i < fish.length; i++) {381const localIndex = i - SYNC_BATCH;382const delay = Math.min(localIndex * 12, 400);383fish[i].element.style.transitionDelay = `${delay}ms`;384fish[i].element.classList.add('visible');385}386});387store.add(fadeIn);388});389store.add(deferred);390}391store.add(toDisposable(() => {392for (const f of fish) {393f.element.remove();394}395// Tear down shared SVG defs so we don't leak across reloads.396disposeSharedFishDefs(targetWindow.document);397}));398399const food: IFoodPellet[] = [];400const removeFood = (pellet: IFoodPellet) => {401const idx = food.indexOf(pellet);402if (idx !== -1) {403food.splice(idx, 1);404pellet.element.remove();405}406};407408// Listen on the main container so we always know cursor position even409// when over the chat input (water has pointer-events:none).410//411// Coalesce updateBounds() across scroll/resize storms: scroll with capture412// fires for ANY descendant scroll, and updateBounds() reads layout. Mark413// dirty here and let the RAF tick refresh at most once per frame.414let boundsDirty = false;415const markBoundsDirty = () => { boundsDirty = true; };416store.add(addDisposableListener(targetWindow, EventType.RESIZE, markBoundsDirty, { passive: true }));417store.add(addDisposableListener(targetWindow, 'scroll', markBoundsDirty, { passive: true, capture: true }));418419let mouseX = -1e6;420let mouseY = -1e6;421const resetMousePosition = () => {422mouseX = -1e6;423mouseY = -1e6;424};425// Generic helpers so this also works under iOS pointer events.426store.add(addDisposableGenericMouseMoveListener(mainContainer, (e: MouseEvent) => {427mouseX = e.clientX - waterScreenOffset.left;428mouseY = e.clientY - waterScreenOffset.top;429}));430// Both mouseleave AND pointerleave so reset works on touch/pointer-only platforms.431store.add(addDisposableListener(mainContainer, EventType.MOUSE_LEAVE, resetMousePosition, { passive: true }));432store.add(addDisposableListener(mainContainer, EventType.POINTER_LEAVE, resetMousePosition, { passive: true }));433434store.add(addDisposableGenericMouseDownListener(mainContainer, (e: MouseEvent) => {435// Only spawn food on plain left clicks against background-ish surfaces.436if (e.button !== 0) {437return;438}439const target = e.target as HTMLElement | null;440if (!isBackgroundClick(target)) {441return;442}443// Refresh once to be safe (mousedown is rare).444updateBounds();445const dropX = e.clientX - waterScreenOffset.left;446const dropY = e.clientY - waterScreenOffset.top;447if (dropX < 0 || dropY < 0 || dropX > bounds.width || dropY > bounds.height) {448return;449}450spawnFood(dropX, dropY);451}));452453function spawnFood(dropX: number, dropY: number): void {454// Cap concurrent food: drop the oldest pellet to make room.455while (food.length >= MAX_FOOD) {456const oldest = food[0];457removeFood(oldest);458}459const el = doc.createElement('div');460el.className = 'agents-aquarium-food';461el.style.transform = `translate(${dropX}px, ${dropY}px)`;462foodLayer.appendChild(el);463food.push({ element: el, positionX: dropX, positionY: dropY, fallSpeed: randomBetween(20, 35) });464}465466let lastFrame = performance.now();467let rafDisposable: IDisposable | undefined;468469const stopAnimation = () => {470rafDisposable?.dispose();471rafDisposable = undefined;472};473const startAnimation = () => {474if (rafDisposable || accessibilityService.isMotionReduced()) {475return;476}477lastFrame = performance.now();478rafDisposable = scheduleAtNextAnimationFrame(targetWindow, tick);479};480481const tick = () => {482rafDisposable = undefined;483const now = performance.now();484const elapsedMs = now - lastFrame;485if (elapsedMs < ACTIVE_FRAME_INTERVAL_MS) {486rafDisposable = scheduleAtNextAnimationFrame(targetWindow, tick);487return;488}489490const dtMs = Math.min(elapsedMs, 100); // clamp big stalls491const dt = dtMs / 1000;492lastFrame = now;493494if (boundsDirty) {495boundsDirty = false;496updateBounds();497}498499// Skip work when window is hidden (RAF stays alive lazily).500if (!accessibilityService.isMotionReduced() && targetWindow.document.visibilityState !== 'hidden') {501updateFood(dt);502updateFish(dt);503}504505if (!accessibilityService.isMotionReduced()) {506rafDisposable = scheduleAtNextAnimationFrame(targetWindow, tick);507}508};509510function updateFood(dt: number): void {511for (let i = food.length - 1; i >= 0; i--) {512const pellet = food[i];513pellet.positionY += pellet.fallSpeed * dt;514pellet.element.style.transform = `translate(${pellet.positionX.toFixed(1)}px, ${pellet.positionY.toFixed(1)}px)`;515if (pellet.positionY > bounds.height + 10) {516removeFood(pellet);517}518}519}520521function updateFish(dt: number): void {522const now = performance.now();523for (const f of fish) {524const centerX = f.positionX + f.size / 2;525const centerY = f.positionY + f.size / 2;526527// Wall steering: turn the heading (not just acceleration) away from528// walls, otherwise fish park against the edge with their thrust529// pinning them in place.530const wallEscapeAngle = computeWallAvoidAngle(centerX, centerY, bounds.width, bounds.height);531if (wallEscapeAngle !== undefined) {532// Turn at up to 4 rad/s toward the safe direction.533const turnDelta = shortestAngleDelta(f.wanderAngle, wallEscapeAngle);534const maxTurnPerFrame = 4 * dt;535f.wanderAngle += Math.max(-maxTurnPerFrame, Math.min(maxTurnPerFrame, turnDelta));536} else {537// Free water: drift the heading by a small random delta.538f.wanderAngle += (Math.random() - 0.5) * 1.2 * dt + (Math.random() - 0.5) * 0.04;539}540541const thrust = 32;542let accelX = Math.cos(f.wanderAngle) * thrust;543let accelY = Math.sin(f.wanderAngle) * thrust;544545// Spontaneous dart with brief panic so it can exceed normal max speed.546if (Math.random() < DART_RATE_PER_SECOND * dt) {547const dartAngle = Math.random() * Math.PI * 2;548f.velocityX += Math.cos(dartAngle) * DART_IMPULSE;549f.velocityY += Math.sin(dartAngle) * DART_IMPULSE;550f.panicUntil = now + PANIC_DURATION_MS;551}552553// Wall repel — backstop so a fish entering the margin is pushed inward immediately.554if (centerX < WALL_MARGIN) {555accelX += (WALL_MARGIN - centerX) * 6;556} else if (centerX > bounds.width - WALL_MARGIN) {557accelX -= (centerX - (bounds.width - WALL_MARGIN)) * 6;558}559if (centerY < WALL_MARGIN) {560accelY += (WALL_MARGIN - centerY) * 6;561} else if (centerY > bounds.height - WALL_MARGIN) {562accelY -= (centerY - (bounds.height - WALL_MARGIN)) * 6;563}564565// Mouse scatter566const mouseDeltaX = centerX - mouseX;567const mouseDeltaY = centerY - mouseY;568const mouseDistSq = mouseDeltaX * mouseDeltaX + mouseDeltaY * mouseDeltaY;569if (mouseDistSq < SCATTER_RADIUS_SQ) {570const mouseDist = Math.max(Math.sqrt(mouseDistSq), 1);571const force = (1 - mouseDist / SCATTER_RADIUS) * 1100;572accelX += (mouseDeltaX / mouseDist) * force;573accelY += (mouseDeltaY / mouseDist) * force;574f.panicUntil = now + PANIC_DURATION_MS;575}576577// Seek nearest food within FOOD_DETECT_RADIUS578let nearestPellet: IFoodPellet | undefined;579let nearestDistSq = FOOD_DETECT_RADIUS_SQ;580for (const pellet of food) {581const foodDeltaX = pellet.positionX - centerX;582const foodDeltaY = pellet.positionY - centerY;583const distSq = foodDeltaX * foodDeltaX + foodDeltaY * foodDeltaY;584if (distSq < nearestDistSq) {585nearestDistSq = distSq;586nearestPellet = pellet;587}588}589if (nearestPellet) {590const nearestDist = Math.max(Math.sqrt(nearestDistSq), 1);591if (nearestDist < EAT_RADIUS) {592removeFood(nearestPellet);593} else {594accelX += (nearestPellet.positionX - centerX) / nearestDist * 200;595accelY += (nearestPellet.positionY - centerY) / nearestDist * 200;596}597}598599f.velocityX += accelX * dt;600f.velocityY += accelY * dt;601602const speedSq = f.velocityX * f.velocityX + f.velocityY * f.velocityY;603const maxSpeed = now < f.panicUntil ? PANIC_MAX_SPEED : MAX_SPEED;604const maxSpeedSq = now < f.panicUntil ? PANIC_MAX_SPEED_SQ : MAX_SPEED_SQ;605if (speedSq > maxSpeedSq) {606const speed = Math.sqrt(speedSq);607f.velocityX = (f.velocityX / speed) * maxSpeed;608f.velocityY = (f.velocityY / speed) * maxSpeed;609}610611f.positionX += f.velocityX * dt;612f.positionY += f.velocityY * dt;613614// Hard clamp safety net.615f.positionX = clamp(f.positionX, -f.size * 0.25, bounds.width - f.size * 0.75);616f.positionY = clamp(f.positionY, -f.size * 0.25, bounds.height - f.size * 0.75);617618f.applyTransform(dt);619}620}621622store.add(accessibilityService.onDidChangeReducedMotion(() => {623if (accessibilityService.isMotionReduced()) {624stopAnimation();625} else {626startAnimation();627}628}));629store.add(toDisposable(() => stopAnimation()));630startAnimation();631632// First-batch fade-in (the deferred batch fades in when it mounts).633const fadeIn = scheduleAtNextAnimationFrame(targetWindow, () => {634if (exiting) {635return;636}637water.classList.add('visible');638for (let i = 0; i < Math.min(SYNC_BATCH, fish.length); i++) {639const f = fish[i];640// Slight stagger, capped at ~400ms so it doesn't drag on.641const delay = Math.min(i * 12, 400);642f.element.style.transitionDelay = `${delay}ms`;643f.element.classList.add('visible');644}645});646store.add(fadeIn);647648const result = new class extends Disposable implements IActiveAquarium {649650constructor() {651super();652this._register(store);653}654655exit(onDidComplete: () => void): IDisposable {656if (exiting) {657return toDisposable(() => this.dispose());658}659exiting = true;660661for (let i = 0; i < fish.length; i++) {662const f = fish[i];663const delay = Math.min(i * 12, 400);664f.element.style.transitionDelay = `${delay}ms`;665f.element.classList.remove('visible');666}667water.classList.remove('visible');668669let timer: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {670timer = undefined;671this.dispose();672onDidComplete();673}, EXIT_DURATION_MS);674return toDisposable(() => {675if (timer !== undefined) {676clearTimeout(timer);677timer = undefined;678}679this.dispose();680});681}682};683684return result;685}686687/** True for clicks not on a control — i.e. safe targets for spawning food. */688function isBackgroundClick(target: HTMLElement | null): boolean {689if (!target) {690return false;691}692if (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')) {693return false;694}695return true;696}697698function randomBetween(min: number, max: number): number {699return min + Math.random() * (max - min);700}701702function clamp(value: number, min: number, max: number): number {703if (max < min) {704return min;705}706return Math.min(Math.max(value, min), max);707}708709/**710* If the fish is inside the wall margin, return the heading (radians) pointing711* back into open water. Returns `undefined` when the fish is comfortably away712* from all walls. Direction sums per-wall vectors weighted by encroachment,713* with a small tangential perturbation so neighbors don't all converge to the714* same heading.715*/716function computeWallAvoidAngle(centerX: number, centerY: number, width: number, height: number): number | undefined {717let escapeX = 0;718let escapeY = 0;719if (centerX < WALL_MARGIN) {720escapeX += (WALL_MARGIN - centerX) / WALL_MARGIN;721} else if (centerX > width - WALL_MARGIN) {722escapeX -= (centerX - (width - WALL_MARGIN)) / WALL_MARGIN;723}724if (centerY < WALL_MARGIN) {725escapeY += (WALL_MARGIN - centerY) / WALL_MARGIN;726} else if (centerY > height - WALL_MARGIN) {727escapeY -= (centerY - (height - WALL_MARGIN)) / WALL_MARGIN;728}729if (escapeX === 0 && escapeY === 0) {730return undefined;731}732return Math.atan2(escapeY, escapeX) + (Math.random() - 0.5) * 0.4;733}734735/** Smallest signed angular delta from `from` to `to`, in [-PI, PI]. */736function shortestAngleDelta(from: number, to: number): number {737let delta = (to - from) % (Math.PI * 2);738if (delta > Math.PI) {739delta -= Math.PI * 2;740} else if (delta < -Math.PI) {741delta += Math.PI * 2;742}743return delta;744}745746747