# Story 3.3: Textes narrateur contextuels et arc de révélation Status: review ## Story As a visiteur, I want que le narrateur réagisse à mes actions et évolue visuellement, so that l'expérience est personnalisée et le narrateur devient familier. ## Acceptance Criteria 1. **Given** le visiteur navigue sur le site **When** il effectue des actions clés **Then** le narrateur affiche un message d'accueil à l'arrivée (adapté au héros choisi) 2. **And** des messages de transition s'affichent entre les zones 3. **And** des encouragements apparaissent à 25%, 50%, 75% de progression 4. **And** des indices s'affichent si le visiteur semble inactif (> 30s sans action) 5. **And** un message spécial "Bienvenue à nouveau" s'affiche si progression existante détectée 6. **And** le message de déblocage du contact s'affiche après 2 zones visitées 7. **Given** le visiteur progresse dans l'exploration **When** le `completionPercent` atteint certains seuils **Then** le `narratorStage` du store est mis à jour (1→5) 8. **And** l'apparence du Bug évolue : silhouette sombre (1) → forme vague (2) → pattes visibles (3) → araignée reconnaissable (4) → mascotte complète révélée (5) 9. **And** le ton du narrateur évolue de mystérieux à complice ## Tasks / Subtasks - [x] **Task 1: Créer le composable useNarrator** (AC: #1, #2, #3, #4, #5, #6) - [x] Créer `frontend/app/composables/useNarrator.ts` - [x] Centraliser la logique d'affichage du narrateur - [x] Exposer les méthodes : showIntro, showTransition, showEncouragement, showHint, showWelcomeBack, showContactUnlocked - [x] Gérer la queue de messages (ne pas interrompre un message en cours) - [x] Intégrer le composable useFetchNarratorText - [x] **Task 2: Implémenter les déclencheurs de transition** (AC: #2) - [x] Déclencher sur navigation vers /projets (transition_projects) - [x] Déclencher sur navigation vers /competences (transition_skills) - [x] Déclencher sur navigation vers /temoignages (transition_testimonials) - [x] Déclencher sur navigation vers /parcours (transition_journey) - [x] Utiliser un plugin Nuxt ou watcher sur la route - [x] **Task 3: Implémenter la détection d'inactivité** (AC: #4) - [x] Créer `frontend/app/composables/useIdleDetection.ts` - [x] Détecter l'absence d'interaction > 30 secondes - [x] Écouter mouse, keyboard, touch, scroll - [x] Déclencher `showHint()` quand idle détecté - [x] Ne pas répéter les hints trop souvent (cooldown de 2min) - [x] **Task 4: Implémenter les encouragements basés sur la progression** (AC: #3) - [x] Watcher sur `completionPercent` du store - [x] Déclencher à 25%, 50%, 75% - [x] Garder en mémoire les seuils déjà atteints (ne pas répéter) - [x] **Task 5: Implémenter l'arc de révélation du Bug** (AC: #7, #8, #9) - [x] Définir les seuils de progression pour chaque stage : - Stage 1 : 0-19% - Stage 2 : 20-39% - Stage 3 : 40-59% - Stage 4 : 60-79% - Stage 5 : 80-100% - [x] Mettre à jour `narratorStage` dans le store (getter calculé) - [x] L'image du Bug se met à jour automatiquement via NarratorBubble - [x] **Task 6: Implémenter le message "Bienvenue à nouveau"** (AC: #5) - [x] Détecter au chargement si `visitedSections` n'est pas vide (progression existante) - [x] Afficher le message `welcome_back` dans ce cas - [x] Sinon afficher le message `intro` normal - [x] **Task 7: Implémenter le message de déblocage contact** (AC: #6) - [x] Watcher sur `contactUnlocked` du store - [x] Quand passe à `true`, afficher `contact_unlocked` - [x] **Task 8: Intégrer dans le layout principal** - [x] Ajouter le NarratorBubble dans adventure.vue - [x] Initialiser useNarrator dans le layout - [x] Gérer l'état visible/hidden du narrateur - [x] **Task 9: Tests et validation** - [x] Tester le message d'accueil adapté au héros - [x] Tester les transitions entre pages - [x] Vérifier les encouragements à 25/50/75% - [x] Tester la détection d'inactivité - [x] Valider l'évolution du Bug (5 stages) - [x] Tester le "Bienvenue à nouveau" ## Dev Notes ### Composable useNarrator ```typescript // frontend/app/composables/useNarrator.ts interface NarratorMessage { context: string priority: number } export function useNarrator() { const { fetchText } = useFetchNarratorText() const progressionStore = useProgressionStore() const isVisible = ref(false) const currentMessage = ref('') const messageQueue = ref([]) const isProcessing = ref(false) // Seuils d'encouragement déjà affichés const shownEncouragements = ref>(new Set()) // Cooldown pour les hints const lastHintTime = ref(0) const HINT_COOLDOWN = 120000 // 2 minutes async function queueMessage(context: string, priority: number = 5) { messageQueue.value.push({ context, priority }) messageQueue.value.sort((a, b) => b.priority - a.priority) if (!isProcessing.value) { processQueue() } } async function processQueue() { if (messageQueue.value.length === 0) { isProcessing.value = false return } isProcessing.value = true const next = messageQueue.value.shift()! try { const response = await fetchText(next.context, progressionStore.heroType) currentMessage.value = response.data.text isVisible.value = true } catch (error) { console.error('Failed to fetch narrator text:', error) processQueue() // Passer au suivant en cas d'erreur } } function hide() { isVisible.value = false // Attendre la fin de l'animation avant de traiter le suivant setTimeout(() => { processQueue() }, 300) } // === Méthodes publiques === async function showIntro() { // Vérifier si le visiteur revient if (progressionStore.visitedSections.length > 0) { await queueMessage('welcome_back', 10) } else { await queueMessage('intro', 10) } } async function showTransition(zone: 'projects' | 'skills' | 'testimonials' | 'journey') { const contextMap = { projects: 'transition_projects', skills: 'transition_skills', testimonials: 'transition_testimonials', journey: 'transition_journey', } await queueMessage(contextMap[zone], 7) } async function showEncouragement(percent: number) { // Ne pas répéter les encouragements if (shownEncouragements.value.has(percent)) return let context: string | null = null if (percent >= 75 && !shownEncouragements.value.has(75)) { context = 'encouragement_75' shownEncouragements.value.add(75) } else if (percent >= 50 && !shownEncouragements.value.has(50)) { context = 'encouragement_50' shownEncouragements.value.add(50) } else if (percent >= 25 && !shownEncouragements.value.has(25)) { context = 'encouragement_25' shownEncouragements.value.add(25) } if (context) { await queueMessage(context, 5) } } async function showHint() { const now = Date.now() if (now - lastHintTime.value < HINT_COOLDOWN) return lastHintTime.value = now await queueMessage('hint', 3) } async function showContactUnlocked() { await queueMessage('contact_unlocked', 8) } async function showWelcomeBack() { await queueMessage('welcome_back', 10) } return { isVisible, currentMessage, hide, showIntro, showTransition, showEncouragement, showHint, showContactUnlocked, showWelcomeBack, } } ``` ### Composable useIdleDetection ```typescript // frontend/app/composables/useIdleDetection.ts export interface UseIdleDetectionOptions { timeout?: number // ms avant de considérer comme idle onIdle?: () => void } export function useIdleDetection(options: UseIdleDetectionOptions = {}) { const { timeout = 30000, onIdle } = options const isIdle = ref(false) let timeoutId: ReturnType | null = null function resetTimer() { isIdle.value = false if (timeoutId) { clearTimeout(timeoutId) } timeoutId = setTimeout(() => { isIdle.value = true onIdle?.() }, timeout) } const events = ['mousedown', 'mousemove', 'keydown', 'scroll', 'touchstart'] onMounted(() => { events.forEach(event => { window.addEventListener(event, resetTimer, { passive: true }) }) resetTimer() // Démarrer le timer }) onUnmounted(() => { events.forEach(event => { window.removeEventListener(event, resetTimer) }) if (timeoutId) { clearTimeout(timeoutId) } }) return { isIdle } } ``` ### Logique de l'arc de révélation (dans useProgressionStore) ```typescript // Ajouter dans frontend/app/stores/progression.ts // Seuils pour les stages du Bug const NARRATOR_STAGE_THRESHOLDS = [0, 20, 40, 60, 80] // 5 stages function calculateNarratorStage(percent: number): number { for (let i = NARRATOR_STAGE_THRESHOLDS.length - 1; i >= 0; i--) { if (percent >= NARRATOR_STAGE_THRESHOLDS[i]) { return i + 1 // Stages 1-5 } } return 1 } // Dans le store export const useProgressionStore = defineStore('progression', () => { // ... autres propriétés existantes ... const narratorStage = computed(() => { return calculateNarratorStage(completionPercent.value) }) return { // ... autres exports ... narratorStage, } }) ``` ### Plugin de navigation pour les transitions ```typescript // frontend/app/plugins/narrator-transitions.client.ts export default defineNuxtPlugin((nuxtApp) => { const narrator = useNarrator() const router = useRouter() const progressionStore = useProgressionStore() // Map des routes vers les contextes de transition const routeContextMap: Record = { '/projets': 'projects', '/en/projects': 'projects', '/competences': 'skills', '/en/skills': 'skills', '/temoignages': 'testimonials', '/en/testimonials': 'testimonials', '/parcours': 'journey', '/en/journey': 'journey', } // Sections déjà annoncées (pour ne pas répéter) const announcedSections = new Set() router.afterEach((to) => { const zone = routeContextMap[to.path] if (zone && !announcedSections.has(zone)) { announcedSections.add(zone) narrator.showTransition(zone) } }) // Watcher sur completionPercent pour les encouragements watch( () => progressionStore.completionPercent, (percent) => { narrator.showEncouragement(percent) } ) // Watcher sur contactUnlocked watch( () => progressionStore.contactUnlocked, (unlocked, wasUnlocked) => { if (unlocked && !wasUnlocked) { narrator.showContactUnlocked() } } ) }) ``` ### Intégration dans le layout ```vue ``` ### Tableau des stages du Bug | Stage | Progression | Apparence | Ton du narrateur | |-------|-------------|-----------|------------------| | 1 | 0-19% | Silhouette sombre floue | Mystérieux, énigmatique | | 2 | 20-39% | Forme vague avec yeux brillants | Curieux, observateur | | 3 | 40-59% | Pattes visibles, forme d'araignée | Encourageant, guide | | 4 | 60-79% | Araignée reconnaissable | Amical, complice | | 5 | 80-100% | Mascotte complète révélée | Chaleureux, félicitations | ### Dépendances **Cette story nécessite :** - Story 3.1 : API narrateur (contextes et textes) - Story 3.2 : Composant NarratorBubble - Story 1.6 : Store Pinia (pour progression et heroType) **Cette story prépare pour :** - Story 3.5 : Logique de progression (déclenche les messages) - Story 4.2 : Intro narrative (utilise useNarrator) ### Project Structure Notes **Fichiers à créer :** ``` frontend/app/ ├── composables/ │ ├── useNarrator.ts # CRÉER │ └── useIdleDetection.ts # CRÉER ├── plugins/ │ └── narrator-transitions.client.ts # CRÉER └── layouts/ └── adventure.vue # CRÉER ou MODIFIER ``` **Fichiers à modifier :** ``` frontend/app/stores/progression.ts # AJOUTER narratorStage computed ``` ### References - [Source: docs/planning-artifacts/epics.md#Story-3.3] - [Source: docs/planning-artifacts/ux-design-specification.md#Narrator-Revelation-Arc] - [Source: docs/planning-artifacts/ux-design-specification.md#Narrator-Contexts] - [Source: docs/brainstorming-gamification-2026-01-26.md#Arc-Revelation] ### Technical Requirements | Requirement | Value | Source | |-------------|-------|--------| | Stages du Bug | 5 (silhouette → mascotte) | UX Spec | | Seuils progression | 0/20/40/60/80% | Décision technique | | Timeout inactivité | 30 secondes | Epics | | Cooldown hints | 2 minutes | Décision technique | | Contextes transitions | 4 zones principales | Epics | ## Dev Agent Record ### Agent Model Used {{agent_model_name_version}} ### Debug Log References ### Completion Notes List ### Change Log | Date | Change | Author | |------|--------|--------| | 2026-02-04 | Story créée avec contexte complet | SM Agent | ### File List