Files
claude-plugins-official/plugins/thinkback/skills/thinkback/helpers/awards_effects.js
Thariq Shihipar 7985b28c03 Add thinkback plugin for Year in Review animation
Adds the thinkback plugin - a personalized Year in Review ASCII animation
generator for Claude Code users.

Features:
- Multiple vibes: cozy, awards show, morning news, RPG quest
- Quick generation with templates or deep dive with personalized narratives
- Comprehensive animation helpers for backgrounds, transitions, particles
- Stats extraction from Claude Code usage history

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 19:27:57 -08:00

997 lines
32 KiB
JavaScript

/* eslint-disable */
/**
* Awards Show Effects
* Specialized effects for the awards show vibe
*/
(function() {
// Easing functions
function easeOut(t) {
return 1 - Math.pow(1 - t, 3);
}
function easeInOut(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// ============================================================================
// TROPHY DISPLAY - ASCII trophy art
// ============================================================================
const TROPHY_GRAND = [
' ___ ',
' | | ',
' /| |\\ ',
' / |___| \\ ',
' | / \\ | ',
' \\/_____\\/ ',
' | | ',
' / \\ ',
' /_____\\ ',
];
const TROPHY_SIMPLE = [
' \\___/',
' | |',
' |___|',
' | | ',
' /___\\',
];
const TROPHY_STAR = [
' * ',
' *** ',
' ***** ',
' ******* ',
' *** ',
' | | ',
' /___\\ ',
];
/**
* Draw a trophy display
* @param fb - Framebuffer
* @param x - X position (center)
* @param y - Y position (top)
* @param options - { label, style, centered }
* @param progress - Animation progress 0-1
* @param frame - Current frame for effects
*/
function trophyDisplay(fb, x, y, options, progress, frame) {
const { label = '', style = 'grand', centered = true, depth = 5 } = options;
const trophy = style === 'simple' ? TROPHY_SIMPLE
: style === 'star' ? TROPHY_STAR
: TROPHY_GRAND;
const easedProgress = easeOut(progress);
const visibleLines = Math.floor(easedProgress * trophy.length);
const trophyWidth = Math.max(...trophy.map(line => line.length));
const startX = centered ? x - Math.floor(trophyWidth / 2) : x;
// Draw trophy lines
for (let i = 0; i < visibleLines; i++) {
const line = trophy[i];
const lineX = startX + Math.floor((trophyWidth - line.length) / 2);
for (let j = 0; j < line.length; j++) {
if (line[j] !== ' ') {
fb.setPixel(lineX + j, y + i, line[j], depth + 2);
}
}
}
// Draw label below trophy
if (label && easedProgress > 0.8) {
const labelProgress = (easedProgress - 0.8) / 0.2;
const visibleLabel = label.slice(0, Math.floor(labelProgress * label.length));
const labelX = centered ? x - Math.floor(visibleLabel.length / 2) : x;
for (let i = 0; i < visibleLabel.length; i++) {
fb.setPixel(labelX + i, y + trophy.length + 1, visibleLabel[i], depth + 1);
}
}
return trophy.length + 2;
}
// ============================================================================
// AWARD BADGE - Medal/badge display
// ============================================================================
/**
* Draw an award badge/medal
* @param fb - Framebuffer
* @param x - X position (center)
* @param y - Y position (top)
* @param options - { category, year, style }
* @param progress - Animation progress 0-1
*/
function awardBadge(fb, x, y, options, progress) {
const { category = 'AWARD', year = '2024', style = 'gold', depth = 5 } = options;
const easedProgress = easeOut(progress);
if (easedProgress < 0.1) return;
const starChar = style === 'gold' ? '★' : style === 'silver' ? '☆' : '✦';
const borderH = style === 'gold' ? '═' : '─';
const cornerTL = style === 'gold' ? '╔' : '┌';
const cornerTR = style === 'gold' ? '╗' : '┐';
const cornerBL = style === 'gold' ? '╚' : '└';
const cornerBR = style === 'gold' ? '╝' : '┘';
const sideV = style === 'gold' ? '║' : '│';
const yearLine = ` ${starChar} ${year} ${starChar} `;
const catLine = ` ${category} `;
const width = Math.max(yearLine.length, catLine.length) + 2;
const startX = x - Math.floor(width / 2);
const visibleWidth = Math.floor(easedProgress * width);
// Top border
fb.setPixel(startX, y, cornerTL, depth);
for (let i = 1; i < Math.min(visibleWidth - 1, width - 1); i++) {
fb.setPixel(startX + i, y, borderH, depth);
}
if (visibleWidth >= width) fb.setPixel(startX + width - 1, y, cornerTR, depth);
// Year line
if (easedProgress > 0.3) {
fb.setPixel(startX, y + 1, sideV, depth);
const yearProgress = Math.min(1, (easedProgress - 0.3) / 0.3);
const yearPadded = yearLine.padStart(Math.floor((width - 2 + yearLine.length) / 2)).padEnd(width - 2);
const visibleYear = yearPadded.slice(0, Math.floor(yearProgress * yearPadded.length));
for (let i = 0; i < visibleYear.length; i++) {
fb.setPixel(startX + 1 + i, y + 1, visibleYear[i], depth + 1);
}
fb.setPixel(startX + width - 1, y + 1, sideV, depth);
}
// Category line
if (easedProgress > 0.5) {
fb.setPixel(startX, y + 2, sideV, depth);
const catProgress = Math.min(1, (easedProgress - 0.5) / 0.3);
const catPadded = catLine.padStart(Math.floor((width - 2 + catLine.length) / 2)).padEnd(width - 2);
const visibleCat = catPadded.slice(0, Math.floor(catProgress * catPadded.length));
for (let i = 0; i < visibleCat.length; i++) {
fb.setPixel(startX + 1 + i, y + 2, visibleCat[i], depth + 2);
}
fb.setPixel(startX + width - 1, y + 2, sideV, depth);
}
// Bottom border
if (easedProgress > 0.8) {
fb.setPixel(startX, y + 3, cornerBL, depth);
for (let i = 1; i < width - 1; i++) {
fb.setPixel(startX + i, y + 3, borderH, depth);
}
fb.setPixel(startX + width - 1, y + 3, cornerBR, depth);
}
return 4;
}
// ============================================================================
// ENVELOPE REVEAL - Dramatic envelope opening
// ============================================================================
/**
* Draw envelope reveal animation
* @param fb - Framebuffer
* @param winnerName - Name to reveal
* @param progress - Animation progress 0-1
* @param frame - Current frame for effects
* @param options - { y, suspenseText }
*/
function envelopeReveal(fb, winnerName, progress, frame, options = {}) {
const {
y = 10,
suspenseText = 'AND THE AWARD GOES TO...',
depth = 5,
} = options;
const easedProgress = easeOut(progress);
const centerX = Math.floor(fb.width / 2);
// Phase 1: Suspense text (0-0.4)
if (progress < 0.4) {
const textProgress = progress / 0.4;
const visibleChars = Math.floor(textProgress * suspenseText.length);
const visibleText = suspenseText.slice(0, visibleChars);
const textX = centerX - Math.floor(suspenseText.length / 2);
for (let i = 0; i < visibleText.length; i++) {
fb.setPixel(textX + i, y, visibleText[i], depth);
}
// Blinking cursor
if (textProgress < 1) {
const cursorChar = Math.floor(frame / 6) % 2 === 0 ? '█' : ' ';
fb.setPixel(textX + visibleChars, y, cursorChar, depth);
}
}
// Phase 2: Envelope appears and opens (0.4-0.7)
if (progress >= 0.4 && progress < 0.7) {
const envProgress = (progress - 0.4) / 0.3;
// Draw envelope
const envWidth = 30;
const envX = centerX - Math.floor(envWidth / 2);
const envY = y + 2;
// Envelope body
const envHeight = 5;
for (let row = 0; row < envHeight; row++) {
fb.setPixel(envX, envY + row, row === 0 ? '╭' : row === envHeight - 1 ? '╰' : '│', depth);
for (let col = 1; col < envWidth - 1; col++) {
if (row === 0) {
fb.setPixel(envX + col, envY + row, '─', depth);
} else if (row === envHeight - 1) {
fb.setPixel(envX + col, envY + row, '─', depth);
} else {
fb.setPixel(envX + col, envY + row, ' ', depth);
}
}
fb.setPixel(envX + envWidth - 1, envY + row, row === 0 ? '╮' : row === envHeight - 1 ? '╯' : '│', depth);
}
// Opening flap animation
if (envProgress > 0.5) {
const flapProgress = (envProgress - 0.5) / 0.5;
const flapChars = ['▔', '▀', '█'];
const flapIdx = Math.min(flapChars.length - 1, Math.floor(flapProgress * flapChars.length));
for (let col = 1; col < envWidth - 1; col++) {
fb.setPixel(envX + col, envY, flapChars[flapIdx], depth + 1);
}
}
}
// Phase 3: Winner name reveal (0.7-1.0)
if (progress >= 0.7) {
const nameProgress = (progress - 0.7) / 0.3;
const easedNameProgress = easeOut(nameProgress);
// Winner name with dramatic reveal
const nameY = y + 4;
const nameX = centerX - Math.floor(winnerName.length / 2);
// Reveal from center outward
const halfLen = Math.ceil(winnerName.length / 2);
const visibleFromCenter = Math.floor(easedNameProgress * halfLen);
for (let i = 0; i < winnerName.length; i++) {
const distFromCenter = Math.abs(i - Math.floor(winnerName.length / 2));
if (distFromCenter <= visibleFromCenter) {
fb.setPixel(nameX + i, nameY, winnerName[i], depth + 3);
}
}
// Decorative sparkles
if (nameProgress > 0.5) {
const sparkleChars = ['✦', '*', '·'];
const sparkleX1 = nameX - 3;
const sparkleX2 = nameX + winnerName.length + 2;
const sparkleIdx = Math.floor(frame / 4) % sparkleChars.length;
fb.setPixel(sparkleX1, nameY, sparkleChars[sparkleIdx], depth + 1);
fb.setPixel(sparkleX2, nameY, sparkleChars[sparkleIdx], depth + 1);
}
}
}
// ============================================================================
// CATEGORY TITLE - Animated category header
// ============================================================================
/**
* Draw a category title header
* @param fb - Framebuffer
* @param y - Y position
* @param title - Category title
* @param progress - Animation progress 0-1
* @param frame - Current frame for effects
* @param options - { style }
*/
function categoryTitle(fb, y, title, progress, frame, options = {}) {
const { style = 'grand', depth = 5 } = options;
const easedProgress = easeOut(progress);
const centerX = Math.floor(fb.width / 2);
if (style === 'grand') {
// Top decorative line
const lineWidth = Math.floor(easedProgress * 40);
const lineX = centerX - Math.floor(lineWidth / 2);
if (lineWidth > 0) {
for (let i = 0; i < lineWidth; i++) {
fb.setPixel(lineX + i, y, '═', depth);
}
}
// Title with stars
if (easedProgress > 0.3) {
const titleProgress = (easedProgress - 0.3) / 0.5;
const fullTitle = `${title}`;
const visibleTitle = fullTitle.slice(0, Math.floor(titleProgress * fullTitle.length));
const titleX = centerX - Math.floor(fullTitle.length / 2);
for (let i = 0; i < visibleTitle.length; i++) {
fb.setPixel(titleX + i, y + 1, visibleTitle[i], depth + 2);
}
}
// Bottom decorative line
if (easedProgress > 0.6) {
const bottomProgress = (easedProgress - 0.6) / 0.4;
const bottomWidth = Math.floor(bottomProgress * 40);
const bottomX = centerX - Math.floor(bottomWidth / 2);
for (let i = 0; i < bottomWidth; i++) {
fb.setPixel(bottomX + i, y + 2, '═', depth);
}
}
} else if (style === 'simple') {
const titleX = centerX - Math.floor(title.length / 2);
const visibleTitle = title.slice(0, Math.floor(easedProgress * title.length));
for (let i = 0; i < visibleTitle.length; i++) {
fb.setPixel(titleX + i, y, visibleTitle[i], depth + 1);
}
} else {
// minimal
const fullTitle = `[ ${title} ]`;
const titleX = centerX - Math.floor(fullTitle.length / 2);
const visibleTitle = fullTitle.slice(0, Math.floor(easedProgress * fullTitle.length));
for (let i = 0; i < visibleTitle.length; i++) {
fb.setPixel(titleX + i, y, visibleTitle[i], depth);
}
}
}
// ============================================================================
// ACCEPTANCE SPEECH - Full project spotlight for awards
// ============================================================================
/**
* Draw a full acceptance speech display for a project
* @param fb - Framebuffer
* @param project - { name, commits, description?, body?, rank? }
* @param progress - Animation progress 0-1
* @param frame - Current frame for effects
* @param options - { y, width, showTrophy }
*/
function acceptanceSpeech(fb, project, progress, frame, options = {}) {
const {
y = 4,
width = 50,
showTrophy = true,
depth = 5,
centered = true,
} = options;
const { name, commits, description, body, rank } = project;
const easedProgress = easeOut(progress);
if (easedProgress < 0.05) return;
const centerX = Math.floor(fb.width / 2);
const boxX = centered ? centerX - Math.floor(width / 2) : 2;
let currentY = y;
// Draw trophy above the box
if (showTrophy && easedProgress > 0.05) {
const trophyProgress = Math.min(1, (easedProgress - 0.05) / 0.2);
trophyDisplay(fb, centerX, currentY, {
label: '',
style: rank === 1 ? 'grand' : rank === 2 ? 'simple' : 'star',
}, trophyProgress, frame);
currentY += rank === 1 ? 10 : 6;
}
// Calculate visible width for animation
const visibleWidth = Math.floor(Math.min(1, (easedProgress - 0.2) / 0.3) * width);
if (visibleWidth < 5) return;
// Helper to draw a row with side borders
function drawRow(text, textDepth = depth) {
fb.setPixel(boxX, currentY, '║', depth);
for (let i = 0; i < text.length && i < visibleWidth - 2; i++) {
fb.setPixel(boxX + 1 + i, currentY, text[i], textDepth);
}
// Fill remaining space
for (let i = text.length; i < visibleWidth - 2; i++) {
fb.setPixel(boxX + 1 + i, currentY, ' ', depth);
}
if (visibleWidth > 1) fb.setPixel(boxX + visibleWidth - 1, currentY, '║', depth);
currentY++;
}
// Top border with flair
if (easedProgress > 0.25) {
fb.setPixel(boxX, currentY, '╔', depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(boxX + i, currentY, '═', depth);
}
if (visibleWidth > 1) fb.setPixel(boxX + visibleWidth - 1, currentY, '╗', depth);
currentY++;
}
// Award category line
if (rank && easedProgress > 0.3) {
const rankLabel = rank === 1 ? '★ BEST PROJECT ★' : rank === 2 ? '☆ RUNNER UP ☆' : '✦ HONORABLE MENTION ✦';
drawRow(` ${rankLabel}`, depth + 1);
}
// Project name
if (easedProgress > 0.4) {
const nameProgress = Math.min(1, (easedProgress - 0.4) / 0.15);
const visibleName = name.slice(0, Math.floor(nameProgress * name.length));
drawRow(` ${visibleName}`, depth + 3);
}
// Divider after name
if (easedProgress > 0.5) {
fb.setPixel(boxX, currentY, '╟', depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(boxX + i, currentY, '─', depth);
}
if (visibleWidth > 1) fb.setPixel(boxX + visibleWidth - 1, currentY, '╢', depth);
currentY++;
}
// Commits stat with animated counter
if (easedProgress > 0.55) {
const counterProgress = Math.min(1, (easedProgress - 0.55) / 0.1);
const displayCommits = Math.floor(counterProgress * commits);
drawRow(` ${displayCommits.toLocaleString()} COMMITS`, depth + 2);
}
// Description
if (description && easedProgress > 0.6) {
const descProgress = Math.min(1, (easedProgress - 0.6) / 0.1);
const maxDesc = visibleWidth - 4;
const displayDesc = description.slice(0, Math.min(maxDesc, Math.floor(descProgress * description.length)));
drawRow(` ${displayDesc}`, depth);
}
// Divider before body
if (body && easedProgress > 0.65) {
fb.setPixel(boxX, currentY, '║', depth);
for (let i = 1; i < Math.min(visibleWidth - 1, 20); i++) {
fb.setPixel(boxX + i, currentY, '·', depth);
}
for (let i = 20; i < visibleWidth - 1; i++) {
fb.setPixel(boxX + i, currentY, ' ', depth);
}
if (visibleWidth > 1) fb.setPixel(boxX + visibleWidth - 1, currentY, '║', depth);
currentY++;
}
// Body text (acceptance speech content)
if (body && easedProgress > 0.7) {
const bodyProgress = Math.min(1, (easedProgress - 0.7) / 0.25);
const maxLineWidth = visibleWidth - 6;
const words = body.split(' ');
const lines = [];
let currentLine = '';
for (const word of words) {
if ((currentLine + ' ' + word).trim().length > maxLineWidth) {
if (currentLine) lines.push(currentLine);
currentLine = word;
} else {
currentLine = currentLine ? currentLine + ' ' + word : word;
}
}
if (currentLine) lines.push(currentLine);
// Draw visible portion of body
const totalChars = lines.join('').length;
let charsDrawn = 0;
const charsToShow = Math.floor(bodyProgress * totalChars);
for (const line of lines) {
if (charsDrawn >= charsToShow) break;
const lineCharsToShow = Math.min(line.length, charsToShow - charsDrawn);
const visibleLine = line.slice(0, lineCharsToShow);
drawRow(` ${visibleLine}`, depth);
charsDrawn += line.length;
}
}
// Bottom border
if (easedProgress > 0.9) {
fb.setPixel(boxX, currentY, '╚', depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(boxX + i, currentY, '═', depth);
}
if (visibleWidth > 1) fb.setPixel(boxX + visibleWidth - 1, currentY, '╝', depth);
}
}
// ============================================================================
// NOMINEE CARD - Display a nominee before winner announcement
// ============================================================================
/**
* Draw a nominee card
* @param fb - Framebuffer
* @param x - X position
* @param y - Y position
* @param nominee - { name, stat, statLabel }
* @param progress - Animation progress 0-1
* @param options - { style, width }
*/
function nomineeCard(fb, x, y, nominee, progress, options = {}) {
const { style = 'elegant', width = 25, depth = 5 } = options;
const { name, stat, statLabel } = nominee;
const easedProgress = easeOut(progress);
if (easedProgress < 0.1) return;
const visibleWidth = Math.floor(easedProgress * width);
if (visibleWidth < 5) return;
let currentY = y;
// Border top
const borderH = style === 'elegant' ? '─' : '═';
const cornerTL = style === 'elegant' ? '╭' : '╔';
const cornerTR = style === 'elegant' ? '╮' : '╗';
const cornerBL = style === 'elegant' ? '╰' : '╚';
const cornerBR = style === 'elegant' ? '╯' : '╝';
const sideV = style === 'elegant' ? '│' : '║';
fb.setPixel(x, currentY, cornerTL, depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(x + i, currentY, borderH, depth);
}
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, cornerTR, depth);
currentY++;
// Name
if (easedProgress > 0.3) {
fb.setPixel(x, currentY, sideV, depth);
const displayName = name.slice(0, visibleWidth - 4);
for (let i = 0; i < displayName.length; i++) {
fb.setPixel(x + 2 + i, currentY, displayName[i], depth + 2);
}
fb.setPixel(x + visibleWidth - 1, currentY, sideV, depth);
currentY++;
}
// Stat
if (stat && easedProgress > 0.5) {
fb.setPixel(x, currentY, sideV, depth);
const statStr = typeof stat === 'number' ? stat.toLocaleString() : stat;
const statText = statLabel ? `${statStr} ${statLabel}` : statStr;
for (let i = 0; i < statText.length && i < visibleWidth - 4; i++) {
fb.setPixel(x + 2 + i, currentY, statText[i], depth + 1);
}
fb.setPixel(x + visibleWidth - 1, currentY, sideV, depth);
currentY++;
}
// Border bottom
if (easedProgress > 0.7) {
fb.setPixel(x, currentY, cornerBL, depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(x + i, currentY, borderH, depth);
}
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, cornerBR, depth);
}
}
// ============================================================================
// WINNER ANNOUNCEMENT - Full reveal sequence
// ============================================================================
/**
* Draw a complete winner announcement sequence
* @param fb - Framebuffer
* @param winnerName - Winner name
* @param progress - Animation progress 0-1
* @param frame - Current frame
* @param options - { category, stat, statLabel }
*/
function winnerAnnouncement(fb, winnerName, progress, frame, options = {}) {
const { category = 'WINNER', stat, statLabel, depth = 5 } = options;
const centerX = Math.floor(fb.width / 2);
const centerY = Math.floor(fb.height / 2);
// Phase 1: Category reveal (0-0.25)
if (progress < 0.25) {
categoryTitle(fb, centerY - 4, category, progress / 0.25, frame, { style: 'grand' });
}
// Phase 2: Envelope reveal (0.25-0.6)
if (progress >= 0.25 && progress < 0.6) {
const envProgress = (progress - 0.25) / 0.35;
envelopeReveal(fb, winnerName, envProgress, frame, { y: centerY - 2 });
}
// Phase 3: Winner celebration (0.6-1.0)
if (progress >= 0.6) {
const celebProgress = (progress - 0.6) / 0.4;
// Winner name with glow effect
const nameX = centerX - Math.floor(winnerName.length / 2);
for (let i = 0; i < winnerName.length; i++) {
fb.setPixel(nameX + i, centerY, winnerName[i], depth + 5);
}
// Sparkle effects around name
if (celebProgress > 0.2) {
const sparkleChars = ['✦', '*', '·', '★'];
const sparklePositions = [
{ x: nameX - 4, y: centerY },
{ x: nameX + winnerName.length + 3, y: centerY },
{ x: nameX - 2, y: centerY - 1 },
{ x: nameX + winnerName.length + 1, y: centerY - 1 },
{ x: nameX - 2, y: centerY + 1 },
{ x: nameX + winnerName.length + 1, y: centerY + 1 },
];
sparklePositions.forEach((pos, idx) => {
const sparkleIdx = (Math.floor(frame / 4) + idx) % sparkleChars.length;
fb.setPixel(pos.x, pos.y, sparkleChars[sparkleIdx], depth + 1);
});
}
// Stat below name
if (stat && celebProgress > 0.4) {
const statProgress = (celebProgress - 0.4) / 0.4;
const statStr = typeof stat === 'number'
? Math.floor(statProgress * stat).toLocaleString()
: stat;
const statText = statLabel ? `${statStr} ${statLabel}` : statStr;
const statX = centerX - Math.floor(statText.length / 2);
for (let i = 0; i < statText.length; i++) {
fb.setPixel(statX + i, centerY + 2, statText[i], depth + 2);
}
}
}
}
// ============================================================================
// APPLAUSE METER - Visual audience reaction
// ============================================================================
/**
* Draw an applause meter
* @param fb - Framebuffer
* @param y - Y position
* @param progress - Animation progress 0-1
* @param frame - Current frame for animation
* @param options - { intensity }
*/
function applauseMeter(fb, y, progress, frame, options = {}) {
const { intensity = 0.8, depth = 5 } = options;
const width = 30;
const centerX = Math.floor(fb.width / 2);
const startX = centerX - Math.floor(width / 2);
const easedProgress = easeOut(progress);
const filledWidth = Math.floor(easedProgress * intensity * width);
// Animated applause chars
const applauseChars = ['👏', '✋', '🙌', '✨'];
const activeChar = applauseChars[Math.floor(frame / 6) % applauseChars.length];
// Draw the meter
for (let i = 0; i < width; i++) {
if (i < filledWidth) {
// Filled portion - wave effect
const waveOffset = Math.sin((frame * 0.3) + (i * 0.5)) * 0.3;
const charIdx = Math.floor((i + frame * 0.2) % applauseChars.length);
fb.setPixel(startX + i, y, applauseChars[charIdx], depth + 2);
} else {
fb.setPixel(startX + i, y, '░', depth);
}
}
}
// ============================================================================
// STANDING OVATION - Celebration particle effect
// ============================================================================
/**
* Standing ovation particle effect
* @param fb - Framebuffer
* @param frame - Current frame
* @param options - { intensity, chars }
*/
function standingOvation(fb, frame, options = {}) {
const {
intensity = 1.0,
chars = ['✦', '*', '·', '★', '✧'],
depth = 3,
} = options;
const particleCount = Math.floor(intensity * 20);
for (let i = 0; i < particleCount; i++) {
// Rising particles
const seed = i * 12.345 + frame * 0.02;
const x = Math.floor((Math.sin(seed * 7.89) * 0.5 + 0.5) * fb.width);
const baseY = fb.height - 1 - ((frame * 0.3 + i * 3) % fb.height);
const y = Math.floor(baseY + Math.sin(seed * 2.34) * 2);
if (y >= 0 && y < fb.height && x >= 0 && x < fb.width) {
const charIdx = Math.floor((seed * 100) % chars.length);
fb.setPixel(x, y, chars[charIdx], depth);
}
}
}
// ============================================================================
// RED CARPET BORDER - Decorative border
// ============================================================================
/**
* Draw a red carpet style border
* @param fb - Framebuffer
* @param progress - Animation progress 0-1
* @param options - { style }
*/
function redCarpetBorder(fb, progress, options = {}) {
const { style = 'velvet', depth = 2 } = options;
const easedProgress = easeOut(progress);
const topY = 0;
const bottomY = fb.height - 1;
const leftX = 0;
const rightX = fb.width - 1;
const chars = style === 'velvet' ? { h: '═', v: '║', corner: '◆' }
: style === 'gold' ? { h: '─', v: '│', corner: '★' }
: { h: '·', v: '·', corner: '✦' };
// Animate from corners
const visibleH = Math.floor(easedProgress * fb.width / 2);
const visibleV = Math.floor(easedProgress * fb.height / 2);
// Corners
fb.setPixel(leftX, topY, chars.corner, depth + 1);
fb.setPixel(rightX, topY, chars.corner, depth + 1);
fb.setPixel(leftX, bottomY, chars.corner, depth + 1);
fb.setPixel(rightX, bottomY, chars.corner, depth + 1);
// Top and bottom borders
for (let i = 1; i <= visibleH; i++) {
fb.setPixel(leftX + i, topY, chars.h, depth);
fb.setPixel(rightX - i, topY, chars.h, depth);
fb.setPixel(leftX + i, bottomY, chars.h, depth);
fb.setPixel(rightX - i, bottomY, chars.h, depth);
}
// Left and right borders
for (let i = 1; i <= visibleV; i++) {
fb.setPixel(leftX, topY + i, chars.v, depth);
fb.setPixel(leftX, bottomY - i, chars.v, depth);
fb.setPixel(rightX, topY + i, chars.v, depth);
fb.setPixel(rightX, bottomY - i, chars.v, depth);
}
}
// ============================================================================
// SPOTLIGHT TEXT - Glowing text effect
// ============================================================================
/**
* Draw text with spotlight/glow effect
* @param fb - Framebuffer
* @param y - Y position
* @param text - Text to display
* @param frame - Current frame for animation
* @param options - { glow, centered }
*/
function spotlightText(fb, y, text, frame, options = {}) {
const { glow = true, centered = true, depth = 5 } = options;
const x = centered ? Math.floor((fb.width - text.length) / 2) : 2;
// Draw main text
for (let i = 0; i < text.length; i++) {
fb.setPixel(x + i, y, text[i], depth + 3);
}
// Glow effect - subtle sparkles around text
if (glow) {
const glowChars = ['·', '*', '✦'];
const glowPositions = [
{ dx: -1, dy: 0 },
{ dx: text.length, dy: 0 },
{ dx: 0, dy: -1 },
{ dx: text.length - 1, dy: -1 },
{ dx: 0, dy: 1 },
{ dx: text.length - 1, dy: 1 },
];
glowPositions.forEach((pos, idx) => {
const glowX = x + pos.dx;
const glowY = y + pos.dy;
if (glowX >= 0 && glowX < fb.width && glowY >= 0 && glowY < fb.height) {
const charIdx = (Math.floor(frame / 5) + idx) % glowChars.length;
fb.setPixel(glowX, glowY, glowChars[charIdx], depth);
}
});
}
}
// ============================================================================
// SPOTLIGHT REVEAL - Circle reveal from center with spotlight feel
// ============================================================================
/**
* Spotlight reveal transition
* @param fb - Framebuffer
* @param progress - Transition progress 0-1
* @param options - { x, y } center point
*/
function spotlightReveal(fb, progress, options = {}) {
const {
x = Math.floor(fb.width / 2),
y = Math.floor(fb.height / 2),
} = options;
const maxRadius = Math.sqrt(fb.width * fb.width + fb.height * fb.height);
const radius = easeOut(progress) * maxRadius;
for (let py = 0; py < fb.height; py++) {
for (let px = 0; px < fb.width; px++) {
const dist = Math.sqrt((px - x) ** 2 + ((py - y) * 2) ** 2);
if (dist > radius) {
fb.setPixel(px, py, ' ', -100);
}
}
}
}
// ============================================================================
// CURTAIN REVEAL - Theater curtain opening
// ============================================================================
/**
* Curtain reveal transition
* @param fb - Framebuffer
* @param progress - Transition progress 0-1
*/
function curtainReveal(fb, progress) {
const easedProgress = easeOut(progress);
const centerX = Math.floor(fb.width / 2);
const openWidth = Math.floor(easedProgress * centerX);
// Clear areas outside the "opening"
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
if (x < centerX - openWidth || x > centerX + openWidth) {
fb.setPixel(x, y, '█', -50);
}
}
// Curtain edge effect
if (openWidth > 0 && openWidth < centerX) {
const edgeChars = ['│', '┃', '║'];
const leftEdge = centerX - openWidth;
const rightEdge = centerX + openWidth;
if (leftEdge >= 0) fb.setPixel(leftEdge, y, edgeChars[1], -40);
if (rightEdge < fb.width) fb.setPixel(rightEdge, y, edgeChars[1], -40);
}
}
}
// ============================================================================
// AWARDS STATUE - Large decorative trophy ASCII art
// ============================================================================
const OSCAR_STATUE = [
' ▄▄ ',
' ████ ',
' ████ ',
' ██████ ',
' ████████ ',
' ██████████ ',
' ██ ██ ██ ',
' ██ ',
' ████ ',
' ██████ ',
' ████████ ',
' ██ ',
' ██████ ',
' ████████ ',
' ██████████ ',
];
const GLOBE_STATUE = [
' ╭────╮ ',
' ╭─┤ ├─╮ ',
' │ ╰────╯ │ ',
' │ ╭────╮ │ ',
' ╰─┤ ├─╯ ',
' ╰────╯ ',
' ││ ',
' ╭────╮ ',
' ╭──────╮ ',
' ╭────────╮ ',
];
const STAR_STATUE = [
' ★ ',
' ★★★ ',
' ★★★★★ ',
' ★★★★★★★ ',
' ★★★★★★★★★ ',
' ★★★★★ ',
' ★★★ ',
' ★ ',
' ███ ',
' █████ ',
' ███████ ',
];
/**
* Draw a large awards statue
* @param fb - Framebuffer
* @param x - X position (center)
* @param y - Y position (top)
* @param progress - Animation progress 0-1
* @param options - { style }
*/
function awardsStatue(fb, x, y, progress, options = {}) {
const { style = 'oscar', depth = 5 } = options;
const statue = style === 'globe' ? GLOBE_STATUE
: style === 'star' ? STAR_STATUE
: OSCAR_STATUE;
if (!statue || !statue.length) return;
const easedProgress = easeOut(Math.min(1, progress));
const visibleLines = Math.min(statue.length, Math.floor(easedProgress * statue.length));
const statueWidth = Math.max(...statue.map(line => line.length));
const startX = x - Math.floor(statueWidth / 2);
for (let i = 0; i < visibleLines; i++) {
const line = statue[i];
if (!line) continue;
const lineX = startX + Math.floor((statueWidth - line.length) / 2);
for (let j = 0; j < line.length; j++) {
if (line[j] !== ' ') {
fb.setPixel(lineX + j, y + i, line[j], depth + 2);
}
}
}
}
// Make available globally
if (typeof globalThis !== 'undefined') {
Object.assign(globalThis, {
trophyDisplay,
awardBadge,
envelopeReveal,
categoryTitle,
acceptanceSpeech,
nomineeCard,
winnerAnnouncement,
applauseMeter,
standingOvation,
redCarpetBorder,
spotlightText,
spotlightReveal,
curtainReveal,
awardsStatue,
});
}
})();