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.
178 lines
5.6 KiB
TypeScript
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();
|
|
}
|