Trending-skills acgti-anime-persona-quiz
ACG Type Indicator — MBTI-inspired anime character persona quiz built with Vue 3, TypeScript, and Vite
git clone https://github.com/Aradotso/trending-skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/Aradotso/trending-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/acgti-anime-persona-quiz" ~/.claude/skills/aradotso-trending-skills-acgti-anime-persona-quiz && rm -rf "$T"
skills/acgti-anime-persona-quiz/SKILL.mdACGTI Anime Persona Quiz
Skill by ara.so — Daily 2026 Skills collection.
ACGTI (ACG Type Indicator) is a purely client-side Vue 3 + TypeScript quiz that maps 39 seven-point Likert-scale questions onto four MBTI dimensions (E/I, S/N, T/F, J/P), matches the result to one of 8 anime archetypes, and then selects a specific anime character from a 40+ entry database. No backend, no user data collection — everything runs in the browser.
Installation & Local Development
# Clone the repo git clone https://github.com/tianxingleo/ACGTI.git cd ACGTI # Install dependencies (Node 18+ recommended) npm install # Start dev server (Vite, hot-reload) npm run dev # Type-check npx tsc --noEmit # Production build → dist/ npm run build # Preview production build locally npm run preview
The
dist/ folder uses base: './' (relative paths), so it deploys directly to any static host.
Project Architecture
src/ ├── components/ # Reusable UI (QuestionCard, ResultSummary, SharePoster …) ├── composables/ │ ├── useQuiz.ts # Quiz state machine & answer logic │ └── useShare.ts # PNG poster export ├── data/ # ALL content lives here as JSON │ ├── questions.json │ ├── archetypes.json │ ├── characters.json │ ├── characterVisuals.json │ └── characterProbabilities.json ├── pages/ # Vue route-level components ├── types/quiz.ts # Shared TypeScript types ├── utils/ │ ├── quizEngine.ts # Score → archetype → character pipeline │ ├── characterVisuals.ts │ ├── characterProbability.ts │ └── storage.ts # localStorage helpers └── router/index.ts
Core Types (src/types/quiz.ts
)
src/types/quiz.tsUnderstanding these types is essential before touching any data file or engine logic.
// MBTI dimension keys export type Dimension = 'EI' | 'SN' | 'TF' | 'JP'; // One question entry export interface Question { id: number; text: string; dimension: Dimension; archetypeWeights: Record<string, number>; // archetype id → weight (-3..+3) tags?: string[]; } // One of 8 archetypes export interface Archetype { id: string; // e.g. "glowing-protagonist" name: string; mbtiTypes: string[]; // e.g. ["ENFJ","ENFP"] description: string; strengths: string[]; weaknesses: string[]; color: string; // hex } // Anime character entry export interface Character { id: string; // unique slug, becomes the "character code" name: string; series: string; mbtiType: string; // e.g. "ENFJ" archetypeId: string; tags: string[]; stats: { // 0–100 six-axis radar energy: number; intuition: number; empathy: number; logic: number; order: number; chaos: number; }; } // Visual theming per character export interface CharacterVisual { characterId: string; portraitUrl: string; backgroundUrl: string; primaryColor: string; accentColor: string; } // Final computed result passed to ResultPage export interface QuizResult { mbtiType: string; // e.g. "INFP" dimensionScores: Record<Dimension, number>; // 50–100, direction-normalised archetypeId: string; characterId: string; }
Scoring Engine (src/utils/quizEngine.ts
)
src/utils/quizEngine.tsThe engine is a pure function pipeline — ideal extension point.
import questions from '@/data/questions.json'; import archetypes from '@/data/archetypes.json'; import characters from '@/data/characters.json'; import type { Dimension, QuizResult } from '@/types/quiz'; type Answers = Record<number, number>; // questionId → -3..+3 /** Step 1: Sum raw signed scores per MBTI dimension */ function calcDimensionRaw(answers: Answers): Record<Dimension, number> { const raw: Record<Dimension, number> = { EI: 0, SN: 0, TF: 0, JP: 0 }; for (const q of questions) { const val = answers[q.id] ?? 0; raw[q.dimension as Dimension] += val; } return raw; } /** Step 2: Normalise to 50–100 (50 = perfectly balanced) */ function normaliseDimension(raw: number, questionCount: number): number { const max = questionCount * 3; // maximum possible absolute value const clamped = Math.max(-max, Math.min(max, raw)); return Math.round(50 + (Math.abs(clamped) / max) * 50); } /** Step 3: Derive MBTI letter for one dimension */ function mbtiLetter(dimension: Dimension, raw: number): string { const positive: Record<Dimension, string> = { EI: 'E', SN: 'N', TF: 'T', JP: 'J' }; const negative: Record<Dimension, string> = { EI: 'I', SN: 'S', TF: 'F', JP: 'P' }; return raw >= 0 ? positive[dimension] : negative[dimension]; } /** Full pipeline */ export function computeResult(answers: Answers): QuizResult { const dims: Dimension[] = ['EI', 'SN', 'TF', 'JP']; const raw = calcDimensionRaw(answers); // Count questions per dimension for normalisation const countPerDim = dims.reduce((acc, d) => { acc[d] = questions.filter(q => q.dimension === d).length; return acc; }, {} as Record<Dimension, number>); const dimensionScores = dims.reduce((acc, d) => { acc[d] = normaliseDimension(raw[d], countPerDim[d]); return acc; }, {} as Record<Dimension, number>); const mbtiType = dims.map(d => mbtiLetter(d, raw[d])).join(''); // Match archetype (archetypes list mbtiTypes they cover) const archetype = archetypes.find(a => a.mbtiTypes.includes(mbtiType)) ?? archetypes[0]; // Pick best-fit character within archetype const candidates = characters.filter(c => c.archetypeId === archetype.id); // Default: first match; extendable with probability weighting const character = candidates[0]; return { mbtiType, dimensionScores, archetypeId: archetype.id, characterId: character.id, }; }
Adding a New Character
Edit
src/data/characters.json — append one object following the schema:
{ "id": "hatsune-miku", "name": "初音ミク", "series": "VOCALOID", "mbtiType": "ENFP", "archetypeId": "chaotic-spark", "tags": ["vocaloid", "energetic", "creative"], "stats": { "energy": 90, "intuition": 85, "empathy": 75, "logic": 50, "order": 40, "chaos": 80 } }
Then add the matching visual entry to
src/data/characterVisuals.json:
{ "characterId": "hatsune-miku", "portraitUrl": "https://your-cdn.example.com/miku-portrait.webp", "backgroundUrl": "https://your-cdn.example.com/miku-bg.webp", "primaryColor": "#39C5BB", "accentColor": "#86EFDF" }
And an optional prior probability in
src/data/characterProbabilities.json:
{ "characterId": "hatsune-miku", "baseProbability": 0.15 }
Rules:
•must be unique and kebab-case.id
•must be one of the 16 standard types.mbtiType
•must match anarchetypeIdinid.archetypes.json
•values are integers 0–100.stats
Adding New Quiz Questions
Edit
src/data/questions.json — append to the array:
{ "id": 40, "text": "在一个陌生的聚会上,你更倾向于主动找人搭话还是等别人来找你?", "dimension": "EI", "archetypeWeights": { "glowing-protagonist": 2, "ice-observer": -2, "oath-captain": 1, "agile-spinner": 1, "gentle-healer": 0, "shadow-strategist": -1, "chaotic-spark": 2, "moonlit-guardian": -1 }, "tags": ["social", "introvert-extrovert"] }
Guidelines:
must be unique and increment sequentially.id
is one ofdimension
."EI" | "SN" | "TF" | "JP"
keys must match all 8 archetypearchetypeWeights
values; weights range -3 to +3.id- Positive weight = answer "strongly agree" nudges toward that archetype.
- Keep question text in Chinese (Simplified) to match existing copy.
Modifying Archetypes (src/data/archetypes.json
)
src/data/archetypes.json{ "id": "glowing-protagonist", "name": "发光主角位", "mbtiTypes": ["ENFJ", "ENFP"], "description": "天生的领袖与感召者,能点燃周围人的热情。", "strengths": ["感召力强", "共情深刻", "行动力高"], "weaknesses": ["容易过度承担", "情绪波动大"], "color": "#FF6B6B" }
Each MBTI type (16 total) should appear in exactly one archetype's
array. The engine uses a first-match lookup — gaps cause a fallback tombtiTypes.archetypes[0]
useQuiz
Composable (state management)
useQuiz// src/composables/useQuiz.ts — typical usage from a page component import { useQuiz } from '@/composables/useQuiz'; const { currentQuestion, // Ref<Question> currentIndex, // Ref<number> totalQuestions, // number (39) progress, // ComputedRef<number> 0–100 answer, // (value: number) => void — records -3..+3 and advances goBack, // () => void result, // Ref<QuizResult | null> isComplete, // ComputedRef<boolean> resetQuiz, // () => void } = useQuiz();
Share / Export Poster (useShare
)
useShareimport { useShare } from '@/composables/useShare'; const { exportPNG, shareNative } = useShare(); // exportPNG wraps html2canvas on the #share-poster element await exportPNG('#share-poster', 'my-acgti-result.png'); // shareNative uses Web Share API with fallback to clipboard copy await shareNative({ title: 'My ACGTI Result', text: `I got ${result.value?.characterId}!`, url: 'https://acgti.tianxingleo.top', });
Routing (src/router/index.ts
)
src/router/index.ts// Five named routes const routes = [ { path: '/', name: 'home', component: HomePage }, { path: '/intro', name: 'intro', component: IntroPage }, { path: '/quiz', name: 'quiz', component: QuizPage }, { path: '/result', name: 'result', component: ResultPage }, { path: '/characters',name: 'characters', component: CharactersPage }, { path: '/about', name: 'about', component: AboutPage }, ];
Navigate programmatically after quiz completion:
import { useRouter } from 'vue-router'; const router = useRouter(); router.push({ name: 'result' });
localStorage Utilities (src/utils/storage.ts
)
src/utils/storage.tsimport { saveResult, loadResult, clearResult } from '@/utils/storage'; import type { QuizResult } from '@/types/quiz'; // Persist result across page refreshes saveResult(result); // Restore on ResultPage mount const saved: QuizResult | null = loadResult(); // Reset for retake clearResult();
Deployment
Cloudflare Pages (recommended)
- Connect GitHub repo → Cloudflare Pages dashboard.
- Build command:
npm run build - Build output directory:
dist - No environment variables required (pure frontend).
GitHub Actions CI
The repo includes a workflow that runs on every push to
main/dev and on PRs:
# .github/workflows/ci.yml (existing) - run: npm ci - run: npm run build
Release a version
git tag v1.2.0 git push origin v1.2.0 # GitHub Actions auto-builds dist/, zips it, creates a Release
Common Patterns & Tips
Filtering characters by archetype in a component
import characters from '@/data/characters.json'; import type { Character } from '@/types/quiz'; const archetypeId = 'glowing-protagonist'; const subset: Character[] = characters.filter( (c) => c.archetypeId === archetypeId );
Accessing visuals by character ID
import visuals from '@/data/characterVisuals.json'; import { enrichCharacterVisuals } from '@/utils/characterVisuals'; const enriched = enrichCharacterVisuals(characters, visuals); // enriched[i] = { ...Character, ...CharacterVisual }
Reactive dimension label (E vs I, etc.)
function dimensionLabel(dim: Dimension, score: number): string { const labels: Record<Dimension, [string, string]> = { EI: ['E 外向', 'I 内向'], SN: ['N 直觉', 'S 实感'], TF: ['T 思考', 'F 情感'], JP: ['J 判断', 'P 知觉'], }; // score > 50 means positive pole; score === 50 means balanced (show both) return score >= 50 ? labels[dim][0] : labels[dim][1]; }
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
fails with type errors | New JSON data doesn't match types | Run and fix mismatches in |
| Character not appearing in results | mismatch between and | Ensure exactly matches an archetype |
| New question not affecting scores | key is wrong | Must be exactly , , , or |
| Poster export is blank | can't load cross-origin images | Host character images on a CORS-enabled CDN or use base64 data URIs |
| Route returns 404 on Cloudflare Pages | SPA fallback not configured | Add file: in |
Dev server errors on imports | Vite alias not resolving | Check has |