Files
Portfolio-Game/docs/implementation-artifacts/4-3-chemins-narratifs-differencies.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 4.3: Chemins narratifs différenciés

Status: ready-for-dev

Story

As a visiteur, I want que mes choix aient un impact visible sur mon parcours, so that je sens que mon expérience est vraiment personnalisée.

Acceptance Criteria

  1. Given le visiteur fait des choix tout au long de l'aventure When il navigue entre les zones Then 2-3 points de choix binaires créent 4-8 parcours possibles
  2. And chaque choix est enregistré dans choices du store
  3. And l'ordre suggéré des zones varie selon le chemin choisi
  4. And les textes du narrateur s'adaptent au chemin (transitions contextuelles)
  5. And tous les chemins permettent de visiter tout le contenu
  6. And tous les chemins mènent au contact (pas de "mauvais" choix)
  7. And le currentPath du store reflète le chemin actuel
  8. And à la fin de chaque zone, le narrateur propose un choix vers la suite

Tasks / Subtasks

  • Task 1: Définir l'arbre des chemins (AC: #1, #5, #6)

    • Créer frontend/app/data/narrativePaths.ts
    • Définir 2-3 points de choix créant 4-8 parcours
    • S'assurer que tous les chemins visitent toutes les zones
    • S'assurer que tous les chemins mènent au contact
  • Task 2: Créer le composable useNarrativePath (AC: #2, #3, #7)

    • Créer frontend/app/composables/useNarrativePath.ts
    • Calculer le chemin actuel basé sur les choix
    • Exposer la prochaine zone suggérée
    • Exposer les zones restantes dans l'ordre
  • Task 3: Ajouter les textes de transition contextuels (AC: #4)

    • Créer des contextes spécifiques : transition_after_projects_to_skills, etc.
    • Variantes selon le chemin pris
    • Commentaires du narrateur sur les choix précédents
  • Task 4: Intégrer les choix après chaque zone (AC: #8)

    • Composant ZoneEndChoice.vue affiché à la fin de chaque page de zone
    • Proposer les options de destination selon le chemin
    • Utiliser ChoiceCards pour la présentation
  • Task 5: Mettre à jour le store (AC: #2, #7)

    • Ajouter currentPath computed au store
    • Ajouter suggestedNextZone computed
    • Méthode pour obtenir le choix à un point donné
  • Task 6: Créer l'API pour les transitions contextuelles (AC: #4)

    • Endpoint /api/narrator/transition-contextual
    • Paramètres : from_zone, to_zone, path_choices
    • Retourner un texte adapté au contexte
  • Task 7: Tests et validation

    • Tester tous les chemins possibles (4-8)
    • Vérifier que tous mènent au contact
    • Valider les textes contextuels
    • Tester la suggestion de zone suivante

Dev Notes

Arbre des chemins narratifs

// frontend/app/data/narrativePaths.ts

// Points de choix dans l'aventure
export const NARRATIVE_CHOICE_POINTS = {
  // Point 1 : Après l'intro
  intro: {
    id: 'intro',
    options: ['projects', 'skills'],
  },
  // Point 2 : Après la première zone
  after_first_zone: {
    id: 'after_first_zone',
    options: ['testimonials', 'journey'],
  },
  // Point 3 : Après la deuxième zone
  after_second_zone: {
    id: 'after_second_zone',
    // Les options dépendent de ce qui reste
  },
}

// Chemins possibles (4-8 combinaisons)
// Format : intro_choice -> after_first -> after_second -> contact
export const NARRATIVE_PATHS = [
  // Chemin 1 : Projets → Témoignages → Compétences → Parcours → Contact
  ['projects', 'testimonials', 'skills', 'journey', 'contact'],
  // Chemin 2 : Projets → Témoignages → Parcours → Compétences → Contact
  ['projects', 'testimonials', 'journey', 'skills', 'contact'],
  // Chemin 3 : Projets → Parcours → Témoignages → Compétences → Contact
  ['projects', 'journey', 'testimonials', 'skills', 'contact'],
  // Chemin 4 : Projets → Parcours → Compétences → Témoignages → Contact
  ['projects', 'journey', 'skills', 'testimonials', 'contact'],
  // Chemin 5 : Compétences → Témoignages → Projets → Parcours → Contact
  ['skills', 'testimonials', 'projects', 'journey', 'contact'],
  // Chemin 6 : Compétences → Témoignages → Parcours → Projets → Contact
  ['skills', 'testimonials', 'journey', 'projects', 'contact'],
  // Chemin 7 : Compétences → Parcours → Témoignages → Projets → Contact
  ['skills', 'journey', 'testimonials', 'projects', 'contact'],
  // Chemin 8 : Compétences → Parcours → Projets → Témoignages → Contact
  ['skills', 'journey', 'projects', 'testimonials', 'contact'],
]

// Mapper zone key -> route
export const ZONE_ROUTES: Record<string, { fr: string; en: string }> = {
  projects: { fr: '/projets', en: '/en/projects' },
  skills: { fr: '/competences', en: '/en/skills' },
  testimonials: { fr: '/temoignages', en: '/en/testimonials' },
  journey: { fr: '/parcours', en: '/en/journey' },
  contact: { fr: '/contact', en: '/en/contact' },
}

Composable useNarrativePath

// frontend/app/composables/useNarrativePath.ts
import { NARRATIVE_PATHS, ZONE_ROUTES } from '~/data/narrativePaths'

export function useNarrativePath() {
  const progressionStore = useProgressionStore()
  const { locale } = useI18n()

  // Déterminer le chemin actuel basé sur les choix
  const currentPath = computed(() => {
    const choices = progressionStore.choices

    // Trouver le premier choix (intro)
    const introChoice = choices.find(c => c.id === 'intro_first_choice')
    if (!introChoice) return null

    const startZone = introChoice.value === 'choice_projects_first' ? 'projects' : 'skills'

    // Filtrer les chemins qui commencent par cette zone
    let possiblePaths = NARRATIVE_PATHS.filter(path => path[0] === startZone)

    // Affiner avec les choix suivants
    const afterFirstChoice = choices.find(c => c.id === 'after_first_zone')
    if (afterFirstChoice && possiblePaths.length > 1) {
      const secondZone = afterFirstChoice.value.includes('testimonials') ? 'testimonials' : 'journey'
      possiblePaths = possiblePaths.filter(path => path[1] === secondZone)
    }

    return possiblePaths[0] || null
  })

  // Zone actuelle basée sur la route
  const currentZone = computed(() => {
    const route = useRoute()
    const path = route.path.toLowerCase()

    for (const [zone, routes] of Object.entries(ZONE_ROUTES)) {
      if (path.includes(routes.fr.slice(1)) || path.includes(routes.en.slice(4))) {
        return zone
      }
    }
    return null
  })

  // Index de la zone actuelle dans le chemin
  const currentZoneIndex = computed(() => {
    if (!currentPath.value || !currentZone.value) return -1
    return currentPath.value.indexOf(currentZone.value)
  })

  // Prochaine zone suggérée
  const suggestedNextZone = computed(() => {
    if (!currentPath.value || currentZoneIndex.value === -1) return null

    const nextIndex = currentZoneIndex.value + 1
    if (nextIndex >= currentPath.value.length) return null

    return currentPath.value[nextIndex]
  })

  // Zones restantes à visiter
  const remainingZones = computed(() => {
    if (!currentPath.value) return []

    const visited = progressionStore.visitedSections
    return currentPath.value.filter(zone =>
      zone !== 'contact' && !visited.includes(zone as any)
    )
  })

  // Obtenir la route pour une zone
  function getZoneRoute(zone: string): string {
    const routes = ZONE_ROUTES[zone]
    if (!routes) return '/'
    return locale.value === 'fr' ? routes.fr : routes.en
  }

  // Générer le choix pour après la zone actuelle
  function getNextChoicePoint() {
    if (!remainingZones.value.length) {
      // Plus de zones, aller au contact
      return {
        id: 'go_to_contact',
        choices: [
          {
            id: 'contact',
            textFr: 'Rencontrer le développeur',
            textEn: 'Meet the developer',
            icon: '📧',
            destination: getZoneRoute('contact'),
            zoneColor: '#fa784f',
          },
        ],
      }
    }

    // Proposer les 2 prochaines zones
    const nextTwo = remainingZones.value.slice(0, 2)

    return {
      id: `after_${currentZone.value}`,
      questionFr: 'Où vas-tu ensuite ?',
      questionEn: 'Where to next?',
      choices: nextTwo.map(zone => ({
        id: `choice_${zone}`,
        textFr: getZoneLabel(zone, 'fr'),
        textEn: getZoneLabel(zone, 'en'),
        icon: getZoneIcon(zone),
        destination: getZoneRoute(zone),
        zoneColor: getZoneColor(zone),
      })),
    }
  }

  return {
    currentPath,
    currentZone,
    suggestedNextZone,
    remainingZones,
    getZoneRoute,
    getNextChoicePoint,
  }
}

// Helpers
function getZoneLabel(zone: string, locale: string): string {
  const labels: Record<string, { fr: string; en: string }> = {
    projects: { fr: 'Découvrir les créations', en: 'Discover the creations' },
    skills: { fr: 'Explorer les compétences', en: 'Explore the skills' },
    testimonials: { fr: 'Écouter les témoignages', en: 'Listen to testimonials' },
    journey: { fr: 'Suivre le parcours', en: 'Follow the journey' },
  }
  return labels[zone]?.[locale] || zone
}

function getZoneIcon(zone: string): string {
  const icons: Record<string, string> = {
    projects: '💻',
    skills: '⚡',
    testimonials: '💬',
    journey: '📍',
  }
  return icons[zone] || '?'
}

function getZoneColor(zone: string): string {
  const colors: Record<string, string> = {
    projects: '#3b82f6',
    skills: '#10b981',
    testimonials: '#f59e0b',
    journey: '#8b5cf6',
  }
  return colors[zone] || '#fa784f'
}

Composant ZoneEndChoice

<!-- frontend/app/components/feature/ZoneEndChoice.vue -->
<script setup lang="ts">
const { getNextChoicePoint, remainingZones } = useNarrativePath()
const narrator = useNarrator()

const choicePoint = computed(() => getNextChoicePoint())

// Afficher un message du narrateur avant le choix
onMounted(async () => {
  if (remainingZones.value.length > 0) {
    await narrator.showTransitionChoice()
  } else {
    await narrator.showContactReady()
  }
})
</script>

<template>
  <div class="zone-end-choice py-16 px-4 border-t border-sky-dark-100 mt-16">
    <div class="max-w-2xl mx-auto">
      <!-- Message narratif -->
      <p class="font-narrative text-xl text-sky-text text-center mb-8 italic">
        {{ $t('narrative.whatNext') }}
      </p>

      <!-- Choix -->
      <ChoiceCards
        v-if="choicePoint.choices?.length"
        :choice-point="choicePoint"
      />

      <!-- Si une seule option (contact) -->
      <div
        v-else-if="choicePoint.choices?.length === 1"
        class="text-center"
      >
        <NuxtLink
          :to="choicePoint.choices[0].destination"
          class="inline-flex items-center gap-3 px-8 py-4 bg-sky-accent text-white font-ui font-semibold rounded-xl hover:bg-sky-accent/90 transition-colors"
        >
          <span class="text-2xl">{{ choicePoint.choices[0].icon }}</span>
          <span>{{ $i18n.locale === 'fr' ? choicePoint.choices[0].textFr : choicePoint.choices[0].textEn }}</span>
        </NuxtLink>
      </div>
    </div>
  </div>
</template>

Schéma des chemins narratifs

                    ┌─────────────┐
                    │    INTRO    │
                    └──────┬──────┘
                           │
              ┌────────────┴────────────┐
              ▼                         ▼
       ┌──────────┐               ┌──────────┐
       │ PROJETS  │               │ COMPÉT.  │
       └────┬─────┘               └────┬─────┘
            │                          │
    ┌───────┴───────┐          ┌───────┴───────┐
    ▼               ▼          ▼               ▼
┌────────┐    ┌────────┐  ┌────────┐    ┌────────┐
│TÉMOIGN.│    │PARCOURS│  │TÉMOIGN.│    │PARCOURS│
└───┬────┘    └───┬────┘  └───┬────┘    └───┬────┘
    │             │           │             │
    ▼             ▼           ▼             ▼
  (suite)       (suite)     (suite)       (suite)
    │             │           │             │
    └──────┬──────┴───────────┴──────┬──────┘
           │                         │
           ▼                         ▼
    ┌──────────────────────────────────────┐
    │              CONTACT                  │
    │     (tous les chemins y mènent)      │
    └──────────────────────────────────────┘

Dépendances

Cette story nécessite :

  • Story 4.1 : ChoiceCards
  • Story 4.2 : Intro narrative (premier choix)
  • Story 3.5 : Store de progression (choices)

Cette story prépare pour :

  • Story 4.7 : Révélation (fin des chemins)
  • Story 4.8 : Page contact (destination finale)

Project Structure Notes

Fichiers à créer :

frontend/app/
├── data/
│   └── narrativePaths.ts                # CRÉER
├── composables/
│   └── useNarrativePath.ts              # CRÉER
└── components/feature/
    └── ZoneEndChoice.vue                # CRÉER

Fichiers à modifier :

frontend/app/pages/projets.vue           # AJOUTER ZoneEndChoice
frontend/app/pages/competences.vue       # AJOUTER ZoneEndChoice
frontend/app/pages/temoignages.vue       # AJOUTER ZoneEndChoice
frontend/app/pages/parcours.vue          # AJOUTER ZoneEndChoice

References

  • [Source: docs/planning-artifacts/epics.md#Story-4.3]
  • [Source: docs/planning-artifacts/ux-design-specification.md#Narrative-Paths]
  • [Source: docs/brainstorming-gamification-2026-01-26.md#Parcours-Narratifs]

Technical Requirements

Requirement Value Source
Points de choix 2-3 Epics
Parcours possibles 4-8 Epics
Toutes zones visitables Oui Epics
Tous chemins → contact Oui 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