From 7985b28c0357ba5bdb3cfa4b4d9eca75800edd00 Mon Sep 17 00:00:00 2001 From: Thariq Shihipar Date: Sun, 14 Dec 2025 19:27:57 -0800 Subject: [PATCH] Add thinkback plugin for Year in Review animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude-plugin/marketplace.json | 11 + plugins/thinkback/.claude-plugin/plugin.json | 8 + plugins/thinkback/README.md | 35 + plugins/thinkback/package.json | 3 + plugins/thinkback/skills/thinkback/SKILL.md | 159 +++ .../thinkback/skills/thinkback/ascii_anim.js | 435 ++++++ .../thinkback/helpers/awards_effects.js | 996 ++++++++++++++ .../skills/thinkback/helpers/backgrounds.js | 324 +++++ .../skills/thinkback/helpers/borders.js | 313 +++++ .../skills/thinkback/helpers/index.js | 66 + .../skills/thinkback/helpers/news_effects.js | 970 +++++++++++++ .../skills/thinkback/helpers/particles.js | 349 +++++ .../skills/thinkback/helpers/rpg_effects.js | 1181 ++++++++++++++++ .../skills/thinkback/helpers/scene_system.js | 415 ++++++ .../skills/thinkback/helpers/text_effects.js | 503 +++++++ .../skills/thinkback/helpers/transitions.js | 449 ++++++ .../skills/thinkback/high_token_version.md | 437 ++++++ .../skills/thinkback/low_token_version.md | 129 ++ plugins/thinkback/skills/thinkback/player.js | 174 +++ .../skills/thinkback/scripts/get_all_stats.js | 1207 +++++++++++++++++ .../skills/thinkback/scripts/helpers_demo.js | 390 ++++++ .../skills/thinkback/scripts/test_intro.js | 145 ++ .../skills/thinkback/scripts/validate.js | 142 ++ .../templates/awards-show-template.js | 399 ++++++ .../thinkback/templates/cozy-template.js | 300 ++++ .../templates/morning-news-template.js | 412 ++++++ .../thinkback/templates/rpg-quest-template.js | 443 ++++++ .../thinkback/vibes/awards-show-vibe.md | 440 ++++++ .../skills/thinkback/vibes/cozy-vibe.md | 92 ++ .../thinkback/vibes/morning-news-vibe.md | 443 ++++++ .../skills/thinkback/vibes/other-vibe.md | 5 + .../skills/thinkback/vibes/rpg-quest-vibe.md | 603 ++++++++ .../skills/thinkback/year_in_review.html | 502 +++++++ .../thinkback/year_in_review_template.js | 373 +++++ 34 files changed, 12853 insertions(+) create mode 100644 plugins/thinkback/.claude-plugin/plugin.json create mode 100644 plugins/thinkback/README.md create mode 100644 plugins/thinkback/package.json create mode 100644 plugins/thinkback/skills/thinkback/SKILL.md create mode 100644 plugins/thinkback/skills/thinkback/ascii_anim.js create mode 100644 plugins/thinkback/skills/thinkback/helpers/awards_effects.js create mode 100644 plugins/thinkback/skills/thinkback/helpers/backgrounds.js create mode 100644 plugins/thinkback/skills/thinkback/helpers/borders.js create mode 100644 plugins/thinkback/skills/thinkback/helpers/index.js create mode 100644 plugins/thinkback/skills/thinkback/helpers/news_effects.js create mode 100644 plugins/thinkback/skills/thinkback/helpers/particles.js create mode 100644 plugins/thinkback/skills/thinkback/helpers/rpg_effects.js create mode 100644 plugins/thinkback/skills/thinkback/helpers/scene_system.js create mode 100644 plugins/thinkback/skills/thinkback/helpers/text_effects.js create mode 100644 plugins/thinkback/skills/thinkback/helpers/transitions.js create mode 100644 plugins/thinkback/skills/thinkback/high_token_version.md create mode 100644 plugins/thinkback/skills/thinkback/low_token_version.md create mode 100644 plugins/thinkback/skills/thinkback/player.js create mode 100644 plugins/thinkback/skills/thinkback/scripts/get_all_stats.js create mode 100644 plugins/thinkback/skills/thinkback/scripts/helpers_demo.js create mode 100755 plugins/thinkback/skills/thinkback/scripts/test_intro.js create mode 100644 plugins/thinkback/skills/thinkback/scripts/validate.js create mode 100644 plugins/thinkback/skills/thinkback/templates/awards-show-template.js create mode 100644 plugins/thinkback/skills/thinkback/templates/cozy-template.js create mode 100644 plugins/thinkback/skills/thinkback/templates/morning-news-template.js create mode 100644 plugins/thinkback/skills/thinkback/templates/rpg-quest-template.js create mode 100644 plugins/thinkback/skills/thinkback/vibes/awards-show-vibe.md create mode 100644 plugins/thinkback/skills/thinkback/vibes/cozy-vibe.md create mode 100644 plugins/thinkback/skills/thinkback/vibes/morning-news-vibe.md create mode 100644 plugins/thinkback/skills/thinkback/vibes/other-vibe.md create mode 100644 plugins/thinkback/skills/thinkback/vibes/rpg-quest-vibe.md create mode 100644 plugins/thinkback/skills/thinkback/year_in_review.html create mode 100644 plugins/thinkback/skills/thinkback/year_in_review_template.js diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 68e6526..62b23a5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -139,6 +139,17 @@ "category": "development", "homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/plugin-dev" }, + { + "name": "thinkback", + "description": "Generate a personalized Year in Review ASCII animation celebrating your year with Claude Code. Features multiple vibes (cozy, awards show, morning news, RPG quest) and comprehensive animation helpers.", + "author": { + "name": "Anthropic", + "email": "support@anthropic.com" + }, + "source": "./plugins/thinkback", + "category": "productivity", + "homepage": "https://github.com/anthropics/claude-plugins-official/tree/main/plugins/thinkback" + }, { "name": "greptile", "description": "AI-powered codebase search and understanding. Query your repositories using natural language to find relevant code, understand dependencies, and get contextual answers about your codebase architecture.", diff --git a/plugins/thinkback/.claude-plugin/plugin.json b/plugins/thinkback/.claude-plugin/plugin.json new file mode 100644 index 0000000..00152c3 --- /dev/null +++ b/plugins/thinkback/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "thinkback", + "description": "Generate a personalized Year in Review ASCII animation celebrating your year with Claude Code", + "author": { + "name": "Anthropic", + "email": "support@anthropic.com" + } +} diff --git a/plugins/thinkback/README.md b/plugins/thinkback/README.md new file mode 100644 index 0000000..068af00 --- /dev/null +++ b/plugins/thinkback/README.md @@ -0,0 +1,35 @@ +# Thinkback Plugin + +Generate a personalized "Year in Review" ASCII animation celebrating your year with Claude Code. + +## What It Does + +Creates a custom ASCII art animation showcasing your coding statistics and achievements with Claude Code. Features: + +- **Multiple vibes**: Cozy fireplace, Awards show, Morning news, RPG quest +- **Personalized stats**: Commits, conversations, projects, and more +- **Animation helpers**: Backgrounds, transitions, particles, text effects +- **Quick or deep generation**: Template-based or fully personalized narratives + +## Usage + +``` +/thinkback +``` + +Claude will guide you through: +1. Extracting your Claude Code usage statistics +2. Choosing a vibe for your animation +3. Selecting which projects to highlight +4. Generating your personalized Year in Review animation + +## Modes + +- `mode=generate` (default) - Create a new Year in Review +- `mode=edit` - Modify an existing animation +- `mode=fix` - Validate and fix errors in existing animation +- `mode=regenerate` - Start fresh + +## Authors + +Thariq Shihipar (thariq@anthropic.com) diff --git a/plugins/thinkback/package.json b/plugins/thinkback/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/plugins/thinkback/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/plugins/thinkback/skills/thinkback/SKILL.md b/plugins/thinkback/skills/thinkback/SKILL.md new file mode 100644 index 0000000..1b89cba --- /dev/null +++ b/plugins/thinkback/skills/thinkback/SKILL.md @@ -0,0 +1,159 @@ +--- +name: thinkback +description: Generate a personalized "Year in Review" ASCII animation script. Use when the user wants to create their Thinkback, year in review, or usage summary animation. +--- + +# Thinkback - Year in Review Generator + +Generate a personalized ASCII art animation celebrating the user's year with Claude Code. + +## Step 1: Determine Mode + +Check if the user's request includes a mode parameter: + +| Mode | Action | +|------|--------| +| `mode=generate` (default) | Continue to Step 2 | +| `mode=edit` | Read existing `./year_in_review.js`, ask what to change, make edits, then validate | +| `mode=fix` | Read existing `./year_in_review.js`, run validation, fix errors until it passes | +| `mode=regenerate` | Delete existing file, continue to Step 2 | + +## Step 2: Extract Statistics + +Run the stats script from the skill folder root: +```bash +cd ${CLAUDE_PLUGIN_ROOT}/skills/thinkback && node scripts/get_all_stats.js --markdown +``` + +The `--markdown` flag also generates `activity-report.md` with: +- Every repo with Claude co-authored commits +- Recent commits per repo (up to 10) +- Recent user messages per project (up to 5) + +## Step 3: Read the Activity Report + +Read `activity-report.md` to understand the user's reposistories and what they're working on. + +## Step 4: Interview the User + +Use the AskUserQuestion tool to ask these questions. +```json + { + "questions": [ + { + "question": "How would you like to generate your Thinkback?", + "header": "Generation", + "options": [ + { + "label": "Quick Generation (Recommended for Pro Users)", + "description": "Uses pre-built templates with your stats injected. Faster, uses fewer tokens." + }, + { + "label": "Deep Ddive", + "description": "Analyzes your projects, commits, and conversations to create a personalized narrative. Uses more tokens, takes longer." + } + ], + "multiSelect": false + }, + { + "question": "What vibe should your Thinkback have?", + "header": "Vibe", + "options": [ + { + "label": "Cozy", + "description": "Warm and gentle, like a fireplace evening" + }, + { + "label": "Awards show", + "description": "Glamorous ceremony with envelope reveals" + }, + { + "label": "Morning news", + "description": "Upbeat broadcast with breaking news energy" + }, + { + "label": "RPG Quest", + "description": "Epic adventure with quests and level ups" + } + ], + "multiSelect": false + }, + { + "question": "Which projects should we include from your Thinkback? Consider if you want to share this more publicly.", + "header": "Include - pt 1", + "options": [ + { + "label": "Project 1", + "description": "Project Description" + }, + { + "label": "Project 2", + "description": "Project Description" + }, + { + "label": "Project 3", + "description": "Project Description" + }, + { + "label": "Project 4", + "description": "Project Description" + }, + ], + ], + "multiSelect": true + }, + { + "question": "Which projects should we include from your Thinkback? Consider if you want to share this more publicly.", + "header": "Include - pt 2", + "options": [ + { + "label": "Project 5", + "description": "Project Description" + }, + { + "label": "Project 6", + "description": "Project Description" + }, + ], + "multiSelect": true + } + ] + } +``` + +Question #1 and Question #2 are the same for every user, use this wording EXACTLY. + +For Question #3, select potential projects OR repos that you want to highlight but might be sensitive. +If you have more than 4 options, you can use multiple questions (e.g. have a question 4 and 5). + + +## Step 5: Load the Appropriate Instructions + +Based on Question 2 response: + +### If "Deep dive" selected: +Read and follow instructions in: `high_token_version.md` + +This mode: +- Extracts detailed stats +- Reads activity reports +- Spins off subagents to analyze repos and transcripts +- Creates a deeply personalized narrative + +### If "Quick generation" selected: +Read and follow instructions in: `low_token_version.md` + +This mode: +- Extracts stats +- Uses pre-built templates based on vibe selection +- Injects stats into template +- Fast and token-efficient + +## Vibe Reference Files + +Load the appropriate vibe guide based on Question 1: +- `vibes/cozy-vibe.md` - Warm, nurturing, unhurried aesthetic +- `vibes/awards-show-vibe.md` - Glamorous ceremony, envelope reveals +- `vibes/morning-news-vibe.md` - Cheerful broadcast, breaking news +- `vibes/rpg-quest-vibe.md` - Epic adventure, quests, level ups +- `vibes/other-vibe.md` - If they enter free text input \ No newline at end of file diff --git a/plugins/thinkback/skills/thinkback/ascii_anim.js b/plugins/thinkback/skills/thinkback/ascii_anim.js new file mode 100644 index 0000000..4b125e0 --- /dev/null +++ b/plugins/thinkback/skills/thinkback/ascii_anim.js @@ -0,0 +1,435 @@ +/* eslint-disable */ +/** + * ASCII Art Animation Library + * A library for creating ASCII art animations with primitives and framebuffer rendering + */ + +class Point { + constructor(x, y) { + this.x = x; + this.y = y; + } +} + +class FrameBuffer { + // Density characters from light to dark + static DENSITY_CHARS = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@']; + + // Large text font (3x5 characters, accounting for aspect ratio) + static FIGLET_FONT = { + 'A': [' # ', ' # # ', '#####', '# #', '# #'], + 'B': ['#### ', '# #', '#### ', '# #', '#### '], + 'C': [' ### ', '# #', '# ', '# #', ' ### '], + 'D': ['#### ', '# #', '# #', '# #', '#### '], + 'E': ['#####', '# ', '#### ', '# ', '#####'], + 'F': ['#####', '# ', '#### ', '# ', '# '], + 'G': [' ### ', '# ', '# ##', '# #', ' ### '], + 'H': ['# #', '# #', '#####', '# #', '# #'], + 'I': ['#####', ' # ', ' # ', ' # ', '#####'], + 'J': ['#####', ' #', ' #', '# #', ' ### '], + 'K': ['# #', '# # ', '### ', '# # ', '# #'], + 'L': ['# ', '# ', '# ', '# ', '#####'], + 'M': ['# #', '## ##', '# # #', '# #', '# #'], + 'N': ['# #', '## #', '# # #', '# ##', '# #'], + 'O': [' ### ', '# #', '# #', '# #', ' ### '], + 'P': ['#### ', '# #', '#### ', '# ', '# '], + 'Q': [' ### ', '# #', '# #', '# ##', ' ####'], + 'R': ['#### ', '# #', '#### ', '# # ', '# #'], + 'S': [' ####', '# ', ' ### ', ' #', '#### '], + 'T': ['#####', ' # ', ' # ', ' # ', ' # '], + 'U': ['# #', '# #', '# #', '# #', ' ### '], + 'V': ['# #', '# #', '# #', ' # # ', ' # '], + 'W': ['# #', '# #', '# # #', '## ##', '# #'], + 'X': ['# #', ' # # ', ' # ', ' # # ', '# #'], + 'Y': ['# #', ' # # ', ' # ', ' # ', ' # '], + 'Z': ['#####', ' # ', ' # ', ' # ', '#####'], + '0': [' ### ', '# #', '# #', '# #', ' ### '], + '1': [' # ', ' ## ', ' # ', ' # ', '#####'], + '2': [' ### ', '# #', ' # ', ' # ', '#####'], + '3': [' ### ', '# #', ' ## ', '# #', ' ### '], + '4': ['# #', '# #', '#####', ' #', ' #'], + '5': ['#####', '# ', '#### ', ' #', '#### '], + '6': [' ### ', '# ', '#### ', '# #', ' ### '], + '7': ['#####', ' #', ' # ', ' # ', ' # '], + '8': [' ### ', '# #', ' ### ', '# #', ' ### '], + '9': [' ### ', '# #', ' ####', ' #', ' ### '], + ' ': [' ', ' ', ' ', ' ', ' '], + '!': [' # ', ' # ', ' # ', ' ', ' # '], + '?': [' ### ', '# #', ' # ', ' ', ' # '], + '.': [' ', ' ', ' ', ' ', ' # '], + ',': [' ', ' ', ' ', ' # ', ' # '], + ':': [' ', ' # ', ' ', ' # ', ' '], + "'": [' # ', ' # ', ' ', ' ', ' '], + '-': [' ', ' ', '#####', ' ', ' '], + '+': [' ', ' # ', '#####', ' # ', ' '], + '=': [' ', '#####', ' ', '#####', ' '], + '*': [' ', '# # #', ' ### ', '# # #', ' '], + '/': [' #', ' # ', ' # ', ' # ', '# '], + '(': [' ## ', ' # ', ' # ', ' # ', ' ## '], + ')': ['## ', ' # ', ' # ', ' # ', '## '], + '<': [' # ', ' # ', ' # ', ' # ', ' # '], + '>': [' # ', ' # ', ' # ', ' # ', ' # '], + }; + + constructor(width, height) { + this.width = width; + this.height = height; + this.clear(); + } + + clear(char = ' ') { + this.buffer = Array(this.height).fill(null).map(() => Array(this.width).fill(char)); + this.colorBuffer = Array(this.height).fill(null).map(() => Array(this.width).fill(null)); + this.depthBuffer = Array(this.height).fill(null).map(() => Array(this.width).fill(Infinity)); + } + + // Convert hex color to ANSI true color escape sequence + hexToAnsi(hex) { + if (!hex || typeof hex !== 'string') return null; + const match = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i); + if (!match) return null; + const r = parseInt(match[1], 16); + const g = parseInt(match[2], 16); + const b = parseInt(match[3], 16); + return `\x1b[38;2;${r};${g};${b}m`; + } + + setPixel(x, y, char, depth = 0, color = null) { + x = Math.floor(x); + y = Math.floor(y); + if (x >= 0 && x < this.width && y >= 0 && y < this.height) { + if (depth <= this.depthBuffer[y][x]) { + this.buffer[y][x] = char; + this.colorBuffer[y][x] = color; + this.depthBuffer[y][x] = depth; + } + } + } + + getPixel(x, y) { + if (x >= 0 && x < this.width && y >= 0 && y < this.height) { + return this.buffer[y][x]; + } + return ' '; + } + + drawLine(x1, y1, x2, y2, char = '#', depth = 0) { + const dx = Math.abs(x2 - x1); + const dy = Math.abs(y2 - y1); + const sx = x1 < x2 ? 1 : -1; + const sy = y1 < y2 ? 1 : -1; + let err = dx - dy; + + let x = x1; + let y = y1; + while (true) { + this.setPixel(x, y, char, depth); + if (x === x2 && y === y2) break; + const e2 = 2 * err; + if (e2 > -dy) { + err -= dy; + x += sx; + } + if (e2 < dx) { + err += dx; + y += sy; + } + } + } + + drawHorizontalLine(x, y, length, char = '-', depth = 0) { + for (let i = 0; i < length; i++) { + this.setPixel(x + i, y, char, depth); + } + } + + drawVerticalLine(x, y, length, char = '|', depth = 0) { + for (let i = 0; i < length; i++) { + this.setPixel(x, y + i, char, depth); + } + } + + drawBox(x, y, width, height, char = '#', filled = false, depth = 0) { + if (filled) { + for (let dy = 0; dy < height; dy++) { + for (let dx = 0; dx < width; dx++) { + this.setPixel(x + dx, y + dy, char, depth); + } + } + } else { + // Top and bottom + this.drawHorizontalLine(x, y, width, char, depth); + this.drawHorizontalLine(x, y + height - 1, width, char, depth); + // Left and right + this.drawVerticalLine(x, y, height, char, depth); + this.drawVerticalLine(x + width - 1, y, height, char, depth); + } + } + + drawCircle(cx, cy, radius, char = 'o', filled = false, depth = 0) { + if (filled) { + for (let y = -radius; y <= radius; y++) { + for (let x = -radius; x <= radius; x++) { + if (x * x + y * y <= radius * radius) { + this.setPixel(cx + x, cy + y, char, depth); + } + } + } + } else { + let x = radius; + let y = 0; + let err = 0; + + while (x >= y) { + this.setPixel(cx + x, cy + y, char, depth); + this.setPixel(cx + y, cy + x, char, depth); + this.setPixel(cx - y, cy + x, char, depth); + this.setPixel(cx - x, cy + y, char, depth); + this.setPixel(cx - x, cy - y, char, depth); + this.setPixel(cx - y, cy - x, char, depth); + this.setPixel(cx + y, cy - x, char, depth); + this.setPixel(cx + x, cy - y, char, depth); + + if (err <= 0) { + y += 1; + err += 2 * y + 1; + } + if (err > 0) { + x -= 1; + err -= 2 * x + 1; + } + } + } + } + + drawText(x, y, text, colorOrDepth = null, color = null) { + // Support both (x, y, text, color) and (x, y, text, depth, color) signatures + let depth = 0; + let actualColor = null; + + if (typeof colorOrDepth === 'string') { + // Called as (x, y, text, color) - color passed as third arg + actualColor = colorOrDepth; + } else if (typeof colorOrDepth === 'number') { + // Called as (x, y, text, depth, color) + depth = colorOrDepth; + actualColor = color; + } + + for (let i = 0; i < text.length; i++) { + this.setPixel(x + i, y, text[i], depth, actualColor); + } + } + + drawCenteredText(y, text, colorOrDepth = null, color = null) { + const x = Math.floor((this.width - text.length) / 2); + this.drawText(x, y, text, colorOrDepth, color); + } + + drawGradientBox(x, y, width, height, startDensity = 0, endDensity = 9, depth = 0) { + for (let dy = 0; dy < height; dy++) { + let densityIdx = Math.floor(startDensity + (endDensity - startDensity) * dy / height); + densityIdx = Math.max(0, Math.min(9, densityIdx)); + const char = FrameBuffer.DENSITY_CHARS[densityIdx]; + for (let dx = 0; dx < width; dx++) { + this.setPixel(x + dx, y + dy, char, depth); + } + } + } + + drawStar(x, y, char = '*', depth = 0) { + this.setPixel(x, y, char, depth); + } + + drawParticles(particles, depth = 0) { + for (const [px, py, char] of particles) { + this.setPixel(px, py, char, depth); + } + } + + fillCanvas(char = '#', depth = 0) { + for (let y = 0; y < this.height; y++) { + for (let x = 0; x < this.width; x++) { + this.setPixel(x, y, char, depth); + } + } + } + + clearBox(x, y, width, height, char = ' ') { + for (let dy = 0; dy < height; dy++) { + for (let dx = 0; dx < width; dx++) { + if (x + dx >= 0 && x + dx < this.width && y + dy >= 0 && y + dy < this.height) { + this.buffer[y + dy][x + dx] = char; + this.depthBuffer[y + dy][x + dx] = Infinity; + } + } + } + } + + fillExceptBox(excludeX, excludeY, excludeWidth, excludeHeight, char = '#', depth = 0) { + for (let y = 0; y < this.height; y++) { + for (let x = 0; x < this.width; x++) { + if (x >= excludeX && x < excludeX + excludeWidth && + y >= excludeY && y < excludeY + excludeHeight) { + continue; + } + this.setPixel(x, y, char, depth); + } + } + } + + fillExceptCircle(cx, cy, radius, char = '#', depth = 0) { + for (let y = 0; y < this.height; y++) { + for (let x = 0; x < this.width; x++) { + const dx = (x - cx) * 2.16; // 13/6 aspect ratio correction + const dy = y - cy; + if (dx * dx + dy * dy <= radius * radius) { + continue; + } + this.setPixel(x, y, char, depth); + } + } + } + + drawLargeText(x, y, text, depth = 0) { + text = text.toUpperCase(); + let xOffset = 0; + + for (const char of text) { + const charToUse = FrameBuffer.FIGLET_FONT[char] ? char : ' '; + const charLines = FrameBuffer.FIGLET_FONT[charToUse]; + + for (let rowIdx = 0; rowIdx < charLines.length; rowIdx++) { + const line = charLines[rowIdx]; + for (let colIdx = 0; colIdx < line.length; colIdx++) { + if (line[colIdx] !== ' ') { + this.setPixel(x + xOffset + colIdx, y + rowIdx, line[colIdx], depth); + } + } + } + + xOffset += 6; // 5 character width + 1 space + } + } + + drawLargeTextCentered(y, text, depth = 0) { + const textWidth = text.length * 6; + const x = Math.floor((this.width - textWidth) / 2); + this.drawLargeText(x, y, text, depth); + } + + blit() { + // Move cursor to top-left + process.stdout.write('\x1b[H'); + + // Write the buffer with colors + const RESET = '\x1b[0m'; + for (let y = 0; y < this.height; y++) { + let line = ''; + let currentColor = null; + for (let x = 0; x < this.width; x++) { + const char = this.buffer[y][x]; + const color = this.colorBuffer[y][x]; + + if (color !== currentColor) { + if (color) { + const ansi = this.hexToAnsi(color); + if (ansi) { + line += ansi; + } + } else if (currentColor) { + line += RESET; + } + currentColor = color; + } + line += char; + } + if (currentColor) { + line += RESET; + } + process.stdout.write(line + '\n'); + } + } + + getFrameString() { + return this.buffer.map(row => row.join('')).join('\n'); + } +} + +class AnimationEngine { + constructor(width = null, height = null) { + if (width === null || height === null) { + this.width = width || process.stdout.columns || 80; + this.height = height || (process.stdout.rows || 24) - 2; + } else { + this.width = width; + this.height = height; + } + + this.fb = new FrameBuffer(this.width, this.height); + this.frameCount = 0; + } + + clearScreen() { + process.stdout.write('\x1b[2J'); + process.stdout.write('\x1b[H'); + } + + hideCursor() { + process.stdout.write('\x1b[?25l'); + } + + showCursor() { + process.stdout.write('\x1b[?25h'); + } + + renderFrame(frameFunc, frameNum, fps = 24) { + this.fb.clear(); + frameFunc(this.fb, frameNum); + this.fb.blit(); + return new Promise(resolve => setTimeout(resolve, 1000 / fps)); + } + + async playAnimation(frameFunc, numFrames, fps = 24) { + this.clearScreen(); + this.hideCursor(); + + try { + for (let i = 0; i < numFrames; i++) { + await this.renderFrame(frameFunc, i, fps); + this.frameCount++; + } + } finally { + this.showCursor(); + } + } +} + +// Utility functions for common patterns +function interpolate(start, end, t) { + return start + (end - start) * t; +} + +function easeInOut(t) { + return t * t * (3 - 2 * t); +} + +function rotatePoint(x, y, cx, cy, angle) { + const cosA = Math.cos(angle); + const sinA = Math.sin(angle); + const dx = x - cx; + const dy = y - cy; + return [ + cx + dx * cosA - dy * sinA, + cy + dx * sinA + dy * cosA + ]; +} + +export { + Point, + FrameBuffer, + AnimationEngine, + interpolate, + easeInOut, + rotatePoint +}; diff --git a/plugins/thinkback/skills/thinkback/helpers/awards_effects.js b/plugins/thinkback/skills/thinkback/helpers/awards_effects.js new file mode 100644 index 0000000..020df98 --- /dev/null +++ b/plugins/thinkback/skills/thinkback/helpers/awards_effects.js @@ -0,0 +1,996 @@ +/* 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, + }); +} +})(); diff --git a/plugins/thinkback/skills/thinkback/helpers/backgrounds.js b/plugins/thinkback/skills/thinkback/helpers/backgrounds.js new file mode 100644 index 0000000..2f6e7c1 --- /dev/null +++ b/plugins/thinkback/skills/thinkback/helpers/backgrounds.js @@ -0,0 +1,324 @@ +/* eslint-disable */ +/** + * Background Effects + * Animated backgrounds for scene atmosphere + * All functions take (fb, frame, options) + */ +(function() { + +// Seeded random for consistent patterns +function seededRandom(seed) { + const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; + return x - Math.floor(x); +} + +const DENSITY_CHARS = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@']; + +/** + * Twinkling stars background + */ +function stars(fb, frame, options = {}) { + const { density = 0.006, twinkle = true, depth = 100 } = options; + + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + const seed = x * 31 + y * 17; + const rand = seededRandom(seed); + + if (rand < density) { + // Twinkle effect based on frame + const twinkleSeed = seed + Math.floor(frame / 8); + const isTwinkling = twinkle && seededRandom(twinkleSeed) > 0.7; + const char = isTwinkling ? 'ยท' : '.'; + fb.setPixel(x, y, char, depth); + } + } + } +} + +/** + * 3D starfield zoom effect + */ +function starfield(fb, frame, options = {}) { + const { speed = 1, numStars = 50, depth = 100 } = options; + const centerX = fb.width / 2; + const centerY = fb.height / 2; + const aspectRatio = 2.16; + + for (let i = 0; i < numStars; i++) { + const seed = i * 17; + // Star position in normalized space (-1 to 1) + const baseX = seededRandom(seed) * 2 - 1; + const baseY = seededRandom(seed + 1) * 2 - 1; + + // Z position cycles based on frame + const z = ((seededRandom(seed + 2) + frame * speed * 0.01) % 1); + const scale = 1 / (z + 0.1); + + const screenX = Math.floor(centerX + baseX * scale * 20 * aspectRatio); + const screenY = Math.floor(centerY + baseY * scale * 10); + + if (screenX >= 0 && screenX < fb.width && screenY >= 0 && screenY < fb.height) { + // Brighter stars closer (lower z) + const char = z < 0.3 ? '*' : z < 0.6 ? 'ยท' : '.'; + fb.setPixel(screenX, screenY, char, depth); + } + } +} + +/** + * Rain effect + */ +function rain(fb, frame, options = {}) { + const { density = 0.02, speed = 1, char = '|', depth = 100 } = options; + + for (let x = 0; x < fb.width; x++) { + const columnSeed = x * 31; + const columnDensity = seededRandom(columnSeed) < density * 10 ? 1 : 0; + + if (columnDensity) { + const dropSpeed = 0.5 + seededRandom(columnSeed + 1) * speed; + const offset = Math.floor(frame * dropSpeed); + const startY = seededRandom(columnSeed + 2) * fb.height; + + for (let len = 0; len < 3; len++) { + const y = Math.floor((startY + offset + len) % fb.height); + const dropChar = len === 0 ? char : (len === 1 ? ':' : '.'); + fb.setPixel(x, y, dropChar, depth); + } + } + } +} + +/** + * Snow effect + */ +function snow(fb, frame, options = {}) { + const { density = 0.01, chars = ['*', 'ยท', '.'], depth = 100 } = options; + + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + const seed = x * 31 + y * 17; + if (seededRandom(seed) < density) { + // Gentle falling motion with slight horizontal drift + const fallSpeed = 0.3 + seededRandom(seed + 1) * 0.3; + const drift = Math.sin((frame + seed) * 0.1) * 2; + const offsetY = Math.floor(frame * fallSpeed); + const offsetX = Math.floor(drift); + + const drawY = (y + offsetY) % fb.height; + const drawX = ((x + offsetX) % fb.width + fb.width) % fb.width; + + const charIdx = Math.floor(seededRandom(seed + 2) * chars.length); + fb.setPixel(drawX, drawY, chars[charIdx], depth); + } + } + } +} + +/** + * Fog/mist effect + */ +function fog(fb, frame, options = {}) { + const { density = 0.3, speed = 0.5, depth = 100 } = options; + const fogChars = ['.', ':', '.', ' ']; + + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + const seed = x * 31 + y * 17; + const noise = seededRandom(seed + Math.floor(frame * speed * 0.1)); + + if (noise < density) { + const charIdx = Math.floor(noise / density * fogChars.length); + fb.setPixel(x, y, fogChars[charIdx], depth); + } + } + } +} + +/** + * Aurora/northern lights effect + */ +function aurora(fb, frame, options = {}) { + const { intensity = 0.5, depth = 100 } = options; + const waveChars = ['ยท', ':', '=', '~', 'โ‰ˆ']; + + // Aurora bands at different heights + const numBands = 3; + for (let band = 0; band < numBands; band++) { + const baseY = 2 + band * 3; + const phaseOffset = band * 2; + + for (let x = 0; x < fb.width; x++) { + const wave = Math.sin((x + frame * 0.5 + phaseOffset) * 0.1) * 2; + const y = Math.floor(baseY + wave); + + if (y >= 0 && y < fb.height) { + const charIdx = Math.floor((Math.sin(x * 0.2 + frame * 0.1) + 1) / 2 * waveChars.length); + if (seededRandom(x + band * 100 + frame) < intensity) { + fb.setPixel(x, y, waveChars[charIdx], depth); + } + } + } + } +} + +/** + * Wave pattern effect + */ +function waves(fb, frame, options = {}) { + const { amplitude = 2, frequency = 0.1, char = '~', baseY = null, depth = 100 } = options; + const waveY = baseY !== null ? baseY : Math.floor(fb.height / 2); + + for (let x = 0; x < fb.width; x++) { + const y = Math.floor(waveY + Math.sin((x + frame) * frequency) * amplitude); + if (y >= 0 && y < fb.height) { + fb.setPixel(x, y, char, depth); + } + } +} + +/** + * Gradient fill (vertical, horizontal, or radial) + */ +function gradient(fb, options = {}) { + const { direction = 'vertical', chars = DENSITY_CHARS, depth = 100, invert = false } = options; + const centerX = fb.width / 2; + const centerY = fb.height / 2; + const maxDist = Math.sqrt(centerX * centerX + centerY * centerY); + + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + let t; + switch (direction) { + case 'horizontal': + t = x / fb.width; + break; + case 'radial': + const dx = x - centerX; + const dy = y - centerY; + t = Math.sqrt(dx * dx + dy * dy) / maxDist; + break; + case 'vertical': + default: + t = y / fb.height; + } + + if (invert) t = 1 - t; + const charIdx = Math.floor(t * (chars.length - 1)); + fb.setPixel(x, y, chars[charIdx], depth); + } + } +} + +/** + * TV static noise effect + */ +function staticNoise(fb, frame, options = {}) { + const { density = 0.1, chars = ['.', ':', '#'], depth = 100 } = options; + + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + const seed = x * 31 + y * 17 + frame * 7; + if (seededRandom(seed) < density) { + const charIdx = Math.floor(seededRandom(seed + 1) * chars.length); + fb.setPixel(x, y, chars[charIdx], depth); + } + } + } +} + +/** + * Concentric ripples effect + */ +function ripples(fb, frame, options = {}) { + const { cx = null, cy = null, speed = 1, char = 'ยท', depth = 100 } = options; + const centerX = cx !== null ? cx : fb.width / 2; + const centerY = cy !== null ? cy : fb.height / 2; + const aspectRatio = 2.16; + + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + const dx = (x - centerX) / aspectRatio; + const dy = y - centerY; + const dist = Math.sqrt(dx * dx + dy * dy); + + // Ripple pattern + const ripple = Math.sin(dist - frame * speed * 0.5); + if (ripple > 0.8) { + fb.setPixel(x, y, char, depth); + } + } + } +} + +/** + * Fireflies effect + */ +function fireflies(fb, frame, options = {}) { + const { count = 8, chars = ['ยท', '*', 'ยฐ'], depth = 50 } = options; + + for (let i = 0; i < count; i++) { + const seed = i * 31; + // Random base position + const baseX = seededRandom(seed) * fb.width; + const baseY = seededRandom(seed + 1) * fb.height; + + // Gentle floating motion + const floatX = Math.sin((frame + seed) * 0.05) * 3; + const floatY = Math.cos((frame + seed * 2) * 0.03) * 2; + + const x = Math.floor((baseX + floatX + fb.width) % fb.width); + const y = Math.floor((baseY + floatY + fb.height) % fb.height); + + // Blink effect + const blinkPhase = (frame + seed * 7) % 60; + if (blinkPhase < 30) { + const brightness = blinkPhase < 15 ? blinkPhase / 15 : (30 - blinkPhase) / 15; + const charIdx = Math.floor(brightness * (chars.length - 1)); + fb.setPixel(x, y, chars[charIdx], depth); + } + } +} + +/** + * Drifting clouds effect + */ +function clouds(fb, frame, options = {}) { + const { count = 3, speed = 0.5, depth = 100 } = options; + const cloudChars = ['โ–‘', 'โ–’', 'โ–“']; + + for (let i = 0; i < count; i++) { + const seed = i * 47; + const baseY = 2 + Math.floor(seededRandom(seed) * (fb.height / 3)); + const baseX = seededRandom(seed + 1) * fb.width; + const cloudWidth = 8 + Math.floor(seededRandom(seed + 2) * 12); + + const x = Math.floor((baseX + frame * speed) % (fb.width + cloudWidth)) - cloudWidth; + + // Draw cloud shape + for (let dx = 0; dx < cloudWidth; dx++) { + const cloudX = x + dx; + if (cloudX >= 0 && cloudX < fb.width) { + // Cloud density varies across width + const density = 1 - Math.abs(dx - cloudWidth / 2) / (cloudWidth / 2); + const charIdx = Math.floor(density * (cloudChars.length - 1)); + fb.setPixel(cloudX, baseY, cloudChars[charIdx], depth); + + // Add some height variation + if (density > 0.5 && baseY > 0) { + fb.setPixel(cloudX, baseY - 1, cloudChars[0], depth); + } + } + } + } +} + +// Make available globally for browser script tag usage +if (typeof globalThis !== 'undefined') { + Object.assign(globalThis, { + stars, starfield, rain, snow, fog, aurora, waves, + gradient, staticNoise, ripples, fireflies, clouds + }); +} +})(); diff --git a/plugins/thinkback/skills/thinkback/helpers/borders.js b/plugins/thinkback/skills/thinkback/helpers/borders.js new file mode 100644 index 0000000..5ab4a51 --- /dev/null +++ b/plugins/thinkback/skills/thinkback/helpers/borders.js @@ -0,0 +1,313 @@ +/* eslint-disable */ +/** + * Border and Frame Effects + * Decorative borders for framing content + */ +(function() { + +// Border character sets +const BORDERS = { + single: { tl: 'โ”Œ', tr: 'โ”', bl: 'โ””', br: 'โ”˜', h: 'โ”€', v: 'โ”‚' }, + double: { tl: 'โ•”', tr: 'โ•—', bl: 'โ•š', br: 'โ•', h: 'โ•', v: 'โ•‘' }, + rounded: { tl: 'โ•ญ', tr: 'โ•ฎ', bl: 'โ•ฐ', br: 'โ•ฏ', h: 'โ”€', v: 'โ”‚' }, + heavy: { tl: 'โ”', tr: 'โ”“', bl: 'โ”—', br: 'โ”›', h: 'โ”', v: 'โ”ƒ' }, + ascii: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }, + dotted: { tl: 'ยท', tr: 'ยท', bl: 'ยท', br: 'ยท', h: 'ยท', v: 'ยท' }, +}; + +// Seeded random for animation consistency +function seededRandom(seed) { + const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; + return x - Math.floor(x); +} + +/** + * Draw a box border + */ +function boxBorder(fb, options = {}) { + const { x = 0, y = 0, width = fb.width, height = fb.height, style = 'single', depth = 0 } = options; + const chars = BORDERS[style] || BORDERS.single; + + // Corners + fb.setPixel(x, y, chars.tl, depth); + fb.setPixel(x + width - 1, y, chars.tr, depth); + fb.setPixel(x, y + height - 1, chars.bl, depth); + fb.setPixel(x + width - 1, y + height - 1, chars.br, depth); + + // Horizontal lines + for (let dx = 1; dx < width - 1; dx++) { + fb.setPixel(x + dx, y, chars.h, depth); + fb.setPixel(x + dx, y + height - 1, chars.h, depth); + } + + // Vertical lines + for (let dy = 1; dy < height - 1; dy++) { + fb.setPixel(x, y + dy, chars.v, depth); + fb.setPixel(x + width - 1, y + dy, chars.v, depth); + } +} + +/** + * Draw a fullscreen border with padding + */ +function fullscreenBorder(fb, options = {}) { + const { style = 'single', padding = 1, depth = 0 } = options; + boxBorder(fb, { + x: padding, + y: padding, + width: fb.width - padding * 2, + height: fb.height - padding * 2, + style, + depth + }); +} + +/** + * Draw only corner decorations + */ +function cornerDecor(fb, options = {}) { + const { style = 'flourish', padding = 1, size = 3, depth = 0 } = options; + + const flourishCorners = { + tl: ['โ•”', 'โ•', 'โ•', 'โ•‘', ' ', ' ', 'โ•‘', ' ', ' '], + tr: ['โ•', 'โ•', 'โ•—', ' ', ' ', 'โ•‘', ' ', ' ', 'โ•‘'], + bl: ['โ•‘', ' ', ' ', 'โ•‘', ' ', ' ', 'โ•š', 'โ•', 'โ•'], + br: [' ', ' ', 'โ•‘', ' ', ' ', 'โ•‘', 'โ•', 'โ•', 'โ•'], + }; + + const simpleCorners = { + tl: ['โ”Œ', 'โ”€', ' ', 'โ”‚', ' ', ' ', ' ', ' ', ' '], + tr: [' ', 'โ”€', 'โ”', ' ', ' ', 'โ”‚', ' ', ' ', ' '], + bl: [' ', ' ', ' ', 'โ”‚', ' ', ' ', 'โ””', 'โ”€', ' '], + br: [' ', ' ', ' ', ' ', ' ', 'โ”‚', ' ', 'โ”€', 'โ”˜'], + }; + + const corners = style === 'flourish' ? flourishCorners : simpleCorners; + + // Draw each corner + const positions = [ + { corner: 'tl', x: padding, y: padding }, + { corner: 'tr', x: fb.width - padding - size, y: padding }, + { corner: 'bl', x: padding, y: fb.height - padding - size }, + { corner: 'br', x: fb.width - padding - size, y: fb.height - padding - size }, + ]; + + for (const pos of positions) { + const chars = corners[pos.corner]; + for (let dy = 0; dy < 3; dy++) { + for (let dx = 0; dx < 3; dx++) { + const char = chars[dy * 3 + dx]; + if (char !== ' ') { + fb.setPixel(pos.x + dx, pos.y + dy, char, depth); + } + } + } + } +} + +/** + * Marching ants animated border + */ +function marchingAnts(fb, frame, options = {}) { + const { x = 0, y = 0, width = fb.width, height = fb.height, speed = 1, depth = 0 } = options; + const pattern = ['โ”€', 'ยท', 'โ”€', 'ยท']; + const offset = Math.floor(frame * speed) % pattern.length; + + // Top edge + for (let dx = 0; dx < width; dx++) { + const char = pattern[(dx + offset) % pattern.length]; + fb.setPixel(x + dx, y, char, depth); + } + + // Bottom edge + for (let dx = 0; dx < width; dx++) { + const char = pattern[(dx - offset + pattern.length * 100) % pattern.length]; + fb.setPixel(x + dx, y + height - 1, char, depth); + } + + // Left edge + for (let dy = 1; dy < height - 1; dy++) { + const char = pattern[(dy - offset + pattern.length * 100) % pattern.length] === 'โ”€' ? 'โ”‚' : 'ยท'; + fb.setPixel(x, y + dy, char, depth); + } + + // Right edge + for (let dy = 1; dy < height - 1; dy++) { + const char = pattern[(dy + offset) % pattern.length] === 'โ”€' ? 'โ”‚' : 'ยท'; + fb.setPixel(x + width - 1, y + dy, char, depth); + } +} + +/** + * Pulsing border effect + */ +function pulseBorder(fb, frame, options = {}) { + const { x = 1, y = 1, width = null, height = null, depth = 0 } = options; + const w = width || fb.width - 2; + const h = height || fb.height - 2; + + // Cycle through border styles + const styles = ['dotted', 'single', 'heavy', 'double', 'heavy', 'single']; + const styleIdx = Math.floor(frame / 10) % styles.length; + + boxBorder(fb, { x, y, width: w, height: h, style: styles[styleIdx], depth }); +} + +/** + * Growing border animation + */ +function growBorder(fb, progress, options = {}) { + const { x = 0, y = 0, width = fb.width, height = fb.height, style = 'single', depth = 0 } = options; + const chars = BORDERS[style] || BORDERS.single; + + // Calculate how much of the border to draw + const perimeter = 2 * (width + height) - 4; + const drawn = Math.floor(progress * perimeter); + + let count = 0; + + // Top edge (left to right) + for (let dx = 0; dx < width && count < drawn; dx++, count++) { + const char = dx === 0 ? chars.tl : (dx === width - 1 ? chars.tr : chars.h); + fb.setPixel(x + dx, y, char, depth); + } + + // Right edge (top to bottom, excluding top corner) + for (let dy = 1; dy < height && count < drawn; dy++, count++) { + const char = dy === height - 1 ? chars.br : chars.v; + fb.setPixel(x + width - 1, y + dy, char, depth); + } + + // Bottom edge (right to left, excluding right corner) + for (let dx = width - 2; dx >= 0 && count < drawn; dx--, count++) { + const char = dx === 0 ? chars.bl : chars.h; + fb.setPixel(x + dx, y + height - 1, char, depth); + } + + // Left edge (bottom to top, excluding corners) + for (let dy = height - 2; dy > 0 && count < drawn; dy--, count++) { + fb.setPixel(x, y + dy, chars.v, depth); + } +} + +/** + * Draw a border with a title + */ +function framedTitle(fb, y, title, options = {}) { + const { style = 'single', padding = 2, depth = 0 } = options; + const chars = BORDERS[style] || BORDERS.single; + + const totalWidth = title.length + padding * 2 + 4; // 4 for border chars and spacing + const x = Math.floor((fb.width - totalWidth) / 2); + + // Left side: โ”Œโ”€โ”€โ”€ + fb.setPixel(x, y, chars.tl, depth); + for (let i = 1; i <= padding; i++) { + fb.setPixel(x + i, y, chars.h, depth); + } + + // Space before title + fb.setPixel(x + padding + 1, y, ' ', depth); + + // Title + for (let i = 0; i < title.length; i++) { + fb.setPixel(x + padding + 2 + i, y, title[i], depth); + } + + // Space after title + fb.setPixel(x + padding + 2 + title.length, y, ' ', depth); + + // Right side: โ”€โ”€โ”€โ” + for (let i = 1; i <= padding; i++) { + fb.setPixel(x + padding + 3 + title.length + i - 1, y, chars.h, depth); + } + fb.setPixel(x + totalWidth - 1, y, chars.tr, depth); +} + +/** + * Gradient border using density characters + */ +function gradientBorder(fb, frame, options = {}) { + const { x = 1, y = 1, width = null, height = null, depth = 0 } = options; + const w = width || fb.width - 2; + const h = height || fb.height - 2; + const densityChars = ['.', ':', '-', '=', '+', '*', '#', '%', '@']; + + const perimeter = 2 * (w + h) - 4; + + let pos = 0; + + // Top edge + for (let dx = 0; dx < w; dx++, pos++) { + const charIdx = Math.floor(((pos + frame) % perimeter) / perimeter * densityChars.length); + fb.setPixel(x + dx, y, densityChars[charIdx], depth); + } + + // Right edge + for (let dy = 1; dy < h; dy++, pos++) { + const charIdx = Math.floor(((pos + frame) % perimeter) / perimeter * densityChars.length); + fb.setPixel(x + w - 1, y + dy, densityChars[charIdx], depth); + } + + // Bottom edge + for (let dx = w - 2; dx >= 0; dx--, pos++) { + const charIdx = Math.floor(((pos + frame) % perimeter) / perimeter * densityChars.length); + fb.setPixel(x + dx, y + h - 1, densityChars[charIdx], depth); + } + + // Left edge + for (let dy = h - 2; dy > 0; dy--, pos++) { + const charIdx = Math.floor(((pos + frame) % perimeter) / perimeter * densityChars.length); + fb.setPixel(x, y + dy, densityChars[charIdx], depth); + } +} + +/** + * Decorative divider line + */ +function divider(fb, y, options = {}) { + const { style = 'single', padding = 2, depth = 0 } = options; + const chars = BORDERS[style] || BORDERS.single; + + for (let x = padding; x < fb.width - padding; x++) { + fb.setPixel(x, y, chars.h, depth); + } +} + +/** + * Decorative divider with center text + */ +function dividerWithText(fb, y, text, options = {}) { + const { style = 'single', depth = 0 } = options; + const chars = BORDERS[style] || BORDERS.single; + + const textStart = Math.floor((fb.width - text.length) / 2) - 1; + const textEnd = textStart + text.length + 1; + + // Left line + for (let x = 2; x < textStart; x++) { + fb.setPixel(x, y, chars.h, depth); + } + + // Text with spacing + fb.setPixel(textStart, y, ' ', depth); + for (let i = 0; i < text.length; i++) { + fb.setPixel(textStart + 1 + i, y, text[i], depth); + } + fb.setPixel(textEnd, y, ' ', depth); + + // Right line + for (let x = textEnd + 1; x < fb.width - 2; x++) { + fb.setPixel(x, y, chars.h, depth); + } +} + +// Make available globally for browser script tag usage +if (typeof globalThis !== 'undefined') { + Object.assign(globalThis, { + BORDERS, boxBorder, fullscreenBorder, cornerDecor, + marchingAnts, pulseBorder, growBorder, framedTitle, + gradientBorder, divider, dividerWithText + }); +} +})(); diff --git a/plugins/thinkback/skills/thinkback/helpers/index.js b/plugins/thinkback/skills/thinkback/helpers/index.js new file mode 100644 index 0000000..1281213 --- /dev/null +++ b/plugins/thinkback/skills/thinkback/helpers/index.js @@ -0,0 +1,66 @@ +/* eslint-disable */ +/** + * Thinkback Animation Helpers + * Re-exports all helper modules for easy importing + */ + +// Import files to execute them and populate globalThis +import './transitions.js'; +import './backgrounds.js'; +import './text_effects.js'; +import './particles.js'; +import './borders.js'; +import './scene_system.js'; +import './news_effects.js'; +import './awards_effects.js'; +import './rpg_effects.js'; + +// Re-export from globalThis for ES module consumers +export const { + // transitions + wipeLeft, wipeRight, wipeDown, wipeUp, + circleReveal, circleClose, irisIn, irisOut, + blindsH, blindsV, checkerboard, diagonalWipe, + dissolve, pixelate, matrixRain, getSlideOffset, fade, + supernova, spiral, shatter, tornado, + // backgrounds + stars, starfield, rain, snow, fog, aurora, waves, + gradient, staticNoise, ripples, fireflies, clouds, + // text_effects + typewriter, fadeByLetter, wave, bounce, shake, float, + drawTypewriter, drawTypewriterCentered, drawWaveText, + drawGlitchText, drawGlitchTextCentered, drawScatterText, + slideIn, slideOut, drawZoomText, drawFadeInText, + drawRainbowText, drawWipeReveal, + // Claude branding + CLAUDE_MASCOT, CLAUDE_MASCOT_WIDTH, drawClaudeMascot, + CLAUDE_CODE_LOGO, CLAUDE_CODE_LOGO_WIDTH, CLAUDE_CODE_LOGO_HEIGHT, + CLAUDE_ORANGE, drawClaudeCodeLogo, drawThinkbackIntro, + // particles + confetti, sparkles, burst, bubbles, hearts, musicNotes, + leaves, embers, dust, floatingParticles, trail, orbit, + shootingStars, glitter, + // borders + BORDERS, boxBorder, fullscreenBorder, cornerDecor, + marchingAnts, pulseBorder, growBorder, framedTitle, + gradientBorder, divider, dividerWithText, + // scene system + SceneManager, getScenePhase, renderScene, createScene, + staggeredReveal, easeInOut, easeOut, animateCounter, + ANIMATION_FPS, DEFAULT_HOLD_SECONDS, + DEFAULT_TRANSITION_IN_SECONDS, DEFAULT_TRANSITION_OUT_SECONDS, + // news effects + lowerThird, tickerTape, breakingBanner, liveIndicator, + segmentTitle, statCounter, forecastBar, splitWipe, + pushTransition, headlineCrawl, countdownReveal, + newsArticle, newsGrid, headlineCarousel, accomplishmentSpotlight, newsFeed, + // awards effects + trophyDisplay, awardBadge, envelopeReveal, categoryTitle, + acceptanceSpeech, nomineeCard, winnerAnnouncement, applauseMeter, + standingOvation, redCarpetBorder, spotlightText, spotlightReveal, + curtainReveal, awardsStatue, + // rpg effects + characterSprite, titleScreen, textBox, classSelect, + questCard, questBanner, xpBar, levelUp, + statsPanel, bossHealth, victoryFanfare, creditsRoll, inventorySlot, +} = globalThis; diff --git a/plugins/thinkback/skills/thinkback/helpers/news_effects.js b/plugins/thinkback/skills/thinkback/helpers/news_effects.js new file mode 100644 index 0000000..cbff9ed --- /dev/null +++ b/plugins/thinkback/skills/thinkback/helpers/news_effects.js @@ -0,0 +1,970 @@ +/* eslint-disable */ +/** + * News Broadcast Effects + * Specialized effects for the morning news vibe + */ +(function() { + +// Seeded random for consistent effects +function seededRandom(seed) { + const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; + return x - Math.floor(x); +} + +// 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; +} + +// ============================================================================ +// LOWER THIRD - News-style stat display bar +// ============================================================================ + +/** + * Draw a "lower third" graphics bar (news-style info bar) + * @param fb - Framebuffer + * @param y - Y position (typically near bottom) + * @param label - Left side label (e.g., "COMMITS THIS YEAR") + * @param value - Right side value (e.g., "1,247") + * @param progress - Animation progress 0-1 (for reveal) + * @param options - { style: 'single'|'double'|'heavy', depth: number } + */ +function lowerThird(fb, y, label, value, progress, options = {}) { + const { style = 'heavy', depth = 5, accentChar = 'โ–Œ' } = options; + + const totalWidth = Math.min(fb.width - 4, 50); + const x = Math.floor((fb.width - totalWidth) / 2); + + // Animate width based on progress + const visibleWidth = Math.floor(easeOut(progress) * totalWidth); + if (visibleWidth < 3) return; + + const BORDERS = { + single: { h: 'โ”€', v: 'โ”‚', tl: 'โ”Œ', tr: 'โ”', bl: 'โ””', br: 'โ”˜' }, + double: { h: 'โ•', v: 'โ•‘', tl: 'โ•”', tr: 'โ•—', bl: 'โ•š', br: 'โ•' }, + heavy: { h: 'โ”', v: 'โ”ƒ', tl: 'โ”', tr: 'โ”“', bl: 'โ”—', br: 'โ”›' }, + }; + const chars = BORDERS[style] || BORDERS.heavy; + + const startX = Math.floor((fb.width - visibleWidth) / 2); + + // Top border + fb.setPixel(startX, y, chars.tl, depth); + for (let i = 1; i < visibleWidth - 1; i++) { + fb.setPixel(startX + i, y, chars.h, depth); + } + fb.setPixel(startX + visibleWidth - 1, y, chars.tr, depth); + + // Content line with accent bar + fb.setPixel(startX, y + 1, chars.v, depth); + fb.setPixel(startX + 1, y + 1, accentChar, depth); + fb.setPixel(startX + 2, y + 1, ' ', depth); + + // Draw label (left-aligned after accent) + const maxLabelLen = visibleWidth - value.length - 6; + const displayLabel = label.slice(0, Math.max(0, maxLabelLen)); + for (let i = 0; i < displayLabel.length; i++) { + fb.setPixel(startX + 3 + i, y + 1, displayLabel[i], depth); + } + + // Draw value (right-aligned) + const valueStart = startX + visibleWidth - value.length - 2; + for (let i = 0; i < value.length; i++) { + if (valueStart + i > startX + 3 + displayLabel.length) { + fb.setPixel(valueStart + i, y + 1, value[i], depth); + } + } + + fb.setPixel(startX + visibleWidth - 1, y + 1, chars.v, depth); + + // Bottom border + fb.setPixel(startX, y + 2, chars.bl, depth); + for (let i = 1; i < visibleWidth - 1; i++) { + fb.setPixel(startX + i, y + 2, chars.h, depth); + } + fb.setPixel(startX + visibleWidth - 1, y + 2, chars.br, depth); +} + +// ============================================================================ +// TICKER TAPE - Scrolling news ticker +// ============================================================================ + +/** + * Draw a scrolling ticker tape at bottom of screen + * @param fb - Framebuffer + * @param y - Y position + * @param items - Array of strings to scroll + * @param frame - Current frame for animation + * @param options - { separator: string, speed: number, depth: number } + */ +function tickerTape(fb, y, items, frame, options = {}) { + const { separator = ' โ–ธ ', speed = 0.5, depth = 5 } = options; + + // Build full ticker string + const tickerText = items.join(separator) + separator; + const doubledText = tickerText + tickerText; // Double for seamless loop + + // Calculate scroll offset + const offset = Math.floor(frame * speed) % tickerText.length; + + // Draw ticker background line + for (let x = 0; x < fb.width; x++) { + const charIdx = (x + offset) % doubledText.length; + fb.setPixel(x, y, doubledText[charIdx], depth); + } +} + +// ============================================================================ +// BREAKING NEWS BANNER +// ============================================================================ + +/** + * Draw a breaking news banner with optional flash effect + * @param fb - Framebuffer + * @param y - Y position + * @param text - Breaking news text + * @param frame - Current frame for flash effect + * @param options - { flash: boolean, depth: number } + */ +function breakingBanner(fb, y, text, frame, options = {}) { + const { flash = true, depth = 10 } = options; + + const totalWidth = text.length + 8; + const x = Math.floor((fb.width - totalWidth) / 2); + + // Flash effect - alternate between filled and empty style + const isFlash = flash && Math.floor(frame / 8) % 2 === 0; + + // Top border + fb.setPixel(x, y, 'โ•”', depth); + for (let i = 1; i < totalWidth - 1; i++) { + fb.setPixel(x + i, y, 'โ•', depth); + } + fb.setPixel(x + totalWidth - 1, y, 'โ•—', depth); + + // Content line + fb.setPixel(x, y + 1, 'โ•‘', depth); + + // Flashing indicator + const indicator = isFlash ? ' โšก ' : ' '; + for (let i = 0; i < 3; i++) { + fb.setPixel(x + 1 + i, y + 1, indicator[i], depth); + } + + // Text + for (let i = 0; i < text.length; i++) { + fb.setPixel(x + 4 + i, y + 1, text[i], depth); + } + + // Flashing indicator (end) + for (let i = 0; i < 3; i++) { + fb.setPixel(x + 4 + text.length + i, y + 1, indicator[2 - i], depth); + } + + fb.setPixel(x + totalWidth - 1, y + 1, 'โ•‘', depth); + + // Bottom border + fb.setPixel(x, y + 2, 'โ•š', depth); + for (let i = 1; i < totalWidth - 1; i++) { + fb.setPixel(x + i, y + 2, 'โ•', depth); + } + fb.setPixel(x + totalWidth - 1, y + 2, 'โ•', depth); +} + +// ============================================================================ +// LIVE INDICATOR - Blinking "LIVE" badge +// ============================================================================ + +/** + * Draw a blinking LIVE indicator + * @param fb - Framebuffer + * @param x - X position + * @param y - Y position + * @param frame - Current frame for blink effect + * @param options - { speed: number, depth: number } + */ +function liveIndicator(fb, x, y, frame, options = {}) { + const { speed = 0.3, depth = 10 } = options; + + // Blink the dot + const showDot = Math.sin(frame * speed) > 0; + const dot = showDot ? 'โ—' : 'โ—‹'; + + const text = `${dot} LIVE`; + for (let i = 0; i < text.length; i++) { + fb.setPixel(x + i, y, text[i], depth); + } +} + +// ============================================================================ +// SEGMENT TITLE - News segment header +// ============================================================================ + +/** + * Draw a segment title with decorative elements + * @param fb - Framebuffer + * @param y - Y position + * @param title - Segment title (e.g., "TOP STORIES") + * @param progress - Animation progress 0-1 + * @param options - { style: 'bracket'|'arrow'|'box', depth: number } + */ +function segmentTitle(fb, y, title, progress, options = {}) { + const { style = 'arrow', depth = 5 } = options; + + const easedProgress = easeOut(progress); + const visibleChars = Math.floor(easedProgress * title.length); + const visibleTitle = title.slice(0, visibleChars); + + let fullText; + switch (style) { + case 'bracket': + fullText = `ใ€ ${visibleTitle} ใ€‘`; + break; + case 'box': + fullText = `โ”ƒ ${visibleTitle} โ”ƒ`; + break; + case 'arrow': + default: + fullText = `โ–ธโ–ธโ–ธ ${visibleTitle}`; + } + + const x = Math.floor((fb.width - fullText.length) / 2); + + for (let i = 0; i < fullText.length; i++) { + fb.setPixel(x + i, y, fullText[i], depth); + } +} + +// ============================================================================ +// STAT COUNTER - Animated number reveal +// ============================================================================ + +/** + * Animate a number counting up + * @param fb - Framebuffer + * @param x - X position + * @param y - Y position + * @param targetValue - Final number to display + * @param progress - Animation progress 0-1 + * @param options - { prefix: string, suffix: string, depth: number } + */ +function statCounter(fb, x, y, targetValue, progress, options = {}) { + const { prefix = '', suffix = '', depth = 5, commas = true } = options; + + const easedProgress = easeOut(progress); + const currentValue = Math.floor(easedProgress * targetValue); + + // Format with commas if requested + let valueStr = currentValue.toString(); + if (commas && currentValue >= 1000) { + valueStr = currentValue.toLocaleString(); + } + + const fullText = `${prefix}${valueStr}${suffix}`; + + for (let i = 0; i < fullText.length; i++) { + fb.setPixel(x + i, y, fullText[i], depth); + } +} + +// ============================================================================ +// WEATHER FORECAST STYLE - Bar chart display +// ============================================================================ + +/** + * Draw a horizontal bar chart (weather forecast style) + * @param fb - Framebuffer + * @param x - X position + * @param y - Y position + * @param label - Label for this bar + * @param value - Value (0-1 for percentage) + * @param maxWidth - Maximum width of bar + * @param progress - Animation progress 0-1 + * @param options - { char: string, depth: number } + */ +function forecastBar(fb, x, y, label, value, maxWidth, progress, options = {}) { + const { char = 'โ–ˆ', emptyChar = 'โ–‘', depth = 5 } = options; + + const labelWidth = 12; + const barWidth = maxWidth - labelWidth - 2; + + // Draw label (right-aligned in label area) + const paddedLabel = label.slice(0, labelWidth).padStart(labelWidth); + for (let i = 0; i < paddedLabel.length; i++) { + fb.setPixel(x + i, y, paddedLabel[i], depth); + } + + // Separator + fb.setPixel(x + labelWidth, y, ' ', depth); + + // Draw bar + const animatedValue = value * easeOut(progress); + const filledWidth = Math.floor(animatedValue * barWidth); + + for (let i = 0; i < barWidth; i++) { + const barChar = i < filledWidth ? char : emptyChar; + fb.setPixel(x + labelWidth + 1 + i, y, barChar, depth); + } +} + +// ============================================================================ +// SPLIT WIPE TRANSITION - News-style double wipe +// ============================================================================ + +/** + * Split wipe from center outward (news transition style) + * @param fb - Framebuffer + * @param progress - Transition progress 0-1 + */ +function splitWipe(fb, progress) { + const centerY = Math.floor(fb.height / 2); + const halfHeight = fb.height / 2; + const wipeDistance = Math.floor(easeOut(progress) * halfHeight); + + // Wipe from center upward + for (let y = centerY - wipeDistance; y >= 0; y--) { + for (let x = 0; x < fb.width; x++) { + if (centerY - y > wipeDistance) { + fb.setPixel(x, y, ' ', -100); + } + } + } + + // Wipe from center downward + for (let y = centerY + wipeDistance; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + if (y - centerY > wipeDistance) { + fb.setPixel(x, y, ' ', -100); + } + } + } +} + +// ============================================================================ +// PUSH TRANSITION - Content pushes off screen +// ============================================================================ + +/** + * Push transition - old content slides out as new slides in + * @param fb - Framebuffer + * @param progress - Transition progress 0-1 + * @param direction - 'left', 'right', 'up', 'down' + */ +function pushTransition(fb, progress, direction = 'left') { + const easedProgress = easeOut(progress); + + if (direction === 'left' || direction === 'right') { + const offset = Math.floor(easedProgress * fb.width); + const dir = direction === 'left' ? 1 : -1; + + // Clear the area being pushed into + for (let y = 0; y < fb.height; y++) { + for (let i = 0; i < offset; i++) { + const x = direction === 'left' ? fb.width - 1 - i : i; + fb.setPixel(x, y, ' ', -100); + } + } + } else { + const offset = Math.floor(easedProgress * fb.height); + + for (let x = 0; x < fb.width; x++) { + for (let i = 0; i < offset; i++) { + const y = direction === 'up' ? fb.height - 1 - i : i; + fb.setPixel(x, y, ' ', -100); + } + } + } +} + +// ============================================================================ +// HEADLINE CRAWL - Typewriter with cursor flash +// ============================================================================ + +/** + * Draw headline with news-style typewriter and blinking cursor + * @param fb - Framebuffer + * @param y - Y position + * @param text - Headline text + * @param progress - Animation progress 0-1 + * @param frame - Current frame for cursor blink + * @param options - { depth: number } + */ +function headlineCrawl(fb, y, text, progress, frame, options = {}) { + const { depth = 5, centered = true } = options; + + const visibleChars = Math.floor(progress * text.length); + const visibleText = text.slice(0, visibleChars); + + const x = centered + ? Math.floor((fb.width - text.length) / 2) + : 2; + + // Draw visible text + for (let i = 0; i < visibleText.length; i++) { + fb.setPixel(x + i, y, visibleText[i], depth); + } + + // Blinking block cursor at end + if (progress < 1) { + const cursorChar = Math.floor(frame / 6) % 2 === 0 ? 'โ–ˆ' : ' '; + fb.setPixel(x + visibleChars, y, cursorChar, depth); + } +} + +// ============================================================================ +// COUNTDOWN REVEAL - "3... 2... 1..." style countdown +// ============================================================================ + +/** + * Draw a dramatic countdown + * @param fb - Framebuffer + * @param progress - Animation progress 0-1 + * @param options - { numbers: array, depth: number } + */ +function countdownReveal(fb, progress, options = {}) { + const { numbers = ['3', '2', '1', 'GO!'], depth = 10 } = options; + + const numIdx = Math.floor(progress * numbers.length); + if (numIdx >= numbers.length) return; + + const current = numbers[numIdx]; + const phaseProgress = (progress * numbers.length) % 1; + + // Scale effect + const scale = 1 + (1 - phaseProgress) * 0.5; + + const x = Math.floor((fb.width - current.length) / 2); + const y = Math.floor(fb.height / 2); + + for (let i = 0; i < current.length; i++) { + fb.setPixel(x + i, y, current[i], depth); + } +} + +// ============================================================================ +// NEWS ARTICLE - Full article card display +// ============================================================================ + +/** + * Draw a news article card (like a newspaper article) + * @param fb - Framebuffer + * @param x - X position (left edge) + * @param y - Y position (top edge) + * @param article - { headline, subhead?, body?, stat?, statLabel?, category? } + * @param progress - Animation progress 0-1 + * @param options - { width, style, depth } + */ +function newsArticle(fb, x, y, article, progress, options = {}) { + const { + width = 40, + style = 'boxed', // 'boxed', 'minimal', 'breaking' + depth = 5, + } = options; + + const { + headline = '', + subhead = '', + body = '', + stat = '', + statLabel = '', + category = '', + } = article; + + const easedProgress = easeOut(progress); + if (easedProgress < 0.05) return; + + let currentY = y; + + // Calculate visible width for animation + const visibleWidth = Math.floor(easedProgress * width); + if (visibleWidth < 5) return; + + // Draw box border if boxed style + if (style === 'boxed' || style === 'breaking') { + const borderChar = style === 'breaking' ? 'โ•' : 'โ”€'; + const cornerTL = style === 'breaking' ? 'โ•”' : 'โ”Œ'; + const cornerTR = style === 'breaking' ? 'โ•—' : 'โ”'; + + // Top border + fb.setPixel(x, currentY, cornerTL, depth); + for (let i = 1; i < visibleWidth - 1; i++) { + fb.setPixel(x + i, currentY, borderChar, depth); + } + if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, cornerTR, depth); + currentY++; + } + + // Category tag + if (category && easedProgress > 0.2) { + const catText = ` ${category} `; + const catStart = x + 2; + for (let i = 0; i < catText.length && catStart + i < x + visibleWidth - 1; i++) { + fb.setPixel(catStart + i, currentY, catText[i], depth + 1); + } + currentY++; + } + + // Headline + if (headline && easedProgress > 0.3) { + const headlineProgress = Math.min(1, (easedProgress - 0.3) / 0.3); + const visibleHeadline = headline.slice(0, Math.floor(headlineProgress * headline.length)); + const headStart = x + 2; + for (let i = 0; i < visibleHeadline.length && headStart + i < x + visibleWidth - 2; i++) { + fb.setPixel(headStart + i, currentY, visibleHeadline[i], depth + 2); + } + currentY++; + } + + // Subhead + if (subhead && easedProgress > 0.5) { + const subStart = x + 2; + const displaySub = subhead.slice(0, visibleWidth - 4); + for (let i = 0; i < displaySub.length; i++) { + fb.setPixel(subStart + i, currentY, displaySub[i], depth); + } + currentY++; + } + + // Divider line + if ((body || stat) && easedProgress > 0.55) { + for (let i = 2; i < Math.min(visibleWidth - 2, 15); i++) { + fb.setPixel(x + i, currentY, 'ยท', depth); + } + currentY++; + } + + // Body text (wrap if needed) + if (body && easedProgress > 0.6) { + const bodyProgress = Math.min(1, (easedProgress - 0.6) / 0.3); + const maxBodyWidth = visibleWidth - 4; + const words = body.split(' '); + let line = ''; + let lineCount = 0; + const maxLines = 3; + + for (const word of words) { + if ((line + ' ' + word).length > maxBodyWidth) { + // Draw current line + const visibleLine = line.slice(0, Math.floor(bodyProgress * line.length)); + for (let i = 0; i < visibleLine.length; i++) { + fb.setPixel(x + 2 + i, currentY, visibleLine[i], depth); + } + currentY++; + lineCount++; + if (lineCount >= maxLines) break; + line = word; + } else { + line = line ? line + ' ' + word : word; + } + } + // Draw remaining line + if (line && lineCount < maxLines) { + const visibleLine = line.slice(0, Math.floor(bodyProgress * line.length)); + for (let i = 0; i < visibleLine.length; i++) { + fb.setPixel(x + 2 + i, currentY, visibleLine[i], depth); + } + currentY++; + } + } + + // Big stat display + if (stat && easedProgress > 0.7) { + const statProgress = Math.min(1, (easedProgress - 0.7) / 0.2); + const statStr = typeof stat === 'number' + ? Math.floor(statProgress * stat).toLocaleString() + : stat; + const statStart = x + 2; + for (let i = 0; i < statStr.length && statStart + i < x + visibleWidth - 2; i++) { + fb.setPixel(statStart + i, currentY, statStr[i], depth + 3); + } + currentY++; + + if (statLabel) { + for (let i = 0; i < statLabel.length && statStart + i < x + visibleWidth - 2; i++) { + fb.setPixel(statStart + i, currentY, statLabel[i], depth); + } + currentY++; + } + } + + // Bottom border + if (style === 'boxed' || style === 'breaking') { + const borderChar = style === 'breaking' ? 'โ•' : 'โ”€'; + const cornerBL = style === 'breaking' ? 'โ•š' : 'โ””'; + const cornerBR = style === 'breaking' ? 'โ•' : 'โ”˜'; + + fb.setPixel(x, currentY, cornerBL, depth); + for (let i = 1; i < visibleWidth - 1; i++) { + fb.setPixel(x + i, currentY, borderChar, depth); + } + if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, cornerBR, depth); + } + + return currentY - y + 1; // Return height +} + +// ============================================================================ +// NEWS GRID - Multiple articles in a grid layout +// ============================================================================ + +/** + * Draw multiple news articles in a grid + * @param fb - Framebuffer + * @param articles - Array of article objects + * @param progress - Overall animation progress 0-1 + * @param options - { columns, startY, spacing, articleWidth, staggerDelay } + */ +function newsGrid(fb, articles, progress, options = {}) { + const { + columns = 2, + startY = 4, + startX = 2, + spacing = 2, + articleWidth = 35, + staggerDelay = 0.15, + style = 'boxed', + } = options; + + let currentY = startY; + let maxHeightInRow = 0; + + articles.forEach((article, idx) => { + const col = idx % columns; + const row = Math.floor(idx / columns); + + // Calculate staggered progress for this article + const articleProgress = Math.max(0, Math.min(1, + (progress - idx * staggerDelay) / (1 - (articles.length - 1) * staggerDelay) + )); + + if (articleProgress <= 0) return; + + const x = startX + col * (articleWidth + spacing); + + // Reset Y for new row + if (col === 0 && row > 0) { + currentY += maxHeightInRow + spacing; + maxHeightInRow = 0; + } + + const height = newsArticle(fb, x, currentY, article, articleProgress, { + width: articleWidth, + style, + }); + + maxHeightInRow = Math.max(maxHeightInRow, height || 0); + }); +} + +// ============================================================================ +// HEADLINE CAROUSEL - Rotating headlines +// ============================================================================ + +/** + * Show headlines one at a time with transitions + * @param fb - Framebuffer + * @param headlines - Array of headline strings or article objects + * @param progress - Animation progress 0-1 (cycles through all headlines) + * @param frame - Current frame for effects + * @param options - { y, style } + */ +function headlineCarousel(fb, headlines, progress, frame, options = {}) { + const { y = 10, style = 'crawl', depth = 5 } = options; + + const numHeadlines = headlines.length; + const headlineIdx = Math.floor(progress * numHeadlines) % numHeadlines; + const headlineProgress = (progress * numHeadlines) % 1; + + const headline = headlines[headlineIdx]; + const text = typeof headline === 'string' ? headline : headline.headline; + + if (style === 'crawl') { + headlineCrawl(fb, y, text, headlineProgress, frame, { depth }); + } else if (style === 'slide') { + if (headlineProgress < 0.2) { + // Slide in + const slideP = headlineProgress / 0.2; + slideIn(fb, y, text, slideP, { from: 'right' }); + } else if (headlineProgress > 0.8) { + // Slide out + const slideP = (headlineProgress - 0.8) / 0.2; + slideOut(fb, y, text, slideP, { to: 'left' }); + } else { + // Static + fb.drawCenteredText(y, text); + } + } else { + // Fade style + const opacity = headlineProgress < 0.2 ? headlineProgress / 0.2 + : headlineProgress > 0.8 ? 1 - (headlineProgress - 0.8) / 0.2 + : 1; + if (opacity > 0.5) { + fb.drawCenteredText(y, text); + } + } +} + +// Helper for slideIn/slideOut (if not already available) +function slideIn(fb, y, text, progress, options = {}) { + const { from = 'left', depth = 5 } = options; + const x = from === 'left' + ? Math.floor(-text.length + easeOut(progress) * (fb.width / 2 + text.length / 2)) + : Math.floor(fb.width - easeOut(progress) * (fb.width / 2 + text.length / 2)); + + for (let i = 0; i < text.length; i++) { + if (x + i >= 0 && x + i < fb.width) { + fb.setPixel(x + i, y, text[i], depth); + } + } +} + +function slideOut(fb, y, text, progress, options = {}) { + const { to = 'left', depth = 5 } = options; + const centerX = Math.floor((fb.width - text.length) / 2); + const offset = Math.floor(easeOut(progress) * (fb.width / 2 + text.length)); + const x = to === 'left' ? centerX - offset : centerX + offset; + + for (let i = 0; i < text.length; i++) { + if (x + i >= 0 && x + i < fb.width) { + fb.setPixel(x + i, y, text[i], depth); + } + } +} + +// ============================================================================ +// PROJECT SPOTLIGHT - Dedicated project article display +// ============================================================================ + +/** + * Draw a featured project as a full news article + * @param fb - Framebuffer + * @param project - { name, commits, description?, body?, rank? } + * @param progress - Animation progress 0-1 + * @param frame - Current frame for effects + * @param options - { centered, width } + */ +function accomplishmentSpotlight(fb, project, progress, frame, options = {}) { + const { + centered = true, + width = 50, + y = 5, + depth = 5, + showRank = true, + } = options; + + const { name, commits, description, body, rank } = project; + + const x = centered ? Math.floor((fb.width - width) / 2) : 2; + const easedProgress = easeOut(progress); + + if (easedProgress < 0.1) return; + + // Box border with double lines for emphasis + const visibleWidth = Math.floor(easedProgress * width); + let currentY = y; + + // Helper to draw a row with side borders + function drawRow(text, textDepth = depth) { + fb.setPixel(x, currentY, 'โ•‘', depth); + for (let i = 0; i < text.length && i < visibleWidth - 2; i++) { + fb.setPixel(x + 1 + i, currentY, text[i], textDepth); + } + // Fill remaining space + for (let i = text.length; i < visibleWidth - 2; i++) { + fb.setPixel(x + 1 + i, currentY, ' ', depth); + } + if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, 'โ•‘', depth); + currentY++; + } + + // Top border + fb.setPixel(x, currentY, 'โ•”', depth); + for (let i = 1; i < visibleWidth - 1; i++) { + fb.setPixel(x + i, currentY, 'โ•', depth); + } + if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, 'โ•—', depth); + currentY++; + + // Rank badge + if (showRank && rank && easedProgress > 0.15) { + drawRow(` #${rank} PROJECT`, depth + 1); + } + + // Project name + if (easedProgress > 0.25) { + const nameProgress = Math.min(1, (easedProgress - 0.25) / 0.15); + const visibleName = name.slice(0, Math.floor(nameProgress * name.length)); + drawRow(` ${visibleName}`, depth + 2); + } + + // Divider after name + if (easedProgress > 0.35) { + fb.setPixel(x, currentY, 'โ•Ÿ', depth); + for (let i = 1; i < visibleWidth - 1; i++) { + fb.setPixel(x + i, currentY, 'โ”€', depth); + } + if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, 'โ•ข', depth); + currentY++; + } + + // Commits stat with animated counter + if (easedProgress > 0.4) { + const counterProgress = Math.min(1, (easedProgress - 0.4) / 0.15); + const displayCommits = Math.floor(counterProgress * commits); + drawRow(` ${displayCommits.toLocaleString()} COMMITS`, depth + 3); + } + + // Description (short tagline) + if (description && easedProgress > 0.5) { + const descProgress = Math.min(1, (easedProgress - 0.5) / 0.1); + const maxDesc = visibleWidth - 4; + const displayDesc = description.slice(0, Math.min(maxDesc, Math.floor(descProgress * description.length))); + drawRow(` ${displayDesc}`, depth); + } + + // Body text (longer description with word wrap) + if (body && easedProgress > 0.55) { + // Add a small divider + fb.setPixel(x, currentY, 'โ•‘', depth); + for (let i = 1; i < Math.min(visibleWidth - 1, 20); i++) { + fb.setPixel(x + i, currentY, 'ยท', depth); + } + for (let i = 20; i < visibleWidth - 1; i++) { + fb.setPixel(x + i, currentY, ' ', depth); + } + if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, 'โ•‘', depth); + currentY++; + + // Word-wrap the body text + const bodyProgress = Math.min(1, (easedProgress - 0.55) / 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.85) { + fb.setPixel(x, currentY, 'โ•š', depth); + for (let i = 1; i < visibleWidth - 1; i++) { + fb.setPixel(x + i, currentY, 'โ•', depth); + } + if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, 'โ•', depth); + } +} + +// ============================================================================ +// SCROLLING NEWS FEED - Vertical scrolling headlines +// ============================================================================ + +/** + * Draw a vertically scrolling news feed + * @param fb - Framebuffer + * @param items - Array of { headline, category?, time? } + * @param frame - Current frame for scroll animation + * @param options - { x, y, width, height, speed } + */ +function newsFeed(fb, items, frame, options = {}) { + const { + x = 2, + y = 4, + width = 40, + height = 15, + speed = 0.1, + depth = 5, + } = options; + + const lineHeight = 3; + const totalHeight = items.length * lineHeight; + const scrollOffset = (frame * speed) % totalHeight; + + // Draw border + fb.setPixel(x, y - 1, 'โ”Œ', depth); + for (let i = 1; i < width - 1; i++) { + fb.setPixel(x + i, y - 1, 'โ”€', depth); + } + fb.setPixel(x + width - 1, y - 1, 'โ”', depth); + + items.forEach((item, idx) => { + const itemY = y + idx * lineHeight - Math.floor(scrollOffset); + + // Only draw if within visible area + if (itemY >= y && itemY < y + height) { + // Category/time + if (item.category) { + const catText = `[${item.category}]`; + for (let i = 0; i < catText.length && x + 1 + i < x + width - 1; i++) { + fb.setPixel(x + 1 + i, itemY, catText[i], depth); + } + } + + // Headline + const headY = itemY + 1; + if (headY < y + height) { + const maxLen = width - 3; + const headline = item.headline.slice(0, maxLen); + for (let i = 0; i < headline.length; i++) { + fb.setPixel(x + 2 + i, headY, headline[i], depth + 1); + } + } + } + }); + + // Bottom border + fb.setPixel(x, y + height, 'โ””', depth); + for (let i = 1; i < width - 1; i++) { + fb.setPixel(x + i, y + height, 'โ”€', depth); + } + fb.setPixel(x + width - 1, y + height, 'โ”˜', depth); +} + +// Make available globally for browser script tag usage +if (typeof globalThis !== 'undefined') { + Object.assign(globalThis, { + lowerThird, + tickerTape, + breakingBanner, + liveIndicator, + segmentTitle, + statCounter, + forecastBar, + splitWipe, + pushTransition, + headlineCrawl, + countdownReveal, + // New article helpers + newsArticle, + newsGrid, + headlineCarousel, + accomplishmentSpotlight, + newsFeed, + }); +} +})(); diff --git a/plugins/thinkback/skills/thinkback/helpers/particles.js b/plugins/thinkback/skills/thinkback/helpers/particles.js new file mode 100644 index 0000000..f4874d8 --- /dev/null +++ b/plugins/thinkback/skills/thinkback/helpers/particles.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/** + * Particle System Effects + * Various particle effects for celebrations, atmosphere, etc. + * All functions take (fb, frame, options) + */ +(function() { + +// Seeded random for consistent patterns +function seededRandom(seed) { + const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; + return x - Math.floor(x); +} + +/** + * Confetti celebration effect + */ +function confetti(fb, frame, options = {}) { + const { count = 20, chars = ['โ– ', 'โ—†', 'โ—', 'โ–ฒ', 'โ˜…'], depth = 50, speed = 1 } = options; + + for (let i = 0; i < count; i++) { + const seed = i * 31; + // Random starting position + const startX = seededRandom(seed) * fb.width; + const startY = -seededRandom(seed + 1) * fb.height; + + // Fall with some horizontal drift + const fallSpeed = 0.3 + seededRandom(seed + 2) * 0.5 * speed; + const drift = Math.sin((frame + seed) * 0.1) * 2; + + const y = (startY + frame * fallSpeed) % (fb.height + 10); + const x = Math.floor((startX + drift + fb.width) % fb.width); + + if (y >= 0 && y < fb.height) { + const charIdx = Math.floor(seededRandom(seed + 3) * chars.length); + fb.setPixel(x, Math.floor(y), chars[charIdx], depth); + } + } +} + +/** + * Sparkle effect - random twinkling lights + */ +function sparkles(fb, frame, options = {}) { + const { density = 0.005, chars = ['โœฆ', '*', 'ยท', '+'], depth = 50 } = options; + + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + const seed = x * 31 + y * 17; + // Sparkle appears and disappears + const sparklePhase = (frame + seed) % 20; + if (sparklePhase < 5 && seededRandom(seed) < density) { + const charIdx = Math.floor(sparklePhase / 5 * chars.length); + fb.setPixel(x, y, chars[charIdx], depth); + } + } + } +} + +/** + * Burst effect - particles exploding from a point + */ +function burst(fb, frame, options = {}) { + const { cx = null, cy = null, count = 12, startFrame = 0, char = '*', depth = 50, duration = 30 } = options; + const centerX = cx !== null ? cx : fb.width / 2; + const centerY = cy !== null ? cy : fb.height / 2; + + const elapsed = frame - startFrame; + if (elapsed < 0 || elapsed > duration) return; + + const progress = elapsed / duration; + const radius = progress * Math.max(fb.width, fb.height) / 2; + const fade = 1 - progress; + + for (let i = 0; i < count; i++) { + const angle = (i / count) * Math.PI * 2; + const x = Math.floor(centerX + Math.cos(angle) * radius * 2.16); // Aspect ratio + const y = Math.floor(centerY + Math.sin(angle) * radius); + + if (x >= 0 && x < fb.width && y >= 0 && y < fb.height && fade > 0.3) { + fb.setPixel(x, y, char, depth); + } + } +} + +/** + * Bubbles rising effect + */ +function bubbles(fb, frame, options = {}) { + const { count = 10, chars = ['โ—‹', 'โ—ฏ', 'O', 'o'], depth = 50, speed = 1 } = options; + + for (let i = 0; i < count; i++) { + const seed = i * 47; + const baseX = seededRandom(seed) * fb.width; + const riseSpeed = 0.2 + seededRandom(seed + 1) * 0.3 * speed; + + // Bubbles rise from bottom + const y = fb.height - (frame * riseSpeed + seededRandom(seed + 2) * fb.height) % (fb.height + 5); + + // Slight horizontal wobble + const wobble = Math.sin((frame + seed) * 0.15) * 1.5; + const x = Math.floor((baseX + wobble + fb.width) % fb.width); + + if (y >= 0 && y < fb.height) { + const charIdx = Math.floor(seededRandom(seed + 3) * chars.length); + fb.setPixel(x, Math.floor(y), chars[charIdx], depth); + } + } +} + +/** + * Floating hearts + */ +function hearts(fb, frame, options = {}) { + const { count = 6, char = 'โ™ฅ', depth = 50, speed = 1 } = options; + + for (let i = 0; i < count; i++) { + const seed = i * 23; + const baseX = seededRandom(seed) * fb.width; + const floatSpeed = 0.15 + seededRandom(seed + 1) * 0.2 * speed; + + // Hearts rise gently + const y = fb.height - (frame * floatSpeed + seededRandom(seed + 2) * fb.height) % (fb.height + 5); + + // Gentle sway + const sway = Math.sin((frame + seed) * 0.08) * 2; + const x = Math.floor((baseX + sway + fb.width) % fb.width); + + if (y >= 0 && y < fb.height) { + fb.setPixel(x, Math.floor(y), char, depth); + } + } +} + +/** + * Music notes floating + */ +function musicNotes(fb, frame, options = {}) { + const { count = 8, chars = ['โ™ช', 'โ™ซ', 'โ™ฌ', 'โ™ฉ'], depth = 50, speed = 1 } = options; + + for (let i = 0; i < count; i++) { + const seed = i * 37; + const baseX = seededRandom(seed) * fb.width; + const floatSpeed = 0.2 + seededRandom(seed + 1) * 0.3 * speed; + + // Notes rise and sway + const y = fb.height - (frame * floatSpeed + seededRandom(seed + 2) * fb.height) % (fb.height + 5); + const sway = Math.sin((frame + seed) * 0.1) * 3; + const x = Math.floor((baseX + sway + fb.width) % fb.width); + + if (y >= 0 && y < fb.height) { + const charIdx = Math.floor(seededRandom(seed + 3) * chars.length); + fb.setPixel(x, Math.floor(y), chars[charIdx], depth); + } + } +} + +/** + * Falling leaves + */ +function leaves(fb, frame, options = {}) { + const { count = 8, chars = ['*', 'โœฟ', 'โ€', 'โ—‡'], depth = 50, speed = 1 } = options; + + for (let i = 0; i < count; i++) { + const seed = i * 29; + const baseX = seededRandom(seed) * fb.width; + const fallSpeed = 0.15 + seededRandom(seed + 1) * 0.2 * speed; + + // Leaves fall slowly with horizontal drift + const y = (frame * fallSpeed + seededRandom(seed + 2) * fb.height) % (fb.height + 5); + const drift = Math.sin((frame + seed) * 0.07) * 4; + const x = Math.floor((baseX + drift + frame * 0.1 + fb.width) % fb.width); + + if (y >= 0 && y < fb.height) { + const charIdx = Math.floor(seededRandom(seed + 3) * chars.length); + fb.setPixel(x, Math.floor(y), chars[charIdx], depth); + } + } +} + +/** + * Rising embers/sparks + */ +function embers(fb, frame, options = {}) { + const { count = 10, chars = ['.', 'ยท', '*'], depth = 50, speed = 1 } = options; + + for (let i = 0; i < count; i++) { + const seed = i * 41; + const baseX = seededRandom(seed) * fb.width; + const riseSpeed = 0.3 + seededRandom(seed + 1) * 0.4 * speed; + + // Embers rise from bottom + const y = fb.height - (frame * riseSpeed + seededRandom(seed + 2) * fb.height) % (fb.height + 5); + + // Slight random drift + const drift = Math.sin((frame * 0.5 + seed) * 0.2) * 2; + const x = Math.floor((baseX + drift + fb.width) % fb.width); + + // Fade as they rise + const fadeProgress = 1 - y / fb.height; + const charIdx = Math.floor(fadeProgress * (chars.length - 1)); + + if (y >= 0 && y < fb.height) { + fb.setPixel(x, Math.floor(y), chars[charIdx], depth); + } + } +} + +/** + * Dust motes floating in light + */ +function dust(fb, frame, options = {}) { + const { density = 0.003, char = 'ยท', depth = 100 } = options; + + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + const seed = x * 31 + y * 17; + if (seededRandom(seed) < density) { + // Slow floating motion + const floatX = Math.sin((frame * 0.05 + seed) * 0.3) * 2; + const floatY = Math.cos((frame * 0.05 + seed * 2) * 0.2) * 1; + + const drawX = Math.floor((x + floatX + fb.width) % fb.width); + const drawY = Math.floor((y + floatY + fb.height) % fb.height); + + // Occasional visibility toggle + const visible = ((frame + seed) % 40) < 30; + if (visible) { + fb.setPixel(drawX, drawY, char, depth); + } + } + } + } +} + +/** + * Generic floating particles (refactored from existing) + */ +function floatingParticles(fb, frame, options = {}) { + const { count = 12, char = 'โ—‡', depth = 50, speed = 1 } = options; + + for (let i = 0; i < count; i++) { + const seed = i * 23; + const baseX = seededRandom(seed) * fb.width; + const baseY = seededRandom(seed + 1) * fb.height; + const floatSpeed = 0.3 + seededRandom(seed + 2) * 0.3; + + // Upward float with some variation + const y = (baseY - frame * floatSpeed * speed + fb.height * 2) % fb.height; + const sway = Math.sin((frame + seed) * 0.1) * 2; + const x = Math.floor((baseX + sway + fb.width) % fb.width); + + fb.setPixel(x, Math.floor(y), char, depth); + } +} + +/** + * Trail effect - particles following a path + */ +function trail(fb, points, frame, options = {}) { + const { char = 'ยท', fade = true, depth = 50 } = options; + const trailChars = fade ? ['@', '#', '*', 'ยท', '.'] : [char]; + + for (let i = 0; i < points.length; i++) { + const [x, y] = points[i]; + if (x >= 0 && x < fb.width && y >= 0 && y < fb.height) { + const charIdx = fade ? Math.min(i, trailChars.length - 1) : 0; + fb.setPixel(Math.floor(x), Math.floor(y), trailChars[charIdx], depth); + } + } +} + +/** + * Orbiting particles around a center point + */ +function orbit(fb, frame, options = {}) { + const { cx = null, cy = null, radius = 5, count = 4, char = '*', depth = 50, speed = 1 } = options; + const centerX = cx !== null ? cx : fb.width / 2; + const centerY = cy !== null ? cy : fb.height / 2; + const aspectRatio = 2.16; + + for (let i = 0; i < count; i++) { + const angle = (frame * 0.05 * speed) + (i / count) * Math.PI * 2; + const x = Math.floor(centerX + Math.cos(angle) * radius * aspectRatio); + const y = Math.floor(centerY + Math.sin(angle) * radius); + + if (x >= 0 && x < fb.width && y >= 0 && y < fb.height) { + fb.setPixel(x, y, char, depth); + } + } +} + +/** + * Shooting stars + */ +function shootingStars(fb, frame, options = {}) { + const { count = 2, chars = ['โ˜…', '*', 'ยท', '.'], depth = 50 } = options; + + for (let i = 0; i < count; i++) { + const seed = i * 73 + Math.floor(frame / 50) * 17; + const active = (frame % 50) < 20; + + if (active) { + const progress = (frame % 50) / 20; + const startX = seededRandom(seed) * fb.width * 0.8; + const startY = seededRandom(seed + 1) * fb.height * 0.3; + + // Diagonal trajectory + const x = Math.floor(startX + progress * 30); + const y = Math.floor(startY + progress * 10); + + // Trail + for (let t = 0; t < chars.length; t++) { + const trailX = x - t * 2; + const trailY = y - t; + if (trailX >= 0 && trailX < fb.width && trailY >= 0 && trailY < fb.height) { + fb.setPixel(trailX, trailY, chars[t], depth); + } + } + } + } +} + +/** + * Glitter effect - intense sparkle burst + */ +function glitter(fb, frame, options = {}) { + const { density = 0.02, chars = ['โœฆ', 'โœง', '*', 'ยท'], depth = 40 } = options; + + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + const seed = x * 31 + y * 17 + frame * 3; + if (seededRandom(seed) < density) { + const charIdx = Math.floor(seededRandom(seed + 1) * chars.length); + fb.setPixel(x, y, chars[charIdx], depth); + } + } + } +} + +// Make available globally for browser script tag usage +if (typeof globalThis !== 'undefined') { + Object.assign(globalThis, { + confetti, sparkles, burst, bubbles, hearts, musicNotes, + leaves, embers, dust, floatingParticles, trail, orbit, + shootingStars, glitter + }); +} +})(); diff --git a/plugins/thinkback/skills/thinkback/helpers/rpg_effects.js b/plugins/thinkback/skills/thinkback/helpers/rpg_effects.js new file mode 100644 index 0000000..a8c6ef2 --- /dev/null +++ b/plugins/thinkback/skills/thinkback/helpers/rpg_effects.js @@ -0,0 +1,1181 @@ +/* eslint-disable */ +/** + * RPG Quest Effects + * Specialized effects for the RPG quest 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; +} + +// Seeded random for consistent effects +function seededRandom(seed) { + const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; + return x - Math.floor(x); +} + +// ============================================================================ +// CHARACTER SPRITES - Simple ASCII character art +// ============================================================================ + +const SPRITES = { + FEATURE_CRAFTER: [ + ' โ—‰โ—กโ—‰ ', + ' /|\\ ', + ' / \\ ', + ], + BUG_SLAYER: [ + ' โš”โ—กโš” ', + '', + ' / \\ ', + ], + DOCS_WIZARD: [ + ' โ—‰_โ—‰ ', + '(|โ˜†|)', + ' /|\\ ', + ], + REFACTOR_KNIGHT: [ + ' [โ—ก] ', + 'โ•”|โ–ˆ|โ•—', + ' / \\ ', + ], + FULL_STACK_PALADIN: [ + ' โœงโ—กโœง ', + 'โŒ|โ–ˆ|ยฌ', + ' โ•ฑ โ•ฒ ', + ], + SPEED_DEMON: [ + ' โ‰ซโ—กโ‰ช ', + 'ยป/|\\ยซ', + ' ยป ยซ ', + ], + DEEP_DELVER: [ + ' โ—Ž_โ—Ž ', + ' \\|/ ', + ' _โ•ฑโ•ฒ_', + ], +}; + +const IDLE_FRAMES = [ + [' โ—‰โ—กโ—‰ ', ' /|\\ ', ' / \\ '], + [' โ—‰โ—กโ—‰ ', ' /|\\/', ' / \\ '], + [' โ—‰โ—กโ—‰ ', '\\/|\\ ', ' / \\ '], +]; + +/** + * Draw a character sprite + * @param fb - Framebuffer + * @param x - X position (center) + * @param y - Y position (top) + * @param options - { class, animate } + * @param frame - Current frame for animation + */ +function characterSprite(fb, x, y, options, frame) { + const { class: charClass = 'FEATURE_CRAFTER', animate = false, depth = 5 } = options; + + let sprite = SPRITES[charClass] || SPRITES.FEATURE_CRAFTER; + + // Simple idle animation + if (animate) { + const animFrame = Math.floor(frame / 10) % IDLE_FRAMES.length; + sprite = IDLE_FRAMES[animFrame]; + } + + const spriteWidth = Math.max(...sprite.map(line => line.length)); + const startX = x - Math.floor(spriteWidth / 2); + + for (let i = 0; i < sprite.length; i++) { + const line = sprite[i]; + for (let j = 0; j < line.length; j++) { + if (line[j] !== ' ') { + fb.setPixel(startX + j, y + i, line[j], depth + 2); + } + } + } + + return sprite.length; +} + +// ============================================================================ +// TITLE SCREEN - Classic RPG title +// ============================================================================ + +/** + * Draw a classic RPG title screen + * @param fb - Framebuffer + * @param options - { title, subtitle, prompt } + * @param progress - Animation progress 0-1 + * @param frame - Current frame for blink effect + */ +function titleScreen(fb, options, progress, frame) { + const { title = 'ADVENTURE', subtitle = '2024', prompt = 'PRESS START', depth = 5 } = options; + + const centerX = Math.floor(fb.width / 2); + const centerY = Math.floor(fb.height / 2); + const easedProgress = easeOut(progress); + + // Draw decorative box + const boxWidth = Math.max(title.length, subtitle.length, prompt.length) + 10; + const boxHeight = 9; + const boxX = centerX - Math.floor(boxWidth / 2); + const boxY = centerY - Math.floor(boxHeight / 2); + + if (easedProgress > 0.1) { + const visibleWidth = Math.floor(((easedProgress - 0.1) / 0.3) * boxWidth); + + // Top border + fb.setPixel(boxX, boxY, 'โ•”', depth); + for (let i = 1; i < Math.min(visibleWidth - 1, boxWidth - 1); i++) { + fb.setPixel(boxX + i, boxY, 'โ•', depth); + } + if (visibleWidth >= boxWidth) fb.setPixel(boxX + boxWidth - 1, boxY, 'โ•—', depth); + + // Sides + for (let row = 1; row < boxHeight - 1; row++) { + fb.setPixel(boxX, boxY + row, 'โ•‘', depth); + if (visibleWidth >= boxWidth) fb.setPixel(boxX + boxWidth - 1, boxY + row, 'โ•‘', depth); + } + + // Bottom border + if (easedProgress > 0.4) { + fb.setPixel(boxX, boxY + boxHeight - 1, 'โ•š', depth); + for (let i = 1; i < boxWidth - 1; i++) { + fb.setPixel(boxX + i, boxY + boxHeight - 1, 'โ•', depth); + } + fb.setPixel(boxX + boxWidth - 1, boxY + boxHeight - 1, 'โ•', depth); + } + } + + // Title text + if (easedProgress > 0.3) { + const titleProgress = Math.min(1, (easedProgress - 0.3) / 0.2); + const visibleTitle = title.slice(0, Math.floor(titleProgress * title.length)); + const titleX = centerX - Math.floor(title.length / 2); + + for (let i = 0; i < visibleTitle.length; i++) { + fb.setPixel(titleX + i, centerY - 2, visibleTitle[i], depth + 3); + } + } + + // Subtitle + if (easedProgress > 0.5) { + const subProgress = Math.min(1, (easedProgress - 0.5) / 0.15); + const visibleSub = subtitle.slice(0, Math.floor(subProgress * subtitle.length)); + const subX = centerX - Math.floor(subtitle.length / 2); + + for (let i = 0; i < visibleSub.length; i++) { + fb.setPixel(subX + i, centerY, visibleSub[i], depth + 2); + } + } + + // Blinking prompt + if (easedProgress > 0.7) { + const showPrompt = Math.floor(frame / 20) % 2 === 0; + if (showPrompt) { + const promptX = centerX - Math.floor(prompt.length / 2); + for (let i = 0; i < prompt.length; i++) { + fb.setPixel(promptX + i, centerY + 3, prompt[i], depth + 1); + } + } + } +} + +// ============================================================================ +// TEXT BOX - Classic RPG dialog box +// ============================================================================ + +/** + * Draw an RPG-style text box with character-by-character reveal + * @param fb - Framebuffer + * @param y - Y position + * @param text - Text to display + * @param progress - Animation progress 0-1 + * @param frame - Current frame for cursor blink + * @param options - { width, style, speaker } + */ +function textBox(fb, y, text, progress, frame, options = {}) { + const { + width = 50, + style = 'rpg', + speaker = '', + depth = 5, + centered = true, + } = options; + + const easedProgress = easeOut(progress); + const centerX = Math.floor(fb.width / 2); + const x = centered ? centerX - Math.floor(width / 2) : 2; + + // Box characters based on style + const chars = style === 'rpg' + ? { tl: 'โ”Œ', tr: 'โ”', bl: 'โ””', br: 'โ”˜', h: 'โ”€', v: 'โ”‚' } + : style === 'modern' + ? { tl: 'โ•ญ', tr: 'โ•ฎ', bl: 'โ•ฐ', br: 'โ•ฏ', h: 'โ”€', v: 'โ”‚' } + : { tl: '[', tr: ']', bl: '[', br: ']', h: '-', v: '|' }; + + // Draw top border with optional speaker label + if (speaker) { + fb.setPixel(x, y, chars.tl, depth); + fb.setPixel(x + 1, y, chars.h, depth); + fb.setPixel(x + 2, y, ' ', depth); + for (let i = 0; i < speaker.length; i++) { + fb.setPixel(x + 3 + i, y, speaker[i], depth + 1); + } + fb.setPixel(x + 3 + speaker.length, y, ' ', depth); + for (let i = 4 + speaker.length; i < width - 1; i++) { + fb.setPixel(x + i, y, chars.h, depth); + } + fb.setPixel(x + width - 1, y, chars.tr, depth); + } else { + fb.setPixel(x, y, chars.tl, depth); + for (let i = 1; i < width - 1; i++) { + fb.setPixel(x + i, y, chars.h, depth); + } + fb.setPixel(x + width - 1, y, chars.tr, depth); + } + + // Text area (word wrap) + const maxTextWidth = width - 4; + const words = text.split(' '); + const lines = []; + let currentLine = ''; + + for (const word of words) { + if ((currentLine + ' ' + word).trim().length > maxTextWidth) { + if (currentLine) lines.push(currentLine); + currentLine = word; + } else { + currentLine = currentLine ? currentLine + ' ' + word : word; + } + } + if (currentLine) lines.push(currentLine); + + // Calculate visible characters + const totalChars = lines.join('').length; + const visibleChars = Math.floor(easedProgress * totalChars); + let charsDrawn = 0; + + // Draw text lines + let currentY = y + 1; + for (const line of lines) { + fb.setPixel(x, currentY, chars.v, depth); + + const lineCharsToShow = Math.min(line.length, visibleChars - charsDrawn); + if (lineCharsToShow > 0) { + const visibleLine = line.slice(0, lineCharsToShow); + for (let i = 0; i < visibleLine.length; i++) { + fb.setPixel(x + 2 + i, currentY, visibleLine[i], depth); + } + + // Blinking cursor at end of visible text + if (charsDrawn + lineCharsToShow < totalChars && easedProgress < 1) { + const cursorChar = Math.floor(frame / 6) % 2 === 0 ? 'โ–ผ' : ' '; + fb.setPixel(x + 2 + lineCharsToShow, currentY, cursorChar, depth); + } + } + + // Fill rest with spaces + for (let i = Math.max(0, lineCharsToShow) + 2; i < width - 1; i++) { + fb.setPixel(x + i, currentY, ' ', depth); + } + + fb.setPixel(x + width - 1, currentY, chars.v, depth); + charsDrawn += line.length; + currentY++; + + if (charsDrawn >= visibleChars) break; + } + + // Add empty rows if we haven't filled enough + const minRows = 2; + while (currentY < y + 1 + minRows) { + fb.setPixel(x, currentY, chars.v, depth); + for (let i = 1; i < width - 1; i++) { + fb.setPixel(x + i, currentY, ' ', depth); + } + fb.setPixel(x + width - 1, currentY, chars.v, depth); + currentY++; + } + + // Continuation indicator + if (easedProgress >= 1) { + const blinkIndicator = Math.floor(frame / 8) % 2 === 0 ? 'โ–ผ' : ' '; + fb.setPixel(x + width - 3, currentY - 1, blinkIndicator, depth); + } + + // Bottom border + fb.setPixel(x, currentY, chars.bl, depth); + for (let i = 1; i < width - 1; i++) { + fb.setPixel(x + i, currentY, chars.h, depth); + } + fb.setPixel(x + width - 1, currentY, chars.br, depth); + + return currentY - y + 1; +} + +// ============================================================================ +// CLASS SELECT - Character class display +// ============================================================================ + +/** + * Draw a character class selection/display screen + * @param fb - Framebuffer + * @param options - { className, description, stats, traits } + * @param progress - Animation progress 0-1 + * @param frame - Current frame for animation + * @param displayOptions - { y, showSprite } + */ +function classSelect(fb, options, progress, frame, displayOptions = {}) { + const { + className = 'ADVENTURER', + description = 'A brave soul', + stats = {}, + traits = [], + } = options; + + const { y = 5, showSprite = true, depth = 5 } = displayOptions; + + const centerX = Math.floor(fb.width / 2); + const easedProgress = easeOut(progress); + + let currentY = y; + + // Draw sprite + if (showSprite && easedProgress > 0.1) { + const spriteProgress = Math.min(1, (easedProgress - 0.1) / 0.2); + if (spriteProgress > 0.5) { + // Draw a box around sprite + const spriteBoxWidth = 11; + const spriteBoxX = centerX - Math.floor(spriteBoxWidth / 2); + + fb.setPixel(spriteBoxX, currentY, 'โ•ญ', depth); + for (let i = 1; i < spriteBoxWidth - 1; i++) { + fb.setPixel(spriteBoxX + i, currentY, 'โ”€', depth); + } + fb.setPixel(spriteBoxX + spriteBoxWidth - 1, currentY, 'โ•ฎ', depth); + currentY++; + + // Sprite lines + const spriteKey = className.replace(/ /g, '_').toUpperCase(); + const sprite = SPRITES[spriteKey] || SPRITES.FEATURE_CRAFTER; + + for (const line of sprite) { + fb.setPixel(spriteBoxX, currentY, 'โ”‚', depth); + const lineX = spriteBoxX + Math.floor((spriteBoxWidth - line.length) / 2); + for (let i = 0; i < line.length; i++) { + if (line[i] !== ' ') { + fb.setPixel(lineX + i, currentY, line[i], depth + 2); + } + } + fb.setPixel(spriteBoxX + spriteBoxWidth - 1, currentY, 'โ”‚', depth); + currentY++; + } + + fb.setPixel(spriteBoxX, currentY, 'โ•ฐ', depth); + for (let i = 1; i < spriteBoxWidth - 1; i++) { + fb.setPixel(spriteBoxX + i, currentY, 'โ”€', depth); + } + fb.setPixel(spriteBoxX + spriteBoxWidth - 1, currentY, 'โ•ฏ', depth); + currentY += 2; + } + } + + // Class name + if (easedProgress > 0.3) { + const nameProgress = Math.min(1, (easedProgress - 0.3) / 0.15); + const visibleName = className.slice(0, Math.floor(nameProgress * className.length)); + const nameX = centerX - Math.floor(className.length / 2); + + for (let i = 0; i < visibleName.length; i++) { + fb.setPixel(nameX + i, currentY, visibleName[i], depth + 3); + } + currentY++; + } + + // Description in quotes + if (description && easedProgress > 0.4) { + const descProgress = Math.min(1, (easedProgress - 0.4) / 0.15); + const fullDesc = `"${description}"`; + const visibleDesc = fullDesc.slice(0, Math.floor(descProgress * fullDesc.length)); + const descX = centerX - Math.floor(fullDesc.length / 2); + + for (let i = 0; i < visibleDesc.length; i++) { + fb.setPixel(descX + i, currentY, visibleDesc[i], depth + 1); + } + currentY += 2; + } + + // Stats bars + if (stats && Object.keys(stats).length > 0 && easedProgress > 0.5) { + const statsProgress = Math.min(1, (easedProgress - 0.5) / 0.25); + const statEntries = Object.entries(stats); + const maxStatWidth = 30; + const statsX = centerX - Math.floor(maxStatWidth / 2); + + for (const [statName, statValue] of statEntries) { + // Stat label + const labelWidth = 4; + const label = statName.slice(0, labelWidth).padEnd(labelWidth); + for (let i = 0; i < label.length; i++) { + fb.setPixel(statsX + i, currentY, label[i], depth); + } + + // Stat bar + const barWidth = 15; + const maxStat = 10; + const filledWidth = Math.floor(statsProgress * (statValue / maxStat) * barWidth); + + for (let i = 0; i < barWidth; i++) { + const char = i < filledWidth ? 'โ–ˆ' : 'โ–‘'; + fb.setPixel(statsX + labelWidth + 1 + i, currentY, char, depth + 1); + } + + // Stat value + const valueStr = ` ${statValue}`; + for (let i = 0; i < valueStr.length; i++) { + fb.setPixel(statsX + labelWidth + 1 + barWidth + i, currentY, valueStr[i], depth); + } + + currentY++; + } + currentY++; + } + + // Traits + if (traits && traits.length > 0 && easedProgress > 0.75) { + const traitProgress = Math.min(1, (easedProgress - 0.75) / 0.2); + const visibleTraits = Math.floor(traitProgress * traits.length); + + const traitStr = traits.slice(0, visibleTraits).map(t => `[${t}]`).join(' '); + const traitX = centerX - Math.floor(traitStr.length / 2); + + for (let i = 0; i < traitStr.length; i++) { + fb.setPixel(traitX + i, currentY, traitStr[i], depth + 1); + } + } +} + +// ============================================================================ +// QUEST CARD - Project spotlight for RPG vibe +// ============================================================================ + +/** + * Draw a full quest completion card 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, showRewards } + */ +function questCard(fb, project, progress, frame, options = {}) { + const { + y = 3, + width = 55, + showRewards = 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 x = centered ? centerX - Math.floor(width / 2) : 2; + + let currentY = y; + const visibleWidth = Math.floor(easedProgress * width); + if (visibleWidth < 5) return; + + // Helper to draw a row with side borders + function drawRow(text, textDepth = depth) { + fb.setPixel(x, currentY, 'โ•‘', depth); + for (let i = 0; i < text.length && i < visibleWidth - 2; i++) { + fb.setPixel(x + 1 + i, currentY, text[i], textDepth); + } + for (let i = text.length; i < visibleWidth - 2; i++) { + fb.setPixel(x + 1 + i, currentY, ' ', depth); + } + if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, 'โ•‘', depth); + currentY++; + } + + // Top border + fb.setPixel(x, currentY, 'โ•”', depth); + for (let i = 1; i < visibleWidth - 1; i++) { + fb.setPixel(x + i, currentY, 'โ•', depth); + } + if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, 'โ•—', depth); + currentY++; + + // Quest complete banner with sparkle animation + if (easedProgress > 0.1) { + const sparkleChars = ['โ˜…', 'โœฆ', 'ยท', 'โœง']; + const sparkleIdx = Math.floor(frame / 5) % sparkleChars.length; + const sparkle = sparkleChars[sparkleIdx]; + drawRow(` ${sparkle} QUEST COMPLETE ${sparkle}`, depth + 2); + } + + // Divider + if (easedProgress > 0.15) { + fb.setPixel(x, currentY, 'โ•Ÿ', depth); + for (let i = 1; i < visibleWidth - 1; i++) { + fb.setPixel(x + i, currentY, 'โ”€', depth); + } + if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, 'โ•ข', depth); + currentY++; + } + + // Empty row + drawRow('', depth); + + // Quest/Project name + if (easedProgress > 0.2) { + const nameProgress = Math.min(1, (easedProgress - 0.2) / 0.15); + const visibleName = name.slice(0, Math.floor(nameProgress * name.length)); + drawRow(` ${visibleName}`, depth + 3); + } + + // Description in quotes (quest flavor text) + if (description && easedProgress > 0.35) { + const descProgress = Math.min(1, (easedProgress - 0.35) / 0.1); + const fullDesc = `"${description}"`; + const visibleDesc = fullDesc.slice(0, Math.floor(descProgress * fullDesc.length)); + drawRow(` ${visibleDesc}`, depth + 1); + } + + // Divider line + if (easedProgress > 0.4) { + fb.setPixel(x, currentY, 'โ•‘', depth); + for (let i = 1; i < Math.min(visibleWidth - 1, width - 10); i++) { + fb.setPixel(x + i, currentY, 'โ”€', depth); + } + for (let i = width - 10; i < visibleWidth - 1; i++) { + fb.setPixel(x + i, currentY, ' ', depth); + } + if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, 'โ•‘', depth); + currentY++; + } + + // Body text (quest story) + if (body && easedProgress > 0.45) { + const bodyProgress = Math.min(1, (easedProgress - 0.45) / 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); + + 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; + } + } + + // Empty row before rewards + drawRow('', depth); + + // Rewards box + if (showRewards && easedProgress > 0.75) { + const rewardsProgress = Math.min(1, (easedProgress - 0.75) / 0.2); + + // Rewards header + fb.setPixel(x, currentY, 'โ•‘', depth); + fb.setPixel(x + 2, currentY, 'โ”Œ', depth); + fb.setPixel(x + 3, currentY, 'โ”€', depth); + const rewardLabel = ' REWARDS '; + for (let i = 0; i < rewardLabel.length; i++) { + fb.setPixel(x + 4 + i, currentY, rewardLabel[i], depth + 1); + } + for (let i = 4 + rewardLabel.length; i < visibleWidth - 4; i++) { + fb.setPixel(x + i, currentY, 'โ”€', depth); + } + fb.setPixel(x + visibleWidth - 3, currentY, 'โ”', depth); + for (let i = visibleWidth - 2; i < visibleWidth - 1; i++) { + fb.setPixel(x + i, currentY, ' ', depth); + } + if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, 'โ•‘', depth); + currentY++; + + // Rewards content + const xpGained = Math.floor(rewardsProgress * commits); + const skills = Math.floor(commits / 100) + 1; + const rarity = commits > 200 ? 'Legendary' : commits > 100 ? 'Epic' : commits > 50 ? 'Rare' : 'Common'; + const itemLabel = `+1 ${rarity} Item`; + + const rewardLine = ` +${xpGained.toLocaleString()} XP +${skills} Skills ${itemLabel} `; + + fb.setPixel(x, currentY, 'โ•‘', depth); + fb.setPixel(x + 2, currentY, 'โ”‚', depth); + for (let i = 0; i < rewardLine.length && i < visibleWidth - 6; i++) { + fb.setPixel(x + 3 + i, currentY, rewardLine[i], depth + 2); + } + for (let i = rewardLine.length + 3; i < visibleWidth - 3; i++) { + fb.setPixel(x + i, currentY, ' ', depth); + } + fb.setPixel(x + visibleWidth - 3, currentY, 'โ”‚', depth); + for (let i = visibleWidth - 2; i < visibleWidth - 1; i++) { + fb.setPixel(x + i, currentY, ' ', depth); + } + if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, 'โ•‘', depth); + currentY++; + + // Rewards bottom + fb.setPixel(x, currentY, 'โ•‘', depth); + fb.setPixel(x + 2, currentY, 'โ””', depth); + for (let i = 3; i < visibleWidth - 3; i++) { + fb.setPixel(x + i, currentY, 'โ”€', depth); + } + fb.setPixel(x + visibleWidth - 3, currentY, 'โ”˜', depth); + for (let i = visibleWidth - 2; i < visibleWidth - 1; i++) { + fb.setPixel(x + i, currentY, ' ', depth); + } + if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, 'โ•‘', depth); + currentY++; + } + + // Bottom border + if (easedProgress > 0.9) { + fb.setPixel(x, currentY, 'โ•š', depth); + for (let i = 1; i < visibleWidth - 1; i++) { + fb.setPixel(x + i, currentY, 'โ•', depth); + } + if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, 'โ•', depth); + } +} + +// ============================================================================ +// QUEST BANNER - Animated quest announcement +// ============================================================================ + +/** + * Draw an animated quest banner + * @param fb - Framebuffer + * @param text - Banner text + * @param progress - Animation progress 0-1 + * @param frame - Current frame for effects + * @param options - { y, style } + */ +function questBanner(fb, text, progress, frame, options = {}) { + const { y = 2, style = 'fanfare', depth = 5 } = options; + + const centerX = Math.floor(fb.width / 2); + const easedProgress = easeOut(progress); + + if (style === 'fanfare') { + // Decorative elements that animate in + const decorChars = ['ยท', ':', 'ยท', ':', 'ยท']; + const decorWidth = Math.floor(easedProgress * (text.length / 2 + 5)); + + // Left decoration + for (let i = 0; i < Math.min(decorWidth, 5); i++) { + const decX = centerX - Math.floor(text.length / 2) - 6 + i; + fb.setPixel(decX, y, decorChars[i], depth); + } + + // Text + if (easedProgress > 0.3) { + const textProgress = (easedProgress - 0.3) / 0.5; + const visibleChars = Math.floor(textProgress * text.length); + const textX = centerX - Math.floor(text.length / 2); + + for (let i = 0; i < visibleChars; i++) { + fb.setPixel(textX + i, y, text[i], depth + 3); + } + } + + // Right decoration + if (easedProgress > 0.5) { + for (let i = 0; i < Math.min(decorWidth, 5); i++) { + const decX = centerX + Math.floor(text.length / 2) + 1 + i; + fb.setPixel(decX, y, decorChars[4 - i], depth); + } + } + } else if (style === 'simple') { + const textX = centerX - Math.floor(text.length / 2); + const visibleChars = Math.floor(easedProgress * text.length); + + for (let i = 0; i < visibleChars; i++) { + fb.setPixel(textX + i, y, text[i], depth + 2); + } + } else { + // legendary style with sparkles + const sparkles = ['โ˜…', 'โœฆ', 'โœง', 'ยท']; + const sparkleIdx = Math.floor(frame / 4) % sparkles.length; + + const fullText = `${sparkles[sparkleIdx]} ${text} ${sparkles[(sparkleIdx + 2) % sparkles.length]}`; + const textX = centerX - Math.floor(fullText.length / 2); + const visibleChars = Math.floor(easedProgress * fullText.length); + + for (let i = 0; i < visibleChars; i++) { + fb.setPixel(textX + i, y, fullText[i], depth + 3); + } + } +} + +// ============================================================================ +// XP BAR - Animated experience bar +// ============================================================================ + +/** + * Draw an XP progress bar + * @param fb - Framebuffer + * @param x - X position + * @param y - Y position + * @param options - { current, max, label } + * @param progress - Animation progress 0-1 + * @param displayOptions - { width, showNumbers } + */ +function xpBar(fb, x, y, options, progress, displayOptions = {}) { + const { current = 0, max = 100, label = 'LVL' } = options; + const { width = 40, showNumbers = true, depth = 5 } = displayOptions; + + const easedProgress = easeOut(progress); + + // Draw label + for (let i = 0; i < label.length; i++) { + fb.setPixel(x + i, y, label[i], depth + 1); + } + + // Draw bar + const barStart = x + label.length + 2; + const barWidth = width - label.length - (showNumbers ? 15 : 2); + const fillPercent = current / max; + const animatedFill = fillPercent * easedProgress; + const filledWidth = Math.floor(animatedFill * barWidth); + + for (let i = 0; i < barWidth; i++) { + const char = i < filledWidth ? 'โ–ˆ' : 'โ–‘'; + fb.setPixel(barStart + i, y, char, depth + (i < filledWidth ? 2 : 0)); + } + + // Draw numbers + if (showNumbers) { + const animatedCurrent = Math.floor(easedProgress * current); + const numStr = `${animatedCurrent.toLocaleString()} / ${max.toLocaleString()} XP`; + const numX = barStart + barWidth + 2; + + for (let i = 0; i < numStr.length; i++) { + fb.setPixel(numX + i, y, numStr[i], depth); + } + } +} + +// ============================================================================ +// LEVEL UP - Level up celebration display +// ============================================================================ + +/** + * Draw a level up celebration + * @param fb - Framebuffer + * @param options - { level, stats } + * @param progress - Animation progress 0-1 + * @param frame - Current frame for effects + * @param displayOptions - { y } + */ +function levelUp(fb, options, progress, frame, displayOptions = {}) { + const { level = 1, stats = [] } = options; + const { y = 8, depth = 5 } = displayOptions; + + const centerX = Math.floor(fb.width / 2); + const easedProgress = easeOut(progress); + + const boxWidth = 25; + const boxX = centerX - Math.floor(boxWidth / 2); + let currentY = y; + + // Box with glow effect + if (easedProgress > 0.1) { + const visibleWidth = Math.floor(((easedProgress - 0.1) / 0.3) * boxWidth); + + // Top border + fb.setPixel(boxX, currentY, 'โ•”', depth); + for (let i = 1; i < Math.min(visibleWidth - 1, boxWidth - 1); i++) { + fb.setPixel(boxX + i, currentY, 'โ•', depth); + } + if (visibleWidth >= boxWidth) fb.setPixel(boxX + boxWidth - 1, currentY, 'โ•—', depth); + currentY++; + + // Level up text + if (easedProgress > 0.3) { + fb.setPixel(boxX, currentY, 'โ•‘', depth); + const lvlText = 'LEVEL UP!'; + const lvlX = boxX + Math.floor((boxWidth - lvlText.length) / 2); + for (let i = 0; i < lvlText.length; i++) { + fb.setPixel(lvlX + i, currentY, lvlText[i], depth + 3); + } + fb.setPixel(boxX + boxWidth - 1, currentY, 'โ•‘', depth); + currentY++; + + // Level number with sparkle + const sparkles = ['โ˜…', 'โœฆ', 'โœง', '*']; + const sparkleIdx = Math.floor(frame / 5) % sparkles.length; + const levelText = `${sparkles[sparkleIdx]} LV. ${level} ${sparkles[(sparkleIdx + 2) % sparkles.length]}`; + const levelX = boxX + Math.floor((boxWidth - levelText.length) / 2); + + fb.setPixel(boxX, currentY, 'โ•‘', depth); + for (let i = 0; i < levelText.length; i++) { + fb.setPixel(levelX + i, currentY, levelText[i], depth + 2); + } + fb.setPixel(boxX + boxWidth - 1, currentY, 'โ•‘', depth); + currentY++; + } + + // Divider + if (easedProgress > 0.5) { + fb.setPixel(boxX, currentY, 'โ• ', depth); + for (let i = 1; i < boxWidth - 1; i++) { + fb.setPixel(boxX + i, currentY, 'โ•', depth); + } + fb.setPixel(boxX + boxWidth - 1, currentY, 'โ•ฃ', depth); + currentY++; + + // Stats gained + for (let i = 0; i < stats.length; i++) { + const statProgress = Math.min(1, (easedProgress - 0.5 - i * 0.1) / 0.15); + if (statProgress > 0) { + fb.setPixel(boxX, currentY, 'โ•‘', depth); + + const stat = stats[i]; + const statLine = `${stat.name.padEnd(12)}${stat.gained}`; + const visibleStat = statLine.slice(0, Math.floor(statProgress * statLine.length)); + + for (let j = 0; j < visibleStat.length && j < boxWidth - 4; j++) { + fb.setPixel(boxX + 2 + j, currentY, visibleStat[j], depth + 1); + } + + fb.setPixel(boxX + boxWidth - 1, currentY, 'โ•‘', depth); + currentY++; + } + } + } + + // Bottom border + if (easedProgress > 0.85) { + fb.setPixel(boxX, currentY, 'โ•š', depth); + for (let i = 1; i < boxWidth - 1; i++) { + fb.setPixel(boxX + i, currentY, 'โ•', depth); + } + fb.setPixel(boxX + boxWidth - 1, currentY, 'โ•', depth); + } + } +} + +// ============================================================================ +// STATS PANEL - RPG-style stats display +// ============================================================================ + +/** + * Draw an RPG stats panel + * @param fb - Framebuffer + * @param x - X position + * @param y - Y position + * @param stats - Object of stat name -> value + * @param progress - Animation progress 0-1 + * @param options - { style, width } + */ +function statsPanel(fb, x, y, stats, progress, options = {}) { + const { style = 'bordered', width = 30, depth = 5 } = options; + + const easedProgress = easeOut(progress); + const statEntries = Object.entries(stats); + let currentY = y; + + if (style === 'bordered') { + // Header + fb.setPixel(x, currentY, 'โ”Œ', depth); + fb.setPixel(x + 1, currentY, 'โ”€', depth); + const headerText = ' HERO STATS '; + for (let i = 0; i < headerText.length; i++) { + fb.setPixel(x + 2 + i, currentY, headerText[i], depth + 1); + } + for (let i = 2 + headerText.length; i < width - 1; i++) { + fb.setPixel(x + i, currentY, 'โ”€', depth); + } + fb.setPixel(x + width - 1, currentY, 'โ”', depth); + currentY++; + + // Stats + for (let i = 0; i < statEntries.length; i++) { + const [statName, statValue] = statEntries[i]; + const statProgress = Math.min(1, (easedProgress - i * 0.1) / 0.2); + + if (statProgress > 0) { + fb.setPixel(x, currentY, 'โ”‚', depth); + + const valueStr = typeof statValue === 'number' ? statValue.toLocaleString() : statValue; + const animatedValue = typeof statValue === 'number' + ? Math.floor(statProgress * statValue).toLocaleString() + : valueStr; + + const statLine = `${statName.padEnd(width - animatedValue.length - 4)}${animatedValue}`; + + for (let j = 0; j < statLine.length && j < width - 2; j++) { + fb.setPixel(x + 1 + j, currentY, statLine[j], depth); + } + + fb.setPixel(x + width - 1, currentY, 'โ”‚', depth); + currentY++; + } + } + + // Footer + if (easedProgress > 0.8) { + fb.setPixel(x, currentY, 'โ””', depth); + for (let i = 1; i < width - 1; i++) { + fb.setPixel(x + i, currentY, 'โ”€', depth); + } + fb.setPixel(x + width - 1, currentY, 'โ”˜', depth); + } + } +} + +// ============================================================================ +// BOSS HEALTH - Boss battle health bar +// ============================================================================ + +/** + * Draw a boss health bar + * @param fb - Framebuffer + * @param y - Y position + * @param options - { name, health, maxHealth } + * @param progress - Animation progress 0-1 + * @param frame - Current frame for effects + */ +function bossHealth(fb, y, options, progress, frame) { + const { name = 'BOSS', health = 0, maxHealth = 100, depth = 5 } = options; + + const centerX = Math.floor(fb.width / 2); + const barWidth = 30; + const x = centerX - Math.floor(barWidth / 2); + const easedProgress = easeOut(progress); + + // Boss name + const nameX = centerX - Math.floor(name.length / 2); + for (let i = 0; i < name.length; i++) { + fb.setPixel(nameX + i, y, name[i], depth + 2); + } + + // Health bar + const currentHealth = Math.floor((1 - easedProgress) * health); + const healthPercent = currentHealth / maxHealth; + const filledWidth = Math.floor(healthPercent * (barWidth - 2)); + + fb.setPixel(x, y + 1, '[', depth); + for (let i = 0; i < barWidth - 2; i++) { + const char = i < filledWidth ? 'โ–ˆ' : 'โ–‘'; + fb.setPixel(x + 1 + i, y + 1, char, depth + 1); + } + fb.setPixel(x + barWidth - 1, y + 1, ']', depth); + + // Status text + const statusText = currentHealth <= 0 ? 'DEFEATED!' : `${Math.floor(healthPercent * 100)}%`; + const statusX = centerX - Math.floor(statusText.length / 2); + + if (currentHealth <= 0) { + // Flash on defeat + const flash = Math.floor(frame / 5) % 2 === 0; + if (flash) { + for (let i = 0; i < statusText.length; i++) { + fb.setPixel(statusX + i, y + 2, statusText[i], depth + 3); + } + } + } else { + for (let i = 0; i < statusText.length; i++) { + fb.setPixel(statusX + i, y + 2, statusText[i], depth); + } + } +} + +// ============================================================================ +// VICTORY FANFARE - Celebration effect +// ============================================================================ + +/** + * Victory celebration particles + * @param fb - Framebuffer + * @param frame - Current frame + * @param options - { intensity, style } + */ +function victoryFanfare(fb, frame, options = {}) { + const { intensity = 1.0, style = 'classic', depth = 3 } = options; + + const chars = style === 'epic' + ? ['โ˜…', 'โœฆ', 'โœง', 'โ—†', 'โ—'] + : style === 'subtle' + ? ['ยท', '*', '+'] + : ['โ˜…', '*', 'ยท', '+', 'โœฆ']; + + const particleCount = Math.floor(intensity * 15); + + for (let i = 0; i < particleCount; i++) { + const seed = i * 9.876 + frame * 0.03; + const x = Math.floor((Math.sin(seed * 5.43) * 0.5 + 0.5) * fb.width); + const yBase = (frame * 0.4 + i * 4) % (fb.height + 5); + const y = fb.height - Math.floor(yBase); + + 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); + } + } +} + +// ============================================================================ +// CREDITS ROLL - Scrolling end credits +// ============================================================================ + +/** + * Draw scrolling credits + * @param fb - Framebuffer + * @param items - Array of { type, text?, label?, value? } + * @param progress - Animation progress 0-1 + * @param frame - Current frame + * @param options - { speed } + */ +function creditsRoll(fb, items, progress, frame, options = {}) { + const { speed = 0.5, depth = 5 } = options; + + const centerX = Math.floor(fb.width / 2); + const easedProgress = easeInOut(progress); + + // Calculate total height and scroll position + let totalHeight = 0; + for (const item of items) { + if (item.type === 'spacer') totalHeight += 2; + else totalHeight += 2; + } + + const scrollOffset = Math.floor(easedProgress * (totalHeight + fb.height)); + let currentY = fb.height - scrollOffset; + + for (const item of items) { + if (currentY >= -2 && currentY < fb.height + 2) { + if (item.type === 'header') { + const text = item.text || ''; + const textX = centerX - Math.floor(text.length / 2); + for (let i = 0; i < text.length && currentY >= 0 && currentY < fb.height; i++) { + fb.setPixel(textX + i, currentY, text[i], depth + 3); + } + } else if (item.type === 'stat') { + const line = `${item.label}: ${item.value}`; + const lineX = centerX - Math.floor(line.length / 2); + if (currentY >= 0 && currentY < fb.height) { + for (let i = 0; i < line.length; i++) { + fb.setPixel(lineX + i, currentY, line[i], depth + 1); + } + } + } else if (item.type === 'text') { + const text = item.text || ''; + const textX = centerX - Math.floor(text.length / 2); + if (currentY >= 0 && currentY < fb.height) { + for (let i = 0; i < text.length; i++) { + fb.setPixel(textX + i, currentY, text[i], depth); + } + } + } + } + + currentY += item.type === 'spacer' ? 2 : 2; + } +} + +// ============================================================================ +// INVENTORY SLOT - Item display +// ============================================================================ + +/** + * Draw an inventory slot with item + * @param fb - Framebuffer + * @param x - X position + * @param y - Y position + * @param options - { icon, name, rarity } + * @param progress - Animation progress 0-1 + */ +function inventorySlot(fb, x, y, options, progress) { + const { icon = '?', name = 'Item', rarity = 'common', depth = 5 } = options; + + const easedProgress = easeOut(progress); + if (easedProgress < 0.1) return; + + // Box + fb.setPixel(x, y, 'โ”Œ', depth); + fb.setPixel(x + 1, y, 'โ”€', depth); + fb.setPixel(x + 2, y, 'โ”€', depth); + fb.setPixel(x + 3, y, 'โ”€', depth); + fb.setPixel(x + 4, y, 'โ”', depth); + + fb.setPixel(x, y + 1, 'โ”‚', depth); + fb.setPixel(x + 2, y + 1, icon, depth + 2); + fb.setPixel(x + 4, y + 1, 'โ”‚', depth); + + fb.setPixel(x, y + 2, 'โ””', depth); + fb.setPixel(x + 1, y + 2, 'โ”€', depth); + fb.setPixel(x + 2, y + 2, 'โ”€', depth); + fb.setPixel(x + 3, y + 2, 'โ”€', depth); + fb.setPixel(x + 4, y + 2, 'โ”˜', depth); + + // Name + if (easedProgress > 0.4) { + const nameProgress = (easedProgress - 0.4) / 0.3; + const visibleName = name.slice(0, Math.floor(nameProgress * name.length)); + for (let i = 0; i < visibleName.length; i++) { + fb.setPixel(x + 6 + i, y + 1, visibleName[i], depth + 1); + } + } + + // Rarity stars + if (easedProgress > 0.7) { + const rarityStars = rarity === 'legendary' ? 'โ˜…โ˜…โ˜…โ˜…' + : rarity === 'epic' ? 'โ˜…โ˜…โ˜…โ˜†' + : rarity === 'rare' ? 'โ˜…โ˜…โ˜†โ˜†' + : 'โ˜…โ˜†โ˜†โ˜†'; + + for (let i = 0; i < rarityStars.length; i++) { + fb.setPixel(x + 6 + i, y + 2, rarityStars[i], depth); + } + } +} + +// Make available globally +if (typeof globalThis !== 'undefined') { + Object.assign(globalThis, { + characterSprite, + titleScreen, + textBox, + classSelect, + questCard, + questBanner, + xpBar, + levelUp, + statsPanel, + bossHealth, + victoryFanfare, + creditsRoll, + inventorySlot, + }); +} +})(); diff --git a/plugins/thinkback/skills/thinkback/helpers/scene_system.js b/plugins/thinkback/skills/thinkback/helpers/scene_system.js new file mode 100644 index 0000000..aad1bc0 --- /dev/null +++ b/plugins/thinkback/skills/thinkback/helpers/scene_system.js @@ -0,0 +1,415 @@ +/* eslint-disable */ +/** + * Deterministic Scene System with Seconds-Based Timing + * + * This system guarantees: + * 1. Scenes are defined in SECONDS, not frames - no hallucinated timing + * 2. Each scene has guaranteed phases: transition in, content reveal, hold, transition out + * 3. Content is NEVER shown during transitions + * 4. Hold time ensures viewers can absorb content before it disappears (default 2s) + * 5. Total animation duration is computed automatically from scene definitions + * + * Scene Phases: + * - TRANSITION_IN: Content hidden, transition effect plays (~0.5s) + * - CONTENT: Content animates in with normalized progress (0-1) + * - HOLD: Content fully visible, guaranteed reading time (default 2s) + * - TRANSITION_OUT: Fade/transition out begins (~0.5s) + * + * Usage: + * const SCENE_DEFINITIONS = [ + * { name: 'opening', duration: 5 }, // 2s default hold + * { name: 'tarot', duration: 7.5, hold: 3 }, // 3s hold for dense content + * { name: 'closing', duration: 4, hold: 1.5 }, // 1.5s hold + * ]; + */ +(function() { + +const FPS = 24; // Standard frame rate + +// Default timing in seconds +const DEFAULT_TRANSITION_IN_SECONDS = 0.5; // 0.5s transition in +const DEFAULT_TRANSITION_OUT_SECONDS = 0.5; // 0.5s transition out +const DEFAULT_HOLD_SECONDS = 2; // 2s hold before transition out + +// Minimum content time to prevent scenes that are all transitions +const MIN_CONTENT_SECONDS = 0.5; + +/** + * Calculate phase percentages from seconds-based timing + * + * @param {number} durationSeconds - Total scene duration in seconds + * @param {number} holdSeconds - Hold time in seconds + * @param {number} transitionInSeconds - Transition in time in seconds + * @param {number} transitionOutSeconds - Transition out time in seconds + * @returns {object} Phase percentages { TRANSITION_IN, CONTENT, HOLD, TRANSITION_OUT } + */ +function calculatePhases(durationSeconds, holdSeconds, transitionInSeconds, transitionOutSeconds) { + // Validate: ensure we have enough time for all phases + const fixedTime = transitionInSeconds + holdSeconds + transitionOutSeconds; + + if (fixedTime >= durationSeconds) { + // Not enough time - scale down proportionally but keep minimum content time + const availableForFixed = durationSeconds - MIN_CONTENT_SECONDS; + const scale = availableForFixed / fixedTime; + + transitionInSeconds *= scale; + holdSeconds *= scale; + transitionOutSeconds *= scale; + } + + // Calculate percentages + const transitionIn = transitionInSeconds / durationSeconds; + const transitionOut = transitionOutSeconds / durationSeconds; + const hold = holdSeconds / durationSeconds; + const content = 1 - transitionIn - hold - transitionOut; + + return { + TRANSITION_IN: transitionIn, + CONTENT: content, + HOLD: hold, + TRANSITION_OUT: transitionOut, + }; +} + +/** + * SceneManager - The main class for managing scene-based animations + * + * Usage: + * const manager = new SceneManager([ + * { name: 'intro', duration: 5 }, // 5 seconds, 2s default hold + * { name: 'stats', duration: 8, hold: 3 }, // 8 seconds, 3s hold + * { name: 'closing', duration: 4 }, // 4 seconds, 2s default hold + * ]); + * + * // In your animation: + * const scene = manager.getSceneAt(frame); + * // scene = { name: 'intro', phase: 'CONTENT', contentProgress: 0.6, ... } + */ +class SceneManager { + constructor(sceneDefinitions, options = {}) { + this.fps = options.fps || FPS; + this.defaultHold = options.defaultHold ?? DEFAULT_HOLD_SECONDS; + this.defaultTransitionIn = options.defaultTransitionIn ?? DEFAULT_TRANSITION_IN_SECONDS; + this.defaultTransitionOut = options.defaultTransitionOut ?? DEFAULT_TRANSITION_OUT_SECONDS; + + // Build scene list with computed frame ranges + this.scenes = []; + let currentFrame = 0; + + for (const def of sceneDefinitions) { + const durationFrames = Math.round(def.duration * this.fps); + + // Get timing values (seconds) + const holdSeconds = def.hold ?? this.defaultHold; + const transitionInSeconds = def.transitionIn ?? this.defaultTransitionIn; + const transitionOutSeconds = def.transitionOut ?? this.defaultTransitionOut; + + // Calculate phase percentages from seconds + const phases = calculatePhases( + def.duration, + holdSeconds, + transitionInSeconds, + transitionOutSeconds + ); + + this.scenes.push({ + name: def.name, + duration: def.duration, + startFrame: currentFrame, + endFrame: currentFrame + durationFrames, + durationFrames, + phases, + // Store timing info for debugging + timing: { + hold: holdSeconds, + transitionIn: transitionInSeconds, + transitionOut: transitionOutSeconds, + }, + // Store any custom data + data: def.data || {}, + }); + + currentFrame += durationFrames; + } + + this.totalFrames = currentFrame; + this.totalDuration = currentFrame / this.fps; + } + + /** + * Get the current scene and progress at a given frame + * + * @param {number} frame - Current frame number + * @returns {object} Scene info with phase, progress, etc. + */ + getSceneAt(frame) { + // Find which scene we're in + for (let i = 0; i < this.scenes.length; i++) { + const scene = this.scenes[i]; + if (frame >= scene.startFrame && frame < scene.endFrame) { + const rawProgress = (frame - scene.startFrame) / scene.durationFrames; + const phaseInfo = getScenePhase(rawProgress, scene.phases); + + return { + name: scene.name, + index: i, + frame: frame - scene.startFrame, // Frame within this scene + rawProgress, + ...phaseInfo, + data: scene.data, + isFirst: i === 0, + isLast: i === this.scenes.length - 1, + }; + } + } + + // Past the end - return last scene at 100% + if (this.scenes.length > 0) { + const lastScene = this.scenes[this.scenes.length - 1]; + return { + name: lastScene.name, + index: this.scenes.length - 1, + frame: lastScene.durationFrames, + rawProgress: 1, + phase: 'TRANSITION_OUT', + contentProgress: 1, + transitionProgress: 0, + data: lastScene.data, + isFirst: false, + isLast: true, + }; + } + + return null; + } + + /** + * Get scene by name + */ + getSceneByName(name) { + return this.scenes.find(s => s.name === name); + } + + /** + * Get total frames (useful for TOTAL_FRAMES export) + */ + getTotalFrames() { + return this.totalFrames; + } + + /** + * Get total duration in seconds + */ + getTotalDuration() { + return this.totalDuration; + } + + /** + * Get scene names in order + */ + getSceneNames() { + return this.scenes.map(s => s.name); + } + + /** + * Simplified scene getter that returns { sceneId, progress } + * This is a convenience wrapper around getSceneAt for simpler animations + * + * @param {number} frame - Current frame number + * @returns {object} { sceneId, progress } where progress is 0-1 + */ + getScene(frame) { + const info = this.getSceneAt(frame); + if (!info) { + return { sceneId: null, progress: 1 }; + } + return { + sceneId: info.name, + progress: info.rawProgress, + }; + } + + /** + * Debug: Print scene timing info + */ + debugTiming() { + console.log('Scene Timing:'); + console.log('============='); + for (const scene of this.scenes) { + const { phases, timing } = scene; + console.log(`${scene.name} (${scene.duration}s):`); + console.log(` Transition In: ${(phases.TRANSITION_IN * 100).toFixed(1)}% (${timing.transitionIn}s)`); + console.log(` Content: ${(phases.CONTENT * 100).toFixed(1)}% (${(phases.CONTENT * scene.duration).toFixed(1)}s)`); + console.log(` Hold: ${(phases.HOLD * 100).toFixed(1)}% (${timing.hold}s)`); + console.log(` Transition Out: ${(phases.TRANSITION_OUT * 100).toFixed(1)}% (${timing.transitionOut}s)`); + } + console.log(`\nTotal: ${this.totalDuration}s (${this.totalFrames} frames)`); + } +} + +/** + * Calculate which phase we're in and the progress within that phase + * + * @param {number} rawProgress - Scene progress 0-1 + * @param {object} phases - Phase durations as percentages + * @returns {object} { phase, contentProgress, transitionProgress } + */ +function getScenePhase(rawProgress, phases) { + const p = Math.max(0, Math.min(1, rawProgress)); + + // Phase 1: TRANSITION_IN + if (p < phases.TRANSITION_IN) { + return { + phase: 'TRANSITION_IN', + contentProgress: 0, // Content NOT visible yet + transitionProgress: p / phases.TRANSITION_IN, + }; + } + + // Phase 2: CONTENT + const contentStart = phases.TRANSITION_IN; + const contentEnd = 1 - phases.HOLD - phases.TRANSITION_OUT; + + if (p < contentEnd) { + return { + phase: 'CONTENT', + contentProgress: (p - contentStart) / (contentEnd - contentStart), + transitionProgress: 1, // Transition complete + }; + } + + // Phase 3: HOLD + const holdEnd = 1 - phases.TRANSITION_OUT; + if (p < holdEnd) { + return { + phase: 'HOLD', + contentProgress: 1, // Content fully revealed + transitionProgress: 1, + }; + } + + // Phase 4: TRANSITION_OUT + return { + phase: 'TRANSITION_OUT', + contentProgress: 1, // Content stays visible during fade + transitionProgress: (1 - p) / phases.TRANSITION_OUT, + }; +} + +/** + * Render a scene with automatic transition handling + * + * @param {object} fb - Framebuffer + * @param {number} frame - Current frame number + * @param {number} rawProgress - Scene progress 0-1 + * @param {object} config - Scene configuration + * @param {function} config.background - Background renderer (fb, frame) + * @param {function} config.content - Content renderer (fb, frame, contentProgress) + * @param {function} config.transitionIn - Transition in effect (fb, progress, frame) + * @param {function} config.transitionOut - Transition out effect (fb, progress, frame) + * @param {object} config.phases - Optional custom phase durations + */ +function renderScene(fb, frame, rawProgress, config) { + const phases = config.phases; + const { phase, contentProgress, transitionProgress } = getScenePhase(rawProgress, phases); + + // Always render background first (visible through transitions) + if (config.background) { + config.background(fb, frame); + } + + // Only render content after transition-in completes + if (phase !== 'TRANSITION_IN' && config.content) { + config.content(fb, frame, contentProgress); + } + + // Apply transition-in effect (masks/reveals content) + if (phase === 'TRANSITION_IN' && config.transitionIn) { + config.transitionIn(fb, transitionProgress, frame); + } + + // Apply transition-out effect (fades/masks content) + if (phase === 'TRANSITION_OUT' && config.transitionOut) { + // transitionProgress goes from 1 to 0 during TRANSITION_OUT + // Most transition functions expect 0-1 for "amount visible" + config.transitionOut(fb, transitionProgress, frame); + } +} + +/** + * Create a scene config helper for common patterns + */ +function createScene(options) { + return { + background: options.background || null, + content: options.content || null, + transitionIn: options.transitionIn || null, + transitionOut: options.transitionOut || null, + phases: options.phases, + }; +} + +/** + * Helper: Create staggered reveal timing for multiple items + * + * Returns a function that takes contentProgress and item index, + * and returns the item's individual progress (0-1). + * + * @param {number} itemCount - Number of items to stagger + * @param {number} overlap - How much items overlap (0 = sequential, 1 = all at once) + * @returns {function} (contentProgress, itemIndex) => itemProgress + */ +function staggeredReveal(itemCount, overlap = 0.5) { + return (contentProgress, itemIndex) => { + if (itemCount <= 1) return contentProgress; + + const itemDuration = 1 / (1 + (itemCount - 1) * (1 - overlap)); + const itemStart = itemIndex * itemDuration * (1 - overlap); + const itemEnd = itemStart + itemDuration; + + if (contentProgress < itemStart) return 0; + if (contentProgress >= itemEnd) return 1; + return (contentProgress - itemStart) / itemDuration; + }; +} + +/** + * Helper: Ease in/out function + */ +function easeInOut(t) { + return t * t * (3 - 2 * t); +} + +/** + * Helper: Ease out function (fast start, slow end) + */ +function easeOut(t) { + return 1 - Math.pow(1 - t, 3); +} + +/** + * Helper: Animate a counter from 0 to target + */ +function animateCounter(target, progress) { + return Math.floor(target * easeOut(progress)); +} + +// Export to globalThis for browser usage +if (typeof globalThis !== 'undefined') { + Object.assign(globalThis, { + SceneManager, + getScenePhase, + renderScene, + createScene, + staggeredReveal, + easeInOut, + easeOut, + animateCounter, + ANIMATION_FPS: FPS, + DEFAULT_HOLD_SECONDS, + DEFAULT_TRANSITION_IN_SECONDS, + DEFAULT_TRANSITION_OUT_SECONDS, + }); +} + +})(); diff --git a/plugins/thinkback/skills/thinkback/helpers/text_effects.js b/plugins/thinkback/skills/thinkback/helpers/text_effects.js new file mode 100644 index 0000000..9705ac5 --- /dev/null +++ b/plugins/thinkback/skills/thinkback/helpers/text_effects.js @@ -0,0 +1,503 @@ +/* eslint-disable */ +/** + * Text Animation Effects + * Utilities for animating text in various ways + */ +(function() { + +// Seeded random for consistent effects +function seededRandom(seed) { + const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; + return x - Math.floor(x); +} + +// Easing function +function easeOut(t) { + return 1 - Math.pow(1 - t, 3); +} + +/** + * Typewriter effect - returns substring of text + * @returns The portion of text to display + */ +function typewriter(text, progress) { + const numChars = Math.floor(progress * text.length); + return text.slice(0, numChars); +} + +/** + * Fade text by letter using density characters + * @returns Array of { char, opacity } for each character + */ +function fadeByLetter(text, progress) { + const densityChars = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@']; + + return text.split('').map((char, i) => { + const charProgress = Math.max(0, Math.min(1, progress * text.length - i)); + if (char === ' ') return { char: ' ', opacity: 1 }; + const densityIdx = Math.floor(charProgress * (densityChars.length - 1)); + return { char: densityChars[densityIdx], opacity: charProgress }; + }); +} + +/** + * Wave position modifier - returns y offset for bouncing effect + * @param index - Character index for phase offset + */ +function wave(frame, options = {}) { + const { amplitude = 1, frequency = 0.2, index = 0, speed = 1 } = options; + return Math.round(Math.sin((frame * speed + index) * frequency) * amplitude); +} + +/** + * Bounce position modifier - returns y offset for bounce effect + */ +function bounce(frame, options = {}) { + const { amplitude = 2, speed = 1 } = options; + const t = (frame * speed * 0.1) % (Math.PI * 2); + return Math.round(Math.abs(Math.sin(t)) * amplitude); +} + +/** + * Shake position modifier - returns {x, y} offset for trembling + */ +function shake(frame, options = {}) { + const { intensity = 1 } = options; + return { + x: Math.round((seededRandom(frame * 7) - 0.5) * 2 * intensity), + y: Math.round((seededRandom(frame * 13) - 0.5) * 2 * intensity) + }; +} + +/** + * Float position modifier - gentle up/down drift + */ +function float(frame, options = {}) { + const { amplitude = 0.5, speed = 0.5 } = options; + return Math.round(Math.sin(frame * speed * 0.1) * amplitude); +} + +/** + * Draw text with typewriter effect + */ +function drawTypewriter(fb, x, y, text, progress, options = {}) { + const { cursor = 'โ–Œ', depth = 0 } = options; + const visibleText = typewriter(text, progress); + + fb.drawText(x, y, visibleText, depth); + + // Draw blinking cursor + if (progress < 1) { + const cursorX = x + visibleText.length; + fb.setPixel(cursorX, y, cursor, depth); + } +} + +/** + * Draw centered text with typewriter effect + */ +function drawTypewriterCentered(fb, y, text, progress, options = {}) { + const x = Math.floor((fb.width - text.length) / 2); + drawTypewriter(fb, x, y, text, progress, options); +} + +/** + * Draw text with wave effect (each character bobs up/down) + */ +function drawWaveText(fb, y, text, frame, options = {}) { + const { amplitude = 1, frequency = 0.3, speed = 1, depth = 0 } = options; + const x = Math.floor((fb.width - text.length) / 2); + + for (let i = 0; i < text.length; i++) { + const offsetY = wave(frame, { amplitude, frequency, index: i, speed }); + fb.setPixel(x + i, y + offsetY, text[i], depth); + } +} + +/** + * Draw text with glitch effect + */ +function drawGlitchText(fb, x, y, text, frame, options = {}) { + const { intensity = 0.1, depth = 0 } = options; + const glitchChars = '#@$%&*!?โ–‘โ–’โ–“'; + + for (let i = 0; i < text.length; i++) { + const seed = i * 17 + frame * 7; + const shouldGlitch = seededRandom(seed) < intensity; + + if (shouldGlitch) { + const glitchIdx = Math.floor(seededRandom(seed + 1) * glitchChars.length); + fb.setPixel(x + i, y, glitchChars[glitchIdx], depth); + } else { + fb.setPixel(x + i, y, text[i], depth); + } + } +} + +/** + * Draw centered text with glitch effect + */ +function drawGlitchTextCentered(fb, y, text, frame, options = {}) { + const x = Math.floor((fb.width - text.length) / 2); + drawGlitchText(fb, x, y, text, frame, options); +} + +/** + * Draw text that scatters then reassembles + */ +function drawScatterText(fb, text, progress, options = {}) { + const { cx = null, cy = null, depth = 0 } = options; + const centerX = cx !== null ? cx : fb.width / 2; + const centerY = cy !== null ? cy : fb.height / 2; + const finalX = Math.floor(centerX - text.length / 2); + const finalY = Math.floor(centerY); + + const easedProgress = easeOut(progress); + + for (let i = 0; i < text.length; i++) { + const seed = i * 31; + // Random scattered position + const scatterX = seededRandom(seed) * fb.width; + const scatterY = seededRandom(seed + 1) * fb.height; + + // Interpolate between scattered and final position + const x = Math.floor(scatterX + (finalX + i - scatterX) * easedProgress); + const y = Math.floor(scatterY + (finalY - scatterY) * easedProgress); + + if (x >= 0 && x < fb.width && y >= 0 && y < fb.height) { + fb.setPixel(x, y, text[i], depth); + } + } +} + +/** + * Slide text in from edge + */ +function slideIn(fb, y, text, progress, options = {}) { + const { from = 'left', depth = 0 } = options; + const finalX = Math.floor((fb.width - text.length) / 2); + const easedProgress = easeOut(progress); + + let startX; + switch (from) { + case 'right': + startX = fb.width; + break; + case 'left': + default: + startX = -text.length; + } + + const x = Math.floor(startX + (finalX - startX) * easedProgress); + + for (let i = 0; i < text.length; i++) { + const charX = x + i; + if (charX >= 0 && charX < fb.width) { + fb.setPixel(charX, y, text[i], depth); + } + } +} + +/** + * Slide text out to edge + */ +function slideOut(fb, y, text, progress, options = {}) { + const { to = 'right', depth = 0 } = options; + const startX = Math.floor((fb.width - text.length) / 2); + const easedProgress = easeOut(progress); + + let endX; + switch (to) { + case 'left': + endX = -text.length; + break; + case 'right': + default: + endX = fb.width; + } + + const x = Math.floor(startX + (endX - startX) * easedProgress); + + for (let i = 0; i < text.length; i++) { + const charX = x + i; + if (charX >= 0 && charX < fb.width) { + fb.setPixel(charX, y, text[i], depth); + } + } +} + +/** + * Draw text with zoom effect (characters expand from center) + */ +function drawZoomText(fb, y, text, progress, options = {}) { + const { depth = 0 } = options; + const centerX = fb.width / 2; + const textWidth = text.length; + const easedProgress = easeOut(progress); + + for (let i = 0; i < text.length; i++) { + // Start from center, expand to final position + const finalOffset = i - textWidth / 2; + const offset = finalOffset * easedProgress; + const x = Math.floor(centerX + offset); + + if (x >= 0 && x < fb.width) { + fb.setPixel(x, y, text[i], depth); + } + } +} + +/** + * Draw text character by character with fade + */ +function drawFadeInText(fb, y, text, progress, options = {}) { + const { depth = 0 } = options; + const x = Math.floor((fb.width - text.length) / 2); + const chars = fadeByLetter(text, progress); + + for (let i = 0; i < chars.length; i++) { + if (chars[i].opacity > 0.9) { + fb.setPixel(x + i, y, text[i], depth); + } else if (chars[i].opacity > 0) { + fb.setPixel(x + i, y, chars[i].char, depth); + } + } +} + +/** + * Rainbow cycling through density characters + * (Monochrome version using different characters) + */ +function drawRainbowText(fb, y, text, frame, options = {}) { + const { depth = 0, chars = ['ยท', '*', 'โ—†', 'โ—', 'โ– '] } = options; + const x = Math.floor((fb.width - text.length) / 2); + + for (let i = 0; i < text.length; i++) { + if (text[i] !== ' ') { + const cycleIdx = (i + Math.floor(frame / 4)) % chars.length; + // Keep the original character but could swap for effect chars + fb.setPixel(x + i, y, text[i], depth); + } else { + fb.setPixel(x + i, y, ' ', depth); + } + } +} + +/** + * Reveal text with a wipe effect (character by character from direction) + */ +function drawWipeReveal(fb, y, text, progress, options = {}) { + const { direction = 'left', depth = 0 } = options; + const x = Math.floor((fb.width - text.length) / 2); + const numVisible = Math.floor(progress * text.length); + + for (let i = 0; i < text.length; i++) { + const visibleIdx = direction === 'right' ? text.length - 1 - i : i; + if (visibleIdx < numVisible) { + fb.setPixel(x + i, y, text[i], depth); + } + } +} + +// ============================================================================ +// CLAUDE MASCOT & BRANDING +// ============================================================================ + +// Claude mascot ASCII art (Clawd - 3 lines, 10 chars wide) +const CLAUDE_MASCOT = [ + ' โ–โ–›โ–ˆโ–ˆโ–ˆโ–œโ–Œ ', + 'โ–โ–œโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–›โ–˜', + ' โ–˜โ–˜ โ–โ– ', +]; +const CLAUDE_MASCOT_WIDTH = 10; + +// Claude Code logo ASCII art (large block letters, left-aligned) +const CLAUDE_CODE_LOGO = [ + ' โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—', + 'โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•', + 'โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— ', + 'โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ• ', + 'โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—', + ' โ•šโ•โ•โ•โ•โ•โ•โ•šโ•โ•โ•โ•โ•โ•โ•โ•šโ•โ• โ•šโ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ•โ•', + ' ', + ' โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— ', + 'โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ• ', + 'โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— ', + 'โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ• ', + 'โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— ', + ' โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ•โ• ', +]; +const CLAUDE_CODE_LOGO_WIDTH = 48; +const CLAUDE_CODE_LOGO_HEIGHT = 13; + +// Claude orange color +const CLAUDE_ORANGE = '#D97757'; + +/** + * Draw Claude mascot (Clawd) centered at position + * @param fb - Framebuffer + * @param centerX - X center position + * @param y - Top Y position + * @param color - Color for the mascot (default: Anthropic orange) + */ +function drawClaudeMascot(fb, centerX, y, color = CLAUDE_ORANGE) { + const startX = Math.floor(centerX - CLAUDE_MASCOT_WIDTH / 2); + for (let i = 0; i < CLAUDE_MASCOT.length; i++) { + const line = CLAUDE_MASCOT[i]; + for (let j = 0; j < line.length; j++) { + if (line[j] !== ' ') { + // Use setPixel with depth=0 and color for HTML, terminal ignores color + fb.setPixel(startX + j, y + i, line[j], 0, color); + } + } + } +} + +/** + * Draw Claude Code logo centered at position + * @param fb - Framebuffer + * @param centerX - X center position + * @param y - Top Y position + * @param color - Color for the logo (default: Claude orange) + */ +function drawClaudeCodeLogo(fb, centerX, y, color = CLAUDE_ORANGE) { + const startX = Math.floor(centerX - CLAUDE_CODE_LOGO_WIDTH / 2); + for (let i = 0; i < CLAUDE_CODE_LOGO.length; i++) { + const line = CLAUDE_CODE_LOGO[i]; + for (let j = 0; j < line.length; j++) { + if (line[j] !== ' ') { + fb.setPixel(startX + j, y + i, line[j], 0, color); + } + } + } +} + +/** + * Draw the Thinkback intro scene with Clawd, Claude Code logo, and standard text + * This creates a consistent opening experience for all Thinkback animations. + * + * @param fb - Framebuffer + * @param frame - Current frame number + * @param progress - Scene progress (0-1) + * @param options - Configuration options + * @param options.year - Year to display (default: 2025) + * @param options.static - If true, render everything fully visible (for thumbnails/stills) + * @param options.staticFrame - Frame number to render as static (default: 0) + * + * @example + * drawThinkbackIntro(fb, frame, progress); + * // For a still frame: + * drawThinkbackIntro(fb, 0, 1, { static: true }); + */ +function drawThinkbackIntro(fb, frame, progress, options = {}) { + const { year = 2025, static: isStatic = false, staticFrame = 0 } = options; + + // Frame 0 (or specified staticFrame) is always rendered as a complete still + // This provides a good thumbnail when converting to video + const renderAsStatic = isStatic || frame === staticFrame; + + const centerX = fb.width / 2; + + // If static mode, skip all animations and show everything fully rendered + const logoPhase = renderAsStatic ? 1 : Math.min(1, progress * 5); + const textPhase = renderAsStatic ? 1 : Math.max(0, Math.min(1, (progress - 0.2) * 4)); + const subtitlePhase = renderAsStatic ? 1 : Math.max(0, Math.min(1, (progress - 0.5) * 3)); + const showYear = renderAsStatic || progress > 0.6; + + // Draw Clawd mascot at top center (no pixelation - appears immediately) + const clawdY = 2; + drawClaudeMascot(fb, centerX, clawdY, CLAUDE_ORANGE); + + // Draw Claude Code logo below Clawd with fast dissolve effect + const logoY = 6; + const startX = Math.floor(centerX - CLAUDE_CODE_LOGO_WIDTH / 2); + + for (let i = 0; i < CLAUDE_CODE_LOGO.length; i++) { + const line = CLAUDE_CODE_LOGO[i]; + for (let j = 0; j < line.length; j++) { + if (line[j] !== ' ') { + // Dissolve effect - random pixels appear based on progress + // In static mode (including frame 0), always show all pixels + const seed = i * 100 + j; + const threshold = seededRandom(seed); + + if (renderAsStatic || logoPhase > threshold) { + const x = startX + j; + if (x >= 0 && x < fb.width) { + fb.setPixel(x, logoY + i, line[j], 0, CLAUDE_ORANGE); + } + } + } + } + } + + // Draw "Think Back on..." text + if (textPhase > 0 || renderAsStatic) { + const thinkBackY = 21; + const thinkBackText = 'Think Back on...'; + + // Typewriter effect for "Think Back on..." (or full text in static mode) + const visibleChars = renderAsStatic ? thinkBackText.length : Math.floor(textPhase * thinkBackText.length * 1.2); + const displayText = thinkBackText.slice(0, Math.min(visibleChars, thinkBackText.length)); + const textX = Math.floor(centerX - thinkBackText.length / 2); + + for (let i = 0; i < displayText.length; i++) { + fb.setPixel(textX + i, thinkBackY, displayText[i], 0, '#FFFFFF'); + } + + // Draw cursor while typing (not in static mode) + if (!renderAsStatic && visibleChars < thinkBackText.length) { + fb.setPixel(textX + displayText.length, thinkBackY, 'โ–Œ', 0, '#FFFFFF'); + } + } + + // Draw "your year with Claude Code" only after "Think Back on..." is fully typed + if (subtitlePhase > 0 || renderAsStatic) { + const subtitleY = 23; + const subtitleText = 'your year with Claude Code'; + const easedSubtitle = easeOut(subtitlePhase); + const visibleChars = renderAsStatic ? subtitleText.length : Math.floor(easedSubtitle * subtitleText.length); + const displaySubtitle = subtitleText.slice(0, visibleChars); + const subtitleX = Math.floor(centerX - subtitleText.length / 2); + + for (let i = 0; i < displaySubtitle.length; i++) { + fb.setPixel(subtitleX + i, subtitleY, displaySubtitle[i], 0, CLAUDE_ORANGE); + } + + // Draw cursor while typing (not in static mode) + if (!renderAsStatic && visibleChars < subtitleText.length) { + fb.setPixel(subtitleX + displaySubtitle.length, subtitleY, 'โ–Œ', 0, CLAUDE_ORANGE); + } + } + + // Draw year at bottom + if (showYear) { + const yearPhase = renderAsStatic ? 1 : (progress - 0.6) / 0.4; + const yearText = String(year); + const yearY = fb.height - 3; + const yearX = Math.floor(centerX - yearText.length / 2); + + // Fade in year (or show immediately in static mode) + if (renderAsStatic || yearPhase > 0.3) { + for (let i = 0; i < yearText.length; i++) { + fb.setPixel(yearX + i, yearY, yearText[i], 0, '#FFD700'); + } + } + } +} + +// Make available globally for browser script tag usage +if (typeof globalThis !== 'undefined') { + Object.assign(globalThis, { + typewriter, fadeByLetter, wave, bounce, shake, float, + drawTypewriter, drawTypewriterCentered, drawWaveText, + drawGlitchText, drawGlitchTextCentered, drawScatterText, + slideIn, slideOut, drawZoomText, drawFadeInText, + drawRainbowText, drawWipeReveal, + // Claude branding + CLAUDE_MASCOT, CLAUDE_MASCOT_WIDTH, drawClaudeMascot, + CLAUDE_CODE_LOGO, CLAUDE_CODE_LOGO_WIDTH, CLAUDE_CODE_LOGO_HEIGHT, + CLAUDE_ORANGE, drawClaudeCodeLogo, drawThinkbackIntro, + }); +} +})(); diff --git a/plugins/thinkback/skills/thinkback/helpers/transitions.js b/plugins/thinkback/skills/thinkback/helpers/transitions.js new file mode 100644 index 0000000..0fe03de --- /dev/null +++ b/plugins/thinkback/skills/thinkback/helpers/transitions.js @@ -0,0 +1,449 @@ +/* eslint-disable */ +/** + * Scene Transition Effects + * All functions take (fb, progress, options) where progress is 0-1 + */ +(function() { + +// Seeded random for consistent dissolve patterns +function seededRandom(seed) { + const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; + return x - Math.floor(x); +} + +/** + * Wipe from right to left (reveals content as wipe passes) + */ +function wipeLeft(fb, progress, char = 'โ–ˆ') { + const edge = Math.floor((1 - progress) * fb.width); + for (let y = 0; y < fb.height; y++) { + for (let x = edge; x < fb.width; x++) { + fb.setPixel(x, y, char, -100); + } + } +} + +/** + * Wipe from left to right + */ +function wipeRight(fb, progress, char = 'โ–ˆ') { + const edge = Math.floor(progress * fb.width); + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < edge; x++) { + fb.setPixel(x, y, char, -100); + } + } +} + +/** + * Wipe from top to bottom + */ +function wipeDown(fb, progress, char = 'โ–ˆ') { + const edge = Math.floor(progress * fb.height); + for (let y = 0; y < edge; y++) { + for (let x = 0; x < fb.width; x++) { + fb.setPixel(x, y, char, -100); + } + } +} + +/** + * Wipe from bottom to top + */ +function wipeUp(fb, progress, char = 'โ–ˆ') { + const edge = Math.floor((1 - progress) * fb.height); + for (let y = edge; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + fb.setPixel(x, y, char, -100); + } + } +} + +/** + * Circular reveal expanding from center + * Uses aspect ratio correction for circular appearance + */ +function circleReveal(fb, progress, cx = null, cy = null) { + const centerX = cx !== null ? cx : fb.width / 2; + const centerY = cy !== null ? cy : fb.height / 2; + const maxRadius = Math.sqrt(fb.width * fb.width + fb.height * fb.height); + const radius = progress * maxRadius; + const aspectRatio = 2.16; // Terminal character aspect ratio + + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + const dx = (x - centerX) / aspectRatio; + const dy = y - centerY; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > radius) { + fb.setPixel(x, y, ' ', -100); + } + } + } +} + +/** + * Circular close - shrinks to center + */ +function circleClose(fb, progress, cx = null, cy = null) { + circleReveal(fb, 1 - progress, cx, cy); +} + +/** + * Classic iris-in effect (circle reveals from center) + */ +function irisIn(fb, progress) { + circleReveal(fb, progress); +} + +/** + * Classic iris-out effect (circle closes to center) + */ +function irisOut(fb, progress) { + circleClose(fb, progress); +} + +/** + * Horizontal venetian blinds effect + */ +function blindsH(fb, progress, numBlinds = 8) { + const blindHeight = Math.ceil(fb.height / numBlinds); + const revealHeight = Math.floor(progress * blindHeight); + + for (let blind = 0; blind < numBlinds; blind++) { + const blindStart = blind * blindHeight; + for (let dy = revealHeight; dy < blindHeight; dy++) { + const y = blindStart + dy; + if (y < fb.height) { + for (let x = 0; x < fb.width; x++) { + fb.setPixel(x, y, ' ', -100); + } + } + } + } +} + +/** + * Vertical blinds effect + */ +function blindsV(fb, progress, numBlinds = 12) { + const blindWidth = Math.ceil(fb.width / numBlinds); + const revealWidth = Math.floor(progress * blindWidth); + + for (let blind = 0; blind < numBlinds; blind++) { + const blindStart = blind * blindWidth; + for (let dx = revealWidth; dx < blindWidth; dx++) { + const x = blindStart + dx; + if (x < fb.width) { + for (let y = 0; y < fb.height; y++) { + fb.setPixel(x, y, ' ', -100); + } + } + } + } +} + +/** + * Checkerboard dissolve pattern + */ +function checkerboard(fb, progress, size = 4) { + const numCellsX = Math.ceil(fb.width / size); + const numCellsY = Math.ceil(fb.height / size); + const totalCells = numCellsX * numCellsY; + const revealedCells = Math.floor(progress * totalCells); + + // Create ordered reveal pattern (checkerboard pattern) + const cells = []; + for (let cy = 0; cy < numCellsY; cy++) { + for (let cx = 0; cx < numCellsX; cx++) { + const phase = (cx + cy) % 2; + cells.push({ cx, cy, order: phase * totalCells / 2 + cy * numCellsX + cx }); + } + } + cells.sort((a, b) => a.order - b.order); + + // Cover unrevealed cells + for (let i = revealedCells; i < cells.length; i++) { + const { cx, cy } = cells[i]; + for (let dy = 0; dy < size; dy++) { + for (let dx = 0; dx < size; dx++) { + const x = cx * size + dx; + const y = cy * size + dy; + if (x < fb.width && y < fb.height) { + fb.setPixel(x, y, ' ', -100); + } + } + } + } +} + +/** + * Diagonal wipe from corner to corner + * @param dir - 'tl' (top-left), 'tr', 'bl', 'br' + */ +function diagonalWipe(fb, progress, dir = 'tl') { + const maxDist = fb.width + fb.height; + const threshold = progress * maxDist; + + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + let dist; + switch (dir) { + case 'tl': dist = x + y; break; + case 'tr': dist = (fb.width - x) + y; break; + case 'bl': dist = x + (fb.height - y); break; + case 'br': dist = (fb.width - x) + (fb.height - y); break; + default: dist = x + y; + } + if (dist > threshold) { + fb.setPixel(x, y, ' ', -100); + } + } + } +} + +/** + * Random pixel dissolve effect + */ +function dissolve(fb, progress, seed = 0) { + const totalPixels = fb.width * fb.height; + const visiblePixels = Math.floor(progress * totalPixels); + + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + const pixelSeed = seed + x * 31 + y * 17; + const rand = seededRandom(pixelSeed); + if (rand > progress) { + fb.setPixel(x, y, ' ', -100); + } + } + } +} + +/** + * Pixelation effect - content becomes less pixelated over time + */ +function pixelate(fb, progress, maxSize = 8) { + if (progress >= 1) return; + + const blockSize = Math.max(1, Math.floor((1 - progress) * maxSize)); + if (blockSize <= 1) return; + + // Sample and replicate blocks + for (let by = 0; by < fb.height; by += blockSize) { + for (let bx = 0; bx < fb.width; bx += blockSize) { + // Get center pixel of block + const sampleX = Math.min(bx + Math.floor(blockSize / 2), fb.width - 1); + const sampleY = Math.min(by + Math.floor(blockSize / 2), fb.height - 1); + const char = fb.getPixel(sampleX, sampleY); + + // Fill block with sampled character + for (let dy = 0; dy < blockSize; dy++) { + for (let dx = 0; dx < blockSize; dx++) { + const x = bx + dx; + const y = by + dy; + if (x < fb.width && y < fb.height) { + fb.setPixel(x, y, char, -50); + } + } + } + } + } +} + +/** + * Matrix rain transition effect + */ +function matrixRain(fb, frame, progress, density = 0.1) { + const chars = '01'; + const visibleColumns = Math.floor(progress * fb.width); + + for (let x = 0; x < visibleColumns; x++) { + const columnSeed = x * 17; + const speed = 0.5 + seededRandom(columnSeed) * 0.5; + const offset = Math.floor(frame * speed + seededRandom(columnSeed + 1) * fb.height); + + for (let y = 0; y < fb.height; y++) { + const charSeed = columnSeed + y * 31 + frame; + if (seededRandom(charSeed) < density) { + const trailPos = (y + offset) % fb.height; + const charIdx = Math.floor(seededRandom(charSeed + 7) * chars.length); + fb.setPixel(x, trailPos, chars[charIdx], -80); + } + } + } +} + +/** + * Slide offset calculator - returns offset for sliding content + * Use text_effects.js slideIn/slideOut for drawing text + */ +function getSlideOffset(progress, from = 'left', width, height) { + const offset = Math.floor((1 - progress) * (from === 'left' || from === 'right' ? width : height)); + + return { + x: from === 'left' ? -offset : from === 'right' ? offset : 0, + y: from === 'top' ? -offset : from === 'bottom' ? offset : 0 + }; +} + +/** + * Fade using density characters + */ +function fade(fb, progress, invert = false) { + const densityChars = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@']; + const p = invert ? 1 - progress : progress; + const charIdx = Math.floor(p * (densityChars.length - 1)); + const fadeChar = densityChars[charIdx]; + + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + const current = fb.getPixel(x, y); + if (current !== ' ') { + fb.setPixel(x, y, fadeChar, -90); + } + } + } +} + +// Rainbow color helper for effects +function rainbowColor(index, offset = 0) { + const colors = ['#9C5C5C', '#CC785C', '#B8A85C', '#5C9A5C', '#5C9A9A', '#5C7A9C', '#8C6C9C']; + return colors[Math.abs(Math.floor(index + offset)) % colors.length]; +} + +// ============================================================================ +// SUPERNOVA - Explosive burst from center +// ============================================================================ +function supernova(fb, progress) { + const centerX = fb.width / 2; + const centerY = fb.height / 2; + const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY) * 1.2; + const currentRadius = progress * maxRadius; + const chars = 'โœฆโœง*ยท'; + + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + const dx = (x - centerX) / 2; // Compensate for character aspect ratio + const dy = y - centerY; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < currentRadius) { + // Inside the explosion - clear + fb.setPixel(x, y, ' ', -100); + } else if (dist < currentRadius + 3) { + // Edge of explosion - draw particles + const charIdx = Math.floor(seededRandom(x * 31 + y * 17) * chars.length); + fb.setPixel(x, y, chars[charIdx], -100); + } + } + } +} + +// ============================================================================ +// SPIRAL - Spinning vortex clear +// ============================================================================ +function spiral(fb, progress) { + const centerX = fb.width / 2; + const centerY = fb.height / 2; + const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY); + + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + const dx = (x - centerX) / 2; + const dy = y - centerY; + const dist = Math.sqrt(dx * dx + dy * dy); + const angle = Math.atan2(dy, dx) + Math.PI; // 0 to 2ฯ€ + + // Spiral threshold: combines angle and distance + const threshold = (angle / (Math.PI * 2) + dist / maxRadius) / 2; + + if (threshold < progress) { + fb.setPixel(x, y, ' ', -100); + } + } + } +} + +// ============================================================================ +// SHATTER - Breaking glass effect +// ============================================================================ +function shatter(fb, progress) { + const numShards = 20; + + for (let i = 0; i < numShards; i++) { + // Each shard has a random position and falls at different speeds + const shardX = seededRandom(i * 17) * fb.width; + const shardY = seededRandom(i * 31) * fb.height; + const shardSize = 3 + Math.floor(seededRandom(i * 47) * 5); + const fallSpeed = 0.5 + seededRandom(i * 61); + + const shardProgress = Math.min(1, progress * (1 + fallSpeed)); + + if (shardProgress > seededRandom(i * 73) * 0.5) { + // Clear this shard area + for (let dy = 0; dy < shardSize; dy++) { + for (let dx = 0; dx < shardSize * 2; dx++) { + const px = Math.floor(shardX + dx); + const py = Math.floor(shardY + dy + shardProgress * 10); + if (px >= 0 && px < fb.width && py >= 0 && py < fb.height) { + fb.setPixel(px, py, ' ', -100); + } + } + } + } + } + + // Ensure full clear at end + if (progress >= 0.95) { + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + fb.setPixel(x, y, ' ', -100); + } + } + } +} + +// ============================================================================ +// TORNADO - Swirling funnel clear +// ============================================================================ +function tornado(fb, progress) { + const centerX = fb.width / 2; + const rotation = progress * Math.PI * 6; // 3 full rotations + + for (let y = 0; y < fb.height; y++) { + // Funnel width varies with height (wider at top) + const funnelWidth = (fb.height - y) / fb.height * fb.width * 0.4; + const offset = Math.sin(rotation + y * 0.3) * funnelWidth * progress; + + const clearStart = Math.floor(centerX + offset - funnelWidth * progress); + const clearEnd = Math.floor(centerX + offset + funnelWidth * progress); + + for (let x = clearStart; x < clearEnd; x++) { + if (x >= 0 && x < fb.width) { + fb.setPixel(x, y, ' ', -100); + } + } + } + + // Ensure full clear at end + if (progress >= 0.95) { + for (let y = 0; y < fb.height; y++) { + for (let x = 0; x < fb.width; x++) { + fb.setPixel(x, y, ' ', -100); + } + } + } +} + +// Make available globally for browser script tag usage +if (typeof globalThis !== 'undefined') { + Object.assign(globalThis, { + wipeLeft, wipeRight, wipeDown, wipeUp, + circleReveal, circleClose, irisIn, irisOut, + blindsH, blindsV, checkerboard, diagonalWipe, + dissolve, pixelate, matrixRain, getSlideOffset, fade, + supernova, spiral, shatter, tornado + }); +} +})(); diff --git a/plugins/thinkback/skills/thinkback/high_token_version.md b/plugins/thinkback/skills/thinkback/high_token_version.md new file mode 100644 index 0000000..2c5484d --- /dev/null +++ b/plugins/thinkback/skills/thinkback/high_token_version.md @@ -0,0 +1,437 @@ +# High Token Version - Deep Dive Generation + +This mode creates a deeply personalized Thinkback by analyzing the user's projects, commits, and conversations. + +## Narrative Philosophy + +Each animation is deeply personalized to the user. While it is informed by stats it is not ABOUT stats. +It is instead about telling a story about the user. +It is structured in scenes with an opening that draws the user and a closing that wraps it up nicely. + +### Narrative Principles + +1. **Stats are evidence, not the story** - Don't just show "106 commits in October." Ask: *What was happening in October? What were they building? Why did it matter?* + +2. **Find the defining moments & accomplishments** - Look for: + - Their first moment with Claude code + - A major feature or PR that shipped + - A problem they solved repeatedly + - A shift in what they worked on (new repo, new area) + - Patterns that reveal personality (night owl, weekend warrior, refactor-then-ship) + +3. **Create emotional beats** - Each scene should make the user feel something: + - Opening: Anticipation, curiosity + - Middle: Recognition ("that's so me"), pride, humor + - Closing: Gratitude, momentum for next year + +4. **Connect the dots** - The best narratives link scenes: + - "You started the year exploring... but by October, you were building" + - "42 commits at midnight - burning the midnight oil" + +### Scene Types to Include + +- **Origin moment**: How/when they started with Claude Code +- **Signature move**: What they do most (debug? refactor? prototype?) +- **Growth arc**: How their usage evolved +- **Defining projects**: The projects that mattered most or unique accomplishments + +--- + +## Step 1: Extract Statistics + +Run the stats script from the skill folder root: +```bash +cd ${CLAUDE_PLUGIN_ROOT}/skills/thinkback && node scripts/get_all_stats.js --markdown +``` + +The `--markdown` flag also generates `activity-report.md` with: +- Every repo with Claude co-authored commits +- Recent commits per repo (up to 10) +- Recent user messages per project (up to 5) + +## Step 2: Read the Activity Report + +Read `activity-report.md` for narrative inspiration - specific projects, commit messages, and conversation snippets that make the thinkback personal. + +## Step 3: Spin off Subagents + +Spin off subagents to read individual repos with instructions to: understand what the repo is, and then read the commits by the user to understand major accomplishments and projects that the user did. +Each subagent should return this information with the projects, what they are about and the user's accomplishments. + +Also spin off an explore subagent to read the users transcripts at `~/.claude/projects` and look for specific keywords to indicate a big accomplishment or something the user was very happy about. + +## Step 4: Generate year_in_review.js + +Write the customized file to this skill folder as `year_in_review.js`. + +**CRITICAL: SCENE_DEFINITIONS use SECONDS, not frames:** +```javascript +// CORRECT - durations in seconds: +const SCENE_DEFINITIONS = [ + { name: 'INTRO', duration: 7.5 }, + { name: 'FIRST_SPARK', duration: 7.5 }, + { name: 'THE_JOURNEY', duration: 8.5 }, + // ... etc +]; + +// WRONG - DO NOT use frames: +const SCENE_DEFINITIONS = [ + { name: 'INTRO', frames: 180 }, // WRONG +]; +``` + +## Step 5: Validate the Animation + +**IMPORTANT: Always run validation after generating year_in_review.js to catch common errors.** + +```bash +cd ${CLAUDE_PLUGIN_ROOT}/skills/thinkback && node scripts/validate.js +``` + +The validator checks for: +- Missing exports (YearInReviewScenes, TOTAL_FRAMES, mainAnimation) +- **Scene names being undefined** (using `id` instead of `name` in SCENE_DEFINITIONS) +- Render function errors at various frames +- Frame coverage gaps + +**If validation fails, fix the errors and re-run validation until it passes.** + +Common errors and fixes: +| Error | Fix | +|-------|-----| +| "scenes have undefined names" | Change `{ id: 'SCENE_NAME' }` to `{ name: 'SCENE_NAME' }` in SCENE_DEFINITIONS | +| "Render error at frame X" | Check the switch statement cases match the scene names exactly | +| "TOTAL_FRAMES is invalid" or "TOTAL_FRAMES = NaN" | Use `getScene()` correctly - it returns `{ sceneId, progress }`, NOT `{ sceneId, sceneFrame, sceneLength }` | +| Progress `p` is NaN | Wrong API: use `const { sceneId, progress } = sceneManager.getScene(frame); const p = progress;` | +| animateCounter not working | Only 2 args: `animateCounter(target, progress)` NOT `animateCounter(0, target, progress)` | +| Scenes using `frames` instead of `duration` | SCENE_DEFINITIONS use **seconds**, not frames. Use `{ name: 'SCENE', duration: 7.5 }` NOT `{ name: 'SCENE', frames: 180 }` | + +## Step 6: Signal Completion + +After validation passes, tell the user their animation is ready and ask them to run `/thinkback` again to play it. + +--- + +## Required Intro Scene + +**IMPORTANT: Every Thinkback animation MUST begin with a consistent intro scene.** + +The intro scene uses `drawThinkbackIntro()` to display: +1. **Clawd** (the Claude mascot) at the top +2. **Claude Code** logo in large ASCII art +3. **"Think Back on..."** text with typewriter effect +4. **"your year with Claude Code"** subtitle +5. **Year** at the bottom + +--- + +## Animation Helpers Reference + +**IMPORTANT: DO NOT use ES module `import` statements in `year_in_review.js`.** + +The file is loaded as a regular ` + + + + + + + + + + + + + + + + diff --git a/plugins/thinkback/skills/thinkback/year_in_review_template.js b/plugins/thinkback/skills/thinkback/year_in_review_template.js new file mode 100644 index 0000000..540d931 --- /dev/null +++ b/plugins/thinkback/skills/thinkback/year_in_review_template.js @@ -0,0 +1,373 @@ +/* eslint-disable */ +/** + * Year in Review Animation Template + * + * This template uses the SceneManager for guaranteed timing: + * - Scenes are defined in SECONDS (not frames) + * - Each scene has automatic transition in/out phases (~0.5s each) + * - Content is guaranteed time to be absorbed (HOLD phase, default 2s) + * - Total duration is computed automatically + * + * Scene timing breakdown (for a 5s scene with default 2s hold): + * - 0.0s - 0.5s: TRANSITION_IN (content hidden) + * - 0.5s - 2.5s: CONTENT (content animates in, contentProgress 0โ†’1) + * - 2.5s - 4.5s: HOLD (content fully visible, time to read) + * - 4.5s - 5.0s: TRANSITION_OUT (fade out) + * + * Save the customized version as year_in_review.js in this folder. + * + * IMPORTANT: DO NOT USE ES MODULE IMPORTS (import/export statements) + * This file is loaded as a regular