Files
Portfolio-Game/docs/implementation-artifacts/3-3-textes-narrateur-contextuels-arc-revelation.md
skycel ec1ae92799 🎉 Init monorepo Nuxt 4 + Laravel 12 (Story 1.1)
Setup complet de l'infrastructure projet :
- Frontend Nuxt 4 (SSR, TypeScript, i18n, Pinia, TailwindCSS)
- Backend Laravel 12 API-only avec middleware X-API-Key et CORS
- Design tokens (sky-dark, sky-accent, sky-text) et polices (Merriweather, Inter)
- Documentation planning et implementation artifacts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 02:08:56 +01:00

14 KiB

Story 3.3: Textes narrateur contextuels et arc de révélation

Status: ready-for-dev

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

  • Task 1: Créer le composable useNarrator (AC: #1, #2, #3, #4, #5, #6)

    • Créer frontend/app/composables/useNarrator.ts
    • Centraliser la logique d'affichage du narrateur
    • Exposer les méthodes : showIntro, showTransition, showEncouragement, showHint, showWelcomeBack, showContactUnlocked
    • Gérer la queue de messages (ne pas interrompre un message en cours)
    • Intégrer le composable useFetchNarratorText
  • Task 2: Implémenter les déclencheurs de transition (AC: #2)

    • Déclencher sur navigation vers /projets (transition_projects)
    • Déclencher sur navigation vers /competences (transition_skills)
    • Déclencher sur navigation vers /temoignages (transition_testimonials)
    • Déclencher sur navigation vers /parcours (transition_journey)
    • Utiliser un plugin Nuxt ou watcher sur la route
  • Task 3: Implémenter la détection d'inactivité (AC: #4)

    • Créer frontend/app/composables/useIdleDetection.ts
    • Détecter l'absence d'interaction > 30 secondes
    • Écouter mouse, keyboard, touch, scroll
    • Déclencher showHint() quand idle détecté
    • Ne pas répéter les hints trop souvent (cooldown de 2min)
  • Task 4: Implémenter les encouragements basés sur la progression (AC: #3)

    • Watcher sur completionPercent du store
    • Déclencher à 25%, 50%, 75%
    • Garder en mémoire les seuils déjà atteints (ne pas répéter)
  • Task 5: Implémenter l'arc de révélation du Bug (AC: #7, #8, #9)

    • 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%
    • Mettre à jour narratorStage dans le store
    • L'image du Bug se met à jour automatiquement via NarratorBubble
  • Task 6: Implémenter le message "Bienvenue à nouveau" (AC: #5)

    • Détecter au chargement si visitedSections n'est pas vide (progression existante)
    • Afficher le message welcome_back dans ce cas
    • Sinon afficher le message intro normal
  • Task 7: Implémenter le message de déblocage contact (AC: #6)

    • Watcher sur contactUnlocked du store
    • Quand passe à true, afficher contact_unlocked
  • Task 8: Intégrer dans le layout principal

    • Ajouter le NarratorBubble dans default.vue ou adventure.vue
    • Initialiser useNarrator dans le layout
    • Gérer l'état visible/hidden du narrateur
  • Task 9: Tests et validation

    • Tester le message d'accueil adapté au héros
    • Tester les transitions entre pages
    • Vérifier les encouragements à 25/50/75%
    • Tester la détection d'inactivité
    • Valider l'évolution du Bug (5 stages)
    • Tester le "Bienvenue à nouveau"

Dev Notes

Composable useNarrator

// 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<NarratorMessage[]>([])
  const isProcessing = ref(false)

  // Seuils d'encouragement déjà affichés
  const shownEncouragements = ref<Set<number>>(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

// 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<typeof setTimeout> | 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)

// 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

// 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<string, 'projects' | 'skills' | 'testimonials' | 'journey'> = {
    '/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<string>()

  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

<!-- frontend/app/layouts/adventure.vue -->
<script setup lang="ts">
const narrator = useNarrator()

// Détection d'inactivité
useIdleDetection({
  timeout: 30000,
  onIdle: () => {
    narrator.showHint()
  }
})

// Afficher l'intro au montage
onMounted(() => {
  // Délai pour laisser la page se charger
  setTimeout(() => {
    narrator.showIntro()
  }, 1000)
})
</script>

<template>
  <div class="adventure-layout">
    <slot />

    <!-- Narrateur -->
    <NarratorBubble
      :message="narrator.currentMessage.value"
      :visible="narrator.isVisible.value"
      @close="narrator.hide()"
    />
  </div>
</template>

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