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

478 lines
14 KiB
Markdown

# 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)
```php
// À 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
```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
```vue
<!-- 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
```vue
<!-- 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 :**
```json
{
"intro": {
"continue": "Continuer",
"startExploring": "Commencer l'exploration",
"skip": "Passer l'intro"
}
}
```
**en.json :**
```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