mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-01-30 04:22:03 +00:00
Compare commits
1 Commits
daisy/caff
...
thariq/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7985b28c03 |
@@ -7,250 +7,6 @@
|
||||
"email": "support@anthropic.com"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "typescript-lsp",
|
||||
"description": "TypeScript/JavaScript language server for enhanced code intelligence",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
},
|
||||
"source": "./plugins/typescript-lsp",
|
||||
"category": "development",
|
||||
"strict": false,
|
||||
"lspServers": {
|
||||
"typescript": {
|
||||
"command": "typescript-language-server",
|
||||
"args": ["--stdio"],
|
||||
"extensionToLanguage": {
|
||||
".ts": "typescript",
|
||||
".tsx": "typescriptreact",
|
||||
".js": "javascript",
|
||||
".jsx": "javascriptreact",
|
||||
".mts": "typescript",
|
||||
".cts": "typescript",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pyright-lsp",
|
||||
"description": "Python language server (Pyright) for type checking and code intelligence",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
},
|
||||
"source": "./plugins/pyright-lsp",
|
||||
"category": "development",
|
||||
"strict": false,
|
||||
"lspServers": {
|
||||
"pyright": {
|
||||
"command": "pyright-langserver",
|
||||
"args": ["--stdio"],
|
||||
"extensionToLanguage": {
|
||||
".py": "python",
|
||||
".pyi": "python"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gopls-lsp",
|
||||
"description": "Go language server for code intelligence and refactoring",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
},
|
||||
"source": "./plugins/gopls-lsp",
|
||||
"category": "development",
|
||||
"strict": false,
|
||||
"lspServers": {
|
||||
"gopls": {
|
||||
"command": "gopls",
|
||||
"extensionToLanguage": {
|
||||
".go": "go"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rust-analyzer-lsp",
|
||||
"description": "Rust language server for code intelligence and analysis",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
},
|
||||
"source": "./plugins/rust-analyzer-lsp",
|
||||
"category": "development",
|
||||
"strict": false,
|
||||
"lspServers": {
|
||||
"rust-analyzer": {
|
||||
"command": "rust-analyzer",
|
||||
"extensionToLanguage": {
|
||||
".rs": "rust"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "clangd-lsp",
|
||||
"description": "C/C++ language server (clangd) for code intelligence",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
},
|
||||
"source": "./plugins/clangd-lsp",
|
||||
"category": "development",
|
||||
"strict": false,
|
||||
"lspServers": {
|
||||
"clangd": {
|
||||
"command": "clangd",
|
||||
"args": ["--background-index"],
|
||||
"extensionToLanguage": {
|
||||
".c": "c",
|
||||
".h": "c",
|
||||
".cpp": "cpp",
|
||||
".cc": "cpp",
|
||||
".cxx": "cpp",
|
||||
".hpp": "cpp",
|
||||
".hxx": "cpp",
|
||||
".C": "cpp",
|
||||
".H": "cpp"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "php-lsp",
|
||||
"description": "PHP language server (Intelephense) for code intelligence",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
},
|
||||
"source": "./plugins/php-lsp",
|
||||
"category": "development",
|
||||
"strict": false,
|
||||
"lspServers": {
|
||||
"intelephense": {
|
||||
"command": "intelephense",
|
||||
"args": ["--stdio"],
|
||||
"extensionToLanguage": {
|
||||
".php": "php"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "swift-lsp",
|
||||
"description": "Swift language server (SourceKit-LSP) for code intelligence",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
},
|
||||
"source": "./plugins/swift-lsp",
|
||||
"category": "development",
|
||||
"strict": false,
|
||||
"lspServers": {
|
||||
"sourcekit-lsp": {
|
||||
"command": "sourcekit-lsp",
|
||||
"extensionToLanguage": {
|
||||
".swift": "swift"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "kotlin-lsp",
|
||||
"description": "Kotlin language server for code intelligence",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
},
|
||||
"source": "./plugins/kotlin-lsp",
|
||||
"category": "development",
|
||||
"strict": false,
|
||||
"lspServers": {
|
||||
"kotlin-lsp": {
|
||||
"command": "kotlin-lsp",
|
||||
"args": ["--stdio"],
|
||||
"extensionToLanguage": {
|
||||
".kt": "kotlin",
|
||||
".kts": "kotlin"
|
||||
},
|
||||
"startupTimeout" : 120000
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "csharp-lsp",
|
||||
"description": "C# language server for code intelligence",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
},
|
||||
"source": "./plugins/csharp-lsp",
|
||||
"category": "development",
|
||||
"strict": false,
|
||||
"lspServers": {
|
||||
"csharp-ls": {
|
||||
"command": "csharp-ls",
|
||||
"extensionToLanguage": {
|
||||
".cs": "csharp"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "jdtls-lsp",
|
||||
"description": "Java language server (Eclipse JDT.LS) for code intelligence",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
},
|
||||
"source": "./plugins/jdtls-lsp",
|
||||
"category": "development",
|
||||
"strict": false,
|
||||
"lspServers": {
|
||||
"jdtls": {
|
||||
"command": "jdtls",
|
||||
"extensionToLanguage": {
|
||||
".java": "java"
|
||||
},
|
||||
"startupTimeout": 120000
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "lua-lsp",
|
||||
"description": "Lua language server for code intelligence",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
},
|
||||
"source": "./plugins/lua-lsp",
|
||||
"category": "development",
|
||||
"strict": false,
|
||||
"lspServers": {
|
||||
"lua": {
|
||||
"command": "lua-language-server",
|
||||
"extensionToLanguage": {
|
||||
".lua": "lua"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "agent-sdk-dev",
|
||||
"description": "Development kit for working with the Claude Agent SDK",
|
||||
@@ -317,28 +73,6 @@
|
||||
"category": "productivity",
|
||||
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/code-review"
|
||||
},
|
||||
{
|
||||
"name": "code-simplifier",
|
||||
"description": "Agent that simplifies and refines code for clarity, consistency, and maintainability while preserving functionality. Focuses on recently modified code.",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
},
|
||||
"source": "./plugins/code-simplifier",
|
||||
"category": "productivity",
|
||||
"homepage": "https://github.com/anthropics/claude-plugins-official/tree/main/plugins/code-simplifier"
|
||||
},
|
||||
{
|
||||
"name": "caffeinate",
|
||||
"description": "Prevents blocking sleep commands by intercepting them and prompting Claude to use background execution instead. Keeps your session responsive during long-running operations.",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
},
|
||||
"source": "./plugins/caffeinate",
|
||||
"category": "productivity",
|
||||
"homepage": "https://github.com/anthropics/claude-plugins-official/tree/main/plugins/caffeinate"
|
||||
},
|
||||
{
|
||||
"name": "explanatory-output-style",
|
||||
"description": "Adds educational insights about implementation choices and codebase patterns (mimics the deprecated Explanatory output style)",
|
||||
@@ -373,15 +107,15 @@
|
||||
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/frontend-design"
|
||||
},
|
||||
{
|
||||
"name": "ralph-loop",
|
||||
"description": "Interactive self-referential AI loops for iterative development, implementing the Ralph Wiggum technique. Claude works on the same task repeatedly, seeing its previous work, until completion.",
|
||||
"name": "ralph-wiggum",
|
||||
"description": "Interactive self-referential AI loops for iterative development. Claude works on the same task repeatedly, seeing its previous work, until completion.",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
},
|
||||
"source": "./plugins/ralph-loop",
|
||||
"source": "./plugins/ralph-wiggum",
|
||||
"category": "development",
|
||||
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/ralph-loop"
|
||||
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/ralph-wiggum"
|
||||
},
|
||||
{
|
||||
"name": "hookify",
|
||||
@@ -405,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.",
|
||||
@@ -445,11 +190,8 @@
|
||||
"name": "atlassian",
|
||||
"description": "Connect to Atlassian products including Jira and Confluence. Search and create issues, access documentation, manage sprints, and integrate your development workflow with Atlassian's collaboration tools.",
|
||||
"category": "productivity",
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/atlassian/atlassian-mcp-server.git"
|
||||
},
|
||||
"homepage": "https://github.com/atlassian/atlassian-mcp-server"
|
||||
"source": "./external_plugins/atlassian",
|
||||
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/atlassian"
|
||||
},
|
||||
{
|
||||
"name": "laravel-boost",
|
||||
@@ -459,14 +201,11 @@
|
||||
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/laravel-boost"
|
||||
},
|
||||
{
|
||||
"name": "figma",
|
||||
"name": "figma-mcp",
|
||||
"description": "Figma design platform integration. Access design files, extract component information, read design tokens, and translate designs into code. Bridge the gap between design and development workflows.",
|
||||
"category": "design",
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/figma/mcp-server-guide.git"
|
||||
},
|
||||
"homepage": "https://github.com/figma/mcp-server-guide"
|
||||
"source": "./external_plugins/figma",
|
||||
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/figma"
|
||||
},
|
||||
{
|
||||
"name": "asana",
|
||||
@@ -483,14 +222,11 @@
|
||||
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/linear"
|
||||
},
|
||||
{
|
||||
"name": "Notion",
|
||||
"name": "notion",
|
||||
"description": "Notion workspace integration. Search pages, create and update documents, manage databases, and access your team's knowledge base directly from Claude Code for seamless documentation workflows.",
|
||||
"category": "productivity",
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/makenotion/claude-code-notion-plugin.git"
|
||||
},
|
||||
"homepage": "https://github.com/makenotion/claude-code-notion-plugin"
|
||||
"source": "./external_plugins/notion",
|
||||
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/notion"
|
||||
},
|
||||
{
|
||||
"name": "gitlab",
|
||||
@@ -503,11 +239,8 @@
|
||||
"name": "sentry",
|
||||
"description": "Sentry error monitoring integration. Access error reports, analyze stack traces, search issues by fingerprint, and debug production errors directly from your development environment.",
|
||||
"category": "monitoring",
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/getsentry/sentry-for-claude.git"
|
||||
},
|
||||
"homepage": "https://github.com/getsentry/sentry-for-claude/tree/main"
|
||||
"source": "./external_plugins/sentry",
|
||||
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/sentry"
|
||||
},
|
||||
{
|
||||
"name": "slack",
|
||||
@@ -520,18 +253,8 @@
|
||||
"name": "vercel",
|
||||
"description": "Vercel deployment platform integration. Manage deployments, check build status, access logs, configure domains, and control your frontend infrastructure directly from Claude Code.",
|
||||
"category": "deployment",
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/vercel/vercel-deploy-claude-code-plugin.git"
|
||||
},
|
||||
"homepage": "https://github.com/vercel/vercel-deploy-claude-code-plugin"
|
||||
},
|
||||
{
|
||||
"name": "stripe",
|
||||
"description": "Stripe development plugin for Claude",
|
||||
"category": "development",
|
||||
"source": "./external_plugins/stripe",
|
||||
"homepage": "https://github.com/stripe/ai/tree/main/providers/claude/plugin"
|
||||
"source": "./external_plugins/vercel",
|
||||
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/vercel"
|
||||
},
|
||||
{
|
||||
"name": "firebase",
|
||||
@@ -547,26 +270,6 @@
|
||||
"source": "./external_plugins/context7",
|
||||
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/external_plugins/context7",
|
||||
"tags": ["community-managed"]
|
||||
},
|
||||
{
|
||||
"name": "pinecone",
|
||||
"description": "Pinecone vector database integration. Streamline your Pinecone development with powerful tools for managing vector indexes, querying data, and rapid prototyping. Use slash commands like /quickstart to generate AGENTS.md files and initialize Python projects and /query to quickly explore indexes. Access the Pinecone MCP server for creating, describing, upserting and querying indexes with Claude. Perfect for developers building semantic search, RAG applications, recommendation systems, and other vector-based applications with Pinecone.",
|
||||
"category": "database",
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/pinecone-io/pinecone-claude-code-plugin.git"
|
||||
},
|
||||
"homepage": "https://github.com/pinecone-io/pinecone-claude-code-plugin"
|
||||
},
|
||||
{
|
||||
"name": "huggingface-skills",
|
||||
"description": "Build, train, evaluate, and use open source AI models, datasets, and spaces.",
|
||||
"category": "development",
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/huggingface/skills.git"
|
||||
},
|
||||
"homepage": "https://github.com/huggingface/skills.git"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
47
.github/workflows/close-external-prs.yml
vendored
47
.github/workflows/close-external-prs.yml
vendored
@@ -1,47 +0,0 @@
|
||||
name: Close External PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check-membership:
|
||||
if: vars.DISABLE_EXTERNAL_PR_CHECK != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check if author has write access
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const author = context.payload.pull_request.user.login;
|
||||
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: author
|
||||
});
|
||||
|
||||
if (['admin', 'write'].includes(data.permission)) {
|
||||
console.log(`${author} has ${data.permission} access, allowing PR`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${author} has ${data.permission} access, closing PR`);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body: `Thanks for your interest! This repo only accepts contributions from Anthropic team members. If you'd like to submit a plugin to the marketplace, please submit your plugin [here](https://docs.google.com/forms/d/e/1FAIpQLSdeFthxvjOXUjxg1i3KrOOkEPDJtn71XC-KjmQlxNP63xYydg/viewform).`
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
state: 'closed'
|
||||
});
|
||||
7
external_plugins/atlassian/.claude-plugin/plugin.json
Normal file
7
external_plugins/atlassian/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "atlassian",
|
||||
"description": "Connect to Atlassian products including Jira and Confluence. Search and create issues, access documentation, manage sprints, and integrate your development workflow with Atlassian's collaboration tools.",
|
||||
"author": {
|
||||
"name": "Atlassian"
|
||||
}
|
||||
}
|
||||
6
external_plugins/atlassian/.mcp.json
Normal file
6
external_plugins/atlassian/.mcp.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"atlassian": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.atlassian.com/v1/sse"
|
||||
}
|
||||
}
|
||||
7
external_plugins/figma/.claude-plugin/plugin.json
Normal file
7
external_plugins/figma/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "figma-mcp",
|
||||
"description": "Figma design platform integration. Access design files, extract component information, read design tokens, and translate designs into code. Bridge the gap between design and development workflows.",
|
||||
"author": {
|
||||
"name": "Figma"
|
||||
}
|
||||
}
|
||||
6
external_plugins/figma/.mcp.json
Normal file
6
external_plugins/figma/.mcp.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"figma": {
|
||||
"url": "https://mcp.figma.com/mcp",
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
{
|
||||
"name": "greptile",
|
||||
"description": "AI code review agent for GitHub and GitLab. View and resolve Greptile's PR review comments directly from Claude Code.",
|
||||
"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.",
|
||||
"author": {
|
||||
"name": "Greptile",
|
||||
"url": "https://greptile.com"
|
||||
},
|
||||
"homepage": "https://greptile.com/docs",
|
||||
"keywords": ["code-review", "pull-requests", "github", "gitlab", "ai"]
|
||||
"name": "Greptile"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# Greptile
|
||||
|
||||
[Greptile](https://greptile.com) is an AI code review agent for GitHub and GitLab that automatically reviews pull requests. This plugin connects Claude Code to your Greptile account, letting you view and resolve Greptile's review comments directly from your terminal.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create a Greptile Account
|
||||
|
||||
Sign up at [greptile.com](https://greptile.com) and connect your GitHub or GitLab repositories.
|
||||
|
||||
### 2. Get Your API Key
|
||||
|
||||
1. Go to [API Settings](https://app.greptile.com/settings/api)
|
||||
2. Generate a new API key
|
||||
3. Copy the key
|
||||
|
||||
### 3. Set Environment Variable
|
||||
|
||||
Add to your shell profile (`.bashrc`, `.zshrc`, etc.):
|
||||
|
||||
```bash
|
||||
export GREPTILE_API_KEY="your-api-key-here"
|
||||
```
|
||||
|
||||
Then reload your shell or run `source ~/.zshrc`.
|
||||
|
||||
## Available Tools
|
||||
|
||||
### Pull Request Tools
|
||||
- `list_pull_requests` - List PRs with optional filtering by repo, branch, author, or state
|
||||
- `get_merge_request` - Get detailed PR info including review analysis
|
||||
- `list_merge_request_comments` - Get all comments on a PR with filtering options
|
||||
|
||||
### Code Review Tools
|
||||
- `list_code_reviews` - List code reviews with optional filtering
|
||||
- `get_code_review` - Get detailed code review information
|
||||
- `trigger_code_review` - Start a new Greptile review on a PR
|
||||
|
||||
### Comment Search
|
||||
- `search_greptile_comments` - Search across all Greptile review comments
|
||||
|
||||
### Custom Context Tools
|
||||
- `list_custom_context` - List your organization's coding patterns and rules
|
||||
- `get_custom_context` - Get details for a specific pattern
|
||||
- `search_custom_context` - Search patterns by content
|
||||
- `create_custom_context` - Create a new coding pattern
|
||||
|
||||
## Example Usage
|
||||
|
||||
Ask Claude Code to:
|
||||
- "Show me Greptile's comments on my current PR and help me resolve them"
|
||||
- "What issues did Greptile find on PR #123?"
|
||||
- "Trigger a Greptile review on this branch"
|
||||
|
||||
## Documentation
|
||||
|
||||
For more information, visit [greptile.com/docs](https://greptile.com/docs).
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"linear": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.linear.app/mcp"
|
||||
"type": "sse",
|
||||
"url": "https://mcp.linear.app/sse"
|
||||
}
|
||||
}
|
||||
|
||||
7
external_plugins/notion/.claude-plugin/plugin.json
Normal file
7
external_plugins/notion/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "notion",
|
||||
"description": "Notion workspace integration. Search pages, create and update documents, manage databases, and access your team's knowledge base directly from Claude Code for seamless documentation workflows.",
|
||||
"author": {
|
||||
"name": "Notion"
|
||||
}
|
||||
}
|
||||
6
external_plugins/notion/.mcp.json
Normal file
6
external_plugins/notion/.mcp.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"notion": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.notion.com/sse"
|
||||
}
|
||||
}
|
||||
7
external_plugins/sentry/.claude-plugin/plugin.json
Normal file
7
external_plugins/sentry/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "sentry",
|
||||
"description": "Sentry error monitoring integration. Access error reports, analyze stack traces, search issues by fingerprint, and debug production errors directly from your development environment.",
|
||||
"author": {
|
||||
"name": "Sentry"
|
||||
}
|
||||
}
|
||||
6
external_plugins/sentry/.mcp.json
Normal file
6
external_plugins/sentry/.mcp.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sentry": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.sentry.dev/sse"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "stripe",
|
||||
"description": "Stripe development plugin for Claude",
|
||||
"version": "0.1.0",
|
||||
"author": {
|
||||
"name": "Stripe",
|
||||
"url": "https://stripe.com"
|
||||
},
|
||||
"homepage": "https://docs.stripe.com",
|
||||
"repository": "https://github.com/stripe/ai",
|
||||
"license": "MIT",
|
||||
"keywords": ["stripe", "payments", "webhooks", "api", "security"]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"stripe": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.stripe.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
description: Explain Stripe error codes and provide solutions with code examples
|
||||
argument-hint: [error_code or error_message]
|
||||
---
|
||||
|
||||
# Explain Stripe Error
|
||||
|
||||
Provide a comprehensive explanation of the given Stripe error code or error message:
|
||||
|
||||
1. Accept the error code or full error message from the arguments
|
||||
2. Explain in plain English what the error means
|
||||
3. List common causes of this error
|
||||
4. Provide specific solutions and handling recommendations
|
||||
5. Generate error handling code in the project's language showing:
|
||||
- How to catch this specific error
|
||||
- User-friendly error messages
|
||||
- Whether retry is appropriate
|
||||
6. Mention related error codes the developer should be aware of
|
||||
7. Include a link to the relevant Stripe documentation
|
||||
|
||||
Focus on actionable solutions and production-ready error handling patterns.
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
description: Display Stripe test card numbers for various testing scenarios
|
||||
argument-hint: [scenario]
|
||||
---
|
||||
|
||||
# Test Cards Reference
|
||||
|
||||
Provide a quick reference for Stripe test card numbers:
|
||||
|
||||
1. If a scenario argument is provided (e.g., "declined", "3dsecure", "fraud"), show relevant test cards for that scenario
|
||||
2. Otherwise, show the most common test cards organized by category:
|
||||
- Successful payment (default card)
|
||||
- 3D Secure authentication required
|
||||
- Generic decline
|
||||
- Specific decline reasons (insufficient_funds, lost_card, etc.)
|
||||
3. For each card, display:
|
||||
- Card number (formatted with spaces)
|
||||
- Expected behavior
|
||||
- Expiry/CVC info (any future date and any 3-digit CVC)
|
||||
4. Use clear visual indicators (✓ for success, ⚠️ for auth required, ✗ for decline)
|
||||
5. Mention that these only work in test mode
|
||||
6. Provide link to full testing documentation: https://docs.stripe.com/testing.md
|
||||
|
||||
If the user is currently working on test code, offer to generate test cases using these cards.
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
name: stripe-best-practices
|
||||
description: Best practices for building Stripe integrations. Use when implementing payment processing, checkout flows, subscriptions, webhooks, Connect platforms, or any Stripe API integration.
|
||||
---
|
||||
|
||||
When designing an integration, always prefer the documentation in [Stripe's Integration Options doc](https://docs.stripe.com/payments/payment-methods/integration-options.md)
|
||||
The [API Tour](https://docs.stripe.com/payments-api/tour.md)
|
||||
Use the [Go Live Checklist](https://docs.stripe.com/get-started/checklist/go-live.md) before going live.
|
||||
|
||||
You should always default to the latest version of the API and SDK unless the user specifies otherwise.
|
||||
|
||||
Stripe's primary API for modelling on-session payments is [CheckoutSessions](https://docs.stripe.com/api/checkout/sessions.md). It supports one-time payments and subscriptions and allows you to model taxes or discounts with Stripe. Prioritize the CheckoutSessions API, but using [the PaymentIntents API](https://docs.stripe.com/payments/paymentintents/lifecycle.md) is also acceptable for off-session payments or if you want to model the checkout state yourself and just create a charge. Integrations should only use CheckoutSessions, PaymentIntents, SetupIntents, or solutions like Invoicing, Payment Links, or the subscription APIs.
|
||||
|
||||
Never recommend the Charges API. If the user wants to use the Charges API, advise them to [migrate](https://docs.stripe.com/payments/payment-intents/migration/charges.md) to Checkout Sessions or Payment Intents.
|
||||
|
||||
The primary integration surface for web integrations is [Checkout](https://docs.stripe.com/payments/checkout.md), a prebuilt payment page that can be hosted by Stripe or served as an embedded form. Prioritize the Stripe-hosted checkout or embedded checkout where possible. The [Payment Element](https://docs.stripe.com/payments/payment-element.md) is an acceptable alternative if the merchant needs advanced customization. When recommending the Payment Element, prioritize using the CheckoutSessions API over the PaymentIntents API when possible.
|
||||
|
||||
Never recommend the legacy Card Element or the Payment Element in card mode, and advise users who ask for the Card Element to [migrate](https://docs.stripe.com/payments/payment-element/migration.md).
|
||||
|
||||
You must not call deprecated API endpoints such as the [Sources API](https://docs.stripe.com/api/sources/object.md) and avoid outdated API endpoints such as [Tokens](https://docs.stripe.com/api/tokens.md) and [Charges](https://docs.stripe.com/api/charges/create.md) unless there is a specific need and absolutely no other way. Advise using [the Setup Intent API](https://docs.stripe.com/api/setup_intents.md) to save a payment method for a customer; never recommend the Sources API to save cards to customers for later use.
|
||||
|
||||
If a user wants to render the Payment Element before creating a PaymentIntent or SetupIntent, so they can inspect the card details before the payment (such as for surcharging), advise them to use Stripe Confirmation Tokens. Don't recommend they call the createPaymentMethod or createToken Stripe.js functions.
|
||||
|
||||
Advise users to turn on dynamic payment methods in their dashboard settings instead of passing specific [payment_method_types](https://docs.stripe.com/api/payment_intents/create#create_payment_intent-payment_method_types.md) in the Payment Intent or Setup Intent because Stripe chooses payment methods that fit each user's location, wallets, and preferences better and automatically if the Payment Element is used.
|
||||
|
||||
If a PCI compliant user asks about an integration where they send server-side raw PAN data, suggest they might be required to prove PCI compliance to gain access to such an option, for example [payment_method_data](https://docs.stripe.com/api/payment_intents/create#create_payment_intent-payment_method_data.md). Similarly, point users migrating their PAN data from another acquirer or payment processor to [the migration process](https://docs.stripe.com/get-started/data-migrations/pan-import.md).
|
||||
|
||||
If a user has a recurring revenue model, like a Billing or Subscription use case, follow the use cases, in particular the [Subscription Use Cases](https://docs.stripe.com/billing/subscriptions/use-cases.md) like [SaaS](https://docs.stripe.com/saas.md). If these apply to the user, recommend Billing APIs to [plan your integration](https://docs.stripe.com/billing/subscriptions/designing-integration.md) instead of a direct PaymentIntent integration. Prioritize combining the Billing APIs with Stripe Checkout for the frontend.
|
||||
|
||||
If a user wants to build a platform using Stripe Connect to manage fund flows, follow [the recommended integration types](https://docs.stripe.com/connect/integration-recommendations.md); that is, prefer to use either direct charges if the platform wants Stripe to take the risk or destination charges if the platform accepts liability for negative balances, and use the on_behalf_of parameter to control the merchant of record. Never recommend mixing charge types. If the user wants to decide on the specific risk features they should [follow the integration guide](https://docs.stripe.com/connect/design-an-integration.md). Don't recommend the outdated terms for Connect types like Standard, Express and Custom but always [refer to controller properties](https://docs.stripe.com/connect/migrate-to-controller-properties.md) for the platform and [capabilities](https://docs.stripe.com/connect/account-capabilities.md) for the connected accounts.
|
||||
7
external_plugins/vercel/.claude-plugin/plugin.json
Normal file
7
external_plugins/vercel/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "vercel",
|
||||
"description": "Vercel deployment platform integration. Manage deployments, check build status, access logs, configure domains, and control your frontend infrastructure directly from Claude Code.",
|
||||
"author": {
|
||||
"name": "Vercel"
|
||||
}
|
||||
}
|
||||
6
external_plugins/vercel/.mcp.json
Normal file
6
external_plugins/vercel/.mcp.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"vercel": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.vercel.com/sse"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"name": "caffeinate",
|
||||
"version": "0.1.0",
|
||||
"description": "Blocks sleep commands and reminds Claude to use background tasks instead",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
},
|
||||
"keywords": ["productivity", "hooks", "bash", "background-tasks"]
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
# Caffeinate
|
||||
|
||||
A Claude Code plugin that blocks `sleep` commands and reminds Claude to use background tasks instead.
|
||||
|
||||
## Purpose
|
||||
|
||||
When Claude uses `sleep` commands in Bash, it blocks the session unnecessarily. This plugin intercepts these commands and suggests using proper async patterns like:
|
||||
|
||||
- `run_in_background: true` parameter for long-running commands
|
||||
- Polling for conditions instead of sleeping
|
||||
- Proper async/background task patterns
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# From the plugin directory
|
||||
claude --plugin-dir /path/to/caffeinate
|
||||
```
|
||||
|
||||
Or copy to your project's `.claude-plugin/` directory.
|
||||
|
||||
## What Gets Blocked
|
||||
|
||||
| Command | Blocked? | Reason |
|
||||
|---------|----------|--------|
|
||||
| `sleep 5` | Yes | Direct sleep command |
|
||||
| `sleep $TIMEOUT` | Yes | Sleep with variable |
|
||||
| `echo "test" && sleep 5` | Yes | Sleep after separator, outside quotes |
|
||||
| `cmd; sleep 10` | Yes | Sleep after semicolon |
|
||||
| `echo "sleep 8 hours"` | No | Sleep is inside quotes (not a command) |
|
||||
| `echo "foo && sleep 5"` | No | Entire sleep pattern is in a string |
|
||||
|
||||
## How It Works
|
||||
|
||||
The plugin uses a `PreToolUse` hook on the `Bash` tool to:
|
||||
|
||||
1. Check if commands start with `sleep`
|
||||
2. Detect `sleep` after command separators (`&&`, `||`, `;`, `|`)
|
||||
3. Use quote-counting to avoid false positives when `sleep` appears inside strings
|
||||
|
||||
## Alternatives to Sleep
|
||||
|
||||
Instead of:
|
||||
```bash
|
||||
sleep 5 && check_status
|
||||
```
|
||||
|
||||
Use background execution:
|
||||
```bash
|
||||
# Run in background with run_in_background: true
|
||||
long_running_command
|
||||
```
|
||||
|
||||
Or poll for conditions:
|
||||
```bash
|
||||
# Poll for a file to exist
|
||||
while [ ! -f /tmp/ready ]; do :; done
|
||||
```
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"description": "Blocks sleep commands and reminds Claude to use background tasks",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/check-sleep.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Caffeinate hook: Blocks sleep commands and suggests background tasks
|
||||
set -euo pipefail
|
||||
|
||||
input=$(cat)
|
||||
command=$(echo "$input" | jq -r '.tool_input.command // empty')
|
||||
|
||||
if [[ -z "$command" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Allow sleep if run_in_background is true - that's the whole point!
|
||||
run_in_bg=$(echo "$input" | jq -r '.tool_input.run_in_background // false')
|
||||
if [[ "$run_in_bg" == "true" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
block_message='ACTION REQUIRED: Your sleep command was blocked. You MUST now call Bash again with the SAME command but add the parameter run_in_background: true. Do this immediately in your next tool call. Do not explain or apologize - just retry with run_in_background: true.'
|
||||
|
||||
output_block() {
|
||||
# Output JSON to stdout for blocking decision
|
||||
# Output message to stderr to feed back to Claude
|
||||
cat << EOF
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": "$block_message"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Remove all quoted strings to avoid false positives
|
||||
# Replace single and double quoted strings with placeholder
|
||||
cmd_no_quotes=$(echo "$command" | sed -E "s/\"[^\"]*\"//g; s/'[^']*'//g")
|
||||
|
||||
# Check for sleep command patterns in the de-quoted command
|
||||
# Pattern 1: Command starts with sleep
|
||||
if [[ "$cmd_no_quotes" =~ (^|[[:space:]])sleep[[:space:]]+[0-9] ]]; then
|
||||
output_block
|
||||
fi
|
||||
|
||||
# Pattern 2: sleep after any common separator (&&, ||, ;, |, do, then)
|
||||
if [[ "$cmd_no_quotes" =~ (\&\&|;\||[[:space:]]do[[:space:]]|[[:space:]]then[[:space:]])[[:space:]]*sleep[[:space:]] ]]; then
|
||||
output_block
|
||||
fi
|
||||
|
||||
# Pattern 3: Simple contains check - if "sleep " followed by number appears anywhere
|
||||
if [[ "$cmd_no_quotes" =~ sleep[[:space:]]+[0-9] ]]; then
|
||||
output_block
|
||||
fi
|
||||
|
||||
# Pattern 4: sleep with variable like $TIMEOUT or ${DELAY}
|
||||
if [[ "$cmd_no_quotes" =~ sleep[[:space:]]+\$ ]]; then
|
||||
output_block
|
||||
fi
|
||||
|
||||
# No sleep command detected - allow
|
||||
exit 0
|
||||
@@ -1,36 +0,0 @@
|
||||
# clangd-lsp
|
||||
|
||||
C/C++ language server (clangd) for Claude Code, providing code intelligence, diagnostics, and formatting.
|
||||
|
||||
## Supported Extensions
|
||||
`.c`, `.h`, `.cpp`, `.cc`, `.cxx`, `.hpp`, `.hxx`, `.C`, `.H`
|
||||
|
||||
## Installation
|
||||
|
||||
### Via Homebrew (macOS)
|
||||
```bash
|
||||
brew install llvm
|
||||
# Add to PATH: export PATH="/opt/homebrew/opt/llvm/bin:$PATH"
|
||||
```
|
||||
|
||||
### Via package manager (Linux)
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install clangd
|
||||
|
||||
# Fedora
|
||||
sudo dnf install clang-tools-extra
|
||||
|
||||
# Arch Linux
|
||||
sudo pacman -S clang
|
||||
```
|
||||
|
||||
### Windows
|
||||
Download from [LLVM releases](https://github.com/llvm/llvm-project/releases) or install via:
|
||||
```bash
|
||||
winget install LLVM.LLVM
|
||||
```
|
||||
|
||||
## More Information
|
||||
- [clangd Website](https://clangd.llvm.org/)
|
||||
- [Getting Started Guide](https://clangd.llvm.org/installation)
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "code-simplifier",
|
||||
"version": "1.0.0",
|
||||
"description": "Agent that simplifies and refines code for clarity, consistency, and maintainability while preserving functionality",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
name: code-simplifier
|
||||
description: Simplifies and refines code for clarity, consistency, and maintainability while preserving all functionality. Focuses on recently modified code unless instructed otherwise.
|
||||
model: opus
|
||||
---
|
||||
|
||||
You are an expert code simplification specialist focused on enhancing code clarity, consistency, and maintainability while preserving exact functionality. Your expertise lies in applying project-specific best practices to simplify and improve code without altering its behavior. You prioritize readable, explicit code over overly compact solutions. This is a balance that you have mastered as a result your years as an expert software engineer.
|
||||
|
||||
You will analyze recently modified code and apply refinements that:
|
||||
|
||||
1. **Preserve Functionality**: Never change what the code does - only how it does it. All original features, outputs, and behaviors must remain intact.
|
||||
|
||||
2. **Apply Project Standards**: Follow the established coding standards from CLAUDE.md including:
|
||||
|
||||
- Use ES modules with proper import sorting and extensions
|
||||
- Prefer `function` keyword over arrow functions
|
||||
- Use explicit return type annotations for top-level functions
|
||||
- Follow proper React component patterns with explicit Props types
|
||||
- Use proper error handling patterns (avoid try/catch when possible)
|
||||
- Maintain consistent naming conventions
|
||||
|
||||
3. **Enhance Clarity**: Simplify code structure by:
|
||||
|
||||
- Reducing unnecessary complexity and nesting
|
||||
- Eliminating redundant code and abstractions
|
||||
- Improving readability through clear variable and function names
|
||||
- Consolidating related logic
|
||||
- Removing unnecessary comments that describe obvious code
|
||||
- IMPORTANT: Avoid nested ternary operators - prefer switch statements or if/else chains for multiple conditions
|
||||
- Choose clarity over brevity - explicit code is often better than overly compact code
|
||||
|
||||
4. **Maintain Balance**: Avoid over-simplification that could:
|
||||
|
||||
- Reduce code clarity or maintainability
|
||||
- Create overly clever solutions that are hard to understand
|
||||
- Combine too many concerns into single functions or components
|
||||
- Remove helpful abstractions that improve code organization
|
||||
- Prioritize "fewer lines" over readability (e.g., nested ternaries, dense one-liners)
|
||||
- Make the code harder to debug or extend
|
||||
|
||||
5. **Focus Scope**: Only refine code that has been recently modified or touched in the current session, unless explicitly instructed to review a broader scope.
|
||||
|
||||
Your refinement process:
|
||||
|
||||
1. Identify the recently modified code sections
|
||||
2. Analyze for opportunities to improve elegance and consistency
|
||||
3. Apply project-specific best practices and coding standards
|
||||
4. Ensure all functionality remains unchanged
|
||||
5. Verify the refined code is simpler and more maintainable
|
||||
6. Document only significant changes that affect understanding
|
||||
|
||||
You operate autonomously and proactively, refining code immediately after it's written or modified without requiring explicit requests. Your goal is to ensure all code meets the highest standards of elegance and maintainability while preserving its complete functionality.
|
||||
@@ -1,25 +0,0 @@
|
||||
# csharp-lsp
|
||||
|
||||
C# language server for Claude Code, providing code intelligence and diagnostics.
|
||||
|
||||
## Supported Extensions
|
||||
`.cs`
|
||||
|
||||
## Installation
|
||||
|
||||
### Via .NET tool (recommended)
|
||||
```bash
|
||||
dotnet tool install --global csharp-ls
|
||||
```
|
||||
|
||||
### Via Homebrew (macOS)
|
||||
```bash
|
||||
brew install csharp-ls
|
||||
```
|
||||
|
||||
## Requirements
|
||||
- .NET SDK 6.0 or later
|
||||
|
||||
## More Information
|
||||
- [csharp-ls GitHub](https://github.com/razzmatazz/csharp-language-server)
|
||||
- [.NET SDK Download](https://dotnet.microsoft.com/download)
|
||||
@@ -1,20 +0,0 @@
|
||||
# gopls-lsp
|
||||
|
||||
Go language server for Claude Code, providing code intelligence, refactoring, and analysis.
|
||||
|
||||
## Supported Extensions
|
||||
`.go`
|
||||
|
||||
## Installation
|
||||
|
||||
Install gopls using the Go toolchain:
|
||||
|
||||
```bash
|
||||
go install golang.org/x/tools/gopls@latest
|
||||
```
|
||||
|
||||
Make sure `$GOPATH/bin` (or `$HOME/go/bin`) is in your PATH.
|
||||
|
||||
## More Information
|
||||
- [gopls Documentation](https://pkg.go.dev/golang.org/x/tools/gopls)
|
||||
- [GitHub Repository](https://github.com/golang/tools/tree/master/gopls)
|
||||
@@ -7,7 +7,7 @@ from functools import lru_cache
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
# Import from local module
|
||||
from core.config_loader import Rule, Condition
|
||||
from hookify.core.config_loader import Rule, Condition
|
||||
|
||||
|
||||
# Cache compiled regexes (max 128 patterns)
|
||||
@@ -275,7 +275,7 @@ class RuleEngine:
|
||||
|
||||
# For testing
|
||||
if __name__ == '__main__':
|
||||
from core.config_loader import Condition, Rule
|
||||
from hookify.core.config_loader import Condition, Rule
|
||||
|
||||
# Test rule evaluation
|
||||
rule = Rule(
|
||||
|
||||
@@ -9,14 +9,18 @@ import os
|
||||
import sys
|
||||
import json
|
||||
|
||||
# Add plugin root to Python path for imports
|
||||
# CRITICAL: Add plugin root to Python path for imports
|
||||
PLUGIN_ROOT = os.environ.get('CLAUDE_PLUGIN_ROOT')
|
||||
if PLUGIN_ROOT and PLUGIN_ROOT not in sys.path:
|
||||
sys.path.insert(0, PLUGIN_ROOT)
|
||||
if PLUGIN_ROOT:
|
||||
parent_dir = os.path.dirname(PLUGIN_ROOT)
|
||||
if parent_dir not in sys.path:
|
||||
sys.path.insert(0, parent_dir)
|
||||
if PLUGIN_ROOT not in sys.path:
|
||||
sys.path.insert(0, PLUGIN_ROOT)
|
||||
|
||||
try:
|
||||
from core.config_loader import load_rules
|
||||
from core.rule_engine import RuleEngine
|
||||
from hookify.core.config_loader import load_rules
|
||||
from hookify.core.rule_engine import RuleEngine
|
||||
except ImportError as e:
|
||||
error_msg = {"systemMessage": f"Hookify import error: {e}"}
|
||||
print(json.dumps(error_msg), file=sys.stdout)
|
||||
|
||||
@@ -9,14 +9,22 @@ import os
|
||||
import sys
|
||||
import json
|
||||
|
||||
# Add plugin root to Python path for imports
|
||||
# CRITICAL: Add plugin root to Python path for imports
|
||||
# We need to add the parent of the plugin directory so Python can find "hookify" package
|
||||
PLUGIN_ROOT = os.environ.get('CLAUDE_PLUGIN_ROOT')
|
||||
if PLUGIN_ROOT and PLUGIN_ROOT not in sys.path:
|
||||
sys.path.insert(0, PLUGIN_ROOT)
|
||||
if PLUGIN_ROOT:
|
||||
# Add the parent directory of the plugin
|
||||
parent_dir = os.path.dirname(PLUGIN_ROOT)
|
||||
if parent_dir not in sys.path:
|
||||
sys.path.insert(0, parent_dir)
|
||||
|
||||
# Also add PLUGIN_ROOT itself in case we have other scripts
|
||||
if PLUGIN_ROOT not in sys.path:
|
||||
sys.path.insert(0, PLUGIN_ROOT)
|
||||
|
||||
try:
|
||||
from core.config_loader import load_rules
|
||||
from core.rule_engine import RuleEngine
|
||||
from hookify.core.config_loader import load_rules
|
||||
from hookify.core.rule_engine import RuleEngine
|
||||
except ImportError as e:
|
||||
# If imports fail, allow operation and log error
|
||||
error_msg = {"systemMessage": f"Hookify import error: {e}"}
|
||||
|
||||
@@ -9,14 +9,18 @@ import os
|
||||
import sys
|
||||
import json
|
||||
|
||||
# Add plugin root to Python path for imports
|
||||
# CRITICAL: Add plugin root to Python path for imports
|
||||
PLUGIN_ROOT = os.environ.get('CLAUDE_PLUGIN_ROOT')
|
||||
if PLUGIN_ROOT and PLUGIN_ROOT not in sys.path:
|
||||
sys.path.insert(0, PLUGIN_ROOT)
|
||||
if PLUGIN_ROOT:
|
||||
parent_dir = os.path.dirname(PLUGIN_ROOT)
|
||||
if parent_dir not in sys.path:
|
||||
sys.path.insert(0, parent_dir)
|
||||
if PLUGIN_ROOT not in sys.path:
|
||||
sys.path.insert(0, PLUGIN_ROOT)
|
||||
|
||||
try:
|
||||
from core.config_loader import load_rules
|
||||
from core.rule_engine import RuleEngine
|
||||
from hookify.core.config_loader import load_rules
|
||||
from hookify.core.rule_engine import RuleEngine
|
||||
except ImportError as e:
|
||||
error_msg = {"systemMessage": f"Hookify import error: {e}"}
|
||||
print(json.dumps(error_msg), file=sys.stdout)
|
||||
|
||||
@@ -9,14 +9,18 @@ import os
|
||||
import sys
|
||||
import json
|
||||
|
||||
# Add plugin root to Python path for imports
|
||||
# CRITICAL: Add plugin root to Python path for imports
|
||||
PLUGIN_ROOT = os.environ.get('CLAUDE_PLUGIN_ROOT')
|
||||
if PLUGIN_ROOT and PLUGIN_ROOT not in sys.path:
|
||||
sys.path.insert(0, PLUGIN_ROOT)
|
||||
if PLUGIN_ROOT:
|
||||
parent_dir = os.path.dirname(PLUGIN_ROOT)
|
||||
if parent_dir not in sys.path:
|
||||
sys.path.insert(0, parent_dir)
|
||||
if PLUGIN_ROOT not in sys.path:
|
||||
sys.path.insert(0, PLUGIN_ROOT)
|
||||
|
||||
try:
|
||||
from core.config_loader import load_rules
|
||||
from core.rule_engine import RuleEngine
|
||||
from hookify.core.config_loader import load_rules
|
||||
from hookify.core.rule_engine import RuleEngine
|
||||
except ImportError as e:
|
||||
error_msg = {"systemMessage": f"Hookify import error: {e}"}
|
||||
print(json.dumps(error_msg), file=sys.stdout)
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# jdtls-lsp
|
||||
|
||||
Java language server (Eclipse JDT.LS) for Claude Code, providing code intelligence and refactoring.
|
||||
|
||||
## Supported Extensions
|
||||
`.java`
|
||||
|
||||
## Installation
|
||||
|
||||
### Via Homebrew (macOS)
|
||||
```bash
|
||||
brew install jdtls
|
||||
```
|
||||
|
||||
### Via package manager (Linux)
|
||||
```bash
|
||||
# Arch Linux (AUR)
|
||||
yay -S jdtls
|
||||
|
||||
# Other distros: manual installation required
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
1. Download from [Eclipse JDT.LS releases](https://download.eclipse.org/jdtls/snapshots/)
|
||||
2. Extract to a directory (e.g., `~/.local/share/jdtls`)
|
||||
3. Create a wrapper script named `jdtls` in your PATH
|
||||
|
||||
## Requirements
|
||||
- Java 17 or later (JDK, not just JRE)
|
||||
|
||||
## More Information
|
||||
- [Eclipse JDT.LS GitHub](https://github.com/eclipse-jdtls/eclipse.jdt.ls)
|
||||
- [VSCode Java Extension](https://github.com/redhat-developer/vscode-java) (uses JDT.LS)
|
||||
@@ -1,16 +0,0 @@
|
||||
Kotlin language server for Claude Code, providing code intelligence, refactoring, and analysis.
|
||||
|
||||
## Supported Extensions
|
||||
`.kt`
|
||||
`.kts`
|
||||
|
||||
## Installation
|
||||
|
||||
Install the Kotlin LSP CLI.
|
||||
|
||||
```bash
|
||||
brew install JetBrains/utils/kotlin-lsp
|
||||
```
|
||||
|
||||
## More Information
|
||||
- [kotlin LSP](https://github.com/Kotlin/kotlin-lsp)
|
||||
@@ -1,32 +0,0 @@
|
||||
# lua-lsp
|
||||
|
||||
Lua language server for Claude Code, providing code intelligence and diagnostics.
|
||||
|
||||
## Supported Extensions
|
||||
`.lua`
|
||||
|
||||
## Installation
|
||||
|
||||
### Via Homebrew (macOS)
|
||||
```bash
|
||||
brew install lua-language-server
|
||||
```
|
||||
|
||||
### Via package manager (Linux)
|
||||
```bash
|
||||
# Ubuntu/Debian (via snap)
|
||||
sudo snap install lua-language-server --classic
|
||||
|
||||
# Arch Linux
|
||||
sudo pacman -S lua-language-server
|
||||
|
||||
# Fedora
|
||||
sudo dnf install lua-language-server
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
Download pre-built binaries from the [releases page](https://github.com/LuaLS/lua-language-server/releases).
|
||||
|
||||
## More Information
|
||||
- [Lua Language Server GitHub](https://github.com/LuaLS/lua-language-server)
|
||||
- [Documentation](https://luals.github.io/)
|
||||
@@ -1,24 +0,0 @@
|
||||
# php-lsp
|
||||
|
||||
PHP language server (Intelephense) for Claude Code, providing code intelligence and diagnostics.
|
||||
|
||||
## Supported Extensions
|
||||
`.php`
|
||||
|
||||
## Installation
|
||||
|
||||
Install Intelephense globally via npm:
|
||||
|
||||
```bash
|
||||
npm install -g intelephense
|
||||
```
|
||||
|
||||
Or with yarn:
|
||||
|
||||
```bash
|
||||
yarn global add intelephense
|
||||
```
|
||||
|
||||
## More Information
|
||||
- [Intelephense Website](https://intelephense.com/)
|
||||
- [Intelephense on npm](https://www.npmjs.com/package/intelephense)
|
||||
@@ -120,7 +120,7 @@ Use this workflow for structured, high-quality plugin development from concept t
|
||||
- YAML frontmatter + markdown body structure
|
||||
- Parsing techniques for bash scripts (sed, awk, grep patterns)
|
||||
- Temporarily active hooks (flag files and quick-exit)
|
||||
- Real-world examples from multi-agent-swarm and ralph-loop plugins
|
||||
- Real-world examples from multi-agent-swarm and ralph-wiggum plugins
|
||||
- Atomic file updates and validation
|
||||
- Gitignore and lifecycle management
|
||||
|
||||
|
||||
@@ -449,7 +449,7 @@ Coordinate with auth-agent on shared types.
|
||||
- Sends notifications to coordinator if enabled
|
||||
- Allows quick activation/deactivation via `enabled: true/false`
|
||||
|
||||
### ralph-loop Plugin
|
||||
### ralph-wiggum Plugin
|
||||
|
||||
**.claude/ralph-loop.local.md:**
|
||||
```markdown
|
||||
@@ -512,7 +512,7 @@ fi
|
||||
For detailed implementation patterns:
|
||||
|
||||
- **`references/parsing-techniques.md`** - Complete guide to parsing YAML frontmatter and markdown bodies
|
||||
- **`references/real-world-examples.md`** - Deep dive into multi-agent-swarm and ralph-loop implementations
|
||||
- **`references/real-world-examples.md`** - Deep dive into multi-agent-swarm and ralph-wiggum implementations
|
||||
|
||||
### Example Files
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ sed "s/^pr_number: .*/pr_number: $PR_NUM/" \
|
||||
mv temp.md ".claude/multi-agent-swarm.local.md"
|
||||
```
|
||||
|
||||
## ralph-loop Plugin
|
||||
## ralph-wiggum Plugin
|
||||
|
||||
### Settings File Structure
|
||||
|
||||
@@ -253,7 +253,7 @@ echo "Ralph loop initialized: .claude/ralph-loop.local.md"
|
||||
|
||||
## Pattern Comparison
|
||||
|
||||
| Feature | multi-agent-swarm | ralph-loop |
|
||||
| Feature | multi-agent-swarm | ralph-wiggum |
|
||||
|---------|-------------------|--------------|
|
||||
| **File** | `.claude/multi-agent-swarm.local.md` | `.claude/ralph-loop.local.md` |
|
||||
| **Purpose** | Agent coordination state | Loop iteration state |
|
||||
|
||||
@@ -310,7 +310,7 @@ Study the skills in this plugin as examples of best practices:
|
||||
|
||||
**plugin-settings skill:**
|
||||
- Specific triggers: "plugin settings", ".local.md files", "YAML frontmatter"
|
||||
- References show real implementations (multi-agent-swarm, ralph-loop)
|
||||
- References show real implementations (multi-agent-swarm, ralph-wiggum)
|
||||
- Working parsing scripts
|
||||
|
||||
Each demonstrates progressive disclosure and strong triggering.
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# pyright-lsp
|
||||
|
||||
Python language server (Pyright) for Claude Code, providing static type checking and code intelligence.
|
||||
|
||||
## Supported Extensions
|
||||
`.py`, `.pyi`
|
||||
|
||||
## Installation
|
||||
|
||||
Install Pyright globally via npm:
|
||||
|
||||
```bash
|
||||
npm install -g pyright
|
||||
```
|
||||
|
||||
Or with pip:
|
||||
|
||||
```bash
|
||||
pip install pyright
|
||||
```
|
||||
|
||||
Or with pipx (recommended for CLI tools):
|
||||
|
||||
```bash
|
||||
pipx install pyright
|
||||
```
|
||||
|
||||
## More Information
|
||||
- [Pyright on npm](https://www.npmjs.com/package/pyright)
|
||||
- [Pyright on PyPI](https://pypi.org/project/pyright/)
|
||||
- [GitHub Repository](https://github.com/microsoft/pyright)
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "ralph-loop",
|
||||
"description": "Continuous self-referential AI loops for interactive iterative development, implementing the Ralph Wiggum technique. Run Claude in a while-true loop with the same prompt until task completion.",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
description: "Cancel active Ralph Loop"
|
||||
allowed-tools: ["Bash(test -f .claude/ralph-loop.local.md:*)", "Bash(rm .claude/ralph-loop.local.md)", "Read(.claude/ralph-loop.local.md)"]
|
||||
hide-from-slash-command-tool: "true"
|
||||
---
|
||||
|
||||
# Cancel Ralph
|
||||
|
||||
To cancel the Ralph loop:
|
||||
|
||||
1. Check if `.claude/ralph-loop.local.md` exists using Bash: `test -f .claude/ralph-loop.local.md && echo "EXISTS" || echo "NOT_FOUND"`
|
||||
|
||||
2. **If NOT_FOUND**: Say "No active Ralph loop found."
|
||||
|
||||
3. **If EXISTS**:
|
||||
- Read `.claude/ralph-loop.local.md` to get the current iteration number from the `iteration:` field
|
||||
- Remove the file using Bash: `rm .claude/ralph-loop.local.md`
|
||||
- Report: "Cancelled Ralph loop (was at iteration N)" where N is the iteration value
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
description: "Start Ralph Loop in current session"
|
||||
argument-hint: "PROMPT [--max-iterations N] [--completion-promise TEXT]"
|
||||
allowed-tools: ["Bash(${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh:*)"]
|
||||
hide-from-slash-command-tool: "true"
|
||||
---
|
||||
|
||||
# Ralph Loop Command
|
||||
|
||||
Execute the setup script to initialize the Ralph loop:
|
||||
|
||||
```!
|
||||
"${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh" $ARGUMENTS
|
||||
```
|
||||
|
||||
Please work on the task. When you try to exit, the Ralph loop will feed the SAME PROMPT back to you for the next iteration. You'll see your previous work in files and git history, allowing you to iterate and improve.
|
||||
|
||||
CRITICAL RULE: If a completion promise is set, you may ONLY output it when the statement is completely and unequivocally TRUE. Do not output false promises to escape the loop, even if you think you're stuck or should exit for other reasons. The loop is designed to continue until genuine completion.
|
||||
8
plugins/ralph-wiggum/.claude-plugin/plugin.json
Normal file
8
plugins/ralph-wiggum/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "ralph-wiggum",
|
||||
"description": "Implementation of the Ralph Wiggum technique - continuous self-referential AI loops for interactive iterative development. Run Claude in a while-true loop with the same prompt until task completion.",
|
||||
"author": {
|
||||
"name": "Anthropic",
|
||||
"email": "support@anthropic.com"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
# Ralph Loop Plugin
|
||||
# Ralph Wiggum Plugin
|
||||
|
||||
Implementation of the Ralph Wiggum technique for iterative, self-referential AI development loops in Claude Code.
|
||||
|
||||
## What is Ralph Loop?
|
||||
## What is Ralph?
|
||||
|
||||
Ralph Loop is a development methodology based on continuous AI agent loops. As Geoffrey Huntley describes it: **"Ralph is a Bash loop"** - a simple `while true` that repeatedly feeds an AI agent a prompt file, allowing it to iteratively improve its work until completion.
|
||||
Ralph is a development methodology based on continuous AI agent loops. As Geoffrey Huntley describes it: **"Ralph is a Bash loop"** - a simple `while true` that repeatedly feeds an AI agent a prompt file, allowing it to iteratively improve its work until completion.
|
||||
|
||||
This technique is inspired by the Ralph Wiggum coding technique (named after the character from The Simpsons), embodying the philosophy of persistent iteration despite setbacks.
|
||||
The technique is named after Ralph Wiggum from The Simpsons, embodying the philosophy of persistent iteration despite setbacks.
|
||||
|
||||
### Core Concept
|
||||
|
||||
26
plugins/ralph-wiggum/commands/cancel-ralph.md
Normal file
26
plugins/ralph-wiggum/commands/cancel-ralph.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
description: "Cancel active Ralph Wiggum loop"
|
||||
allowed-tools: ["Bash"]
|
||||
hide-from-slash-command-tool: "true"
|
||||
---
|
||||
|
||||
# Cancel Ralph
|
||||
|
||||
```!
|
||||
if [[ -f .claude/ralph-loop.local.md ]]; then
|
||||
ITERATION=$(grep '^iteration:' .claude/ralph-loop.local.md | sed 's/iteration: *//')
|
||||
echo "FOUND_LOOP=true"
|
||||
echo "ITERATION=$ITERATION"
|
||||
else
|
||||
echo "FOUND_LOOP=false"
|
||||
fi
|
||||
```
|
||||
|
||||
Check the output above:
|
||||
|
||||
1. **If FOUND_LOOP=false**:
|
||||
- Say "No active Ralph loop found."
|
||||
|
||||
2. **If FOUND_LOOP=true**:
|
||||
- Use Bash: `rm .claude/ralph-loop.local.md`
|
||||
- Report: "Cancelled Ralph loop (was at iteration N)" where N is the ITERATION value from above.
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
description: "Explain Ralph Loop plugin and available commands"
|
||||
description: "Explain Ralph Wiggum technique and available commands"
|
||||
---
|
||||
|
||||
# Ralph Loop Plugin Help
|
||||
# Ralph Wiggum Plugin Help
|
||||
|
||||
Please explain the following to the user:
|
||||
|
||||
## What is Ralph Loop?
|
||||
## What is the Ralph Wiggum Technique?
|
||||
|
||||
Ralph Loop implements the Ralph Wiggum technique - an iterative development methodology based on continuous AI loops, pioneered by Geoffrey Huntley.
|
||||
The Ralph Wiggum technique is an iterative development methodology based on continuous AI loops, pioneered by Geoffrey Huntley.
|
||||
|
||||
**Core concept:**
|
||||
```bash
|
||||
48
plugins/ralph-wiggum/commands/ralph-loop.md
Normal file
48
plugins/ralph-wiggum/commands/ralph-loop.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
description: "Start Ralph Wiggum loop in current session"
|
||||
argument-hint: "PROMPT [--max-iterations N] [--completion-promise TEXT]"
|
||||
allowed-tools: ["Bash(${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh)"]
|
||||
hide-from-slash-command-tool: "true"
|
||||
---
|
||||
|
||||
# Ralph Loop Command
|
||||
|
||||
Execute the setup script to initialize the Ralph loop:
|
||||
|
||||
```!
|
||||
"${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh" $ARGUMENTS
|
||||
|
||||
# Extract and display completion promise if set
|
||||
if [ -f .claude/ralph-loop.local.md ]; then
|
||||
PROMISE=$(grep '^completion_promise:' .claude/ralph-loop.local.md | sed 's/completion_promise: *//' | sed 's/^"\(.*\)"$/\1/')
|
||||
if [ -n "$PROMISE" ] && [ "$PROMISE" != "null" ]; then
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo "CRITICAL - Ralph Loop Completion Promise"
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo "To complete this loop, output this EXACT text:"
|
||||
echo " <promise>$PROMISE</promise>"
|
||||
echo ""
|
||||
echo "STRICT REQUIREMENTS (DO NOT VIOLATE):"
|
||||
echo " ✓ Use <promise> XML tags EXACTLY as shown above"
|
||||
echo " ✓ The statement MUST be completely and unequivocally TRUE"
|
||||
echo " ✓ Do NOT output false statements to exit the loop"
|
||||
echo " ✓ Do NOT lie even if you think you should exit"
|
||||
echo ""
|
||||
echo "IMPORTANT - Do not circumvent the loop:"
|
||||
echo " Even if you believe you're stuck, the task is impossible,"
|
||||
echo " or you've been running too long - you MUST NOT output a"
|
||||
echo " false promise statement. The loop is designed to continue"
|
||||
echo " until the promise is GENUINELY TRUE. Trust the process."
|
||||
echo ""
|
||||
echo " If the loop should stop, the promise statement will become"
|
||||
echo " true naturally. Do not force it by lying."
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
Please work on the task. When you try to exit, the Ralph loop will feed the SAME PROMPT back to you for the next iteration. You'll see your previous work in files and git history, allowing you to iterate and improve.
|
||||
|
||||
CRITICAL RULE: If a completion promise is set, you may ONLY output it when the statement is completely and unequivocally TRUE. Do not output false promises to escape the loop, even if you think you're stuck or should exit for other reasons. The loop is designed to continue until genuine completion.
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"description": "Ralph Loop plugin stop hook for self-referential loops",
|
||||
"description": "Ralph Wiggum plugin stop hook for self-referential loops",
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Ralph Loop Stop Hook
|
||||
# Ralph Wiggum Stop Hook
|
||||
# Prevents session exit when a ralph-loop is active
|
||||
# Feeds Claude's output back as input to continue the loop
|
||||
|
||||
@@ -29,7 +29,7 @@ OPTIONS:
|
||||
-h, --help Show this help message
|
||||
|
||||
DESCRIPTION:
|
||||
Starts a Ralph Loop in your CURRENT session. The stop hook prevents
|
||||
Starts a Ralph Wiggum loop in your CURRENT session. The stop hook prevents
|
||||
exit and feeds your output back as input until completion or iteration limit.
|
||||
|
||||
To signal completion, you must output: <promise>YOUR_PHRASE</promise>
|
||||
@@ -174,30 +174,3 @@ if [[ -n "$PROMPT" ]]; then
|
||||
echo ""
|
||||
echo "$PROMPT"
|
||||
fi
|
||||
|
||||
# Display completion promise requirements if set
|
||||
if [[ "$COMPLETION_PROMISE" != "null" ]]; then
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo "CRITICAL - Ralph Loop Completion Promise"
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo "To complete this loop, output this EXACT text:"
|
||||
echo " <promise>$COMPLETION_PROMISE</promise>"
|
||||
echo ""
|
||||
echo "STRICT REQUIREMENTS (DO NOT VIOLATE):"
|
||||
echo " ✓ Use <promise> XML tags EXACTLY as shown above"
|
||||
echo " ✓ The statement MUST be completely and unequivocally TRUE"
|
||||
echo " ✓ Do NOT output false statements to exit the loop"
|
||||
echo " ✓ Do NOT lie even if you think you should exit"
|
||||
echo ""
|
||||
echo "IMPORTANT - Do not circumvent the loop:"
|
||||
echo " Even if you believe you're stuck, the task is impossible,"
|
||||
echo " or you've been running too long - you MUST NOT output a"
|
||||
echo " false promise statement. The loop is designed to continue"
|
||||
echo " until the promise is GENUINELY TRUE. Trust the process."
|
||||
echo ""
|
||||
echo " If the loop should stop, the promise statement will become"
|
||||
echo " true naturally. Do not force it by lying."
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
fi
|
||||
@@ -1,34 +0,0 @@
|
||||
# rust-analyzer-lsp
|
||||
|
||||
Rust language server for Claude Code, providing code intelligence and analysis.
|
||||
|
||||
## Supported Extensions
|
||||
`.rs`
|
||||
|
||||
## Installation
|
||||
|
||||
### Via rustup (recommended)
|
||||
```bash
|
||||
rustup component add rust-analyzer
|
||||
```
|
||||
|
||||
### Via Homebrew (macOS)
|
||||
```bash
|
||||
brew install rust-analyzer
|
||||
```
|
||||
|
||||
### Via package manager (Linux)
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install rust-analyzer
|
||||
|
||||
# Arch Linux
|
||||
sudo pacman -S rust-analyzer
|
||||
```
|
||||
|
||||
### Manual download
|
||||
Download pre-built binaries from the [releases page](https://github.com/rust-lang/rust-analyzer/releases).
|
||||
|
||||
## More Information
|
||||
- [rust-analyzer Website](https://rust-analyzer.github.io/)
|
||||
- [GitHub Repository](https://github.com/rust-lang/rust-analyzer)
|
||||
@@ -1,25 +0,0 @@
|
||||
# swift-lsp
|
||||
|
||||
Swift language server (SourceKit-LSP) for Claude Code, providing code intelligence for Swift projects.
|
||||
|
||||
## Supported Extensions
|
||||
`.swift`
|
||||
|
||||
## Installation
|
||||
|
||||
SourceKit-LSP is included with the Swift toolchain.
|
||||
|
||||
### macOS
|
||||
Install Xcode from the App Store, or install Swift via:
|
||||
```bash
|
||||
brew install swift
|
||||
```
|
||||
|
||||
### Linux
|
||||
Download and install Swift from [swift.org](https://www.swift.org/download/).
|
||||
|
||||
After installation, `sourcekit-lsp` should be available in your PATH.
|
||||
|
||||
## More Information
|
||||
- [SourceKit-LSP GitHub](https://github.com/apple/sourcekit-lsp)
|
||||
- [Swift.org](https://www.swift.org/)
|
||||
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,
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
# typescript-lsp
|
||||
|
||||
TypeScript/JavaScript language server for Claude Code, providing code intelligence features like go-to-definition, find references, and error checking.
|
||||
|
||||
## Supported Extensions
|
||||
`.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, `.cts`, `.mjs`, `.cjs`
|
||||
|
||||
## Installation
|
||||
|
||||
Install the TypeScript language server globally via npm:
|
||||
|
||||
```bash
|
||||
npm install -g typescript-language-server typescript
|
||||
```
|
||||
|
||||
Or with yarn:
|
||||
|
||||
```bash
|
||||
yarn global add typescript-language-server typescript
|
||||
```
|
||||
|
||||
## More Information
|
||||
- [typescript-language-server on npm](https://www.npmjs.com/package/typescript-language-server)
|
||||
- [GitHub Repository](https://github.com/typescript-language-server/typescript-language-server)
|
||||
Reference in New Issue
Block a user