feat(#22): gamificação RPG — XP, quests, conquistas, cosméticos e personagem SVG pixel art

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-26 21:46:41 -03:00
co-authored by Claude Sonnet 4.6
parent 9a964423fd
commit c1964ea036
21 changed files with 1346 additions and 10 deletions
+1
View File
@@ -10,6 +10,7 @@
<RouterLink to="/transacoes">Transações</RouterLink>
<RouterLink to="/importar">Importar</RouterLink>
<RouterLink to="/contas">Contas</RouterLink>
<RouterLink to="/personagem">Personagem</RouterLink>
<RouterLink to="/configuracoes">Configurações</RouterLink>
</nav>
</header>
@@ -0,0 +1,96 @@
<template>
<div class="character-wrapper" :class="{ celebrate }">
<svg class="character-svg" viewBox="0 0 32 48" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges">
<!-- hair -->
<rect x="10" y="2" width="12" height="4" :fill="hairColor" />
<rect x="8" y="4" width="16" height="2" :fill="hairColor" />
<!-- face -->
<rect x="9" y="6" width="14" height="12" fill="#f5c5a3" />
<!-- eyes -->
<rect x="11" y="10" width="3" height="3" fill="#1e293b" />
<rect x="18" y="10" width="3" height="3" fill="#1e293b" />
<!-- mouth -->
<rect x="13" y="15" width="6" height="2" fill="#e07070" />
<!-- neck -->
<rect x="13" y="18" width="6" height="3" fill="#f5c5a3" />
<!-- body (shirt) -->
<rect x="8" y="21" width="16" height="14" :fill="shirtColor" />
<!-- collar -->
<rect x="12" y="21" width="8" height="3" fill="#fff" opacity="0.4" />
<!-- arms -->
<rect x="2" y="21" width="6" height="12" :fill="shirtColor" />
<rect x="24" y="21" width="6" height="12" :fill="shirtColor" />
<!-- hands -->
<rect x="2" y="33" width="6" height="4" fill="#f5c5a3" />
<rect x="24" y="33" width="6" height="4" fill="#f5c5a3" />
<!-- pants -->
<rect x="8" y="35" width="7" height="10" :fill="pantsColor" />
<rect x="17" y="35" width="7" height="10" :fill="pantsColor" />
<!-- shoes -->
<rect x="7" y="45" width="8" height="3" :fill="shoeColor" />
<rect x="17" y="45" width="8" height="3" :fill="shoeColor" />
<!-- accessory slot -->
<slot name="accessory" />
</svg>
<div v-if="celebrate" class="sparkles"></div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Cosmetic } from '@/stores/game'
const props = defineProps<{
level: number
cosmetics?: Cosmetic[]
celebrate?: boolean
}>()
function equippedOfType(type: string) {
return props.cosmetics?.find(c => c.type === type && c.equipped)
}
function cssValue(type: string, fallback: string) {
const c = equippedOfType(type)
if (!c) return fallback
try {
const data = JSON.parse(c.css_data)
return data.color ?? fallback
} catch {
return fallback
}
}
const hairColor = computed(() => cssValue('hair', '#5a3a1a'))
const shirtColor = computed(() => cssValue('shirt', '#3b82f6'))
const pantsColor = computed(() => cssValue('pants', '#1e3a5f'))
const shoeColor = computed(() => cssValue('shoes', '#1e293b'))
</script>
<style scoped>
.character-wrapper {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: center;
image-rendering: pixelated;
}
.character-svg {
width: 96px;
height: 144px;
image-rendering: pixelated;
transition: transform 0.15s;
}
.character-wrapper.celebrate .character-svg {
animation: bounce 0.5s ease infinite alternate;
}
@keyframes bounce {
from { transform: translateY(0); }
to { transform: translateY(-8px); }
}
.sparkles {
font-size: 1.5rem;
animation: fadein 0.3s ease;
}
@keyframes fadein { from { opacity: 0; } to { opacity: 1; } }
</style>
+42
View File
@@ -0,0 +1,42 @@
<template>
<div class="xp-bar-wrap">
<div class="xp-labels">
<span class="level-badge">Nv {{ level }}</span>
<span class="xp-text">{{ xp }} / {{ xpToNext }} XP</span>
</div>
<div class="xp-track">
<div class="xp-fill" :style="{ width: pct + '%' }" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
level: number
xp: number
xpToNext: number
}>()
const pct = computed(() => Math.min(100, Math.round((props.xp / props.xpToNext) * 100)))
</script>
<style scoped>
.xp-bar-wrap { display: flex; flex-direction: column; gap: 4px; }
.xp-labels { display: flex; justify-content: space-between; align-items: center; }
.level-badge {
background: #7c3aed; color: #fff; font-size: 0.75rem;
font-weight: 700; padding: 2px 8px; border-radius: 4px;
}
.xp-text { font-size: 0.75rem; color: var(--color-text-secondary, #888); }
.xp-track {
height: 10px; background: #e5e7eb; border-radius: 5px; overflow: hidden;
}
.xp-fill {
height: 100%;
background: linear-gradient(90deg, #7c3aed, #a855f7);
border-radius: 5px;
transition: width 0.4s ease;
}
</style>
+5
View File
@@ -29,6 +29,11 @@ const router = createRouter({
name: 'accounts',
component: () => import('../views/AccountsView.vue'),
},
{
path: '/personagem',
name: 'character',
component: () => import('../views/CharacterView.vue'),
},
{
path: '/configuracoes',
name: 'settings',
+80
View File
@@ -0,0 +1,80 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface PlayerProfile {
id: number
level: number
xp: number
xp_to_next: number
}
export interface PlayerQuest {
id: number
quest_id: number
title: string
quest_type: string
target_count: number | null
target_pct: number | null
xp_reward: number
period: string
current_count: number
current_pct: number
completed: boolean
claimed: boolean
}
export interface Achievement {
id: number
title: string
description: string
icon: string
xp_reward: number
unlock_condition: string
earned: boolean
earned_at: string
}
export interface Cosmetic {
id: number
name: string
type: string
unlock_level: number
css_data: string
unlocked: boolean
equipped: boolean
}
export interface GameSummary {
profile: PlayerProfile
active_quests: PlayerQuest[]
recent_achievements: Achievement[]
equipped_cosmetics: Cosmetic[]
}
export const useGameStore = defineStore('game', () => {
const summary = ref<GameSummary | null>(null)
const loading = ref(false)
async function fetchSummary() {
loading.value = true
try {
const res = await fetch('/api/game/summary')
if (!res.ok) throw new Error('failed')
summary.value = await res.json()
} finally {
loading.value = false
}
}
async function claimQuest(playerQuestId: number) {
await fetch(`/api/game/quests/${playerQuestId}/claim`, { method: 'POST' })
await fetchSummary()
}
async function equipCosmetic(cosmeticId: number) {
await fetch(`/api/game/cosmetics/${cosmeticId}/equip`, { method: 'POST' })
await fetchSummary()
}
return { summary, loading, fetchSummary, claimQuest, equipCosmetic }
})
+243
View File
@@ -0,0 +1,243 @@
<template>
<div class="character-page">
<h1 class="page-title">Personagem</h1>
<div v-if="store.loading" class="loading">Carregando...</div>
<template v-else-if="store.summary">
<!-- Profile panel -->
<div class="profile-panel card">
<div class="character-area">
<CharacterWidget
:level="store.summary.profile.level"
:cosmetics="allCosmetics"
:celebrate="justLeveledUp"
/>
</div>
<div class="profile-info">
<h2>Nível {{ store.summary.profile.level }}</h2>
<XPBar
:level="store.summary.profile.level"
:xp="store.summary.profile.xp"
:xp-to-next="store.summary.profile.xp_to_next"
/>
<p class="xp-hint">Continue registrando transações para ganhar XP!</p>
</div>
</div>
<!-- Quests -->
<section class="section">
<h2 class="section-title">Quests Ativas</h2>
<div v-if="store.summary.active_quests.length === 0" class="empty">Nenhuma quest ativa.</div>
<div class="quest-list">
<div
v-for="q in store.summary.active_quests"
:key="q.id"
class="quest-card card"
:class="{ completed: q.completed, claimed: q.claimed }"
>
<div class="quest-header">
<span class="quest-type-badge" :class="q.quest_type">{{ typeLabel(q.quest_type) }}</span>
<span class="quest-xp">+{{ q.xp_reward }} XP</span>
</div>
<p class="quest-title">{{ q.title }}</p>
<div class="quest-progress">
<template v-if="q.target_count !== null">
<div class="progress-track">
<div class="progress-fill" :style="{ width: countPct(q) + '%' }" />
</div>
<span class="progress-label">{{ q.current_count }} / {{ q.target_count }}</span>
</template>
<template v-else-if="q.target_pct !== null">
<div class="progress-track">
<div class="progress-fill" :style="{ width: Math.min(100, q.current_pct) + '%' }" />
</div>
<span class="progress-label">{{ q.current_pct.toFixed(1) }}% / {{ q.target_pct }}%</span>
</template>
</div>
<button
v-if="q.completed && !q.claimed"
class="btn-claim"
@click="claim(q.id)"
>Resgatar</button>
<span v-else-if="q.claimed" class="claimed-badge"> Resgatado</span>
</div>
</div>
</section>
<!-- Achievements -->
<section class="section">
<h2 class="section-title">Conquistas</h2>
<div class="achievement-list">
<div
v-for="a in achievements"
:key="a.id"
class="achievement-card card"
:class="{ earned: a.earned }"
>
<span class="achievement-icon">{{ a.icon || '🏆' }}</span>
<div class="achievement-info">
<p class="achievement-title">{{ a.title }}</p>
<p class="achievement-desc">{{ a.description }}</p>
<span v-if="a.earned" class="earned-tag">+{{ a.xp_reward }} XP conquistado</span>
<span v-else class="locked-tag">Bloqueado</span>
</div>
</div>
</div>
</section>
<!-- Cosmetics -->
<section class="section">
<h2 class="section-title">Cosméticos</h2>
<div class="cosmetic-list">
<div
v-for="c in allCosmetics"
:key="c.id"
class="cosmetic-card card"
:class="{ equipped: c.equipped, locked: !c.unlocked }"
@click="c.unlocked && !c.equipped && equip(c.id)"
>
<div class="cosmetic-swatch" :style="swatchStyle(c)" />
<p class="cosmetic-name">{{ c.name }}</p>
<p class="cosmetic-unlock">Nv {{ c.unlock_level }}</p>
<span v-if="c.equipped" class="equipped-tag">Equipado</span>
<span v-else-if="!c.unlocked" class="locked-tag">🔒</span>
</div>
</div>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useGameStore, type Achievement, type Cosmetic } from '@/stores/game'
import CharacterWidget from '@/components/CharacterWidget.vue'
import XPBar from '@/components/XPBar.vue'
const store = useGameStore()
const justLeveledUp = ref(false)
const achievements = ref<Achievement[]>([])
const allCosmetics = ref<Cosmetic[]>([])
onMounted(async () => {
await store.fetchSummary()
const [achRes, cosRes] = await Promise.all([
fetch('/api/game/achievements'),
fetch('/api/game/cosmetics'),
])
if (achRes.ok) achievements.value = await achRes.json()
if (cosRes.ok) allCosmetics.value = await cosRes.json()
})
function typeLabel(t: string) {
return { daily: 'Diária', weekly: 'Semanal', monthly: 'Mensal' }[t] ?? t
}
function countPct(q: { current_count: number; target_count: number | null }) {
if (!q.target_count) return 0
return Math.min(100, Math.round((q.current_count / q.target_count) * 100))
}
async function claim(id: number) {
await store.claimQuest(id)
justLeveledUp.value = true
setTimeout(() => (justLeveledUp.value = false), 2000)
}
async function equip(id: number) {
await store.equipCosmetic(id)
const res = await fetch('/api/game/cosmetics')
if (res.ok) allCosmetics.value = await res.json()
}
function swatchStyle(c: Cosmetic) {
try {
const data = JSON.parse(c.css_data)
return { background: data.color ?? '#ccc' }
} catch {
return { background: '#ccc' }
}
}
</script>
<style scoped>
.character-page { max-width: 860px; margin: 0 auto; padding: 1.5rem; }
.page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 1.5rem; }
.loading { text-align: center; padding: 2rem; color: #888; }
.card {
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 12px;
padding: 1rem;
}
.profile-panel {
display: flex;
gap: 1.5rem;
align-items: center;
margin-bottom: 2rem;
}
.character-area { flex-shrink: 0; }
.profile-info { flex: 1; }
.profile-info h2 { font-size: 1.25rem; font-weight: 700; margin-bottom: 0.5rem; }
.xp-hint { font-size: 0.75rem; color: #888; margin-top: 0.5rem; }
.section { margin-bottom: 2rem; }
.section-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem; }
.empty { color: #888; font-size: 0.9rem; }
.quest-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.75rem; }
.quest-card { display: flex; flex-direction: column; gap: 0.5rem; }
.quest-card.claimed { opacity: 0.6; }
.quest-header { display: flex; justify-content: space-between; align-items: center; }
.quest-type-badge {
font-size: 0.65rem; font-weight: 700; text-transform: uppercase;
padding: 2px 6px; border-radius: 4px;
}
.quest-type-badge.daily { background: #fef3c7; color: #92400e; }
.quest-type-badge.weekly { background: #dbeafe; color: #1e40af; }
.quest-type-badge.monthly { background: #ede9fe; color: #5b21b6; }
.quest-xp { font-size: 0.75rem; font-weight: 600; color: #7c3aed; }
.quest-title { font-size: 0.9rem; font-weight: 500; }
.quest-progress { display: flex; flex-direction: column; gap: 4px; }
.progress-track { height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; background: #7c3aed; border-radius: 4px; transition: width 0.3s; }
.progress-label { font-size: 0.7rem; color: #888; }
.btn-claim {
background: #7c3aed; color: #fff; border: none; border-radius: 6px;
padding: 6px 12px; font-size: 0.8rem; cursor: pointer; font-weight: 600;
}
.btn-claim:hover { background: #6d28d9; }
.claimed-badge { font-size: 0.75rem; color: #16a34a; font-weight: 600; }
.achievement-list {
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.75rem;
}
.achievement-card {
display: flex; gap: 0.75rem; align-items: flex-start;
opacity: 0.5; filter: grayscale(1);
}
.achievement-card.earned { opacity: 1; filter: none; }
.achievement-icon { font-size: 1.75rem; flex-shrink: 0; }
.achievement-title { font-weight: 600; font-size: 0.9rem; }
.achievement-desc { font-size: 0.75rem; color: #888; margin-top: 2px; }
.earned-tag { font-size: 0.7rem; color: #7c3aed; font-weight: 600; }
.locked-tag { font-size: 0.7rem; color: #aaa; }
.cosmetic-list {
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.75rem;
}
.cosmetic-card {
display: flex; flex-direction: column; align-items: center; gap: 0.5rem;
cursor: pointer; transition: border-color 0.2s;
}
.cosmetic-card:hover:not(.locked):not(.equipped) { border-color: #7c3aed; }
.cosmetic-card.equipped { border-color: #7c3aed; background: #ede9fe; }
.cosmetic-card.locked { opacity: 0.5; cursor: default; }
.cosmetic-swatch { width: 48px; height: 48px; border-radius: 8px; border: 2px solid #e5e7eb; }
.cosmetic-name { font-size: 0.8rem; font-weight: 500; text-align: center; }
.cosmetic-unlock { font-size: 0.7rem; color: #888; }
.equipped-tag { font-size: 0.65rem; color: #7c3aed; font-weight: 700; }
</style>
+16 -1
View File
@@ -1,11 +1,17 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useDashboardStore } from '@/stores/dashboard'
import { useGameStore } from '@/stores/game'
import XPBar from '@/components/XPBar.vue'
const store = useDashboardStore()
const gameStore = useGameStore()
const currentMonth = ref(new Date().toISOString().slice(0, 7))
onMounted(() => store.fetch(currentMonth.value))
onMounted(() => {
store.fetch(currentMonth.value)
gameStore.fetchSummary()
})
function changeMonth(delta: number) {
const [y, m] = currentMonth.value.split('-').map(Number)
@@ -83,6 +89,14 @@ const maxEvolution = computed(() => {
</div>
<div class="savings-target">todas as contas</div>
</div>
<RouterLink to="/personagem" class="card character-mini-card" v-if="gameStore.summary">
<div class="card-label">Personagem</div>
<XPBar
:level="gameStore.summary.profile.level"
:xp="gameStore.summary.profile.xp"
:xp-to-next="gameStore.summary.profile.xp_to_next"
/>
</RouterLink>
</div>
<!-- Bottom section: two columns on wide, stacked on mobile -->
@@ -194,6 +208,7 @@ h2 { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.75rem; color: #374151;
.savings-ok .savings-pct { color: #059669; }
.savings-low .savings-pct { color: #dc2626; }
.savings-target { font-size: 0.7rem; color: #9ca3af; margin-top: 0.2rem; }
.character-mini-card { display: flex; flex-direction: column; gap: 0.5rem; text-decoration: none; }
/* Bottom grid */
.bottom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 0.75rem; }