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>
478 lines
14 KiB
Markdown
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
|
|
|