agent-ecosystem/packages/agent-graph/src/canvas/draw-effects.ts
iliya bd242fac5a fix(team): re-add control_response via stdin for teammate permissions
Belt-and-suspenders approach:
1. Settings file: handles all FUTURE calls (teammate finds rule on retry)
2. control_response via stdin: may unblock CURRENT waiting prompt
   (now includes updatedInput: {} which was the previous ZodError fix)

Without #2, approved teammates stay stuck until team restart because
the CLI doesn't hot-reload settings.local.json for pending prompts.
2026-03-30 21:47:40 +03:00

178 lines
5.6 KiB
TypeScript

/**
* Visual effects: spawn animation, completion shatter, spawn ring.
* Adapted from agent-flow's draw-effects.ts (Apache 2.0).
*/
import { alphaHex } from '../constants/colors';
import { SPAWN_FX, COMPLETE_FX } from '../constants/canvas-constants';
import { drawHexagon } from './draw-misc';
import { hexWithAlpha } from './render-cache';
// ─── Effect Type ────────────────────────────────────────────────────────────
export interface VisualEffect {
type: 'spawn' | 'complete' | 'shatter';
x: number;
y: number;
color: string;
age: number;
duration: number;
/** Node radius for scaling the effect */
nodeRadius?: number;
particles?: ShatterParticle[];
}
interface ShatterParticle {
angle: number;
speed: number;
size: number;
}
/**
* Create a spawn effect at position.
*/
export function createSpawnEffect(x: number, y: number, color: string, nodeRadius?: number): VisualEffect {
return { type: 'spawn', x, y, color, age: 0, duration: 0.8, nodeRadius };
}
/**
* Create a completion shatter effect at position.
*/
export function createCompleteEffect(x: number, y: number, color: string): VisualEffect {
const particles: ShatterParticle[] = [];
for (let i = 0; i < 12; i++) {
particles.push({
angle: (Math.PI * 2 * i) / 12 + (Math.random() - 0.5) * 0.3,
speed: 30 + Math.random() * 60,
size: 1 + Math.random() * 2,
});
}
return { type: 'shatter', x, y, color, age: 0, duration: 0.8, particles };
}
// ─── Draw Effects ───────────────────────────────────────────────────────────
export function drawEffects(
ctx: CanvasRenderingContext2D,
effects: VisualEffect[],
): void {
for (const fx of effects) {
const progress = fx.age / fx.duration;
if (progress >= 1) continue;
switch (fx.type) {
case 'spawn':
drawSpawnEffect(ctx, fx, progress);
break;
case 'complete':
drawCompleteEffect(ctx, fx, progress);
break;
case 'shatter':
drawShatterEffect(ctx, fx, progress);
break;
}
}
}
// ─── Spawn: expanding hex ring + white flash ────────────────────────────────
function drawSpawnEffect(ctx: CanvasRenderingContext2D, fx: VisualEffect, progress: number): void {
const alpha = SPAWN_FX.maxAlpha * (1 - progress);
const baseR = fx.nodeRadius ?? SPAWN_FX.ringStart;
const ringR = baseR + SPAWN_FX.ringExpand * progress;
ctx.save();
ctx.globalAlpha = alpha;
// Expanding hex ring
drawHexagon(ctx, fx.x, fx.y, ringR);
ctx.strokeStyle = fx.color;
ctx.lineWidth = 2 * (1 - progress);
ctx.stroke();
// Flash
if (progress < SPAWN_FX.flashThreshold) {
const flashProgress = progress / SPAWN_FX.flashThreshold;
const flashR = SPAWN_FX.flashBaseRadius * (1 - flashProgress) + SPAWN_FX.flashMinRadius;
ctx.fillStyle = '#ffffff' + alphaHex(SPAWN_FX.flashAlpha * (1 - flashProgress));
ctx.beginPath();
ctx.arc(fx.x, fx.y, flashR, 0, Math.PI * 2);
ctx.fill();
}
// Scatter particles
for (let i = 0; i < SPAWN_FX.particleCount; i++) {
const angle = (Math.PI * 2 * i) / SPAWN_FX.particleCount;
const dist = ringR * 0.8 * progress;
const px = fx.x + Math.cos(angle) * dist;
const py = fx.y + Math.sin(angle) * dist;
ctx.fillStyle = fx.color + alphaHex(alpha * 0.6);
ctx.beginPath();
ctx.arc(px, py, SPAWN_FX.particleSize * (1 - progress), 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
// ─── Complete: white flash + expanding ring ─────────────────────────────────
function drawCompleteEffect(ctx: CanvasRenderingContext2D, fx: VisualEffect, progress: number): void {
const alpha = COMPLETE_FX.maxAlpha * (1 - progress);
const ringR = COMPLETE_FX.ringStart + COMPLETE_FX.ringExpand * progress;
ctx.save();
ctx.globalAlpha = alpha;
// Expanding ring
ctx.beginPath();
ctx.arc(fx.x, fx.y, ringR, 0, Math.PI * 2);
ctx.strokeStyle = fx.color;
ctx.lineWidth = COMPLETE_FX.lineWidthMax * (1 - progress);
ctx.stroke();
// Flash
if (progress < COMPLETE_FX.flashThreshold) {
const flashAlpha = COMPLETE_FX.flashAlpha * (1 - progress / COMPLETE_FX.flashThreshold);
ctx.fillStyle = '#ffffff' + alphaHex(flashAlpha);
ctx.beginPath();
ctx.arc(fx.x, fx.y, COMPLETE_FX.flashRadius * (1 - progress), 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
// ─── Shatter: particles scatter outward ─────────────────────────────────────
function drawShatterEffect(ctx: CanvasRenderingContext2D, fx: VisualEffect, progress: number): void {
if (!fx.particles) return;
const alpha = 1 - progress;
ctx.save();
ctx.globalAlpha = alpha;
for (const p of fx.particles) {
const dist = p.speed * progress;
const px = fx.x + Math.cos(p.angle) * dist;
const py = fx.y + Math.sin(p.angle) * dist;
const size = p.size * (1 - progress * 0.5);
// Glow
const grad = ctx.createRadialGradient(px, py, 0, px, py, size * 3);
grad.addColorStop(0, fx.color + alphaHex(alpha * 0.4));
grad.addColorStop(1, hexWithAlpha(fx.color, 0));
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(px, py, size * 3, 0, Math.PI * 2);
ctx.fill();
// Core
ctx.fillStyle = fx.color;
ctx.beginPath();
ctx.arc(px, py, size, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}