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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user