mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-01-30 04:22:03 +00:00
Compare commits
2 Commits
noahz/upda
...
thariq/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7985b28c03 | ||
|
|
19a119f97e |
@@ -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.",
|
||||
|
||||
8
plugins/thinkback/.claude-plugin/plugin.json
Normal file
8
plugins/thinkback/.claude-plugin/plugin.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
35
plugins/thinkback/README.md
Normal file
35
plugins/thinkback/README.md
Normal file
@@ -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)
|
||||
3
plugins/thinkback/package.json
Normal file
3
plugins/thinkback/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
159
plugins/thinkback/skills/thinkback/SKILL.md
Normal file
159
plugins/thinkback/skills/thinkback/SKILL.md
Normal file
@@ -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
|
||||
435
plugins/thinkback/skills/thinkback/ascii_anim.js
Normal file
435
plugins/thinkback/skills/thinkback/ascii_anim.js
Normal file
@@ -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
|
||||
};
|
||||
996
plugins/thinkback/skills/thinkback/helpers/awards_effects.js
Normal file
996
plugins/thinkback/skills/thinkback/helpers/awards_effects.js
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
})();
|
||||
324
plugins/thinkback/skills/thinkback/helpers/backgrounds.js
Normal file
324
plugins/thinkback/skills/thinkback/helpers/backgrounds.js
Normal file
@@ -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
|
||||
});
|
||||
}
|
||||
})();
|
||||
313
plugins/thinkback/skills/thinkback/helpers/borders.js
Normal file
313
plugins/thinkback/skills/thinkback/helpers/borders.js
Normal file
@@ -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
|
||||
});
|
||||
}
|
||||
})();
|
||||
66
plugins/thinkback/skills/thinkback/helpers/index.js
Normal file
66
plugins/thinkback/skills/thinkback/helpers/index.js
Normal file
@@ -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;
|
||||
970
plugins/thinkback/skills/thinkback/helpers/news_effects.js
Normal file
970
plugins/thinkback/skills/thinkback/helpers/news_effects.js
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
})();
|
||||
349
plugins/thinkback/skills/thinkback/helpers/particles.js
Normal file
349
plugins/thinkback/skills/thinkback/helpers/particles.js
Normal file
@@ -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
|
||||
});
|
||||
}
|
||||
})();
|
||||
1181
plugins/thinkback/skills/thinkback/helpers/rpg_effects.js
Normal file
1181
plugins/thinkback/skills/thinkback/helpers/rpg_effects.js
Normal file
File diff suppressed because it is too large
Load Diff
415
plugins/thinkback/skills/thinkback/helpers/scene_system.js
Normal file
415
plugins/thinkback/skills/thinkback/helpers/scene_system.js
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
503
plugins/thinkback/skills/thinkback/helpers/text_effects.js
Normal file
503
plugins/thinkback/skills/thinkback/helpers/text_effects.js
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
})();
|
||||
449
plugins/thinkback/skills/thinkback/helpers/transitions.js
Normal file
449
plugins/thinkback/skills/thinkback/helpers/transitions.js
Normal file
@@ -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
|
||||
});
|
||||
}
|
||||
})();
|
||||
437
plugins/thinkback/skills/thinkback/high_token_version.md
Normal file
437
plugins/thinkback/skills/thinkback/high_token_version.md
Normal file
@@ -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 `<script>` tag in the browser, not as a module. All helpers are set on `globalThis` by the helper scripts that load before `year_in_review.js`.
|
||||
|
||||
**CRITICAL: Destructure helpers BEFORE using SceneManager.**
|
||||
|
||||
The `SceneManager` class must be destructured from `globalThis` before you can use `new SceneManager(...)`. Place the destructuring block at the very top of your file, before scene definitions:
|
||||
|
||||
```javascript
|
||||
// =============================================================================
|
||||
// HELPER DESTRUCTURING (must come FIRST, before SceneManager usage)
|
||||
// =============================================================================
|
||||
|
||||
const {
|
||||
// Scene system (REQUIRED - must be destructured before use)
|
||||
SceneManager, staggeredReveal, easeInOut, easeOut, animateCounter,
|
||||
// ... other helpers
|
||||
} = globalThis;
|
||||
|
||||
// NOW you can use SceneManager
|
||||
const SCENE_DEFINITIONS = [...];
|
||||
const sceneManager = new SceneManager(SCENE_DEFINITIONS); // This works!
|
||||
```
|
||||
|
||||
Access helpers by destructuring from `globalThis` at the top of your file:
|
||||
|
||||
```javascript
|
||||
const {
|
||||
// Transitions
|
||||
wipeLeft, wipeRight, wipeDown, wipeUp,
|
||||
circleReveal, circleClose, irisIn, irisOut,
|
||||
blindsH, blindsV, checkerboard, diagonalWipe,
|
||||
dissolve, pixelate, fade,
|
||||
supernova, spiral, shatter, tornado, // Creative transitions
|
||||
|
||||
// Backgrounds
|
||||
stars, starfield, rain, snow, fog, aurora,
|
||||
waves, gradient, ripples, fireflies, clouds,
|
||||
|
||||
// Particles
|
||||
confetti, sparkles, burst, bubbles, hearts,
|
||||
musicNotes, leaves, embers, dust, floatingParticles,
|
||||
orbit, shootingStars, glitter,
|
||||
|
||||
// Text effects
|
||||
typewriter, drawTypewriter, drawTypewriterCentered,
|
||||
drawWaveText, drawGlitchText, slideIn, slideOut,
|
||||
drawZoomText, drawFadeInText, drawScatterText,
|
||||
|
||||
// Claude branding & intro
|
||||
CLAUDE_MASCOT, CLAUDE_MASCOT_WIDTH, drawClaudeMascot,
|
||||
CLAUDE_CODE_LOGO, CLAUDE_CODE_LOGO_WIDTH, CLAUDE_CODE_LOGO_HEIGHT,
|
||||
CLAUDE_ORANGE, drawClaudeCodeLogo, drawThinkbackIntro,
|
||||
|
||||
// News broadcast effects (for morning news vibe)
|
||||
lowerThird, tickerTape, breakingBanner, liveIndicator,
|
||||
segmentTitle, statCounter, forecastBar, splitWipe,
|
||||
pushTransition, headlineCrawl, countdownReveal,
|
||||
|
||||
// Awards show effects (for awards show vibe)
|
||||
trophyDisplay, awardBadge, envelopeReveal, categoryTitle,
|
||||
acceptanceSpeech, nomineeCard, winnerAnnouncement, applauseMeter,
|
||||
standingOvation, redCarpetBorder, spotlightText, spotlightReveal,
|
||||
curtainReveal, awardsStatue,
|
||||
|
||||
// RPG quest effects (for rpg quest vibe)
|
||||
characterSprite, titleScreen, textBox, classSelect,
|
||||
questCard, questBanner, xpBar, levelUp,
|
||||
statsPanel, bossHealth, victoryFanfare, creditsRoll, inventorySlot,
|
||||
} = globalThis;
|
||||
```
|
||||
|
||||
### Transitions
|
||||
|
||||
Transitions mask/reveal content. Apply after drawing your scene content.
|
||||
|
||||
```javascript
|
||||
// Circular reveal from center (0-1 progress)
|
||||
circleReveal(fb, progress);
|
||||
|
||||
// Wipe from left to right
|
||||
wipeRight(fb, progress, '█');
|
||||
|
||||
// Diagonal wipe from top-left corner
|
||||
diagonalWipe(fb, progress, 'tl');
|
||||
|
||||
// Venetian blinds effect (horizontal)
|
||||
blindsH(fb, progress, 8);
|
||||
|
||||
// Random dissolve
|
||||
dissolve(fb, progress, seed);
|
||||
|
||||
// Iris in/out (classic film transition)
|
||||
irisIn(fb, progress);
|
||||
irisOut(fb, progress);
|
||||
|
||||
// Creative transitions
|
||||
supernova(fb, progress); // Explosive burst from center
|
||||
spiral(fb, progress); // Spinning vortex clear
|
||||
shatter(fb, progress); // Breaking glass effect
|
||||
tornado(fb, progress); // Swirling funnel clear
|
||||
```
|
||||
|
||||
### Backgrounds
|
||||
|
||||
Draw backgrounds first, before scene content.
|
||||
|
||||
```javascript
|
||||
// Twinkling stars (cozy, slow twinkle)
|
||||
stars(fb, frame, { density: 0.006, twinkle: true });
|
||||
|
||||
// 3D starfield zoom effect
|
||||
starfield(fb, frame, { speed: 1, numStars: 50 });
|
||||
|
||||
// Fireflies (warm, cozy)
|
||||
fireflies(fb, frame, { count: 8 });
|
||||
|
||||
// Snow falling
|
||||
snow(fb, frame, { density: 0.01 });
|
||||
|
||||
// Aurora / northern lights
|
||||
aurora(fb, frame, { intensity: 0.5 });
|
||||
|
||||
// Gradient background
|
||||
gradient(fb, { direction: 'vertical', invert: false });
|
||||
|
||||
// Concentric ripples from center
|
||||
ripples(fb, frame, { speed: 1, char: '·' });
|
||||
```
|
||||
|
||||
### Particles
|
||||
|
||||
Particles add atmosphere and celebration.
|
||||
|
||||
```javascript
|
||||
// Gentle floating particles
|
||||
floatingParticles(fb, frame, { count: 12, char: '◇' });
|
||||
|
||||
// Confetti celebration
|
||||
confetti(fb, frame, { count: 20 });
|
||||
|
||||
// Sparkles twinkling
|
||||
sparkles(fb, frame, { density: 0.005 });
|
||||
|
||||
// Rising embers
|
||||
embers(fb, frame, { count: 10 });
|
||||
|
||||
// Floating hearts
|
||||
hearts(fb, frame, { count: 6 });
|
||||
|
||||
// Dust motes in light
|
||||
dust(fb, frame, { density: 0.003 });
|
||||
|
||||
// Orbiting particles around center
|
||||
orbit(fb, frame, { cx: 40, cy: 12, radius: 5, count: 4 });
|
||||
```
|
||||
|
||||
### Text Effects
|
||||
|
||||
Text animations for revealing and emphasizing text.
|
||||
|
||||
```javascript
|
||||
// Typewriter effect (returns partial text)
|
||||
const visibleText = typewriter('Hello World', progress);
|
||||
|
||||
// Draw with typewriter + cursor
|
||||
drawTypewriterCentered(fb, y, 'Hello World', progress);
|
||||
|
||||
// Slide text in from edge
|
||||
slideIn(fb, y, 'Welcome', progress, { from: 'left' });
|
||||
|
||||
// Wave effect (each character bobs)
|
||||
drawWaveText(fb, y, 'Wavy Text', frame, { amplitude: 1 });
|
||||
|
||||
// Zoom text from center
|
||||
drawZoomText(fb, y, 'Zoom!', progress);
|
||||
|
||||
// Fade in character by character
|
||||
drawFadeInText(fb, y, 'Fading in', progress);
|
||||
|
||||
// Scatter then reassemble
|
||||
drawScatterText(fb, 'Scatter', progress);
|
||||
```
|
||||
|
||||
### Claude Mascot (Clawd)
|
||||
|
||||
The Claude mascot "Clawd" is a small ASCII art logo that can be used in opener/closer scenes.
|
||||
|
||||
```javascript
|
||||
// Draw Claude mascot centered at position
|
||||
// CLAUDE_MASCOT is an array of 3 lines
|
||||
// CLAUDE_MASCOT_WIDTH is 10 characters
|
||||
drawClaudeMascot(fb, fb.width / 2, 5, CLAUDE_ORANGE);
|
||||
|
||||
// The mascot looks like:
|
||||
// ▐▛███▜▌
|
||||
// ▝▜█████▛▘
|
||||
// ▘▘ ▝▝
|
||||
```
|
||||
|
||||
### Claude Code Logo
|
||||
|
||||
Large ASCII art logo for the Claude Code branding.
|
||||
|
||||
```javascript
|
||||
// Draw the full Claude Code logo (12 lines tall, 50 chars wide)
|
||||
drawClaudeCodeLogo(fb, fb.width / 2, 5, CLAUDE_ORANGE);
|
||||
|
||||
// The logo displays:
|
||||
// ██████╗██╗ █████╗ ██╗ ██╗██████╗ ███████╗
|
||||
// ██╔════╝██║ ██╔══██╗██║ ██║██╔══██╗██╔════╝
|
||||
// ██║ ██║ ███████║██║ ██║██║ ██║█████╗
|
||||
// ██║ ██║ ██╔══██║██║ ██║██║ ██║██╔══╝
|
||||
// ╚██████╗███████╗██║ ██║╚██████╔╝██████╔╝███████╗
|
||||
// ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝
|
||||
// ██████╗ ██████╗ ██████╗ ███████╗
|
||||
// ██╔════╝██╔═══██╗██╔══██╗██╔════╝
|
||||
// ██║ ██║ ██║██║ ██║█████╗
|
||||
// ██║ ██║ ██║██║ ██║██╔══╝
|
||||
// ╚██████╗╚██████╔╝██████╔╝███████╗
|
||||
// ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝
|
||||
```
|
||||
|
||||
### Thinkback Intro Scene (REQUIRED)
|
||||
|
||||
**Every animation MUST use this as the first scene.** It provides a consistent, branded intro experience.
|
||||
|
||||
```javascript
|
||||
// In your intro scene renderer:
|
||||
case 'INTRO': {
|
||||
// Add a starfield background
|
||||
stars(fb, frame, { density: 0.008, twinkle: true });
|
||||
|
||||
// Draw the complete intro scene with Clawd, logo, and standard text
|
||||
drawThinkbackIntro(fb, frame, p);
|
||||
|
||||
// Handle transition out
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
The intro displays:
|
||||
- Clawd mascot fading in
|
||||
- Claude Code logo with dissolve effect
|
||||
- "Think Back on..." with typewriter effect
|
||||
- "your year with Claude Code" subtitle
|
||||
- Year at the bottom
|
||||
|
||||
### Example Scene with Helpers
|
||||
|
||||
```javascript
|
||||
case 'OPENING': {
|
||||
// Background: twinkling stars
|
||||
stars(fb, frame, { density: 0.008, twinkle: true });
|
||||
|
||||
// Particles: gentle floating diamonds
|
||||
floatingParticles(fb, frame, { count: 15, char: '◇' });
|
||||
|
||||
// Text: typewriter reveal
|
||||
const title = 'Your Year in Review';
|
||||
if (p < 0.6) {
|
||||
drawTypewriterCentered(fb, 10, title, p / 0.6);
|
||||
} else {
|
||||
fb.drawCenteredText(10, title);
|
||||
}
|
||||
|
||||
// Transition: iris out at end of scene
|
||||
if (p > 0.85) {
|
||||
irisOut(fb, (p - 0.85) / 0.15);
|
||||
}
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
## Critical Export Format
|
||||
|
||||
**IMPORTANT: The generated `year_in_review.js` MUST export in this exact format for the HTML to work:**
|
||||
|
||||
```javascript
|
||||
// =============================================================================
|
||||
// EXPORTS - MUST match what year_in_review.html expects
|
||||
// =============================================================================
|
||||
|
||||
globalThis.YearInReviewScenes = {
|
||||
TOTAL_FRAMES: sceneManager.getTotalFrames(),
|
||||
mainAnimation: render, // Your main render function
|
||||
getSceneName: (frame) => {
|
||||
const { sceneId } = sceneManager.getScene(frame);
|
||||
return sceneId || 'Complete';
|
||||
},
|
||||
sceneManager,
|
||||
};
|
||||
```
|
||||
|
||||
**DO NOT use these incorrect patterns:**
|
||||
```javascript
|
||||
// WRONG - will cause "YearInReviewScenes not loaded" error
|
||||
globalThis.render = render;
|
||||
globalThis.TOTAL_FRAMES = sceneManager.getTotalFrames();
|
||||
|
||||
// WRONG - using ES module exports (not supported in browser script tag)
|
||||
export { render, TOTAL_FRAMES };
|
||||
```
|
||||
|
||||
The `year_in_review.html` file specifically checks for `globalThis.YearInReviewScenes` and calls `mainAnimation` from it.
|
||||
129
plugins/thinkback/skills/thinkback/low_token_version.md
Normal file
129
plugins/thinkback/skills/thinkback/low_token_version.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Low Token Version - Template-Based Generation
|
||||
|
||||
This mode uses pre-built templates for fast, token-efficient generation. Templates handle all scene structure, timing, and visual effects - you just inject the user's stats.
|
||||
|
||||
## Step 1: Extract Statistics
|
||||
|
||||
Run the stats script:
|
||||
```bash
|
||||
cd ${CLAUDE_PLUGIN_ROOT}/skills/thinkback && node scripts/get_all_stats.js
|
||||
```
|
||||
|
||||
Save the output - you'll need these values for the template.
|
||||
|
||||
## Step 2: Choose Template
|
||||
|
||||
Based on the user's vibe selection, use:
|
||||
|
||||
| Vibe | Template File |
|
||||
|------|---------------|
|
||||
| Cozy | `templates/cozy-template.js` |
|
||||
| Awards show | `templates/awards-show-template.js` |
|
||||
| Morning news | `templates/morning-news-template.js` |
|
||||
| RPG Quest | `templates/rpg-quest-template.js` |
|
||||
|
||||
## Step 3: Read the Template
|
||||
|
||||
Read the chosen template file. Each template has injection points marked with `// INJECT:` comments.
|
||||
|
||||
## Step 4: Fill Injection Points
|
||||
|
||||
Search for `// INJECT:` comments and fill in values from the stats output.
|
||||
|
||||
### Common STATS Object
|
||||
|
||||
All templates have a STATS object to fill:
|
||||
|
||||
```javascript
|
||||
const STATS = {
|
||||
userName: '', // User's name from stats
|
||||
year: 2025,
|
||||
totalCommits: 0, // Total commits
|
||||
totalSessions: 0, // Total sessions
|
||||
totalMessages: 0, // Total messages
|
||||
repoCount: 0, // Number of repos
|
||||
peakHour: '', // e.g., '12am', '3pm'
|
||||
peakDay: '', // e.g., 'Wed', 'Mon'
|
||||
nightOwlPercent: 0, // Percentage (0-100)
|
||||
earlyBirdPercent: 0, // Percentage (0-100)
|
||||
weekendPercent: 0, // Percentage (0-100)
|
||||
longestStreak: 0, // Days
|
||||
currentStreak: 0, // Days
|
||||
totalActiveDays: 0, // Days
|
||||
marathonDays: 0, // Days with 100+ messages
|
||||
longestSessionMessages: 0, // Messages in longest session
|
||||
firstSessionDate: '', // 'YYYY-MM-DD'
|
||||
busiestWeek: '', // e.g., 'Nov 24-30, 2025'
|
||||
};
|
||||
```
|
||||
|
||||
### Template-Specific Injection Points
|
||||
|
||||
**RPG Quest template** also needs:
|
||||
```javascript
|
||||
const CHARACTER_CLASS = ''; // INJECT: Based on work patterns
|
||||
// Options: 'Code Knight', 'Debug Wizard', 'Refactor Monk', 'Feature Bard'
|
||||
```
|
||||
|
||||
**Awards Show template** also needs:
|
||||
```javascript
|
||||
const TOP_REPOS = [
|
||||
{ name: '', commits: 0 }, // INJECT: Top repo
|
||||
{ name: '', commits: 0 }, // INJECT: 2nd repo
|
||||
{ name: '', commits: 0 }, // INJECT: 3rd repo
|
||||
];
|
||||
```
|
||||
|
||||
## Step 5: Write to year_in_review.js
|
||||
|
||||
Write the filled template to `year_in_review.js` in this skill folder.
|
||||
|
||||
## Step 6: Validate
|
||||
|
||||
Run validation to ensure no errors:
|
||||
```bash
|
||||
cd ${CLAUDE_PLUGIN_ROOT}/skills/thinkback && node scripts/validate.js
|
||||
```
|
||||
|
||||
If validation fails, check:
|
||||
- All STATS values are filled (no empty strings for required fields)
|
||||
- Numbers are actual numbers, not strings
|
||||
- Arrays have the expected structure
|
||||
|
||||
## Step 7: Signal Completion
|
||||
|
||||
Tell the user their animation is ready and ask them to run `/thinkback` again to play it.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Stats Output to STATS Mapping
|
||||
|
||||
The `get_all_stats.js` output maps to STATS like this:
|
||||
|
||||
| Stats Output | STATS Field |
|
||||
|--------------|-------------|
|
||||
| `commits.total` | `totalCommits` |
|
||||
| `sessions.total` | `totalSessions` |
|
||||
| `messages.total` | `totalMessages` |
|
||||
| `repos` array length | `repoCount` |
|
||||
| `timing.peakHour` | `peakHour` |
|
||||
| `timing.peakDay` | `peakDay` |
|
||||
| `timing.nightOwlPercent` | `nightOwlPercent` |
|
||||
| `timing.earlyBirdPercent` | `earlyBirdPercent` |
|
||||
| `timing.weekendPercent` | `weekendPercent` |
|
||||
| `streaks.longest` | `longestStreak` |
|
||||
| `streaks.current` | `currentStreak` |
|
||||
| `activity.activeDays` | `totalActiveDays` |
|
||||
| `activity.marathonDays` | `marathonDays` |
|
||||
| `sessions.longestMessages` | `longestSessionMessages` |
|
||||
| `firstSession.date` | `firstSessionDate` |
|
||||
| `activity.busiestWeek` | `busiestWeek` |
|
||||
|
||||
---
|
||||
|
||||
## Tips for Fast Generation
|
||||
|
||||
1. **Don't modify scene structure** - Templates have pre-tuned timing and transitions
|
||||
2. **Keep STATS simple** - Just copy numbers from the stats output
|
||||
3. **Skip narrative analysis** - Templates have generic but polished narratives built-in
|
||||
4. **Validate immediately** - Catch typos before signaling completion
|
||||
174
plugins/thinkback/skills/thinkback/player.js
Normal file
174
plugins/thinkback/skills/thinkback/player.js
Normal file
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* 2025 Year in Review - ASCII Animation (Terminal Version)
|
||||
* A celebration of collaboration between Thariq and Claude Code
|
||||
*/
|
||||
|
||||
import { AnimationEngine } from './ascii_anim.js';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { access } from 'fs/promises';
|
||||
import { constants } from 'fs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Helper to safely import optional modules
|
||||
async function safeImport(modulePath, description) {
|
||||
const fullPath = join(__dirname, modulePath);
|
||||
try {
|
||||
await access(fullPath, constants.F_OK);
|
||||
await import(modulePath);
|
||||
} catch (err) {
|
||||
if (err.code === 'ERR_MODULE_NOT_FOUND' || err.code === 'ENOENT') {
|
||||
// Module doesn't exist, skip it silently (it's optional)
|
||||
return;
|
||||
}
|
||||
console.error(`Error loading ${description} (${modulePath}):`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to require import modules
|
||||
async function requireImport(modulePath, description) {
|
||||
try {
|
||||
await import(modulePath);
|
||||
} catch (err) {
|
||||
console.error(`\nFailed to load ${description}:`);
|
||||
console.error(` File: ${modulePath}`);
|
||||
console.error(` Error: ${err.message}`);
|
||||
if (err.stack) {
|
||||
// Show a few lines of stack trace for context
|
||||
const stackLines = err.stack.split('\n').slice(1, 4);
|
||||
console.error(' Stack:', stackLines.join('\n '));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Load dependencies first (they set globalThis)
|
||||
try {
|
||||
await requireImport('./helpers/index.js', 'animation helpers');
|
||||
} catch (err) {
|
||||
console.error('\nAnimation helpers failed to load. Cannot continue.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Optional vibe-specific helpers (may not exist depending on vibe choice)
|
||||
await safeImport('./rpg_class.js', 'RPG class helpers');
|
||||
await safeImport('./tarot.js', 'tarot helpers');
|
||||
|
||||
// Check if year_in_review.js exists
|
||||
const yearInReviewPath = join(__dirname, 'year_in_review.js');
|
||||
try {
|
||||
await access(yearInReviewPath, constants.F_OK);
|
||||
} catch (err) {
|
||||
console.error('\nError: year_in_review.js not found!');
|
||||
console.error('Please run the /thinkback command first to generate your thinkback animation.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Load generated animation data (reads from globalThis, sets globalThis.YearInReviewScenes)
|
||||
try {
|
||||
await import('./year_in_review.js');
|
||||
} catch (err) {
|
||||
console.error('\nFailed to load year_in_review.js:');
|
||||
console.error(` Error: ${err.message}`);
|
||||
if (err.stack) {
|
||||
const stackLines = err.stack.split('\n').slice(1, 6);
|
||||
console.error(' Stack:\n ', stackLines.join('\n '));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate that required exports exist
|
||||
if (!globalThis.YearInReviewScenes) {
|
||||
console.error('\nError: year_in_review.js did not export YearInReviewScenes!');
|
||||
console.error('Make sure the file ends with:');
|
||||
console.error(' globalThis.YearInReviewScenes = { TOTAL_FRAMES, mainAnimation, ... }');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { mainAnimation, TOTAL_FRAMES } = globalThis.YearInReviewScenes;
|
||||
|
||||
if (!mainAnimation || typeof mainAnimation !== 'function') {
|
||||
console.error('\nError: mainAnimation is not defined or not a function!');
|
||||
console.error('Check that year_in_review.js exports a valid mainAnimation function.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!TOTAL_FRAMES || typeof TOTAL_FRAMES !== 'number' || TOTAL_FRAMES <= 0) {
|
||||
console.error('\nError: TOTAL_FRAMES is not defined or invalid!');
|
||||
console.error(` Got: ${TOTAL_FRAMES} (type: ${typeof TOTAL_FRAMES})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const engine = new AnimationEngine();
|
||||
|
||||
// Handle Ctrl+C immediately so user can exit at any time
|
||||
process.on('SIGINT', () => {
|
||||
engine.showCursor();
|
||||
console.log("\n\nThanks for watching! Happy New Year!\n");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Listen for ESC key to exit
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.on('data', (key) => {
|
||||
// ESC key is \x1b (27)
|
||||
if (key[0] === 0x1b && key.length === 1) {
|
||||
engine.showCursor();
|
||||
console.log("\n\nThanks for watching! Happy New Year!\n");
|
||||
process.exit(0);
|
||||
}
|
||||
// Also handle Ctrl+C in raw mode
|
||||
if (key[0] === 0x03) {
|
||||
engine.showCursor();
|
||||
console.log("\n\nThanks for watching! Happy New Year!\n");
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await engine.playAnimation(mainAnimation, TOTAL_FRAMES, 24);
|
||||
} catch (err) {
|
||||
engine.showCursor();
|
||||
console.error("\n" + "=".repeat(60));
|
||||
console.error(" ANIMATION PLAYBACK ERROR");
|
||||
console.error("=".repeat(60));
|
||||
console.error(`\nError: ${err.message}`);
|
||||
if (err.stack) {
|
||||
console.error('\nStack trace:');
|
||||
const stackLines = err.stack.split('\n').slice(1, 10);
|
||||
stackLines.forEach(line => console.error(' ' + line.trim()));
|
||||
}
|
||||
console.error("\nThis error occurred during animation playback.");
|
||||
console.error("Tip: Run 'node validate.js' in the thinkback folder to check your animation.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Hold final frame
|
||||
engine.showCursor();
|
||||
console.log("\n\n" + "=".repeat(60));
|
||||
console.log(" That's a wrap on 2025!");
|
||||
console.log("=".repeat(60) + "\n");
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error("\n" + "=".repeat(60));
|
||||
console.error(" ERROR RUNNING ANIMATION");
|
||||
console.error("=".repeat(60));
|
||||
console.error(`\nError: ${err.message}`);
|
||||
if (err.stack) {
|
||||
console.error('\nStack trace:');
|
||||
const stackLines = err.stack.split('\n').slice(1, 8);
|
||||
stackLines.forEach(line => console.error(' ' + line.trim()));
|
||||
}
|
||||
console.error("\nTip: Run 'node validate.js' in the thinkback folder to check for common issues.");
|
||||
console.error("");
|
||||
process.exit(1);
|
||||
});
|
||||
1207
plugins/thinkback/skills/thinkback/scripts/get_all_stats.js
Normal file
1207
plugins/thinkback/skills/thinkback/scripts/get_all_stats.js
Normal file
File diff suppressed because it is too large
Load Diff
390
plugins/thinkback/skills/thinkback/scripts/helpers_demo.js
Normal file
390
plugins/thinkback/skills/thinkback/scripts/helpers_demo.js
Normal file
@@ -0,0 +1,390 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Helpers Demo Animation
|
||||
* Showcases all the animation helpers in action
|
||||
*
|
||||
* Run with: node helpers_demo.js
|
||||
*/
|
||||
|
||||
import { FrameBuffer, AnimationEngine } from '../ascii_anim.js';
|
||||
import {
|
||||
// Transitions
|
||||
wipeRight, wipeLeft, circleReveal, circleClose, blindsH, blindsV,
|
||||
diagonalWipe, dissolve, checkerboard,
|
||||
// Backgrounds
|
||||
stars, starfield, rain, snow, aurora, waves, fireflies, ripples, staticNoise,
|
||||
// Text effects
|
||||
drawTypewriter, drawWaveText, drawGlitchText, drawScatterText,
|
||||
slideIn, slideOut, drawZoomText, drawFadeInText,
|
||||
// Particles
|
||||
confetti, sparkles, burst, bubbles, hearts, musicNotes, leaves, embers, floatingParticles,
|
||||
// Borders
|
||||
boxBorder, fullscreenBorder, marchingAnts, growBorder, framedTitle, dividerWithText,
|
||||
} from '../helpers/index.js';
|
||||
|
||||
// Scene definitions with timing
|
||||
const FPS = 24;
|
||||
const SCENES = {
|
||||
// Intro
|
||||
INTRO_FADE: { start: 0, end: 48 }, // 2s - stars fade in
|
||||
TITLE: { start: 48, end: 144 }, // 4s - title with effects
|
||||
|
||||
// Transition showcase
|
||||
TRANS_WIPE: { start: 144, end: 216 }, // 3s - wipe transitions
|
||||
TRANS_CIRCLE: { start: 216, end: 288 }, // 3s - circle reveal
|
||||
TRANS_BLINDS: { start: 288, end: 360 }, // 3s - blinds effect
|
||||
|
||||
// Background showcase
|
||||
BG_WEATHER: { start: 360, end: 456 }, // 4s - rain/snow
|
||||
BG_CELESTIAL: { start: 456, end: 552 }, // 4s - aurora/starfield
|
||||
BG_PATTERNS: { start: 552, end: 624 }, // 3s - waves/ripples
|
||||
|
||||
// Text effects showcase
|
||||
TEXT_TYPE: { start: 624, end: 720 }, // 4s - typewriter
|
||||
TEXT_WAVE: { start: 720, end: 816 }, // 4s - wave text
|
||||
TEXT_GLITCH: { start: 816, end: 888 }, // 3s - glitch effect
|
||||
TEXT_SCATTER: { start: 888, end: 984 }, // 4s - scatter/assemble
|
||||
|
||||
// Particles showcase
|
||||
PART_CONFETTI: { start: 984, end: 1080 }, // 4s - confetti celebration
|
||||
PART_NATURE: { start: 1080, end: 1176 }, // 4s - leaves/embers
|
||||
PART_LOVE: { start: 1176, end: 1248 }, // 3s - hearts/music
|
||||
|
||||
// Borders showcase
|
||||
BORDER_GROW: { start: 1248, end: 1344 }, // 4s - growing border
|
||||
BORDER_MARCH: { start: 1344, end: 1416 }, // 3s - marching ants
|
||||
|
||||
// Finale
|
||||
FINALE: { start: 1416, end: 1560 }, // 6s - everything together
|
||||
OUTRO: { start: 1560, end: 1632 }, // 3s - fade out
|
||||
};
|
||||
|
||||
const TOTAL_FRAMES = 1632; // ~68 seconds
|
||||
|
||||
function getScene(frame) {
|
||||
for (const [name, timing] of Object.entries(SCENES)) {
|
||||
if (frame >= timing.start && frame < timing.end) {
|
||||
return {
|
||||
name,
|
||||
progress: (frame - timing.start) / (timing.end - timing.start),
|
||||
localFrame: frame - timing.start,
|
||||
duration: timing.end - timing.start
|
||||
};
|
||||
}
|
||||
}
|
||||
return { name: 'DONE', progress: 1, localFrame: 0, duration: 0 };
|
||||
}
|
||||
|
||||
function easeOut(t) {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
function easeInOut(t) {
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
function renderFrame(fb, frame) {
|
||||
const scene = getScene(frame);
|
||||
const { name, progress: p, localFrame } = scene;
|
||||
const centerY = Math.floor(fb.height / 2);
|
||||
const centerX = Math.floor(fb.width / 2);
|
||||
|
||||
switch (name) {
|
||||
// ========== INTRO ==========
|
||||
case 'INTRO_FADE': {
|
||||
stars(fb, frame, { density: p * 0.008, twinkle: true });
|
||||
if (p > 0.5) {
|
||||
const fadeP = (p - 0.5) * 2;
|
||||
drawFadeInText(fb, centerY, "✦ HELPERS DEMO ✦", fadeP);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'TITLE': {
|
||||
starfield(fb, frame, { speed: 0.3, numStars: 40 });
|
||||
|
||||
// Animated border
|
||||
if (p < 0.3) {
|
||||
growBorder(fb, p / 0.3, { x: 5, y: 3, width: fb.width - 10, height: fb.height - 6, style: 'double' });
|
||||
} else {
|
||||
boxBorder(fb, { x: 5, y: 3, width: fb.width - 10, height: fb.height - 6, style: 'double' });
|
||||
}
|
||||
|
||||
drawWaveText(fb, centerY - 2, "Animation Helpers", frame, { amplitude: 1 });
|
||||
fb.drawCenteredText(centerY + 1, "Transitions · Backgrounds · Text · Particles · Borders");
|
||||
|
||||
sparkles(fb, frame, { density: 0.003 });
|
||||
break;
|
||||
}
|
||||
|
||||
// ========== TRANSITIONS ==========
|
||||
case 'TRANS_WIPE': {
|
||||
stars(fb, frame, { density: 0.005 });
|
||||
|
||||
fb.drawCenteredText(3, "─── TRANSITIONS ───");
|
||||
|
||||
if (p < 0.25) {
|
||||
fb.drawCenteredText(centerY, "Wipe Right →");
|
||||
wipeRight(fb, p * 4, '░');
|
||||
} else if (p < 0.5) {
|
||||
fb.drawCenteredText(centerY, "← Wipe Left");
|
||||
wipeLeft(fb, (p - 0.25) * 4, '▒');
|
||||
} else if (p < 0.75) {
|
||||
fb.drawCenteredText(centerY, "Diagonal Wipe ↘");
|
||||
diagonalWipe(fb, (p - 0.5) * 4, 'tl');
|
||||
} else {
|
||||
fb.drawCenteredText(centerY, "Dissolve Effect");
|
||||
dissolve(fb, (p - 0.75) * 4);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'TRANS_CIRCLE': {
|
||||
fireflies(fb, frame, { count: 6 });
|
||||
|
||||
fb.drawCenteredText(3, "─── CIRCLE TRANSITIONS ───");
|
||||
fb.drawCenteredText(centerY - 1, "Circle Reveal");
|
||||
fb.drawCenteredText(centerY + 1, "Classic Iris Effect");
|
||||
|
||||
if (p < 0.5) {
|
||||
circleReveal(fb, easeOut(p * 2));
|
||||
} else {
|
||||
circleClose(fb, easeOut((p - 0.5) * 2));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'TRANS_BLINDS': {
|
||||
aurora(fb, frame, { intensity: 0.3 });
|
||||
|
||||
fb.drawCenteredText(3, "─── BLINDS TRANSITIONS ───");
|
||||
|
||||
if (p < 0.33) {
|
||||
fb.drawCenteredText(centerY, "Horizontal Blinds");
|
||||
blindsH(fb, p * 3, 6);
|
||||
} else if (p < 0.66) {
|
||||
fb.drawCenteredText(centerY, "Vertical Blinds");
|
||||
blindsV(fb, (p - 0.33) * 3, 10);
|
||||
} else {
|
||||
fb.drawCenteredText(centerY, "Checkerboard Dissolve");
|
||||
checkerboard(fb, (p - 0.66) * 3, 3);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ========== BACKGROUNDS ==========
|
||||
case 'BG_WEATHER': {
|
||||
fb.drawCenteredText(2, "─── WEATHER EFFECTS ───");
|
||||
|
||||
if (p < 0.5) {
|
||||
rain(fb, frame, { density: 0.03, speed: 1.5 });
|
||||
fb.drawCenteredText(centerY, "☔ Rain Effect");
|
||||
} else {
|
||||
snow(fb, frame, { density: 0.015 });
|
||||
fb.drawCenteredText(centerY, "❄ Snow Effect");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'BG_CELESTIAL': {
|
||||
fb.drawCenteredText(2, "─── CELESTIAL EFFECTS ───");
|
||||
|
||||
if (p < 0.5) {
|
||||
starfield(fb, frame, { speed: 1, numStars: 60 });
|
||||
fb.drawCenteredText(centerY, "✦ 3D Starfield");
|
||||
} else {
|
||||
stars(fb, frame, { density: 0.004 });
|
||||
aurora(fb, frame, { intensity: 0.6 });
|
||||
fb.drawCenteredText(centerY, "Northern Lights");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'BG_PATTERNS': {
|
||||
fb.drawCenteredText(2, "─── PATTERN EFFECTS ───");
|
||||
|
||||
if (p < 0.5) {
|
||||
waves(fb, frame, { amplitude: 3, frequency: 0.08, char: '~', baseY: centerY + 5 });
|
||||
waves(fb, frame, { amplitude: 2, frequency: 0.1, char: '≈', baseY: centerY + 3 });
|
||||
fb.drawCenteredText(centerY - 2, "Ocean Waves");
|
||||
} else {
|
||||
ripples(fb, frame, { speed: 0.8 });
|
||||
fb.drawCenteredText(centerY, "Ripple Effect");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ========== TEXT EFFECTS ==========
|
||||
case 'TEXT_TYPE': {
|
||||
stars(fb, frame, { density: 0.003 });
|
||||
boxBorder(fb, { x: 10, y: 5, width: fb.width - 20, height: fb.height - 10, style: 'rounded' });
|
||||
|
||||
fb.drawCenteredText(7, "─ Typewriter Effect ─");
|
||||
drawTypewriter(fb, 15, centerY, "Characters appear one by one...", p, { cursor: '▌' });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'TEXT_WAVE': {
|
||||
floatingParticles(fb, frame, { count: 8, char: '·' });
|
||||
|
||||
fb.drawCenteredText(5, "─ Wave Text Effect ─");
|
||||
drawWaveText(fb, centerY - 1, "Text that flows like water", frame, { amplitude: 2, frequency: 0.25 });
|
||||
drawWaveText(fb, centerY + 2, "Each letter bobs up and down", frame, { amplitude: 1.5, frequency: 0.3 });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'TEXT_GLITCH': {
|
||||
staticNoise(fb, frame, { density: 0.02 });
|
||||
|
||||
fb.drawCenteredText(5, "─ Glitch Effect ─");
|
||||
drawGlitchText(fb, centerX - 12, centerY - 1, "SYSTEM MALFUNCTION", frame, { intensity: 0.15 });
|
||||
drawGlitchText(fb, centerX - 10, centerY + 1, "ERROR: REALITY", frame, { intensity: 0.2 });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'TEXT_SCATTER': {
|
||||
stars(fb, frame, { density: 0.004 });
|
||||
|
||||
fb.drawCenteredText(5, "─ Scatter & Assemble ─");
|
||||
|
||||
if (p < 0.5) {
|
||||
drawScatterText(fb, "LETTERS FLY IN", p * 2, { cy: centerY });
|
||||
} else {
|
||||
fb.drawCenteredText(centerY, "LETTERS FLY IN");
|
||||
sparkles(fb, frame, { density: 0.01 });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ========== PARTICLES ==========
|
||||
case 'PART_CONFETTI': {
|
||||
fb.drawCenteredText(3, "─── CELEBRATION! ───");
|
||||
fb.drawCenteredText(centerY, "🎉 CONFETTI & SPARKLES 🎉");
|
||||
|
||||
confetti(fb, frame, { count: 25 });
|
||||
sparkles(fb, frame, { density: 0.008 });
|
||||
|
||||
// Burst in middle
|
||||
if (localFrame > 24 && localFrame < 60) {
|
||||
burst(fb, frame, { cx: centerX, cy: centerY, count: 16, startFrame: scene.start + 24 });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'PART_NATURE': {
|
||||
fb.drawCenteredText(3, "─── NATURE PARTICLES ───");
|
||||
|
||||
if (p < 0.5) {
|
||||
leaves(fb, frame, { count: 12 });
|
||||
fb.drawCenteredText(centerY, "🍂 Falling Leaves");
|
||||
} else {
|
||||
embers(fb, frame, { count: 15 });
|
||||
fb.drawCenteredText(centerY, "🔥 Rising Embers");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'PART_LOVE': {
|
||||
stars(fb, frame, { density: 0.003 });
|
||||
|
||||
fb.drawCenteredText(3, "─── FLOATING SYMBOLS ───");
|
||||
|
||||
if (p < 0.5) {
|
||||
hearts(fb, frame, { count: 10 });
|
||||
fb.drawCenteredText(centerY, "♥ Floating Hearts ♥");
|
||||
} else {
|
||||
musicNotes(fb, frame, { count: 12 });
|
||||
fb.drawCenteredText(centerY, "♪ Music Notes ♫");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ========== BORDERS ==========
|
||||
case 'BORDER_GROW': {
|
||||
fireflies(fb, frame, { count: 5 });
|
||||
|
||||
fb.drawCenteredText(centerY, "Watch the border grow...");
|
||||
|
||||
growBorder(fb, easeInOut(p), {
|
||||
x: 8, y: 4,
|
||||
width: fb.width - 16, height: fb.height - 8,
|
||||
style: 'double'
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'BORDER_MARCH': {
|
||||
stars(fb, frame, { density: 0.003 });
|
||||
|
||||
marchingAnts(fb, frame, { x: 5, y: 3, width: fb.width - 10, height: fb.height - 6, speed: 0.5 });
|
||||
|
||||
framedTitle(fb, 3, " Marching Ants ", { style: 'single' });
|
||||
fb.drawCenteredText(centerY, "Animated border pattern");
|
||||
dividerWithText(fb, fb.height - 4, " borders.js ");
|
||||
break;
|
||||
}
|
||||
|
||||
// ========== FINALE ==========
|
||||
case 'FINALE': {
|
||||
// Layer everything together
|
||||
starfield(fb, frame, { speed: 0.5, numStars: 30 });
|
||||
aurora(fb, frame, { intensity: 0.3 });
|
||||
|
||||
// Border
|
||||
if (p < 0.2) {
|
||||
growBorder(fb, p * 5, { x: 3, y: 2, width: fb.width - 6, height: fb.height - 4, style: 'double' });
|
||||
} else {
|
||||
boxBorder(fb, { x: 3, y: 2, width: fb.width - 6, height: fb.height - 4, style: 'double' });
|
||||
}
|
||||
|
||||
// Title
|
||||
framedTitle(fb, 2, " HELPERS DEMO ", { style: 'double' });
|
||||
|
||||
// Animated text
|
||||
drawWaveText(fb, centerY - 3, "All Effects Combined!", frame, { amplitude: 1 });
|
||||
|
||||
// Stats
|
||||
fb.drawCenteredText(centerY, "69 Effects Available");
|
||||
fb.drawCenteredText(centerY + 2, "transitions · backgrounds · text · particles · borders");
|
||||
|
||||
// Particles
|
||||
confetti(fb, frame, { count: 10 });
|
||||
sparkles(fb, frame, { density: 0.004 });
|
||||
floatingParticles(fb, frame, { count: 6, char: '◇' });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'OUTRO': {
|
||||
stars(fb, frame, { density: 0.006 * (1 - p) });
|
||||
|
||||
if (p < 0.7) {
|
||||
fb.drawCenteredText(centerY - 1, "Thanks for watching!");
|
||||
fb.drawCenteredText(centerY + 1, "import from './helpers/index.js'");
|
||||
}
|
||||
|
||||
// Fade out with circle close
|
||||
if (p > 0.5) {
|
||||
circleClose(fb, (p - 0.5) * 2);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
fb.drawCenteredText(centerY, "Demo Complete!");
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
async function main() {
|
||||
const engine = new AnimationEngine();
|
||||
|
||||
console.log('Starting Helpers Demo...');
|
||||
console.log(`Duration: ${Math.round(TOTAL_FRAMES / FPS)}s (${TOTAL_FRAMES} frames @ ${FPS}fps)`);
|
||||
console.log('Press Ctrl+C to exit\n');
|
||||
|
||||
await engine.playAnimation(renderFrame, TOTAL_FRAMES, FPS);
|
||||
|
||||
console.log('\nDemo complete!');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
145
plugins/thinkback/skills/thinkback/scripts/test_intro.js
Executable file
145
plugins/thinkback/skills/thinkback/scripts/test_intro.js
Executable file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Test script for iterating on the Thinkback intro scene
|
||||
*
|
||||
* Usage:
|
||||
* node test_intro.js # Play intro animation
|
||||
* node test_intro.js --loop # Loop continuously
|
||||
* node test_intro.js --frame 50 # Show a specific frame
|
||||
* node test_intro.js --slow # Play at half speed
|
||||
* node test_intro.js --static # Show static frame (for thumbnail/still)
|
||||
*/
|
||||
|
||||
import { AnimationEngine, FrameBuffer } from '../ascii_anim.js';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Load helpers
|
||||
await import('../helpers/index.js');
|
||||
|
||||
// Get helpers from globalThis
|
||||
const {
|
||||
SceneManager, stars, sparkles, dissolve,
|
||||
drawThinkbackIntro, CLAUDE_ORANGE,
|
||||
} = globalThis;
|
||||
|
||||
// Parse command line args
|
||||
const args = process.argv.slice(2);
|
||||
const loop = args.includes('--loop');
|
||||
const slow = args.includes('--slow');
|
||||
const frameIdx = args.indexOf('--frame');
|
||||
const singleFrame = frameIdx !== -1 ? parseInt(args[frameIdx + 1], 10) : null;
|
||||
|
||||
// Scene definition for intro only
|
||||
const SCENE_DEFINITIONS = [
|
||||
{ name: 'thinkback_intro', duration: 7, hold: 2 },
|
||||
];
|
||||
|
||||
// Intro options
|
||||
const INTRO_OPTIONS = {
|
||||
year: 2025,
|
||||
};
|
||||
|
||||
const sceneManager = new SceneManager(SCENE_DEFINITIONS);
|
||||
const TOTAL_FRAMES = sceneManager.getTotalFrames();
|
||||
|
||||
function renderIntro(fb, frame) {
|
||||
const scene = sceneManager.getSceneAt(frame);
|
||||
if (!scene) return;
|
||||
|
||||
// Starfield background
|
||||
stars(fb, frame, { density: 0.012, twinkle: true });
|
||||
|
||||
// Calculate overall progress including hold phase
|
||||
let p = 0;
|
||||
if (scene.phase === 'TRANSITION_IN') {
|
||||
p = scene.transitionProgress * 0.1;
|
||||
} else if (scene.phase === 'CONTENT') {
|
||||
p = 0.1 + scene.contentProgress * 0.7;
|
||||
} else if (scene.phase === 'HOLD' || scene.phase === 'TRANSITION_OUT') {
|
||||
p = 1;
|
||||
}
|
||||
|
||||
// Draw the intro
|
||||
drawThinkbackIntro(fb, frame, p, INTRO_OPTIONS);
|
||||
|
||||
// Add sparkles during hold
|
||||
if (scene.phase === 'HOLD' || scene.phase === 'TRANSITION_OUT') {
|
||||
sparkles(fb, frame, { density: 0.004 });
|
||||
}
|
||||
|
||||
// Transition out
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const engine = new AnimationEngine();
|
||||
const fps = slow ? 12 : 24;
|
||||
|
||||
console.log("\n" + "=".repeat(60));
|
||||
console.log(" THINKBACK INTRO TEST");
|
||||
console.log("=".repeat(60));
|
||||
console.log(`\n Total frames: ${TOTAL_FRAMES}`);
|
||||
console.log(` FPS: ${fps}`);
|
||||
console.log(` Duration: ${(TOTAL_FRAMES / fps).toFixed(1)}s`);
|
||||
if (loop) console.log(" Mode: LOOP (Ctrl+C to exit)");
|
||||
if (singleFrame !== null) console.log(` Showing frame: ${singleFrame}`);
|
||||
console.log("");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
engine.showCursor();
|
||||
console.log("\n\nExited.\n");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
try {
|
||||
if (singleFrame !== null) {
|
||||
// Show a single frame and hold
|
||||
engine.clearScreen();
|
||||
engine.hideCursor();
|
||||
const fb = new FrameBuffer(engine.width, engine.height);
|
||||
fb.clear();
|
||||
renderIntro(fb, singleFrame);
|
||||
fb.blit();
|
||||
|
||||
// Show frame info
|
||||
const scene = sceneManager.getSceneAt(singleFrame);
|
||||
console.log(`\nFrame ${singleFrame}/${TOTAL_FRAMES} | Phase: ${scene?.phase || 'N/A'}`);
|
||||
console.log("Press Ctrl+C to exit");
|
||||
|
||||
// Hold indefinitely
|
||||
await new Promise(() => {});
|
||||
} else {
|
||||
// Play animation
|
||||
do {
|
||||
await engine.playAnimation(renderIntro, TOTAL_FRAMES, fps);
|
||||
if (loop) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
} while (loop);
|
||||
}
|
||||
} catch (err) {
|
||||
engine.showCursor();
|
||||
console.error("\nError:", err.message);
|
||||
if (err.stack) {
|
||||
console.error(err.stack.split('\n').slice(1, 5).join('\n'));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
engine.showCursor();
|
||||
console.log("\n\nDone.\n");
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error("Error:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
142
plugins/thinkback/skills/thinkback/scripts/validate.js
Normal file
142
plugins/thinkback/skills/thinkback/scripts/validate.js
Normal file
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Validates thinkback animation files for common issues
|
||||
* Run with: node validate.js
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
let hasErrors = false;
|
||||
|
||||
function error(msg) {
|
||||
console.error(`❌ ${msg}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
function success(msg) {
|
||||
console.log(`✓ ${msg}`);
|
||||
}
|
||||
|
||||
async function validate() {
|
||||
console.log('Validating thinkback animation files...\n');
|
||||
|
||||
// Load helpers first
|
||||
await import('../helpers/index.js');
|
||||
|
||||
// Load the animation
|
||||
await import('../year_in_review.js');
|
||||
|
||||
const { YearInReviewScenes } = globalThis;
|
||||
|
||||
// Check 1: YearInReviewScenes exists
|
||||
if (!YearInReviewScenes) {
|
||||
error('YearInReviewScenes not exported to globalThis');
|
||||
return;
|
||||
}
|
||||
success('YearInReviewScenes exported');
|
||||
|
||||
// Check 2: Required exports exist
|
||||
const requiredExports = ['TOTAL_FRAMES', 'mainAnimation', 'sceneManager'];
|
||||
for (const key of requiredExports) {
|
||||
if (!(key in YearInReviewScenes)) {
|
||||
error(`Missing export: ${key}`);
|
||||
} else {
|
||||
success(`Export exists: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check 3: TOTAL_FRAMES is reasonable
|
||||
const { TOTAL_FRAMES, sceneManager } = YearInReviewScenes;
|
||||
if (typeof TOTAL_FRAMES !== 'number' || TOTAL_FRAMES < 24) {
|
||||
error(`TOTAL_FRAMES is invalid: ${TOTAL_FRAMES} (expected >= 24)`);
|
||||
} else {
|
||||
success(`TOTAL_FRAMES = ${TOTAL_FRAMES} (${(TOTAL_FRAMES / 24).toFixed(1)}s at 24fps)`);
|
||||
}
|
||||
|
||||
// Check 4: Scene names are defined (not undefined)
|
||||
if (sceneManager && sceneManager.scenes) {
|
||||
const undefinedScenes = sceneManager.scenes.filter(s => !s.name);
|
||||
if (undefinedScenes.length > 0) {
|
||||
error(`${undefinedScenes.length} scenes have undefined names - did you use 'id' instead of 'name' in SCENE_DEFINITIONS?`);
|
||||
} else {
|
||||
success(`All ${sceneManager.scenes.length} scenes have valid names`);
|
||||
}
|
||||
|
||||
// List scene names for reference
|
||||
console.log('\n Scenes:');
|
||||
for (const scene of sceneManager.scenes) {
|
||||
console.log(` - ${scene.name} (${scene.duration}s, frames ${scene.startFrame}-${scene.endFrame})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check 5: Test render function with a few frames
|
||||
const { mainAnimation } = YearInReviewScenes;
|
||||
if (typeof mainAnimation === 'function') {
|
||||
// Create a mock framebuffer
|
||||
const mockFb = {
|
||||
width: 80,
|
||||
height: 24,
|
||||
drawText: () => {},
|
||||
drawCenteredText: () => {},
|
||||
drawLargeText: () => {},
|
||||
drawLargeTextCentered: () => {},
|
||||
setPixel: () => {},
|
||||
drawBox: () => {},
|
||||
drawCircle: () => {},
|
||||
clear: () => {},
|
||||
getPixel: () => ' ',
|
||||
};
|
||||
|
||||
const testFrames = [0, Math.floor(TOTAL_FRAMES / 2), TOTAL_FRAMES - 1];
|
||||
let renderErrors = [];
|
||||
|
||||
for (const frame of testFrames) {
|
||||
try {
|
||||
mainAnimation(mockFb, frame);
|
||||
} catch (e) {
|
||||
renderErrors.push({ frame, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (renderErrors.length > 0) {
|
||||
for (const { frame, error: msg } of renderErrors) {
|
||||
error(`Render error at frame ${frame}: ${msg}`);
|
||||
}
|
||||
} else {
|
||||
success(`Render function executes without errors`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check 6: Verify scene transitions cover all frames
|
||||
if (sceneManager) {
|
||||
let coveredFrames = 0;
|
||||
for (const scene of sceneManager.scenes) {
|
||||
coveredFrames += scene.durationFrames;
|
||||
}
|
||||
if (coveredFrames !== TOTAL_FRAMES) {
|
||||
error(`Scene frames (${coveredFrames}) don't match TOTAL_FRAMES (${TOTAL_FRAMES})`);
|
||||
} else {
|
||||
success(`All frames are covered by scenes`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(50));
|
||||
if (hasErrors) {
|
||||
console.log('❌ Validation FAILED - fix errors above');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('✓ Validation PASSED');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
validate().catch(err => {
|
||||
error(`Validation crashed: ${err.message}`);
|
||||
console.error(err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Thinkback - Awards Show Vibe Template
|
||||
*
|
||||
* Glamorous awards ceremony style with dramatic reveals.
|
||||
* Stats presented as award categories with envelope reveals and trophies.
|
||||
*
|
||||
* INJECTION POINTS (search for "INJECT:"):
|
||||
* - STATS object: Fill in all numeric/string values
|
||||
* - TOP_REPOS array: Fill in top 3 repos with names and commits
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// HELPER DESTRUCTURING
|
||||
// =============================================================================
|
||||
|
||||
const {
|
||||
// Scene system
|
||||
SceneManager, staggeredReveal, easeInOut, easeOut, animateCounter,
|
||||
// Backgrounds
|
||||
stars, gradient,
|
||||
// Particles
|
||||
confetti, sparkles, glitter,
|
||||
// Transitions
|
||||
dissolve, circleReveal, fade, curtainReveal, spotlightReveal,
|
||||
// Text effects
|
||||
drawTypewriterCentered, slideIn, drawZoomText, drawFadeInText,
|
||||
// Claude branding
|
||||
drawThinkbackIntro, CLAUDE_ORANGE,
|
||||
// Awards effects
|
||||
trophyDisplay, awardBadge, envelopeReveal, categoryTitle,
|
||||
winnerAnnouncement, applauseMeter, standingOvation, redCarpetBorder,
|
||||
spotlightText,
|
||||
} = globalThis;
|
||||
|
||||
// =============================================================================
|
||||
// INJECT: STATS - Fill in all values below
|
||||
// =============================================================================
|
||||
|
||||
const STATS = {
|
||||
userName: '', // INJECT: User's name
|
||||
year: 2025,
|
||||
totalCommits: 0, // INJECT: Total commits
|
||||
totalSessions: 0, // INJECT: Total sessions
|
||||
totalMessages: 0, // INJECT: Total messages
|
||||
repoCount: 0, // INJECT: Number of repos
|
||||
peakHour: '', // INJECT: e.g., '12am', '3pm'
|
||||
peakDay: '', // INJECT: e.g., 'Wed', 'Mon'
|
||||
nightOwlPercent: 0, // INJECT: Percentage (0-100)
|
||||
earlyBirdPercent: 0, // INJECT: Percentage (0-100)
|
||||
weekendPercent: 0, // INJECT: Percentage (0-100)
|
||||
longestStreak: 0, // INJECT: Days
|
||||
currentStreak: 0, // INJECT: Days
|
||||
totalActiveDays: 0, // INJECT: Days
|
||||
marathonDays: 0, // INJECT: Days with 100+ messages
|
||||
longestSessionMessages: 0, // INJECT: Messages in longest session
|
||||
firstSessionDate: '', // INJECT: 'YYYY-MM-DD'
|
||||
busiestWeek: '', // INJECT: e.g., 'Nov 24-30, 2025'
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// INJECT: TOP REPOS - Fill in top 3 repos
|
||||
// =============================================================================
|
||||
|
||||
const TOP_REPOS = [
|
||||
{ name: '', commits: 0 }, // INJECT: #1 repo name and commits
|
||||
{ name: '', commits: 0 }, // INJECT: #2 repo name and commits
|
||||
{ name: '', commits: 0 }, // INJECT: #3 repo name and commits
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// SCENE DEFINITIONS (pre-configured for awards show vibe)
|
||||
// =============================================================================
|
||||
|
||||
const SCENE_DEFINITIONS = [
|
||||
{ name: 'thinkback_intro', duration: 7, hold: 2 },
|
||||
{ name: 'opening_ceremony', duration: 7, hold: 2.5 },
|
||||
{ name: 'best_streak', duration: 8, hold: 3 },
|
||||
{ name: 'dedication_award', duration: 8, hold: 3 },
|
||||
{ name: 'lifetime_achievement', duration: 8, hold: 3 },
|
||||
{ name: 'finale', duration: 6, hold: 2 },
|
||||
];
|
||||
|
||||
const sceneManager = new SceneManager(SCENE_DEFINITIONS);
|
||||
const TOTAL_FRAMES = sceneManager.getTotalFrames();
|
||||
|
||||
// =============================================================================
|
||||
// USER INTRO
|
||||
// =============================================================================
|
||||
|
||||
const USER_INTRO = {
|
||||
userName: STATS.userName,
|
||||
year: STATS.year,
|
||||
tagline: 'your year with Claude Code',
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// SCENE RENDERERS
|
||||
// =============================================================================
|
||||
|
||||
function renderThinkbackIntro(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.012, twinkle: true });
|
||||
|
||||
let p = 0;
|
||||
if (scene.phase === 'TRANSITION_IN') {
|
||||
p = scene.transitionProgress * 0.1;
|
||||
} else if (scene.phase === 'CONTENT') {
|
||||
p = 0.1 + scene.contentProgress * 0.7;
|
||||
} else {
|
||||
p = 1;
|
||||
}
|
||||
|
||||
drawThinkbackIntro(fb, frame, p, USER_INTRO);
|
||||
|
||||
if (scene.phase === 'HOLD' || scene.phase === 'TRANSITION_OUT') {
|
||||
sparkles(fb, frame, { density: 0.004 });
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
}
|
||||
|
||||
function renderOpeningCeremony(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.008, twinkle: true });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
// Curtain reveal effect
|
||||
if (p < 0.4) {
|
||||
curtainReveal(fb, p / 0.4);
|
||||
}
|
||||
|
||||
// Red carpet border
|
||||
if (p > 0.3) {
|
||||
redCarpetBorder(fb, Math.min(1, (p - 0.3) / 0.3));
|
||||
}
|
||||
|
||||
// Welcome text
|
||||
if (p > 0.4) {
|
||||
const textP = Math.min(1, (p - 0.4) / 0.3);
|
||||
spotlightText(fb, 8, 'Welcome to the', frame, { intensity: textP });
|
||||
spotlightText(fb, 10, `${STATS.year} Claude Code Awards`, frame, { intensity: textP });
|
||||
}
|
||||
|
||||
// Presenter intro
|
||||
if (p > 0.7) {
|
||||
fb.drawCenteredText(15, `Honoring ${STATS.userName}`);
|
||||
}
|
||||
|
||||
// Sparkles
|
||||
if (p > 0.5) {
|
||||
glitter(fb, frame, { density: 0.003 });
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
spotlightReveal(fb, 1 - scene.transitionProgress);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBestStreak(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.005 });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
// Category title
|
||||
if (p > 0.1) {
|
||||
categoryTitle(fb, 5, 'BEST STREAK AWARD', Math.min(1, (p - 0.1) / 0.2), frame);
|
||||
}
|
||||
|
||||
// Envelope reveal
|
||||
if (p > 0.3 && p < 0.7) {
|
||||
const envelopeP = (p - 0.3) / 0.4;
|
||||
envelopeReveal(fb, `${STATS.longestStreak} Days`, envelopeP, frame, {
|
||||
y: 12,
|
||||
});
|
||||
}
|
||||
|
||||
// Winner announcement
|
||||
if (p > 0.7) {
|
||||
const winP = Math.min(1, (p - 0.7) / 0.2);
|
||||
const count = animateCounter(STATS.longestStreak, winP);
|
||||
fb.drawCenteredText(12, `${count} consecutive days!`);
|
||||
|
||||
if (winP > 0.5) {
|
||||
fb.drawCenteredText(15, 'of showing up');
|
||||
}
|
||||
|
||||
// Trophy
|
||||
if (winP > 0.3) {
|
||||
trophyDisplay(fb, Math.floor(fb.width / 2), 20, {
|
||||
style: 'simple',
|
||||
label: 'STREAK',
|
||||
}, winP, frame);
|
||||
}
|
||||
|
||||
// Applause
|
||||
applauseMeter(fb, fb.height - 5, winP, frame);
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDedicationAward(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.005 });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
// Category title
|
||||
if (p > 0.1) {
|
||||
categoryTitle(fb, 5, 'DEDICATION AWARD', Math.min(1, (p - 0.1) / 0.2), frame);
|
||||
}
|
||||
|
||||
// Stats reveal
|
||||
if (p > 0.3) {
|
||||
const reveal = staggeredReveal(3, 0.4);
|
||||
|
||||
const stats = [
|
||||
`${STATS.totalActiveDays} active days`,
|
||||
`${STATS.totalSessions.toLocaleString()} sessions`,
|
||||
`${STATS.totalMessages.toLocaleString()} messages`,
|
||||
];
|
||||
|
||||
stats.forEach((stat, i) => {
|
||||
const itemP = reveal(p - 0.3, i);
|
||||
if (itemP > 0) {
|
||||
const y = 11 + i * 2;
|
||||
awardBadge(fb, Math.floor(fb.width / 2) - 25, y, {
|
||||
label: stat,
|
||||
style: i === 0 ? 'gold' : 'silver',
|
||||
}, itemP);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Marathon days highlight
|
||||
if (p > 0.7 && STATS.marathonDays > 0) {
|
||||
const marathonP = Math.min(1, (p - 0.7) / 0.2);
|
||||
fb.drawCenteredText(20, `${STATS.marathonDays} marathon days (100+ messages)`);
|
||||
|
||||
if (marathonP > 0.5) {
|
||||
sparkles(fb, frame, { density: 0.005 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLifetimeAchievement(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.006, twinkle: true });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
// Grand category title
|
||||
if (p > 0.1) {
|
||||
categoryTitle(fb, 4, 'LIFETIME ACHIEVEMENT', Math.min(1, (p - 0.1) / 0.2), frame, {
|
||||
style: 'grand',
|
||||
});
|
||||
}
|
||||
|
||||
// Grand trophy
|
||||
if (p > 0.3) {
|
||||
const trophyP = Math.min(1, (p - 0.3) / 0.3);
|
||||
trophyDisplay(fb, Math.floor(fb.width / 2), 10, {
|
||||
style: 'grand',
|
||||
label: STATS.year.toString(),
|
||||
}, trophyP, frame);
|
||||
}
|
||||
|
||||
// Winner name with spotlight
|
||||
if (p > 0.6) {
|
||||
const nameP = Math.min(1, (p - 0.6) / 0.2);
|
||||
winnerAnnouncement(fb, STATS.userName, nameP, frame, {
|
||||
y: 22,
|
||||
});
|
||||
}
|
||||
|
||||
// Summary stats
|
||||
if (p > 0.8) {
|
||||
fb.drawCenteredText(28, `${STATS.totalCommits} commits across ${STATS.repoCount} projects`);
|
||||
}
|
||||
|
||||
// Celebration
|
||||
if (p > 0.7) {
|
||||
glitter(fb, frame, { density: 0.004 });
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
}
|
||||
|
||||
function renderFinale(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.01, twinkle: true });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
// Standing ovation effect
|
||||
if (p > 0.2) {
|
||||
standingOvation(fb, frame, { intensity: Math.min(1, (p - 0.2) / 0.3) });
|
||||
}
|
||||
|
||||
// Thank you message
|
||||
if (p > 0.3) {
|
||||
spotlightText(fb, Math.floor(fb.height / 2) - 2, 'Thank you for an amazing year!', frame);
|
||||
}
|
||||
|
||||
if (p > 0.6) {
|
||||
fb.drawCenteredText(Math.floor(fb.height / 2) + 2, 'See you at next year\'s ceremony!');
|
||||
}
|
||||
|
||||
// Confetti celebration
|
||||
if (p > 0.4) {
|
||||
confetti(fb, frame, { count: 20 });
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
const fadeP = 1 - scene.transitionProgress;
|
||||
for (let y = 0; y < fb.height; y++) {
|
||||
for (let x = 0; x < fb.width; x++) {
|
||||
if (Math.random() < fadeP * 0.5) {
|
||||
fb.setPixel(x, y, ' ', -100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SCENE MAPPING
|
||||
// =============================================================================
|
||||
|
||||
const SCENE_RENDERERS = {
|
||||
thinkback_intro: renderThinkbackIntro,
|
||||
opening_ceremony: renderOpeningCeremony,
|
||||
best_streak: renderBestStreak,
|
||||
dedication_award: renderDedicationAward,
|
||||
lifetime_achievement: renderLifetimeAchievement,
|
||||
finale: renderFinale,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN ANIMATION
|
||||
// =============================================================================
|
||||
|
||||
function mainAnimation(fb, frame) {
|
||||
const scene = sceneManager.getSceneAt(frame);
|
||||
|
||||
if (!scene) {
|
||||
fb.drawCenteredText(Math.floor(fb.height / 2), 'Animation complete');
|
||||
return;
|
||||
}
|
||||
|
||||
const renderer = SCENE_RENDERERS[scene.name];
|
||||
if (renderer) {
|
||||
renderer(fb, frame, scene);
|
||||
}
|
||||
}
|
||||
|
||||
function getSceneName(frame) {
|
||||
const scene = sceneManager.getSceneAt(frame);
|
||||
if (!scene) return 'Complete';
|
||||
|
||||
const names = {
|
||||
thinkback_intro: 'Think Back',
|
||||
opening_ceremony: 'Opening',
|
||||
best_streak: 'Best Streak',
|
||||
dedication_award: 'Dedication',
|
||||
lifetime_achievement: 'Lifetime',
|
||||
finale: 'Finale',
|
||||
};
|
||||
|
||||
return names[scene.name] || scene.name;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXPORTS
|
||||
// =============================================================================
|
||||
|
||||
globalThis.YearInReviewScenes = {
|
||||
TOTAL_FRAMES,
|
||||
mainAnimation,
|
||||
getSceneName,
|
||||
sceneManager,
|
||||
};
|
||||
300
plugins/thinkback/skills/thinkback/templates/cozy-template.js
Normal file
300
plugins/thinkback/skills/thinkback/templates/cozy-template.js
Normal file
@@ -0,0 +1,300 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Thinkback - Cozy Vibe Template
|
||||
*
|
||||
* Warm, gentle, and comforting. Like a bedtime story.
|
||||
* Stats are revealed softly with typewriter effects and gentle backgrounds.
|
||||
*
|
||||
* INJECTION POINTS (search for "INJECT:"):
|
||||
* - STATS object: Fill in all numeric/string values
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// HELPER DESTRUCTURING
|
||||
// =============================================================================
|
||||
|
||||
const {
|
||||
// Scene system
|
||||
SceneManager, staggeredReveal, easeInOut, easeOut, animateCounter,
|
||||
// Backgrounds
|
||||
stars, fireflies, dust,
|
||||
// Particles
|
||||
floatingParticles, sparkles,
|
||||
// Transitions
|
||||
dissolve, circleReveal, fade,
|
||||
// Text effects
|
||||
drawTypewriterCentered, drawFadeInText,
|
||||
// Claude branding
|
||||
drawThinkbackIntro, CLAUDE_ORANGE,
|
||||
} = globalThis;
|
||||
|
||||
// =============================================================================
|
||||
// INJECT: STATS - Fill in all values below
|
||||
// =============================================================================
|
||||
|
||||
const STATS = {
|
||||
userName: '', // INJECT: User's name
|
||||
year: 2025,
|
||||
totalCommits: 0, // INJECT: Total commits
|
||||
totalSessions: 0, // INJECT: Total sessions
|
||||
totalMessages: 0, // INJECT: Total messages
|
||||
repoCount: 0, // INJECT: Number of repos
|
||||
peakHour: '', // INJECT: e.g., '12am', '3pm'
|
||||
peakDay: '', // INJECT: e.g., 'Wed', 'Mon'
|
||||
nightOwlPercent: 0, // INJECT: Percentage (0-100)
|
||||
earlyBirdPercent: 0, // INJECT: Percentage (0-100)
|
||||
weekendPercent: 0, // INJECT: Percentage (0-100)
|
||||
longestStreak: 0, // INJECT: Days
|
||||
currentStreak: 0, // INJECT: Days
|
||||
totalActiveDays: 0, // INJECT: Days
|
||||
marathonDays: 0, // INJECT: Days with 100+ messages
|
||||
longestSessionMessages: 0, // INJECT: Messages in longest session
|
||||
firstSessionDate: '', // INJECT: 'YYYY-MM-DD'
|
||||
busiestWeek: '', // INJECT: e.g., 'Nov 24-30, 2025'
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// SCENE DEFINITIONS (pre-configured for cozy vibe)
|
||||
// =============================================================================
|
||||
|
||||
const SCENE_DEFINITIONS = [
|
||||
{ name: 'thinkback_intro', duration: 7, hold: 2 },
|
||||
{ name: 'your_rhythm', duration: 8, hold: 3 },
|
||||
{ name: 'the_streak', duration: 7, hold: 2.5 },
|
||||
{ name: 'quiet_moments', duration: 6, hold: 2 },
|
||||
{ name: 'closing', duration: 5, hold: 2 },
|
||||
];
|
||||
|
||||
const sceneManager = new SceneManager(SCENE_DEFINITIONS);
|
||||
const TOTAL_FRAMES = sceneManager.getTotalFrames();
|
||||
|
||||
// =============================================================================
|
||||
// USER INTRO
|
||||
// =============================================================================
|
||||
|
||||
const USER_INTRO = {
|
||||
userName: STATS.userName,
|
||||
year: STATS.year,
|
||||
tagline: 'your year with Claude Code',
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// SCENE RENDERERS
|
||||
// =============================================================================
|
||||
|
||||
function renderThinkbackIntro(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.012, twinkle: true });
|
||||
|
||||
let p = 0;
|
||||
if (scene.phase === 'TRANSITION_IN') {
|
||||
p = scene.transitionProgress * 0.1;
|
||||
} else if (scene.phase === 'CONTENT') {
|
||||
p = 0.1 + scene.contentProgress * 0.7;
|
||||
} else {
|
||||
p = 1;
|
||||
}
|
||||
|
||||
drawThinkbackIntro(fb, frame, p, USER_INTRO);
|
||||
|
||||
if (scene.phase === 'HOLD' || scene.phase === 'TRANSITION_OUT') {
|
||||
sparkles(fb, frame, { density: 0.004 });
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
}
|
||||
|
||||
function renderYourRhythm(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.006, twinkle: true });
|
||||
floatingParticles(fb, frame, { count: 8, char: '◇', speed: 0.5 });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
// Title
|
||||
if (p > 0.1) {
|
||||
drawTypewriterCentered(fb, 5, '~ Your Rhythm ~', Math.min(1, (p - 0.1) / 0.2));
|
||||
}
|
||||
|
||||
// Time stats - dynamically built based on available data
|
||||
const reveal = staggeredReveal(4, 0.4);
|
||||
|
||||
const timeStats = [
|
||||
`You were most active at ${STATS.peakHour}`,
|
||||
`${STATS.peakDay}s were your favorite coding day`,
|
||||
STATS.nightOwlPercent > 15 ? `Night owl: ${STATS.nightOwlPercent.toFixed(1)}% of sessions after 10pm` : null,
|
||||
STATS.earlyBirdPercent > 10 ? `Early bird: ${STATS.earlyBirdPercent.toFixed(1)}% of sessions before 8am` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
timeStats.forEach((stat, i) => {
|
||||
const itemP = reveal(p, i);
|
||||
if (itemP > 0) {
|
||||
const y = 10 + i * 2;
|
||||
const visibleChars = Math.floor(stat.length * itemP);
|
||||
fb.drawCenteredText(y, stat.slice(0, visibleChars));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTheStreak(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.004 });
|
||||
fireflies(fb, frame, { count: 6 });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
if (p > 0.1) {
|
||||
const titleP = Math.min(1, (p - 0.1) / 0.2);
|
||||
drawTypewriterCentered(fb, 5, '~ The Streak ~', titleP);
|
||||
}
|
||||
|
||||
if (p > 0.3 && STATS.longestStreak > 0) {
|
||||
const streakP = Math.min(1, (p - 0.3) / 0.3);
|
||||
const streakCount = animateCounter(STATS.longestStreak, streakP);
|
||||
fb.drawCenteredText(9, `${streakCount} days in a row`);
|
||||
|
||||
if (streakP > 0.5) {
|
||||
fb.drawCenteredText(11, 'you showed up');
|
||||
}
|
||||
}
|
||||
|
||||
if (p > 0.6) {
|
||||
const msgP = Math.min(1, (p - 0.6) / 0.3);
|
||||
const msg = 'take a moment to appreciate that';
|
||||
const visibleChars = Math.floor(msg.length * msgP);
|
||||
fb.drawCenteredText(15, msg.slice(0, visibleChars));
|
||||
}
|
||||
|
||||
if (STATS.currentStreak > 0 && p > 0.8) {
|
||||
fb.drawCenteredText(18, `(current streak: ${STATS.currentStreak} days)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
}
|
||||
|
||||
function renderQuietMoments(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.008, twinkle: true });
|
||||
dust(fb, frame, { density: 0.002 });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
if (p > 0.1) {
|
||||
drawTypewriterCentered(fb, 5, '~ Quiet Moments ~', Math.min(1, (p - 0.1) / 0.2));
|
||||
}
|
||||
|
||||
const reveal = staggeredReveal(3, 0.5);
|
||||
|
||||
const moments = [
|
||||
`${STATS.totalActiveDays} days you chose to build`,
|
||||
`${STATS.totalMessages.toLocaleString()} messages exchanged`,
|
||||
`across ${STATS.repoCount} projects`,
|
||||
];
|
||||
|
||||
moments.forEach((moment, i) => {
|
||||
const itemP = reveal(p, i);
|
||||
if (itemP > 0) {
|
||||
const y = 10 + i * 2;
|
||||
fb.drawCenteredText(y, moment, 0, itemP < 1 ? '#666666' : undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
}
|
||||
|
||||
function renderClosing(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.01, twinkle: true });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
if (p > 0.2) {
|
||||
fb.drawCenteredText(Math.floor(fb.height / 2) - 3, 'Thank you for this year');
|
||||
}
|
||||
|
||||
if (p > 0.5) {
|
||||
fb.drawCenteredText(Math.floor(fb.height / 2) + 1, 'Rest well. You\'ve earned it.');
|
||||
}
|
||||
|
||||
sparkles(fb, frame, { density: 0.005 });
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
const fadeP = 1 - scene.transitionProgress;
|
||||
for (let y = 0; y < fb.height; y++) {
|
||||
for (let x = 0; x < fb.width; x++) {
|
||||
if (Math.random() < fadeP * 0.5) {
|
||||
fb.setPixel(x, y, ' ', -100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SCENE MAPPING
|
||||
// =============================================================================
|
||||
|
||||
const SCENE_RENDERERS = {
|
||||
thinkback_intro: renderThinkbackIntro,
|
||||
your_rhythm: renderYourRhythm,
|
||||
the_streak: renderTheStreak,
|
||||
quiet_moments: renderQuietMoments,
|
||||
closing: renderClosing,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN ANIMATION
|
||||
// =============================================================================
|
||||
|
||||
function mainAnimation(fb, frame) {
|
||||
const scene = sceneManager.getSceneAt(frame);
|
||||
|
||||
if (!scene) {
|
||||
fb.drawCenteredText(Math.floor(fb.height / 2), 'Animation complete');
|
||||
return;
|
||||
}
|
||||
|
||||
const renderer = SCENE_RENDERERS[scene.name];
|
||||
if (renderer) {
|
||||
renderer(fb, frame, scene);
|
||||
}
|
||||
}
|
||||
|
||||
function getSceneName(frame) {
|
||||
const scene = sceneManager.getSceneAt(frame);
|
||||
if (!scene) return 'Complete';
|
||||
|
||||
const names = {
|
||||
thinkback_intro: 'Think Back',
|
||||
your_rhythm: 'Your Rhythm',
|
||||
the_streak: 'The Streak',
|
||||
quiet_moments: 'Quiet Moments',
|
||||
closing: 'Closing',
|
||||
};
|
||||
|
||||
return names[scene.name] || scene.name;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXPORTS
|
||||
// =============================================================================
|
||||
|
||||
globalThis.YearInReviewScenes = {
|
||||
TOTAL_FRAMES,
|
||||
mainAnimation,
|
||||
getSceneName,
|
||||
sceneManager,
|
||||
};
|
||||
@@ -0,0 +1,412 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Thinkback - Morning News Vibe Template
|
||||
*
|
||||
* Upbeat, professional news broadcast style.
|
||||
* Stats revealed as "breaking news" with tickers and dramatic counters.
|
||||
*
|
||||
* INJECTION POINTS (search for "INJECT:"):
|
||||
* - STATS object: Fill in all numeric/string values
|
||||
* - TOP_REPOS array: Fill in top 3 repos with names and commits
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// HELPER DESTRUCTURING
|
||||
// =============================================================================
|
||||
|
||||
const {
|
||||
// Scene system
|
||||
SceneManager, staggeredReveal, easeInOut, easeOut, animateCounter,
|
||||
// Backgrounds
|
||||
stars, gradient,
|
||||
// Particles
|
||||
confetti, sparkles, burst,
|
||||
// Transitions
|
||||
dissolve, wipeRight, wipeLeft, wipeDown, blindsH,
|
||||
// Text effects
|
||||
drawTypewriterCentered, slideIn,
|
||||
// Claude branding
|
||||
drawThinkbackIntro, CLAUDE_ORANGE,
|
||||
// News effects
|
||||
lowerThird, tickerTape, breakingBanner, liveIndicator,
|
||||
segmentTitle, statCounter, forecastBar, splitWipe,
|
||||
headlineCrawl, countdownReveal,
|
||||
} = globalThis;
|
||||
|
||||
// =============================================================================
|
||||
// INJECT: STATS - Fill in all values below
|
||||
// =============================================================================
|
||||
|
||||
const STATS = {
|
||||
userName: '', // INJECT: User's name
|
||||
year: 2025,
|
||||
totalCommits: 0, // INJECT: Total commits
|
||||
totalSessions: 0, // INJECT: Total sessions
|
||||
totalMessages: 0, // INJECT: Total messages
|
||||
repoCount: 0, // INJECT: Number of repos
|
||||
peakHour: '', // INJECT: e.g., '12am', '3pm'
|
||||
peakDay: '', // INJECT: e.g., 'Wed', 'Mon'
|
||||
nightOwlPercent: 0, // INJECT: Percentage (0-100)
|
||||
earlyBirdPercent: 0, // INJECT: Percentage (0-100)
|
||||
weekendPercent: 0, // INJECT: Percentage (0-100)
|
||||
longestStreak: 0, // INJECT: Days
|
||||
currentStreak: 0, // INJECT: Days
|
||||
totalActiveDays: 0, // INJECT: Days
|
||||
marathonDays: 0, // INJECT: Days with 100+ messages
|
||||
longestSessionMessages: 0, // INJECT: Messages in longest session
|
||||
firstSessionDate: '', // INJECT: 'YYYY-MM-DD'
|
||||
busiestWeek: '', // INJECT: e.g., 'Nov 24-30, 2025'
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// INJECT: TOP REPOS - Fill in top 3 repos
|
||||
// =============================================================================
|
||||
|
||||
const TOP_REPOS = [
|
||||
{ name: '', commits: 0 }, // INJECT: #1 repo name and commits
|
||||
{ name: '', commits: 0 }, // INJECT: #2 repo name and commits
|
||||
{ name: '', commits: 0 }, // INJECT: #3 repo name and commits
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// SCENE DEFINITIONS (pre-configured for morning news vibe)
|
||||
// =============================================================================
|
||||
|
||||
const SCENE_DEFINITIONS = [
|
||||
{ name: 'thinkback_intro', duration: 7, hold: 2 },
|
||||
{ name: 'breaking_news', duration: 8, hold: 2.5 },
|
||||
{ name: 'headline_stats', duration: 8, hold: 3 },
|
||||
{ name: 'coding_forecast', duration: 7, hold: 2.5 },
|
||||
{ name: 'top_stories', duration: 7, hold: 2.5 },
|
||||
{ name: 'closing', duration: 5, hold: 2 },
|
||||
];
|
||||
|
||||
const sceneManager = new SceneManager(SCENE_DEFINITIONS);
|
||||
const TOTAL_FRAMES = sceneManager.getTotalFrames();
|
||||
|
||||
// =============================================================================
|
||||
// USER INTRO
|
||||
// =============================================================================
|
||||
|
||||
const USER_INTRO = {
|
||||
userName: STATS.userName,
|
||||
year: STATS.year,
|
||||
tagline: 'your year with Claude Code',
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// SCENE RENDERERS
|
||||
// =============================================================================
|
||||
|
||||
function renderThinkbackIntro(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.012, twinkle: true });
|
||||
|
||||
let p = 0;
|
||||
if (scene.phase === 'TRANSITION_IN') {
|
||||
p = scene.transitionProgress * 0.1;
|
||||
} else if (scene.phase === 'CONTENT') {
|
||||
p = 0.1 + scene.contentProgress * 0.7;
|
||||
} else {
|
||||
p = 1;
|
||||
}
|
||||
|
||||
drawThinkbackIntro(fb, frame, p, USER_INTRO);
|
||||
|
||||
if (scene.phase === 'HOLD' || scene.phase === 'TRANSITION_OUT') {
|
||||
sparkles(fb, frame, { density: 0.004 });
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBreakingNews(fb, frame, scene) {
|
||||
// Dark gradient background
|
||||
gradient(fb, { direction: 'vertical', invert: true });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
// Breaking banner at top
|
||||
if (p > 0.1) {
|
||||
breakingBanner(fb, 3, 'BREAKING NEWS', frame, {
|
||||
flash: true,
|
||||
width: 50,
|
||||
});
|
||||
}
|
||||
|
||||
// Live indicator
|
||||
if (p > 0.2) {
|
||||
liveIndicator(fb, 70, 3, frame, { blink: true });
|
||||
}
|
||||
|
||||
// Main headline - biggest stat
|
||||
if (p > 0.3) {
|
||||
const headlineP = Math.min(1, (p - 0.3) / 0.3);
|
||||
segmentTitle(fb, 10, `${STATS.userName}'s Year in Review`, headlineP, {
|
||||
style: 'double',
|
||||
});
|
||||
}
|
||||
|
||||
// Big stat counter
|
||||
if (p > 0.5) {
|
||||
const statP = Math.min(1, (p - 0.5) / 0.3);
|
||||
const count = animateCounter(STATS.totalMessages, statP);
|
||||
fb.drawCenteredText(15, `${count.toLocaleString()}`);
|
||||
if (statP > 0.5) {
|
||||
fb.drawCenteredText(17, 'messages exchanged');
|
||||
}
|
||||
}
|
||||
|
||||
// Ticker at bottom
|
||||
if (p > 0.4) {
|
||||
const tickerItems = [
|
||||
`${STATS.totalCommits} commits`,
|
||||
`${STATS.repoCount} projects`,
|
||||
`${STATS.totalActiveDays} active days`,
|
||||
`Peak hour: ${STATS.peakHour}`,
|
||||
];
|
||||
tickerTape(fb, fb.height - 3, tickerItems, frame, { speed: 0.5 });
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
wipeRight(fb, scene.transitionProgress, '░');
|
||||
}
|
||||
}
|
||||
|
||||
function renderHeadlineStats(fb, frame, scene) {
|
||||
gradient(fb, { direction: 'vertical', invert: true });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
// Section header
|
||||
if (p > 0.1) {
|
||||
segmentTitle(fb, 3, 'TOP HEADLINES', Math.min(1, (p - 0.1) / 0.2), {
|
||||
style: 'single',
|
||||
});
|
||||
}
|
||||
|
||||
// Stats as lower thirds
|
||||
const reveal = staggeredReveal(4, 0.3);
|
||||
|
||||
const headlines = [
|
||||
{ label: 'TOTAL SESSIONS', value: STATS.totalSessions.toLocaleString() },
|
||||
{ label: 'LONGEST STREAK', value: `${STATS.longestStreak} days` },
|
||||
{ label: 'MARATHON DAYS', value: STATS.marathonDays.toString() },
|
||||
{ label: 'BUSIEST WEEK', value: STATS.busiestWeek },
|
||||
];
|
||||
|
||||
headlines.forEach((item, i) => {
|
||||
const itemP = reveal(p, i);
|
||||
if (itemP > 0) {
|
||||
const y = 8 + i * 4;
|
||||
lowerThird(fb, y, item.label, item.value, itemP, {
|
||||
width: 50,
|
||||
centered: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Ticker
|
||||
if (p > 0.3) {
|
||||
tickerTape(fb, fb.height - 3, [
|
||||
'More stats after the break...',
|
||||
`First session: ${STATS.firstSessionDate}`,
|
||||
], frame);
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
blindsH(fb, scene.transitionProgress, 6);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCodingForecast(fb, frame, scene) {
|
||||
gradient(fb, { direction: 'vertical', invert: true });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
// Section header
|
||||
if (p > 0.1) {
|
||||
segmentTitle(fb, 3, 'CODING FORECAST', Math.min(1, (p - 0.1) / 0.2));
|
||||
}
|
||||
|
||||
// Time patterns as forecast bars
|
||||
const reveal = staggeredReveal(3, 0.4);
|
||||
|
||||
if (p > 0.3) {
|
||||
const forecastP = reveal(p, 0);
|
||||
if (forecastP > 0) {
|
||||
fb.drawCenteredText(9, `Peak activity: ${STATS.peakHour} on ${STATS.peakDay}s`);
|
||||
}
|
||||
}
|
||||
|
||||
if (p > 0.4) {
|
||||
const nightP = reveal(p, 1);
|
||||
if (nightP > 0 && STATS.nightOwlPercent > 5) {
|
||||
forecastBar(fb, 15, 13, 'Night Owl', STATS.nightOwlPercent, 40, nightP, {
|
||||
char: '█',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (p > 0.5) {
|
||||
const earlyP = reveal(p, 2);
|
||||
if (earlyP > 0 && STATS.earlyBirdPercent > 5) {
|
||||
forecastBar(fb, 15, 16, 'Early Bird', STATS.earlyBirdPercent, 40, earlyP, {
|
||||
char: '█',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (p > 0.6) {
|
||||
const weekendP = Math.min(1, (p - 0.6) / 0.3);
|
||||
if (weekendP > 0) {
|
||||
forecastBar(fb, 15, 19, 'Weekend', STATS.weekendPercent, 40, weekendP, {
|
||||
char: '█',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
wipeDown(fb, scene.transitionProgress, '░');
|
||||
}
|
||||
}
|
||||
|
||||
function renderTopStories(fb, frame, scene) {
|
||||
gradient(fb, { direction: 'vertical', invert: true });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
// Section header
|
||||
if (p > 0.1) {
|
||||
segmentTitle(fb, 3, 'TOP REPOSITORIES', Math.min(1, (p - 0.1) / 0.2));
|
||||
}
|
||||
|
||||
// Top repos as headlines
|
||||
const reveal = staggeredReveal(3, 0.4);
|
||||
|
||||
TOP_REPOS.forEach((repo, i) => {
|
||||
if (repo.name) {
|
||||
const itemP = reveal(p, i);
|
||||
if (itemP > 0) {
|
||||
const y = 9 + i * 4;
|
||||
const rank = i + 1;
|
||||
lowerThird(fb, y, `#${rank}`, `${repo.name} (${repo.commits} commits)`, itemP, {
|
||||
width: 55,
|
||||
centered: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Celebration sparkles when fully revealed
|
||||
if (p > 0.8) {
|
||||
sparkles(fb, frame, { density: 0.003 });
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
}
|
||||
|
||||
function renderClosing(fb, frame, scene) {
|
||||
gradient(fb, { direction: 'vertical', invert: true });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
if (p > 0.2) {
|
||||
segmentTitle(fb, 8, 'THAT\'S A WRAP', Math.min(1, (p - 0.2) / 0.3));
|
||||
}
|
||||
|
||||
if (p > 0.4) {
|
||||
fb.drawCenteredText(14, `Thanks for tuning in, ${STATS.userName}!`);
|
||||
}
|
||||
|
||||
if (p > 0.6) {
|
||||
fb.drawCenteredText(17, 'See you next year!');
|
||||
}
|
||||
|
||||
// Celebration
|
||||
if (p > 0.5) {
|
||||
confetti(fb, frame, { count: 15 });
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
const fadeP = 1 - scene.transitionProgress;
|
||||
for (let y = 0; y < fb.height; y++) {
|
||||
for (let x = 0; x < fb.width; x++) {
|
||||
if (Math.random() < fadeP * 0.5) {
|
||||
fb.setPixel(x, y, ' ', -100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SCENE MAPPING
|
||||
// =============================================================================
|
||||
|
||||
const SCENE_RENDERERS = {
|
||||
thinkback_intro: renderThinkbackIntro,
|
||||
breaking_news: renderBreakingNews,
|
||||
headline_stats: renderHeadlineStats,
|
||||
coding_forecast: renderCodingForecast,
|
||||
top_stories: renderTopStories,
|
||||
closing: renderClosing,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN ANIMATION
|
||||
// =============================================================================
|
||||
|
||||
function mainAnimation(fb, frame) {
|
||||
const scene = sceneManager.getSceneAt(frame);
|
||||
|
||||
if (!scene) {
|
||||
fb.drawCenteredText(Math.floor(fb.height / 2), 'Animation complete');
|
||||
return;
|
||||
}
|
||||
|
||||
const renderer = SCENE_RENDERERS[scene.name];
|
||||
if (renderer) {
|
||||
renderer(fb, frame, scene);
|
||||
}
|
||||
}
|
||||
|
||||
function getSceneName(frame) {
|
||||
const scene = sceneManager.getSceneAt(frame);
|
||||
if (!scene) return 'Complete';
|
||||
|
||||
const names = {
|
||||
thinkback_intro: 'Think Back',
|
||||
breaking_news: 'Breaking News',
|
||||
headline_stats: 'Headlines',
|
||||
coding_forecast: 'Forecast',
|
||||
top_stories: 'Top Stories',
|
||||
closing: 'Closing',
|
||||
};
|
||||
|
||||
return names[scene.name] || scene.name;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXPORTS
|
||||
// =============================================================================
|
||||
|
||||
globalThis.YearInReviewScenes = {
|
||||
TOTAL_FRAMES,
|
||||
mainAnimation,
|
||||
getSceneName,
|
||||
sceneManager,
|
||||
};
|
||||
@@ -0,0 +1,443 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Thinkback - RPG Quest Vibe Template
|
||||
*
|
||||
* Epic RPG adventure experience with quest logs, level-ups, and legendary achievements.
|
||||
* Stats presented as XP gained, skills acquired, and character class reveal.
|
||||
*
|
||||
* INJECTION POINTS (search for "INJECT:"):
|
||||
* - STATS object: Fill in all numeric/string values
|
||||
* - TOP_REPOS array: Fill in top 3 repos with names and commits
|
||||
* - CHARACTER_CLASS: Fill in based on user's work patterns
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// HELPER DESTRUCTURING
|
||||
// =============================================================================
|
||||
|
||||
const {
|
||||
// Scene system
|
||||
SceneManager, staggeredReveal, easeInOut, easeOut, animateCounter,
|
||||
// Backgrounds
|
||||
stars, gradient,
|
||||
// Particles
|
||||
confetti, sparkles, burst,
|
||||
// Transitions
|
||||
dissolve, pixelate, blindsH, fade,
|
||||
// Text effects
|
||||
drawTypewriterCentered, slideIn, drawZoomText,
|
||||
// Claude branding
|
||||
drawThinkbackIntro, CLAUDE_ORANGE,
|
||||
// RPG-specific effects
|
||||
titleScreen, textBox, classSelect, questCard, questBanner,
|
||||
xpBar, levelUp, statsPanel, creditsRoll, victoryFanfare,
|
||||
} = globalThis;
|
||||
|
||||
// =============================================================================
|
||||
// INJECT: STATS - Fill in all values below
|
||||
// =============================================================================
|
||||
|
||||
const STATS = {
|
||||
userName: '', // INJECT: User's name
|
||||
year: 2025,
|
||||
totalCommits: 0, // INJECT: Total commits
|
||||
totalSessions: 0, // INJECT: Total sessions
|
||||
totalMessages: 0, // INJECT: Total messages
|
||||
repoCount: 0, // INJECT: Number of repos
|
||||
peakHour: '', // INJECT: e.g., '12am', '3pm'
|
||||
peakDay: '', // INJECT: e.g., 'Wed', 'Mon'
|
||||
nightOwlPercent: 0, // INJECT: Percentage (0-100)
|
||||
earlyBirdPercent: 0, // INJECT: Percentage (0-100)
|
||||
weekendPercent: 0, // INJECT: Percentage (0-100)
|
||||
longestStreak: 0, // INJECT: Days
|
||||
currentStreak: 0, // INJECT: Days
|
||||
totalActiveDays: 0, // INJECT: Days
|
||||
marathonDays: 0, // INJECT: Days with 100+ messages
|
||||
longestSessionMessages: 0, // INJECT: Messages in longest session
|
||||
firstSessionDate: '', // INJECT: 'YYYY-MM-DD'
|
||||
busiestWeek: '', // INJECT: e.g., 'Nov 24-30, 2025'
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// INJECT: TOP REPOS - Fill in top 3 repos
|
||||
// =============================================================================
|
||||
|
||||
const TOP_REPOS = [
|
||||
{ name: '', commits: 0 }, // INJECT: #1 repo name and commits
|
||||
{ name: '', commits: 0 }, // INJECT: #2 repo name and commits
|
||||
{ name: '', commits: 0 }, // INJECT: #3 repo name and commits
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// INJECT: CHARACTER CLASS - Based on user's work patterns
|
||||
// =============================================================================
|
||||
|
||||
// Choose based on user's activity patterns:
|
||||
// - 'BUG_SLAYER': Lots of fixes
|
||||
// - 'FEATURE_CRAFTER': New functionality focused
|
||||
// - 'DOCS_WIZARD': Documentation heavy
|
||||
// - 'REFACTOR_KNIGHT': Code improvements
|
||||
// - 'FULL_STACK_PALADIN': Balanced across all areas
|
||||
// - 'SPEED_DEMON': High commit velocity
|
||||
// - 'DEEP_DELVER': Long, complex sessions
|
||||
|
||||
const CHARACTER_CLASS = ''; // INJECT: e.g., 'FEATURE_CRAFTER'
|
||||
const CLASS_DESCRIPTION = ''; // INJECT: e.g., 'A builder of new worlds'
|
||||
|
||||
// =============================================================================
|
||||
// SCENE DEFINITIONS (pre-configured for RPG quest vibe)
|
||||
// =============================================================================
|
||||
|
||||
const SCENE_DEFINITIONS = [
|
||||
{ name: 'thinkback_intro', duration: 7, hold: 2 },
|
||||
{ name: 'title_screen', duration: 6, hold: 2 },
|
||||
{ name: 'class_reveal', duration: 8, hold: 3 },
|
||||
{ name: 'quest_log', duration: 8, hold: 3 },
|
||||
{ name: 'level_up', duration: 7, hold: 2.5 },
|
||||
{ name: 'credits', duration: 6, hold: 2 },
|
||||
];
|
||||
|
||||
const sceneManager = new SceneManager(SCENE_DEFINITIONS);
|
||||
const TOTAL_FRAMES = sceneManager.getTotalFrames();
|
||||
|
||||
// =============================================================================
|
||||
// USER INTRO
|
||||
// =============================================================================
|
||||
|
||||
const USER_INTRO = {
|
||||
userName: STATS.userName,
|
||||
year: STATS.year,
|
||||
tagline: 'your year with Claude Code',
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// DERIVED STATS (computed from STATS for RPG presentation)
|
||||
// =============================================================================
|
||||
|
||||
// Calculate "level" based on total activity
|
||||
function calculateLevel() {
|
||||
const xp = STATS.totalCommits * 10 + STATS.totalMessages + STATS.totalActiveDays * 50;
|
||||
return Math.min(99, Math.floor(Math.log2(xp / 100) + 1)) || 1;
|
||||
}
|
||||
|
||||
// Generate character stats based on activity patterns
|
||||
function getCharacterStats() {
|
||||
const stats = {};
|
||||
|
||||
// STR = commit intensity
|
||||
stats.STR = Math.min(10, Math.floor(STATS.totalCommits / 100) + 3);
|
||||
|
||||
// DEX = session frequency
|
||||
stats.DEX = Math.min(10, Math.floor(STATS.totalSessions / 50) + 2);
|
||||
|
||||
// INT = message depth
|
||||
stats.INT = Math.min(10, Math.floor(STATS.totalMessages / 500) + 3);
|
||||
|
||||
// WIS = consistency (streak-based)
|
||||
stats.WIS = Math.min(10, Math.floor(STATS.longestStreak / 10) + 2);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Generate character traits based on patterns
|
||||
function getCharacterTraits() {
|
||||
const traits = [];
|
||||
|
||||
if (STATS.nightOwlPercent > 30) traits.push('Night Owl');
|
||||
if (STATS.earlyBirdPercent > 20) traits.push('Early Riser');
|
||||
if (STATS.weekendPercent > 30) traits.push('Weekend Warrior');
|
||||
if (STATS.longestStreak > 14) traits.push('Persistent');
|
||||
if (STATS.marathonDays > 5) traits.push('Enduring');
|
||||
if (STATS.totalCommits > 500) traits.push('Prolific');
|
||||
|
||||
// Return top 3 traits
|
||||
return traits.slice(0, 3);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SCENE RENDERERS
|
||||
// =============================================================================
|
||||
|
||||
function renderThinkbackIntro(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.012, twinkle: true });
|
||||
|
||||
let p = 0;
|
||||
if (scene.phase === 'TRANSITION_IN') {
|
||||
p = scene.transitionProgress * 0.1;
|
||||
} else if (scene.phase === 'CONTENT') {
|
||||
p = 0.1 + scene.contentProgress * 0.7;
|
||||
} else {
|
||||
p = 1;
|
||||
}
|
||||
|
||||
drawThinkbackIntro(fb, frame, p, USER_INTRO);
|
||||
|
||||
if (scene.phase === 'HOLD' || scene.phase === 'TRANSITION_OUT') {
|
||||
sparkles(fb, frame, { density: 0.004 });
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
pixelate(fb, scene.transitionProgress, 8);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTitleScreen(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.008, twinkle: true });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
titleScreen(fb, {
|
||||
title: 'YEAR IN CODE',
|
||||
subtitle: STATS.year.toString(),
|
||||
prompt: 'PRESS START',
|
||||
}, p, frame);
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
pixelate(fb, scene.transitionProgress, 6);
|
||||
}
|
||||
}
|
||||
|
||||
function renderClassReveal(fb, frame, scene) {
|
||||
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
// Build up text
|
||||
if (p < 0.3) {
|
||||
textBox(fb, 18, 'Your deeds have defined you...', p / 0.3, frame, {
|
||||
width: 45,
|
||||
style: 'rpg',
|
||||
});
|
||||
}
|
||||
|
||||
// Class reveal
|
||||
if (p >= 0.3) {
|
||||
const classP = (p - 0.3) / 0.7;
|
||||
|
||||
const className = CHARACTER_CLASS.replace(/_/g, ' ') || 'ADVENTURER';
|
||||
const description = CLASS_DESCRIPTION || 'A brave soul';
|
||||
|
||||
classSelect(fb, {
|
||||
className,
|
||||
description,
|
||||
stats: getCharacterStats(),
|
||||
traits: getCharacterTraits(),
|
||||
}, classP, frame, {
|
||||
y: 4,
|
||||
showSprite: true,
|
||||
});
|
||||
|
||||
if (classP > 0.5) {
|
||||
sparkles(fb, frame, { density: 0.004 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
blindsH(fb, scene.transitionProgress, 6);
|
||||
}
|
||||
}
|
||||
|
||||
function renderQuestLog(fb, frame, scene) {
|
||||
gradient(fb, { direction: 'vertical', chars: [' ', '░'] });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
// Quest log header
|
||||
if (p < 0.15) {
|
||||
questBanner(fb, 'QUEST LOG', p / 0.15, frame, { y: 2, style: 'simple' });
|
||||
}
|
||||
|
||||
// Cycle through completed quests (repos)
|
||||
if (p >= 0.15) {
|
||||
const questP = (p - 0.15) / 0.85;
|
||||
const validRepos = TOP_REPOS.filter(r => r.name);
|
||||
const numQuests = validRepos.length || 1;
|
||||
|
||||
const questIdx = Math.min(numQuests - 1, Math.floor(questP * numQuests));
|
||||
const questLocalP = (questP * numQuests) % 1;
|
||||
|
||||
if (validRepos.length > 0) {
|
||||
const repo = validRepos[questIdx];
|
||||
|
||||
// Quest complete banner
|
||||
if (questLocalP < 0.25) {
|
||||
questBanner(fb, 'QUEST COMPLETE', questLocalP / 0.25, frame, {
|
||||
y: 3,
|
||||
style: 'fanfare',
|
||||
});
|
||||
}
|
||||
|
||||
// Quest card (simplified - no body/description)
|
||||
if (questLocalP >= 0.25) {
|
||||
const cardP = (questLocalP - 0.25) / 0.75;
|
||||
questCard(fb, {
|
||||
name: repo.name,
|
||||
commits: repo.commits,
|
||||
rank: questIdx + 1,
|
||||
}, cardP, frame, {
|
||||
y: 5,
|
||||
width: 50,
|
||||
showRewards: true,
|
||||
});
|
||||
|
||||
// Victory sparkles
|
||||
if (cardP > 0.4) {
|
||||
sparkles(fb, frame, { density: 0.005 });
|
||||
}
|
||||
}
|
||||
|
||||
// Progress indicator
|
||||
if (numQuests > 1) {
|
||||
const indicator = `${questIdx + 1}/${numQuests}`;
|
||||
fb.drawText(fb.width - indicator.length - 2, 2, indicator);
|
||||
}
|
||||
} else {
|
||||
// Fallback if no repos
|
||||
fb.drawCenteredText(12, 'Your quests await...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLevelUp(fb, frame, scene) {
|
||||
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
// Dramatic build
|
||||
if (p < 0.3) {
|
||||
textBox(fb, 10, 'Your journey has made you stronger...', p / 0.3, frame, {
|
||||
width: 45,
|
||||
style: 'rpg',
|
||||
});
|
||||
}
|
||||
|
||||
// Level up reveal
|
||||
if (p >= 0.3) {
|
||||
const lvlP = (p - 0.3) / 0.7;
|
||||
|
||||
levelUp(fb, {
|
||||
level: calculateLevel(),
|
||||
stats: [
|
||||
{ name: 'COMMITS', gained: `+${STATS.totalCommits.toLocaleString()}` },
|
||||
{ name: 'QUESTS', gained: `+${STATS.repoCount}` },
|
||||
{ name: 'ACTIVE DAYS', gained: `+${STATS.totalActiveDays}` },
|
||||
],
|
||||
}, lvlP, frame, {
|
||||
y: 6,
|
||||
});
|
||||
|
||||
// Celebration
|
||||
if (lvlP > 0.3) {
|
||||
victoryFanfare(fb, frame, { intensity: lvlP });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCredits(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.006, twinkle: true });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
creditsRoll(fb, [
|
||||
{ type: 'header', text: 'ADVENTURE COMPLETE' },
|
||||
{ type: 'spacer' },
|
||||
{ type: 'stat', label: 'Total XP', value: (STATS.totalCommits * 10 + STATS.totalMessages).toLocaleString() },
|
||||
{ type: 'stat', label: 'Quests Completed', value: STATS.repoCount.toString() },
|
||||
{ type: 'stat', label: 'Days Adventured', value: STATS.totalActiveDays.toString() },
|
||||
{ type: 'stat', label: 'Longest Streak', value: `${STATS.longestStreak} days` },
|
||||
{ type: 'spacer' },
|
||||
{ type: 'spacer' },
|
||||
{ type: 'text', text: 'Your adventure continues...' },
|
||||
{ type: 'spacer' },
|
||||
{ type: 'header', text: (STATS.year + 1).toString() },
|
||||
], p, frame, {
|
||||
speed: 0.4,
|
||||
});
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
const fadeP = 1 - scene.transitionProgress;
|
||||
for (let y = 0; y < fb.height; y++) {
|
||||
for (let x = 0; x < fb.width; x++) {
|
||||
if (Math.random() < fadeP * 0.5) {
|
||||
fb.setPixel(x, y, ' ', -100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SCENE MAPPING
|
||||
// =============================================================================
|
||||
|
||||
const SCENE_RENDERERS = {
|
||||
thinkback_intro: renderThinkbackIntro,
|
||||
title_screen: renderTitleScreen,
|
||||
class_reveal: renderClassReveal,
|
||||
quest_log: renderQuestLog,
|
||||
level_up: renderLevelUp,
|
||||
credits: renderCredits,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN ANIMATION
|
||||
// =============================================================================
|
||||
|
||||
function mainAnimation(fb, frame) {
|
||||
const scene = sceneManager.getSceneAt(frame);
|
||||
|
||||
if (!scene) {
|
||||
fb.drawCenteredText(Math.floor(fb.height / 2), 'Animation complete');
|
||||
return;
|
||||
}
|
||||
|
||||
const renderer = SCENE_RENDERERS[scene.name];
|
||||
if (renderer) {
|
||||
renderer(fb, frame, scene);
|
||||
}
|
||||
}
|
||||
|
||||
function getSceneName(frame) {
|
||||
const scene = sceneManager.getSceneAt(frame);
|
||||
if (!scene) return 'Complete';
|
||||
|
||||
const names = {
|
||||
thinkback_intro: 'Think Back',
|
||||
title_screen: 'Title Screen',
|
||||
class_reveal: 'Class Reveal',
|
||||
quest_log: 'Quest Log',
|
||||
level_up: 'Level Up',
|
||||
credits: 'Credits',
|
||||
};
|
||||
|
||||
return names[scene.name] || scene.name;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXPORTS
|
||||
// =============================================================================
|
||||
|
||||
globalThis.YearInReviewScenes = {
|
||||
TOTAL_FRAMES,
|
||||
mainAnimation,
|
||||
getSceneName,
|
||||
sceneManager,
|
||||
};
|
||||
440
plugins/thinkback/skills/thinkback/vibes/awards-show-vibe.md
Normal file
440
plugins/thinkback/skills/thinkback/vibes/awards-show-vibe.md
Normal file
@@ -0,0 +1,440 @@
|
||||
# Awards Show Vibe Instructions
|
||||
|
||||
Generate a glamorous, celebratory awards ceremony experience. Think red carpet energy, dramatic envelope reveals, standing ovations, and acceptance speeches.
|
||||
|
||||
Imagine you are the host of a prestigious awards ceremony honoring the year's greatest developer achievements.
|
||||
|
||||
## Tone Guidelines
|
||||
|
||||
- **Celebratory and glamorous**: This is their night to shine
|
||||
- **Dramatic tension**: Build suspense before reveals
|
||||
- **Reverent**: Treat achievements as genuinely impressive accomplishments
|
||||
- **Warm**: Personal touches, like the host knows the honoree
|
||||
|
||||
## Pacing
|
||||
|
||||
- Slow builds to dramatic reveals ("And the award goes to...")
|
||||
- Pause for applause moments after big announcements
|
||||
- Quick montage cuts for recap sections
|
||||
- Lingering on acceptance speech moments (project spotlights)
|
||||
|
||||
## Segment Ideas
|
||||
|
||||
Structure the thinkback like an awards ceremony:
|
||||
|
||||
- **RED CARPET INTRO**: Welcome, set the scene, tease what's coming
|
||||
- **OPENING MONTAGE**: Quick highlights reel of the year
|
||||
- **TECHNICAL ACHIEVEMENT**: Stats-heavy awards (commits, PRs, lines)
|
||||
- **BEST SUPPORTING**: Secondary projects, contributions
|
||||
- **BEST PROJECT**: **THE STAR OF THE SHOW** - Top 3 projects get full spotlight treatment, each with their own acceptance speech moment
|
||||
- **LIFETIME ACHIEVEMENT**: Overall year stats, career highlights
|
||||
- **IN MEMORIAM**: Deprecated code, closed issues, bugs squashed
|
||||
- **FINALE**: Standing ovation, confetti, thank-you montage
|
||||
|
||||
### Project Awards Are the Star
|
||||
|
||||
The Best Project segment should be the emotional centerpiece. For each of the user's top 3 projects:
|
||||
|
||||
1. **Build the suspense** - "And the award for Best Project goes to..."
|
||||
2. **Dramatic reveal** - Envelope opens, name appears with fanfare
|
||||
3. **Acceptance speech** - Full spotlight with description and body text
|
||||
4. **Show the stats** - Commits, impact, what made it special
|
||||
5. **Applause moment** - Confetti, sparkles, celebration
|
||||
|
||||
Think of each project like a winner taking the stage - they deserve their moment in the spotlight.
|
||||
|
||||
## Closing Scene
|
||||
|
||||
End with that classic awards show finale:
|
||||
|
||||
- "What a year it's been. Congratulations to all our winners."
|
||||
- "Thank you for being part of this journey."
|
||||
- "Until next year... keep making magic."
|
||||
|
||||
---
|
||||
|
||||
## Recommended Helpers for Awards Show Vibe
|
||||
|
||||
Access helpers by destructuring from `globalThis` at the top of your file:
|
||||
|
||||
```javascript
|
||||
const {
|
||||
// Elegant backgrounds
|
||||
gradient, sparkles, stars,
|
||||
|
||||
// Celebration particles
|
||||
confetti, burst, glitter,
|
||||
|
||||
// Dramatic transitions
|
||||
circleReveal, fade, dissolve, blindsH,
|
||||
curtainReveal, spotlightReveal,
|
||||
|
||||
// Text effects
|
||||
drawTypewriterCentered, slideIn, drawFadeInText,
|
||||
drawZoomText, drawGlitchText,
|
||||
|
||||
// Awards-specific effects
|
||||
envelopeReveal, awardBadge, acceptanceSpeech,
|
||||
nomineeCard, trophyDisplay, applauseMeter,
|
||||
redCarpetBorder, spotlightText, winnerAnnouncement,
|
||||
categoryTitle, standingOvation, awardsStatue,
|
||||
} = globalThis;
|
||||
```
|
||||
|
||||
### Glamorous Background Combinations
|
||||
|
||||
```javascript
|
||||
// Subtle sparkle (gala atmosphere)
|
||||
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
|
||||
sparkles(fb, frame, { density: 0.003, chars: ['·', '*', '✦'] });
|
||||
|
||||
// Starry night (outdoor ceremony feel)
|
||||
stars(fb, frame, { density: 0.008, twinkle: true });
|
||||
```
|
||||
|
||||
### Dramatic Transitions
|
||||
|
||||
```javascript
|
||||
// Curtain reveal (opening/segment changes)
|
||||
curtainReveal(fb, progress);
|
||||
|
||||
// Spotlight reveal (winner announcements)
|
||||
spotlightReveal(fb, progress, { x: 40, y: 12 });
|
||||
|
||||
// Circle reveal for dramatic moments
|
||||
circleReveal(fb, progress);
|
||||
|
||||
// Fade for emotional moments
|
||||
fade(fb, progress);
|
||||
```
|
||||
|
||||
### Celebration Effects
|
||||
|
||||
```javascript
|
||||
// Victory confetti burst
|
||||
confetti(fb, frame, { count: 30, chars: ['*', '◆', '●', '✦'] });
|
||||
|
||||
// Golden glitter shower
|
||||
glitter(fb, frame, { density: 0.008 });
|
||||
|
||||
// Burst for announcements
|
||||
burst(fb, frame, { x: 40, y: 12, count: 15 });
|
||||
```
|
||||
|
||||
### Text Animation Examples
|
||||
|
||||
```javascript
|
||||
// Dramatic zoom for winner names
|
||||
drawZoomText(fb, y, 'claude-code', progress);
|
||||
|
||||
// Typewriter for "And the award goes to..."
|
||||
drawTypewriterCentered(fb, y, 'AND THE AWARD GOES TO...', progress, frame);
|
||||
|
||||
// Slide in for category names
|
||||
slideIn(fb, y, 'BEST PROJECT', progress, { from: 'left' });
|
||||
|
||||
// Spotlight text (glowing effect)
|
||||
spotlightText(fb, y, 'WINNER', frame, { glow: true });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Awards-Specific Effects Reference
|
||||
|
||||
### Envelope Reveal
|
||||
|
||||
```javascript
|
||||
// Dramatic envelope opening animation
|
||||
envelopeReveal(fb, 'claude-code', progress, frame, {
|
||||
y: 10,
|
||||
suspenseText: 'AND THE AWARD GOES TO...',
|
||||
});
|
||||
// Phases: envelope appears → opens → winner name revealed with fanfare
|
||||
```
|
||||
|
||||
### Award Badge
|
||||
|
||||
```javascript
|
||||
// Display an award badge/medal
|
||||
awardBadge(fb, x, y, {
|
||||
category: 'BEST PROJECT',
|
||||
year: '2024',
|
||||
style: 'gold', // 'gold', 'silver', 'bronze'
|
||||
}, progress);
|
||||
// Output:
|
||||
// ╭─────────────╮
|
||||
// │ ★ 2024 ★ │
|
||||
// │ BEST PROJECT│
|
||||
// ╰─────────────╯
|
||||
```
|
||||
|
||||
### Trophy Display
|
||||
|
||||
```javascript
|
||||
// ASCII trophy with label
|
||||
trophyDisplay(fb, x, y, {
|
||||
label: '#1 PROJECT',
|
||||
style: 'grand', // 'grand', 'simple', 'star'
|
||||
}, progress, frame);
|
||||
// Output:
|
||||
// ___
|
||||
// | |
|
||||
// /| |\
|
||||
// / |___| \
|
||||
// | / \ |
|
||||
// \/_____\/
|
||||
// #1 PROJECT
|
||||
```
|
||||
|
||||
### Acceptance Speech (Project Spotlight) ⭐
|
||||
|
||||
**This is the star helper for the awards show vibe.** Use it to give each project its own acceptance speech moment.
|
||||
|
||||
```javascript
|
||||
// Full acceptance speech display for a project
|
||||
acceptanceSpeech(fb, {
|
||||
name: 'claude-code',
|
||||
commits: 275,
|
||||
rank: 1,
|
||||
description: 'CLI tool for developers',
|
||||
body: 'I want to thank everyone who contributed to this project. The late nights, the debugging sessions, the code reviews - it all led to this moment.',
|
||||
}, progress, frame, {
|
||||
y: 4,
|
||||
width: 55,
|
||||
showTrophy: true,
|
||||
});
|
||||
// Output:
|
||||
// ___
|
||||
// | |
|
||||
// /| |\
|
||||
// / |___| \
|
||||
// | / \ |
|
||||
// \/_____\/
|
||||
// ╔═══════════════════════════════════════════════════╗
|
||||
// ║ ★ BEST PROJECT ★ ║
|
||||
// ║ claude-code ║
|
||||
// ╟───────────────────────────────────────────────────╢
|
||||
// ║ 275 COMMITS ║
|
||||
// ║ CLI tool for developers ║
|
||||
// ║ ················· ║
|
||||
// ║ I want to thank everyone who contributed to ║
|
||||
// ║ this project. The late nights, the debugging ║
|
||||
// ║ sessions, the code reviews - it all led to ║
|
||||
// ║ this moment. ║
|
||||
// ╚═══════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
### Nominee Card
|
||||
|
||||
```javascript
|
||||
// Display a nominee before winner announcement
|
||||
nomineeCard(fb, x, y, {
|
||||
name: 'sdk-demos',
|
||||
stat: 27,
|
||||
statLabel: 'commits',
|
||||
}, progress, {
|
||||
style: 'elegant',
|
||||
width: 30,
|
||||
});
|
||||
```
|
||||
|
||||
### Category Title
|
||||
|
||||
```javascript
|
||||
// Animated category header
|
||||
categoryTitle(fb, y, 'BEST PROJECT', progress, frame, {
|
||||
style: 'grand', // 'grand', 'simple', 'minimal'
|
||||
});
|
||||
// Output:
|
||||
// ════════════════════════════════════════
|
||||
// ★ BEST PROJECT ★
|
||||
// ════════════════════════════════════════
|
||||
```
|
||||
|
||||
### Winner Announcement
|
||||
|
||||
```javascript
|
||||
// Full winner reveal sequence
|
||||
winnerAnnouncement(fb, 'claude-code', progress, frame, {
|
||||
category: 'BEST PROJECT',
|
||||
stat: 275,
|
||||
statLabel: 'commits',
|
||||
});
|
||||
// Handles the full reveal: category → suspense → winner → celebration
|
||||
```
|
||||
|
||||
### Applause Meter
|
||||
|
||||
```javascript
|
||||
// Visual applause indicator (like an audience reaction)
|
||||
applauseMeter(fb, y, progress, frame, {
|
||||
intensity: 0.8, // 0-1 how enthusiastic
|
||||
});
|
||||
// Output: 👏👏👏👏👏👏👏░░░
|
||||
```
|
||||
|
||||
### Standing Ovation
|
||||
|
||||
```javascript
|
||||
// Particle effect for standing ovation moment
|
||||
standingOvation(fb, frame, {
|
||||
intensity: 1.0,
|
||||
chars: ['👏', '✦', '*', '·'],
|
||||
});
|
||||
```
|
||||
|
||||
### Red Carpet Border
|
||||
|
||||
```javascript
|
||||
// Decorative border with awards show feel
|
||||
redCarpetBorder(fb, progress, {
|
||||
style: 'velvet', // 'velvet', 'gold', 'stars'
|
||||
});
|
||||
```
|
||||
|
||||
### Awards Statue
|
||||
|
||||
```javascript
|
||||
// Large decorative trophy/statue ASCII art
|
||||
awardsStatue(fb, x, y, progress, {
|
||||
style: 'oscar', // 'oscar', 'globe', 'star'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Sample Phrases
|
||||
|
||||
- "Welcome to the ceremony..."
|
||||
- "And the nominees are..."
|
||||
- "The envelope, please..."
|
||||
- "And the award goes to..."
|
||||
- "Let's give them a round of applause!"
|
||||
- "What an incredible achievement."
|
||||
- "A truly remarkable year."
|
||||
- "Please welcome to the stage..."
|
||||
- "Thank you to everyone who made this possible."
|
||||
|
||||
---
|
||||
|
||||
### Example Scene Structure
|
||||
|
||||
```javascript
|
||||
case 'BEST_PROJECT': {
|
||||
// Glamorous background
|
||||
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
|
||||
sparkles(fb, frame, { density: 0.003, chars: ['·', '*'] });
|
||||
|
||||
// Category title
|
||||
if (p < 0.15) {
|
||||
categoryTitle(fb, 5, 'BEST PROJECT', p / 0.15, frame, { style: 'grand' });
|
||||
}
|
||||
|
||||
// Suspense build
|
||||
if (p >= 0.15 && p < 0.3) {
|
||||
const suspenseP = (p - 0.15) / 0.15;
|
||||
drawTypewriterCentered(fb, 10, 'AND THE AWARD GOES TO...', suspenseP, frame);
|
||||
}
|
||||
|
||||
// Winner reveal with envelope
|
||||
if (p >= 0.3 && p < 0.5) {
|
||||
const revealP = (p - 0.3) / 0.2;
|
||||
envelopeReveal(fb, 'claude-code', revealP, frame, { y: 8 });
|
||||
}
|
||||
|
||||
// Acceptance speech
|
||||
if (p >= 0.5) {
|
||||
const speechP = (p - 0.5) / 0.5;
|
||||
acceptanceSpeech(fb, {
|
||||
name: 'claude-code',
|
||||
commits: 275,
|
||||
rank: 1,
|
||||
description: 'CLI tool for developers',
|
||||
body: 'Major refactors to agent architecture shipped this year. The CLI became faster, smarter, and more powerful.',
|
||||
}, speechP, frame, {
|
||||
y: 4,
|
||||
width: 55,
|
||||
showTrophy: true,
|
||||
});
|
||||
|
||||
// Celebration
|
||||
if (speechP > 0.3) {
|
||||
confetti(fb, frame, { count: 20 });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example: Full Awards Sequence (The Star of the Show)
|
||||
|
||||
This is how you make projects shine. Each of the top 3 projects gets their own awards ceremony moment:
|
||||
|
||||
```javascript
|
||||
case 'PROJECT_AWARDS': {
|
||||
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
|
||||
|
||||
const projects = [
|
||||
{
|
||||
name: 'claude-code',
|
||||
commits: 275,
|
||||
rank: 1,
|
||||
description: 'The heart of Claude Code',
|
||||
body: 'Major refactors to agent architecture, new subagent system, and animation framework shipped. A year of transformation.',
|
||||
},
|
||||
{
|
||||
name: 'sdk-demos',
|
||||
commits: 27,
|
||||
rank: 2,
|
||||
description: 'Example applications & demos',
|
||||
body: 'Email agent, deep research demos, and customer showcase apps. Helping teams see the art of the possible.',
|
||||
},
|
||||
{
|
||||
name: 'docs',
|
||||
commits: 15,
|
||||
rank: 3,
|
||||
description: 'Documentation & guides',
|
||||
body: 'User guides, API reference, and tutorials. Good docs make great products.',
|
||||
},
|
||||
];
|
||||
|
||||
// Each project gets dedicated screen time with full ceremony
|
||||
const numProjects = projects.length;
|
||||
const projectIdx = Math.min(numProjects - 1, Math.floor(p * numProjects));
|
||||
const projectLocalP = (p * numProjects) % 1;
|
||||
|
||||
const project = projects[projectIdx];
|
||||
|
||||
// Phase 1: Category reveal (0-0.2)
|
||||
if (projectLocalP < 0.2) {
|
||||
categoryTitle(fb, 5, `#${project.rank} PROJECT`, projectLocalP / 0.2, frame);
|
||||
}
|
||||
|
||||
// Phase 2: Envelope reveal (0.2-0.4)
|
||||
if (projectLocalP >= 0.2 && projectLocalP < 0.4) {
|
||||
const envP = (projectLocalP - 0.2) / 0.2;
|
||||
envelopeReveal(fb, project.name, envP, frame, { y: 8 });
|
||||
}
|
||||
|
||||
// Phase 3: Acceptance speech (0.4-1.0)
|
||||
if (projectLocalP >= 0.4) {
|
||||
const speechP = (projectLocalP - 0.4) / 0.6;
|
||||
acceptanceSpeech(fb, project, speechP, frame, {
|
||||
y: 4,
|
||||
width: 55,
|
||||
showTrophy: true,
|
||||
});
|
||||
|
||||
// Celebration particles
|
||||
if (speechP > 0.2) {
|
||||
confetti(fb, frame, { count: 15, chars: ['*', '✦', '·'] });
|
||||
}
|
||||
}
|
||||
|
||||
// Progress indicator
|
||||
fb.drawText(fb.width - 5, 2, `${projectIdx + 1}/${numProjects}`);
|
||||
break;
|
||||
}
|
||||
```
|
||||
92
plugins/thinkback/skills/thinkback/vibes/cozy-vibe.md
Normal file
92
plugins/thinkback/skills/thinkback/vibes/cozy-vibe.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Cozy Vibe Instructions
|
||||
|
||||
Generate a warm, gentle, and comforting thinkback experience. Think blankets, hot cocoa, gathering around a fire, and quiet satisfaction.
|
||||
|
||||
Imagine you are a parent giving your child a bedtime story.
|
||||
|
||||
## Tone Guidelines
|
||||
|
||||
- **Warm and gentle**: Use soft, nurturing language
|
||||
- **Appreciative**: Focus on the journey, not just achievements
|
||||
- **Unhurried**: Let moments breathe, no rushing through stats
|
||||
- **Nostalgic**: Frame the year as memories worth cherishing
|
||||
|
||||
## Pacing
|
||||
|
||||
- Slower transitions between scenes
|
||||
- Longer pauses for reflection
|
||||
- Let ASCII art fade in gently rather than appearing abruptly
|
||||
|
||||
## Closing Scene
|
||||
|
||||
End with gratitude and warmth, not a call to action:
|
||||
|
||||
- "Until next year... take care of yourself"
|
||||
- "The code will be here when you're ready"
|
||||
- "Rest well. You've earned it."
|
||||
|
||||
---
|
||||
|
||||
## Recommended Helpers for Cozy Vibe
|
||||
|
||||
Import these helpers for a warm, gentle atmosphere:
|
||||
|
||||
```javascript
|
||||
import {
|
||||
// Cozy backgrounds
|
||||
stars, fireflies, dust, snow,
|
||||
|
||||
// Gentle particles
|
||||
floatingParticles, embers, sparkles,
|
||||
|
||||
// Soft transitions
|
||||
dissolve, circleReveal, circleClose,
|
||||
|
||||
// Text effects
|
||||
drawTypewriterCentered, drawFadeInText, slideIn,
|
||||
} from './helpers/index.js';
|
||||
```
|
||||
|
||||
### Cozy Background Combinations
|
||||
|
||||
```javascript
|
||||
// Gentle starfield (slow twinkle)
|
||||
stars(fb, frame, { density: 0.006, twinkle: true });
|
||||
|
||||
// Warm fireflies (like a summer evening)
|
||||
fireflies(fb, frame, { count: 6, chars: ['·', '*', '°'] });
|
||||
|
||||
// Dust motes in afternoon light
|
||||
dust(fb, frame, { density: 0.002 });
|
||||
|
||||
// Light snow for winter scenes
|
||||
snow(fb, frame, { density: 0.008, chars: ['·', '.'] });
|
||||
```
|
||||
|
||||
### Gentle Particle Effects
|
||||
|
||||
```javascript
|
||||
// Floating diamonds (signature cozy particle)
|
||||
floatingParticles(fb, frame, { count: 12, char: '◇', speed: 0.5 });
|
||||
|
||||
// Rising embers (warm hearth feeling)
|
||||
embers(fb, frame, { count: 8, chars: ['.', '·', '*'], speed: 0.7 });
|
||||
|
||||
// Subtle sparkles (magical but not overwhelming)
|
||||
sparkles(fb, frame, { density: 0.003, chars: ['·', '*', '°'] });
|
||||
```
|
||||
|
||||
### Soft Transitions
|
||||
|
||||
Avoid harsh wipes. Use gentle reveals:
|
||||
|
||||
```javascript
|
||||
// Dissolve (gentle fade between scenes)
|
||||
dissolve(fb, progress, seed);
|
||||
|
||||
// Circle reveal from center (soft iris)
|
||||
circleReveal(fb, progress);
|
||||
|
||||
// Fade using density characters
|
||||
fade(fb, progress, false);
|
||||
```
|
||||
443
plugins/thinkback/skills/thinkback/vibes/morning-news-vibe.md
Normal file
443
plugins/thinkback/skills/thinkback/vibes/morning-news-vibe.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# Morning News Vibe Instructions
|
||||
|
||||
Generate a cheerful, upbeat news broadcast experience. Think morning show energy, breaking news graphics, weather-style stat presentations, and that signature "and finally..." feel-good closer.
|
||||
|
||||
Imagine you are a morning news anchor delivering the year's top developer stories with a smile.
|
||||
|
||||
## Tone Guidelines
|
||||
|
||||
- **Upbeat and professional**: Friendly but polished, like your favorite morning hosts
|
||||
- **Informative**: Present stats like they're newsworthy headlines
|
||||
- **Light humor**: Occasional puns and wordplay, never forced
|
||||
- **Celebratory**: Treat achievements like breaking good news
|
||||
|
||||
## Pacing
|
||||
|
||||
- Snappy transitions between "segments"
|
||||
- Dramatic pauses before big reveals ("And the number one story...")
|
||||
- Quick "ticker tape" style for smaller stats
|
||||
- Slow down for the heartfelt "human interest" closer
|
||||
|
||||
## Segment Ideas
|
||||
|
||||
Structure the thinkback like a news broadcast:
|
||||
|
||||
- **BREAKING**: The biggest stat or achievement
|
||||
- **TOP STORIES**: Key metrics from the year
|
||||
- **PROJECT SPOTLIGHT**: **THE STAR OF THE SHOW** - Feature the top 3 accomplishements of the user as full news articles, one at a time. Each accomplishment gets its own dedicated screen with headline, description, body text, and stats. Use `accomplishmentSpotlight` to give each project the attention it deserves.
|
||||
- **WEATHER**: Coding "forecast" (busy periods, productivity patterns)
|
||||
- **SPORTS**: Competitive stats (lines of code, PRs merged)
|
||||
- **AND FINALLY...**: Warm, feel-good closer
|
||||
|
||||
### Project Articles Are the Star
|
||||
|
||||
The project spotlight segment should be the centerpiece of the broadcast. For each of the user's top 3 projects:
|
||||
|
||||
1. **Give it a full screen** - Don't cram multiple projects together
|
||||
2. **Write a compelling headline** - Make it sound newsworthy
|
||||
3. **Include body text** - 2-3 sentences about what was accomplished
|
||||
4. **Show the stats** - Commits, contributions, impact
|
||||
5. **Use transitions** - Smooth handoffs between projects
|
||||
|
||||
Think of each project like a feature story on the evening news - it deserves time and attention.
|
||||
|
||||
## Closing Scene
|
||||
|
||||
End with that classic news sign-off warmth:
|
||||
|
||||
- "That's all for 2024. See you bright and early next year."
|
||||
- "From all of us here at the terminal... goodnight."
|
||||
- "Stay curious, stay coding, and we'll see you tomorrow."
|
||||
|
||||
---
|
||||
|
||||
## Recommended Helpers for Morning News Vibe
|
||||
|
||||
Access helpers by destructuring from `globalThis` at the top of your file:
|
||||
|
||||
```javascript
|
||||
const {
|
||||
// Clean backgrounds
|
||||
gradient,
|
||||
|
||||
// Celebration particles (for big reveals)
|
||||
confetti, sparkles, burst,
|
||||
|
||||
// Professional transitions
|
||||
wipeRight, wipeDown, blindsH, blindsV,
|
||||
dissolve, fade, splitWipe, pushTransition,
|
||||
|
||||
// Text effects
|
||||
drawTypewriterCentered, slideIn, headlineCrawl,
|
||||
|
||||
// News-specific effects
|
||||
lowerThird, tickerTape, breakingBanner, liveIndicator,
|
||||
segmentTitle, statCounter, forecastBar, countdownReveal,
|
||||
|
||||
// Article display helpers
|
||||
newsArticle, newsGrid, headlineCarousel, accomplishmentSpotlight, newsFeed,
|
||||
} = globalThis;
|
||||
```
|
||||
|
||||
### Broadcast Background Combinations
|
||||
|
||||
```javascript
|
||||
// Subtle gradient (news desk feel)
|
||||
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
|
||||
```
|
||||
|
||||
### News-Style Transitions
|
||||
|
||||
```javascript
|
||||
// Wipe right (classic news transition)
|
||||
wipeRight(fb, progress);
|
||||
|
||||
// Venetian blinds (segment change)
|
||||
blindsH(fb, progress, 4);
|
||||
|
||||
// Dissolve for softer moments
|
||||
dissolve(fb, progress, seed);
|
||||
```
|
||||
|
||||
### Breaking News Effects
|
||||
|
||||
```javascript
|
||||
// Confetti for big achievements
|
||||
confetti(fb, frame, { count: 20, chars: ['*', '◆', '●'] });
|
||||
|
||||
// Burst for "BREAKING" moments
|
||||
burst(fb, frame, { x: 40, y: 12, count: 10 });
|
||||
|
||||
// Sparkles for feel-good segments
|
||||
sparkles(fb, frame, { density: 0.004, chars: ['·', '*'] });
|
||||
```
|
||||
|
||||
### Text Animation Examples
|
||||
|
||||
```javascript
|
||||
// Headline with blinking cursor
|
||||
headlineCrawl(fb, y, 'BREAKING: 50,000 LINES WRITTEN', progress, frame, {
|
||||
centered: true,
|
||||
});
|
||||
|
||||
// Slide in for segment titles
|
||||
slideIn(fb, y, '>>> TOP STORIES', progress, { from: 'left' });
|
||||
|
||||
// Segment title with decorative brackets
|
||||
segmentTitle(fb, y, 'TOP STORIES', progress, { style: 'arrow' });
|
||||
// Outputs: ▸▸▸ TOP STORIES
|
||||
|
||||
// Animated stat counter
|
||||
statCounter(fb, x, y, 1247, progress, {
|
||||
prefix: 'COMMITS: ',
|
||||
commas: true,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## News-Specific Effects Reference
|
||||
|
||||
### Lower Third (Info Bar)
|
||||
|
||||
```javascript
|
||||
// News-style stat display bar
|
||||
lowerThird(fb, 20, 'COMMITS THIS YEAR', '1,247', progress, {
|
||||
style: 'heavy', // 'single', 'double', 'heavy'
|
||||
accentChar: '▌',
|
||||
});
|
||||
// Output:
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃▌ COMMITS THIS YEAR 1,247 ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
```
|
||||
|
||||
### Ticker Tape
|
||||
|
||||
```javascript
|
||||
// Scrolling news ticker
|
||||
tickerTape(fb, 23, [
|
||||
'47 PRs merged',
|
||||
'12 repos touched',
|
||||
'892 files changed',
|
||||
'156 bugs squashed',
|
||||
], frame, { separator: ' ▸ ', speed: 0.5 });
|
||||
// Output: 47 PRs merged ▸ 12 repos touched ▸ 892 files changed ▸ ...
|
||||
```
|
||||
|
||||
### Breaking News Banner
|
||||
|
||||
```javascript
|
||||
// Flashing breaking news alert
|
||||
breakingBanner(fb, 10, 'NEW PERSONAL BEST', frame, { flash: true });
|
||||
// Output:
|
||||
// ╔═══════════════════════════════════════╗
|
||||
// ║ ⚡ NEW PERSONAL BEST ⚡ ║
|
||||
// ╚═══════════════════════════════════════╝
|
||||
```
|
||||
|
||||
### Live Indicator
|
||||
|
||||
```javascript
|
||||
// Blinking LIVE badge (top corner)
|
||||
liveIndicator(fb, 2, 1, frame, { speed: 0.3 });
|
||||
// Output: ● LIVE (dot blinks)
|
||||
```
|
||||
|
||||
### Forecast Bar (Weather-Style)
|
||||
|
||||
```javascript
|
||||
// Horizontal bar chart for stats
|
||||
forecastBar(fb, 5, 10, 'JANUARY', 0.3, 40, progress);
|
||||
forecastBar(fb, 5, 11, 'FEBRUARY', 0.5, 40, progress);
|
||||
forecastBar(fb, 5, 12, 'MARCH', 0.8, 40, progress);
|
||||
// Output:
|
||||
// JANUARY ███████░░░░░░░░░░░░░
|
||||
// FEBRUARY █████████████░░░░░░░
|
||||
// MARCH ████████████████████
|
||||
```
|
||||
|
||||
### Split Wipe Transition
|
||||
|
||||
```javascript
|
||||
// News-style wipe from center outward
|
||||
splitWipe(fb, progress);
|
||||
```
|
||||
|
||||
### Push Transition
|
||||
|
||||
```javascript
|
||||
// Content slides off as new content slides in
|
||||
pushTransition(fb, progress, 'left'); // 'left', 'right', 'up', 'down'
|
||||
```
|
||||
|
||||
### Countdown Reveal
|
||||
|
||||
```javascript
|
||||
// Dramatic "3... 2... 1... GO!" countdown
|
||||
countdownReveal(fb, progress, {
|
||||
numbers: ['3', '2', '1', 'GO!'],
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Sample Phrases
|
||||
|
||||
- "Good morning! Let's look at the numbers..."
|
||||
- "Breaking overnight: a new milestone reached"
|
||||
- "In developer news today..."
|
||||
- "Our top story this hour..."
|
||||
- "And now for your coding forecast..."
|
||||
- "In sports: a record-breaking performance"
|
||||
- "And finally, a story that will warm your heart..."
|
||||
- "That's the news. Thanks for watching."
|
||||
|
||||
---
|
||||
|
||||
### Example Scene Structure
|
||||
|
||||
```javascript
|
||||
case 'BREAKING': {
|
||||
// Background
|
||||
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
|
||||
|
||||
// Live indicator in corner
|
||||
liveIndicator(fb, 2, 1, frame);
|
||||
|
||||
// Breaking banner
|
||||
if (p < 0.3) {
|
||||
breakingBanner(fb, 8, 'NEW PERSONAL BEST', frame, { flash: true });
|
||||
}
|
||||
|
||||
// Lower third with stat
|
||||
if (p > 0.4) {
|
||||
lowerThird(fb, 18, 'TOTAL COMMITS', '1,247', (p - 0.4) / 0.6);
|
||||
}
|
||||
|
||||
// Ticker at bottom
|
||||
tickerTape(fb, 23, ['47 PRs', '12 repos', '892 files'], frame);
|
||||
|
||||
// Transition out
|
||||
if (p > 0.9) {
|
||||
pushTransition(fb, (p - 0.9) / 0.1, 'left');
|
||||
}
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Article Display Helpers Reference
|
||||
|
||||
### News Article (Full Article Card)
|
||||
|
||||
```javascript
|
||||
// Display a complete news article card
|
||||
newsArticle(fb, 5, 3, {
|
||||
category: 'TECH',
|
||||
headline: 'NEW FEATURE SHIPS',
|
||||
subhead: 'Users love the update',
|
||||
body: 'The new feature has been deployed to production and is already seeing great adoption.',
|
||||
stat: 1247,
|
||||
statLabel: 'users affected',
|
||||
}, progress, {
|
||||
width: 45,
|
||||
style: 'boxed', // 'boxed', 'minimal', 'breaking'
|
||||
});
|
||||
// Output:
|
||||
// ┌───────────────────────────────────────────┐
|
||||
// TECH
|
||||
// NEW FEATURE SHIPS
|
||||
// Users love the update
|
||||
// ···········
|
||||
// The new feature has been deployed to
|
||||
// production and is already seeing...
|
||||
// 1,247
|
||||
// users affected
|
||||
// └───────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### News Grid (Multiple Articles)
|
||||
|
||||
```javascript
|
||||
// Display multiple articles in a grid layout
|
||||
newsGrid(fb, [
|
||||
{ category: 'COMMITS', headline: 'Record Month', stat: 275 },
|
||||
{ category: 'FEATURES', headline: 'SDK Released', stat: 15 },
|
||||
{ category: 'DOCS', headline: 'Guides Updated', stat: 42 },
|
||||
{ category: 'FIXES', headline: 'Bugs Squashed', stat: 89 },
|
||||
], progress, {
|
||||
columns: 2,
|
||||
startY: 4,
|
||||
startX: 2,
|
||||
spacing: 2,
|
||||
articleWidth: 35,
|
||||
staggerDelay: 0.15, // Stagger animation for each article
|
||||
});
|
||||
```
|
||||
|
||||
### Project Spotlight (Featured Project) ⭐
|
||||
|
||||
**This is the star helper for the morning news vibe.** Use it to give each project its own dedicated feature article.
|
||||
|
||||
```javascript
|
||||
// Highlight a single project with full details
|
||||
accomplishmentSpotlight(fb, {
|
||||
name: 'claude-code',
|
||||
commits: 275,
|
||||
rank: 1,
|
||||
description: 'CLI tool for developers',
|
||||
body: 'Major refactors to agent architecture shipped this year. The CLI became faster, smarter, and more powerful with new subagent capabilities.',
|
||||
}, progress, frame, {
|
||||
y: 5,
|
||||
width: 50,
|
||||
centered: true,
|
||||
showRank: true,
|
||||
});
|
||||
// Output:
|
||||
// ╔════════════════════════════════════════════════╗
|
||||
// #1 PROJECT
|
||||
// ║ claude-code ║
|
||||
// ╟────────────────────────────────────────────────╢
|
||||
// ║ 275 COMMITS ║
|
||||
// ║ CLI tool for developers ║
|
||||
// ║ ··················· ║
|
||||
// ║ Major refactors to agent architecture ║
|
||||
// ║ shipped this year. The CLI became faster, ║
|
||||
// ║ smarter, and more powerful with new ║
|
||||
// ║ subagent capabilities. ║
|
||||
// ╚════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
### Headline Carousel (Rotating Headlines)
|
||||
|
||||
```javascript
|
||||
// Cycle through headlines with animations
|
||||
headlineCarousel(fb, [
|
||||
'RECORD COMMITS THIS WEEK',
|
||||
'NEW PERSONAL BEST ACHIEVED',
|
||||
'DOCUMENTATION SHIPPED',
|
||||
], progress, frame, {
|
||||
y: 10,
|
||||
style: 'crawl', // 'crawl', 'slide', 'fade'
|
||||
});
|
||||
```
|
||||
|
||||
### News Feed (Scrolling Headlines)
|
||||
|
||||
```javascript
|
||||
// Vertical scrolling news feed
|
||||
newsFeed(fb, [
|
||||
{ category: 'BREAKING', headline: 'New milestone reached' },
|
||||
{ category: 'TECH', headline: 'API improvements deployed' },
|
||||
{ category: 'SPORTS', headline: 'Streak continues: 16 days' },
|
||||
], frame, {
|
||||
x: 2,
|
||||
y: 4,
|
||||
width: 40,
|
||||
height: 15,
|
||||
speed: 0.1,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example: Project Carousel Scene (The Star of the Show)
|
||||
|
||||
This is how you make projects shine. Each of the top 3 projects gets its own full-screen feature article:
|
||||
|
||||
```javascript
|
||||
case 'PROJECT_SPOTLIGHT': {
|
||||
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
|
||||
liveIndicator(fb, 2, 1, frame);
|
||||
segmentTitle(fb, 3, 'PROJECT SPOTLIGHT', Math.min(1, p / 0.2), { style: 'arrow' });
|
||||
|
||||
// Top 3 projects - each gets its own feature article
|
||||
const projects = [
|
||||
{
|
||||
name: 'claude-code',
|
||||
commits: 275,
|
||||
rank: 1,
|
||||
description: 'The heart of Claude Code',
|
||||
body: 'Major refactors to agent architecture, new subagent system, and animation framework shipped. The CLI became faster, smarter, and more powerful.',
|
||||
},
|
||||
{
|
||||
name: 'sdk-demos',
|
||||
commits: 27,
|
||||
rank: 2,
|
||||
description: 'Example applications & demos',
|
||||
body: 'Email agent, deep research demos, and customer showcase apps built to demonstrate SDK capabilities. These demos help teams see the art of the possible.',
|
||||
},
|
||||
{
|
||||
name: 'docs',
|
||||
commits: 15,
|
||||
rank: 3,
|
||||
description: 'Documentation & guides',
|
||||
body: 'User guides, API reference, and tutorials to help developers get started quickly. Good docs make great products.',
|
||||
},
|
||||
];
|
||||
|
||||
// Carousel through projects - each gets dedicated screen time
|
||||
const numProjects = projects.length;
|
||||
if (p > 0.15) {
|
||||
const carouselP = (p - 0.15) / 0.8;
|
||||
const projectIdx = Math.min(numProjects - 1, Math.floor(carouselP * numProjects));
|
||||
const projectLocalP = (carouselP * numProjects) % 1;
|
||||
|
||||
// Use accomplishmentSpotlight for the full article experience
|
||||
accomplishmentSpotlight(fb, projects[projectIdx], Math.min(1, projectLocalP * 1.5), frame, {
|
||||
y: 5,
|
||||
width: 55,
|
||||
centered: true,
|
||||
showRank: true,
|
||||
});
|
||||
|
||||
// Progress indicator
|
||||
fb.drawText(fb.width - 5, 3, `${projectIdx + 1}/${numProjects}`);
|
||||
}
|
||||
|
||||
tickerTape(fb, 23, projects.map(p => `#${p.rank} ${p.name}: ${p.commits} commits`), frame);
|
||||
break;
|
||||
}
|
||||
```
|
||||
5
plugins/thinkback/skills/thinkback/vibes/other-vibe.md
Normal file
5
plugins/thinkback/skills/thinkback/vibes/other-vibe.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Other Vibe Instructions
|
||||
|
||||
The user is not able to select other and give custom instructions.
|
||||
|
||||
Ask them again using the AskUserQuestion Tool what vibe they would like from: cozy, awards show, morning news and rpg quest.
|
||||
603
plugins/thinkback/skills/thinkback/vibes/rpg-quest-vibe.md
Normal file
603
plugins/thinkback/skills/thinkback/vibes/rpg-quest-vibe.md
Normal file
@@ -0,0 +1,603 @@
|
||||
# RPG Quest Vibe Instructions
|
||||
|
||||
Generate an epic RPG adventure experience. Think quest logs, hero's journey, level-ups, and legendary achievements. Frame the year as a completed adventure with dungeons conquered and bosses defeated.
|
||||
|
||||
Imagine you are the narrator of an 8-bit RPG recounting a hero's legendary year of quests.
|
||||
|
||||
## Tone Guidelines
|
||||
|
||||
- **Epic but warm**: Heroic language without being overwrought
|
||||
- **Nostalgic RPG flavor**: References to classic games (Final Fantasy, Dragon Quest, Zelda)
|
||||
- **Achievement-focused**: Every project is a quest completed, every stat is XP earned
|
||||
- **Encouraging**: The hero has grown stronger through their journey
|
||||
|
||||
## Pacing
|
||||
|
||||
- Classic RPG text box reveals (character by character with sound effect feeling)
|
||||
- Dramatic pauses before big stat reveals ("You gained... 1,247 XP!")
|
||||
- Quest completion fanfares for project spotlights
|
||||
- Slow, reflective ending like a credits roll
|
||||
|
||||
## Segment Ideas
|
||||
|
||||
Structure the thinkback like an RPG adventure:
|
||||
|
||||
- **TITLE SCREEN**: "Press Start" intro, game logo, year
|
||||
- **CHARACTER SELECT**: User's "class" based on their work patterns
|
||||
- **ADVENTURE BEGINS**: Set the scene, the hero enters the codebase
|
||||
- **QUEST LOG**: **THE STAR OF THE SHOW** - Top 3 projects as completed quests, each with its own full quest card showing objectives, rewards, and story
|
||||
- **BOSS BATTLES**: Major challenges overcome (big refactors, critical bugs, launches)
|
||||
- **LEVEL UP**: Stats gained, skills acquired, growth over the year
|
||||
- **CREDITS ROLL**: Warm closing, "Your adventure continues..."
|
||||
|
||||
### Quest Cards Are the Star
|
||||
|
||||
The quest log segment should be the emotional centerpiece. For each of the user's top 3 projects:
|
||||
|
||||
1. **Quest banner** - "QUEST COMPLETE" fanfare with quest name
|
||||
2. **Objectives list** - What was accomplished (commits, features, fixes)
|
||||
3. **Quest story** - 2-3 sentences about the journey
|
||||
4. **Rewards earned** - XP, gold (commits), items (skills learned)
|
||||
5. **Completion flourish** - Sparkles, level-up sound effect feeling
|
||||
|
||||
Think of each project like completing a major side quest - it deserves a full quest completion screen.
|
||||
|
||||
## Character Classes
|
||||
|
||||
Assign the user a class based on their work patterns:
|
||||
|
||||
- **Bug Slayer**: Lots of fixes, issue closures
|
||||
- **Feature Crafter**: New functionality, enhancements
|
||||
- **Docs Wizard**: Documentation, guides, READMEs
|
||||
- **Refactor Knight**: Code improvements, cleanup
|
||||
- **Full Stack Paladin**: Balanced across all areas
|
||||
- **Speed Demon**: High commit velocity
|
||||
- **Deep Delver**: Long-running complex projects
|
||||
|
||||
## Closing Scene
|
||||
|
||||
End with classic RPG warmth:
|
||||
|
||||
- "Your adventure continues in 2025..."
|
||||
- "SAVE COMPLETE. See you next quest."
|
||||
- "The hero rests... but new adventures await."
|
||||
- "TO BE CONTINUED..."
|
||||
|
||||
---
|
||||
|
||||
## Recommended Helpers for RPG Quest Vibe
|
||||
|
||||
Access helpers by destructuring from `globalThis` at the top of your file:
|
||||
|
||||
```javascript
|
||||
const {
|
||||
// Backgrounds
|
||||
starfield, gradient,
|
||||
|
||||
// Celebration particles
|
||||
confetti, sparkles, burst, glitter,
|
||||
|
||||
// Transitions
|
||||
pixelate, blindsH, blindsV, wipeRight, wipeDown,
|
||||
fade, dissolve,
|
||||
|
||||
// Text effects
|
||||
drawTypewriterCentered, drawZoomText, slideIn,
|
||||
drawWaveText, drawGlitchText,
|
||||
|
||||
// RPG-specific effects
|
||||
questCard, questBanner, xpBar, levelUp,
|
||||
textBox, characterSprite, statsPanel,
|
||||
inventorySlot, bossHealth, victoryFanfare,
|
||||
titleScreen, classSelect, creditsRoll,
|
||||
} = globalThis;
|
||||
```
|
||||
|
||||
### Background Combinations
|
||||
|
||||
```javascript
|
||||
// Starfield for title screen / space dungeons
|
||||
starfield(fb, frame, { speed: 1, numStars: 30 });
|
||||
|
||||
// Subtle gradient for quest screens
|
||||
gradient(fb, { direction: 'vertical', chars: [' ', '·', '░'] });
|
||||
```
|
||||
|
||||
### Classic RPG Transitions
|
||||
|
||||
```javascript
|
||||
// Pixelate (battle transition feel)
|
||||
pixelate(fb, progress, 8);
|
||||
|
||||
// Blinds (menu/screen change)
|
||||
blindsH(fb, progress, 4);
|
||||
|
||||
// Fade for emotional moments
|
||||
fade(fb, progress);
|
||||
```
|
||||
|
||||
### Celebration Effects
|
||||
|
||||
```javascript
|
||||
// Quest complete sparkles
|
||||
sparkles(fb, frame, { density: 0.006, chars: ['*', '+', '·'] });
|
||||
|
||||
// Level up burst
|
||||
burst(fb, frame, { x: 40, y: 12, count: 12 });
|
||||
|
||||
// Victory confetti
|
||||
confetti(fb, frame, { count: 20, chars: ['*', '◆', '●', '▲'] });
|
||||
```
|
||||
|
||||
### Text Animation Examples
|
||||
|
||||
```javascript
|
||||
// RPG text box style (character by character)
|
||||
textBox(fb, y, 'A new quest awaits...', progress, frame, {
|
||||
width: 50,
|
||||
style: 'rpg',
|
||||
});
|
||||
|
||||
// Dramatic zoom for numbers
|
||||
drawZoomText(fb, y, '+1,247 XP', progress);
|
||||
|
||||
// Wave text for celebration
|
||||
drawWaveText(fb, y, 'LEVEL UP!', frame, { amplitude: 1, speed: 2 });
|
||||
|
||||
// Slide in for menu items
|
||||
slideIn(fb, y, '> QUEST LOG', progress, { from: 'left' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RPG-Specific Effects Reference
|
||||
|
||||
### Title Screen
|
||||
|
||||
```javascript
|
||||
// Classic RPG title screen
|
||||
titleScreen(fb, {
|
||||
title: 'YEAR IN CODE',
|
||||
subtitle: '2024',
|
||||
prompt: 'PRESS START',
|
||||
}, progress, frame);
|
||||
// Output:
|
||||
//
|
||||
// ╔═══════════════════════════════╗
|
||||
// ║ ║
|
||||
// ║ YEAR IN CODE ║
|
||||
// ║ 2024 ║
|
||||
// ║ ║
|
||||
// ║ PRESS START ║ (blinking)
|
||||
// ║ ║
|
||||
// ╚═══════════════════════════════╝
|
||||
```
|
||||
|
||||
### Text Box (RPG Dialog Style)
|
||||
|
||||
```javascript
|
||||
// Classic RPG text box with character-by-character reveal
|
||||
textBox(fb, 16, 'The hero embarked on a year of epic quests...', progress, frame, {
|
||||
width: 60,
|
||||
style: 'rpg', // 'rpg', 'modern', 'minimal'
|
||||
speaker: 'NARRATOR',
|
||||
});
|
||||
// Output:
|
||||
// ┌─ NARRATOR ─────────────────────────────────────────────┐
|
||||
// │ The hero embarked on a year of epic quests... │
|
||||
// │ ▼ │
|
||||
// └────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Class Select
|
||||
|
||||
```javascript
|
||||
// Character class display
|
||||
classSelect(fb, {
|
||||
className: 'FEATURE CRAFTER',
|
||||
description: 'A builder of new worlds',
|
||||
stats: { STR: 8, DEX: 6, INT: 9, WIS: 7 },
|
||||
traits: ['Creative', 'Persistent', 'Ambitious'],
|
||||
}, progress, frame, {
|
||||
y: 5,
|
||||
showSprite: true,
|
||||
});
|
||||
// Output:
|
||||
// ╭───────╮
|
||||
// │ ◉◡◉ │
|
||||
// │ /|\ │
|
||||
// │ / \ │
|
||||
// ╰───────╯
|
||||
// FEATURE CRAFTER
|
||||
// "A builder of new worlds"
|
||||
//
|
||||
// STR ████████░░ 8
|
||||
// DEX ██████░░░░ 6
|
||||
// INT █████████░ 9
|
||||
// WIS ███████░░░ 7
|
||||
//
|
||||
// [Creative] [Persistent] [Ambitious]
|
||||
```
|
||||
|
||||
### Quest Card (Project Spotlight)
|
||||
|
||||
**This is the star helper for the RPG quest vibe.** Use it to give each project a full quest completion screen.
|
||||
|
||||
```javascript
|
||||
// Full quest completion card
|
||||
questCard(fb, {
|
||||
name: 'claude-code',
|
||||
commits: 275,
|
||||
rank: 1,
|
||||
description: 'The legendary CLI tool',
|
||||
body: 'The hero ventured deep into the codebase, refactoring ancient architectures and forging new subagent systems. Many bugs were slain along the way.',
|
||||
}, progress, frame, {
|
||||
y: 3,
|
||||
width: 55,
|
||||
showRewards: true,
|
||||
});
|
||||
// Output:
|
||||
// ╔═══════════════════════════════════════════════════════╗
|
||||
// ║ ★ QUEST COMPLETE ★ ║
|
||||
// ╟───────────────────────────────────────────────────────╢
|
||||
// ║ ║
|
||||
// ║ claude-code ║
|
||||
// ║ "The legendary CLI tool" ║
|
||||
// ║ ───────────────────────────────────────────────── ║
|
||||
// ║ The hero ventured deep into the codebase, ║
|
||||
// ║ refactoring ancient architectures and forging ║
|
||||
// ║ new subagent systems. Many bugs were slain ║
|
||||
// ║ along the way. ║
|
||||
// ║ ║
|
||||
// ║ ┌─ REWARDS ─────────────────────────────────────┐ ║
|
||||
// ║ │ +275 XP +3 Skills +1 Legendary Item │ ║
|
||||
// ║ └───────────────────────────────────────────────┘ ║
|
||||
// ╚═══════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
### Quest Banner
|
||||
|
||||
```javascript
|
||||
// Animated quest complete banner
|
||||
questBanner(fb, 'QUEST COMPLETE', progress, frame, {
|
||||
y: 2,
|
||||
style: 'fanfare', // 'fanfare', 'simple', 'legendary'
|
||||
});
|
||||
// Output (with animation):
|
||||
// ·:·:·:·:· QUEST COMPLETE ·:·:·:·:·
|
||||
```
|
||||
|
||||
### XP Bar
|
||||
|
||||
```javascript
|
||||
// Animated XP/progress bar
|
||||
xpBar(fb, x, y, {
|
||||
current: 1247,
|
||||
max: 2000,
|
||||
label: 'LEVEL 7',
|
||||
}, progress, {
|
||||
width: 40,
|
||||
showNumbers: true,
|
||||
});
|
||||
// Output:
|
||||
// LEVEL 7 ████████████████████░░░░░░░░░░ 1,247 / 2,000 XP
|
||||
```
|
||||
|
||||
### Level Up
|
||||
|
||||
```javascript
|
||||
// Level up celebration effect
|
||||
levelUp(fb, {
|
||||
level: 7,
|
||||
stats: [
|
||||
{ name: 'COMMITS', gained: '+275' },
|
||||
{ name: 'PROJECTS', gained: '+3' },
|
||||
{ name: 'SKILLS', gained: '+5' },
|
||||
],
|
||||
}, progress, frame, {
|
||||
y: 8,
|
||||
});
|
||||
// Output:
|
||||
// ╔═══════════════════╗
|
||||
// ║ LEVEL UP! ║
|
||||
// ║ LV. 7 ║
|
||||
// ╠═══════════════════╣
|
||||
// ║ COMMITS +275 ║
|
||||
// ║ PROJECTS +3 ║
|
||||
// ║ SKILLS +5 ║
|
||||
// ╚═══════════════════╝
|
||||
```
|
||||
|
||||
### Stats Panel
|
||||
|
||||
```javascript
|
||||
// RPG-style stats display
|
||||
statsPanel(fb, x, y, {
|
||||
'COMMITS': 1247,
|
||||
'QUESTS': 12,
|
||||
'BUGS SLAIN': 89,
|
||||
'FEATURES': 34,
|
||||
}, progress, {
|
||||
style: 'bordered',
|
||||
width: 30,
|
||||
});
|
||||
// Output:
|
||||
// ┌─ HERO STATS ─────────────┐
|
||||
// │ COMMITS 1,247 │
|
||||
// │ QUESTS 12 │
|
||||
// │ BUGS SLAIN 89 │
|
||||
// │ FEATURES 34 │
|
||||
// └──────────────────────────┘
|
||||
```
|
||||
|
||||
### Character Sprite
|
||||
|
||||
```javascript
|
||||
// Simple ASCII character sprite
|
||||
characterSprite(fb, x, y, {
|
||||
class: 'FEATURE_CRAFTER',
|
||||
animate: true,
|
||||
}, frame);
|
||||
// Output (animated):
|
||||
// ◉◡◉
|
||||
// /|\
|
||||
// / \
|
||||
```
|
||||
|
||||
### Boss Health Bar
|
||||
|
||||
```javascript
|
||||
// Boss battle health bar (for challenges overcome)
|
||||
bossHealth(fb, y, {
|
||||
name: 'LEGACY CODEBASE',
|
||||
health: 0, // Defeated!
|
||||
maxHealth: 100,
|
||||
}, progress, frame);
|
||||
// Output:
|
||||
// LEGACY CODEBASE
|
||||
// [░░░░░░░░░░░░░░░░░░░░] DEFEATED!
|
||||
```
|
||||
|
||||
### Victory Fanfare
|
||||
|
||||
```javascript
|
||||
// Victory celebration effect
|
||||
victoryFanfare(fb, frame, {
|
||||
intensity: 1.0,
|
||||
style: 'classic', // 'classic', 'epic', 'subtle'
|
||||
});
|
||||
// Triggers sparkles, bursts, and celebration particles
|
||||
```
|
||||
|
||||
### Credits Roll
|
||||
|
||||
```javascript
|
||||
// Scrolling credits with RPG feel
|
||||
creditsRoll(fb, [
|
||||
{ type: 'header', text: 'QUEST COMPLETE' },
|
||||
{ type: 'stat', label: 'Total XP', value: '12,470' },
|
||||
{ type: 'stat', label: 'Quests Completed', value: '47' },
|
||||
{ type: 'stat', label: 'Bugs Slain', value: '156' },
|
||||
{ type: 'spacer' },
|
||||
{ type: 'text', text: 'Your adventure continues...' },
|
||||
{ type: 'text', text: '2025' },
|
||||
], progress, frame, {
|
||||
speed: 0.5,
|
||||
});
|
||||
```
|
||||
|
||||
### Inventory Slot
|
||||
|
||||
```javascript
|
||||
// Show acquired "items" (skills, tools, achievements)
|
||||
inventorySlot(fb, x, y, {
|
||||
icon: '⚔',
|
||||
name: 'TypeScript',
|
||||
rarity: 'epic', // 'common', 'rare', 'epic', 'legendary'
|
||||
}, progress);
|
||||
// Output:
|
||||
// ┌───┐
|
||||
// │ ⚔ │ TypeScript
|
||||
// └───┘ ★★★☆
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Sample Phrases
|
||||
|
||||
- "A new quest awaits..."
|
||||
- "The hero ventured forth into the codebase..."
|
||||
- "Quest complete! You gained 275 XP."
|
||||
- "A legendary bug has been slain!"
|
||||
- "Level up! Your skills have grown."
|
||||
- "The dungeon has been cleared."
|
||||
- "Your party grows stronger."
|
||||
- "A new skill has been learned!"
|
||||
- "Victory! The refactor is complete."
|
||||
- "Save complete. Your progress has been recorded."
|
||||
- "The adventure continues..."
|
||||
|
||||
---
|
||||
|
||||
### Example Scene Structure
|
||||
|
||||
```javascript
|
||||
case 'TITLE_SCREEN': {
|
||||
starfield(fb, frame, { speed: 1, numStars: 25 });
|
||||
|
||||
titleScreen(fb, {
|
||||
title: 'YEAR IN CODE',
|
||||
subtitle: '2024',
|
||||
prompt: 'PRESS START',
|
||||
}, p, frame);
|
||||
|
||||
// Transition out
|
||||
if (p > 0.85) {
|
||||
pixelate(fb, (p - 0.85) / 0.15, 8);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CLASS_REVEAL': {
|
||||
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
|
||||
|
||||
// Build up
|
||||
if (p < 0.3) {
|
||||
textBox(fb, 18, 'Your deeds have defined you...', p / 0.3, frame, {
|
||||
width: 50,
|
||||
style: 'rpg',
|
||||
});
|
||||
}
|
||||
|
||||
// Class reveal
|
||||
if (p >= 0.3) {
|
||||
const classP = (p - 0.3) / 0.7;
|
||||
classSelect(fb, {
|
||||
className: 'FEATURE CRAFTER',
|
||||
description: 'A builder of new worlds',
|
||||
stats: { STR: 8, DEX: 6, INT: 9, WIS: 7 },
|
||||
traits: ['Creative', 'Persistent', 'Ambitious'],
|
||||
}, classP, frame, {
|
||||
y: 4,
|
||||
showSprite: true,
|
||||
});
|
||||
|
||||
if (classP > 0.5) {
|
||||
sparkles(fb, frame, { density: 0.004 });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example: Quest Log Sequence (The Star of the Show)
|
||||
|
||||
This is how you make projects shine. Each of the top 3 projects gets a full quest completion screen:
|
||||
|
||||
```javascript
|
||||
case 'QUEST_LOG': {
|
||||
gradient(fb, { direction: 'vertical', chars: [' ', '░'] });
|
||||
|
||||
const projects = [
|
||||
{
|
||||
name: 'claude-code',
|
||||
commits: 275,
|
||||
rank: 1,
|
||||
description: 'The legendary CLI tool',
|
||||
body: 'The hero ventured deep into the codebase, refactoring ancient architectures and forging new subagent systems. Many bugs were slain along the way.',
|
||||
},
|
||||
{
|
||||
name: 'sdk-demos',
|
||||
commits: 27,
|
||||
rank: 2,
|
||||
description: 'Grimoire of examples',
|
||||
body: 'Ancient knowledge was transcribed into demos and examples. Future adventurers will learn from these scrolls.',
|
||||
},
|
||||
{
|
||||
name: 'docs',
|
||||
commits: 15,
|
||||
rank: 3,
|
||||
description: 'The sacred texts',
|
||||
body: 'Documentation was written to guide those who follow. The path is now clear for all.',
|
||||
},
|
||||
];
|
||||
|
||||
// Quest log header
|
||||
if (p < 0.1) {
|
||||
questBanner(fb, 'QUEST LOG', p / 0.1, frame, { y: 2, style: 'simple' });
|
||||
}
|
||||
|
||||
// Cycle through quests - each gets dedicated screen time
|
||||
if (p >= 0.1) {
|
||||
const questP = (p - 0.1) / 0.9;
|
||||
const numQuests = projects.length;
|
||||
const questIdx = Math.min(numQuests - 1, Math.floor(questP * numQuests));
|
||||
const questLocalP = (questP * numQuests) % 1;
|
||||
|
||||
const project = projects[questIdx];
|
||||
|
||||
// Quest complete banner
|
||||
if (questLocalP < 0.2) {
|
||||
questBanner(fb, 'QUEST COMPLETE', questLocalP / 0.2, frame, {
|
||||
y: 2,
|
||||
style: 'fanfare',
|
||||
});
|
||||
}
|
||||
|
||||
// Full quest card
|
||||
if (questLocalP >= 0.2) {
|
||||
const cardP = (questLocalP - 0.2) / 0.8;
|
||||
questCard(fb, project, cardP, frame, {
|
||||
y: 4,
|
||||
width: 55,
|
||||
showRewards: true,
|
||||
});
|
||||
|
||||
// Victory sparkles
|
||||
if (cardP > 0.3) {
|
||||
sparkles(fb, frame, { density: 0.005, chars: ['*', '+', '·'] });
|
||||
}
|
||||
}
|
||||
|
||||
// Progress indicator
|
||||
fb.drawText(fb.width - 8, 2, `${questIdx + 1}/${numQuests}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'LEVEL_UP': {
|
||||
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
|
||||
|
||||
// Dramatic build
|
||||
if (p < 0.3) {
|
||||
textBox(fb, 10, 'Your journey has made you stronger...', p / 0.3, frame, {
|
||||
width: 45,
|
||||
style: 'rpg',
|
||||
});
|
||||
}
|
||||
|
||||
// Level up reveal
|
||||
if (p >= 0.3) {
|
||||
const lvlP = (p - 0.3) / 0.7;
|
||||
levelUp(fb, {
|
||||
level: 7,
|
||||
stats: [
|
||||
{ name: 'COMMITS', gained: '+1,247' },
|
||||
{ name: 'QUESTS', gained: '+12' },
|
||||
{ name: 'BUGS SLAIN', gained: '+89' },
|
||||
],
|
||||
}, lvlP, frame, {
|
||||
y: 6,
|
||||
});
|
||||
|
||||
// Celebration
|
||||
if (lvlP > 0.3) {
|
||||
victoryFanfare(fb, frame, { intensity: lvlP });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CREDITS': {
|
||||
starfield(fb, frame, { speed: 0.5, numStars: 20 });
|
||||
|
||||
creditsRoll(fb, [
|
||||
{ type: 'header', text: 'ADVENTURE COMPLETE' },
|
||||
{ type: 'spacer' },
|
||||
{ type: 'stat', label: 'Total XP', value: '12,470' },
|
||||
{ type: 'stat', label: 'Quests Completed', value: '12' },
|
||||
{ type: 'stat', label: 'Bugs Slain', value: '89' },
|
||||
{ type: 'stat', label: 'Features Forged', value: '34' },
|
||||
{ type: 'spacer' },
|
||||
{ type: 'spacer' },
|
||||
{ type: 'text', text: 'Your adventure continues...' },
|
||||
{ type: 'spacer' },
|
||||
{ type: 'header', text: '2025' },
|
||||
], p, frame, {
|
||||
speed: 0.4,
|
||||
});
|
||||
break;
|
||||
}
|
||||
```
|
||||
502
plugins/thinkback/skills/thinkback/year_in_review.html
Normal file
502
plugins/thinkback/skills/thinkback/year_in_review.html
Normal file
@@ -0,0 +1,502 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>2025 Year in Review - Download</title>
|
||||
<style>
|
||||
body {
|
||||
background: #191919;
|
||||
color: #FAFAF7;
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
h1 {
|
||||
margin-bottom: 20px;
|
||||
color: #E3703F;
|
||||
}
|
||||
#status {
|
||||
margin-bottom: 20px;
|
||||
color: #91918D;
|
||||
}
|
||||
#progress {
|
||||
margin-bottom: 20px;
|
||||
color: #FAFAF7;
|
||||
}
|
||||
button {
|
||||
background: #E3703F;
|
||||
color: #191919;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
button:hover {
|
||||
background: #D4A27F;
|
||||
}
|
||||
button:disabled {
|
||||
background: #40403E;
|
||||
color: #666663;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
#reminder {
|
||||
display: none;
|
||||
margin-top: 24px;
|
||||
padding: 16px 24px;
|
||||
background: #2a2a28;
|
||||
border: 1px solid #40403E;
|
||||
border-radius: 6px;
|
||||
max-width: 500px;
|
||||
text-align: center;
|
||||
}
|
||||
#reminder h3 {
|
||||
color: #E3703F;
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
#reminder p {
|
||||
color: #91918D;
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
#reminder code {
|
||||
background: #191919;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
color: #FAFAF7;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Claude Code: 2025 Year in Review</h1>
|
||||
<div id="status">Ready to generate video</div>
|
||||
<div id="progress"></div>
|
||||
<button id="downloadBtn">Generate & Download Video</button>
|
||||
<div id="reminder">
|
||||
<h3>Before sharing your Thinkback</h3>
|
||||
<p>Please review the video for any sensitive or confidential information (project names, commits, etc.)</p>
|
||||
<p>To make changes, run <code>/thinkback</code> again and select "edit" or "regenerate".</p>
|
||||
</div>
|
||||
|
||||
<!-- Hidden canvas for off-screen rendering -->
|
||||
<canvas id="canvas" width="1152" height="1040" style="display: none;"></canvas>
|
||||
|
||||
<!-- MP4 muxer for WebCodecs output -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/mp4-muxer@5.1.3/build/mp4-muxer.min.js"></script>
|
||||
|
||||
<!-- Load animation helpers (sets globalThis) -->
|
||||
<script src="helpers/transitions.js"></script>
|
||||
<script src="helpers/backgrounds.js"></script>
|
||||
<script src="helpers/text_effects.js"></script>
|
||||
<script src="helpers/particles.js"></script>
|
||||
<script src="helpers/borders.js"></script>
|
||||
<script src="helpers/scene_system.js"></script>
|
||||
<script src="helpers/awards_effects.js"></script>
|
||||
<script src="helpers/news_effects.js"></script>
|
||||
<script src="helpers/rpg_effects.js"></script>
|
||||
<script src="year_in_review.js"></script>
|
||||
|
||||
<script>
|
||||
// Global error handler to display errors visibly
|
||||
window.onerror = function(msg, url, lineNo, columnNo, error) {
|
||||
const status = document.getElementById('status');
|
||||
const progress = document.getElementById('progress');
|
||||
if (status) {
|
||||
status.style.color = '#ff6b6b';
|
||||
status.textContent = 'Error: ' + msg;
|
||||
}
|
||||
if (progress) {
|
||||
progress.style.color = '#ff6b6b';
|
||||
progress.style.whiteSpace = 'pre-wrap';
|
||||
progress.style.textAlign = 'left';
|
||||
progress.style.maxWidth = '800px';
|
||||
const file = url ? url.split('/').pop() : 'unknown';
|
||||
progress.textContent = `File: ${file}\nLine: ${lineNo}, Col: ${columnNo}\n\n${error ? error.stack : ''}\n\nPlease run /thinkback again in Claude Code to regenerate the animation.`;
|
||||
}
|
||||
const btn = document.getElementById('downloadBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
return false;
|
||||
};
|
||||
|
||||
(function() {
|
||||
if (!globalThis.YearInReviewScenes) {
|
||||
throw new Error('YearInReviewScenes not loaded. Check year_in_review.js for syntax errors.');
|
||||
}
|
||||
const { mainAnimation, TOTAL_FRAMES } = globalThis.YearInReviewScenes;
|
||||
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const DPI_SCALE = 2;
|
||||
const CHAR_WIDTH = 7.2;
|
||||
const CHAR_HEIGHT = 13;
|
||||
const COLS = 80;
|
||||
const ROWS = 40;
|
||||
const FPS = 24;
|
||||
|
||||
ctx.scale(DPI_SCALE, DPI_SCALE);
|
||||
|
||||
class FrameBuffer {
|
||||
constructor(width, height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.buffer = [];
|
||||
this.colorBuffer = [];
|
||||
this.clear();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.buffer = [];
|
||||
this.colorBuffer = [];
|
||||
for (let y = 0; y < this.height; y++) {
|
||||
this.buffer[y] = [];
|
||||
this.colorBuffer[y] = [];
|
||||
for (let x = 0; x < this.width; x++) {
|
||||
this.buffer[y][x] = ' ';
|
||||
this.colorBuffer[y][x] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getPixel(x, y) {
|
||||
x = Math.floor(x);
|
||||
y = Math.floor(y);
|
||||
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
|
||||
return this.buffer[y][x];
|
||||
}
|
||||
return ' ';
|
||||
}
|
||||
|
||||
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) {
|
||||
this.buffer[y][x] = char;
|
||||
this.colorBuffer[y][x] = color;
|
||||
}
|
||||
}
|
||||
|
||||
drawText(x, y, text, depth = 0, color = null) {
|
||||
x = Math.floor(x);
|
||||
y = Math.floor(y);
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (x + i >= 0 && x + i < this.width && y >= 0 && y < this.height) {
|
||||
this.buffer[y][x + i] = text[i];
|
||||
this.colorBuffer[y][x + i] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawCenteredText(y, text, color = null) {
|
||||
const x = Math.floor((this.width - text.length) / 2);
|
||||
this.drawText(x, y, text, 0, color);
|
||||
}
|
||||
|
||||
drawLargeText(x, y, text) {
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i].toUpperCase();
|
||||
const pattern = FIGLET_FONT[char] || FIGLET_FONT[' '];
|
||||
for (let row = 0; row < 5; row++) {
|
||||
for (let col = 0; col < 5; col++) {
|
||||
if (pattern[row][col] === '@') {
|
||||
this.setPixel(x + i * 6 + col, y + row, '@');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawLargeTextCentered(y, text) {
|
||||
const totalWidth = text.length * 6;
|
||||
const x = Math.floor((this.width - totalWidth) / 2);
|
||||
this.drawLargeText(x, y, text);
|
||||
}
|
||||
|
||||
drawHorizontalLine(x, y, length, char = '-') {
|
||||
for (let i = 0; i < length; i++) {
|
||||
this.setPixel(x + i, y, char);
|
||||
}
|
||||
}
|
||||
|
||||
drawBox(x, y, width, height, char = '+', filled = false) {
|
||||
if (filled) {
|
||||
for (let dy = 0; dy < height; dy++) {
|
||||
for (let dx = 0; dx < width; dx++) {
|
||||
this.setPixel(x + dx, y + dy, char);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let dx = 0; dx < width; dx++) {
|
||||
this.setPixel(x + dx, y, char);
|
||||
this.setPixel(x + dx, y + height - 1, char);
|
||||
}
|
||||
for (let dy = 0; dy < height; dy++) {
|
||||
this.setPixel(x, y + dy, char);
|
||||
this.setPixel(x + width - 1, y + dy, char);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
ctx.fillStyle = '#191919';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = '12px monospace';
|
||||
|
||||
// Block character rendering as rectangles for pixel-perfect display
|
||||
const halfW = CHAR_WIDTH / 2;
|
||||
const halfH = CHAR_HEIGHT / 2;
|
||||
|
||||
for (let y = 0; y < this.height; y++) {
|
||||
for (let x = 0; x < this.width; x++) {
|
||||
const char = this.buffer[y][x];
|
||||
if (char !== ' ') {
|
||||
const color = this.colorBuffer[y][x] || '#FAFAF7';
|
||||
const px = x * CHAR_WIDTH;
|
||||
const py = y * CHAR_HEIGHT;
|
||||
ctx.fillStyle = color;
|
||||
|
||||
// Render block characters as geometric shapes
|
||||
switch (char) {
|
||||
case '█': // Full block
|
||||
ctx.fillRect(px, py, CHAR_WIDTH + 0.5, CHAR_HEIGHT);
|
||||
break;
|
||||
case '▌': // Left half
|
||||
ctx.fillRect(px, py, halfW, CHAR_HEIGHT);
|
||||
break;
|
||||
case '▐': // Right half
|
||||
ctx.fillRect(px + halfW, py, halfW + 0.5, CHAR_HEIGHT);
|
||||
break;
|
||||
case '▀': // Top half
|
||||
ctx.fillRect(px, py, CHAR_WIDTH + 0.5, halfH);
|
||||
break;
|
||||
case '▄': // Bottom half
|
||||
ctx.fillRect(px, py + halfH, CHAR_WIDTH + 0.5, halfH);
|
||||
break;
|
||||
case '▛': // Top-left + top-right + bottom-left
|
||||
ctx.fillRect(px, py, CHAR_WIDTH + 0.5, halfH); // top
|
||||
ctx.fillRect(px, py + halfH, halfW, halfH); // bottom-left
|
||||
break;
|
||||
case '▜': // Top-left + top-right + bottom-right
|
||||
ctx.fillRect(px, py, CHAR_WIDTH + 0.5, halfH); // top
|
||||
ctx.fillRect(px + halfW, py + halfH, halfW + 0.5, halfH); // bottom-right
|
||||
break;
|
||||
case '▙': // Top-left + bottom-left + bottom-right
|
||||
ctx.fillRect(px, py + halfH, CHAR_WIDTH + 0.5, halfH); // bottom
|
||||
ctx.fillRect(px, py, halfW, halfH); // top-left
|
||||
break;
|
||||
case '▟': // Top-right + bottom-left + bottom-right
|
||||
ctx.fillRect(px, py + halfH, CHAR_WIDTH + 0.5, halfH); // bottom
|
||||
ctx.fillRect(px + halfW, py, halfW + 0.5, halfH); // top-right
|
||||
break;
|
||||
case '▘': // Top-left quadrant
|
||||
ctx.fillRect(px, py, halfW, halfH);
|
||||
break;
|
||||
case '▝': // Top-right quadrant
|
||||
ctx.fillRect(px + halfW, py, halfW + 0.5, halfH);
|
||||
break;
|
||||
case '▖': // Bottom-left quadrant
|
||||
ctx.fillRect(px, py + halfH, halfW, halfH);
|
||||
break;
|
||||
case '▗': // Bottom-right quadrant
|
||||
ctx.fillRect(px + halfW, py + halfH, halfW + 0.5, halfH);
|
||||
break;
|
||||
case '▓': // Dark shade
|
||||
case '▒': // Medium shade
|
||||
case '░': // Light shade
|
||||
ctx.fillRect(px, py, CHAR_WIDTH + 0.5, CHAR_HEIGHT);
|
||||
break;
|
||||
default:
|
||||
// Regular text character
|
||||
ctx.fillText(char, px, (y + 1) * CHAR_HEIGHT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fb = new FrameBuffer(COLS, ROWS);
|
||||
|
||||
function updateProgress(frame, phase = 'Rendering') {
|
||||
const percent = Math.round((frame / TOTAL_FRAMES) * 100);
|
||||
document.getElementById('progress').textContent = `${phase}: ${percent}% (${frame}/${TOTAL_FRAMES})`;
|
||||
}
|
||||
|
||||
function renderFrame(frame) {
|
||||
fb.clear();
|
||||
mainAnimation(fb, frame);
|
||||
fb.render();
|
||||
}
|
||||
|
||||
// Check if WebCodecs is available
|
||||
function hasWebCodecs() {
|
||||
return typeof VideoEncoder !== 'undefined' && typeof VideoFrame !== 'undefined';
|
||||
}
|
||||
|
||||
// Fast generation using WebCodecs
|
||||
async function generateWithWebCodecs() {
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = 'Generating video...';
|
||||
|
||||
const muxer = new Mp4Muxer.Muxer({
|
||||
target: new Mp4Muxer.ArrayBufferTarget(),
|
||||
video: {
|
||||
codec: 'avc',
|
||||
width: canvas.width,
|
||||
height: canvas.height
|
||||
},
|
||||
fastStart: 'in-memory'
|
||||
});
|
||||
|
||||
let frameCount = 0;
|
||||
const frameDuration = 1_000_000 / FPS; // microseconds
|
||||
|
||||
const encoder = new VideoEncoder({
|
||||
output: (chunk, meta) => {
|
||||
muxer.addVideoChunk(chunk, meta);
|
||||
},
|
||||
error: (e) => console.error('Encoder error:', e)
|
||||
});
|
||||
|
||||
encoder.configure({
|
||||
codec: 'avc1.640028',
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
bitrate: 5_000_000,
|
||||
framerate: FPS
|
||||
});
|
||||
|
||||
// Render and encode all frames as fast as possible
|
||||
for (let frame = 0; frame < TOTAL_FRAMES; frame++) {
|
||||
renderFrame(frame);
|
||||
updateProgress(frame, 'Encoding');
|
||||
|
||||
const videoFrame = new VideoFrame(canvas, {
|
||||
timestamp: frame * frameDuration,
|
||||
duration: frameDuration
|
||||
});
|
||||
|
||||
encoder.encode(videoFrame, { keyFrame: frame % 24 === 0 });
|
||||
videoFrame.close();
|
||||
frameCount++;
|
||||
|
||||
// Yield to UI every 10 frames
|
||||
if (frame % 10 === 0) {
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
}
|
||||
|
||||
await encoder.flush();
|
||||
muxer.finalize();
|
||||
|
||||
const buffer = muxer.target.buffer;
|
||||
const blob = new Blob([buffer], { type: 'video/mp4' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'year_in_review_2025.mp4';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
return 'mp4';
|
||||
}
|
||||
|
||||
// Fallback: real-time MediaRecorder
|
||||
async function generateWithMediaRecorder() {
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = 'Generating video...';
|
||||
|
||||
const stream = canvas.captureStream(FPS);
|
||||
let options = {};
|
||||
let fileExtension = 'webm';
|
||||
|
||||
if (MediaRecorder.isTypeSupported('video/mp4;codecs=avc1')) {
|
||||
options = { mimeType: 'video/mp4;codecs=avc1' };
|
||||
fileExtension = 'mp4';
|
||||
} else if (MediaRecorder.isTypeSupported('video/webm;codecs=vp9')) {
|
||||
options = { mimeType: 'video/webm;codecs=vp9' };
|
||||
} else if (MediaRecorder.isTypeSupported('video/webm')) {
|
||||
options = { mimeType: 'video/webm' };
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
const recorder = new MediaRecorder(stream, options);
|
||||
|
||||
recorder.ondataavailable = e => {
|
||||
if (e.data.size > 0) chunks.push(e.data);
|
||||
};
|
||||
|
||||
const downloadPromise = new Promise(resolve => {
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunks, { type: options.mimeType || 'video/webm' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `year_in_review_2025.${fileExtension}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
|
||||
recorder.start();
|
||||
|
||||
let frame = 0;
|
||||
await new Promise(resolve => {
|
||||
const interval = setInterval(() => {
|
||||
renderFrame(frame);
|
||||
updateProgress(frame, 'Recording');
|
||||
frame++;
|
||||
if (frame >= TOTAL_FRAMES) {
|
||||
clearInterval(interval);
|
||||
setTimeout(() => {
|
||||
recorder.stop();
|
||||
resolve();
|
||||
}, 100);
|
||||
}
|
||||
}, 1000 / FPS);
|
||||
});
|
||||
|
||||
await downloadPromise;
|
||||
return fileExtension;
|
||||
}
|
||||
|
||||
async function generateAndDownload() {
|
||||
const btn = document.getElementById('downloadBtn');
|
||||
const status = document.getElementById('status');
|
||||
|
||||
btn.disabled = true;
|
||||
const startTime = performance.now();
|
||||
|
||||
let fileExtension;
|
||||
try {
|
||||
if (hasWebCodecs()) {
|
||||
fileExtension = await generateWithWebCodecs();
|
||||
} else {
|
||||
fileExtension = await generateWithMediaRecorder();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('WebCodecs failed, falling back to MediaRecorder:', e);
|
||||
fileExtension = await generateWithMediaRecorder();
|
||||
}
|
||||
|
||||
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
|
||||
status.textContent = `Video saved as year_in_review_2025.${fileExtension} (${elapsed}s)`;
|
||||
document.getElementById('progress').textContent = '';
|
||||
document.getElementById('reminder').style.display = 'block';
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
document.getElementById('downloadBtn').addEventListener('click', generateAndDownload);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
373
plugins/thinkback/skills/thinkback/year_in_review_template.js
Normal file
373
plugins/thinkback/skills/thinkback/year_in_review_template.js
Normal file
@@ -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 <script> tag in year_in_review.html, not as a module.
|
||||
* All helper functions are available on globalThis from scripts loaded before this one.
|
||||
*
|
||||
* To use helper functions, destructure from globalThis at the top of your file:
|
||||
*
|
||||
* const {
|
||||
* // Scene system (REQUIRED)
|
||||
* SceneManager, staggeredReveal, easeInOut, easeOut, animateCounter,
|
||||
* // Backgrounds
|
||||
* stars, fireflies, dust, snow, fog, aurora, waves, rain,
|
||||
* // Particles
|
||||
* floatingParticles, embers, sparkles, confetti, hearts,
|
||||
* // Transitions
|
||||
* dissolve, circleReveal, circleClose, fade, wipeLeft, wipeRight, irisOut,
|
||||
* // Text effects
|
||||
* drawTypewriterCentered, drawFadeInText, slideIn, drawGlitchText,
|
||||
* } = globalThis;
|
||||
*
|
||||
* See helpers/index.js for the complete list of available functions.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// HELPER DESTRUCTURING (must come BEFORE SceneManager usage)
|
||||
// =============================================================================
|
||||
|
||||
// Get helpers from globalThis - SceneManager MUST be destructured before use
|
||||
const {
|
||||
// Scene system (REQUIRED - must be first)
|
||||
SceneManager, staggeredReveal, easeInOut, easeOut, animateCounter,
|
||||
// Backgrounds
|
||||
stars, fireflies, dust, snow, fog, aurora, waves, rain,
|
||||
// Particles
|
||||
floatingParticles, embers, sparkles, confetti, hearts,
|
||||
// Transitions
|
||||
dissolve, circleReveal, circleClose, fade, wipeLeft, wipeRight, irisOut,
|
||||
// Text effects
|
||||
drawTypewriterCentered, drawFadeInText, slideIn, drawGlitchText,
|
||||
// Claude branding & intro (REQUIRED for intro scene)
|
||||
drawThinkbackIntro, CLAUDE_ORANGE,
|
||||
} = globalThis;
|
||||
|
||||
// =============================================================================
|
||||
// SCENE DEFINITIONS (in seconds)
|
||||
// =============================================================================
|
||||
|
||||
// Define your scenes with durations in SECONDS
|
||||
// The SceneManager will compute frame ranges automatically
|
||||
//
|
||||
// Each scene has:
|
||||
// - duration: Total scene length in seconds
|
||||
// - hold: (optional) Time content stays fully visible before transition out (default: 2s)
|
||||
//
|
||||
// The system guarantees:
|
||||
// - ~0.5s transition in
|
||||
// - Content animation phase (remaining time after transitions and hold)
|
||||
// - Your specified hold time (default 2s)
|
||||
// - ~0.5s transition out
|
||||
//
|
||||
// IMPORTANT: The first scene MUST be 'thinkback_intro' using drawThinkbackIntro()
|
||||
const SCENE_DEFINITIONS = [
|
||||
{ name: 'thinkback_intro', duration: 7, hold: 2 }, // REQUIRED: Branded intro with Clawd & logo
|
||||
{ name: 'stats', duration: 6, hold: 2.5 }, // 2.5s hold for more stats
|
||||
{ name: 'projects', duration: 6, hold: 2.5 }, // Project showcase scene
|
||||
{ name: 'closing', duration: 4, hold: 1.5 }, // 1.5s hold (shorter scene)
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// USER PERSONALIZATION (customize these based on stats analysis)
|
||||
// =============================================================================
|
||||
|
||||
// Personalization for the intro scene - customize based on user's stats!
|
||||
// Examples:
|
||||
// - Night owl: { userName: 'the midnight coder', tagline: 'burning the midnight oil' }
|
||||
// - Prolific: { userName: 'the prolific builder', tagline: '1,234 commits and counting' }
|
||||
// - Explorer: { userName: '@username', tagline: 'your year across the codebase' }
|
||||
const USER_INTRO = {
|
||||
userName: 'you', // Replace with user's name or creative handle
|
||||
year: 2025, // Year being reviewed
|
||||
tagline: 'your year with Claude', // Optional tagline based on their story
|
||||
};
|
||||
|
||||
// Create the scene manager - this computes TOTAL_FRAMES automatically
|
||||
const sceneManager = new SceneManager(SCENE_DEFINITIONS);
|
||||
const TOTAL_FRAMES = sceneManager.getTotalFrames();
|
||||
|
||||
// =============================================================================
|
||||
// ANIMATION UTILITIES (provided by scene_system.js)
|
||||
// =============================================================================
|
||||
|
||||
// These are now available from globalThis:
|
||||
// - easeInOut(t) - smooth ease in/out
|
||||
// - easeOut(t) - fast start, slow end
|
||||
// - animateCounter(target, progress) - animate a number from 0 to target
|
||||
// - staggeredReveal(count, overlap) - create staggered timing for lists
|
||||
|
||||
// Seeded random - returns consistent values for the same seed
|
||||
function seededRandom(seed) {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
// Figlet-style font for large ASCII text (5 rows high, 5 chars wide)
|
||||
const FIGLET_FONT = {
|
||||
'0': [' @@@ ', '@ @', '@ @', '@ @', ' @@@ '],
|
||||
'1': [' @ ', ' @@ ', ' @ ', ' @ ', ' @@@ '],
|
||||
'2': [' @@@ ', '@ @', ' @@ ', ' @ ', '@@@@@'],
|
||||
'3': [' @@@ ', '@ @', ' @@ ', '@ @', ' @@@ '],
|
||||
'4': ['@ @', '@ @', '@@@@@', ' @', ' @'],
|
||||
'5': ['@@@@@', '@ ', '@@@@ ', ' @', '@@@@ '],
|
||||
'6': [' @@@ ', '@ ', '@@@@ ', '@ @', ' @@@ '],
|
||||
'7': ['@@@@@', ' @', ' @ ', ' @ ', ' @ '],
|
||||
'8': [' @@@ ', '@ @', ' @@@ ', '@ @', ' @@@ '],
|
||||
'9': [' @@@ ', '@ @', ' @@@@', ' @', ' @@@ '],
|
||||
'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': ['@@@@@', ' @ ', ' @ ', ' @ ', '@@@@@'],
|
||||
' ': [' ', ' ', ' ', ' ', ' '],
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// SCENE RENDERERS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Each scene renderer receives:
|
||||
* - fb: Framebuffer with drawing methods
|
||||
* - frame: Global frame number (for animations)
|
||||
* - scene: Scene info from SceneManager:
|
||||
* - name: Scene name
|
||||
* - phase: 'TRANSITION_IN' | 'CONTENT' | 'HOLD' | 'TRANSITION_OUT'
|
||||
* - contentProgress: 0-1, progress through CONTENT phase
|
||||
* - transitionProgress: 0-1, progress through current transition
|
||||
*/
|
||||
|
||||
/**
|
||||
* REQUIRED: Thinkback intro scene with Clawd, Claude Code logo, and personalized text.
|
||||
* This scene MUST be the first scene in every Thinkback animation.
|
||||
*/
|
||||
function renderThinkbackIntro(fb, frame, scene) {
|
||||
// Starfield background
|
||||
stars(fb, frame, { density: 0.012, twinkle: true });
|
||||
|
||||
// Calculate overall progress including hold phase
|
||||
let p = 0;
|
||||
if (scene.phase === 'TRANSITION_IN') {
|
||||
p = scene.transitionProgress * 0.1; // Start slowly during transition in
|
||||
} else if (scene.phase === 'CONTENT') {
|
||||
p = 0.1 + scene.contentProgress * 0.7; // Main content animation
|
||||
} else if (scene.phase === 'HOLD' || scene.phase === 'TRANSITION_OUT') {
|
||||
p = 1; // Fully visible during hold
|
||||
}
|
||||
|
||||
// Draw the branded intro with all elements
|
||||
drawThinkbackIntro(fb, frame, p, USER_INTRO);
|
||||
|
||||
// Add sparkles during hold and transition out for celebration
|
||||
if (scene.phase === 'HOLD' || scene.phase === 'TRANSITION_OUT') {
|
||||
sparkles(fb, frame, { density: 0.004 });
|
||||
}
|
||||
|
||||
// Transition out
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.003 });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
fb.drawCenteredText(6, '=== Your Stats ===');
|
||||
|
||||
// Use staggered reveal for list items
|
||||
const reveal = globalThis.staggeredReveal(3, 0.5);
|
||||
|
||||
const stats = [
|
||||
{ label: 'Commits', value: 100 },
|
||||
{ label: 'Sessions', value: 500 },
|
||||
{ label: 'Messages', value: 2000 },
|
||||
];
|
||||
|
||||
stats.forEach((stat, i) => {
|
||||
const itemP = reveal(p, i);
|
||||
if (itemP > 0) {
|
||||
const count = globalThis.animateCounter(stat.value, itemP);
|
||||
fb.drawCenteredText(10 + i * 3, `${stat.label}: ${count.toLocaleString()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example scene showing project highlights
|
||||
*/
|
||||
function renderProjects(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.003 });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
fb.drawCenteredText(6, '=== Your Projects ===');
|
||||
|
||||
// Example project list - in real usage, this would be populated from stats
|
||||
const projects = [
|
||||
{ name: 'claude-code', commits: 42 },
|
||||
{ name: 'awesome-project', commits: 28 },
|
||||
{ name: 'open-source-lib', commits: 15 },
|
||||
{ name: 'side-project', commits: 8 },
|
||||
];
|
||||
|
||||
const reveal = globalThis.staggeredReveal(projects.length, 0.4);
|
||||
|
||||
projects.forEach((project, i) => {
|
||||
const itemP = reveal(p, i);
|
||||
if (itemP > 0) {
|
||||
const y = 10 + i * 2;
|
||||
const text = `• ${project.name} (${project.commits} commits)`;
|
||||
fb.drawCenteredText(y, text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
dissolve(fb, scene.transitionProgress, frame);
|
||||
}
|
||||
}
|
||||
|
||||
function renderClosing(fb, frame, scene) {
|
||||
stars(fb, frame, { density: 0.01, twinkle: true });
|
||||
|
||||
if (scene.phase !== 'TRANSITION_IN') {
|
||||
const p = scene.contentProgress;
|
||||
|
||||
if (p > 0.2) {
|
||||
fb.drawCenteredText(Math.floor(fb.height / 2) - 2, 'Thank you');
|
||||
}
|
||||
|
||||
if (p > 0.5) {
|
||||
fb.drawCenteredText(Math.floor(fb.height / 2) + 2, 'See you next year!');
|
||||
}
|
||||
|
||||
sparkles(fb, frame, { density: 0.006 });
|
||||
}
|
||||
|
||||
// Final fade to black
|
||||
if (scene.phase === 'TRANSITION_OUT') {
|
||||
const fadeP = 1 - scene.transitionProgress;
|
||||
for (let y = 0; y < fb.height; y++) {
|
||||
for (let x = 0; x < fb.width; x++) {
|
||||
if (Math.random() < fadeP * 0.5) {
|
||||
fb.setPixel(x, y, ' ', -100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map scene names to renderers
|
||||
const SCENE_RENDERERS = {
|
||||
thinkback_intro: renderThinkbackIntro,
|
||||
stats: renderStats,
|
||||
projects: renderProjects,
|
||||
closing: renderClosing,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN ANIMATION FUNCTION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Main animation function - called once per frame
|
||||
*
|
||||
* @param {Object} fb - Framebuffer with drawing methods
|
||||
* @param {number} frame - Current frame number (0 to TOTAL_FRAMES-1)
|
||||
*/
|
||||
function mainAnimation(fb, frame) {
|
||||
// Get current scene info from the manager
|
||||
const scene = sceneManager.getSceneAt(frame);
|
||||
|
||||
if (!scene) {
|
||||
fb.drawCenteredText(Math.floor(fb.height / 2), 'Animation complete');
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the appropriate scene renderer
|
||||
const renderer = SCENE_RENDERERS[scene.name];
|
||||
if (renderer) {
|
||||
renderer(fb, frame, scene);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the current scene (shown in player UI)
|
||||
*/
|
||||
function getSceneName(frame) {
|
||||
const scene = sceneManager.getSceneAt(frame);
|
||||
if (!scene) return 'Complete';
|
||||
|
||||
const names = {
|
||||
thinkback_intro: 'Think Back',
|
||||
stats: 'The Stats',
|
||||
projects: 'Your Projects',
|
||||
closing: 'Closing',
|
||||
};
|
||||
|
||||
return names[scene.name] || scene.name;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXPORTS - CRITICAL: Must use this exact format!
|
||||
// =============================================================================
|
||||
// The year_in_review.html expects globalThis.YearInReviewScenes with mainAnimation.
|
||||
// DO NOT use: globalThis.render = render; globalThis.TOTAL_FRAMES = ...;
|
||||
// That pattern will cause "YearInReviewScenes not loaded" errors.
|
||||
|
||||
globalThis.YearInReviewScenes = {
|
||||
TOTAL_FRAMES,
|
||||
FIGLET_FONT,
|
||||
easeInOut: globalThis.easeInOut,
|
||||
seededRandom,
|
||||
mainAnimation,
|
||||
getSceneName,
|
||||
// Export scene manager for debugging
|
||||
sceneManager,
|
||||
};
|
||||
Reference in New Issue
Block a user