generated from nhcarrigan/template
feat: achievements
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import type { AchievementUnlockedEvent } from '$lib/types/achievements';
|
||||
|
||||
let achievements = $state<AchievementUnlockedEvent[]>([]);
|
||||
let currentAchievement = $state<AchievementUnlockedEvent | null>(null);
|
||||
let showNotification = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
const unlisten = await listen<AchievementUnlockedEvent>('achievement:unlocked', (event) => {
|
||||
achievements.push(event.payload);
|
||||
if (!showNotification) {
|
||||
showNext();
|
||||
}
|
||||
});
|
||||
|
||||
return unlisten;
|
||||
});
|
||||
|
||||
function showNext() {
|
||||
if (achievements.length > 0) {
|
||||
currentAchievement = achievements.shift() || null;
|
||||
showNotification = true;
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
showNotification = false;
|
||||
// Show next achievement after animation completes
|
||||
setTimeout(() => showNext(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
showNotification = false;
|
||||
// Show next achievement after animation completes
|
||||
setTimeout(() => showNext(), 300);
|
||||
}
|
||||
|
||||
function getRarityColor(rarity: string): string {
|
||||
switch (rarity) {
|
||||
case 'legendary': return 'from-yellow-400 to-orange-500';
|
||||
case 'epic': return 'from-purple-400 to-pink-500';
|
||||
case 'rare': return 'from-blue-400 to-indigo-500';
|
||||
default: return 'from-green-400 to-emerald-500';
|
||||
}
|
||||
}
|
||||
|
||||
function getAchievementRarity(id: string): string {
|
||||
// Determine rarity based on achievement ID
|
||||
if (id === 'TokenMaster') return 'legendary';
|
||||
if (['CodeMachine', 'Unstoppable'].includes(id)) return 'epic';
|
||||
if (['BlossomingCoder', 'CodeWizard', 'MasterBuilder', 'EnduranceChamp', 'DeepDive', 'CreativeCoder'].includes(id)) return 'rare';
|
||||
return 'common';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showNotification && currentAchievement}
|
||||
<div
|
||||
class="fixed top-20 right-4 z-50 max-w-sm"
|
||||
in:fly={{ x: 300, duration: 500, easing: cubicOut }}
|
||||
out:fade={{ duration: 300 }}
|
||||
>
|
||||
<!-- Backdrop with animated gradient border -->
|
||||
<div class="relative p-[2px] rounded-lg overflow-hidden">
|
||||
<!-- Animated gradient border -->
|
||||
<div class="absolute inset-0 bg-gradient-to-r {getRarityColor(getAchievementRarity(currentAchievement.achievement.id))} animate-pulse"></div>
|
||||
|
||||
<!-- Main notification content -->
|
||||
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
|
||||
<button
|
||||
onclick={dismiss}
|
||||
onkeydown={(e) => e.key === 'Enter' && dismiss()}
|
||||
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Icon with animated sparkles -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="text-5xl animate-bounce">{currentAchievement.achievement.icon}</div>
|
||||
|
||||
<!-- Sparkle animations -->
|
||||
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping">✨</div>
|
||||
<div class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200">✨</div>
|
||||
<div class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400">✨</div>
|
||||
</div>
|
||||
|
||||
<!-- Text content -->
|
||||
<div class="flex-1 min-w-0 pt-1">
|
||||
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
Achievement Unlocked!
|
||||
</h3>
|
||||
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
|
||||
{currentAchievement.achievement.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{currentAchievement.achievement.description}
|
||||
</p>
|
||||
|
||||
<!-- Rarity badge -->
|
||||
<div class="mt-2 inline-flex items-center">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {getRarityColor(getAchievementRarity(currentAchievement.achievement.id))} text-white capitalize">
|
||||
{getAchievementRarity(currentAchievement.achievement.id)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Celebration confetti effect (CSS only) -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
||||
{#each Array(10) as _, i}
|
||||
<div
|
||||
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(getAchievementRarity(currentAchievement.achievement.id))} rounded-full animate-fall"
|
||||
style="left: {Math.random() * 100}%; animation-delay: {Math.random() * 2}s; animation-duration: {2 + Math.random() * 2}s;"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes fall {
|
||||
0% {
|
||||
transform: translateY(-20px) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(400px) rotate(720deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fall {
|
||||
animation: fall linear infinite;
|
||||
}
|
||||
|
||||
.animation-delay-200 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.animation-delay-400 {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,223 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import {
|
||||
achievementsStore,
|
||||
achievementProgress,
|
||||
achievementCategories,
|
||||
} from '$lib/stores/achievements';
|
||||
import type { Achievement } from '$lib/types/achievements';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const { isOpen = $bindable(false), onClose } = $props<Props>();
|
||||
let selectedCategory = $state<string | null>(null);
|
||||
|
||||
const achievementsState = $derived($achievementsStore);
|
||||
const progress = $derived($achievementProgress);
|
||||
|
||||
function getRarityColor(rarity: string): string {
|
||||
switch (rarity) {
|
||||
case 'legendary': return 'text-yellow-500 dark:text-yellow-400';
|
||||
case 'epic': return 'text-purple-500 dark:text-purple-400';
|
||||
case 'rare': return 'text-blue-500 dark:text-blue-400';
|
||||
default: return 'text-green-500 dark:text-green-400';
|
||||
}
|
||||
}
|
||||
|
||||
function getRarityBg(rarity: string): string {
|
||||
switch (rarity) {
|
||||
case 'legendary': return 'bg-yellow-500/10';
|
||||
case 'epic': return 'bg-purple-500/10';
|
||||
case 'rare': return 'bg-blue-500/10';
|
||||
default: return 'bg-green-500/10';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date | undefined): string {
|
||||
if (!date) return '';
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function getAchievementsForCategory(categoryIds: string[]): Achievement[] {
|
||||
return categoryIds.map(id => achievementsState.achievements[id as keyof typeof achievementsState.achievements]).filter(Boolean);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Achievements panel -->
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-40"
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Close achievements panel"
|
||||
transition:slide={{ duration: 300, easing: quintOut }}
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="fixed left-0 top-0 h-full w-96 bg-[var(--bg-primary)] border-r border-[var(--border-color)]
|
||||
shadow-2xl z-50 flex flex-col"
|
||||
transition:slide={{ duration: 300, easing: quintOut, axis: 'x' }}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold text-[var(--text-primary)]">Achievements</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => e.key === 'Enter' && onClose()}
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Overall progress -->
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
<span>{progress.unlocked} / {progress.total} Unlocked</span>
|
||||
<span>{progress.percentage}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] h-2 rounded-full transition-all duration-500"
|
||||
style="width: {progress.percentage}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#each achievementCategories as category}
|
||||
{@const achievements = getAchievementsForCategory(category.ids)}
|
||||
{@const unlockedCount = achievements.filter(a => a.unlocked).length}
|
||||
|
||||
<div class="border-b border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={() => selectedCategory = selectedCategory === category.name ? null : category.name}
|
||||
onkeydown={(e) => e.key === 'Enter' && (selectedCategory = selectedCategory === category.name ? null : category.name)}
|
||||
class="w-full p-4 text-left hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold text-[var(--text-primary)]">{category.name}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{category.description}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{unlockedCount} / {achievements.length}
|
||||
</span>
|
||||
<svg
|
||||
class="w-5 h-5 transition-transform {selectedCategory === category.name ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if selectedCategory === category.name}
|
||||
<div class="p-4 space-y-3" transition:slide={{ duration: 200, easing: quintOut }}>
|
||||
{#each achievements as achievement}
|
||||
<div
|
||||
class="p-3 rounded-lg border {achievement.unlocked ? 'border-[var(--border-color)] bg-[var(--bg-secondary)]' : 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 opacity-50'}"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Icon -->
|
||||
<div class="text-3xl flex-shrink-0 {achievement.unlocked ? '' : 'grayscale'}">
|
||||
{achievement.icon}
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="font-semibold text-[var(--text-primary)]">
|
||||
{achievement.name}
|
||||
</h4>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full {getRarityBg(achievement.rarity)} {getRarityColor(achievement.rarity)} capitalize">
|
||||
{achievement.rarity}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{achievement.description}
|
||||
</p>
|
||||
|
||||
{#if achievement.unlocked && achievement.unlockedAt}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mt-2">
|
||||
Unlocked {formatDate(achievement.unlockedAt)}
|
||||
</p>
|
||||
{:else if achievement.maxProgress && achievement.progress !== undefined}
|
||||
<!-- Progress bar for locked achievements -->
|
||||
<div class="mt-2">
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Progress</span>
|
||||
<span>{achievement.progress} / {achievement.maxProgress}</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
class="bg-gray-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style="width: {Math.min((achievement.progress / achievement.maxProgress) * 100, 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Footer with last unlocked -->
|
||||
{#if achievementsState.lastUnlocked}
|
||||
<div class="p-4 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Last Unlocked:</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">{achievementsState.lastUnlocked.icon}</span>
|
||||
<div>
|
||||
<p class="font-semibold text-[var(--text-primary)]">{achievementsState.lastUnlocked.name}</p>
|
||||
<p class="text-xs text-gray-500">{formatDate(achievementsState.lastUnlocked.unlockedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Custom scrollbar for achievement list */
|
||||
:global(.overflow-y-auto::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
:global(.overflow-y-auto::-webkit-scrollbar-track) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
:global(.overflow-y-auto::-webkit-scrollbar-thumb) {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:global(.overflow-y-auto::-webkit-scrollbar-thumb:hover) {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,10 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
onToggleAchievements?: () => void;
|
||||
}
|
||||
|
||||
const { onToggleAchievements = () => {} } = $props<Props>();
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
@@ -8,6 +14,7 @@
|
||||
import type { ConnectionStatus } from "$lib/types/messages";
|
||||
import { onMount } from "svelte";
|
||||
import StatsDisplay from "./StatsDisplay.svelte";
|
||||
import { achievementProgress } from "$lib/stores/achievements";
|
||||
|
||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||
|
||||
@@ -18,6 +25,7 @@
|
||||
let grantedToolsList: string[] = $state([]);
|
||||
let appVersion = $state("");
|
||||
let showStats = $state(false);
|
||||
const progress = $derived($achievementProgress);
|
||||
let currentConfig: HikariConfig = $state({
|
||||
model: null,
|
||||
api_key: null,
|
||||
@@ -128,6 +136,10 @@
|
||||
return "Disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAchievements() {
|
||||
onToggleAchievements();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -169,6 +181,18 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onclick={toggleAchievements}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors relative"
|
||||
title="Achievements"
|
||||
>
|
||||
<span class="text-lg">🏆</span>
|
||||
{#if progress.unlocked > 0}
|
||||
<span class="absolute -top-1 -right-1 bg-[var(--accent-primary)] text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]">
|
||||
{progress.unlocked}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => showStats = !showStats}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats ? 'text-[var(--accent-primary)]' : ''}"
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { Achievement, AchievementUnlockedEvent, AchievementId } from '$lib/types/achievements';
|
||||
|
||||
interface AchievementState {
|
||||
achievements: Record<AchievementId, Achievement>;
|
||||
totalUnlocked: number;
|
||||
lastUnlocked: Achievement | null;
|
||||
}
|
||||
|
||||
// Initial achievement definitions
|
||||
const achievementDefinitions: Record<AchievementId, Omit<Achievement, 'unlocked' | 'unlockedAt'>> = {
|
||||
// Token milestones
|
||||
FirstSteps: {
|
||||
id: 'FirstSteps',
|
||||
name: 'First Steps',
|
||||
description: 'Generated your first 1,000 tokens',
|
||||
icon: '👶',
|
||||
rarity: 'common',
|
||||
maxProgress: 1000,
|
||||
},
|
||||
GrowingStrong: {
|
||||
id: 'GrowingStrong',
|
||||
name: 'Growing Strong',
|
||||
description: 'Reached 10,000 tokens total',
|
||||
icon: '🌱',
|
||||
rarity: 'common',
|
||||
maxProgress: 10000,
|
||||
},
|
||||
BlossomingCoder: {
|
||||
id: 'BlossomingCoder',
|
||||
name: 'Blossoming Coder',
|
||||
description: 'Generated 100,000 tokens - you\'re really growing!',
|
||||
icon: '🌸',
|
||||
rarity: 'rare',
|
||||
maxProgress: 100000,
|
||||
},
|
||||
TokenMaster: {
|
||||
id: 'TokenMaster',
|
||||
name: 'Token Master',
|
||||
description: 'One million tokens! You\'re unstoppable!',
|
||||
icon: '👑',
|
||||
rarity: 'legendary',
|
||||
maxProgress: 1000000,
|
||||
},
|
||||
|
||||
// Code generation
|
||||
HelloWorld: {
|
||||
id: 'HelloWorld',
|
||||
name: 'Hello, World!',
|
||||
description: 'Generated your first code block',
|
||||
icon: '👋',
|
||||
rarity: 'common',
|
||||
maxProgress: 1,
|
||||
},
|
||||
CodeWizard: {
|
||||
id: 'CodeWizard',
|
||||
name: 'Code Wizard',
|
||||
description: '100 code blocks generated',
|
||||
icon: '🧙♀️',
|
||||
rarity: 'rare',
|
||||
maxProgress: 100,
|
||||
},
|
||||
ThousandBlocks: {
|
||||
id: 'ThousandBlocks',
|
||||
name: 'Thousand Blocks',
|
||||
description: '1,000 code blocks! You\'re a code machine!',
|
||||
icon: '🏗️',
|
||||
rarity: 'epic',
|
||||
maxProgress: 1000,
|
||||
},
|
||||
|
||||
// File operations
|
||||
FileManipulator: {
|
||||
id: 'FileManipulator',
|
||||
name: 'File Manipulator',
|
||||
description: 'Edited 10 files',
|
||||
icon: '📝',
|
||||
rarity: 'common',
|
||||
maxProgress: 10,
|
||||
},
|
||||
FileArchitect: {
|
||||
id: 'FileArchitect',
|
||||
name: 'File Architect',
|
||||
description: 'Created or edited 100 files',
|
||||
icon: '🏛️',
|
||||
rarity: 'rare',
|
||||
maxProgress: 100,
|
||||
},
|
||||
|
||||
// Conversation milestones
|
||||
ConversationStarter: {
|
||||
id: 'ConversationStarter',
|
||||
name: 'Conversation Starter',
|
||||
description: 'Exchanged 10 messages',
|
||||
icon: '💬',
|
||||
rarity: 'common',
|
||||
maxProgress: 10,
|
||||
},
|
||||
ChattyKathy: {
|
||||
id: 'ChattyKathy',
|
||||
name: 'Chatty Kathy',
|
||||
description: '100 messages exchanged',
|
||||
icon: '🗣️',
|
||||
rarity: 'common',
|
||||
maxProgress: 100,
|
||||
},
|
||||
Conversationalist: {
|
||||
id: 'Conversationalist',
|
||||
name: 'Master Conversationalist',
|
||||
description: '1,000 messages! We\'re really connecting!',
|
||||
icon: '💖',
|
||||
rarity: 'rare',
|
||||
maxProgress: 1000,
|
||||
},
|
||||
|
||||
// Tool usage
|
||||
Toolsmith: {
|
||||
id: 'Toolsmith',
|
||||
name: 'Toolsmith',
|
||||
description: 'Used 5 different tools',
|
||||
icon: '🔨',
|
||||
rarity: 'common',
|
||||
maxProgress: 5,
|
||||
},
|
||||
ToolMaster: {
|
||||
id: 'ToolMaster',
|
||||
name: 'Tool Master',
|
||||
description: 'Used 10 different tools efficiently',
|
||||
icon: '🛠️',
|
||||
rarity: 'rare',
|
||||
maxProgress: 10,
|
||||
},
|
||||
|
||||
// Time-based achievements
|
||||
EarlyBird: {
|
||||
id: 'EarlyBird',
|
||||
name: 'Early Bird',
|
||||
description: 'Started a session between 5 AM and 7 AM',
|
||||
icon: '🌅',
|
||||
rarity: 'common',
|
||||
},
|
||||
NightOwl: {
|
||||
id: 'NightOwl',
|
||||
name: 'Night Owl',
|
||||
description: 'Coding after midnight',
|
||||
icon: '🦉',
|
||||
rarity: 'common',
|
||||
},
|
||||
AllNighter: {
|
||||
id: 'AllNighter',
|
||||
name: 'All Nighter',
|
||||
description: 'Worked through the night (2 AM - 5 AM)',
|
||||
icon: '🌙',
|
||||
rarity: 'rare',
|
||||
},
|
||||
WeekendWarrior: {
|
||||
id: 'WeekendWarrior',
|
||||
name: 'Weekend Warrior',
|
||||
description: 'Coding on a weekend',
|
||||
icon: '⚔️',
|
||||
rarity: 'common',
|
||||
},
|
||||
DedicatedDeveloper: {
|
||||
id: 'DedicatedDeveloper',
|
||||
name: 'Dedicated Developer',
|
||||
description: 'Coded for 30 days in a row',
|
||||
icon: '🏆',
|
||||
rarity: 'legendary',
|
||||
},
|
||||
|
||||
// Search and exploration
|
||||
Explorer: {
|
||||
id: 'Explorer',
|
||||
name: 'Explorer',
|
||||
description: 'Used search tools 50 times',
|
||||
icon: '🔍',
|
||||
rarity: 'common',
|
||||
maxProgress: 50,
|
||||
},
|
||||
MasterSearcher: {
|
||||
id: 'MasterSearcher',
|
||||
name: 'Master Searcher',
|
||||
description: 'Searched 500 times across files',
|
||||
icon: '🕵️♀️',
|
||||
rarity: 'rare',
|
||||
maxProgress: 500,
|
||||
},
|
||||
|
||||
// Session achievements
|
||||
QuickSession: {
|
||||
id: 'QuickSession',
|
||||
name: 'Quick Session',
|
||||
description: 'Completed a productive session in under 5 minutes',
|
||||
icon: '⚡',
|
||||
rarity: 'common',
|
||||
},
|
||||
FocusedWork: {
|
||||
id: 'FocusedWork',
|
||||
name: 'Focused Work',
|
||||
description: 'Worked for 30 minutes straight',
|
||||
icon: '🎯',
|
||||
rarity: 'common',
|
||||
},
|
||||
DeepDive: {
|
||||
id: 'DeepDive',
|
||||
name: 'Deep Dive',
|
||||
description: 'Worked for 2 hours continuously',
|
||||
icon: '🏊♀️',
|
||||
rarity: 'rare',
|
||||
},
|
||||
MarathonSession: {
|
||||
id: 'MarathonSession',
|
||||
name: 'Marathon Session',
|
||||
description: '5+ hour coding session!',
|
||||
icon: '🏃♀️',
|
||||
rarity: 'epic',
|
||||
},
|
||||
|
||||
// Special achievements
|
||||
FirstMessage: {
|
||||
id: 'FirstMessage',
|
||||
name: 'First Message',
|
||||
description: 'Sent your first message to Hikari',
|
||||
icon: '✨',
|
||||
rarity: 'common',
|
||||
maxProgress: 1,
|
||||
},
|
||||
FirstTool: {
|
||||
id: 'FirstTool',
|
||||
name: 'First Tool',
|
||||
description: 'Used your first tool',
|
||||
icon: '🔧',
|
||||
rarity: 'common',
|
||||
maxProgress: 1,
|
||||
},
|
||||
FirstCodeBlock: {
|
||||
id: 'FirstCodeBlock',
|
||||
name: 'First Code',
|
||||
description: 'Generated your first code block',
|
||||
icon: '📦',
|
||||
rarity: 'common',
|
||||
maxProgress: 1,
|
||||
},
|
||||
FirstFileEdit: {
|
||||
id: 'FirstFileEdit',
|
||||
name: 'First Edit',
|
||||
description: 'Made your first file edit',
|
||||
icon: '✏️',
|
||||
rarity: 'common',
|
||||
maxProgress: 1,
|
||||
},
|
||||
Polyglot: {
|
||||
id: 'Polyglot',
|
||||
name: 'Polyglot',
|
||||
description: 'Generated code in 5+ languages in one session',
|
||||
icon: '🌍',
|
||||
rarity: 'rare',
|
||||
maxProgress: 5,
|
||||
},
|
||||
SpeedCoder: {
|
||||
id: 'SpeedCoder',
|
||||
name: 'Speed Coder',
|
||||
description: 'Generated 10 code blocks in 10 minutes',
|
||||
icon: '🚀',
|
||||
rarity: 'rare',
|
||||
},
|
||||
ClaudeConnoisseur: {
|
||||
id: 'ClaudeConnoisseur',
|
||||
name: 'Claude Connoisseur',
|
||||
description: 'Used all available Claude models',
|
||||
icon: '🎨',
|
||||
rarity: 'epic',
|
||||
maxProgress: 5, // Adjust based on available models
|
||||
},
|
||||
MarathonCoder: {
|
||||
id: 'MarathonCoder',
|
||||
name: 'Marathon Coder',
|
||||
description: '10,000 tokens in a single session',
|
||||
icon: '🏃♂️',
|
||||
rarity: 'epic',
|
||||
maxProgress: 10000,
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize all achievements as locked
|
||||
const initialAchievements: Record<AchievementId, Achievement> = {} as Record<AchievementId, Achievement>;
|
||||
for (const [id, def] of Object.entries(achievementDefinitions)) {
|
||||
initialAchievements[id as AchievementId] = {
|
||||
...def,
|
||||
unlocked: false,
|
||||
progress: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Create the main store
|
||||
function createAchievementsStore() {
|
||||
const { subscribe, update } = writable<AchievementState>({
|
||||
achievements: initialAchievements,
|
||||
totalUnlocked: 0,
|
||||
lastUnlocked: null,
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
unlockAchievement: (event: AchievementUnlockedEvent) => {
|
||||
update(state => {
|
||||
const achievement = state.achievements[event.achievement.id];
|
||||
if (achievement && !achievement.unlocked) {
|
||||
achievement.unlocked = true;
|
||||
achievement.unlockedAt = event.achievement.unlocked_at ? new Date(event.achievement.unlocked_at) : new Date();
|
||||
state.totalUnlocked++;
|
||||
state.lastUnlocked = achievement;
|
||||
}
|
||||
return state;
|
||||
});
|
||||
},
|
||||
updateProgress: (id: AchievementId, progress: number) => {
|
||||
update(state => {
|
||||
const achievement = state.achievements[id];
|
||||
if (achievement) {
|
||||
achievement.progress = progress;
|
||||
}
|
||||
return state;
|
||||
});
|
||||
},
|
||||
reset: () => {
|
||||
update(() => ({
|
||||
achievements: initialAchievements,
|
||||
totalUnlocked: 0,
|
||||
lastUnlocked: null,
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const achievementsStore = createAchievementsStore();
|
||||
|
||||
// Derived stores for different views
|
||||
export const unlockedAchievements = derived(
|
||||
achievementsStore,
|
||||
$store => Object.values($store.achievements).filter(a => a.unlocked)
|
||||
);
|
||||
|
||||
export const lockedAchievements = derived(
|
||||
achievementsStore,
|
||||
$store => Object.values($store.achievements).filter(a => !a.unlocked)
|
||||
);
|
||||
|
||||
export const achievementsByRarity = derived(
|
||||
achievementsStore,
|
||||
$store => {
|
||||
const byRarity: Record<string, Achievement[]> = {
|
||||
common: [],
|
||||
rare: [],
|
||||
epic: [],
|
||||
legendary: [],
|
||||
};
|
||||
|
||||
for (const achievement of Object.values($store.achievements)) {
|
||||
byRarity[achievement.rarity].push(achievement);
|
||||
}
|
||||
|
||||
return byRarity;
|
||||
}
|
||||
);
|
||||
|
||||
export const achievementProgress = derived(
|
||||
achievementsStore,
|
||||
$store => ({
|
||||
unlocked: $store.totalUnlocked,
|
||||
total: Object.keys($store.achievements).length,
|
||||
percentage: Math.round(($store.totalUnlocked / Object.keys($store.achievements).length) * 100),
|
||||
})
|
||||
);
|
||||
|
||||
// Initialize achievement listener
|
||||
export async function initAchievementsListener() {
|
||||
// Listen for achievement unlocked events
|
||||
await listen<AchievementUnlockedEvent>('achievement:unlocked', (event) => {
|
||||
achievementsStore.unlockAchievement(event.payload);
|
||||
});
|
||||
|
||||
// Load saved achievements from persistent storage
|
||||
try {
|
||||
const savedAchievements = await invoke<AchievementUnlockedEvent[]>('load_saved_achievements');
|
||||
|
||||
// Update the store with saved achievements
|
||||
for (const event of savedAchievements) {
|
||||
achievementsStore.unlockAchievement(event);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load saved achievements:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Export achievement categories for the display panel
|
||||
export const achievementCategories = [
|
||||
{
|
||||
name: 'Token Milestones',
|
||||
description: 'Track your token generation progress',
|
||||
ids: ['FirstSteps', 'GrowingStrong', 'BlossomingCoder', 'TokenMaster'] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: 'Code Generation',
|
||||
description: 'Achievements for generating code',
|
||||
ids: ['HelloWorld', 'CodeWizard', 'ThousandBlocks'] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: 'File Operations',
|
||||
description: 'Working with files and projects',
|
||||
ids: ['FileManipulator', 'FileArchitect'] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: 'Conversations',
|
||||
description: 'Building our relationship through chat',
|
||||
ids: ['ConversationStarter', 'ChattyKathy', 'Conversationalist'] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: 'Tools & Skills',
|
||||
description: 'Mastering different tools',
|
||||
ids: ['Toolsmith', 'ToolMaster'] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: 'Time-Based',
|
||||
description: 'When you code matters too!',
|
||||
ids: ['EarlyBird', 'NightOwl', 'AllNighter', 'WeekendWarrior', 'DedicatedDeveloper'] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: 'Search & Explore',
|
||||
description: 'Finding what you need',
|
||||
ids: ['Explorer', 'MasterSearcher'] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: 'Session Records',
|
||||
description: 'Your coding session achievements',
|
||||
ids: ['QuickSession', 'FocusedWork', 'DeepDive', 'MarathonSession', 'MarathonCoder'] as AchievementId[],
|
||||
},
|
||||
{
|
||||
name: 'Special',
|
||||
description: 'Unique accomplishments',
|
||||
ids: ['FirstMessage', 'FirstTool', 'FirstCodeBlock', 'FirstFileEdit', 'Polyglot', 'SpeedCoder', 'ClaudeConnoisseur'] as AchievementId[],
|
||||
},
|
||||
];
|
||||
@@ -4,6 +4,7 @@ import { claudeStore } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
import { initStatsListener, resetSessionStats } from "$lib/stores/stats";
|
||||
import { initAchievementsListener } from "$lib/stores/achievements";
|
||||
import type { ConnectionStatus, PermissionPromptEvent } from "$lib/types/messages";
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
import {
|
||||
@@ -80,6 +81,9 @@ export async function initializeTauriListeners() {
|
||||
// Initialize stats listener
|
||||
await initStatsListener();
|
||||
|
||||
// Initialize achievements listener
|
||||
await initAchievementsListener();
|
||||
|
||||
const connectionUnlisten = await listen<string>("claude:connection", async (event) => {
|
||||
const status = event.payload as ConnectionStatus;
|
||||
claudeStore.setConnectionStatus(status);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
export interface AchievementUnlockedEvent {
|
||||
achievement: {
|
||||
id: AchievementId;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
unlocked_at: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type AchievementId =
|
||||
// Token Milestones
|
||||
| 'FirstSteps' // 1,000 tokens
|
||||
| 'GrowingStrong' // 10,000 tokens
|
||||
| 'BlossomingCoder' // 100,000 tokens
|
||||
| 'TokenMaster' // 1,000,000 tokens
|
||||
// Code Generation
|
||||
| 'HelloWorld' // First code block
|
||||
| 'CodeWizard' // 100 code blocks
|
||||
| 'ThousandBlocks' // 1,000 code blocks
|
||||
// File Operations
|
||||
| 'FileManipulator' // 10 files edited
|
||||
| 'FileArchitect' // 100 files edited
|
||||
// Conversation milestones
|
||||
| 'ConversationStarter' // 10 messages
|
||||
| 'ChattyKathy' // 100 messages
|
||||
| 'Conversationalist' // 1,000 messages
|
||||
// Tool usage
|
||||
| 'Toolsmith' // 5 different tools
|
||||
| 'ToolMaster' // 10 different tools
|
||||
// Time-based achievements
|
||||
| 'EarlyBird' // Started session 5-7 AM
|
||||
| 'NightOwl' // Coding after midnight
|
||||
| 'AllNighter' // Worked 2-5 AM
|
||||
| 'WeekendWarrior' // Coding on weekend
|
||||
| 'DedicatedDeveloper' // 30 days in a row
|
||||
// Search and exploration
|
||||
| 'Explorer' // 50 searches
|
||||
| 'MasterSearcher' // 500 searches
|
||||
// Session achievements
|
||||
| 'QuickSession' // Productive session < 5 min
|
||||
| 'FocusedWork' // 30 min session
|
||||
| 'DeepDive' // 2 hour session
|
||||
| 'MarathonSession' // 5+ hour session
|
||||
// Special achievements
|
||||
| 'FirstMessage' // First message sent
|
||||
| 'FirstTool' // First tool used
|
||||
| 'FirstCodeBlock' // First code generated
|
||||
| 'FirstFileEdit' // First file edit
|
||||
| 'Polyglot' // 5+ languages in one session
|
||||
| 'SpeedCoder' // 10 code blocks in 10 minutes
|
||||
| 'ClaudeConnoisseur' // Used all Claude models
|
||||
| 'MarathonCoder';
|
||||
|
||||
export interface Achievement {
|
||||
id: AchievementId;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
rarity: 'common' | 'rare' | 'epic' | 'legendary';
|
||||
unlocked: boolean;
|
||||
unlockedAt?: Date;
|
||||
progress?: number;
|
||||
maxProgress?: number;
|
||||
}
|
||||
|
||||
export interface AchievementCategory {
|
||||
name: string;
|
||||
description: string;
|
||||
achievements: Achievement[];
|
||||
}
|
||||
@@ -9,8 +9,11 @@
|
||||
import AnimeGirl from "$lib/components/AnimeGirl.svelte";
|
||||
import PermissionModal from "$lib/components/PermissionModal.svelte";
|
||||
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
|
||||
import AchievementNotification from "$lib/components/AchievementNotification.svelte";
|
||||
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
|
||||
|
||||
let initialized = false;
|
||||
let achievementPanelOpen = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (!initialized) {
|
||||
@@ -33,7 +36,7 @@
|
||||
</script>
|
||||
|
||||
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
|
||||
<StatusBar />
|
||||
<StatusBar onToggleAchievements={() => achievementPanelOpen = !achievementPanelOpen} />
|
||||
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
<!-- Left panel: Character display -->
|
||||
@@ -52,6 +55,8 @@
|
||||
|
||||
<PermissionModal />
|
||||
<ConfigSidebar />
|
||||
<AchievementNotification />
|
||||
<AchievementsPanel bind:isOpen={achievementPanelOpen} onClose={() => achievementPanelOpen = false} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user