Files
Portfolio-Game/docs/implementation-artifacts/4-2-intro-narrative-premier-choix.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.2: Intro narrative et premier choix

Status: ready-for-dev

Story

As a visiteur aventurier, I want une introduction narrative captivante suivie d'un premier choix, so that je suis immergé dès le début de l'aventure.

Acceptance Criteria

  1. Given le visiteur a sélectionné son héros sur la landing page When il commence l'aventure Then une séquence d'intro narrative s'affiche avec le narrateur (Le Bug)
  2. And le texte présente le "héros mystérieux" (le développeur) à découvrir
  3. And l'effet typewriter anime le texte (skippable par clic/Espace)
  4. And l'ambiance visuelle est immersive (fond sombre, illustrations)
  5. And un bouton "Continuer" permet d'avancer
  6. And à la fin de l'intro, le premier choix binaire s'affiche via ChoiceCards
  7. And le choix propose deux zones à explorer en premier (ex: Projets vs Compétences)
  8. And le contenu est bilingue (FR/EN) et adapté au héros (vouvoiement/tutoiement)
  9. And la durée de l'intro est courte (15-30s max, skippable)

Tasks / Subtasks

  • Task 1: Créer les textes d'intro dans l'API (AC: #2, #8)

    • Ajouter les contextes intro_sequence_1, intro_sequence_2, intro_sequence_3 dans narrator_texts
    • Variantes pour chaque type de héros (vouvoiement/tutoiement)
    • Textes mystérieux présentant le développeur
  • Task 2: Créer la page intro (AC: #1, #4, #9)

    • Créer frontend/app/pages/intro.vue
    • Rediriger automatiquement depuis landing après choix du héros
    • Fond sombre avec ambiance mystérieuse
    • Structure en étapes (séquences de texte)
  • Task 3: Implémenter la séquence narrative (AC: #2, #3, #5)

    • Créer composant IntroSequence.vue
    • Afficher le Bug avec le texte en typewriter
    • Bouton "Continuer" pour passer à l'étape suivante
    • Clic/Espace pour skip le typewriter
    • 3-4 séquences de texte courtes
  • Task 4: Ajouter les illustrations d'ambiance (AC: #4)

    • Illustrations de fond (toiles d'araignée, ombres, code flottant)
    • Animation subtile sur les éléments de fond
    • Cohérence avec l'univers de Le Bug
  • Task 5: Intégrer le premier choix (AC: #6, #7)

    • Après la dernière séquence, afficher ChoiceCards
    • Choix : Projets vs Compétences
    • La sélection navigue vers la zone choisie
  • Task 6: Gérer le skip global (AC: #9)

    • Bouton discret "Passer l'intro" visible en permanence
    • Navigation directe vers le choix si skip
    • Enregistrer dans le store que l'intro a été vue/skip
  • Task 7: Tests et validation

    • Tester le flow complet
    • Vérifier les 3 types de héros (textes adaptés)
    • Tester FR et EN
    • Valider la durée (< 30s)
    • Tester le skip intro

Dev Notes

Textes d'intro (exemples)

// À ajouter dans NarratorTextSeeder.php

// Intro séquence 1 - Recruteur (vouvoiement)
['context' => 'intro_sequence_1', 'text_key' => 'narrator.intro_seq.1.recruteur', 'variant' => 1, 'hero_type' => 'recruteur'],

// Intro séquence 1 - Client/Dev (tutoiement)
['context' => 'intro_sequence_1', 'text_key' => 'narrator.intro_seq.1.casual', 'variant' => 1, 'hero_type' => 'client'],
['context' => 'intro_sequence_1', 'text_key' => 'narrator.intro_seq.1.casual', 'variant' => 1, 'hero_type' => 'dev'],

// Traductions
['key' => 'narrator.intro_seq.1.recruteur', 'fr' => "Bienvenue dans mon domaine, voyageur... Je suis Le Bug, et je vais vous guider dans cette aventure.", 'en' => "Welcome to my domain, traveler... I am The Bug, and I will guide you through this adventure."],
['key' => 'narrator.intro_seq.1.casual', 'fr' => "Hey ! Bienvenue chez moi. Je suis Le Bug, ton guide pour cette aventure.", 'en' => "Hey! Welcome to my place. I'm The Bug, your guide for this adventure."],

['key' => 'narrator.intro_seq.2', 'fr' => "Il y a quelqu'un ici que tu cherches... Un développeur mystérieux qui a créé tout ce que tu vois autour de toi.", 'en' => "There's someone here you're looking for... A mysterious developer who created everything you see around you."],

['key' => 'narrator.intro_seq.3', 'fr' => "Pour le trouver, tu devras explorer ce monde. Chaque zone cache une partie de son histoire. Es-tu prêt ?", 'en' => "To find them, you'll have to explore this world. Each zone hides a part of their story. Are you ready?"],

Page intro.vue

<!-- frontend/app/pages/intro.vue -->
<script setup lang="ts">
import { CHOICE_POINTS } from '~/types/choice'

const progressionStore = useProgressionStore()
const { t } = useI18n()

// Rediriger si pas de héros sélectionné
if (!progressionStore.heroType) {
  navigateTo('/')
}

// Étapes de la séquence
const steps = ['intro_sequence_1', 'intro_sequence_2', 'intro_sequence_3', 'choice']
const currentStepIndex = ref(0)

const currentStep = computed(() => steps[currentStepIndex.value])
const isLastTextStep = computed(() => currentStepIndex.value === steps.length - 2)
const isChoiceStep = computed(() => currentStep.value === 'choice')

// Texte actuel
const currentText = ref('')
const isTextComplete = ref(false)

const { fetchText } = useFetchNarratorText()

async function loadCurrentText() {
  if (isChoiceStep.value) return

  const response = await fetchText(currentStep.value, progressionStore.heroType || undefined)
  currentText.value = response.data.text
}

function handleTextComplete() {
  isTextComplete.value = true
}

function nextStep() {
  if (currentStepIndex.value < steps.length - 1) {
    currentStepIndex.value++
    isTextComplete.value = false
    loadCurrentText()
  }
}

function skipIntro() {
  currentStepIndex.value = steps.length - 1 // Aller directement au choix
}

// Charger le premier texte
onMounted(() => {
  loadCurrentText()
})

// Marquer l'intro comme vue
onUnmounted(() => {
  progressionStore.setIntroSeen(true)
})
</script>

<template>
  <div class="intro-page min-h-screen bg-sky-dark relative overflow-hidden">
    <!-- Fond d'ambiance -->
    <IntroBackground />

    <!-- Contenu principal -->
    <div class="relative z-10 flex flex-col items-center justify-center min-h-screen p-8">
      <!-- Séquence narrative -->
      <Transition name="fade" mode="out-in">
        <div
          v-if="!isChoiceStep"
          :key="currentStep"
          class="max-w-2xl mx-auto text-center"
        >
          <IntroSequence
            :text="currentText"
            @complete="handleTextComplete"
            @skip="handleTextComplete"
          />

          <!-- Bouton continuer -->
          <Transition name="fade">
            <button
              v-if="isTextComplete"
              type="button"
              class="mt-8 px-8 py-3 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors"
              @click="nextStep"
            >
              {{ isLastTextStep ? t('intro.startExploring') : t('intro.continue') }}
            </button>
          </Transition>
        </div>

        <!-- Choix après l'intro -->
        <div v-else class="w-full max-w-3xl mx-auto">
          <ChoiceCards :choice-point="CHOICE_POINTS.intro_first_choice" />
        </div>
      </Transition>

      <!-- Bouton skip (toujours visible) -->
      <button
        v-if="!isChoiceStep"
        type="button"
        class="absolute bottom-8 right-8 text-sky-text-muted hover:text-sky-text text-sm font-ui underline transition-colors"
        @click="skipIntro"
      >
        {{ t('intro.skip') }}
      </button>
    </div>
  </div>
</template>

Composant IntroSequence

<!-- frontend/app/components/feature/IntroSequence.vue -->
<script setup lang="ts">
const props = defineProps<{
  text: string
}>()

const emit = defineEmits<{
  complete: []
  skip: []
}>()

const progressionStore = useProgressionStore()
const { displayedText, isTyping, isComplete, start, skip } = useTypewriter({
  speed: 35,
  onComplete: () => emit('complete'),
})

// Bug image selon le stage (toujours stage 1 au début)
const bugImage = '/images/bug/bug-stage-1.svg'

// Démarrer le typewriter quand le texte change
watch(() => props.text, (newText) => {
  if (newText) {
    start(newText)
  }
}, { immediate: true })

function handleInteraction() {
  if (isTyping.value) {
    skip()
    emit('skip')
  }
}

function handleKeydown(e: KeyboardEvent) {
  if (e.code === 'Space' || e.code === 'Enter') {
    e.preventDefault()
    handleInteraction()
  }
}
</script>

<template>
  <div
    class="intro-sequence"
    @click="handleInteraction"
    @keydown="handleKeydown"
    tabindex="0"
  >
    <!-- Avatar du Bug -->
    <div class="mb-8">
      <img
        :src="bugImage"
        alt="Le Bug"
        class="w-32 h-32 mx-auto animate-float"
      />
    </div>

    <!-- Texte avec typewriter -->
    <div class="bg-sky-dark-50/80 backdrop-blur rounded-xl p-8 border border-sky-dark-100">
      <p class="font-narrative text-xl md:text-2xl text-sky-text leading-relaxed">
        {{ displayedText }}
        <span
          v-if="isTyping"
          class="inline-block w-0.5 h-6 bg-sky-accent animate-blink ml-1"
        ></span>
      </p>

      <!-- Indication pour skip -->
      <p
        v-if="isTyping"
        class="text-sm text-sky-text-muted mt-4 font-ui"
      >
        {{ $t('narrator.clickToSkip') }}
      </p>
    </div>
  </div>
</template>

<style scoped>
@keyframes float {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-10px); }
}

.animate-float {
  animation: float 3s ease-in-out infinite;
}

@keyframes blink {
  0%, 50% { opacity: 1; }
  51%, 100% { opacity: 0; }
}

.animate-blink {
  animation: blink 1s infinite;
}

.intro-sequence:focus {
  outline: none;
}

@media (prefers-reduced-motion: reduce) {
  .animate-float {
    animation: none;
  }
}
</style>

Composant IntroBackground

<!-- frontend/app/components/feature/IntroBackground.vue -->
<script setup lang="ts">
const reducedMotion = useReducedMotion()
</script>

<template>
  <div class="intro-background absolute inset-0 overflow-hidden">
    <!-- Gradient de fond -->
    <div class="absolute inset-0 bg-gradient-to-b from-sky-dark via-sky-dark-50 to-sky-dark"></div>

    <!-- Particules flottantes (code fragments) -->
    <div
      v-if="!reducedMotion"
      class="particles absolute inset-0"
    >
      <div
        v-for="i in 20"
        :key="i"
        class="particle absolute text-sky-accent/10 font-mono text-xs"
        :style="{
          left: `${Math.random() * 100}%`,
          top: `${Math.random() * 100}%`,
          animationDelay: `${Math.random() * 5}s`,
          animationDuration: `${10 + Math.random() * 10}s`,
        }"
      >
        {{ ['</', '/>', '{}', '[]', '()', '=>', '&&', '||'][i % 8] }}
      </div>
    </div>

    <!-- Toile d'araignée stylisée (SVG) -->
    <svg
      class="absolute top-0 right-0 w-64 h-64 text-sky-dark-100/30"
      viewBox="0 0 200 200"
    >
      <path
        d="M100,100 L100,0 M100,100 L200,100 M100,100 L100,200 M100,100 L0,100 M100,100 L170,30 M100,100 L170,170 M100,100 L30,170 M100,100 L30,30"
        stroke="currentColor"
        stroke-width="1"
        fill="none"
      />
      <circle cx="100" cy="100" r="30" stroke="currentColor" stroke-width="1" fill="none" />
      <circle cx="100" cy="100" r="60" stroke="currentColor" stroke-width="1" fill="none" />
      <circle cx="100" cy="100" r="90" stroke="currentColor" stroke-width="1" fill="none" />
    </svg>
  </div>
</template>

<style scoped>
@keyframes float-up {
  from {
    transform: translateY(100vh) rotate(0deg);
    opacity: 0;
  }
  10% {
    opacity: 1;
  }
  90% {
    opacity: 1;
  }
  to {
    transform: translateY(-100vh) rotate(360deg);
    opacity: 0;
  }
}

.particle {
  animation: float-up linear infinite;
}
</style>

Clés i18n

fr.json :

{
  "intro": {
    "continue": "Continuer",
    "startExploring": "Commencer l'exploration",
    "skip": "Passer l'intro"
  }
}

en.json :

{
  "intro": {
    "continue": "Continue",
    "startExploring": "Start exploring",
    "skip": "Skip intro"
  }
}

Dépendances

Cette story nécessite :

  • Story 3.1 : API narrateur (contextes intro_sequence_*)
  • Story 3.2 : NarratorBubble et useTypewriter
  • Story 4.1 : ChoiceCards pour le premier choix
  • Story 1.5 : Landing page (choix du héros)

Cette story prépare pour :

  • Story 4.3 : Chemins narratifs (suite de l'aventure)

Project Structure Notes

Fichiers à créer :

frontend/app/
├── pages/
│   └── intro.vue                        # CRÉER
└── components/feature/
    ├── IntroSequence.vue                # CRÉER
    └── IntroBackground.vue              # CRÉER

Fichiers à modifier :

api/database/seeders/NarratorTextSeeder.php  # AJOUTER intro_sequence_*
frontend/i18n/fr.json                         # AJOUTER intro.*
frontend/i18n/en.json                         # AJOUTER intro.*

References

  • [Source: docs/planning-artifacts/epics.md#Story-4.2]
  • [Source: docs/planning-artifacts/ux-design-specification.md#Intro-Sequence]
  • [Source: docs/brainstorming-gamification-2026-01-26.md#Onboarding]

Technical Requirements

Requirement Value Source
Durée intro 15-30s max (skippable) Epics
Séquences 3-4 textes courts Décision technique
Premier choix Projets vs Compétences Epics
Adaptation héros Vouvoiement/tutoiement UX Spec

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